From ed2eb9b48669658fe01ac686258343b5a667e7ea Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:31:30 -0400 Subject: [PATCH 01/23] bugc: compile `!` as a logical not --- packages/bugc/CHANGELOG.md | 3 ++ packages/bugc/src/evmgen/behavioral.test.ts | 51 +++++++++++++++++++ .../evmgen/generation/instructions/unary.ts | 5 +- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index f7b5d6634e..f2dff3dce6 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -89,6 +89,9 @@ support. Changes to the specification itself are tracked in the root has the branch's context: its source range, its variables (the storage variables too) and its transforms. Before, it had no context, so a debugger listed no variables while it ran ([#356]). +- `!` is now a logical not: `!true` is `false`. Before, at every + optimization level, it compiled to the EVM's bitwise `NOT`, so `!x` was + a non-zero word, and so `true`, for every `x` ([#PR]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index 66913f8e72..dea1369738 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -1289,6 +1289,57 @@ code { } }); + describe("logical not", () => { + const program = `name Not; + +define { + function isSet(x: uint256) -> bool { + return x != 0; + }; +} + +storage { + [0] t: bool; + [1] notTrue: bool; + [2] notFalse: bool; + [3] notNot: bool; + [4] branch: uint256; + [5] notCall: bool; +} + +create { + t = true; +} + +code { + notTrue = !t; + notFalse = !notTrue; + notNot = !!t; + if (!t) { + branch = 1; + } else { + branch = 2; + } + notCall = !isSet(0); +}`; + + for (const level of [0, 1, 2, 3] as const) { + it(`should negate a bool (level ${level})`, async () => { + const result = await executeProgram(program, { + calldata: "", + optimizationLevel: level, + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(1n)).toBe(0n); + expect(await result.getStorage(2n)).toBe(1n); + expect(await result.getStorage(3n)).toBe(1n); + expect(await result.getStorage(4n)).toBe(2n); + expect(await result.getStorage(5n)).toBe(1n); + }); + } + }); + describe("modulo", () => { const program = (expr: string) => `name Modulo; diff --git a/packages/bugc/src/evmgen/generation/instructions/unary.ts b/packages/bugc/src/evmgen/generation/instructions/unary.ts index 41f7f3aec4..e64559f146 100644 --- a/packages/bugc/src/evmgen/generation/instructions/unary.ts +++ b/packages/bugc/src/evmgen/generation/instructions/unary.ts @@ -10,7 +10,7 @@ import { } from "#evmgen/operations"; import { loadValue, storeValueIfNeeded } from "../values/index.js"; -const { NOT, PUSHn, SUB } = operations; +const { ISZERO, PUSHn, SUB } = operations; /** * Generate code for unary operations @@ -25,7 +25,8 @@ export function generateUnary( state: State, ) => State; } = { - not: NOT({ debug }), + // `!` takes a bool, so it is a logical not, not a bitwise one + not: ISZERO({ debug }), neg: pipe() .then(rebrandTop("b")) .then(PUSHn(0n, { debug }), { as: "a" }) From a02f0a52fdf965910f32423c1ab7464da8607c6b Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:31:42 -0400 Subject: [PATCH 02/23] bugc: link the changelog entry to #353 --- packages/bugc/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index f2dff3dce6..42bc899313 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -91,7 +91,7 @@ support. Changes to the specification itself are tracked in the root debugger listed no variables while it ran ([#356]). - `!` is now a logical not: `!true` is `false`. Before, at every optimization level, it compiled to the EVM's bitwise `NOT`, so `!x` was - a non-zero word, and so `true`, for every `x` ([#PR]). + a non-zero word, and so `true`, for every `x` ([#353]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert @@ -303,4 +303,5 @@ First publication. [#349]: https://github.com/ethdebug/format/pull/349 [#351]: https://github.com/ethdebug/format/pull/351 [#352]: https://github.com/ethdebug/format/pull/352 +[#353]: https://github.com/ethdebug/format/pull/353 [#356]: https://github.com/ethdebug/format/pull/356 From 7ecc295d9a30592264a3669617c0ab98820868ea Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:54:18 -0400 Subject: [PATCH 03/23] bugc: end each source range at its last token --- packages/bugc/CHANGELOG.md | 5 ++ packages/bugc/src/parser/locations.test.ts | 97 ++++++++++++++++++++++ packages/bugc/src/parser/parser.ts | 23 ++++- 3 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 packages/bugc/src/parser/locations.test.ts diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 42bc899313..152a59f36f 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -92,6 +92,11 @@ support. Changes to the specification itself are tracked in the root - `!` is now a logical not: `!true` is `false`. Before, at every optimization level, it compiled to the EVM's bitwise `NOT`, so `!x` was a non-zero word, and so `true`, for every `x` ([#353]). +- A source range now ends at its node's last token. Before, a node + followed by whitespace or a comment included them, so most `code` + ranges had trailing whitespace: `block.number ` in `block.number + 1`, + the target `x ` in `x = 1`, a statement up to the next statement, and + the `create` and `code` blocks up to the next block ([#PR]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert diff --git a/packages/bugc/src/parser/locations.test.ts b/packages/bugc/src/parser/locations.test.ts new file mode 100644 index 0000000000..2978cbd3c9 --- /dev/null +++ b/packages/bugc/src/parser/locations.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { promises as fs } from "fs"; +import path from "path"; +import { glob } from "glob"; + +import { parse } from "./parser.js"; + +const EXAMPLES_DIR = path.resolve(__dirname, "../../examples"); + +/** + * The spans of the comments in a source, skipping string literals + */ +function commentSpans(source: string): [number, number][] { + const spans: [number, number][] = []; + let i = 0; + while (i < source.length) { + if (source[i] === '"') { + const close = source.indexOf('"', i + 1); + i = close === -1 ? source.length : close + 1; + } else if (source.startsWith("//", i)) { + const newline = source.indexOf("\n", i); + const end = newline === -1 ? source.length : newline; + spans.push([i, end]); + i = end; + } else if (source.startsWith("/*", i)) { + const close = source.indexOf("*/", i + 2); + const end = close === -1 ? source.length : close + 2; + spans.push([i, end]); + i = end; + } else { + i++; + } + } + return spans; +} + +/** + * Every source location in an AST, with the node's kind + */ +function locations( + node: unknown, + found: { kind: string; offset: number; length: number }[] = [], +) { + if (Array.isArray(node)) { + for (const item of node) { + locations(item, found); + } + } else if (node instanceof Map) { + for (const value of node.values()) { + locations(value, found); + } + } else if (node && typeof node === "object") { + const { kind, loc } = node as { + kind?: string; + loc?: { offset: number; length: number } | null; + }; + if (loc) { + found.push({ + kind: kind ?? "?", + offset: Number(loc.offset), + length: Number(loc.length), + }); + } + for (const value of Object.values(node)) { + locations(value, found); + } + } + return found; +} + +describe("source locations", () => { + it("should end each node at its last token", async () => { + const files = await glob("**/*.bug", { cwd: EXAMPLES_DIR }); + expect(files.length).toBeGreaterThan(0); + + for (const file of files) { + const source = await fs.readFile(path.join(EXAMPLES_DIR, file), "utf-8"); + const result = parse(source); + if (!result.success) { + continue; + } + + const comments = commentSpans(source); + for (const { kind, offset, length } of locations(result.value)) { + const text = source.slice(offset, offset + length); + const end = offset + length; + const where = `${file}: ${kind} ${JSON.stringify(text)}`; + + expect(text.trim(), where).toBe(text); + expect( + comments.some(([start, stop]) => start < end && end <= stop), + where, + ).toBe(false); + } + } + }); +}); diff --git a/packages/bugc/src/parser/parser.ts b/packages/bugc/src/parser/parser.ts index cf6e5aabfd..ad555b862e 100644 --- a/packages/bugc/src/parser/parser.ts +++ b/packages/bugc/src/parser/parser.ts @@ -16,6 +16,7 @@ const PENDING_ID = "" as Ast.Id; * Parse a BUG program and return Result */ export function parse(input: string): Result { + tokenEnds.clear(); return runParser(parser, input); } @@ -29,10 +30,22 @@ export function parse(input: string): Result { function toSourceLocation(mark: P.Mark): Ast.SourceLocation { return { offset: mark.start.offset, - length: mark.end.offset - mark.start.offset, + length: tokenEnd(mark.start.offset, mark.end.offset) - mark.start.offset, }; } +/** + * For the input being parsed: the end of each token, before the + * whitespace and comments after it, by the index after them. A node's + * location ends at its last token's end, not after the whitespace and + * comments that follow it. + */ +const tokenEnds = new Map(); + +function tokenEnd(start: number, end: number): number { + return Math.max(start, tokenEnds.get(end) ?? end); +} + /** * Basic parsers */ @@ -51,7 +64,11 @@ const _ = whitespaceOrComment.many().result(undefined); const __ = whitespaceOrComment.atLeast(1).result(undefined); // Lexeme: consume trailing whitespace/comments -const lexeme = (p: P.Parser): P.Parser => p.skip(_); +const lexeme = (p: P.Parser): P.Parser => + P.seqMap(p, P.index, _, P.index, (value, end, _skipped, next) => { + tokenEnds.set(next.offset, end.offset); + return value; + }); // Keywords - must not be followed by identifier chars const keyword = (name: string): P.Parser => @@ -651,7 +668,7 @@ const postfixExpression = P.lazy(() => { suffixes.reduce((obj, [suffix, end]) => { const loc = { offset: start.offset, - length: end.offset - start.offset, + length: tokenEnd(start.offset, end.offset) - start.offset, }; const id = `${loc.offset}_${loc.length}` as Ast.Id; From e2eaa55ce8a0dbc1766bf033bfcda9bc20bb65cc Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:54:33 -0400 Subject: [PATCH 04/23] bugc: link the changelog entry to #362 --- packages/bugc/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 152a59f36f..3f7367a2c4 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -96,7 +96,7 @@ support. Changes to the specification itself are tracked in the root followed by whitespace or a comment included them, so most `code` ranges had trailing whitespace: `block.number ` in `block.number + 1`, the target `x ` in `x = 1`, a statement up to the next statement, and - the `create` and `code` blocks up to the next block ([#PR]). + the `create` and `code` blocks up to the next block ([#362]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert @@ -310,3 +310,4 @@ First publication. [#352]: https://github.com/ethdebug/format/pull/352 [#353]: https://github.com/ethdebug/format/pull/353 [#356]: https://github.com/ethdebug/format/pull/356 +[#362]: https://github.com/ethdebug/format/pull/362 From 095bc0be1f414e34ee25e3970f6302a6915ea63a Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:58:08 -0400 Subject: [PATCH 05/23] bugc: add block.prevrandao --- packages/bugc-react/CHANGELOG.md | 5 +++ .../bugc-react/src/components/CfgView.tsx | 2 ++ packages/bugc/CHANGELOG.md | 2 ++ packages/bugc/src/ast/spec.ts | 24 +++++++++++++- packages/bugc/src/ast/visitor.ts | 1 + packages/bugc/src/evmgen/behavioral.test.ts | 33 +++++++++++++++++++ .../src/evmgen/generation/instructions/env.ts | 3 +- packages/bugc/src/ir/analysis/validator.ts | 1 + packages/bugc/src/ir/spec/instruction.ts | 3 +- .../irgen/generate/expressions/expression.ts | 1 + .../src/irgen/generate/expressions/special.ts | 3 ++ packages/bugc/src/irgen/generator.test.ts | 6 ++++ packages/bugc/src/parser/parser.ts | 21 ++++++++---- packages/bugc/src/typechecker/expressions.ts | 3 ++ 14 files changed, 98 insertions(+), 10 deletions(-) diff --git a/packages/bugc-react/CHANGELOG.md b/packages/bugc-react/CHANGELOG.md index 4881e8c18d..1a5e67136b 100644 --- a/packages/bugc-react/CHANGELOG.md +++ b/packages/bugc-react/CHANGELOG.md @@ -7,6 +7,11 @@ specification itself are tracked in the root ## Unreleased +### Added + +- The CFG view shows a `block.prevrandao` read as `block.prevrandao` + ([#PR]). + ## 0.1.0-preview.1 — 2026-10-03 Updated `@ethdebug/bugc` to `0.1.0-preview.1`. diff --git a/packages/bugc-react/src/components/CfgView.tsx b/packages/bugc-react/src/components/CfgView.tsx index dba9b592a7..e06d619476 100644 --- a/packages/bugc-react/src/components/CfgView.tsx +++ b/packages/bugc-react/src/components/CfgView.tsx @@ -349,6 +349,8 @@ function CfgViewContent({ ir }: CfgViewProps): JSX.Element { return `${envInst.dest} = block.timestamp`; case "block_number": return `${envInst.dest} = block.number`; + case "block_prevrandao": + return `${envInst.dest} = block.prevrandao`; default: return `${envInst.dest} = ${envInst.op}`; } diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 3f7367a2c4..ac89ac688f 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -9,6 +9,8 @@ support. Changes to the specification itself are tracked in the root ### Added +- `block.prevrandao`, the previous block's RANDAO value (EIP-4399), as a + `uint256`. It compiles to the EVM's `PREVRANDAO` (`0x44`) ([#PR]). - The `%` operator, with the precedence of `*` and `/`. It compiles to the EVM's `MOD` (`SMOD` for signed operands), so `x % 0` is `0`, as `x / 0` is ([#321]). diff --git a/packages/bugc/src/ast/spec.ts b/packages/bugc/src/ast/spec.ts index 13310fd29a..0967273886 100644 --- a/packages/bugc/src/ast/spec.ts +++ b/packages/bugc/src/ast/spec.ts @@ -1439,7 +1439,8 @@ export namespace Expression { | Expression.Special.MsgValue | Expression.Special.MsgData | Expression.Special.BlockTimestamp - | Expression.Special.BlockNumber; + | Expression.Special.BlockNumber + | Expression.Special.BlockPrevrandao; export const isSpecial = ( expression: Expression.Base, @@ -1451,6 +1452,7 @@ export namespace Expression { Expression.Special.isMsgSender, Expression.Special.isBlockNumber, Expression.Special.isBlockTimestamp, + Expression.Special.isBlockPrevrandao, ].some((guard) => guard(expression)); export namespace Special { @@ -1532,6 +1534,26 @@ export namespace Expression { export function blockNumber(id: Id, loc?: SourceLocation): BlockNumber { return { id, kind: "expression:special:block.number", loc: loc ?? null }; } + + export interface BlockPrevrandao extends Base { + kind: "expression:special:block.prevrandao"; + } + + export const isBlockPrevrandao = ( + expression: Expression.Base, + ): expression is BlockPrevrandao => + expression.kind === "expression:special:block.prevrandao"; + + export function blockPrevrandao( + id: Id, + loc?: SourceLocation, + ): BlockPrevrandao { + return { + id, + kind: "expression:special:block.prevrandao", + loc: loc ?? null, + }; + } } } diff --git a/packages/bugc/src/ast/visitor.ts b/packages/bugc/src/ast/visitor.ts index 066cba791d..6e0ff010dd 100644 --- a/packages/bugc/src/ast/visitor.ts +++ b/packages/bugc/src/ast/visitor.ts @@ -201,6 +201,7 @@ export function visitDetailed( case "expression:special:msg.data": case "expression:special:block.timestamp": case "expression:special:block.number": + case "expression:special:block.prevrandao": return visitor.expressionSpecial(node as Ast.Expression.Special, context); default: diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index dea1369738..74c15541ac 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest"; import { keccak256 } from "ethereum-cryptography/keccak"; import { bytesToHex } from "ethereum-cryptography/utils"; +import { compile } from "#compiler"; import { executeProgram } from "#test/evm/behavioral"; describe("behavioral tests", () => { @@ -1340,6 +1341,38 @@ code { } }); + describe("block.prevrandao", () => { + const source = `name Randao; +storage { [0] out: uint256; } +code { + out = block.prevrandao + 1; +}`; + + for (const level of [0, 1, 2, 3] as const) { + it(`should read PREVRANDAO (level ${level})`, async () => { + const compiled = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + if (!compiled.success) throw new Error("compile failed"); + const mnemonics = + compiled.value.bytecode.runtimeProgram.instructions.map( + (instruction) => instruction.operation?.mnemonic, + ); + expect(mnemonics).toContain("PREVRANDAO"); + + // @ethdebug/evm runs each call in a block whose prevrandao is 0 + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(1n); + }); + } + }); + describe("modulo", () => { const program = (expr: string) => `name Modulo; diff --git a/packages/bugc/src/evmgen/generation/instructions/env.ts b/packages/bugc/src/evmgen/generation/instructions/env.ts index 56ea859768..9d06896659 100644 --- a/packages/bugc/src/evmgen/generation/instructions/env.ts +++ b/packages/bugc/src/evmgen/generation/instructions/env.ts @@ -6,7 +6,7 @@ import { type Transition, pipe, operations } from "#evmgen/operations"; import { storeValueIfNeeded } from "../values/index.js"; -const { CALLER, CALLVALUE, PUSH0, TIMESTAMP, NUMBER } = operations; +const { CALLER, CALLVALUE, PUSH0, TIMESTAMP, NUMBER, PREVRANDAO } = operations; /** * Generate code for environment operations @@ -26,6 +26,7 @@ export function generateEnvOp( msg_data: PUSH0({ debug }), // Returns calldata offset (0) block_timestamp: TIMESTAMP({ debug }), block_number: NUMBER({ debug }), + block_prevrandao: PREVRANDAO({ debug }), }; return pipe() diff --git a/packages/bugc/src/ir/analysis/validator.ts b/packages/bugc/src/ir/analysis/validator.ts index 51e819f043..e0c03b6db9 100644 --- a/packages/bugc/src/ir/analysis/validator.ts +++ b/packages/bugc/src/ir/analysis/validator.ts @@ -212,6 +212,7 @@ export class Validator { "msg_value", "block_number", "block_timestamp", + "block_prevrandao", ]; if (!validOps.includes(inst.op)) { this.error(`Invalid env operation '${inst.op}'`); diff --git a/packages/bugc/src/ir/spec/instruction.ts b/packages/bugc/src/ir/spec/instruction.ts index ddc5b6b6ee..66f3bd6f02 100644 --- a/packages/bugc/src/ir/spec/instruction.ts +++ b/packages/bugc/src/ir/spec/instruction.ts @@ -383,7 +383,8 @@ export namespace Instruction { | "msg_value" | "msg_data" | "block_number" - | "block_timestamp"; + | "block_timestamp" + | "block_prevrandao"; dest: string; } diff --git a/packages/bugc/src/irgen/generate/expressions/expression.ts b/packages/bugc/src/irgen/generate/expressions/expression.ts index 1a778d2479..6c56f95e56 100644 --- a/packages/bugc/src/irgen/generate/expressions/expression.ts +++ b/packages/bugc/src/irgen/generate/expressions/expression.ts @@ -50,6 +50,7 @@ export function* buildExpression( case "expression:special:msg.data": case "expression:special:block.timestamp": case "expression:special:block.number": + case "expression:special:block.prevrandao": return yield* buildSpecial(expr as Ast.Expression.Special); case "expression:array": return yield* buildArray(expr as Ast.Expression.Array, context); diff --git a/packages/bugc/src/irgen/generate/expressions/special.ts b/packages/bugc/src/irgen/generate/expressions/special.ts index 7a1353979a..2ba03b05fb 100644 --- a/packages/bugc/src/irgen/generate/expressions/special.ts +++ b/packages/bugc/src/irgen/generate/expressions/special.ts @@ -44,6 +44,9 @@ export function* buildSpecial(expr: Ast.Expression.Special): Process { case "expression:special:block.number": op = "block_number"; break; + case "expression:special:block.prevrandao": + op = "block_prevrandao"; + break; default: assertExhausted(expr); } diff --git a/packages/bugc/src/irgen/generator.test.ts b/packages/bugc/src/irgen/generator.test.ts index bd70abb377..b9bd371f53 100644 --- a/packages/bugc/src/irgen/generator.test.ts +++ b/packages/bugc/src/irgen/generator.test.ts @@ -417,6 +417,7 @@ describe("generateModule", () => { code { let num = block.number; let time = block.timestamp; + let randao = block.prevrandao; } `; @@ -430,8 +431,13 @@ describe("generateModule", () => { (i) => i.kind === "env" && i.op === "block_timestamp", ); + const prevrandaoInst = entry.instructions.find( + (i) => i.kind === "env" && i.op === "block_prevrandao", + ); + expect(numberInst).toBeDefined(); expect(timestampInst).toBeDefined(); + expect(prevrandaoInst).toBeDefined(); }); }); diff --git a/packages/bugc/src/parser/parser.ts b/packages/bugc/src/parser/parser.ts index ad555b862e..2f6357a49c 100644 --- a/packages/bugc/src/parser/parser.ts +++ b/packages/bugc/src/parser/parser.ts @@ -552,18 +552,25 @@ const msgExpression = located( }), ); -// block.timestamp and block.number as special expressions +// block.timestamp, block.number and block.prevrandao as special +// expressions const blockExpression = located( P.seq( Lang.keyword("block"), Lang.dot, - P.alt(Lang.keyword("timestamp"), Lang.keyword("number")), + P.alt( + Lang.keyword("timestamp"), + Lang.keyword("number"), + Lang.keyword("prevrandao"), + ), ).map(([_, __, property]) => { - const kind = property === "timestamp" ? "block.timestamp" : "block.number"; - if (kind === "block.timestamp") { - return Ast.Expression.Special.blockTimestamp(PENDING_ID); - } else { - return Ast.Expression.Special.blockNumber(PENDING_ID); + switch (property) { + case "timestamp": + return Ast.Expression.Special.blockTimestamp(PENDING_ID); + case "number": + return Ast.Expression.Special.blockNumber(PENDING_ID); + default: + return Ast.Expression.Special.blockPrevrandao(PENDING_ID); } }), ); diff --git a/packages/bugc/src/typechecker/expressions.ts b/packages/bugc/src/typechecker/expressions.ts index d01fa9c74b..398254f1a6 100644 --- a/packages/bugc/src/typechecker/expressions.ts +++ b/packages/bugc/src/typechecker/expressions.ts @@ -1037,6 +1037,9 @@ export const expressionChecker: Pick, "expression"> = { case "expression:special:block.number": type = Type.Elementary.uint(256); break; + case "expression:special:block.prevrandao": + type = Type.Elementary.uint(256); + break; } const nodeTypes = new Map(context.nodeTypes); From f056c3ccad07fd102d34ff3c0d33267bb0acf736 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:58:24 -0400 Subject: [PATCH 06/23] bugc: link the changelog entries to #363 --- packages/bugc-react/CHANGELOG.md | 3 ++- packages/bugc/CHANGELOG.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/bugc-react/CHANGELOG.md b/packages/bugc-react/CHANGELOG.md index 1a5e67136b..7426dfaeb0 100644 --- a/packages/bugc-react/CHANGELOG.md +++ b/packages/bugc-react/CHANGELOG.md @@ -10,7 +10,7 @@ specification itself are tracked in the root ### Added - The CFG view shows a `block.prevrandao` read as `block.prevrandao` - ([#PR]). + ([#363]). ## 0.1.0-preview.1 — 2026-10-03 @@ -39,3 +39,4 @@ First publication. [#299]: https://github.com/ethdebug/format/pull/299 [#300]: https://github.com/ethdebug/format/pull/300 +[#363]: https://github.com/ethdebug/format/pull/363 diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index ac89ac688f..4ba8c01b9d 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -10,7 +10,7 @@ support. Changes to the specification itself are tracked in the root ### Added - `block.prevrandao`, the previous block's RANDAO value (EIP-4399), as a - `uint256`. It compiles to the EVM's `PREVRANDAO` (`0x44`) ([#PR]). + `uint256`. It compiles to the EVM's `PREVRANDAO` (`0x44`) ([#363]). - The `%` operator, with the precedence of `*` and `/`. It compiles to the EVM's `MOD` (`SMOD` for signed operands), so `x % 0` is `0`, as `x / 0` is ([#321]). @@ -313,3 +313,4 @@ First publication. [#353]: https://github.com/ethdebug/format/pull/353 [#356]: https://github.com/ethdebug/format/pull/356 [#362]: https://github.com/ethdebug/format/pull/362 +[#363]: https://github.com/ethdebug/format/pull/363 From b748500eecac3f0c076d418603a032a70125a9c6 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 22:15:27 -0400 Subject: [PATCH 07/23] bugc: hash several value-type words with keccak256 --- packages/bugc-react/CHANGELOG.md | 5 + .../bugc-react/src/components/CfgView.tsx | 2 +- packages/bugc-react/src/components/IrView.tsx | 5 +- packages/bugc-react/src/utils/irDebugUtils.ts | 4 - packages/bugc/CHANGELOG.md | 10 ++ packages/bugc/examples/README.md | 25 ++++ packages/bugc/src/evmgen/analysis/liveness.ts | 2 +- packages/bugc/src/evmgen/analysis/memory.ts | 8 +- packages/bugc/src/evmgen/behavioral.test.ts | 122 ++++++++++++++++++ .../evmgen/generation/instructions/hash.ts | 52 +++++++- packages/bugc/src/ir/analysis/formatter.ts | 2 +- packages/bugc/src/ir/analysis/validator.ts | 4 +- packages/bugc/src/ir/spec/instruction.ts | 8 +- packages/bugc/src/ir/utils/debug.ts | 3 +- .../src/irgen/generate/expressions/call.ts | 15 ++- .../steps/common-subexpression-elimination.ts | 2 + .../optimizer/steps/constant-folding.test.ts | 15 +-- .../src/optimizer/steps/constant-folding.ts | 42 ++++-- .../optimizer/steps/constant-propagation.ts | 2 +- .../optimizer/steps/dead-code-elimination.ts | 4 +- packages/bugc/src/typechecker/checker.test.ts | 45 +++++++ packages/bugc/src/typechecker/expressions.ts | 77 ++++++----- 22 files changed, 374 insertions(+), 80 deletions(-) diff --git a/packages/bugc-react/CHANGELOG.md b/packages/bugc-react/CHANGELOG.md index 7426dfaeb0..93ab3589e1 100644 --- a/packages/bugc-react/CHANGELOG.md +++ b/packages/bugc-react/CHANGELOG.md @@ -12,6 +12,11 @@ specification itself are tracked in the root - The CFG view shows a `block.prevrandao` read as `block.prevrandao` ([#363]). +### Changed + +- The IR and CFG views show each operand of a `hash` instruction, which + now has a list of `values` ([#PR]). + ## 0.1.0-preview.1 — 2026-10-03 Updated `@ethdebug/bugc` to `0.1.0-preview.1`. diff --git a/packages/bugc-react/src/components/CfgView.tsx b/packages/bugc-react/src/components/CfgView.tsx index e06d619476..8117bed0f8 100644 --- a/packages/bugc-react/src/components/CfgView.tsx +++ b/packages/bugc-react/src/components/CfgView.tsx @@ -356,7 +356,7 @@ function CfgViewContent({ ir }: CfgViewProps): JSX.Element { } } case "hash": - return `${inst.dest} = keccak256(${formatValue(inst.value)})`; + return `${inst.dest} = keccak256(${inst.values.map(formatValue).join(", ")})`; case "cast": return `${inst.dest} = cast ${formatValue(inst.value)} to ${inst.targetType.kind}`; case "compute_slot": { diff --git a/packages/bugc-react/src/components/IrView.tsx b/packages/bugc-react/src/components/IrView.tsx index fd38f1e1bc..725498f644 100644 --- a/packages/bugc-react/src/components/IrView.tsx +++ b/packages/bugc-react/src/components/IrView.tsx @@ -160,7 +160,10 @@ function InstructionRenderer({ case "hash": add(`${formatDest(instruction.dest)} = hash `); - addOperand("value", formatValue(instruction.value)); + instruction.values.forEach((value, index) => { + if (index > 0) add(", "); + addOperand("value", formatValue(value)); + }); break; case "cast": diff --git a/packages/bugc-react/src/utils/irDebugUtils.ts b/packages/bugc-react/src/utils/irDebugUtils.ts index 11bdcc7678..2d04dff01c 100644 --- a/packages/bugc-react/src/utils/irDebugUtils.ts +++ b/packages/bugc-react/src/utils/irDebugUtils.ts @@ -90,10 +90,6 @@ export function extractInstructionDebug( operands.push({ label: "operand", debug: instruction.operandDebug }); break; - case "hash": - operands.push({ label: "value", debug: instruction.valueDebug }); - break; - case "cast": operands.push({ label: "value", debug: instruction.valueDebug }); break; diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 4ba8c01b9d..5f25178f05 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -11,6 +11,14 @@ support. Changes to the specification itself are tracked in the root - `block.prevrandao`, the previous block's RANDAO value (EIP-4399), as a `uint256`. It compiles to the EVM's `PREVRANDAO` (`0x44`) ([#363]). +- `keccak256` over value types: `keccak256(a, b, ...)` hashes the 32-byte + words of one or more integers, `address`, `bool` or `bytesN` values, in + order. For integers, `address` and `bool` this equals Solidity's + `keccak256(abi.encode(a, b, ...))`. A `bytesN` narrower than 32 bytes + hashes its word with its bytes at the right end, unlike `abi.encode`; + cast it to `bytes32` to match Solidity. A single dynamic `bytes` or + `string` argument still hashes its data, and must be the only argument. + The optimizer folds a hash of constant words at levels 1 to 3 ([#PR]). - The `%` operator, with the precedence of `*` and `/`. It compiles to the EVM's `MOD` (`SMOD` for signed operands), so `x % 0` is `0`, as `x / 0` is ([#321]). @@ -30,6 +38,8 @@ support. Changes to the specification itself are tracked in the root ### Changed +- The IR `hash` instruction now has `values`, a list, in place of + `value` and `valueDebug` ([#PR]). - An integer literal operand of an arithmetic or comparison operator now takes the type of the other operand when its value fits, so with `x: int8`, `x < 0`, `x == 1` and `-1 < x` compare as `int8`. A literal diff --git a/packages/bugc/examples/README.md b/packages/bugc/examples/README.md index 197a50dbc9..f43aacb30a 100644 --- a/packages/bugc/examples/README.md +++ b/packages/bugc/examples/README.md @@ -143,3 +143,28 @@ account.isActive = true; accounts[user].balance = accounts[user].balance + 100; accounts[user].isActive = true; ``` + +## Hashing + +`keccak256` has two forms. The compiler tells them apart by the type of +the argument: + +- **One dynamic `bytes` or `string`** hashes its data: + `keccak256("transfer(address,uint256)")`. +- **One or more value types** (integers, `address`, `bool`, `bytesN`) + hashes their 32-byte words, concatenated in order. This equals + Solidity's `keccak256(abi.encode(a, b, ...))` for integers, `address` + and `bool`: + +```bug +// a toy roll: 2 in 3 is a hit +let hit = (keccak256(block.number, msg.sender) as uint256) % 3 != 0; +``` + +A `bytes` or `string` argument must be the only one. + +A `bytesN` narrower than 32 bytes hashes its full word, with its bytes +at the low-order (right) end, as BUG holds it. Solidity's `abi.encode` +puts a `bytesN` value's bytes at the high-order (left) end, so for +`bytes4` and the like the hashes differ from Solidity's. Cast to +`bytes32` first (which puts the bytes at the left end) to match it. diff --git a/packages/bugc/src/evmgen/analysis/liveness.ts b/packages/bugc/src/evmgen/analysis/liveness.ts index 3a92c67542..8d56bbe90f 100644 --- a/packages/bugc/src/evmgen/analysis/liveness.ts +++ b/packages/bugc/src/evmgen/analysis/liveness.ts @@ -275,7 +275,7 @@ function getUsedValues(inst: Ir.Instruction): Set { } break; case "hash": - addValue(inst.value); + inst.values.forEach(addValue); break; case "cast": addValue(inst.value); diff --git a/packages/bugc/src/evmgen/analysis/memory.ts b/packages/bugc/src/evmgen/analysis/memory.ts index 194fe13dfa..24c9b43198 100644 --- a/packages/bugc/src/evmgen/analysis/memory.ts +++ b/packages/bugc/src/evmgen/analysis/memory.ts @@ -248,10 +248,14 @@ function simulateInstruction(stack: string[], inst: Ir.Instruction): string[] { // Pop consumed values based on instruction type switch (inst.kind) { case "binary": - case "hash": newStack.pop(); // Two operands newStack.pop(); break; + case "hash": + for (const _ of inst.values) { + newStack.pop(); + } + break; case "compute_slot": // Depends on kind newStack.pop(); // base @@ -355,7 +359,7 @@ function getUsedValues(inst: Ir.Instruction): Set { addValue(inst.object); break; case "hash": - addValue(inst.value); + inst.values.forEach(addValue); break; case "assert": addValue(inst.condition); diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index 74c15541ac..f9820b8067 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -1373,6 +1373,128 @@ code { } }); + describe("keccak256 over words", () => { + // Solidity's keccak256(abi.encode(...)) for value types: each value + // is one 32-byte word, in order + const word = (value: bigint) => + BigInt.asUintN(256, value).toString(16).padStart(64, "0"); + const hashOfWords = (...values: bigint[]) => + BigInt( + "0x" + + bytesToHex( + keccak256( + Uint8Array.from(Buffer.from(values.map(word).join(""), "hex")), + ), + ), + ); + + const sender = 1n; // @ethdebug/evm calls from address 0x00..01 + + const hashes: Record = { + "two uint256 constants": [ + `out = keccak256(7, 9) as uint256;`, + hashOfWords(7n, 9n), + ], + "a uint256 and an address": [ + `let n: uint256 = 42; + out = keccak256(n, msg.sender) as uint256;`, + hashOfWords(42n, sender), + ], + "storage values": [ + `out = keccak256(a, b) as uint256;`, + hashOfWords(1071n, 462n), + ], + "narrow integers and a bool": [ + `let x: uint8 = 200; + let t = true; + out = keccak256(x, t) as uint256;`, + hashOfWords(200n, 1n), + ], + "a negative int8": [ + `let x = (0 as int8) - 3; + out = keccak256(x, a) as uint256;`, + hashOfWords(-3n, 1071n), + ], + "a bytes32 and three words": [ + `let h: bytes32 = + 0x1122334400000000000000000000000000000000000000000000000000000000; + out = keccak256(h, a, b) as uint256;`, + hashOfWords( + 0x1122334400000000000000000000000000000000000000000000000000000000n, + 1071n, + 462n, + ), + ], + // Not as in Solidity, which left-aligns bytesN in abi.encode + "a bytes4, by its right-aligned word": [ + `let s: bytes4 = 0xaabbccdd; + out = keccak256(s, a) as uint256;`, + hashOfWords(0xaabbccddn, 1071n), + ], + "a bytes4 cast to bytes32, as Solidity encodes it": [ + `let s: bytes4 = 0xaabbccdd; + out = keccak256(s as bytes32, a) as uint256;`, + hashOfWords(0xaabbccddn << 224n, 1071n), + ], + "one uint256": [`out = keccak256(a) as uint256;`, hashOfWords(1071n)], + "a roll": [ + `if ((keccak256(a, msg.sender) as uint256) % 3 != 0) { out = 1; }`, + hashOfWords(1071n, sender) % 3n !== 0n ? 1n : 0n, + ], + }; + + for (const [name, [body, expected]] of Object.entries(hashes)) { + for (const level of [0, 1, 2, 3] as const) { + it(`should hash ${name} (level ${level})`, async () => { + const source = `name HashWords; +storage { + [0] a: uint256; + [1] b: uint256; + [2] out: uint256; +} +create { a = 1071; b = 462; } +code { + ${body} +}`; + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(2n)).toBe(expected); + }); + } + } + + for (const level of [1, 2, 3] as const) { + it(`should fold a hash of constant words (level ${level})`, async () => { + const result = await compile({ + to: "ir", + source: `name Fold; +storage { [0] out: uint256; } +code { out = keccak256(7, 9) as uint256; }`, + optimizer: { level }, + }); + if (!result.success) throw new Error("compile failed"); + + const instructions = [...result.value.ir.main.blocks.values()].flatMap( + (block) => block.instructions, + ); + expect(instructions.some(({ kind }) => kind === "hash")).toBe(false); + expect(instructions).toContainEqual( + expect.objectContaining({ + kind: "write", + value: expect.objectContaining({ + kind: "const", + value: hashOfWords(7n, 9n), + }), + }), + ); + }); + } + }); + describe("modulo", () => { const program = (expr: string) => `name Modulo; diff --git a/packages/bugc/src/evmgen/generation/instructions/hash.ts b/packages/bugc/src/evmgen/generation/instructions/hash.ts index ce5fe7835d..d1cbcb1a68 100644 --- a/packages/bugc/src/evmgen/generation/instructions/hash.ts +++ b/packages/bugc/src/evmgen/generation/instructions/hash.ts @@ -2,26 +2,64 @@ import type * as Ir from "#ir"; import type { Stack } from "#evm"; import { type Transition, pipe, operations } from "#evmgen/operations"; +import { Memory } from "#evmgen/analysis"; import { loadValue, storeValueIfNeeded } from "../values/index.js"; -const { PUSHn, MSTORE, KECCAK256 } = operations; +const { PUSHn, MSTORE, MLOAD, ADD, KECCAK256 } = operations; + +const freeMemoryPointer = BigInt(Memory.regions.FREE_MEMORY_POINTER); /** - * Generate code for hash operations + * Generate code for hash operations: keccak256 of the values' 32-byte + * words, in order. One or two words go in the scratch space at 0x00; + * more go in the free memory, which they do not allocate. */ export function generateHashOp( inst: Ir.Instruction.Hash, ): Transition { const debug = inst.operationDebug; + const size = BigInt(inst.values.length * 32); + + if (inst.values.length <= 2) { + const stores = inst.values.map((value, index) => + pipe() + .then(loadValue(value, { debug })) + .then(PUSHn(BigInt(index * 32), { debug }), { as: "offset" }) + .then(MSTORE({ debug })) + .done(), + ); + return pipe() + .then(sequence(stores)) + .then(PUSHn(size, { debug }), { as: "size" }) + .then(PUSHn(0n, { debug }), { as: "offset" }) + .then(KECCAK256({ debug }), { as: "value" }) + .then(storeValueIfNeeded(inst.dest, { debug })) + .done(); + } + const stores = inst.values.map((value, index) => + pipe() + .then(loadValue(value, { debug })) + .then(PUSHn(BigInt(index * 32), { debug }), { as: "b" }) + .then(PUSHn(freeMemoryPointer, { debug }), { as: "offset" }) + .then(MLOAD({ debug }), { as: "a" }) + .then(ADD({ debug }), { as: "offset" }) + .then(MSTORE({ debug })) + .done(), + ); return pipe() - .then(loadValue(inst.value, { debug })) - .then(PUSHn(0n, { debug }), { as: "offset" }) - .then(MSTORE({ debug })) - .then(PUSHn(32n, { debug }), { as: "size" }) - .then(PUSHn(0n, { debug }), { as: "offset" }) + .then(sequence(stores)) + .then(PUSHn(size, { debug }), { as: "size" }) + .then(PUSHn(freeMemoryPointer, { debug }), { as: "offset" }) + .then(MLOAD({ debug }), { as: "offset" }) .then(KECCAK256({ debug }), { as: "value" }) .then(storeValueIfNeeded(inst.dest, { debug })) .done(); } + +function sequence( + steps: Transition[], +): Transition { + return (state) => steps.reduce((current, step) => step(current), state); +} diff --git a/packages/bugc/src/ir/analysis/formatter.ts b/packages/bugc/src/ir/analysis/formatter.ts index 0fea3d5570..3b848ac872 100644 --- a/packages/bugc/src/ir/analysis/formatter.ts +++ b/packages/bugc/src/ir/analysis/formatter.ts @@ -165,7 +165,7 @@ export class Formatter { return `${destWithType(inst.dest)} = env ${inst.op}`; case "hash": - return `${destWithType(inst.dest)} = hash ${this.formatValue(inst.value)}`; + return `${destWithType(inst.dest)} = hash ${inst.values.map((value) => this.formatValue(value)).join(", ")}`; case "cast": return `${destWithType(inst.dest, inst.targetType)} = cast ${this.formatValue(inst.value)} to ${this.formatType(inst.targetType)}`; diff --git a/packages/bugc/src/ir/analysis/validator.ts b/packages/bugc/src/ir/analysis/validator.ts index e0c03b6db9..68fd464311 100644 --- a/packages/bugc/src/ir/analysis/validator.ts +++ b/packages/bugc/src/ir/analysis/validator.ts @@ -269,10 +269,10 @@ export class Validator { this.tempDefs.add(inst.dest); } - if (!inst.value) { + if (!inst.values?.length) { this.error("Hash instruction must have a value"); } else { - this.validateValue(inst.value); + inst.values.forEach((value) => this.validateValue(value)); } } diff --git a/packages/bugc/src/ir/spec/instruction.ts b/packages/bugc/src/ir/spec/instruction.ts index 66f3bd6f02..b25d4d0256 100644 --- a/packages/bugc/src/ir/spec/instruction.ts +++ b/packages/bugc/src/ir/spec/instruction.ts @@ -389,10 +389,14 @@ export namespace Instruction { dest: string; } + /** + * keccak256 of the 32-byte words of `values`, in order. A single + * memory reference (dynamic `bytes` or a `string`) instead hashes + * the data it refers to. + */ export interface Hash extends Instruction.Base { kind: "hash"; - value: Value; - valueDebug?: Instruction.Debug; + values: Value[]; dest: string; } diff --git a/packages/bugc/src/ir/utils/debug.ts b/packages/bugc/src/ir/utils/debug.ts index 0f171abb83..3b7ea32895 100644 --- a/packages/bugc/src/ir/utils/debug.ts +++ b/packages/bugc/src/ir/utils/debug.ts @@ -294,7 +294,6 @@ export function extractSubInstructionContexts( } break; - case "hash": case "cast": if (instruction.valueDebug?.context) { contexts.push(instruction.valueDebug.context); @@ -364,6 +363,8 @@ export function extractSubInstructionContexts( addValueDebug(instruction.size); break; case "hash": + instruction.values.forEach(addValueDebug); + break; case "cast": addValueDebug(instruction.value); break; diff --git a/packages/bugc/src/irgen/generate/expressions/call.ts b/packages/bugc/src/irgen/generate/expressions/call.ts index c1c0e4214e..2e6db74663 100644 --- a/packages/bugc/src/irgen/generate/expressions/call.ts +++ b/packages/bugc/src/irgen/generate/expressions/call.ts @@ -27,10 +27,10 @@ export const makeBuildCall = ( (expr.callee as Ast.Expression.Identifier).name === "keccak256" ) { // keccak256 built-in function - if (expr.arguments.length !== 1) { + if (expr.arguments.length === 0) { yield* Process.Errors.report( new IrgenError( - "keccak256 expects exactly 1 argument", + "keccak256 expects at least 1 argument", expr.loc ?? undefined, Severity.Error, ), @@ -38,10 +38,11 @@ export const makeBuildCall = ( return Ir.Value.constant(0n, Ir.Type.Scalar.bytes32); } - // Evaluate the argument - const argValue = yield* buildExpression(expr.arguments[0], { - kind: "rvalue", - }); + // Evaluate the arguments, in order + const values: Ir.Value[] = []; + for (const argument of expr.arguments) { + values.push(yield* buildExpression(argument, { kind: "rvalue" })); + } // Generate hash instruction const resultType: Ir.Type = Ir.Type.Scalar.bytes32; @@ -49,7 +50,7 @@ export const makeBuildCall = ( yield* Process.Instructions.emit({ kind: "hash", - value: argValue, + values, dest: resultTemp, operationDebug: yield* Process.Debug.forAstNode(expr), } as Ir.Instruction); diff --git a/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts b/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts index b3fc8311f3..bc66fdf5f4 100644 --- a/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts +++ b/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts @@ -236,6 +236,8 @@ export class CommonSubexpressionEliminationStep extends BaseOptimizationStep { result.size = replaceValue(result.size); break; case "hash": + result.values = result.values.map(replaceValue); + break; case "cast": result.value = replaceValue(result.value); break; diff --git a/packages/bugc/src/optimizer/steps/constant-folding.test.ts b/packages/bugc/src/optimizer/steps/constant-folding.test.ts index 3486c037fd..e3226fd968 100644 --- a/packages/bugc/src/optimizer/steps/constant-folding.test.ts +++ b/packages/bugc/src/optimizer/steps/constant-folding.test.ts @@ -42,7 +42,7 @@ describe("ConstantFoldingStep", () => { }, { kind: "hash", - value: { kind: "temp", id: "t0", type: Ir.Type.Scalar.uint256 }, + values: [{ kind: "temp", id: "t0", type: Ir.Type.Scalar.uint256 }], dest: "t1", operationDebug: {}, }, @@ -72,15 +72,14 @@ describe("ConstantFoldingStep", () => { it("should not fold keccak256 on non-constant values", () => { const module = createTestModule([ { - kind: "const", - value: 123n, - type: Ir.Type.Scalar.uint256, + kind: "env", + op: "msg_sender", dest: "t0", operationDebug: {}, }, { kind: "hash", - value: { kind: "temp", id: "t0", type: Ir.Type.Scalar.uint256 }, + values: [{ kind: "temp", id: "t0", type: Ir.Type.Scalar.uint256 }], dest: "t1", operationDebug: {}, }, @@ -99,7 +98,7 @@ describe("ConstantFoldingStep", () => { expect(block.instructions).toHaveLength(2); expect(block.instructions[1]).toMatchObject({ kind: "hash", - value: { kind: "temp", id: "t0" }, + values: [{ kind: "temp", id: "t0" }], dest: "t1", }); }); @@ -115,7 +114,7 @@ describe("ConstantFoldingStep", () => { }, { kind: "hash", - value: { kind: "temp", id: "t0", type: Ir.Type.Scalar.uint256 }, + values: [{ kind: "temp", id: "t0", type: Ir.Type.Scalar.uint256 }], dest: "t1", operationDebug: {}, }, @@ -128,7 +127,7 @@ describe("ConstantFoldingStep", () => { }, { kind: "hash", - value: { kind: "temp", id: "t2", type: Ir.Type.Scalar.uint256 }, + values: [{ kind: "temp", id: "t2", type: Ir.Type.Scalar.uint256 }], dest: "t3", operationDebug: {}, }, diff --git a/packages/bugc/src/optimizer/steps/constant-folding.ts b/packages/bugc/src/optimizer/steps/constant-folding.ts index e70dcc885b..28e617cc99 100644 --- a/packages/bugc/src/optimizer/steps/constant-folding.ts +++ b/packages/bugc/src/optimizer/steps/constant-folding.ts @@ -1,4 +1,5 @@ import { keccak256 } from "ethereum-cryptography/keccak"; +import { concatBytes, hexToBytes } from "ethereum-cryptography/utils"; import * as Ir from "#ir"; @@ -304,23 +305,44 @@ export class ConstantFoldingStep extends BaseOptimizationStep { constants: Map, ): boolean { if (inst.kind !== "hash") return false; + return this.hashInput(inst, constants) !== undefined; + } + + /** + * The bytes a hash of constants hashes: a single string's bytes, or + * the 32-byte words of its values, as code generation hashes them + */ + private hashInput( + inst: Ir.Instruction.Hash, + constants: Map, + ): Uint8Array | undefined { + const values = inst.values.map((value) => + this.getConstantValue(value, constants), + ); - const inputValue = this.getConstantValue(inst.value, constants); - // We can only fold if the input is a constant string - return typeof inputValue === "string"; + if (values.length === 1 && typeof values[0] === "string") { + return new TextEncoder().encode(values[0]); + } + + const words: Uint8Array[] = []; + for (const value of values) { + if (typeof value !== "bigint" && typeof value !== "boolean") { + return undefined; + } + const word = Ir.Utils.toWord( + typeof value === "boolean" ? (value ? 1n : 0n) : value, + ); + words.push(hexToBytes(word.toString(16).padStart(64, "0"))); + } + return concatBytes(...words); } private foldHash( inst: Ir.Instruction & { kind: "hash" }, constants: Map, ): Ir.Instruction | null { - const inputValue = this.getConstantValue(inst.value, constants); - - if (typeof inputValue !== "string") return null; - - // Convert string to bytes - const encoder = new TextEncoder(); - const inputBytes = encoder.encode(inputValue); + const inputBytes = this.hashInput(inst, constants); + if (!inputBytes) return null; // Compute keccak256 hash const hashBytes = keccak256(inputBytes); diff --git a/packages/bugc/src/optimizer/steps/constant-propagation.ts b/packages/bugc/src/optimizer/steps/constant-propagation.ts index 8f4c000f08..4367a689cb 100644 --- a/packages/bugc/src/optimizer/steps/constant-propagation.ts +++ b/packages/bugc/src/optimizer/steps/constant-propagation.ts @@ -129,7 +129,7 @@ export class ConstantPropagationStep extends BaseOptimizationStep { } break; case "hash": - result.value = propagateValue(result.value); + result.values = result.values.map(propagateValue); break; case "cast": result.value = propagateValue(result.value); diff --git a/packages/bugc/src/optimizer/steps/dead-code-elimination.ts b/packages/bugc/src/optimizer/steps/dead-code-elimination.ts index da5a55d6e7..53cafaa086 100644 --- a/packages/bugc/src/optimizer/steps/dead-code-elimination.ts +++ b/packages/bugc/src/optimizer/steps/dead-code-elimination.ts @@ -127,7 +127,9 @@ export class DeadCodeEliminationStep extends BaseOptimizationStep { } break; case "hash": - this.collectValueUse(inst.value, used); + for (const value of inst.values) { + this.collectValueUse(value, used); + } break; case "cast": this.collectValueUse(inst.value, used); diff --git a/packages/bugc/src/typechecker/checker.test.ts b/packages/bugc/src/typechecker/checker.test.ts index 106fb2f5b7..ed7b6eff74 100644 --- a/packages/bugc/src/typechecker/checker.test.ts +++ b/packages/bugc/src/typechecker/checker.test.ts @@ -640,4 +640,49 @@ describe("checkProgram", () => { expect(Result.hasMessages(result)).toBe(false); }); }); + describe("keccak256", () => { + const program = (expression: string) => ` + name Test; + storage { [0] h: bytes32; } + code { + let n: uint256 = 1; + let s = "x"; + h = ${expression}; + } + `; + + const accepted = { + "one string": `keccak256("x")`, + "one dynamic bytes": `keccak256(msg.data)`, + "one value type": `keccak256(n)`, + "several value types": `keccak256(n, msg.sender, true, h, 0x01)`, + }; + for (const [name, expression] of Object.entries(accepted)) { + it(`should accept ${name}`, () => { + const result = check(program(expression)); + expect(result.success).toBe(true); + }); + } + + const rejected = { + "no arguments": [`keccak256()`, "keccak256 expects at least 1 argument"], + "a string among several arguments": [ + `keccak256(n, s)`, + "keccak256 of several arguments takes only value types; " + + "bytes or string must be its only argument", + ], + "dynamic bytes among several arguments": [ + `keccak256(msg.data, n)`, + "keccak256 of several arguments takes only value types; " + + "bytes or string must be its only argument", + ], + }; + for (const [name, [expression, message]] of Object.entries(rejected)) { + it(`should reject ${name}`, () => { + const result = check(program(expression)); + expect(result.success).toBe(false); + expect(result).toHaveMessage({ severity: Severity.Error, message }); + }); + } + }); }); diff --git a/packages/bugc/src/typechecker/expressions.ts b/packages/bugc/src/typechecker/expressions.ts index 398254f1a6..0c0b928ae1 100644 --- a/packages/bugc/src/typechecker/expressions.ts +++ b/packages/bugc/src/typechecker/expressions.ts @@ -569,9 +569,12 @@ export const expressionChecker: Pick, "expression"> = { // Handle keccak256 built-in function if (functionName === "keccak256") { - if (node.arguments.length !== 1) { + // keccak256 takes one dynamic `bytes` or `string`, and hashes + // its data; or one or more value types, and hashes their + // words in order, as Solidity's keccak256(abi.encode(...)) + if (node.arguments.length === 0) { const error = new TypeError( - "keccak256 expects exactly 1 argument", + "keccak256 expects at least 1 argument", node.loc || undefined, undefined, undefined, @@ -581,39 +584,51 @@ export const expressionChecker: Pick, "expression"> = { return { symbols, nodeTypes, bindings, errors }; } - const argContext: Context = { - ...context, - nodeTypes, - symbols, - bindings, - }; - const argResult = Ast.visit( - context.visitor, - node.arguments[0], - argContext, - ); - nodeTypes = argResult.nodeTypes; - symbols = argResult.symbols; - bindings = argResult.bindings; - errors.push(...argResult.errors); + const argTypes: (Type | undefined)[] = []; + for (const argument of node.arguments) { + const argContext: Context = { + ...context, + nodeTypes, + symbols, + bindings, + }; + const argResult = Ast.visit(context.visitor, argument, argContext); + nodeTypes = argResult.nodeTypes; + symbols = argResult.symbols; + bindings = argResult.bindings; + errors.push(...argResult.errors); + argTypes.push(argResult.type); + } - if (!argResult.type) { + if (argTypes.some((type) => !type)) { return { symbols, nodeTypes, bindings, errors }; } - // keccak256 accepts bytes types and strings - if ( - !Type.Elementary.isBytes(argResult.type) && - !Type.Elementary.isString(argResult.type) - ) { - const error = new TypeError( - "keccak256 argument must be bytes or string type", - node.arguments[0].loc || undefined, - undefined, - undefined, - ErrorCode.TYPE_MISMATCH, - ); - errors.push(error); + const isData = (type: Type) => + Type.isElementary(type) && + ((Type.Elementary.isBytes(type) && type.size === undefined) || + Type.Elementary.isString(type)); + const isWord = (type: Type) => + Type.isElementary(type) && !isData(type); + + const single = argTypes.length === 1 && isData(argTypes[0]!); + const invalid = single + ? [] + : node.arguments.filter((_, index) => !isWord(argTypes[index]!)); + if (invalid.length > 0) { + for (const argument of invalid) { + const error = new TypeError( + argTypes.length === 1 + ? "keccak256 argument must be a value type, bytes or string" + : "keccak256 of several arguments takes only value types; " + + "bytes or string must be its only argument", + argument.loc || undefined, + undefined, + undefined, + ErrorCode.TYPE_MISMATCH, + ); + errors.push(error); + } return { symbols, nodeTypes, bindings, errors }; } From 316db9623d5c5f4c6bb279fbc9f50c11547cc788 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 22:15:54 -0400 Subject: [PATCH 08/23] bugc: link the changelog entries to #365 --- packages/bugc-react/CHANGELOG.md | 3 ++- packages/bugc/CHANGELOG.md | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/bugc-react/CHANGELOG.md b/packages/bugc-react/CHANGELOG.md index 93ab3589e1..ca0a1edd8c 100644 --- a/packages/bugc-react/CHANGELOG.md +++ b/packages/bugc-react/CHANGELOG.md @@ -15,7 +15,7 @@ specification itself are tracked in the root ### Changed - The IR and CFG views show each operand of a `hash` instruction, which - now has a list of `values` ([#PR]). + now has a list of `values` ([#365]). ## 0.1.0-preview.1 — 2026-10-03 @@ -45,3 +45,4 @@ First publication. [#299]: https://github.com/ethdebug/format/pull/299 [#300]: https://github.com/ethdebug/format/pull/300 [#363]: https://github.com/ethdebug/format/pull/363 +[#365]: https://github.com/ethdebug/format/pull/365 diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 5f25178f05..a28acc195b 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -18,7 +18,7 @@ support. Changes to the specification itself are tracked in the root hashes its word with its bytes at the right end, unlike `abi.encode`; cast it to `bytes32` to match Solidity. A single dynamic `bytes` or `string` argument still hashes its data, and must be the only argument. - The optimizer folds a hash of constant words at levels 1 to 3 ([#PR]). + The optimizer folds a hash of constant words at levels 1 to 3 ([#365]). - The `%` operator, with the precedence of `*` and `/`. It compiles to the EVM's `MOD` (`SMOD` for signed operands), so `x % 0` is `0`, as `x / 0` is ([#321]). @@ -39,7 +39,7 @@ support. Changes to the specification itself are tracked in the root ### Changed - The IR `hash` instruction now has `values`, a list, in place of - `value` and `valueDebug` ([#PR]). + `value` and `valueDebug` ([#365]). - An integer literal operand of an arithmetic or comparison operator now takes the type of the other operand when its value fits, so with `x: int8`, `x < 0`, `x == 1` and `-1 < x` compare as `int8`. A literal @@ -324,3 +324,4 @@ First publication. [#356]: https://github.com/ethdebug/format/pull/356 [#362]: https://github.com/ethdebug/format/pull/362 [#363]: https://github.com/ethdebug/format/pull/363 +[#365]: https://github.com/ethdebug/format/pull/365 From b8fbef0db441b63920ad907815efb99316bfa8f0 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:37:03 -0400 Subject: [PATCH 09/23] bugc: slice a fixed-size bytes value by its bytes --- packages/bugc/CHANGELOG.md | 5 + packages/bugc/src/evmgen/behavioral.test.ts | 84 ++++++++++++++ .../src/irgen/generate/expressions/access.ts | 104 ++++++++++++++++++ 3 files changed, 193 insertions(+) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index a28acc195b..dbfeaaf5b8 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -109,6 +109,11 @@ support. Changes to the specification itself are tracked in the root ranges had trailing whitespace: `block.number ` in `block.number + 1`, the target `x ` in `x = 1`, a statement up to the next statement, and the `create` and `code` blocks up to the next block ([#362]). +- A slice of a fixed-size `bytesN` value, as in + `keccak256("transfer(address,uint256)")[0:4]`, now gives those bytes as + dynamic `bytes`. Before, at every optimization level, the compiler read + the value as the memory address of dynamic `bytes`, so the slice + reverted with `Panic(0x32)` or held other memory ([#PR]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index f9820b8067..e647b38eb9 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -1495,6 +1495,90 @@ code { out = keccak256(7, 9) as uint256; }`, } }); + describe("slices of fixed-size bytes", () => { + // The leading four bytes of the hash of the word 0x00..01 + const hashPrefix = BigInt( + "0x" + bytesToHex(keccak256(new Uint8Array(32).fill(1, 31))).slice(0, 8), + ); + + const slices: Record = { + "the leading bytes of a hash": [ + `out = keccak256(0x01)[0:4] as bytes4 as uint256;`, + hashPrefix, + ], + "the leading bytes of a hash, compared": [ + `let sel = keccak256(0x01)[0:4] as bytes4; + if (sel == keccak256(0x01) as bytes4) { out = 1; }`, + 1n, + ], + "inner bytes of a bytes32": [ + `let h: bytes32 = + 0x1122334455667788000000000000000000000000000000000000000000000000; + out = h[1:5] as bytes4 as uint256;`, + 0x22334455n, + ], + "fewer bytes than the cast": [ + `let h: bytes32 = + 0x1122334455667788000000000000000000000000000000000000000000000000; + out = h[2:4] as bytes4 as uint256;`, + 0x33440000n, + ], + "the trailing bytes of a bytes32": [ + `let h: bytes32 = + 0x00000000000000000000000000000000000000000000000000000000aabbccdd; + out = h[28:32] as bytes4 as uint256;`, + 0xaabbccddn, + ], + "a bytes4": [ + `let b: bytes4 = 0x11223344; + out = b[1:3] as bytes4 as uint256;`, + 0x22330000n, + ], + "the length": [ + `let h: bytes32 = keccak256(0x01); + out = h[3:10].length;`, + 7n, + ], + }; + + for (const [name, [body, expected]] of Object.entries(slices)) { + for (const level of [0, 1, 2, 3] as const) { + it(`should slice ${name} (level ${level})`, async () => { + const source = `name Slice; +storage { [0] n: uint256; [1] out: uint256; } +code { + ${body} +}`; + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(1n)).toBe(expected); + }); + } + } + + for (const level of [0, 1, 2, 3] as const) { + it(`should revert on a slice past the end (level ${level})`, async () => { + const source = `name Slice; +storage { [0] end: uint256; [1] out: uint256; } +create { end = 33; } +code { + let h: bytes32 = keccak256(0x01); + out = h[30:end].length; +}`; + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + + expect(result.callSuccess).toBe(false); + }); + } + }); + describe("modulo", () => { const program = (expr: string) => `name Modulo; diff --git a/packages/bugc/src/irgen/generate/expressions/access.ts b/packages/bugc/src/irgen/generate/expressions/access.ts index ab6ac3772d..488eaaeb7d 100644 --- a/packages/bugc/src/irgen/generate/expressions/access.ts +++ b/packages/bugc/src/irgen/generate/expressions/access.ts @@ -206,6 +206,16 @@ const makeBuildSliceAccess = ( const start = yield* buildExpression(expr.start, { kind: "rvalue" }); const end = yield* buildExpression(expr.end, { kind: "rvalue" }); + if (objectType.size !== undefined) { + return yield* emitFixedBytesSlice( + object, + objectType.size, + start, + end, + expr, + ); + } + // Revert unless start <= end <= length const objectLength = yield* emitLength(object, expr); yield* emitBoundsCheck("le", start, end, expr); @@ -333,6 +343,100 @@ const makeBuildSliceAccess = ( ); }; +/** + * Slice a fixed-size `bytesN` value into new dynamic `bytes` in memory. + * + * A `bytesN` value is a word, not a memory address: its N bytes are the + * word's low bytes. Shift byte `start` up to the word's first byte and + * clear the bytes from `end - start` on, then store the length and that + * one data word. + */ +function* emitFixedBytesSlice( + object: Ir.Value, + size: number, + start: Ir.Value, + end: Ir.Value, + expr: Ast.Expression.Access.Slice, +): Process { + const debug = yield* Process.Debug.forAstNode(expr); + const uint256 = Ir.Type.Scalar.uint256; + const constant = (value: bigint) => Ir.Value.constant(value, uint256); + + function* binary( + op: Ir.Instruction.BinaryOp["op"], + left: Ir.Value, + right: Ir.Value, + ): Process { + const dest = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "binary", + op, + left, + right, + dest, + operationDebug: debug, + } as Ir.Instruction.BinaryOp); + return Ir.Value.temp(dest, uint256); + } + + // Revert unless start <= end <= N + yield* emitBoundsCheck("le", start, end, expr); + yield* emitBoundsCheck("le", end, constant(BigInt(size)), expr); + + const length = yield* binary("sub", end, start); + + // Move byte `start` of the N bytes to the word's first byte + const leading = yield* binary( + "add", + yield* binary("mul", start, constant(8n)), + constant(BigInt((32 - size) * 8)), + ); + const aligned = yield* binary("shl", object, leading); + + // Clear the bytes past the slice's length + const trailing = yield* binary( + "mul", + yield* binary("sub", constant(32n), length), + constant(8n), + ); + const data = yield* binary( + "shl", + yield* binary("shr", aligned, trailing), + trailing, + ); + + // The length word and one data word + const dest = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "allocate", + location: "memory", + size: constant(64n), + dest, + operationDebug: debug, + } as Ir.Instruction); + const address = Ir.Value.temp(dest, uint256); + + yield* Process.Instructions.emit({ + kind: "write", + location: "memory", + offset: address, + length: constant(32n), + value: length, + operationDebug: debug, + } as Ir.Instruction.Write); + + yield* Process.Instructions.emit({ + kind: "write", + location: "memory", + offset: yield* binary("add", address, constant(32n)), + length: constant(32n), + value: data, + operationDebug: debug, + } as Ir.Instruction.Write); + + return Ir.Value.temp(dest, Ir.Type.Ref.memory()); +} + const makeBuildIndexAccess = ( buildExpression: ( node: Ast.Expression, From 8df30079846a31f94592fc35d1ff63020f94aee6 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:37:56 -0400 Subject: [PATCH 10/23] bugc: link the changelog entry to #357 --- packages/bugc/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index dbfeaaf5b8..c0a4600938 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -113,7 +113,7 @@ support. Changes to the specification itself are tracked in the root `keccak256("transfer(address,uint256)")[0:4]`, now gives those bytes as dynamic `bytes`. Before, at every optimization level, the compiler read the value as the memory address of dynamic `bytes`, so the slice - reverted with `Panic(0x32)` or held other memory ([#PR]). + reverted with `Panic(0x32)` or held other memory ([#357]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert @@ -327,6 +327,7 @@ First publication. [#352]: https://github.com/ethdebug/format/pull/352 [#353]: https://github.com/ethdebug/format/pull/353 [#356]: https://github.com/ethdebug/format/pull/356 +[#357]: https://github.com/ethdebug/format/pull/357 [#362]: https://github.com/ethdebug/format/pull/362 [#363]: https://github.com/ethdebug/format/pull/363 [#365]: https://github.com/ethdebug/format/pull/365 From eb177613388a34292b03a6519a54be1e771945c0 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:42:23 -0400 Subject: [PATCH 11/23] bugc: hash the data of dynamic bytes and strings --- packages/bugc/CHANGELOG.md | 6 + packages/bugc/src/evmgen/behavioral.test.ts | 120 ++++++++++++++++++ .../bugc/src/evmgen/generation/instruction.ts | 2 +- .../evmgen/generation/instructions/hash.ts | 52 +++++++- 4 files changed, 173 insertions(+), 7 deletions(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index c0a4600938..0c0fc70335 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -114,6 +114,12 @@ support. Changes to the specification itself are tracked in the root dynamic `bytes`. Before, at every optimization level, the compiler read the value as the memory address of dynamic `bytes`, so the slice reverted with `Panic(0x32)` or held other memory ([#357]). +- `keccak256` of dynamic `bytes` or a `string` now hashes its data, and + `keccak256(msg.data)` hashes the calldata. Before, code generation + hashed the word that refers to the data (its memory address, or 0 for + `msg.data`). So at optimization level 0, `keccak256("transfer()")` was + wrong, while levels 1 to 3 folded it to the right constant; at every + level, the hash of a slice or of `msg.data` was wrong ([#PR]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index e647b38eb9..a6792070c9 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -1579,6 +1579,126 @@ code { } }); + describe("hashes of dynamic bytes", () => { + const hashOf = (data: string | Uint8Array) => + BigInt( + "0x" + + bytesToHex( + keccak256( + typeof data === "string" ? new TextEncoder().encode(data) : data, + ), + ), + ); + + const long = "season 2 starts friday, see you on the leaderboard"; + const calldata = "0xaabbccdd" + "11".repeat(32); + + const hashes: Record = { + "a string literal": [ + `out = keccak256("setMotd(string)") as uint256;`, + hashOf("setMotd(string)"), + ], + "a selector": [ + `out = keccak256("setMotd(string)") as bytes4 as uint256;`, + 0x5fe59b9dn, + ], + "an empty string": [`out = keccak256("") as uint256;`, hashOf("")], + "a string longer than a word": [ + `out = keccak256("${long}") as uint256;`, + hashOf(long), + ], + "a string local": [ + `let s = "setMotd(string)"; + out = keccak256(s) as uint256;`, + hashOf("setMotd(string)"), + ], + "a calldata slice": [ + `out = keccak256(msg.data[0:4]) as uint256;`, + hashOf(new Uint8Array([0xaa, 0xbb, 0xcc, 0xdd])), + ], + "msg.data": [ + `out = keccak256(msg.data) as uint256;`, + hashOf(new Uint8Array(Buffer.from(calldata.slice(2), "hex"))), + ], + }; + + for (const [name, [body, expected]] of Object.entries(hashes)) { + for (const level of [0, 1, 2, 3] as const) { + it(`should hash ${name} (level ${level})`, async () => { + const source = `name Hash; +storage { [0] n: uint256; [1] out: uint256; } +code { + ${body} +}`; + const result = await executeProgram(source, { + calldata, + optimizationLevel: level, + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(1n)).toBe(expected); + }); + } + } + }); + + describe("keccak256 of one data value or of words", () => { + // one word hashes its 32 bytes, one string or msg.data hashes its + // data, and several values hash their words in order + const hashOf = (bytes: Uint8Array) => + BigInt("0x" + bytesToHex(keccak256(bytes))); + const words = (...values: bigint[]) => + Uint8Array.from( + Buffer.from( + values.map((value) => value.toString(16).padStart(64, "0")).join(""), + "hex", + ), + ); + const calldata = "0xaabbccdd" + "11".repeat(32); + + const hashes: Record = { + "a uint256 by its word": [ + `let x: uint256 = 7; + out = keccak256(x) as uint256;`, + hashOf(words(7n)), + ], + "a string by its data": [ + `let s = "abc"; + out = keccak256(s) as uint256;`, + hashOf(new TextEncoder().encode("abc")), + ], + "msg.data by the calldata bytes": [ + `out = keccak256(msg.data) as uint256;`, + hashOf(Uint8Array.from(Buffer.from(calldata.slice(2), "hex"))), + ], + "two uint256 values by their words": [ + `let a: uint256 = 7; + let b: uint256 = 9; + out = keccak256(a, b) as uint256;`, + hashOf(words(7n, 9n)), + ], + }; + + for (const [name, [body, expected]] of Object.entries(hashes)) { + for (const level of [0, 1, 2, 3] as const) { + it(`should hash ${name} (level ${level})`, async () => { + const source = `name HashDispatch; +storage { [0] out: uint256; } +code { + ${body} +}`; + const result = await executeProgram(source, { + calldata, + optimizationLevel: level, + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(expected); + }); + } + } + }); + describe("modulo", () => { const program = (expr: string) => `name Modulo; diff --git a/packages/bugc/src/evmgen/generation/instruction.ts b/packages/bugc/src/evmgen/generation/instruction.ts index 1c6a890a3c..5dda5ae623 100644 --- a/packages/bugc/src/evmgen/generation/instruction.ts +++ b/packages/bugc/src/evmgen/generation/instruction.ts @@ -44,7 +44,7 @@ export function generate( case "env": return generateEnvOp(inst); case "hash": - return generateHashOp(inst); + return generateHashOp(inst, msgDataIds); case "length": return generateLength(inst, msgDataIds); case "compute_slot": diff --git a/packages/bugc/src/evmgen/generation/instructions/hash.ts b/packages/bugc/src/evmgen/generation/instructions/hash.ts index d1cbcb1a68..37fe16addf 100644 --- a/packages/bugc/src/evmgen/generation/instructions/hash.ts +++ b/packages/bugc/src/evmgen/generation/instructions/hash.ts @@ -1,26 +1,66 @@ -import type * as Ir from "#ir"; +import * as Ir from "#ir"; import type { Stack } from "#evm"; import { type Transition, pipe, operations } from "#evmgen/operations"; import { Memory } from "#evmgen/analysis"; -import { loadValue, storeValueIfNeeded } from "../values/index.js"; +import { loadValue, storeValueIfNeeded, valueId } from "../values/index.js"; -const { PUSHn, MSTORE, MLOAD, ADD, KECCAK256 } = operations; +const { PUSHn, MSTORE, MLOAD, ADD, KECCAK256, CALLDATASIZE, CALLDATACOPY } = + operations; const freeMemoryPointer = BigInt(Memory.regions.FREE_MEMORY_POINTER); /** - * Generate code for hash operations: keccak256 of the values' 32-byte - * words, in order. One or two words go in the scratch space at 0x00; - * more go in the free memory, which they do not allocate. + * Generate code for hash operations. + * + * One dynamic `bytes` or `string` in memory is the address of a length + * word, followed by the data: hash the data. One `msg.data` hashes the + * calldata. Otherwise, hash the values' 32-byte words, in order. One or + * two words go in the scratch space at 0x00; more go in the free memory, + * which they do not allocate. */ export function generateHashOp( inst: Ir.Instruction.Hash, + msgDataIds: ReadonlySet = new Set(), ): Transition { const debug = inst.operationDebug; const size = BigInt(inst.values.length * 32); + if (inst.values.length === 1) { + const [value] = inst.values; + + // msg.data is a temp defined by an env `msg_data` instruction, so + // the caller passes the ids of those temps. Copy the calldata to the + // free memory, without allocating it, and hash it there. + if (msgDataIds.has(valueId(value))) { + return pipe() + .then(CALLDATASIZE({ debug }), { as: "size" }) + .then(PUSHn(0n, { debug }), { as: "offset" }) + .then(PUSHn(freeMemoryPointer, { debug }), { as: "offset" }) + .then(MLOAD({ debug }), { as: "destOffset" }) + .then(CALLDATACOPY({ debug })) + .then(CALLDATASIZE({ debug }), { as: "size" }) + .then(PUSHn(freeMemoryPointer, { debug }), { as: "offset" }) + .then(MLOAD({ debug }), { as: "offset" }) + .then(KECCAK256({ debug }), { as: "value" }) + .then(storeValueIfNeeded(inst.dest, { debug })) + .done(); + } + + if (Ir.Type.isRef(value.type) && value.type.location === "memory") { + return pipe() + .then(loadValue(value, { debug }), { as: "offset" }) + .then(MLOAD({ debug }), { as: "size" }) + .then(PUSHn(32n, { debug }), { as: "b" }) + .then(loadValue(value, { debug }), { as: "a" }) + .then(ADD({ debug }), { as: "offset" }) + .then(KECCAK256({ debug }), { as: "value" }) + .then(storeValueIfNeeded(inst.dest, { debug })) + .done(); + } + } + if (inst.values.length <= 2) { const stores = inst.values.map((value, index) => pipe() From b2c26312d62c512a62d39e2521e98e6fef005fc0 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:42:34 -0400 Subject: [PATCH 12/23] bugc: link the changelog entry to #360 --- packages/bugc/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 0c0fc70335..01e5f75ad0 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -119,7 +119,7 @@ support. Changes to the specification itself are tracked in the root hashed the word that refers to the data (its memory address, or 0 for `msg.data`). So at optimization level 0, `keccak256("transfer()")` was wrong, while levels 1 to 3 folded it to the right constant; at every - level, the hash of a slice or of `msg.data` was wrong ([#PR]). + level, the hash of a slice or of `msg.data` was wrong ([#360]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert @@ -334,6 +334,7 @@ First publication. [#353]: https://github.com/ethdebug/format/pull/353 [#356]: https://github.com/ethdebug/format/pull/356 [#357]: https://github.com/ethdebug/format/pull/357 +[#360]: https://github.com/ethdebug/format/pull/360 [#362]: https://github.com/ethdebug/format/pull/362 [#363]: https://github.com/ethdebug/format/pull/363 [#365]: https://github.com/ethdebug/format/pull/365 From b8f84a2f26b594c81582088325bff34772113593 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:31:35 -0400 Subject: [PATCH 13/23] bugc: give a caller's call JUMP the invoke's arguments --- packages/bugc/CHANGELOG.md | 7 +++ .../bugc/src/evmgen/call-contexts.test.ts | 51 +++++++++++++++++-- .../evmgen/generation/control-flow/index.ts | 6 ++- .../generation/control-flow/terminator.ts | 30 +++++++++-- .../bugc/src/evmgen/generation/function.ts | 14 +---- 5 files changed, 87 insertions(+), 21 deletions(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 01e5f75ad0..c61652aedf 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -120,6 +120,12 @@ support. Changes to the specification itself are tracked in the root `msg.data`). So at optimization level 0, `keccak256("transfer()")` was wrong, while levels 1 to 3 folded it to the right constant; at every level, the hash of a slice or of `msg.data` was wrong ([#360]). +- The `invoke` context on a caller's `JUMP` into a function now has the + call's `arguments`, as the callee's entry `JUMPDEST` does: the `JUMP` + leaves them on the stack, and the `JUMPDEST` does not change it. + Before, only the `JUMPDEST`'s `invoke` had them, so a debugger that + opens the frame on the step after the `JUMP` showed it for one step + without its arguments ([#354]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert @@ -332,6 +338,7 @@ First publication. [#351]: https://github.com/ethdebug/format/pull/351 [#352]: https://github.com/ethdebug/format/pull/352 [#353]: https://github.com/ethdebug/format/pull/353 +[#354]: https://github.com/ethdebug/format/pull/354 [#356]: https://github.com/ethdebug/format/pull/356 [#357]: https://github.com/ethdebug/format/pull/357 [#360]: https://github.com/ethdebug/format/pull/360 diff --git a/packages/bugc/src/evmgen/call-contexts.test.ts b/packages/bugc/src/evmgen/call-contexts.test.ts index ac21216188..da8248f419 100644 --- a/packages/bugc/src/evmgen/call-contexts.test.ts +++ b/packages/bugc/src/evmgen/call-contexts.test.ts @@ -3,6 +3,9 @@ import { describe, it, expect } from "vitest"; import { compile } from "#compiler"; import type * as Format from "@ethdebug/format"; import { Pointer, Program } from "@ethdebug/format"; +import { createMachineState } from "@ethdebug/evm"; +import { dereference } from "@ethdebug/pointers"; +import { traceLocals } from "#test/evm/locals"; const { Context } = Program; const { Invocation } = Context.Invoke; @@ -70,7 +73,7 @@ code { it( "should emit invoke context on caller JUMP " + - "(identity + code target, no args)", + "(identity, code target and args)", async () => { const program = await compileProgram(source); @@ -100,12 +103,52 @@ code { expect(call.target).toBeDefined(); expect(Pointer.Region.isCode(call.target!.pointer)).toBe(true); - // Caller JUMP should NOT have argument pointers - // (args live on the callee JUMPDEST invoke context) - expect(call.arguments).toBeUndefined(); + // The JUMP's invoke opens the frame, so it names the + // arguments too: the JUMP leaves them on the stack, where the + // callee's entry JUMPDEST (which does not change the stack) + // finds them + expect(call.arguments).toBeDefined(); + expect(call.arguments!.pointer).toEqual({ + group: [ + { name: "a", location: "stack", slot: 1 }, + { name: "b", location: "stack", slot: 0 }, + ], + }); }, ); + it("should give the caller JUMP's arguments their values", async () => { + const { executor, steps, instructionAt } = await traceLocals(source); + + const index = steps.findIndex((step) => { + const instruction = instructionAt(step); + return ( + instruction?.operation?.mnemonic === "JUMP" && + Context.isInvoke(instruction.context) + ); + }); + expect(index).toBeGreaterThanOrEqual(0); + + // Contexts are postconditions: read the JUMP's pointers against + // the state after it, at the callee's entry JUMPDEST + const { invoke } = instructionAt(steps[index])! + .context as Format.Program.Context.Invoke; + const state = createMachineState(executor, { + traceStep: steps[index + 1], + }); + const cursor = await dereference( + (invoke as InternalCall).arguments!.pointer, + { state }, + ); + const view = await cursor.view(state); + const values = await Promise.all( + ["a", "b"].map(async (name) => + (await view.read(view.regions.lookup[name])).asUint(), + ), + ); + expect(values).toEqual([10n, 20n]); + }); + it("should emit return context on the callee's exit JUMP", async () => { const program = await compileProgram(source); diff --git a/packages/bugc/src/evmgen/generation/control-flow/index.ts b/packages/bugc/src/evmgen/generation/control-flow/index.ts index 6a1a06b1a8..73275ef413 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/index.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/index.ts @@ -1 +1,5 @@ -export { generateTerminator, generateCallTerminator } from "./terminator.js"; +export { + generateTerminator, + generateCallTerminator, + invokeArguments, +} from "./terminator.js"; diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index c4d945042f..6d10a33215 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -306,10 +306,10 @@ export function generateCallTerminator( currentState = loadValue(arg, { debug })(currentState); } - // Push function address and jump. - // The JUMP gets a simplified invoke context with - // identity and code target only; the full invoke - // with arg pointers lives on the callee JUMPDEST. + // Push function address and jump. The JUMP's invoke opens the + // callee's frame, so it carries the argument pointers, as the + // callee's entry JUMPDEST does: the JUMP leaves the arguments on + // the stack, and the JUMPDEST does not change it. const funcAddrPatchIndex = currentState.instructions.length; // Build declaration source range if available @@ -333,6 +333,7 @@ export function generateCallTerminator( length: 1, }, }, + ...invokeArguments(targetFunc?.parameters ?? []), }, }; // Compose the call-site source range (from the call op's debug) @@ -538,6 +539,27 @@ function withReturn( }; } +/** + * The `arguments` of an internal call's `invoke`, as they are once the + * call's JUMP has run: on the stack, the first argument deepest. + */ +export function invokeArguments( + parameters: Ir.Function.Parameter[], +): Pick { + if (parameters.length === 0) return {}; + return { + arguments: { + pointer: { + group: parameters.map((parameter, i) => ({ + ...(parameter.name ? { name: parameter.name } : {}), + location: "stack" as const, + slot: parameters.length - 1 - i, + })), + }, + }, + }; +} + /** * Build JUMP instruction options for a TCO-replaced tail call. * diff --git a/packages/bugc/src/evmgen/generation/function.ts b/packages/bugc/src/evmgen/generation/function.ts index 3908182369..95c0c21822 100644 --- a/packages/bugc/src/evmgen/generation/function.ts +++ b/packages/bugc/src/evmgen/generation/function.ts @@ -12,6 +12,7 @@ import { type Layout, Memory } from "#evmgen/analysis"; import type { Error as EvmgenError } from "#evmgen/errors"; import * as Block from "./block.js"; +import { invokeArguments } from "./control-flow/index.js"; import { serialize } from "../serialize.js"; import { type Transition } from "../operations.js"; @@ -31,11 +32,6 @@ function generatePrologue( // Add JUMPDEST with function entry annotation. // After this JUMPDEST executes, the callee's args are // on the stack (first arg deepest). - const argPointers = params.map((p, i) => ({ - ...(p.name ? { name: p.name } : {}), - location: "stack" as const, - slot: params.length - 1 - i, - })); // Build declaration source range if available const declaration = @@ -58,13 +54,7 @@ function generatePrologue( length: 1, }, }, - ...(argPointers.length > 0 && { - arguments: { - pointer: { - group: argPointers, - }, - }, - }), + ...invokeArguments(params), }, }; const entryDebug = { From bfe04d4cdb3c34a1c36d92de247f6459647e0006 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:39:48 -0400 Subject: [PATCH 14/23] bugc: list variables before a function's first statement --- packages/bugc/CHANGELOG.md | 8 +++ .../debug/local-variables.soundness.test.ts | 58 +++++++++++++++++++ .../bugc/src/evmgen/debug/local-variables.ts | 27 +++++++++ packages/bugc/src/evmgen/generation/block.ts | 40 ++++++++----- .../bugc/src/evmgen/generation/function.ts | 44 ++++++++------ 5 files changed, 144 insertions(+), 33 deletions(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index c61652aedf..da40a6d27e 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -126,6 +126,13 @@ support. Changes to the specification itself are tracked in the root Before, only the `JUMPDEST`'s `invoke` had them, so a debugger that opens the frame on the step after the `JUMP` showed it for one step without its arguments ([#354]). +- The instructions that run before a function's first statement now have + a `variables` context: the memory setup at the start of the code (and + `create`) blocks, and a function's entry `JUMPDEST` and prologue. They + list the storage variables and the locals in scope, a local without a + pointer: a parameter is not yet in its home there, and the `invoke`'s + `arguments` point at it on the stack. Before, they listed no variables, + so a debugger showed none for those steps ([#358]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert @@ -341,6 +348,7 @@ First publication. [#354]: https://github.com/ethdebug/format/pull/354 [#356]: https://github.com/ethdebug/format/pull/356 [#357]: https://github.com/ethdebug/format/pull/357 +[#358]: https://github.com/ethdebug/format/pull/358 [#360]: https://github.com/ethdebug/format/pull/360 [#362]: https://github.com/ethdebug/format/pull/362 [#363]: https://github.com/ethdebug/format/pull/363 diff --git a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts index f2ab39d7f7..ef8018ffed 100644 --- a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -1052,3 +1052,61 @@ describe.each(levels)("locals hold the program's values at O%i", (level) => { (program) => check(program, level), ); }); + +describe("variables before a function's first statement", () => { + // Contexts are postconditions: the step after an instruction shows + // its context. Every step lists the storage variable; from the + // callee's entry JUMPDEST through its prologue, the parameters. + const source = `name Prelude; +define { + function add(a: uint256, b: uint256) -> uint256 { + let c = a + b; + return c; + }; +} +storage { [0] r: uint256; } +code { let x = 7; r = add(x, 3); }`; + + for (const level of [0, 1] as Level[]) { + it(`lists variables at every step (O${level})`, async () => { + const { steps, instructionAt } = await traceLocals(source, level); + const unlisted = steps + .map((_, i) => i) + .filter( + (i) => + i > 0 && + localsOf(instructionAt(steps[i - 1])?.context).length === 0, + ); + expect(unlisted).toEqual([]); + }); + + it(`lists the parameters in the prologue (O${level})`, async () => { + const { program } = await traceLocals(source, level); + const at = program.instructions.findIndex( + (instruction) => + instruction.operation?.mnemonic === "JUMPDEST" && + Program.Context.isInvoke(instruction.context), + ); + // The entry JUMPDEST, then the prologue: the ops that map to the + // whole function, before its body's + const whole = codeOffset(program.instructions[at + 1].context); + const rest = program.instructions.slice(at + 1); + const prologue = [ + program.instructions[at], + ...rest.slice( + 0, + rest.findIndex( + (instruction) => codeOffset(instruction.context) !== whole, + ), + ), + ]; + expect(prologue.length).toBeGreaterThan(1); + for (const instruction of prologue) { + const names = localsOf(instruction.context).map((v) => v.identifier); + expect(names, `pc ${instruction.offset}`).toEqual( + expect.arrayContaining(["r", "a", "b"]), + ); + } + }); + } +}); diff --git a/packages/bugc/src/evmgen/debug/local-variables.ts b/packages/bugc/src/evmgen/debug/local-variables.ts index a8376c2195..845135db09 100644 --- a/packages/bugc/src/evmgen/debug/local-variables.ts +++ b/packages/bugc/src/evmgen/debug/local-variables.ts @@ -459,6 +459,33 @@ export function withoutFrameLocals( }; } +/** + * The variables in scope before a function's entry block: during the + * memory setup of main or create, or a function's entry JUMPDEST and + * prologue. They are the entry block's, as listed just before its first + * instruction. No local is in its home yet (the prologue is still + * setting the frame up), so a local is listed without a pointer. + */ +export function preludeVariables(func: Ir.Function): VariableEntry[] { + const block = func.blocks.get(func.entry); + if (!block) return []; + const first = block.instructions[0] ?? block.terminator; + const context = (block.entryDebug?.context ?? + first.operationDebug?.context) as Record | undefined; + if (!context) return []; + + const locals = new Set( + [...(func.ssaVariables?.values() ?? [])].map((ssa) => ssa.name), + ); + return liftVariables(context).variables.map((entry) => { + if (entry.identifier === undefined || !locals.has(entry.identifier)) { + return entry; + } + const { pointer: _, ...rest } = entry; + return rest; + }); +} + /** The source offsets of an instruction/terminator: of its `code` * context, or of each `code` in a `gather` (all of which apply). */ function codeOffsets(context: unknown): number[] { diff --git a/packages/bugc/src/evmgen/generation/block.ts b/packages/bugc/src/evmgen/generation/block.ts index c678bd9c90..24ef450170 100644 --- a/packages/bugc/src/evmgen/generation/block.ts +++ b/packages/bugc/src/evmgen/generation/block.ts @@ -21,7 +21,11 @@ import { generateCallTerminator, } from "./control-flow/index.js"; import { annotateTop } from "./values/identify.js"; -import { withReturned, withStackLocals } from "../debug/local-variables.js"; +import { + preludeVariables, + withReturned, + withStackLocals, +} from "../debug/local-variables.js"; /** * Generate code for a basic block @@ -59,7 +63,11 @@ export function generate( ? { sourceId: func.sourceId, loc: func.loc } : undefined; result = result.then( - initializeMemory(state.memory.nextStaticOffset, sourceInfo), + initializeMemory( + state.memory.nextStaticOffset, + sourceInfo, + func ? preludeVariables(func) : [], + ), ); } @@ -385,23 +393,23 @@ function storePhiDest( function initializeMemory( nextStaticOffset: number, sourceInfo?: { sourceId: string; loc: Ast.SourceLocation }, + variables: Format.Program.Context.Variables["variables"] = [], ): Transition { const { PUSHn, MSTORE } = operations; - const debug = sourceInfo - ? { - context: { - code: { - source: { id: sourceInfo.sourceId }, - range: sourceInfo.loc, - }, - } as Format.Program.Context, - } - : { - context: { - remark: "initialize free memory pointer", - } as Format.Program.Context, - }; + const debug = { + context: { + ...(sourceInfo + ? { + code: { + source: { id: sourceInfo.sourceId }, + range: sourceInfo.loc, + }, + } + : { remark: "initialize free memory pointer" }), + ...(variables.length > 0 ? { variables } : {}), + } as Format.Program.Context, + }; const { PUSH0 } = operations; diff --git a/packages/bugc/src/evmgen/generation/function.ts b/packages/bugc/src/evmgen/generation/function.ts index 95c0c21822..86dd2504f0 100644 --- a/packages/bugc/src/evmgen/generation/function.ts +++ b/packages/bugc/src/evmgen/generation/function.ts @@ -13,6 +13,7 @@ import type { Error as EvmgenError } from "#evmgen/errors"; import * as Block from "./block.js"; import { invokeArguments } from "./control-flow/index.js"; +import { preludeVariables } from "../debug/local-variables.js"; import { serialize } from "../serialize.js"; import { type Transition } from "../operations.js"; @@ -57,8 +58,15 @@ function generatePrologue( ...invokeArguments(params), }, }; + // Until the prologue has stored the arguments in the frame, the + // parameters are listed without a pointer (the invoke's + // `arguments` point at them on the stack) + const variables = preludeVariables(func); const entryDebug = { - context: entryInvoke as Format.Program.Context, + context: { + ...entryInvoke, + ...(variables.length > 0 ? { variables } : {}), + } as Format.Program.Context, }; currentState = { ...currentState, @@ -68,7 +76,7 @@ function generatePrologue( ], }; - const d = makePrologueDebug(func); + const d = makePrologueDebug(func, variables); const frameSize = currentState.memory.frameSize; if (frameSize !== undefined) { @@ -254,21 +262,23 @@ function emitFpRelativeStore( ); } -function makePrologueDebug(func: Ir.Function): Debug { - return func.sourceId && func.loc - ? { - context: { - code: { - source: { id: func.sourceId }, - range: func.loc, - }, - } as Format.Program.Context, - } - : { - context: { - remark: "prologue: allocate call frame", - } as Format.Program.Context, - }; +function makePrologueDebug( + func: Ir.Function, + variables: Format.Program.Context.Variables["variables"], +): Debug { + return { + context: { + ...(func.sourceId && func.loc + ? { + code: { + source: { id: func.sourceId }, + range: func.loc, + }, + } + : { remark: "prologue: allocate call frame" }), + ...(variables.length > 0 ? { variables } : {}), + } as Format.Program.Context, + }; } /** From f3018cd5e0c09d25d66331609efd2f10a35de276 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:32:31 -0400 Subject: [PATCH 15/23] bugc: store a memory string's bytes when assigning it to storage A storage write took its value as a word, so a memory string or bytes reference wrote its memory address into the slot. Code generation now encodes the referenced bytes as Solidity does: inline with length * 2 up to 31 bytes, else length * 2 + 1 in the slot and the data from keccak256(slot), with the last word's trailing bytes cleared. A slice now carries the bytes type, so its value is recognized too. --- packages/bugc/CHANGELOG.md | 6 + .../debug/local-variables.soundness.test.ts | 26 +++ .../evmgen/generation/instructions/storage.ts | 201 ++++++++++++++++++ .../bugc/src/evmgen/string-storage.test.ts | 112 ++++++++++ .../src/irgen/generate/expressions/access.ts | 5 +- 5 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 packages/bugc/src/evmgen/string-storage.test.ts diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index da40a6d27e..cadbeea422 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -133,6 +133,12 @@ support. Changes to the specification itself are tracked in the root pointer: a parameter is not yet in its home there, and the `invoke`'s `arguments` point at it on the stack. Before, they listed no variables, so a debugger showed none for those steps ([#358]). +- Assigning a memory `string` or `bytes` to storage, as in `s = m;`, now + stores its bytes as Solidity does: up to 31 bytes in the slot, with + twice the length in the low byte; else twice the length plus one in the + slot, and the data from `keccak256(slot)`. A slice's bytes past its + length are stored as zero. Before, the slot held the value's memory + address ([#PR]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert diff --git a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts index ef8018ffed..9ae01841a1 100644 --- a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -982,6 +982,32 @@ code { }, }, }, + { + // Storing a string copies its bytes in a loop, which holds + // words of its own on the stack + name: "strings stored to storage", + source: `name StringStore; +storage { [0] r: uint256; [1] s: string; [2] t: string; } +create { r = 1; } +code { + let n = r; + let short = "hello"; + let long = "hello world, this is longer than thirty-two bytes"; + s = short; + t = long; + if (n > 0) { r = n + short.length + long.length; } +}`, + locals: { + n: { values: [1n] }, + short: { shape: { kind: "bytes" }, values: [textBytes("hello")] }, + long: { + shape: { kind: "bytes" }, + values: [ + textBytes("hello world, this is longer than thirty-two bytes"), + ], + }, + }, + }, { name: "bytes literal", source: `name BytesLit; diff --git a/packages/bugc/src/evmgen/generation/instructions/storage.ts b/packages/bugc/src/evmgen/generation/instructions/storage.ts index c65c44dc5a..6ada855ae3 100644 --- a/packages/bugc/src/evmgen/generation/instructions/storage.ts +++ b/packages/bugc/src/evmgen/generation/instructions/storage.ts @@ -1,7 +1,10 @@ import * as Ir from "#ir"; import type { Stack } from "#evm"; +import { Type as BugType } from "#types"; +import type { State } from "#evmgen/state"; import { type Transition, rebrand, pipe, operations } from "#evmgen/operations"; +import { calculateSize } from "#evmgen/serialize"; import { loadValue, storeValueIfNeeded } from "../values/index.js"; import { generateCastSteps } from "./cast.js"; @@ -405,6 +408,10 @@ function generateStorageWrite( const offset = inst.offset?.kind === "const" ? inst.offset.value : 0n; const length = inst.length?.kind === "const" ? inst.length.value : 32n; + if (isMemoryBytes(inst.value!.type)) { + return generateBytesStorageWrite(inst, debug); + } + if (offset === 0n && length === 32n) { // Full slot write - simple SSTORE return pipe() @@ -478,3 +485,197 @@ function generateStorageWrite( .done() ); } + +/** + * Whether a value is a reference to a string or `bytes` in memory: a + * length word, then the data + */ +function isMemoryBytes(type: Ir.Type): boolean { + return ( + type.kind === "ref" && + type.location === "memory" && + type.origin !== "synthetic" && + BugType.isElementary(type.origin) && + (BugType.Elementary.isString(type.origin) || + (BugType.Elementary.isBytes(type.origin) && + type.origin.size === undefined)) + ); +} + +type Step = (state: State) => State; +type Op = (options?: { debug: Ir.Instruction.Debug }) => Step; + +/** + * Store a string or `bytes` from memory as Solidity encodes it in + * storage. Up to 31 bytes go in the slot itself, left-aligned, with + * length * 2 in the low byte. Longer data puts length * 2 + 1 in the + * slot and the data in the slots from keccak256(slot). The bytes + * after the end of the data, in its last word, are stored as zero. + * + * [slot, ptr] DUP2 MLOAD + * [len, ...] PUSH1 31 DUP2 GT PUSH2 long JUMPI + * DUP3 PUSH1 32 ADD MLOAD DUP2 + * DUP2 DUP1 ADD OR DUP3 SSTORE PUSH2 end JUMP + * long: JUMPDEST DUP1 DUP1 ADD PUSH1 1 ADD DUP3 SSTORE + * DUP2 PUSH0 MSTORE PUSH1 32 PUSH0 KECCAK256 + * DUP4 PUSH1 32 ADD DUP3 DUP2 ADD + * loop: JUMPDEST [end, src, dataSlot, len, ...] + * DUP2 MLOAD DUP3 DUP3 SUB DUP4 SSTORE + * SWAP2 PUSH1 1 ADD SWAP2 SWAP1 PUSH1 32 ADD SWAP1 + * DUP2 DUP2 GT PUSH2 loop JUMPI POP POP POP + * end: JUMPDEST POP POP POP + * + * `` takes [k, word] to the word with all but its first k bytes + * cleared, when k < 32: it shifts the word right, then left, by + * (k < 32) * (256 - 8k) bits. + */ +function generateBytesStorageWrite( + inst: Ir.Instruction.Write, + debug: Ir.Instruction.Debug, +): Transition { + const raw = operations as unknown as Record; + const op = + (name: string): Step => + (state) => + raw[name]({ debug })(state); + const push = + (value: bigint): Step => + (state) => + (operations.PUSHn(value, { debug }) as unknown as Step)(state); + + // Push a jump target, patched to the offset of `label` + const target = + (label: string): Step => + (state) => { + const index = state.instructions.length; + const pushed = (operations.PUSH2([0, 0], { debug }) as unknown as Step)( + state, + ); + return { + ...pushed, + patches: [...pushed.patches, { index, target: label }], + }; + }; + const mark = + (label: string): Step => + (state) => { + const marked = { + ...state, + blockOffsets: { + ...state.blockOffsets, + [label]: calculateSize(state.instructions), + }, + }; + return op("JUMPDEST")(marked); + }; + + const mask: Step[] = [ + op("DUP1"), + push(3n), + op("SHL"), + push(256n), + op("SUB"), + op("SWAP1"), + push(32n), + op("SWAP1"), + op("LT"), + op("MUL"), + op("DUP1"), + op("SWAP2"), + op("SWAP1"), + op("SHR"), + op("SWAP1"), + op("SHL"), + ]; + + return ((state: State): State => { + const id = state.nextId; + const long = `$bytes_long_${id}`; + const loop = `$bytes_loop_${id}`; + const end = `$bytes_end_${id}`; + + const loaded = pipe() + .then(loadValue(inst.value!, { debug }), { as: "value" }) + .then(loadValue(inst.slot!, { debug }), { as: "key" }) + .done()({ ...state, nextId: id + 1 }) as State; + + const steps: Step[] = [ + op("DUP2"), + op("MLOAD"), + push(31n), + op("DUP2"), + op("GT"), + target(long), + op("JUMPI"), + + op("DUP3"), + push(32n), + op("ADD"), + op("MLOAD"), + op("DUP2"), + ...mask, + op("DUP2"), + op("DUP1"), + op("ADD"), + op("OR"), + op("DUP3"), + op("SSTORE"), + target(end), + op("JUMP"), + + mark(long), + op("DUP1"), + op("DUP1"), + op("ADD"), + push(1n), + op("ADD"), + op("DUP3"), + op("SSTORE"), + op("DUP2"), + push(0n), + op("MSTORE"), + push(32n), + push(0n), + op("KECCAK256"), + op("DUP4"), + push(32n), + op("ADD"), + op("DUP3"), + op("DUP2"), + op("ADD"), + + mark(loop), + op("DUP2"), + op("MLOAD"), + op("DUP3"), + op("DUP3"), + op("SUB"), + ...mask, + op("DUP4"), + op("SSTORE"), + op("SWAP2"), + push(1n), + op("ADD"), + op("SWAP2"), + op("SWAP1"), + push(32n), + op("ADD"), + op("SWAP1"), + op("DUP2"), + op("DUP2"), + op("GT"), + target(loop), + op("JUMPI"), + op("POP"), + op("POP"), + op("POP"), + + mark(end), + op("POP"), + op("POP"), + op("POP"), + ]; + + return steps.reduce>((current, step) => step(current), loaded); + }) as unknown as Transition; +} diff --git a/packages/bugc/src/evmgen/string-storage.test.ts b/packages/bugc/src/evmgen/string-storage.test.ts new file mode 100644 index 0000000000..0d024a4ba4 --- /dev/null +++ b/packages/bugc/src/evmgen/string-storage.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { keccak256 } from "ethereum-cryptography/keccak"; +import { bytesToHex, hexToBytes } from "ethereum-cryptography/utils"; + +import { + executeProgram, + type ExecuteProgramResult, +} from "#test/evm/behavioral"; + +const lengths = [0, 5, 31, 32, 70]; +const levels = [0, 1, 2, 3] as const; + +const alphabet = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +const text = (length: number) => + Array.from({ length }, (_, i) => alphabet[i % alphabet.length]).join(""); + +const hex = (length: number) => + Array.from({ length }, (_, i) => (i + 1).toString(16).padStart(2, "0")).join( + "", + ); + +const word = (n: bigint) => hexToBytes(n.toString(16).padStart(64, "0")); +const hash = (bytes: Uint8Array) => BigInt("0x" + bytesToHex(keccak256(bytes))); + +/** The slot of a mapping's value: keccak256(key . slot) */ +const mappingSlot = (slot: bigint, key: bigint) => + hash(new Uint8Array([...word(key), ...word(slot)])); + +/** + * Check that storage at `slot` holds `data` as Solidity encodes a + * string or `bytes`: up to 31 bytes in the slot itself, left-aligned, + * with length * 2 in the low byte; else length * 2 + 1 in the slot and + * the data from keccak256(slot). + */ +async function expectEncoded( + result: ExecuteProgramResult, + slot: bigint, + data: Uint8Array, +) { + const length = BigInt(data.length); + const padded = new Uint8Array(Math.ceil(data.length / 32) * 32); + padded.set(data); + const dataWord = (i: number) => + BigInt("0x" + (bytesToHex(padded.slice(i * 32, i * 32 + 32)) || "0")); + + if (length < 32n) { + expect(await result.getStorage(slot)).toBe(dataWord(0) | (length * 2n)); + return; + } + + expect(await result.getStorage(slot)).toBe(length * 2n + 1n); + const base = hash(word(slot)); + for (let i = 0; i < padded.length / 32; i++) { + expect(await result.getStorage(base + BigInt(i))).toBe(dataWord(i)); + } +} + +describe("assigning a memory string to storage", () => { + for (const level of levels) { + for (const length of lengths) { + it(`stores ${length} bytes (level ${level})`, async () => { + const value = text(length); + const source = `name StringToStorage; +storage { [0] s: string; [1] t: string; [2] u: mapping; } +code { + let m: string = "${value}"; + s = m; + t = "${value}"; + u[7] = m; +}`; + + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + + expect(result.callSuccess).toBe(true); + const data = new TextEncoder().encode(value); + await expectEncoded(result, 0n, data); + await expectEncoded(result, 1n, data); + await expectEncoded(result, mappingSlot(2n, 7n), data); + }); + } + } +}); + +describe("assigning memory bytes to storage", () => { + // Slices of a 70-byte literal. A slice of up to 32 bytes holds + // the bytes after its end in the rest of its word; those must not + // reach storage. + for (const level of levels) { + for (const length of lengths) { + it(`stores ${length} bytes (level ${level})`, async () => { + const source = `name BytesToStorage; +storage { [0] b: bytes; } +code { + let m: bytes = 0x${hex(70)}; + ${length === 70 ? "b = m;" : `b = m[0:${length}];`} +}`; + + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + + expect(result.callSuccess).toBe(true); + await expectEncoded(result, 0n, hexToBytes(hex(length))); + }); + } + } +}); diff --git a/packages/bugc/src/irgen/generate/expressions/access.ts b/packages/bugc/src/irgen/generate/expressions/access.ts index 488eaaeb7d..790b3dd1b1 100644 --- a/packages/bugc/src/irgen/generate/expressions/access.ts +++ b/packages/bugc/src/irgen/generate/expressions/access.ts @@ -222,7 +222,10 @@ const makeBuildSliceAccess = ( yield* emitBoundsCheck("le", end, objectLength, expr); // Slicing bytes returns dynamic bytes (memory reference) - const resultType: Ir.Type = Ir.Type.Ref.memory(); + const resultType: Ir.Type = Ir.Type.ref( + "memory", + Type.Elementary.bytes(), + ); // Calculate the length of the slice const lengthTemp = yield* Process.Variables.newTemp(); From ad29e59679e0cf07b6de37c6387ba110e6f4db67 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:32:58 -0400 Subject: [PATCH 16/23] bugc: link the changelog entry to #355 --- packages/bugc/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index cadbeea422..f35cc1fe2d 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -138,7 +138,7 @@ support. Changes to the specification itself are tracked in the root twice the length in the low byte; else twice the length plus one in the slot, and the data from `keccak256(slot)`. A slice's bytes past its length are stored as zero. Before, the slot held the value's memory - address ([#PR]). + address ([#355]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert @@ -352,6 +352,7 @@ First publication. [#352]: https://github.com/ethdebug/format/pull/352 [#353]: https://github.com/ethdebug/format/pull/353 [#354]: https://github.com/ethdebug/format/pull/354 +[#355]: https://github.com/ethdebug/format/pull/355 [#356]: https://github.com/ethdebug/format/pull/356 [#357]: https://github.com/ethdebug/format/pull/357 [#358]: https://github.com/ethdebug/format/pull/358 From 154ee51ab56fe82c51ca10f70eb4d98724d3747f Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:41:42 -0400 Subject: [PATCH 17/23] bugc: say why the bytes test slices without naming the copy width --- packages/bugc/src/evmgen/string-storage.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/bugc/src/evmgen/string-storage.test.ts b/packages/bugc/src/evmgen/string-storage.test.ts index 0d024a4ba4..8bbf36a1ae 100644 --- a/packages/bugc/src/evmgen/string-storage.test.ts +++ b/packages/bugc/src/evmgen/string-storage.test.ts @@ -86,9 +86,8 @@ code { }); describe("assigning memory bytes to storage", () => { - // Slices of a 70-byte literal. A slice of up to 32 bytes holds - // the bytes after its end in the rest of its word; those must not - // reach storage. + // Slices of a 70-byte literal. Memory past the end of a slice's + // data may hold other bytes; those must not reach storage. for (const level of levels) { for (const length of lengths) { it(`stores ${length} bytes (level ${level})`, async () => { From 40ea7a5c6337bfc149391b8f024e49f8d88b76f0 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:40:17 -0400 Subject: [PATCH 18/23] bugc: copy all of a slice's bytes irgen built a slice's data as a read and a write, and a read or write moves at most one word, so only the first 32 bytes were copied. Add a copy IR instruction (CALLDATACOPY from calldata, MCOPY from memory) and emit it for the slice's data. --- packages/bugc-react/CHANGELOG.md | 1 + packages/bugc-react/src/components/IrView.tsx | 10 +++ packages/bugc/CHANGELOG.md | 4 + packages/bugc/src/evmgen/analysis/liveness.ts | 6 ++ packages/bugc/src/evmgen/analysis/memory.ts | 11 +++ .../debug/local-variables.soundness.test.ts | 27 +++++++ .../bugc/src/evmgen/generation/instruction.ts | 3 + .../evmgen/generation/instructions/copy.ts | 26 ++++++ .../evmgen/generation/instructions/index.ts | 1 + packages/bugc/src/evmgen/slice-copy.test.ts | 81 +++++++++++++++++++ packages/bugc/src/ir/analysis/formatter.ts | 7 ++ packages/bugc/src/ir/spec/instruction.ts | 13 +++ packages/bugc/src/ir/utils/debug.ts | 5 ++ .../src/irgen/generate/expressions/access.ts | 40 +++------ packages/bugc/src/irgen/slice.test.ts | 10 +-- .../steps/common-subexpression-elimination.ts | 6 ++ .../optimizer/steps/constant-propagation.ts | 6 ++ .../optimizer/steps/dead-code-elimination.ts | 6 ++ 18 files changed, 228 insertions(+), 35 deletions(-) create mode 100644 packages/bugc/src/evmgen/generation/instructions/copy.ts create mode 100644 packages/bugc/src/evmgen/slice-copy.test.ts diff --git a/packages/bugc-react/CHANGELOG.md b/packages/bugc-react/CHANGELOG.md index ca0a1edd8c..1c7bf4e38f 100644 --- a/packages/bugc-react/CHANGELOG.md +++ b/packages/bugc-react/CHANGELOG.md @@ -16,6 +16,7 @@ specification itself are tracked in the root - The IR and CFG views show each operand of a `hash` instruction, which now has a list of `values` ([#365]). +- The IR view shows bugc's new `copy` instruction ([#PR]). ## 0.1.0-preview.1 — 2026-10-03 diff --git a/packages/bugc-react/src/components/IrView.tsx b/packages/bugc-react/src/components/IrView.tsx index 725498f644..054938b5cb 100644 --- a/packages/bugc-react/src/components/IrView.tsx +++ b/packages/bugc-react/src/components/IrView.tsx @@ -339,6 +339,16 @@ function InstructionRenderer({ break; } + case "copy": + add("memory[offset: "); + addOperand("offset", formatValue(instruction.offset)); + add(", length: "); + addOperand("length", formatValue(instruction.length)); + add(`] = copy ${instruction.location}[`); + addOperand("source", formatValue(instruction.source)); + add("]"); + break; + default: add(`; unknown instruction: ${(instruction as { kind?: string }).kind}`); } diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index f35cc1fe2d..0cd4df2b2f 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -139,6 +139,10 @@ support. Changes to the specification itself are tracked in the root slot, and the data from `keccak256(slot)`. A slice's bytes past its length are stored as zero. Before, the slot held the value's memory address ([#355]). +- A slice now copies all its bytes, as in `msg.data[4:]` or `m[1:40]`. + The IR has a `copy` instruction, which compiles to `CALLDATACOPY` from + calldata and `MCOPY` from memory. Before, a slice copied one word, so a + slice longer than 32 bytes held zeros after its first 32 ([#PR]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert diff --git a/packages/bugc/src/evmgen/analysis/liveness.ts b/packages/bugc/src/evmgen/analysis/liveness.ts index 8d56bbe90f..e74905c54c 100644 --- a/packages/bugc/src/evmgen/analysis/liveness.ts +++ b/packages/bugc/src/evmgen/analysis/liveness.ts @@ -303,6 +303,11 @@ function getUsedValues(inst: Ir.Instruction): Set { addValue(inst.length); addValue(inst.value); break; + case "copy": + addValue(inst.source); + addValue(inst.offset); + addValue(inst.length); + break; // NEW: unified compute offset case "compute_offset": addValue(inst.base); @@ -341,6 +346,7 @@ function getDefinedValue(inst: Ir.Instruction): string | undefined { return inst.dest; // These instructions don't define values case "write": // NEW: unified write + case "copy": case "assert": return undefined; } diff --git a/packages/bugc/src/evmgen/analysis/memory.ts b/packages/bugc/src/evmgen/analysis/memory.ts index 24c9b43198..0a4730bcea 100644 --- a/packages/bugc/src/evmgen/analysis/memory.ts +++ b/packages/bugc/src/evmgen/analysis/memory.ts @@ -285,6 +285,11 @@ function simulateInstruction(stack: string[], inst: Ir.Instruction): string[] { if (inst.length) newStack.pop(); newStack.pop(); // value break; + case "copy": + newStack.pop(); // source + newStack.pop(); // offset + newStack.pop(); // length + break; // NEW: compute offset case "compute_offset": newStack.pop(); // base @@ -361,6 +366,11 @@ function getUsedValues(inst: Ir.Instruction): Set { case "hash": inst.values.forEach(addValue); break; + case "copy": + addValue(inst.source); + addValue(inst.offset); + addValue(inst.length); + break; case "assert": addValue(inst.condition); break; @@ -420,6 +430,7 @@ function definedType(inst: Ir.Instruction): Ir.Type | undefined { case "compute_offset": return Ir.Type.Scalar.word; case "write": + case "copy": case "assert": return undefined; } diff --git a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts index 9ae01841a1..3943454557 100644 --- a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -1040,6 +1040,33 @@ code { // Called with no calldata, so a longer slice would revert locals: { b: { shape: { kind: "bytes" }, values: ["0x"] } }, }, + { + // A slice longer than a word: its pointer reads every byte + name: "a long bytes slice", + source: `name LongSlice; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 1; } +code { + let data: bytes = 0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223; + let b = data[1:35]; + if (s > 0) { r = b.length; } + r = r + b.length; +}`, + locals: { + data: { + shape: { kind: "bytes" }, + values: [ + "0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223", + ], + }, + b: { + shape: { kind: "bytes" }, + values: [ + "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122", + ], + }, + }, + }, { name: "a uint8 cast", source: `name U8; diff --git a/packages/bugc/src/evmgen/generation/instruction.ts b/packages/bugc/src/evmgen/generation/instruction.ts index 5dda5ae623..ee3276fd43 100644 --- a/packages/bugc/src/evmgen/generation/instruction.ts +++ b/packages/bugc/src/evmgen/generation/instruction.ts @@ -21,6 +21,7 @@ import { generateAllocate, generateComputeOffset, generateAssert, + generateCopy, } from "./instructions/index.js"; /** @@ -41,6 +42,8 @@ export function generate( return generateRead(inst); case "write": return generateWrite(inst); + case "copy": + return generateCopy(inst); case "env": return generateEnvOp(inst); case "hash": diff --git a/packages/bugc/src/evmgen/generation/instructions/copy.ts b/packages/bugc/src/evmgen/generation/instructions/copy.ts new file mode 100644 index 0000000000..16c99649d6 --- /dev/null +++ b/packages/bugc/src/evmgen/generation/instructions/copy.ts @@ -0,0 +1,26 @@ +import type * as Ir from "#ir"; +import type { Stack } from "#evm"; + +import { type Transition, pipe, operations } from "#evmgen/operations"; + +import { loadValue } from "../values/index.js"; + +const { MCOPY, CALLDATACOPY } = operations; + +/** + * Generate code for a copy to memory: MCOPY from memory, CALLDATACOPY + * from calldata + */ +export function generateCopy( + inst: Ir.Instruction.Copy, +): Transition { + const debug = inst.operationDebug; + const copy = inst.location === "calldata" ? CALLDATACOPY : MCOPY; + + return pipe() + .then(loadValue(inst.length, { debug }), { as: "size" }) + .then(loadValue(inst.source, { debug }), { as: "offset" }) + .then(loadValue(inst.offset, { debug }), { as: "destOffset" }) + .then(copy({ debug })) + .done(); +} diff --git a/packages/bugc/src/evmgen/generation/instructions/index.ts b/packages/bugc/src/evmgen/generation/instructions/index.ts index 1b16a0c62f..b21459e395 100644 --- a/packages/bugc/src/evmgen/generation/instructions/index.ts +++ b/packages/bugc/src/evmgen/generation/instructions/index.ts @@ -10,3 +10,4 @@ export { generateComputeSlot } from "./compute-slot.js"; export { generateAllocate } from "./allocate.js"; export { generateComputeOffset } from "./compute-offset.js"; export { generateAssert } from "./assert.js"; +export { generateCopy } from "./copy.js"; diff --git a/packages/bugc/src/evmgen/slice-copy.test.ts b/packages/bugc/src/evmgen/slice-copy.test.ts new file mode 100644 index 0000000000..fa42af9b0d --- /dev/null +++ b/packages/bugc/src/evmgen/slice-copy.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { keccak256 } from "ethereum-cryptography/keccak"; +import { bytesToHex, hexToBytes } from "ethereum-cryptography/utils"; + +import { + executeProgram, + type ExecuteProgramResult, +} from "#test/evm/behavioral"; + +const lengths = [0, 31, 32, 33, 100]; +const levels = [0, 1, 2, 3] as const; + +// 101 distinct bytes, 0x01 to 0x65 +const source = Array.from({ length: 101 }, (_, i) => + (i + 1).toString(16).padStart(2, "0"), +).join(""); + +const word = (n: bigint) => hexToBytes(n.toString(16).padStart(64, "0")); + +/** Storage `[1] out: mapping` at key `i` */ +const outSlot = (i: number) => + BigInt( + "0x" + + bytesToHex(keccak256(new Uint8Array([...word(BigInt(i)), ...word(1n)]))), + ); + +/** + * The program copies a slice's length to slot 0, and its bytes, one by + * one, to `out` + */ +const copyOut = (slice: string) => ` + let x = ${slice}; + n = x.length; + for (let i = 0; i < x.length; i = i + 1) { + out[i] = x[i]; + }`; + +async function expectBytes(result: ExecuteProgramResult, expected: string) { + expect(result.callSuccess).toBe(true); + const bytes = hexToBytes(expected); + expect(await result.getStorage(0n)).toBe(BigInt(bytes.length)); + for (let i = 0; i < bytes.length; i++) { + expect(await result.getStorage(outSlot(i)), `byte ${i}`).toBe( + BigInt(bytes[i]), + ); + } +} + +describe("a slice copies all its bytes", () => { + for (const level of levels) { + for (const length of lengths) { + it(`of msg.data, ${length} bytes (level ${level})`, async () => { + const result = await executeProgram( + `name CalldataSlice; +storage { [0] n: uint256; [1] out: mapping; } +code {${copyOut("msg.data[1:msg.data.length]")} +}`, + { + calldata: source.slice(0, (length + 1) * 2), + optimizationLevel: level, + }, + ); + + await expectBytes(result, source.slice(2, (length + 1) * 2)); + }); + + it(`of memory bytes, ${length} bytes (level ${level})`, async () => { + const result = await executeProgram( + `name MemorySlice; +storage { [0] n: uint256; [1] out: mapping; } +code { + let m: bytes = 0x${source};${copyOut(`m[1:${length + 1}]`)} +}`, + { calldata: "", optimizationLevel: level }, + ); + + await expectBytes(result, source.slice(2, (length + 1) * 2)); + }); + } + } +}); diff --git a/packages/bugc/src/ir/analysis/formatter.ts b/packages/bugc/src/ir/analysis/formatter.ts index 3b848ac872..c470febe1c 100644 --- a/packages/bugc/src/ir/analysis/formatter.ts +++ b/packages/bugc/src/ir/analysis/formatter.ts @@ -301,6 +301,13 @@ export class Formatter { } } + case "copy": { + const offset = this.formatValue(inst.offset); + const length = this.formatValue(inst.length); + const source = this.formatValue(inst.source); + return `memory[offset: ${offset}, length: ${length}] = copy ${inst.location}[${source}]`; + } + // NEW: unified compute offset case "compute_offset": { const base = this.formatValue(inst.base); diff --git a/packages/bugc/src/ir/spec/instruction.ts b/packages/bugc/src/ir/spec/instruction.ts index b25d4d0256..6d6462876b 100644 --- a/packages/bugc/src/ir/spec/instruction.ts +++ b/packages/bugc/src/ir/spec/instruction.ts @@ -12,6 +12,7 @@ export type Instruction = // Unified read/write operations | Instruction.Read | Instruction.Write + | Instruction.Copy // Storage slot computation | Instruction.ComputeSlot // Unified compute operations @@ -125,6 +126,18 @@ export namespace Instruction { valueDebug?: Instruction.Debug; } + /** + * Copy `length` bytes from `source` in `location` to memory at + * `offset` + */ + export interface Copy extends Instruction.Base { + kind: "copy"; + location: "memory" | "calldata"; + source: Value; + offset: Value; + length: Value; + } + // NEW: Unified compute offset instruction export type ComputeOffset = | ComputeOffset.Array diff --git a/packages/bugc/src/ir/utils/debug.ts b/packages/bugc/src/ir/utils/debug.ts index 3b7ea32895..aaa0114363 100644 --- a/packages/bugc/src/ir/utils/debug.ts +++ b/packages/bugc/src/ir/utils/debug.ts @@ -344,6 +344,11 @@ export function extractSubInstructionContexts( addValueDebug(instruction.length); addValueDebug(instruction.value); break; + case "copy": + addValueDebug(instruction.source); + addValueDebug(instruction.offset); + addValueDebug(instruction.length); + break; case "compute_offset": addValueDebug(instruction.base); if (instruction.offsetKind === "array") { diff --git a/packages/bugc/src/irgen/generate/expressions/access.ts b/packages/bugc/src/irgen/generate/expressions/access.ts index 790b3dd1b1..00c5bc1368 100644 --- a/packages/bugc/src/irgen/generate/expressions/access.ts +++ b/packages/bugc/src/irgen/generate/expressions/access.ts @@ -269,21 +269,11 @@ const makeBuildSliceAccess = ( operationDebug: yield* Process.Debug.forAstNode(expr), } as Ir.Instruction.Write); - // Read the slice data from the source: `msg.data` is calldata - // from offset 0; other bytes are in memory after a length word. - // Either read copies one word. - const dataTemp = yield* Process.Variables.newTemp(); - if (Ast.Expression.Special.isMsgData(expr.object)) { - yield* Process.Instructions.emit({ - kind: "read", - location: "calldata", - offset: start, - length: Ir.Value.constant(32n, Ir.Type.Scalar.uint256), - type: resultType, - dest: dataTemp, - operationDebug: yield* Process.Debug.forAstNode(expr), - } as Ir.Instruction.Read); - } else { + // Copy the slice's bytes from the source: `msg.data` is calldata + // from offset 0; other bytes are in memory after a length word + const isCalldata = Ast.Expression.Special.isMsgData(expr.object); + let source = start; + if (!isCalldata) { const sourceOffsetTemp = yield* Process.Variables.newTemp(); yield* Process.Instructions.emit({ kind: "binary", @@ -303,16 +293,7 @@ const makeBuildSliceAccess = ( dest: adjustedSourceTemp, operationDebug: yield* Process.Debug.forAstNode(expr), } as Ir.Instruction); - - yield* Process.Instructions.emit({ - kind: "read", - location: "memory", - offset: Ir.Value.temp(adjustedSourceTemp, Ir.Type.Scalar.uint256), - length, - type: resultType, - dest: dataTemp, - operationDebug: yield* Process.Debug.forAstNode(expr), - } as Ir.Instruction.Read); + source = Ir.Value.temp(adjustedSourceTemp, Ir.Type.Scalar.uint256); } // Calculate destination offset (skip length prefix) @@ -326,15 +307,14 @@ const makeBuildSliceAccess = ( operationDebug: yield* Process.Debug.forAstNode(expr), } as Ir.Instruction); - // Write the slice data to destination yield* Process.Instructions.emit({ - kind: "write", - location: "memory", + kind: "copy", + location: isCalldata ? "calldata" : "memory", + source, offset: Ir.Value.temp(destDataOffsetTemp, Ir.Type.Scalar.uint256), length, - value: Ir.Value.temp(dataTemp, resultType), operationDebug: yield* Process.Debug.forAstNode(expr), - } as Ir.Instruction.Write); + } as Ir.Instruction.Copy); return Ir.Value.temp(destTemp, resultType); } diff --git a/packages/bugc/src/irgen/slice.test.ts b/packages/bugc/src/irgen/slice.test.ts index 1373e9c89e..5a49ce7556 100644 --- a/packages/bugc/src/irgen/slice.test.ts +++ b/packages/bugc/src/irgen/slice.test.ts @@ -33,20 +33,20 @@ describe("IR slice generation", () => { const allInsts = mainBlocks.flatMap((block) => block.instructions); // Should have operations for: sub (length calc), add (size calc), - // allocate, write (length), add (source offset), add (adjusted source), - // read, add (dest offset), write (data) + // allocate, write (length), add (dest offset), copy (data) const subInsts = allInsts.filter( (inst) => inst.kind === "binary" && inst.op === "sub", ); const allocInsts = allInsts.filter((inst) => inst.kind === "allocate"); - const readInsts = allInsts.filter((inst) => inst.kind === "read"); + const copyInsts = allInsts.filter((inst) => inst.kind === "copy"); const writeInsts = allInsts.filter((inst) => inst.kind === "write"); // Should have decomposed the slice into multiple operations expect(subInsts.length).toBeGreaterThan(0); // Length calculation expect(allocInsts.length).toBeGreaterThan(0); // Memory allocation(s) - may allocate for result - expect(readInsts.length).toBeGreaterThan(0); // Read slice data - expect(writeInsts.length).toBeGreaterThan(0); // Write length and data + expect(writeInsts.length).toBeGreaterThan(0); // Write length + // Copy the data from calldata + expect(copyInsts).toMatchObject([{ location: "calldata" }]); } } }); diff --git a/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts b/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts index bc66fdf5f4..a9f07ff32f 100644 --- a/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts +++ b/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts @@ -241,6 +241,11 @@ export class CommonSubexpressionEliminationStep extends BaseOptimizationStep { case "cast": result.value = replaceValue(result.value); break; + case "copy": + result.source = replaceValue(result.source); + result.offset = replaceValue(result.offset); + result.length = replaceValue(result.length); + break; case "length": result.object = replaceValue(result.object); break; @@ -319,6 +324,7 @@ export class CommonSubexpressionEliminationStep extends BaseOptimizationStep { private hasSideEffects(inst: Ir.Instruction): boolean { switch (inst.kind) { case "write": + case "copy": return true; default: return false; diff --git a/packages/bugc/src/optimizer/steps/constant-propagation.ts b/packages/bugc/src/optimizer/steps/constant-propagation.ts index 4367a689cb..86bfa6577f 100644 --- a/packages/bugc/src/optimizer/steps/constant-propagation.ts +++ b/packages/bugc/src/optimizer/steps/constant-propagation.ts @@ -131,6 +131,11 @@ export class ConstantPropagationStep extends BaseOptimizationStep { case "hash": result.values = result.values.map(propagateValue); break; + case "copy": + result.source = propagateValue(result.source); + result.offset = propagateValue(result.offset); + result.length = propagateValue(result.length); + break; case "cast": result.value = propagateValue(result.value); break; @@ -182,6 +187,7 @@ export class ConstantPropagationStep extends BaseOptimizationStep { private hasSideEffects(inst: Ir.Instruction): boolean { switch (inst.kind) { case "write": + case "copy": return true; default: return false; diff --git a/packages/bugc/src/optimizer/steps/dead-code-elimination.ts b/packages/bugc/src/optimizer/steps/dead-code-elimination.ts index 53cafaa086..5c8865b5f0 100644 --- a/packages/bugc/src/optimizer/steps/dead-code-elimination.ts +++ b/packages/bugc/src/optimizer/steps/dead-code-elimination.ts @@ -131,6 +131,11 @@ export class DeadCodeEliminationStep extends BaseOptimizationStep { this.collectValueUse(value, used); } break; + case "copy": + this.collectValueUse(inst.source, used); + this.collectValueUse(inst.offset, used); + this.collectValueUse(inst.length, used); + break; case "cast": this.collectValueUse(inst.value, used); break; @@ -170,6 +175,7 @@ export class DeadCodeEliminationStep extends BaseOptimizationStep { private hasSideEffects(inst: Ir.Instruction): boolean { switch (inst.kind) { case "write": + case "copy": case "allocate": // Allocate modifies the free memory pointer case "assert": // Assert may revert return true; From 691f5fdf3546bba40caaa4f3587bc2123c9680d8 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:40:42 -0400 Subject: [PATCH 19/23] bugc: link the changelog entries to #359 --- packages/bugc-react/CHANGELOG.md | 3 ++- packages/bugc/CHANGELOG.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/bugc-react/CHANGELOG.md b/packages/bugc-react/CHANGELOG.md index 1c7bf4e38f..7430ab6ee6 100644 --- a/packages/bugc-react/CHANGELOG.md +++ b/packages/bugc-react/CHANGELOG.md @@ -11,12 +11,12 @@ specification itself are tracked in the root - The CFG view shows a `block.prevrandao` read as `block.prevrandao` ([#363]). +- The IR view shows bugc's new `copy` instruction ([#359]). ### Changed - The IR and CFG views show each operand of a `hash` instruction, which now has a list of `values` ([#365]). -- The IR view shows bugc's new `copy` instruction ([#PR]). ## 0.1.0-preview.1 — 2026-10-03 @@ -45,5 +45,6 @@ First publication. [#299]: https://github.com/ethdebug/format/pull/299 [#300]: https://github.com/ethdebug/format/pull/300 +[#359]: https://github.com/ethdebug/format/pull/359 [#363]: https://github.com/ethdebug/format/pull/363 [#365]: https://github.com/ethdebug/format/pull/365 diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 0cd4df2b2f..9db2501c76 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -142,7 +142,7 @@ support. Changes to the specification itself are tracked in the root - A slice now copies all its bytes, as in `msg.data[4:]` or `m[1:40]`. The IR has a `copy` instruction, which compiles to `CALLDATACOPY` from calldata and `MCOPY` from memory. Before, a slice copied one word, so a - slice longer than 32 bytes held zeros after its first 32 ([#PR]). + slice longer than 32 bytes held zeros after its first 32 ([#359]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert @@ -360,6 +360,7 @@ First publication. [#356]: https://github.com/ethdebug/format/pull/356 [#357]: https://github.com/ethdebug/format/pull/357 [#358]: https://github.com/ethdebug/format/pull/358 +[#359]: https://github.com/ethdebug/format/pull/359 [#360]: https://github.com/ethdebug/format/pull/360 [#362]: https://github.com/ethdebug/format/pull/362 [#363]: https://github.com/ethdebug/format/pull/363 From 63fa54eac5ce3626f3f455644593832c8fc555ab Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:49:28 -0400 Subject: [PATCH 20/23] bugc: copy a struct read from storage into memory --- packages/bugc/CHANGELOG.md | 13 ++ packages/bugc/src/evmgen/analysis/memory.ts | 19 +++ packages/bugc/src/evmgen/behavioral.test.ts | 99 +++++++++++++++ .../irgen/generate/expressions/identifier.ts | 12 ++ .../src/irgen/generate/statements/assign.ts | 19 +-- packages/bugc/src/irgen/generate/storage.ts | 116 ++++++++++++++++++ .../bugc/src/irgen/generator.error.test.ts | 31 +++++ 7 files changed, 300 insertions(+), 9 deletions(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 9db2501c76..a1776d8288 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -143,6 +143,19 @@ support. Changes to the specification itself are tracked in the root The IR has a `copy` instruction, which compiles to `CALLDATACOPY` from calldata and `MCOPY` from memory. Before, a slice copied one word, so a slice longer than 32 bytes held zeros after its first 32 ([#359]). +- A struct read from storage into a local, as in + `let p = players[who];` or `let s = stored;`, is now a copy in memory, + as the examples' README describes: reading `p.score` reads the copy, + and a write to `p.score` changes the copy, not storage. A field that is + a struct is copied too; a struct with an array, mapping, `string` or + dynamic `bytes` field is an error. Before, at every optimization level, + the local held the struct's first storage word as if it were a memory + address, and a write to one of its fields failed code generation + ([#PR]). +- A value whose only later uses are as the address, slot or value of a + read or write, or as the base of an offset, now gets a home in memory + when code generation cannot keep it on the stack. Before, such a value + could fail code generation with "Cannot load value" ([#PR]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert diff --git a/packages/bugc/src/evmgen/analysis/memory.ts b/packages/bugc/src/evmgen/analysis/memory.ts index 0a4730bcea..d8dac0a0e7 100644 --- a/packages/bugc/src/evmgen/analysis/memory.ts +++ b/packages/bugc/src/evmgen/analysis/memory.ts @@ -374,6 +374,25 @@ function getUsedValues(inst: Ir.Instruction): Set { case "assert": addValue(inst.condition); break; + case "read": + addValue(inst.slot); + addValue(inst.offset); + addValue(inst.length); + break; + case "write": + addValue(inst.slot); + addValue(inst.offset); + addValue(inst.length); + addValue(inst.value); + break; + case "compute_offset": + addValue(inst.base); + if (Ir.Instruction.ComputeOffset.isArray(inst)) { + addValue(inst.index); + } else if (Ir.Instruction.ComputeOffset.isByte(inst)) { + addValue(inst.offset); + } + break; // Call instruction removed - calls are now block terminators } diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index a6792070c9..2262abc5b4 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -1699,6 +1699,105 @@ code { } }); + describe("struct locals copied from storage", () => { + const program = (body: string) => `name StructCopy; + +define { + struct Inner { + a: uint8; + b: uint256; + }; + struct Player { + score: uint64; + combo: int32; + active: bool; + total: uint256; + inner: Inner; + }; +} + +storage { + [0] players: mapping; + [1] out: uint256; + [2] one: Player; +} + +create { + players[0x0000000000000000000000000000000000000000].score = 10; + players[0x0000000000000000000000000000000000000000].combo = (0 as int32) - 3; + players[0x0000000000000000000000000000000000000000].active = true; + players[0x0000000000000000000000000000000000000000].total = 99; + players[0x0000000000000000000000000000000000000000].inner.a = 7; + players[0x0000000000000000000000000000000000000000].inner.b = 8; + one.score = 20; + one.total = 21; +} + +code { + let who = 0x0000000000000000000000000000000000000000; + ${body} +}`; + + const cases: Record = { + "a field in the first slot": [ + `let p = players[who]; + out = p.score;`, + 10n, + ], + "a signed field": [ + `let p = players[who]; + if (p.combo == (0 as int32) - 3) { out = 1; }`, + 1n, + ], + "a bool field": [ + `let p = players[who]; + if (p.active) { out = 1; }`, + 1n, + ], + "a field in the second slot": [ + `let p = players[who]; + out = p.total;`, + 99n, + ], + "a nested struct's fields": [ + `let p = players[who]; + out = (p.inner.a as uint256) * 1000 + p.inner.b;`, + 7008n, + ], + "a storage struct variable": [ + `let p = one; + out = p.score + p.total;`, + 41n, + ], + "a write to the copy, which storage does not see": [ + `let p = players[who]; + p.score = 50; + out = p.score * 1000 + players[who].score;`, + 50010n, + ], + "a write to storage, which the copy does not see": [ + `let p = players[who]; + players[who].score = 50; + out = p.score * 1000 + players[who].score;`, + 10050n, + ], + }; + + for (const [name, [body, expected]] of Object.entries(cases)) { + for (const level of [0, 1, 2, 3] as const) { + it(`should copy ${name} (level ${level})`, async () => { + const result = await executeProgram(program(body), { + calldata: "", + optimizationLevel: level, + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(1n)).toBe(expected); + }); + } + } + }); + describe("modulo", () => { const program = (expr: string) => `name Modulo; diff --git a/packages/bugc/src/irgen/generate/expressions/identifier.ts b/packages/bugc/src/irgen/generate/expressions/identifier.ts index 60ab5c3e48..129de58c5b 100644 --- a/packages/bugc/src/irgen/generate/expressions/identifier.ts +++ b/packages/bugc/src/irgen/generate/expressions/identifier.ts @@ -5,7 +5,10 @@ import { Severity } from "#result"; import { Error as IrgenError, ErrorMessages } from "#irgen/errors"; import { fromBugType } from "#irgen/type"; +import { Type } from "#types"; + import { Process } from "../process.js"; +import { emitStorageStructCopy } from "../storage.js"; /** * Build an identifier expression @@ -30,6 +33,15 @@ export function* buildIdentifier( ? fromBugType(storageType) : Ir.Type.Scalar.uint256; + // A struct is a copy in memory, not the slot's word + if (storageType && Type.isStruct(storageType)) { + return yield* emitStorageStructCopy( + Ir.Value.constant(BigInt(storageSlot.slot), Ir.Type.Scalar.uint256), + storageType, + expr, + ); + } + // Build storage load using new unified read instruction const tempId = yield* Process.Variables.newTemp(); yield* Process.Instructions.emit({ diff --git a/packages/bugc/src/irgen/generate/statements/assign.ts b/packages/bugc/src/irgen/generate/statements/assign.ts index 18d794359d..6350b1054c 100644 --- a/packages/bugc/src/irgen/generate/statements/assign.ts +++ b/packages/bugc/src/irgen/generate/statements/assign.ts @@ -173,15 +173,16 @@ function* assignToTarget(node: Ast.Expression, value: Ir.Value): Process { const offsetTemp = yield* Process.Variables.newTemp(); // Calculate field offset - assuming 32 bytes per field for now const fieldOffset = fieldIndex * 32; - yield* Process.Instructions.emit({ - kind: "compute_offset", - location: "memory", - base: object, - field: fieldName, - fieldOffset, - dest: offsetTemp, - operationDebug: yield* Process.Debug.forAstNode(accessNode), - } as Ir.Instruction.ComputeOffset); + yield* Process.Instructions.emit( + Ir.Instruction.ComputeOffset.field( + "memory", + object, + fieldName, + fieldOffset, + offsetTemp, + yield* Process.Debug.forAstNode(accessNode), + ), + ); // Then write to that offset yield* Process.Instructions.emit({ diff --git a/packages/bugc/src/irgen/generate/storage.ts b/packages/bugc/src/irgen/generate/storage.ts index 83cea74561..2160b4b621 100644 --- a/packages/bugc/src/irgen/generate/storage.ts +++ b/packages/bugc/src/irgen/generate/storage.ts @@ -271,6 +271,11 @@ export function* emitStorageChainLoad( } } + // A struct is a copy in memory, not the slot's word + if (currentOrigin && Type.isStruct(currentOrigin)) { + return yield* emitStorageStructCopy(currentSlot, currentOrigin, node); + } + // Check if the last access was a struct field to get packed field info let byteOffset = 0; let fieldSize = 32; // Default to full slot @@ -305,6 +310,117 @@ export function* emitStorageChainLoad( return Ir.Value.temp(loadTempId, valueType); } +/** + * Copy a struct from storage, starting at `slot`, into new memory, and + * return its address. A struct in memory has one word per field, in + * order; a struct field is the address of its own copy. + */ +export function* emitStorageStructCopy( + slot: Ir.Value, + struct: Type.Struct, + node: Ast.Node | undefined, +): Process { + const debug = node ? yield* Process.Debug.forAstNode(node) : {}; + const uint256 = Ir.Type.Scalar.uint256; + const constant = (value: bigint) => Ir.Value.constant(value, uint256); + + const address = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "allocate", + location: "memory", + size: constant(BigInt(struct.fields.size * 32)), + dest: address, + operationDebug: debug, + } as Ir.Instruction); + + let index = 0; + for (const [name, fieldType] of struct.fields) { + const layout = struct.layout.get(name); + if (!layout) { + throw new Error(`Field ${name} not found in struct ${struct.name}`); + } + + // The field's slot + let fieldSlot = slot; + if (layout.byteOffset >= 32) { + const temp = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "compute_slot", + slotKind: "field", + base: slot, + fieldOffset: layout.byteOffset, + dest: temp, + operationDebug: debug, + } as Ir.Instruction.ComputeSlot); + fieldSlot = Ir.Value.temp(temp, uint256); + } + + let value: Ir.Value; + if (Type.isStruct(fieldType)) { + value = yield* emitStorageStructCopy(fieldSlot, fieldType, node); + } else if ( + Type.isElementary(fieldType) && + !(Type.Elementary.isBytes(fieldType) && fieldType.size === undefined) && + !Type.Elementary.isString(fieldType) + ) { + const irType = fromBugType(fieldType); + const temp = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "read", + location: "storage", + slot: fieldSlot, + offset: constant(BigInt(layout.byteOffset % 32)), + length: constant(BigInt(getFieldSize(irType))), + type: irType, + dest: temp, + operationDebug: debug, + } as Ir.Instruction.Read); + value = Ir.Value.temp(temp, irType); + } else { + yield* Process.Errors.report( + new IrgenError( + `Cannot copy struct ${struct.name} from storage: its field ` + + `${name} is a ${Type.format(fieldType)}, and only value ` + + `and struct fields can be copied to memory`, + node?.loc ?? undefined, + Severity.Error, + ), + ); + value = constant(0n); + } + + // The field's word in memory + let offset = Ir.Value.temp(address, uint256); + if (index > 0) { + const temp = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit( + Ir.Instruction.ComputeOffset.field( + "memory", + offset, + name, + index * 32, + temp, + debug, + ), + ); + offset = Ir.Value.temp(temp, uint256); + } + + yield* Process.Instructions.emit({ + kind: "write", + location: "memory", + offset, + length: constant(32n), + value, + operationDebug: debug, + } as Ir.Instruction.Write); + + index++; + } + + return Ir.Value.temp(address, Ir.Type.Ref.memory()); +} + /** * Emit a storage write for an access chain */ diff --git a/packages/bugc/src/irgen/generator.error.test.ts b/packages/bugc/src/irgen/generator.error.test.ts index 7e7a1fed7d..3e9778d4ac 100644 --- a/packages/bugc/src/irgen/generator.error.test.ts +++ b/packages/bugc/src/irgen/generator.error.test.ts @@ -107,4 +107,35 @@ describe("generateModule error handling", () => { ); expect(defaultingWarnings).toHaveLength(0); }); + it("should reject a struct copy from storage with an array field", async () => { + const source = ` + name Test; + define { + struct Bag { + count: uint256; + items: array; + }; + } + storage { + [0] bag: Bag; + [1] out: uint256; + } + code { + let b = bag; + out = b.count; + } + `; + + const result = await compileTest(source); + + expect(result.success).toBe(false); + const messages = Result.findMessages(result, { + severity: Severity.Error, + }).map((d) => d.message); + expect(messages).toContainEqual( + expect.stringContaining( + "Cannot copy struct Bag from storage: its field items", + ), + ); + }); }); From 781740ed41b5ade55aaf6dffb1bd66969678d687 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 21:49:43 -0400 Subject: [PATCH 21/23] bugc: link the changelog entries to #361 --- packages/bugc/CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index a1776d8288..3795b25308 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -151,11 +151,11 @@ support. Changes to the specification itself are tracked in the root dynamic `bytes` field is an error. Before, at every optimization level, the local held the struct's first storage word as if it were a memory address, and a write to one of its fields failed code generation - ([#PR]). + ([#361]). - A value whose only later uses are as the address, slot or value of a read or write, or as the base of an offset, now gets a home in memory when code generation cannot keep it on the stack. Before, such a value - could fail code generation with "Cannot load value" ([#PR]). + could fail code generation with "Cannot load value" ([#361]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert @@ -375,6 +375,7 @@ First publication. [#358]: https://github.com/ethdebug/format/pull/358 [#359]: https://github.com/ethdebug/format/pull/359 [#360]: https://github.com/ethdebug/format/pull/360 +[#361]: https://github.com/ethdebug/format/pull/361 [#362]: https://github.com/ethdebug/format/pull/362 [#363]: https://github.com/ethdebug/format/pull/363 [#365]: https://github.com/ethdebug/format/pull/365 From 61acac449f14725d0811377abf39cb5f5e2ad76c Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 22:09:35 -0400 Subject: [PATCH 22/23] bugc: lay storage arrays out as Solidity does --- packages/bugc/CHANGELOG.md | 12 ++ .../bugc/examples/intermediate/arrays.bug | 18 +- packages/bugc/src/evmgen/behavioral.test.ts | 5 +- .../bugc/src/evmgen/constructor-array.test.ts | 4 +- .../evmgen/generation/instructions/storage.ts | 40 +++- .../src/evmgen/storage-array-layout.test.ts | 180 ++++++++++++++++ .../src/evmgen/storage-verification.test.ts | 150 ++------------ packages/bugc/src/irgen/debug/variables.ts | 192 +++++++----------- .../src/irgen/generate/expressions/array.ts | 79 ++++--- packages/bugc/src/irgen/generate/storage.ts | 182 ++++++++++------- packages/bugc/src/irgen/generator.test.ts | 5 +- packages/bugc/src/typechecker/layout.ts | 15 +- packages/bugc/src/types/index.ts | 3 + packages/bugc/src/types/storage.ts | 54 +++++ .../compiler/case-study-bug.mdx | 32 +++ 15 files changed, 576 insertions(+), 395 deletions(-) create mode 100644 packages/bugc/src/evmgen/storage-array-layout.test.ts create mode 100644 packages/bugc/src/types/storage.ts diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 3795b25308..cd880ee054 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -40,6 +40,17 @@ support. Changes to the specification itself are tracked in the root - The IR `hash` instruction now has `values`, a list, in place of `value` and `valueDebug` ([#365]). +- Storage arrays now have Solidity's layout. A fixed-size array is + inline: its elements start at its own slot, so `[0] a: array` takes slots 0 to 2, and a struct member that is a fixed-size array + takes all its slots. A dynamic array still keeps its length in its + slot and its elements from `keccak256(slot)`. In either, elements + narrower than 16 bytes now share a slot, as many as fit, from its + low-order end: an `array` takes two slots. Each storage + variable's pointer describes the same layout. Before, every array's + elements started at `keccak256(slot)`, one element to a slot, while + the pointer of a fixed-size array described it inline, and the pointer + of an array of narrow elements described them packed ([#364]). - An integer literal operand of an arithmetic or comparison operator now takes the type of the other operand when its value fits, so with `x: int8`, `x < 0`, `x == 1` and `-1 < x` compare as `int8`. A literal @@ -378,4 +389,5 @@ First publication. [#361]: https://github.com/ethdebug/format/pull/361 [#362]: https://github.com/ethdebug/format/pull/362 [#363]: https://github.com/ethdebug/format/pull/363 +[#364]: https://github.com/ethdebug/format/pull/364 [#365]: https://github.com/ethdebug/format/pull/365 diff --git a/packages/bugc/examples/intermediate/arrays.bug b/packages/bugc/examples/intermediate/arrays.bug index a70820d0e8..262fe83365 100644 --- a/packages/bugc/examples/intermediate/arrays.bug +++ b/packages/bugc/examples/intermediate/arrays.bug @@ -2,11 +2,11 @@ name ArraysAndLoops; storage { [0] numbers: array; - [1] sum: uint256; - [2] max: uint256; - [3] count: uint256; - [4] found: bool; - [5] searchValue: uint256; + [10] sum: uint256; + [11] max: uint256; + [12] count: uint256; + [13] found: bool; + [14] searchValue: uint256; } code { @@ -21,7 +21,7 @@ code { after: deploy variables: sum: - pointer: { location: storage, slot: 1 } + pointer: { location: storage, slot: 10 } value: 0 */ for (let i = 0; i < 10; i = i + 1) { @@ -78,21 +78,21 @@ code { fails: stack overflow during call - needs investigation variables: sum: - pointer: { location: storage, slot: 1 } + pointer: { location: storage, slot: 10 } value: 285 */ /*@test final-max fails: stack overflow during call - needs investigation variables: max: - pointer: { location: storage, slot: 2 } + pointer: { location: storage, slot: 11 } value: 81 */ /*@test final-count fails: stack overflow during call - needs investigation variables: count: - pointer: { location: storage, slot: 3 } + pointer: { location: storage, slot: 12 } value: 8 */ diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index 2262abc5b4..8689e8e848 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -2352,7 +2352,8 @@ code { o5 = m128[6]; }`; - // bugc gives each array element its own slot + // A fixed-size array is inline, and its narrow elements share a + // slot, from the low-order end (a[1] is its bytes 2 and 3) const arrays = `name NarrowArrays; storage { [0] a: array; [1] n: int256; [10] o0: int256; } create { n = -2 as int256; } @@ -2391,7 +2392,7 @@ code { a[1] = n as int16; o0 = a[1]; }`; optimizationLevel: level, }); expect(result.callSuccess).toBe(true); - expect(await result.getStorage(hash(pad(0n)) + 1n)).toBe(0xfffen); + expect(await result.getStorage(0n)).toBe(0xfffe0000n); expect(await result.getStorage(10n)).toBe(neg(2n)); }); } diff --git a/packages/bugc/src/evmgen/constructor-array.test.ts b/packages/bugc/src/evmgen/constructor-array.test.ts index af236f8f87..0f71ece5e1 100644 --- a/packages/bugc/src/evmgen/constructor-array.test.ts +++ b/packages/bugc/src/evmgen/constructor-array.test.ts @@ -51,11 +51,11 @@ code {} const createFunc = module.create!; const entry = createFunc.blocks.get("entry")!; - // Check that we have compute_slot instructions for array access + // A fixed-size array is inline: no keccak256 of its slot const computeSlotInstructions = entry.instructions.filter( (i) => i.kind === "compute_slot" && i.slotKind === "array", ); - expect(computeSlotInstructions.length).toBe(3); + expect(computeSlotInstructions.length).toBe(0); // Instructions are verified by checking write instructions below diff --git a/packages/bugc/src/evmgen/generation/instructions/storage.ts b/packages/bugc/src/evmgen/generation/instructions/storage.ts index 6ada855ae3..181342cce5 100644 --- a/packages/bugc/src/evmgen/generation/instructions/storage.ts +++ b/packages/bugc/src/evmgen/generation/instructions/storage.ts @@ -111,6 +111,32 @@ function signExtend( ); } +/** Whether a storage offset is the constant 0 (or absent) */ +function isZero(offset: Ir.Value | undefined): boolean { + return !offset || (offset.kind === "const" && BigInt(offset.value) === 0n); +} + +/** + * Push a storage offset in bits: `offset * 8`. A storage array's + * element that shares its slot has an offset known only at run time. + */ +function bitOffset( + offset: Ir.Value | undefined, + debug: Ir.Instruction.Debug, +): Transition { + if (!offset || offset.kind === "const") { + const bytes = offset ? BigInt(offset.value as bigint) : 0n; + return pipe() + .then(PUSHn(bytes * 8n, { debug }), { as: "shift" }) + .done(); + } + return pipe() + .then(loadValue(offset, { debug }), { as: "value" }) + .then(PUSHn(3n, { debug }), { as: "shift" }) + .then(SHL({ debug }), { as: "shift" }) + .done(); +} + /** * Storage read: SLOAD with optional partial-slot extraction */ @@ -118,10 +144,9 @@ function generateStorageRead( inst: Ir.Instruction.Read, debug: Ir.Instruction.Debug, ): Transition { - const offset = inst.offset?.kind === "const" ? inst.offset.value : 0n; const length = inst.length?.kind === "const" ? inst.length.value : 32n; - if (offset === 0n && length === 32n) { + if (isZero(inst.offset) && length === 32n) { // Full slot read - simple SLOAD return pipe() .then(loadValue(inst.slot!, { debug }), { as: "key" }) @@ -139,7 +164,7 @@ function generateStorageRead( // Shift right by offset * 8 bits: storage writes count `offset` // from the low-order end of the slot - .then(PUSHn(BigInt(offset) * 8n, { debug }), { as: "shift" }) + .then(bitOffset(inst.offset, debug), { as: "shift" }) .then(SHR({ debug }), { as: "shiftedValue" }) .then(PUSHn(1n, { debug }), { as: "b" }) @@ -405,14 +430,13 @@ function generateStorageWrite( inst: Ir.Instruction.Write, debug: Ir.Instruction.Debug, ): Transition { - const offset = inst.offset?.kind === "const" ? inst.offset.value : 0n; const length = inst.length?.kind === "const" ? inst.length.value : 32n; if (isMemoryBytes(inst.value!.type)) { return generateBytesStorageWrite(inst, debug); } - if (offset === 0n && length === 32n) { + if (isZero(inst.offset) && length === 32n) { // Full slot write - simple SSTORE return pipe() .then(loadValue(inst.value!, { debug }), { as: "value" }) @@ -437,9 +461,7 @@ function generateStorageWrite( .then(SUB({ debug }), { as: "lengthMask" }) // Shift mask to offset position - .then(PUSHn(BigInt(offset) * 8n, { debug }), { - as: "bitOffset", - }) + .then(bitOffset(inst.offset, debug), { as: "bitOffset" }) .then( rebrand<"bitOffset", "shift", "lengthMask", "value">({ 1: "shift", @@ -467,7 +489,7 @@ function generateStorageWrite( as: "a", }) .then(AND({ debug }), { as: "value" }) - .then(PUSHn(BigInt(offset) * 8n, { debug }), { as: "shift" }) + .then(bitOffset(inst.offset, debug), { as: "shift" }) .then(SHL({ debug }), { as: "shiftedValue" }) .then( diff --git a/packages/bugc/src/evmgen/storage-array-layout.test.ts b/packages/bugc/src/evmgen/storage-array-layout.test.ts new file mode 100644 index 0000000000..58da6be919 --- /dev/null +++ b/packages/bugc/src/evmgen/storage-array-layout.test.ts @@ -0,0 +1,180 @@ +/** + * Storage arrays have Solidity's layout. A fixed-size array is inline: + * its elements start at its own slot. A dynamic array keeps its length + * in its slot and its elements from keccak256(slot). Either way, an + * element narrower than 16 bytes shares a slot with its neighbors, as + * many as fit, from the low-order end; any other element starts a slot + * of its own and takes as many slots as its type needs. An array, as a + * struct member, starts a slot, and the next member starts the slot + * after it. Each storage variable's pointer must read the values the + * program wrote. + */ +import { describe, it, expect } from "vitest"; +import { keccak256 } from "ethereum-cryptography/keccak"; +import { bytesToHex } from "ethereum-cryptography/utils"; +import { createMachineState } from "@ethdebug/evm"; +import { dereference } from "@ethdebug/pointers"; +import type * as Format from "@ethdebug/format"; + +import { executeProgram } from "#test/evm/behavioral"; +import { traceLocals } from "#test/evm/locals"; + +const levels = [0, 1, 2, 3] as const; + +/** keccak256 of a slot number, as a dynamic array's first slot */ +function hashed(slot: bigint): bigint { + const word = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + word[31 - i] = Number((slot >> BigInt(8 * i)) & 0xffn); + } + return BigInt("0x" + bytesToHex(keccak256(word))); +} + +interface Case { + source: string; + /** Expected words, by slot */ + slots: [bigint, bigint][]; + /** Expected values, in order, of each variable's regions, by name */ + pointers?: Record>; +} + +const cases: Record = { + "a fixed-size array is inline": { + source: `storage { + [0] f: array; + [3] r: uint256; +} +code { f[0] = 10; f[2] = 30; r = f[0] + f[2]; }`, + slots: [ + [0n, 10n], + [1n, 0n], + [2n, 30n], + [3n, 40n], + ], + pointers: { f: { element: [10n, 0n, 30n] } }, + }, + "narrow elements share a slot, from the low-order end": { + source: `storage { + [0] a: array; + [2] r: uint256; +} +code { a[0] = 1; a[1] = 2; a[33] = 7; r = (a[1] as uint256) + (a[33] as uint256); }`, + slots: [ + [0n, 0x0201n], + [1n, 0x0700n], + [2n, 9n], + ], + pointers: { + a: { + element: Array.from({ length: 40 }, (_, i) => + i === 0 ? 1n : i === 1 ? 2n : i === 33 ? 7n : 0n, + ), + }, + }, + }, + "a fixed-size array of fixed-size arrays": { + source: `storage { + [0] m: array, 2>; + [6] r: uint256; +} +code { m[0][1] = 4; m[1][2] = 5; r = m[1][2]; }`, + slots: [ + [1n, 4n], + [5n, 5n], + [6n, 5n], + ], + pointers: { m: { element: [0n, 4n, 0n, 0n, 0n, 5n] } }, + }, + "an array in a struct takes its slots": { + source: `define { + struct S { x: uint8; arr: array; y: uint8; }; +} +storage { + [0] s: S; + [4] r: uint256; +} +code { s.x = 1; s.arr[1] = 2; s.y = 3; r = s.arr[1] + (s.y as uint256); }`, + slots: [ + [0n, 1n], + [1n, 0n], + [2n, 2n], + [3n, 3n], + [4n, 5n], + ], + pointers: { s: { x: [1n], "arr-element": [0n, 2n], y: [3n] } }, + }, + "a fixed-size array of structs": { + source: `define { + struct P { a: uint256; b: uint256; }; +} +storage { + [0] ps: array; + [4] r: uint256; +} +code { ps[1].a = 6; ps[1].b = 7; ps[0].b = 8; r = ps[1].b; }`, + slots: [ + [1n, 8n], + [2n, 6n], + [3n, 7n], + [4n, 7n], + ], + pointers: { ps: { a: [0n, 6n], b: [8n, 7n] } }, + }, + "a dynamic array's narrow elements share a slot": { + source: `storage { + [0] d: array; + [1] r: uint256; +} +code { d[0] = 3; d[1] = 5; r = d[1] as uint256; }`, + slots: [ + [hashed(0n), 0x00050003n], + [1n, 5n], + ], + }, +}; + +describe("storage array layout", () => { + for (const [name, { source, slots }] of Object.entries(cases)) { + for (const level of levels) { + it(`${name} (level ${level})`, async () => { + const result = await executeProgram(`name Layout;\n${source}`, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + for (const [slot, value] of slots) { + expect(await result.getStorage(slot), `slot ${slot}`).toBe(value); + } + }); + } + } +}); + +describe("storage array pointers", () => { + for (const [name, { source, pointers }] of Object.entries(cases)) { + if (!pointers) continue; + it(name, async () => { + const { program, executor, steps } = await traceLocals( + `name Layout;\n${source}`, + ); + const state = createMachineState(executor, { + traceStep: steps[steps.length - 1], + }); + for (const [identifier, regions] of Object.entries(pointers)) { + const variable = ( + program.context as Format.Program.Context.Variables + ).variables.find((v) => v.identifier === identifier)!; + const cursor = await dereference(variable.pointer!, { state }); + const view = await cursor.view(state); + for (const [regionName, values] of Object.entries(regions)) { + const read = await Promise.all( + view.regions + .filter((region) => region.name === regionName) + .map(async (region) => (await view.read(region)).asUint()), + ); + expect(read, `${identifier}: ${regionName}`).toEqual(values); + } + } + }); + } +}); diff --git a/packages/bugc/src/evmgen/storage-verification.test.ts b/packages/bugc/src/evmgen/storage-verification.test.ts index 304e90f961..34ea8175af 100644 --- a/packages/bugc/src/evmgen/storage-verification.test.ts +++ b/packages/bugc/src/evmgen/storage-verification.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; -import { compile } from "#compiler"; +import { executeProgram } from "#test/evm/behavioral"; -describe.skip("Storage verification (requires local Ethereum node)", () => { +describe("Storage verification", () => { it("should store array values correctly in constructor", async () => { const source = ` name ConstructorArray; @@ -19,73 +19,15 @@ describe.skip("Storage verification (requires local Ethereum node)", () => { code {} `; - const result = await compile({ to: "bytecode", source }); - expect(result.success).toBe(true); + const result = await executeProgram(source); - if (!result.success) return; - - const { create: creationBytecode } = result.value.bytecode; - expect(creationBytecode).toBeDefined(); - - // Deploy and check storage - const deployResponse = await fetch("http://localhost:8545", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_sendTransaction", - params: [ - { - from: "0xa0e20c11276b8ab803a7034d0945797afb4d060b", - data: "0x" + Buffer.from(creationBytecode!).toString("hex"), - gas: "0x100000", - }, - ], - id: 1, - }), - }); - - const deployResult = await deployResponse.json(); - const txHash = deployResult.result; - - // Get receipt to find contract address - const receiptResponse = await fetch("http://localhost:8545", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_getTransactionReceipt", - params: [txHash], - id: 2, - }), - }); - - const receipt = await receiptResponse.json(); - const contractAddress = receipt.result.contractAddress; - - // Check storage slots - const expectedValues = [ - { slot: "0x0", value: 1005 }, - { slot: "0x1", value: 1006 }, - { slot: "0x2", value: 1007 }, - ]; - - for (const { slot, value } of expectedValues) { - const storageResponse = await fetch("http://localhost:8545", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_getStorageAt", - params: [contractAddress, slot, "latest"], - id: 3, - }), - }); - - const storageResult = await storageResponse.json(); - const storedValue = BigInt(storageResult.result); - - expect(storedValue).toBe(BigInt(value)); + // A fixed-size array is inline: its elements start at its slot + for (const [slot, value] of [ + [0n, 1005n], + [1n, 1006n], + [2n, 1007n], + ]) { + expect(await result.getStorage(slot)).toBe(value); } }); @@ -108,72 +50,14 @@ describe.skip("Storage verification (requires local Ethereum node)", () => { code {} `; - const result = await compile({ to: "bytecode", source }); - expect(result.success).toBe(true); - - if (!result.success) return; - - const { create: creationBytecode } = result.value.bytecode; - - // Deploy - const deployResponse = await fetch("http://localhost:8545", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_sendTransaction", - params: [ - { - from: "0xa0e20c11276b8ab803a7034d0945797afb4d060b", - data: "0x" + Buffer.from(creationBytecode!).toString("hex"), - gas: "0x100000", - }, - ], - id: 1, - }), - }); - - const deployResult = await deployResponse.json(); - const txHash = deployResult.result; - - // Get receipt - const receiptResponse = await fetch("http://localhost:8545", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_getTransactionReceipt", - params: [txHash], - id: 2, - }), - }); - - const receipt = await receiptResponse.json(); - const contractAddress = receipt.result.contractAddress; - - // Check storage slots - const expectedValues = [ - { slot: "0x0", value: 100 }, - { slot: "0x1", value: 200 }, - { slot: "0x2", value: 300 }, - ]; - - for (const { slot, value } of expectedValues) { - const storageResponse = await fetch("http://localhost:8545", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_getStorageAt", - params: [contractAddress, slot, "latest"], - id: 3, - }), - }); - - const storageResult = await storageResponse.json(); - const storedValue = BigInt(storageResult.result); + const result = await executeProgram(source); - expect(storedValue).toBe(BigInt(value)); + for (const [slot, value] of [ + [0n, 100n], + [1n, 200n], + [2n, 300n], + ]) { + expect(await result.getStorage(slot)).toBe(value); } }); }); diff --git a/packages/bugc/src/irgen/debug/variables.ts b/packages/bugc/src/irgen/debug/variables.ts index 59162be362..7859fa2c4b 100644 --- a/packages/bugc/src/irgen/debug/variables.ts +++ b/packages/bugc/src/irgen/debug/variables.ts @@ -7,7 +7,7 @@ import * as Format from "@ethdebug/format"; import type { State } from "../generate/state.js"; import { generatePointer, type VariableLocation } from "./pointers.js"; -import { Type } from "#types"; +import { Storage, Type } from "#types"; import { convertBugType } from "./types.js"; /** @@ -52,6 +52,15 @@ function getTypeSize(bugType: Type): number { return 32; } +/** A slot plus a number of slots */ +function plus( + slot: Format.Pointer.Expression, + slots: number, +): Format.Pointer.Expression { + if (slots === 0) return slot; + return typeof slot === "number" ? slot + slots : { $sum: [slot, slots] }; +} + /** * Generate a sophisticated pointer for a storage variable based on its type * @@ -61,10 +70,11 @@ function getTypeSize(bugType: Type): number { * `prefix` is that path (e.g. "ceo-" for the members of field `ceo`). */ function generateStoragePointer( - baseSlot: number, + baseSlot: Format.Pointer.Expression, bugType: Type, byteOffset: number = 0, prefix: string = "", + depth: number = 0, ): Format.Pointer | undefined { // For structs, generate a group pointer with each field if (Type.isStruct(bugType)) { @@ -75,7 +85,7 @@ function generateStoragePointer( if (!layout) continue; const absoluteOffset = byteOffset + layout.byteOffset; - const fieldSlot = baseSlot + Math.floor(absoluteOffset / 32); + const fieldSlot = plus(baseSlot, Math.floor(absoluteOffset / 32)); const fieldOffset = absoluteOffset % 32; const fieldPointer = generateStoragePointer( @@ -83,6 +93,7 @@ function generateStoragePointer( fieldType, fieldOffset, `${prefix}${fieldName}-`, + depth, ); if (!fieldPointer) continue; @@ -100,127 +111,76 @@ function generateStoragePointer( return { group }; } - // For arrays, generate a list pointer + // For arrays, generate a list pointer, by Solidity's layout (see + // `Storage`): a fixed-size array's elements start at its slot, a + // dynamic array's at keccak256 of its slot, where its length is if (Type.isArray(bugType)) { - const elementType = bugType.element; - const elementSize = getTypeSize(elementType); - - // Check if this is a fixed-size or dynamic array - if (bugType.size !== undefined) { - // Fixed-size array: elements stored sequentially from base slot - // For fixed arrays, elements are at baseSlot + floor((i * elementSize) / 32) - // with offset (i * elementSize) % 32 - const elementSlotExpression: Format.Pointer.Expression = - elementSize >= 32 - ? // Full slots: baseSlot + i * (elementSize / 32) - { - $sum: [baseSlot, { $product: ["i", elementSize / 32] }], - } - : // Packed elements: baseSlot + floor((i * elementSize) / 32) - { - $sum: [ - baseSlot, - { - $quotient: [{ $product: ["i", elementSize] }, 32], - }, - ], - }; - - const elementPointer: Format.Pointer = { + const element = bugType.element; + // Each nested list needs its own index variable + const index = depth === 0 ? "i" : `i${depth}`; + const first: Format.Pointer.Expression = + bugType.size === undefined + ? { $keccak256: [{ $wordsized: baseSlot }] } + : baseSlot; + + let elementPointer: Format.Pointer | undefined; + const perSlot = Storage.elementsPerSlot(element); + if (perSlot > 1) { + // Elements that share a slot, from its low-order end; a pointer's + // `offset` counts from the high-order end + const size = Storage.size(element)!; + elementPointer = { name: `${prefix}element`, location: "storage", - slot: elementSlotExpression, - }; - - // Add offset for packed elements - if (elementSize < 32) { - elementPointer.offset = { - $remainder: [{ $product: ["i", elementSize] }, 32], - }; - elementPointer.length = elementSize; - } - - // Recursively handle complex element types - const refinedPointer = - Type.isStruct(elementType) || Type.isArray(elementType) - ? generateStoragePointer(0, elementType, 0, prefix) - : elementPointer; - - return { - list: { - count: bugType.size, - each: "i", - is: refinedPointer || elementPointer, + slot: { $sum: [first, { $quotient: [index, perSlot] }] }, + offset: { + $difference: [ + 32 - size, + { $product: [{ $remainder: [index, perSlot] }, size] }, + ], }, + length: size, }; } else { - // Dynamic array: length at base slot, elements at keccak256(slot) + index - // Elements start at keccak256(baseSlot) - // Note: baseSlot must be wordsized for proper 32-byte keccak256 input - const elementSlotExpression: Format.Pointer.Expression = - elementSize >= 32 - ? // Full slots: keccak256(baseSlot) + i * (elementSize / 32) - { - $sum: [ - { $keccak256: [{ $wordsized: baseSlot }] }, - { $product: ["i", elementSize / 32] }, - ], - } - : // Packed elements: keccak256(baseSlot) + floor((i * elementSize) / 32) - { - $sum: [ - { $keccak256: [{ $wordsized: baseSlot }] }, - { - $quotient: [{ $product: ["i", elementSize] }, 32], - }, - ], - }; - - const elementPointer: Format.Pointer = { - name: `${prefix}element`, - location: "storage", - slot: elementSlotExpression, - }; - - // Add offset for packed elements - if (elementSize < 32) { - elementPointer.offset = { - $remainder: [{ $product: ["i", elementSize] }, 32], - }; - elementPointer.length = elementSize; - } - - // Recursively handle complex element types - const refinedPointer = - Type.isStruct(elementType) || Type.isArray(elementType) - ? generateStoragePointer(0, elementType, 0, prefix) - : elementPointer; - - // For dynamic arrays, we use a group to declare both the length region - // and the list of elements - // Note: "array-length" avoids conflict with Array.prototype.length - const lengthRegion: Format.Pointer = { - name: `${prefix}array-length`, - location: "storage", - slot: baseSlot, - }; - if (byteOffset > 0) { - lengthRegion.offset = byteOffset; + const stride = Storage.slots(element); + elementPointer = generateStoragePointer( + { + $sum: [first, stride === 1 ? index : { $product: [index, stride] }], + }, + element, + 0, + prefix, + depth + 1, + ); + if (elementPointer && Format.Pointer.isRegion(elementPointer)) { + elementPointer = { ...elementPointer, name: `${prefix}element` }; } - - return { - group: [ - lengthRegion, - { - list: { - count: { $read: `${prefix}array-length` }, - each: "i", - is: refinedPointer || elementPointer, - }, - }, - ], - }; } + if (!elementPointer) return undefined; + + const list: Format.Pointer = { + list: { + count: + bugType.size === undefined + ? { $read: `${prefix}array-length` } + : bugType.size, + each: index, + is: elementPointer, + }, + }; + if (bugType.size !== undefined) return list; + + // Note: "array-length" avoids conflict with Array.prototype.length + return { + group: [ + { + name: `${prefix}array-length`, + location: "storage", + slot: baseSlot, + }, + list, + ], + }; } // For mappings, we can't represent them without keys diff --git a/packages/bugc/src/irgen/generate/expressions/array.ts b/packages/bugc/src/irgen/generate/expressions/array.ts index f29b911297..ab885519fe 100644 --- a/packages/bugc/src/irgen/generate/expressions/array.ts +++ b/packages/bugc/src/irgen/generate/expressions/array.ts @@ -1,9 +1,10 @@ import type * as Ast from "#ast"; import * as Ir from "#ir"; -import { Type } from "#types"; +import { Storage, Type } from "#types"; import { Process } from "../process.js"; import type { Context } from "./context.js"; import { buildExpression } from "./expression.js"; +import { emitArrayElement } from "../storage.js"; /** * Build IR for an array expression. @@ -18,60 +19,52 @@ export function* buildArray( ): Process { switch (context.kind) { case "lvalue-storage": { - // Storage array assignment - expand to individual storage writes - // First, store the array length at the base slot - const lengthValue = Ir.Value.constant( - BigInt(expr.elements.length), + // Storage array assignment - expand to individual storage writes. + // A dynamic array keeps its length in its slot; a fixed-size + // array's elements start there + const slot = Ir.Value.constant( + BigInt(context.slot), Ir.Type.Scalar.uint256, ); - yield* Process.Instructions.emit({ - kind: "write", - location: "storage", - slot: Ir.Value.constant(BigInt(context.slot), Ir.Type.Scalar.uint256), - offset: Ir.Value.constant(0n, Ir.Type.Scalar.uint256), - length: Ir.Value.constant(32n, Ir.Type.Scalar.uint256), - value: lengthValue, - operationDebug: yield* Process.Debug.forAstNode(expr), - } as Ir.Instruction.Write); + if (!Type.isArray(context.type) || context.type.size === undefined) { + yield* Process.Instructions.emit({ + kind: "write", + location: "storage", + slot, + offset: Ir.Value.constant(0n, Ir.Type.Scalar.uint256), + length: Ir.Value.constant(32n, Ir.Type.Scalar.uint256), + value: Ir.Value.constant( + BigInt(expr.elements.length), + Ir.Type.Scalar.uint256, + ), + operationDebug: yield* Process.Debug.forAstNode(expr), + } as Ir.Instruction.Write); + } + const arrayType: Type.Array = Type.isArray(context.type) + ? context.type + : Type.array(Type.Elementary.uint(256)); // Then write each element for (let i = 0; i < expr.elements.length; i++) { - // Generate the value for this element const elementValue = yield* buildExpression(expr.elements[i], { kind: "rvalue", }); - - // Generate the index value - const indexValue = Ir.Value.constant(BigInt(i), Ir.Type.Scalar.uint256); - - // Compute the first slot for the array - const firstSlotTemp = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit( - Ir.Instruction.ComputeSlot.array( - Ir.Value.constant(BigInt(context.slot), Ir.Type.Scalar.uint256), - firstSlotTemp, - yield* Process.Debug.forAstNode(expr), - ), + const element = yield* emitArrayElement( + arrayType, + slot, + Ir.Value.constant(BigInt(i), Ir.Type.Scalar.uint256), + expr, ); - - // Add the index to get the actual element slot - const slotTemp = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit({ - kind: "binary", - op: "add", - left: Ir.Value.temp(firstSlotTemp, Ir.Type.Scalar.uint256), - right: indexValue, - dest: slotTemp, - operationDebug: yield* Process.Debug.forAstNode(expr), - } as Ir.Instruction.BinaryOp); - - // Write to storage yield* Process.Instructions.emit({ kind: "write", location: "storage", - slot: Ir.Value.temp(slotTemp, Ir.Type.Scalar.uint256), - offset: Ir.Value.constant(0n, Ir.Type.Scalar.uint256), - length: Ir.Value.constant(32n, Ir.Type.Scalar.uint256), + slot: element.slot, + offset: + element.offset ?? Ir.Value.constant(0n, Ir.Type.Scalar.uint256), + length: Ir.Value.constant( + BigInt(Storage.size(arrayType.element) ?? 32), + Ir.Type.Scalar.uint256, + ), value: elementValue, operationDebug: yield* Process.Debug.forAstNode(expr.elements[i]), } as Ir.Instruction.Write); diff --git a/packages/bugc/src/irgen/generate/storage.ts b/packages/bugc/src/irgen/generate/storage.ts index 2160b4b621..03b4ad0764 100644 --- a/packages/bugc/src/irgen/generate/storage.ts +++ b/packages/bugc/src/irgen/generate/storage.ts @@ -1,7 +1,7 @@ import * as Ast from "#ast"; import * as Ir from "#ir"; import { Severity } from "#result"; -import { Type } from "#types"; +import { Storage, Type } from "#types"; import { Error as IrgenError } from "#irgen/errors"; import { fromBugType } from "#irgen/type"; @@ -152,6 +152,82 @@ export function* emitStorageVariableStore( } as Ir.Instruction.Write); } +/** + * Emit the slot of element `index` of the storage array at `slot` (see + * `Storage`), and, for an element that shares its slot, its byte offset + * from the slot's low-order end. A fixed-size array reverts on an index + * out of bounds. (A dynamic array in storage is not checked.) + */ +export function* emitArrayElement( + array: Type.Array, + slot: Ir.Value, + index: Ir.Value, + node: Ast.Node | undefined, +): Process<{ slot: Ir.Value; offset?: Ir.Value }> { + if (array.size !== undefined) { + yield* emitBoundsCheck( + "lt", + index, + Ir.Value.constant(BigInt(array.size), Ir.Type.Scalar.uint256), + node, + ); + } + + const debug = node ? yield* Process.Debug.forAstNode(node) : {}; + const binary = function* ( + op: "add" | "mul" | "div" | "mod", + left: Ir.Value, + right: Ir.Value | number, + ): Process { + const dest = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "binary", + op, + left, + right: + typeof right === "number" + ? Ir.Value.constant(BigInt(right), Ir.Type.Scalar.uint256) + : right, + dest, + operationDebug: debug, + } as Ir.Instruction.BinaryOp); + return Ir.Value.temp(dest, Ir.Type.Scalar.uint256); + }; + + // A fixed-size array's elements start at its slot; a dynamic array's + // at keccak256 of its slot + let first = slot; + if (array.size === undefined) { + const dest = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "compute_slot", + slotKind: "array", + base: slot, + dest, + operationDebug: debug, + } as Ir.Instruction.ComputeSlot); + first = Ir.Value.temp(dest, Ir.Type.Scalar.uint256); + } + + const perSlot = Storage.elementsPerSlot(array.element); + if (perSlot > 1) { + const position = yield* binary("mod", index, perSlot); + return { + slot: yield* binary("add", first, yield* binary("div", index, perSlot)), + offset: yield* binary("mul", position, Storage.size(array.element)!), + }; + } + + const stride = Storage.slots(array.element); + return { + slot: yield* binary( + "add", + first, + stride === 1 ? index : yield* binary("mul", index, stride), + ), + }; +} + /** * Emit a storage chain load */ @@ -170,9 +246,12 @@ export function* emitStorageChainLoad( // Track the Bug type for semantic information let currentOrigin = bugType; + // An array element's byte offset in its slot, if it shares the slot + let elementOffset: Ir.Value | undefined; // Process each access in the chain for (const access of chain.accesses) { + elementOffset = undefined; if (access.kind === "index" && access.key) { // For mapping/array access const tempId = yield* Process.Variables.newTemp(); @@ -193,41 +272,16 @@ export function* emitStorageChainLoad( // Update to the value type currentOrigin = currentOrigin.value; } else if (currentOrigin && Type.isArray(currentOrigin)) { - // A fixed-size array reverts on an index out of bounds. (A - // dynamic array in storage grows when written past its end.) - if (currentOrigin.size !== undefined) { - yield* emitBoundsCheck( - "lt", - access.key, - Ir.Value.constant( - BigInt(currentOrigin.size), - Ir.Type.Scalar.uint256, - ), - node, - ); - } - - // Array access - first compute the array's first slot (hash of base) - const firstSlotTempId = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit({ - kind: "compute_slot", - slotKind: "array", - base: currentSlot, - dest: firstSlotTempId, - operationDebug: node ? yield* Process.Debug.forAstNode(node) : {}, - } as Ir.Instruction.ComputeSlot); - - // Then add the index to get the actual element slot - yield* Process.Instructions.emit({ - kind: "binary", - op: "add", - left: Ir.Value.temp(firstSlotTempId, Ir.Type.Scalar.uint256), - right: access.key, - dest: tempId, - operationDebug: node ? yield* Process.Debug.forAstNode(node) : {}, - } as Ir.Instruction.BinaryOp); - // Update to the element type + const element = yield* emitArrayElement( + currentOrigin, + currentSlot, + access.key, + node, + ); + elementOffset = element.offset; + currentSlot = element.slot; currentOrigin = currentOrigin.element; + continue; } currentSlot = Ir.Value.temp(tempId, Ir.Type.Scalar.uint256); @@ -294,13 +348,19 @@ export function* emitStorageChainLoad( } } + if (elementOffset && currentOrigin) { + fieldSize = Storage.size(currentOrigin) ?? 32; + } + // Generate the final read instruction using new unified format const loadTempId = yield* Process.Variables.newTemp(); yield* Process.Instructions.emit({ kind: "read", location: "storage", slot: currentSlot, - offset: Ir.Value.constant(BigInt(byteOffset), Ir.Type.Scalar.uint256), + offset: + elementOffset ?? + Ir.Value.constant(BigInt(byteOffset), Ir.Type.Scalar.uint256), length: Ir.Value.constant(BigInt(fieldSize), Ir.Type.Scalar.uint256), type: valueType, dest: loadTempId, @@ -444,9 +504,12 @@ export function* emitStorageChainStore( Ir.Type.Scalar.uint256, ); let currentOrigin = bugType; + // An array element's byte offset in its slot, if it shares the slot + let elementOffset: Ir.Value | undefined; // Process each access in the chain for (const access of chain.accesses) { + elementOffset = undefined; if (access.kind === "index" && access.key) { // For mapping/array access if (currentOrigin && Type.isMapping(currentOrigin)) { @@ -465,41 +528,14 @@ export function* emitStorageChainStore( currentSlot = Ir.Value.temp(slotTemp, Ir.Type.Scalar.uint256); currentOrigin = currentOrigin.value; } else if (currentOrigin && Type.isArray(currentOrigin)) { - // A fixed-size array reverts on an index out of bounds. (A - // dynamic array in storage grows when written past its end.) - if (currentOrigin.size !== undefined) { - yield* emitBoundsCheck( - "lt", - access.key, - Ir.Value.constant( - BigInt(currentOrigin.size), - Ir.Type.Scalar.uint256, - ), - node, - ); - } - - // Array access - first compute the array's first slot (hash of base) - const firstSlotTemp = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit({ - kind: "compute_slot", - slotKind: "array", - base: currentSlot, - dest: firstSlotTemp, - operationDebug: node ? yield* Process.Debug.forAstNode(node) : {}, - }); - - // Then add the index to get the actual element slot - const slotTemp = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit({ - kind: "binary", - op: "add", - left: Ir.Value.temp(firstSlotTemp, Ir.Type.Scalar.uint256), - right: access.key, - dest: slotTemp, - operationDebug: node ? yield* Process.Debug.forAstNode(node) : {}, - }); - currentSlot = Ir.Value.temp(slotTemp, Ir.Type.Scalar.uint256); + const element = yield* emitArrayElement( + currentOrigin, + currentSlot, + access.key, + node, + ); + elementOffset = element.offset; + currentSlot = element.slot; currentOrigin = currentOrigin.element; } } else if (access.kind === "member" && access.fieldName) { @@ -574,7 +610,9 @@ export function* emitStorageChainStore( kind: "write", location: "storage", slot: currentSlot, - offset: Ir.Value.constant(BigInt(byteOffset), Ir.Type.Scalar.uint256), + offset: + elementOffset ?? + Ir.Value.constant(BigInt(byteOffset), Ir.Type.Scalar.uint256), length: Ir.Value.constant(BigInt(actualFieldSize), Ir.Type.Scalar.uint256), value, operationDebug: node ? yield* Process.Debug.forAstNode(node) : {}, diff --git a/packages/bugc/src/irgen/generator.test.ts b/packages/bugc/src/irgen/generator.test.ts index b9bd371f53..7b31bc7946 100644 --- a/packages/bugc/src/irgen/generator.test.ts +++ b/packages/bugc/src/irgen/generator.test.ts @@ -736,8 +736,9 @@ describe("generateModule", () => { } expect(hasMappingSlot).toBe(true); - // Both fixed and dynamic arrays now use compute_slot with kind="array" for proper storage layout - expect(hasArraySlot).toBe(true); + // A fixed-size array is inline: its elements start at its own + // slot, with no keccak256 of it + expect(hasArraySlot).toBe(false); // We now generate "add" instructions to compute array element slots expect(hasBinaryAdd).toBe(true); expect(hasStorageDynamic).toBe(true); diff --git a/packages/bugc/src/typechecker/layout.ts b/packages/bugc/src/typechecker/layout.ts index 428e44b7a7..7719f85b3a 100644 --- a/packages/bugc/src/typechecker/layout.ts +++ b/packages/bugc/src/typechecker/layout.ts @@ -1,4 +1,4 @@ -import { Type } from "../types/index.js"; +import { Storage, Type } from "../types/index.js"; /** * Compute storage layout for a struct's fields. @@ -25,13 +25,14 @@ export function computeStructLayout( currentSlotUsed = 0; } - layout.set(fieldName, { - byteOffset: currentSlotOffset, - size: SLOT_SIZE, // Dynamic types use full slot for reference - }); + // A fixed-size array is inline, so it takes all its slots + const size = Type.isArray(fieldType) + ? Storage.slots(fieldType) * SLOT_SIZE + : SLOT_SIZE; + layout.set(fieldName, { byteOffset: currentSlotOffset, size }); - // Move to next slot - currentSlotOffset += SLOT_SIZE; + // Move to the slot after it + currentSlotOffset += size; currentSlotUsed = 0; } else { // For non-dynamic types, try to pack them diff --git a/packages/bugc/src/types/index.ts b/packages/bugc/src/types/index.ts index 9a1e43df9d..ad4bf0205e 100644 --- a/packages/bugc/src/types/index.ts +++ b/packages/bugc/src/types/index.ts @@ -16,5 +16,8 @@ export { mergeBindings, } from "./spec.js"; +// Storage layout +export * as Storage from "./storage.js"; + // Export analysis tools export * as Analysis from "./analysis/index.js"; diff --git a/packages/bugc/src/types/storage.ts b/packages/bugc/src/types/storage.ts new file mode 100644 index 0000000000..a7bcfd9e7a --- /dev/null +++ b/packages/bugc/src/types/storage.ts @@ -0,0 +1,54 @@ +/** + * Storage layout, as Solidity lays storage out. + * + * A value narrower than a slot takes only its own bytes, and values + * that fit share a slot, from its low-order end. An array, a struct, a + * mapping, a string or dynamic bytes starts a slot of its own, and what + * follows it starts the next slot. A fixed-size array is inline: its + * elements start at its own slot. A dynamic array keeps its length in + * its slot, and its elements start at keccak256(slot). + */ +import { Type } from "./spec.js"; + +/** The bytes a value of an elementary type takes, if it has a size */ +export function size(type: Type): number | undefined { + if (!Type.isElementary(type)) return undefined; + switch (type.kind) { + case "uint": + case "int": + return Math.ceil(type.bits / 8); + case "address": + return 20; + case "bool": + return 1; + case "bytes": + return type.size; + default: + return undefined; + } +} + +/** How many elements of an array of `element` share each slot */ +export function elementsPerSlot(element: Type): number { + const bytes = size(element); + return bytes === undefined ? 1 : Math.floor(32 / bytes); +} + +/** How many slots a value of the type takes */ +export function slots(type: Type): number { + if (Type.isArray(type)) { + if (type.size === undefined) return 1; + const perSlot = elementsPerSlot(type.element); + return perSlot > 1 + ? Math.ceil(type.size / perSlot) + : type.size * slots(type.element); + } + if (Type.isStruct(type)) { + let end = 0; + for (const { byteOffset, size } of type.layout.values()) { + end = Math.max(end, byteOffset + size); + } + return Math.max(1, Math.ceil(end / 32)); + } + return 1; +} diff --git a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx index 749054fec2..519ec7a507 100644 --- a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx +++ b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx @@ -230,6 +230,38 @@ For structs, BUG generates group pointers with field offsets: }`} +### Storage arrays + +BUG lays storage arrays out as Solidity does. A fixed-size array is +inline: its elements start at its own slot, and a struct member or the +next storage variable must leave room for all of its slots. A dynamic +array keeps its length in its slot, and its elements start at +`keccak256(slot)`. Either way, elements narrower than 16 bytes share a +slot, as many as fit, from its low-order end; any other element starts +a slot of its own. + + + {`{ + "list": { + "count": 40, + "each": "i", + "is": { + "name": "element", + "location": "storage", + "slot": { "$sum": [0, { "$quotient": ["i", 32] }] }, + "offset": { + "$difference": [31, { "$product": [{ "$remainder": ["i", 32] }, 1] }] + }, + "length": 1 + } + } +}`} + + ### Dynamic arrays For dynamic arrays, BUG generates pointers with keccak256 expressions: From 0880a4f7f10fcbe8b1b6f4204123e8040f85cfce Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 22:16:57 -0400 Subject: [PATCH 23/23] bugc: add push to dynamic arrays in storage --- packages/bugc/CHANGELOG.md | 13 ++ .../bugc/examples/advanced/nested-arrays.bug | 13 +- .../src/evmgen/storage-array-layout.test.ts | 3 +- .../src/evmgen/storage-array-push.test.ts | 172 ++++++++++++++++++ .../src/irgen/generate/expressions/access.ts | 8 + .../src/irgen/generate/expressions/array.ts | 2 + .../src/irgen/generate/expressions/call.ts | 87 ++++++++- packages/bugc/src/irgen/generate/storage.ts | 39 +++- packages/bugc/src/typechecker/expressions.ts | 91 +++++++++ .../compiler/case-study-bug.mdx | 17 +- 10 files changed, 424 insertions(+), 21 deletions(-) create mode 100644 packages/bugc/src/evmgen/storage-array-push.test.ts diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index cd880ee054..22614550ae 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -19,6 +19,11 @@ support. Changes to the specification itself are tracked in the root cast it to `bytes32` to match Solidity. A single dynamic `bytes` or `string` argument still hashes its data, and must be the only argument. The optimizer folds a hash of constant words at levels 1 to 3 ([#365]). +- `push` on a dynamic array in storage, as in Solidity: `a.push(v)` + writes `v` at index `a.length` and adds one to the length, and + `a.push()` adds a zero element, as for an array of arrays. `a` may be + in a mapping or a struct. A push has no value. On an array in memory + or a fixed-size array, it is a type error ([#366]). - The `%` operator, with the precedence of `*` and `/`. It compiles to the EVM's `MOD` (`SMOD` for signed operands), so `x % 0` is `0`, as `x / 0` is ([#321]). @@ -40,6 +45,11 @@ support. Changes to the specification itself are tracked in the root - The IR `hash` instruction now has `values`, a list, in place of `value` and `valueDebug` ([#365]). +- An index into a dynamic array in storage now reverts with Solidity's + `Panic(0x32)` unless it is less than the array's length, for reads and + writes, as an index into an array in memory does. Before, a write past + the end wrote there and left the length as it was, so the array's + pointer did not show the element ([#366]). - Storage arrays now have Solidity's layout. A fixed-size array is inline: its elements start at its own slot, so `[0] a: array` takes slots 0 to 2, and a struct member that is a fixed-size array @@ -102,6 +112,8 @@ support. Changes to the specification itself are tracked in the root ### Fixed +- `a.length` on a dynamic array in storage now reads the length in the + array's slot. Before, it did not read that slot ([#366]). - At optimization levels 2 and 3, an inlined body's control flow now has a `transform: ["inline"]` context: the branch of an `if`, the jumps between the body's blocks, each block's `JUMPDEST`, and the jump that @@ -391,3 +403,4 @@ First publication. [#363]: https://github.com/ethdebug/format/pull/363 [#364]: https://github.com/ethdebug/format/pull/364 [#365]: https://github.com/ethdebug/format/pull/365 +[#366]: https://github.com/ethdebug/format/pull/366 diff --git a/packages/bugc/examples/advanced/nested-arrays.bug b/packages/bugc/examples/advanced/nested-arrays.bug index 8900faf7c4..72007b70ba 100644 --- a/packages/bugc/examples/advanced/nested-arrays.bug +++ b/packages/bugc/examples/advanced/nested-arrays.bug @@ -3,7 +3,7 @@ name NestedArrays; // Example 7: Nested Arrays // // Expected IR: -// // First dimension: matrix[i] +// // First dimension: matrix[i], checked against matrix's length // %outer_base = compute_slot kind="array", base=15 // %inner_array_slot = add %outer_base, %i // @@ -23,8 +23,15 @@ storage { } code { - let i = 2; - let j = 5; + // An index must be below the length, so push the rows and elements + // first: matrix[1] gets two elements + matrix.push(); + matrix.push(); + matrix[1].push(0); + matrix[1].push(0); + + let i = 1; + let j = 1; let value = 999; matrix[i][j] = value; let elem = matrix[i][j]; diff --git a/packages/bugc/src/evmgen/storage-array-layout.test.ts b/packages/bugc/src/evmgen/storage-array-layout.test.ts index 58da6be919..70f7889dee 100644 --- a/packages/bugc/src/evmgen/storage-array-layout.test.ts +++ b/packages/bugc/src/evmgen/storage-array-layout.test.ts @@ -125,8 +125,9 @@ code { ps[1].a = 6; ps[1].b = 7; ps[0].b = 8; r = ps[1].b; }`, [0] d: array; [1] r: uint256; } -code { d[0] = 3; d[1] = 5; r = d[1] as uint256; }`, +code { d.push(3); d.push(5); r = d[1] as uint256; }`, slots: [ + [0n, 2n], [hashed(0n), 0x00050003n], [1n, 5n], ], diff --git a/packages/bugc/src/evmgen/storage-array-push.test.ts b/packages/bugc/src/evmgen/storage-array-push.test.ts new file mode 100644 index 0000000000..4b396e8a96 --- /dev/null +++ b/packages/bugc/src/evmgen/storage-array-push.test.ts @@ -0,0 +1,172 @@ +/** + * `push` on a dynamic array in storage, as in Solidity: `a.push(v)` + * writes `v` at index `a.length`, then adds one to the length; + * `a.push()` adds a zero element. An index at or past the length + * reverts with Panic(0x32), for reads and writes, as in memory. + */ +import { describe, it, expect } from "vitest"; +import { keccak256 } from "ethereum-cryptography/keccak"; +import { bytesToHex } from "ethereum-cryptography/utils"; +import { createMachineState } from "@ethdebug/evm"; +import { dereference } from "@ethdebug/pointers"; +import type * as Format from "@ethdebug/format"; + +import { compile } from "#compiler"; +import { executeProgram } from "#test/evm/behavioral"; +import { traceLocals } from "#test/evm/locals"; + +const levels = [0, 1, 2, 3] as const; + +const panic32 = "4e487b71" + 0x32n.toString(16).padStart(64, "0"); + +function word(value: bigint): Uint8Array { + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + bytes[31 - i] = Number((value >> BigInt(8 * i)) & 0xffn); + } + return bytes; +} + +/** keccak256 of words, as a dynamic array's first slot */ +function hashed(...values: bigint[]): bigint { + const data = new Uint8Array(32 * values.length); + values.forEach((value, i) => data.set(word(value), 32 * i)); + return BigInt("0x" + bytesToHex(keccak256(data))); +} + +const passes: Record = { + "push values and read them back": [ + `storage { [0] h: array; [1] r: uint256; } +code { h.push(5); h.push(7); r = h[1] + h.length; }`, + [ + [0n, 2n], + [hashed(0n), 5n], + [hashed(0n) + 1n, 7n], + [1n, 9n], + ], + ], + "push narrow elements into one slot": [ + `storage { [0] h: array; } +code { h.push(1); h.push(2); }`, + [ + [0n, 2n], + [hashed(0n), 0x0201n], + ], + ], + "push an empty element, then into it": [ + `storage { [0] m: array>; [1] r: uint256; } +code { m.push(); m[0].push(4); r = m[0][0] + m.length + m[0].length; }`, + [ + [0n, 1n], + [hashed(0n), 1n], + [hashed(hashed(0n)), 4n], + [1n, 6n], + ], + ], + "push into a mapping's array": [ + `storage { [0] q: mapping>; [1] r: uint256; } +code { q[3].push(9); r = q[3][0]; }`, + [ + [hashed(3n, 0n), 1n], + [hashed(hashed(3n, 0n)), 9n], + [1n, 9n], + ], + ], + "push into a struct's array": [ + `define { struct S { n: uint8; xs: array; }; } +storage { [0] s: S; [2] r: uint256; } +code { s.n = 1; s.xs.push(6); r = s.xs[0]; }`, + [ + [0n, 1n], + [1n, 1n], + [hashed(1n), 6n], + [2n, 6n], + ], + ], + "write an element below the length": [ + `storage { [0] h: array; } +code { h.push(1); h.push(2); h[1] = 3; }`, + [[hashed(0n) + 1n, 3n]], + ], +}; + +const reverts: Record = { + "write at the length": `storage { [0] h: array; } +code { h.push(1); h[1] = 2; }`, + "read an empty array": `storage { [0] h: array; [1] r: uint256; } +code { r = h[0]; }`, + "write into an empty inner array": `storage { [0] m: array>; } +code { m.push(); m[0][0] = 1; }`, +}; + +describe("push on a storage array", () => { + for (const [name, [body, slots]] of Object.entries(passes)) { + for (const level of levels) { + it(`should ${name} (level ${level})`, async () => { + const result = await executeProgram(`name Push;\n${body}`, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + for (const [slot, value] of slots) { + expect(await result.getStorage(slot), `slot ${slot}`).toBe(value); + } + }); + } + } + + for (const [name, body] of Object.entries(reverts)) { + for (const level of levels) { + it(`should revert: ${name} (level ${level})`, async () => { + const result = await executeProgram(`name Push;\n${body}`, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(false); + expect(bytesToHex(result.returnValue)).toBe(panic32); + }); + } + } + + it("gives the pushed elements to the array's pointer", async () => { + const { program, executor, steps } = await traceLocals( + `name Push;\nstorage { [0] h: array; } +code { h.push(5); h.push(7); h.push(9); }`, + ); + const state = createMachineState(executor, { + traceStep: steps[steps.length - 1], + }); + const variable = ( + program.context as Format.Program.Context.Variables + ).variables.find((v) => v.identifier === "h")!; + const cursor = await dereference(variable.pointer!, { state }); + const view = await cursor.view(state); + const read = await Promise.all( + view.regions + .filter((region) => region.name === "element") + .map(async (region) => (await view.read(region)).asUint()), + ); + expect(read).toEqual([5n, 7n, 9n]); + }); +}); + +describe("push type errors", () => { + const errors: Record = { + "push on a fixed-size array": `storage { [0] f: array; } +code { f.push(1); }`, + "push on a memory array": `code { let a: array = [1, 2]; a.push(3); }`, + "push a value of the wrong type": `storage { [0] h: array; } +code { h.push(true); }`, + "push two values": `storage { [0] h: array; } +code { h.push(1, 2); }`, + }; + for (const [name, body] of Object.entries(errors)) { + it(`rejects ${name}`, async () => { + const result = await compile({ + to: "bytecode", + source: `name Push;\n${body}`, + }); + expect(result.success).toBe(false); + }); + } +}); diff --git a/packages/bugc/src/irgen/generate/expressions/access.ts b/packages/bugc/src/irgen/generate/expressions/access.ts index 00c5bc1368..c89f7aa9e2 100644 --- a/packages/bugc/src/irgen/generate/expressions/access.ts +++ b/packages/bugc/src/irgen/generate/expressions/access.ts @@ -110,6 +110,14 @@ const makeBuildMemberAccess = ( return Ir.Value.temp(tempId, resultType); } + // A dynamic array in storage keeps its length in its slot + if (Type.isArray(objectType)) { + const chain = yield* findStorageAccessChain(expr.object); + if (chain) { + return yield* emitStorageChainLoad(chain, resultType, expr); + } + } + // For dynamic arrays/bytes/strings, emit length instruction const object = yield* buildExpression(expr.object, { kind: "rvalue" }); yield* Process.Instructions.emit({ diff --git a/packages/bugc/src/irgen/generate/expressions/array.ts b/packages/bugc/src/irgen/generate/expressions/array.ts index ab885519fe..5076a07f2b 100644 --- a/packages/bugc/src/irgen/generate/expressions/array.ts +++ b/packages/bugc/src/irgen/generate/expressions/array.ts @@ -54,6 +54,8 @@ export function* buildArray( slot, Ir.Value.constant(BigInt(i), Ir.Type.Scalar.uint256), expr, + // Each index is below the literal's length + false, ); yield* Process.Instructions.emit({ kind: "write", diff --git a/packages/bugc/src/irgen/generate/expressions/call.ts b/packages/bugc/src/irgen/generate/expressions/call.ts index 2e6db74663..7ce1bfc88d 100644 --- a/packages/bugc/src/irgen/generate/expressions/call.ts +++ b/packages/bugc/src/irgen/generate/expressions/call.ts @@ -1,4 +1,4 @@ -import type * as Ast from "#ast"; +import * as Ast from "#ast"; import * as Ir from "#ir"; import { Severity } from "#result"; import { Type } from "#types"; @@ -7,6 +7,12 @@ import { Error as IrgenError } from "#irgen/errors"; import { Process } from "../process.js"; import type { Context } from "./context.js"; import { fromBugType } from "#irgen/type"; +import { + type StorageAccessChain, + findStorageAccessChain, + emitStorageChainLoad, + emitStorageChainStore, +} from "../storage.js"; /** * Build a call expression @@ -21,6 +27,15 @@ export const makeBuildCall = ( expr: Ast.Expression.Call, _context: Context, ): Process { + // `a.push(v)` or `a.push()` on a dynamic array in storage + if ( + Ast.Expression.isAccess(expr.callee) && + Ast.Expression.Access.isMember(expr.callee) && + expr.callee.property === "push" + ) { + return yield* buildPush(expr, expr.callee, buildExpression); + } + // Check if this is a built-in function call if ( expr.callee.kind === "expression:identifier" && @@ -124,3 +139,73 @@ export const makeBuildCall = ( ); return Ir.Value.constant(0n, Ir.Type.Scalar.uint256); }; + +/** + * Push onto a dynamic array in storage, as Solidity does: write the + * value (if any) at index `length`, then store `length + 1`. With no + * value, the new element is zero: nothing can have written past the + * length. + */ +function* buildPush( + expr: Ast.Expression.Call, + callee: Ast.Expression.Access.Member, + buildExpression: ( + node: Ast.Expression, + context: Context, + ) => Process, +): Process { + const marker = Ir.Value.constant(0n, Ir.Type.Scalar.uint256); + const value = + expr.arguments.length > 0 + ? yield* buildExpression(expr.arguments[0], { kind: "rvalue" }) + : undefined; + + const chain = yield* findStorageAccessChain(callee.object); + if (!chain) { + yield* Process.Errors.report( + new IrgenError( + "push needs a dynamic array in storage", + expr.loc ?? undefined, + Severity.Error, + ), + ); + return marker; + } + // Each use of the chain gets its own copy of its accesses + const copy = ( + extra: StorageAccessChain["accesses"] = [], + ): StorageAccessChain => ({ + slot: chain.slot, + accesses: [...chain.accesses.map((access) => ({ ...access })), ...extra], + }); + + const length = yield* emitStorageChainLoad( + copy(), + Ir.Type.Scalar.uint256, + expr, + ); + if (value) { + yield* emitStorageChainStore( + copy([{ kind: "index", key: length, unchecked: true }]), + value, + expr, + ); + } + + const next = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "binary", + op: "add", + left: length, + right: Ir.Value.constant(1n, Ir.Type.Scalar.uint256), + dest: next, + operationDebug: yield* Process.Debug.forAstNode(expr), + } as Ir.Instruction.BinaryOp); + yield* emitStorageChainStore( + copy(), + Ir.Value.temp(next, Ir.Type.Scalar.uint256), + expr, + ); + + return marker; +} diff --git a/packages/bugc/src/irgen/generate/storage.ts b/packages/bugc/src/irgen/generate/storage.ts index 03b4ad0764..e1ab2cac1f 100644 --- a/packages/bugc/src/irgen/generate/storage.ts +++ b/packages/bugc/src/irgen/generate/storage.ts @@ -24,6 +24,8 @@ export interface StorageAccessChain { accesses: Array<{ kind: "index" | "member"; key?: Ir.Value; + /** An index that is not checked against the length (a push's) */ + unchecked?: boolean; fieldName?: string; fieldOffset?: number; fieldType?: Ir.Type; @@ -155,25 +157,40 @@ export function* emitStorageVariableStore( /** * Emit the slot of element `index` of the storage array at `slot` (see * `Storage`), and, for an element that shares its slot, its byte offset - * from the slot's low-order end. A fixed-size array reverts on an index - * out of bounds. (A dynamic array in storage is not checked.) + * from the slot's low-order end. An index at or past the array's length + * reverts, unless it is not `checked` (as a push's, which is the length). */ export function* emitArrayElement( array: Type.Array, slot: Ir.Value, index: Ir.Value, node: Ast.Node | undefined, + checked: boolean = true, ): Process<{ slot: Ir.Value; offset?: Ir.Value }> { - if (array.size !== undefined) { - yield* emitBoundsCheck( - "lt", - index, - Ir.Value.constant(BigInt(array.size), Ir.Type.Scalar.uint256), - node, + const debug = node ? yield* Process.Debug.forAstNode(node) : {}; + + if (checked) { + // A dynamic array's length is the word in its slot + let length = Ir.Value.constant( + BigInt(array.size ?? 0), + Ir.Type.Scalar.uint256, ); + if (array.size === undefined) { + const dest = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "read", + location: "storage", + slot, + offset: Ir.Value.constant(0n, Ir.Type.Scalar.uint256), + length: Ir.Value.constant(32n, Ir.Type.Scalar.uint256), + type: Ir.Type.Scalar.uint256, + dest, + operationDebug: debug, + } as Ir.Instruction.Read); + length = Ir.Value.temp(dest, Ir.Type.Scalar.uint256); + } + yield* emitBoundsCheck("lt", index, length, node); } - - const debug = node ? yield* Process.Debug.forAstNode(node) : {}; const binary = function* ( op: "add" | "mul" | "div" | "mod", left: Ir.Value, @@ -277,6 +294,7 @@ export function* emitStorageChainLoad( currentSlot, access.key, node, + !access.unchecked, ); elementOffset = element.offset; currentSlot = element.slot; @@ -533,6 +551,7 @@ export function* emitStorageChainStore( currentSlot, access.key, node, + !access.unchecked, ); elementOffset = element.offset; currentSlot = element.slot; diff --git a/packages/bugc/src/typechecker/expressions.ts b/packages/bugc/src/typechecker/expressions.ts index 0c0b928ae1..6fb6317828 100644 --- a/packages/bugc/src/typechecker/expressions.ts +++ b/packages/bugc/src/typechecker/expressions.ts @@ -563,6 +563,15 @@ export const expressionChecker: Pick, "expression"> = { let symbols = context.symbols; let bindings = context.bindings; + // `a.push(v)` or `a.push()` on a dynamic array in storage + if ( + Ast.Expression.isAccess(node.callee) && + Ast.Expression.Access.isMember(node.callee) && + node.callee.property === "push" + ) { + return checkPush(node, node.callee, context); + } + // Check if this is a built-in function call if (node.callee.kind === "expression:identifier") { const functionName = node.callee.name; @@ -1237,3 +1246,85 @@ function indexTypeError(what: string, type: Type): string | undefined { } return undefined; } + +/** + * Type check `a.push(v)` (or `a.push()`, which adds a zero element), as + * in Solidity: `a` must be a dynamic array in storage, and `v` must be + * assignable to its element type. A push has no value. + */ +function checkPush( + node: Ast.Expression.Call, + callee: Ast.Expression.Access.Member, + context: Context, +): Report { + const errors: TypeError[] = []; + const objectResult = Ast.visit(context.visitor, callee.object, context); + let { nodeTypes, symbols, bindings } = objectResult; + errors.push(...objectResult.errors); + const report = () => ({ symbols, nodeTypes, bindings, errors }); + + const objectType = objectResult.type; + if (!objectType) return report(); + + const fail = (message: string, code: ErrorCode, at: Ast.Node = node) => { + errors.push( + new TypeError(message, at.loc || undefined, undefined, undefined, code), + ); + return report(); + }; + + // The array's root: the storage variable it is in, if any + let root: Ast.Expression = callee.object; + while (Ast.Expression.isAccess(root)) { + root = (root as Ast.Expression.Access.Member | Ast.Expression.Access.Index) + .object; + } + const inStorage = + Ast.Expression.isIdentifier(root) && + symbols.lookup(root.name)?.location === "storage"; + + if ( + !Type.isArray(objectType) || + objectType.size !== undefined || + !inStorage + ) { + return fail( + `push needs a dynamic array in storage, not ${Type.format(objectType)}`, + ErrorCode.INVALID_OPERATION, + ); + } + + if (node.arguments.length > 1) { + return fail( + `push takes at most 1 argument, but got ${node.arguments.length}`, + ErrorCode.INVALID_ARGUMENT_COUNT, + ); + } + + if (node.arguments.length === 1) { + const argument = node.arguments[0]; + const argResult = Ast.visit(context.visitor, argument, { + ...context, + nodeTypes, + symbols, + bindings, + }); + ({ nodeTypes, symbols, bindings } = argResult); + errors.push(...argResult.errors); + if (argResult.type && !isAssignable(objectType.element, argResult.type)) { + return fail( + ErrorMessages.TYPE_MISMATCH( + Type.format(objectType.element), + Type.format(argResult.type), + ), + ErrorCode.TYPE_MISMATCH, + argument, + ); + } + } + + const type = Type.failure("void function"); + nodeTypes = new Map(nodeTypes); + nodeTypes.set(node.id, type); + return { type, symbols, nodeTypes, bindings, errors }; +} diff --git a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx index 519ec7a507..67cfcfdb63 100644 --- a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx +++ b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx @@ -108,21 +108,26 @@ so `x + 1` with `x: uint8` is a `uint8` and wraps at 8 bits. ### Bounds -An index out of bounds reverts, as in Solidity. `a[i]` on an array or on -dynamic `bytes` in memory, or on a fixed-size array in storage, reverts -unless `i` is less than the length; this holds for reads and writes, and -at each level of a nested array. A slice `b[s:e]` reverts unless +An index out of bounds reverts, as in Solidity. `a[i]` on an array (in +memory or in storage) or on dynamic `bytes` in memory reverts unless `i` +is less than the length; this holds for reads and writes, and at each +level of a nested array. A slice `b[s:e]` reverts unless `s <= e <= b.length`. An index or slice bound must be an unsigned integer; cast a signed one, as in `a[i as uint256]`. The revert data is Solidity's `Panic(0x32)`, and the `REVERT` carries a `revert` context with `panic: 50` and a pointer to -that data. A dynamic array in storage is not checked: BUG has no `push`, -so a write past the end is how such an array grows. +that data. +A dynamic array in storage grows by `push`, as in Solidity: `a.push(v)` +writes `v` at index `a.length` and adds one to the length, and +`a.push()` adds a zero element (as for an array of arrays, before +pushing onto the new inner array). A push has no value. ```bug let a: array = [1, 2, 3]; a[2] = 30; // ok a[3] = 40; // reverts with Panic(0x32) msg.data[0:4]; // reverts if the calldata is shorter +history.push(7); // history: array in storage +history[history.length] = 8; // reverts with Panic(0x32) ``` ## Compilation pipeline