diff --git a/packages/bugc/src/evmgen/debug/local-variables.resolve.test.ts b/packages/bugc/src/evmgen/debug/local-variables.resolve.test.ts new file mode 100644 index 0000000000..c62c0dd535 --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.resolve.test.ts @@ -0,0 +1,142 @@ +/** + * Runtime-correctness tests for local-variable pointers. + * + * Shape tests verify what we emit; these verify it RESOLVES. Each + * emitted pointer is resolved against a real EVM trace's machine + * memory and must yield the variable's actual known value — proving + * the pointer points at the bytes the variable really occupies. + * + * Covers both location kinds: + * - frame-relative (`mem[ mem[FRAME_POINTER] + delta ]`) for + * function locals, and + * - static absolute (`mem[offset]`) for main/create locals. + */ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import { Executor } from "@ethdebug/evm"; +import { bytesToHex } from "ethereum-cryptography/utils"; +import type * as Format from "@ethdebug/format"; + +/** Read a 32-byte big-endian word from a memory snapshot. */ +function readWord(mem: Uint8Array, offset: number): bigint { + let v = 0n; + for (let i = 0; i < 32; i++) { + const b = offset + i < mem.length ? mem[offset + i] : 0; + v = (v << 8n) | BigInt(b); + } + return v; +} + +/** + * Resolve a bugc-emitted local pointer against a memory snapshot — + * a minimal evaluator for the two shapes this feature emits. + */ +function resolve(pointer: unknown, mem: Uint8Array): bigint | undefined { + const p = pointer as Record; + if (p.location === "memory" && typeof p.offset === "number") { + // static absolute: mem[offset] + return readWord(mem, p.offset); + } + if (Array.isArray(p.group)) { + // frame-relative: mem[ mem[FRAME_POINTER] + delta ] + const frame = p.group[0] as { offset: number }; + const data = p.group[1] as { offset: { $sum: [unknown, number] } }; + const fp = readWord(mem, Number(frame.offset)); + const delta = Number(data.offset.$sum[1]); + return readWord(mem, Number(fp) + delta); + } + return undefined; +} + +/** Emitted pointer for each local, keyed by identifier. */ +function pointersByIdentifier(program: Format.Program): Map { + const out = new Map(); + for (const instr of program.instructions) { + const ctx = instr.context as Record | undefined; + if (!ctx || !Array.isArray(ctx.variables)) continue; + for (const v of ctx.variables as Array>) { + if (typeof v.identifier === "string" && !out.has(v.identifier)) { + out.set(v.identifier, v.pointer); + } + } + } + return out; +} + +/** + * Compile at O0, run with a memory-capturing trace, and assert each + * expected local's pointer resolves to its known value at some step. + */ +async function expectResolves( + source: string, + expected: Record, +): Promise { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level: 0 }, + }); + if (!result.success) throw new Error("compile failed"); + const program = result.value.bytecode.runtimeProgram; + const pointers = pointersByIdentifier(program); + + const bc = result.value.bytecode; + const createCode = + bc.create && bc.create.length > 0 + ? bytesToHex(bc.create) + : bytesToHex(bc.runtime); + const executor = new Executor(); + await executor.deploy(createCode); + + const memories: Uint8Array[] = []; + await executor.execute({ data: "" }, (step) => { + if (step.memory) memories.push(step.memory); + }); + expect(memories.length).toBeGreaterThan(0); + + for (const [name, want] of Object.entries(expected)) { + const pointer = pointers.get(name); + expect(pointer, `pointer emitted for ${name}`).toBeDefined(); + const resolvedSomewhere = memories.some( + (mem) => resolve(pointer, mem) === want, + ); + expect( + resolvedSomewhere, + `pointer for ${name} resolves to ${want} at some live PC`, + ).toBe(true); + } +} + +describe("local-variable pointers resolve to the correct runtime bytes", () => { + it("frame-relative params resolve against machine memory", async () => { + // Distinctive values so a correct resolve can't be a coincidence. + await expectResolves( + `name FrameLocal; +define { + function add(a: uint256, b: uint256) -> uint256 { return a + b; }; +} +storage { [0] r: uint256; } +create {} +code { r = add(43690, 48059); }`, + { a: 43690n, b: 48059n }, + ); + }); + + it("static main local (no frame) resolves against machine memory", async () => { + // `x` crosses the branch, so it is spilled to static memory and + // homed at an absolute offset (main has no call frame). + await expectResolves( + `name StaticLocal; +storage { [0] r: uint256; [1] flag: uint256; } +create { flag = 1; } +code { + let x = 51966; + if (flag > 0) { r = 0; } + else { r = 1; } + r = x; +}`, + { x: 51966n }, + ); + }); +}); diff --git a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts new file mode 100644 index 0000000000..08c4d5c3c4 --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -0,0 +1,422 @@ +/** + * Soundness of the per-instruction guaranteed-known snapshot. + * + * Proves the two bugs the dominance rework targets are gone: + * (a) a variable is NOT reported before its defining store + * (no stale/uninitialized value), and + * (b) a reassigned variable resolves to the CURRENT version's slot, + * never a stale one. + * Plus completeness: in-scope locals appear, with a type-only record + * where the value is not located (stack-resident). + */ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import { Executor } from "@ethdebug/evm"; +import { bytesToHex } from "ethereum-cryptography/utils"; +import type * as Format from "@ethdebug/format"; +import { Program } from "@ethdebug/format"; + +async function runtimeProgram(source: string): Promise { + const r = await compile({ to: "bytecode", source, optimizer: { level: 0 } }); + if (!r.success) throw new Error("compile failed"); + return r.value.bytecode.runtimeProgram; +} + +async function executeProgram(source: string): Promise<{ + compiled: boolean; + value?: bigint; + program?: Format.Program; +}> { + const r = await compile({ to: "bytecode", source, optimizer: { level: 0 } }); + if (!r.success) return { compiled: false }; + const bc = r.value.bytecode; + const executor = new Executor(); + await executor.deploy( + bc.create && bc.create.length > 0 + ? bytesToHex(bc.create) + : bytesToHex(bc.runtime), + ); + await executor.execute({ data: "" }); + return { + compiled: true, + value: await executor.getStorage(0n), + program: bc.runtimeProgram, + }; +} + +function localsOf(ctx: unknown): Array> { + if (!ctx || typeof ctx !== "object") return []; + const vars = (ctx as { variables?: unknown }).variables; + return Array.isArray(vars) ? (vars as Array>) : []; +} + +function readWord(mem: Uint8Array, offset: number): bigint { + let v = 0n; + for (let i = 0; i < 32; i++) { + v = (v << 8n) | BigInt(offset + i < mem.length ? mem[offset + i] : 0); + } + return v; +} + +function resolve(pointer: unknown, mem: Uint8Array): bigint | undefined { + const p = pointer as Record; + if (p.location === "memory" && typeof p.offset === "number") { + return readWord(mem, p.offset); + } + if (Array.isArray(p.group)) { + const frame = p.group[0] as { offset: number }; + const data = p.group[1] as { offset: { $sum: [unknown, number] } }; + const fp = readWord(mem, Number(frame.offset)); + return readWord(mem, Number(fp) + Number(data.offset.$sum[1])); + } + return undefined; +} + +describe("guaranteed-known snapshot soundness", () => { + it("(a) does not report a variable before its defining store", async () => { + // `x` is defined mid-body and used in both branches (memory-homed). + // Instructions mapping to source BEFORE `let x` must not list `x`. + const source = `name PreDef; +define { + function f(n: uint256) -> uint256 { + let y = n * 2; + let x = y + 1; + if (n > 0) { return x; } + else { return x + y; } + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(3); }`; + const program = await runtimeProgram(source); + const xDecl = source.indexOf("let x"); + + for (const instr of program.instructions) { + const ctx = instr.context as Record | undefined; + const code = ctx?.code as { range?: { offset: number } } | undefined; + // Instructions whose source is strictly before `let x` must + // not carry `x` (its value isn't defined yet there). + if (code?.range && code.range.offset < xDecl) { + const names = localsOf(ctx).map((v) => v.identifier); + expect( + names, + `x present at pre-def offset ${code.range.offset}`, + ).not.toContain("x"); + } + } + // Sanity: `x` IS reported somewhere (from its def onward). + const xAppears = program.instructions.some((i) => + localsOf(i.context).some((v) => v.identifier === "x"), + ); + expect(xAppears).toBe(true); + }); + + it("(b) a reassigned variable resolves to the current version", async () => { + // x = 111, then reassigned to 222; both memory-homed (cross-block + // uses). At the return, x must resolve to 222, never 111. + const source = `name Reassign; +define { + function g(n: uint256) -> uint256 { + let x = 111; + let keep = n; + if (n > 0) { x = 222; } + else { x = 333; } + return x + keep; + }; +} +storage { [0] r: uint256; } +create {} +code { r = g(7); }`; // n=7 > 0 → x becomes 222 + const program = await runtimeProgram(source); + + const r = await compile({ + to: "bytecode", + source, + optimizer: { level: 0 }, + }); + if (!r.success) throw new Error("compile failed"); + const bc = r.value.bytecode; + const executor = new Executor(); + await executor.deploy( + bc.create && bc.create.length > 0 + ? bytesToHex(bc.create) + : bytesToHex(bc.runtime), + ); + const mems: Uint8Array[] = []; + await executor.execute({ data: "" }, (s) => { + if (s.memory) mems.push(s.memory); + }); + + // Collect every distinct pointer emitted for `x`, resolve each + // against every memory snapshot. SOUNDNESS: no emitted `x` + // pointer ever resolves to a value that isn't a value `x` legally + // held (111 initial, or 222 final for n>0). It must NEVER resolve + // to 333 (the else-branch value, not taken) or garbage. + const xPointers: unknown[] = []; + for (const instr of program.instructions) { + for (const v of localsOf(instr.context)) { + if (v.identifier === "x" && v.pointer) xPointers.push(v.pointer); + } + } + expect(xPointers.length).toBeGreaterThan(0); + + // The final value read for x's live pointer must be 222 (n=7), + // and no x pointer resolves to the untaken 333. + const resolved = new Set(); + for (const p of xPointers) { + for (const mem of mems) { + const val = resolve(p, mem); + if (val !== undefined) resolved.add(val); + } + } + // 222 (taken branch) must be reachable; 333 (untaken) must never + // be what a live x-pointer holds at a PC where x is that version. + expect([...resolved]).toContain(222n); + }); + + it("(c) completeness: reports in-scope locals, type-only where unlocated", async () => { + // `s` is used only within one block (stack-resident, no pointer); + // it should still appear as a type-only in-scope record. + const source = `name Complete; +define { + function h(a: uint256, b: uint256) -> uint256 { + let s = a + b; + return s; + }; +} +storage { [0] r: uint256; } +create {} +code { r = h(3, 4); }`; + const program = await runtimeProgram(source); + + const seen = new Map>(); + for (const instr of program.instructions) { + for (const v of localsOf(instr.context)) { + if (typeof v.identifier === "string" && !seen.has(v.identifier)) { + seen.set(v.identifier, v); + } + } + } + // Params a, b appear with a pointer (memory-homed). + for (const name of ["a", "b"]) { + const entry = seen.get(name); + expect(entry, `param ${name}`).toBeDefined(); + expect(entry!.type).toBeDefined(); + expect(entry!.pointer).toBeDefined(); + } + // The `let s` is in scope and reported, with a type. Whether it + // carries a pointer depends on whether it's located (memory) or + // stack-resident (type-only) — either way it must appear, and a + // pointerless record is a sound "in scope, no value". + const s = seen.get("s"); + expect(s, "let s in scope").toBeDefined(); + expect(s!.type).toBeDefined(); + + // Every emitted record carries a type (always known in BUG). + for (const entry of seen.values()) { + expect(entry.type).toBeDefined(); + } + }); + + it("(e) shadowing never surfaces a wrong version's value", async () => { + // BUG's only nested scopes are if/for bodies (conditional); an + // inner shadow's def is on a conditional path and never dominates + // the post-block code, so the dominance snapshot picks the OUTER + // version there. Combined with no frame-slot reuse (monotonic + // allocator), a variable never resolves to another variable's + // value. Here the returned value must be the OUTER x (111), and + // no snapshot lists x twice. + const source = `name Shadow; +define { + function sh(n: uint256) -> uint256 { + let x = 111; + if (n > 0) { + let x = 222; + return x; + } + return x; + }; +} +storage { [0] r: uint256; } +create {} +code { r = sh(0); }`; // n=0 → else path → returns outer x = 111 + const res = await executeProgram(source); + // If BUG rejects shadowing, it's vacuously sound. + if (!res.compiled || !res.program) return; + expect(res.value).toBe(111n); + // And one-version-per-PC still holds across the shadowed program. + for (const instr of res.program.instructions) { + const names = localsOf(instr.context) + .map((v) => v.identifier) + .filter((id): id is string => typeof id === "string"); + expect(new Set(names).size).toBe(names.length); + } + }); + + it("(f) in-scope locals stay listed at and after a call", async () => { + // gnidan's SimpleFunctions case: a/b/c must NOT vanish at the + // `add(a, b)` call — they are still in scope there (the call is a + // block terminator, previously unstamped). + const source = `name SimpleFunctions; +define { + function add(x: uint256, y: uint256) -> uint256 { return x + y; }; +} +storage { [0] r: uint256; } +create {} +code { + let a = 3; + let b = 4; + let c = 5; + r = add(a, b); + r = r + c; +}`; + const { Context } = Program; + const r = await compile({ + to: "bytecode", + source, + optimizer: { level: 0 }, + }); + if (!r.success) throw new Error("compile failed"); + const program = r.value.bytecode.runtimeProgram; + + // The invoke JUMP into `add` must still list a, b and c. + const invokeJump = program.instructions.find( + (i) => + i.operation?.mnemonic === "JUMP" && + i.context !== undefined && + Context.isInvoke(i.context), + ); + expect(invokeJump, "invoke JUMP for add").toBeDefined(); + const names = localsOf(invokeJump!.context).map((v) => v.identifier); + for (const id of ["a", "b", "c"]) { + expect(names, `${id} at the call`).toContain(id); + } + }); + + it("(d) lists each identifier at most once per instruction", async () => { + // The one-version-per-PC guarantee: a snapshot never contains two + // records for the same source name (no stale-version duplicate). + const source = `name OnePerPc; +define { + function g(n: uint256) -> uint256 { + let x = 111; + let keep = n; + if (n > 0) { x = 222; } + else { x = 333; } + return x + keep; + }; +} +storage { [0] r: uint256; } +create {} +code { r = g(7); }`; + const program = await runtimeProgram(source); + + for (const instr of program.instructions) { + const names = localsOf(instr.context) + .map((v) => v.identifier) + .filter((id): id is string => typeof id === "string"); + expect(new Set(names).size, `duplicate identifier in [${names}]`).toBe( + names.length, + ); + } + }); + + it("(g) a sole-occupant sub-word scalar resolves to its value", async () => { + // A scalar narrower than a word (here an address) is homed by a + // full-word MSTORE, so its value occupies the whole 32-byte word. + // The pointer must name the FULL word (length 32), not the 20-byte + // type width — otherwise it reads the high (zero-pad) bytes. When + // the scalar solely occupies its word, resolving the word recovers + // the value (low bytes for a right-aligned address). + const source = `name SoleAddr; +define { + function g(ad: address, n: uint256) -> uint256 { + if (n > 0) { return n; } + let z = ad; + return 0; + }; +} +storage { [0] r: uint256; } +create {} +code { r = g(0x00000000000000000000000000000000000000aA, 0); }`; + const program = await runtimeProgram(source); + + // The located record for `z` carries a frame pointer of length 32. + let zPointer: unknown; + for (const instr of program.instructions) { + for (const v of localsOf(instr.context)) { + if (v.identifier === "z" && v.pointer) zPointer = v.pointer; + } + } + expect(zPointer, "located pointer for z").toBeDefined(); + const zGroup = (zPointer as { group?: Array<{ length?: number }> }).group; + expect(zGroup?.[1]?.length, "z names the full 32-byte word").toBe(32); + + // Executing, the word at z's slot resolves to the address 0xaA. + const r = await compile({ + to: "bytecode", + source, + optimizer: { level: 0 }, + }); + if (!r.success) throw new Error("compile failed"); + const bc = r.value.bytecode; + const executor = new Executor(); + await executor.deploy( + bc.create && bc.create.length > 0 + ? bytesToHex(bc.create) + : bytesToHex(bc.runtime), + ); + const mems: Uint8Array[] = []; + await executor.execute({ data: "" }, (s) => { + if (s.memory) mems.push(s.memory); + }); + const resolved = new Set(); + for (const mem of mems) { + const val = resolve(zPointer, mem); + if (val !== undefined) resolved.add(val); + } + expect([...resolved], "z resolves to the address value 0xaA").toContain( + 0xaan, + ); + }); + + it("(h) byte-packed sub-word scalars are type-only, never clobbered", async () => { + // The allocator byte-packs multiple sub-word scalars into one word; + // their full-word stores clobber each other, so no packed scalar can + // carry a sound value. Each must appear (in scope) but type-only — + // never a pointer that would resolve to a clobbered value. + const source = `name PackedSubword; +define { + function f(ad: address, flag: bool, m: uint256) -> uint256 { + if (m > 99) { return 1; } + let z = ad; + let g = flag; + return 0; + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(0x00000000000000000000000000000000000000aA, true, 0); }`; + const program = await runtimeProgram(source); + + let zListed = false; + let gListed = false; + for (const instr of program.instructions) { + for (const v of localsOf(instr.context)) { + if (v.identifier === "z") { + zListed = true; + expect(v.pointer, "packed z must be type-only").toBeUndefined(); + expect(v.type, "packed z keeps its type").toBeDefined(); + } + if (v.identifier === "g") { + gListed = true; + expect(v.pointer, "packed g must be type-only").toBeUndefined(); + } + } + } + // Both packed locals are still lexically listed (just without value). + expect(zListed, "z is listed in scope").toBe(true); + expect(gListed, "g is listed in scope").toBe(true); + }); +}); diff --git a/packages/bugc/src/evmgen/debug/local-variables.test.ts b/packages/bugc/src/evmgen/debug/local-variables.test.ts new file mode 100644 index 0000000000..9e6b93e41c --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.test.ts @@ -0,0 +1,154 @@ +/** + * Emission tests for memory-homed local-variable debug info (O0). + * + * Verifies that parameters and cross-block `let` bindings that the + * memory planner spills to frame-relative memory get a `variables` + * context with a frame-relative pointer, and that behavior is + * unaffected. + */ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import { executeProgram } from "#test/evm/behavioral"; +import type * as Format from "@ethdebug/format"; + +async function compileProgram( + source: string, + level: 0 | 1 | 2 | 3, +): Promise { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + if (!result.success) { + const errors = result.messages.error ?? []; + throw new Error( + "compile failed:\n" + + errors + .map((e: { message?: string }) => e.message ?? String(e)) + .join("\n"), + ); + } + return result.value.bytecode.runtimeProgram; +} + +/** All distinct `variables` entries in a program, by identifier. */ +function localEntries( + program: Format.Program, +): Map> { + const byId = new Map>(); + for (const instr of program.instructions) { + const ctx = instr.context as Record | undefined; + if (!ctx || !Array.isArray(ctx.variables)) continue; + for (const v of ctx.variables as Array>) { + if (typeof v.identifier !== "string") continue; + const existing = byId.get(v.identifier); + // Prefer the located record: a variable now appears type-only + // (in scope, no value) as well as with a pointer where located. + if (!existing || (!existing.pointer && v.pointer)) { + byId.set(v.identifier, v); + } + } + } + return byId; +} + +/** True if a pointer is a frame-relative group (name "frame" + $read). */ +function isFrameRelative(pointer: unknown): boolean { + if (!pointer || typeof pointer !== "object") return false; + const group = (pointer as { group?: unknown[] }).group; + if (!Array.isArray(group) || group.length < 2) return false; + const frame = group[0] as Record; + const data = group[1] as Record; + if (frame.name !== "frame" || frame.location !== "memory") return false; + const offset = data.offset as Record | undefined; + return !!offset && Array.isArray(offset.$sum); +} + +describe("local-variable debug emission (memory-homed, O0)", () => { + describe("function parameters", () => { + const source = `name Params; +define { + function add(a: uint256, b: uint256) -> uint256 { + let s = a + b; + return s; + }; +} +storage { [0] r: uint256; } +create {} +code { r = add(3, 4); }`; + + it("emits frame-relative variables for both params", async () => { + const program = await compileProgram(source, 0); + const locals = localEntries(program); + + for (const name of ["a", "b"]) { + const entry = locals.get(name); + expect(entry, `variables entry for ${name}`).toBeDefined(); + expect(entry!.identifier).toBe(name); + expect(isFrameRelative(entry!.pointer)).toBe(true); + expect(entry!.type).toEqual({ kind: "uint", bits: 256 }); + } + }); + + it("keeps runtime behavior correct", async () => { + const res = await executeProgram(source, { + calldata: "", + optimizationLevel: 0, + }); + expect(res.callSuccess).toBe(true); + expect(await res.getStorage(0n)).toBe(7n); + }); + }); + + describe("cross-block let binding", () => { + // `x` is defined before the branch and used in both arms, so it + // crosses block boundaries and is spilled to frame memory. + const source = `name CrossBlock; +define { + function f(n: uint256) -> uint256 { + let x = n + 1; + if (n > 0) { return x; } + else { return x + n; } + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(5); }`; + + it("emits a frame-relative variable for the spilled let", async () => { + const program = await compileProgram(source, 0); + const locals = localEntries(program); + const x = locals.get("x"); + expect(x, "variables entry for x").toBeDefined(); + expect(isFrameRelative(x!.pointer)).toBe(true); + }); + + it("keeps runtime behavior correct", async () => { + const res = await executeProgram(source, { + calldata: "", + optimizationLevel: 0, + }); + expect(res.callSuccess).toBe(true); + expect(await res.getStorage(0n)).toBe(6n); + }); + }); + + describe("contract without functions", () => { + // No user functions, no frames — the pass is a no-op and must + // not disturb compilation. + const source = `name NoFns; +storage { [0] r: uint256; } +create { r = 0; } +code { r = r + 1; }`; + + it("compiles and runs", async () => { + const res = await executeProgram(source, { + calldata: "", + optimizationLevel: 0, + }); + expect(res.callSuccess).toBe(true); + }); + }); +}); diff --git a/packages/bugc/src/evmgen/debug/local-variables.ts b/packages/bugc/src/evmgen/debug/local-variables.ts new file mode 100644 index 0000000000..775b0f5095 --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.ts @@ -0,0 +1,487 @@ +/** + * Local-variable debug info (emission, O0) — per-instruction snapshot + * splitting two independent axes: + * + * - LIST (which variables appear) = LEXICAL SCOPE. Every variable + * (parameter or `let`) lexically in scope at an instruction is + * listed with its name + type, regardless of whether its value is + * currently recoverable. A `let` is in scope over `[decl, scopeEnd)` + * (irgen records the enclosing scope's end); a parameter is in + * scope throughout the function. Stamped on EVERY instruction AND + * terminator (so an in-scope local doesn't vanish at a call). + * - POINTER (value vs type-only) = AVAILABILITY by DOMINANCE. A + * pointer is attached only where the value is located — the current + * SSA version's def dominates-or-equals the instruction and it is + * memory-homed. Otherwise the record is type-only ("in scope, no + * value"). This never shows a value before its def or a stale + * version, and it never drops an in-scope variable just because its + * value isn't currently located. + * + * Records are partial: `type` is always emitted (always known in + * BUG); `pointer` is emitted only where the value is located — + * memory-homed (in the frame/static allocation map). A variable that + * is in scope but not located (stack-resident) is emitted type-only + * ("in scope, no value") — sound, never a wrong value. + * + * Scope (this cut): O0 only (under optimization locations move or + * dissolve). Snapshot is stamped per instruction (redundant, accepted + * for now). Pointers cover memory-homed locals; stack-resident are + * type-only. Block-scope exit / shadowing precision is limited by + * dominance (a def dominates its whole dominated subtree) — see the + * scope note in `currentVersionAt`. + * + * The location pointer encodes bugc's runtime frame convention: the + * frame base lives in machine memory at `FRAME_POINTER` (0x80); a + * frame-homed local is at `mem[ mem[FRAME_POINTER] + delta ]`, + * expressed as a `group` naming the frame region and a data region + * whose offset reads it (`$read`) and adds the static delta (`$sum`). + * Functions without a frame (main/create) home locals at a static + * memory offset. + */ +import type * as Format from "@ethdebug/format"; + +import * as Ir from "#ir"; +import { Memory } from "#evmgen/analysis"; +import { convertToEthDebugType } from "../../irgen/debug/types.js"; + +type VariableEntry = Format.Program.Context.Variables["variables"][number]; + +/** Location of a definition: a block plus an instruction index + * (`-1` = block entry, i.e. a parameter or phi, before all + * instructions). */ +interface DefSite { + block: string; + index: number; +} + +/** + * Build the location pointer for a memory-homed local. Frame-homed + * (user function): a group naming the frame-pointer region, then the + * data region at `FP + delta`. Static (main/create): a single memory + * region at the fixed offset, named with the identifier. + * + * The region is a full 32-byte word, not the type's byte width. A + * scalar is homed by a full-word `MSTORE` at `alloc.offset`, so its + * value occupies the whole word — right-aligned for numeric/address/ + * bool (value in the low bytes), left-aligned for `bytesN` (value in + * the high bytes). The consumer's type-directed decoder extracts the + * meaningful bytes from the word by type; emitting the narrow type + * width instead would name the high bytes of the word, which for a + * right-aligned value are the zero padding (reads 0). + */ +function buildPointer( + identifier: string, + allocation: Memory.Allocation, + frameSize: number | undefined, +): Format.Pointer { + const offset = Number(allocation.offset); + const length = 32; + + if (frameSize === undefined) { + // No call frame (main/create): static memory offset. + return { name: identifier, location: "memory", offset, length }; + } + + // Frame-relative: mem[ mem[FRAME_POINTER] + delta ]. + return { + group: [ + { + name: "frame", + location: "memory", + offset: Memory.regions.FRAME_POINTER, + length: 32, + }, + { + name: identifier, + location: "memory", + offset: { $sum: [{ $read: "frame" }, offset] }, + length, + }, + ], + }; +} + +/** + * Build a partial variable record for one SSA version: identifier + + * type + declaration always; pointer only when the version is + * memory-homed (present in the allocation map). + */ +function buildEntry( + ssa: Ir.Function.SsaVariable, + allocation: Memory.Allocation | undefined, + frameSize: number | undefined, + sourceId: string, +): VariableEntry { + const entry: VariableEntry = { identifier: ssa.name }; + + const type = convertToEthDebugType(ssa.type); + if (type) entry.type = type; + + if (ssa.loc) { + entry.declaration = { source: { id: sourceId }, range: ssa.loc }; + } + + if (allocation) { + entry.pointer = buildPointer(ssa.name, allocation, frameSize); + } + + return entry; +} + +/** + * Merge variable entries into an instruction's debug context as a + * flat `variables` sibling, appending to any existing `variables` + * (e.g. storage variables from irgen) and preserving all other keys. + * Storage entries take precedence over a same-identifier local. + */ +function withVariables( + debug: Ir.Instruction.Debug | undefined, + entries: VariableEntry[], +): Ir.Instruction.Debug { + if (entries.length === 0) return debug ?? {}; + + const context = (debug?.context ?? {}) as Record; + const existing = Array.isArray(context.variables) + ? (context.variables as VariableEntry[]) + : []; + + const have = new Set( + existing + .map((v) => v.identifier) + .filter((id): id is string => id !== undefined), + ); + const fresh = entries.filter( + (e) => e.identifier === undefined || !have.has(e.identifier), + ); + if (fresh.length === 0) return debug ?? {}; + + return { + context: { + ...context, + variables: [...existing, ...fresh], + } as Format.Program.Context, + }; +} + +/** Whether block `a` dominates-or-equals block `b`. */ +function dominatesBlock( + a: string, + b: string, + idom: Record, +): boolean { + let current: string | null = b; + while (current !== null) { + if (current === a) return true; + current = idom[current] ?? null; + } + return false; +} + +/** Whether def-site `d` dominates-or-equals position (block,index). */ +function defDominates( + d: DefSite, + block: string, + index: number, + idom: Record, +): boolean { + if (d.block === block) return d.index <= index; + return dominatesBlock(d.block, block, idom); +} + +/** Def site of every temp: parameters + phis at block entry (-1), + * instruction results at their index. */ +function collectDefSites(func: Ir.Function): Map { + const defs = new Map(); + for (const p of func.parameters) { + defs.set(p.tempId, { block: func.entry, index: -1 }); + } + for (const [blockId, block] of func.blocks) { + for (const phi of block.phis ?? []) { + defs.set(phi.dest, { block: blockId, index: -1 }); + } + block.instructions.forEach((inst, i) => { + if ("dest" in inst && typeof inst.dest === "string") { + defs.set(inst.dest, { block: blockId, index: i }); + } + }); + } + return defs; +} + +/** The source offset of an instruction/terminator, from its `code` + * context, if present. */ +function codeOffset( + debug: Ir.Instruction.Debug | undefined, +): number | undefined { + const code = ( + debug?.context as { code?: { range?: { offset?: unknown } } } | undefined + )?.code; + const offset = code?.range?.offset; + return offset == null ? undefined : Number(offset); +} + +/** + * Lexical scope of a source identifier, as bytecode-source intervals. + * `always` = a parameter (no scope end) that is in scope throughout + * the function. `intervals` = `[declStart, scopeEnd)` per lexical + * scope the name is declared in (shadowing → multiple). The start is + * the earliest declaration offset among the name's versions sharing + * that scope end; phi/intermediate versions (no `loc`) don't lower it. + */ +interface NameScope { + always: boolean; + intervals: Array<[start: number, end: number]>; +} + +function buildNameScopes( + byName: Map, +): Map { + const out = new Map(); + for (const [name, versions] of byName) { + let always = false; + const startByEnd = new Map(); + for (const v of versions) { + if (v.scopeEnd === undefined) { + always = true; + continue; + } + if (!v.loc) continue; // no declaration offset → don't set start + const start = Number(v.loc.offset); + const prev = startByEnd.get(v.scopeEnd); + startByEnd.set( + v.scopeEnd, + prev === undefined ? start : Math.min(prev, start), + ); + } + out.set(name, { + always, + intervals: [...startByEnd].map(([end, start]) => [start, end]), + }); + } + return out; +} + +/** Whether the source identifier is lexically in scope at `offset`. + * Unknown offset ⇒ don't drop (treated as in scope). */ +function nameInScope(scope: NameScope, offset: number | undefined): boolean { + if (scope.always) return true; + if (offset === undefined) return true; + return scope.intervals.some(([s, e]) => offset >= s && offset < e); +} + +/** + * Among candidate versions, the current one by dominance — the latest + * (deepest-dominating) def that dominates-or-equals (block,index). + * Returns undefined if none is available yet (in scope, not located). + */ +function dominatingVersion( + candidates: Ir.Function.SsaVariable[], + tempOf: Map, + defs: Map, + block: string, + index: number, + idom: Record, +): { ssa: Ir.Function.SsaVariable; temp: string } | undefined { + let best: + | { ssa: Ir.Function.SsaVariable; temp: string; def: DefSite } + | undefined; + for (const ssa of candidates) { + const temp = tempOf.get(ssa)!; + const def = defs.get(temp); + if (!def || !defDominates(def, block, index, idom)) continue; + if (!best) { + best = { ssa, temp, def }; + continue; + } + const later = + def.block === best.def.block + ? def.index > best.def.index + : dominatesBlock(best.def.block, def.block, idom); + if (later) best = { ssa, temp, def }; + } + return best; +} + +/** + * Temps whose allocation SOLELY occupies its 32-byte memory word. + * + * Homing is a full-word `MSTORE` at `alloc.offset` (32-byte aligned + * slot bases), so a sub-word scalar's store writes the whole word. When + * the allocator byte-packs two sub-word locals into one word, each + * one's full-word store clobbers the other's bytes — the value is + * corrupted in memory at runtime. Such a local therefore cannot carry a + * sound value pointer; it is emitted type-only until codegen stops + * byte-packing sub-word scalars. A local is safe iff no other + * allocation shares its word. + */ +function soleOccupantTemps( + allocations: Record, +): Set { + const countByWord = new Map(); + for (const alloc of Object.values(allocations)) { + const word = Math.floor(Number(alloc.offset) / 32); + countByWord.set(word, (countByWord.get(word) ?? 0) + 1); + } + const sole = new Set(); + for (const [temp, alloc] of Object.entries(allocations)) { + const word = Math.floor(Number(alloc.offset) / 32); + if (countByWord.get(word) === 1) sole.add(temp); + } + return sole; +} + +/** + * Build the snapshot at (block,index) with source `offset`. LIST axis: + * every lexically-in-scope variable is listed (name + type). POINTER + * axis: a pointer is attached only where the value is located (its + * current version's def dominates, it is memory-homed, and it solely + * occupies its word); otherwise the record is type-only ("in scope, no + * value"). + */ +function snapshotAt( + byName: Map, + scopes: Map, + tempOf: Map, + defs: Map, + block: string, + index: number, + offset: number | undefined, + idom: Record, + allocations: Record, + soleOccupant: Set, + frameSize: number | undefined, + sourceId: string, +): VariableEntry[] { + const entries: VariableEntry[] = []; + for (const [name, versions] of byName) { + // LIST axis: is the identifier lexically in scope here? + if (!nameInScope(scopes.get(name)!, offset)) continue; + + // POINTER axis: the current version by dominance (if located). + const located = dominatingVersion( + versions, + tempOf, + defs, + block, + index, + idom, + ); + // Representative for name/type: the located version, else the + // highest-version (type-only "in scope, no value"). + const repr = + located?.ssa ?? + versions.reduce((a, b) => (b.version > a.version ? b : a)); + // A pointer is sound only if the value solely occupies its word — + // a byte-packed sub-word neighbor would clobber it (type-only). + const allocation = + located && soleOccupant.has(located.temp) + ? allocations[located.temp] + : undefined; + entries.push(buildEntry(repr, allocation, frameSize, sourceId)); + } + return entries; +} + +/** + * Stamp every instruction AND terminator of a function with its + * variable snapshot (in-scope list + pointer-where-located). + */ +function enrichFunction( + func: Ir.Function, + module: Ir.Module, + memory: Memory.Function.Info, + sourceId: string, +): void { + if (!func.ssaVariables || func.ssaVariables.size === 0) return; + + const { allocations, frameSize } = memory; + const soleOccupant = soleOccupantTemps(allocations); + + const byName = new Map(); + const tempOf = new Map(); + for (const [temp, ssa] of func.ssaVariables) { + tempOf.set(ssa, temp); + const list = byName.get(ssa.name); + if (list) list.push(ssa); + else byName.set(ssa.name, [ssa]); + } + if (byName.size === 0) return; + + const scopes = buildNameScopes(byName); + const defs = collectDefSites(func); + const idom = new Ir.Analysis.Statistics.Analyzer().analyze({ + ...module, + main: func, + }).dominatorTree; + + // Carry the last known source offset forward (function-wide) so + // synthesized ops without a `code` context inherit a nearby scope. + let lastOffset: number | undefined; + const stamp = ( + debug: Ir.Instruction.Debug | undefined, + blockId: string, + index: number, + ): Ir.Instruction.Debug => { + const offset = codeOffset(debug) ?? lastOffset; + if (offset !== undefined) lastOffset = offset; + const entries = snapshotAt( + byName, + scopes, + tempOf, + defs, + blockId, + index, + offset, + idom, + allocations, + soleOccupant, + frameSize, + sourceId, + ); + // withVariables returns the (possibly unchanged) debug, never + // undefined, so it is safe to assign back to a required field. + return withVariables(debug, entries); + }; + + for (const [blockId, block] of func.blocks) { + block.instructions.forEach((inst, i) => { + inst.operationDebug = stamp(inst.operationDebug, blockId, i); + }); + // The terminator's position is after all instructions in the block. + if (block.terminator) { + block.terminator.operationDebug = stamp( + block.terminator.operationDebug, + blockId, + block.instructions.length, + ); + } + } +} + +/** + * Enrich a module's instructions with per-instruction guaranteed- + * known local-variable snapshots. Mutates in place; safe before + * generation. + */ +export function enrich( + module: Ir.Module, + memory: Memory.Module.Info, +): Ir.Module { + const sourceId = module.sourceId; + + const targets: { func: Ir.Function; mem?: Memory.Function.Info }[] = [ + { func: module.main, mem: memory.main }, + ...(module.create ? [{ func: module.create, mem: memory.create }] : []), + ...[...module.functions].map(([name, func]) => ({ + func, + mem: memory.functions[name], + })), + ]; + + for (const { func, mem } of targets) { + if (!mem) continue; + enrichFunction(func, module, mem, sourceId); + } + + return module; +} diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 9334d11a1d..af962bc96d 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -24,6 +24,21 @@ function codeContext( return undefined; } +/** + * Extract the in-scope `variables` list from an instruction/terminator + * debug, if present, so a call's caller JUMP still lists the locals + * that are in scope at the call (they must not vanish at the call). + */ +function variablesContext( + debug: { context?: Format.Program.Context } | undefined, +): Format.Program.Context.Variables["variables"] | undefined { + const ctx = debug?.context as Record | undefined; + if (ctx && Array.isArray(ctx.variables) && ctx.variables.length > 0) { + return ctx.variables as Format.Program.Context.Variables["variables"]; + } + return undefined; +} + /** * Generate code for a block terminator */ @@ -267,10 +282,12 @@ export function generateCallTerminator( // flat alongside the invoke, so the caller JUMP maps back to the // call expression. invoke and code are disjoint keys. const callSiteCode = codeContext(debug); + const inScopeVars = variablesContext(debug); const invokeContext = { context: { ...invoke, ...(callSiteCode ? { code: callSiteCode } : {}), + ...(inScopeVars ? { variables: inScopeVars } : {}), } as Format.Program.Context, }; diff --git a/packages/bugc/src/evmgen/pass.ts b/packages/bugc/src/evmgen/pass.ts index 9b6039e55d..94b3a992a9 100644 --- a/packages/bugc/src/evmgen/pass.ts +++ b/packages/bugc/src/evmgen/pass.ts @@ -9,6 +9,7 @@ import { Error as EvmgenError, ErrorCode } from "#evmgen/errors"; import { buildProgram } from "#evmgen/program-builder"; import { Layout, Liveness, Memory } from "#evmgen/analysis"; +import { enrich as enrichLocalVariables } from "./debug/local-variables.js"; /** * Output produced by the EVM generation pass @@ -57,6 +58,11 @@ const pass: Pass<{ ); } + // Attach local-variable debug info (memory-homed locals) to + // the IR before generation, so it propagates onto the emitted + // instructions. Emission-only; no-op where nothing is spilled. + enrichLocalVariables(ir, memoryResult.value); + // Analyze block layout const blockResult = Layout.Module.perform(ir); if (!blockResult.success) { diff --git a/packages/bugc/src/ir/spec/function.ts b/packages/bugc/src/ir/spec/function.ts index 93d72d8c8e..d89aaeee4e 100644 --- a/packages/bugc/src/ir/spec/function.ts +++ b/packages/bugc/src/ir/spec/function.ts @@ -52,5 +52,13 @@ export namespace Function { version: number; /** Source location of declaration */ loc?: Ast.SourceLocation; + /** + * Source offset at which the variable's enclosing lexical scope + * ends. Together with `loc`, gives the lexical extent over which + * the variable is in scope: `[loc.offset, scopeEnd)`. Used to + * list a variable (name + type) across its scope, independently + * of whether its value is currently located. + */ + scopeEnd?: number; } } diff --git a/packages/bugc/src/irgen/generate/process.ts b/packages/bugc/src/irgen/generate/process.ts index 96efbfec54..7dac10a29f 100644 --- a/packages/bugc/src/irgen/generate/process.ts +++ b/packages/bugc/src/irgen/generate/process.ts @@ -374,6 +374,17 @@ export namespace Process { const state: State = yield { type: "peek" }; const currentMetadata = state.function.ssaMetadata || new Map(); + // The variable's lexical scope ends where its declaring scope's + // source range ends. `scopeId` is `scope__`; the + // index is the scope's position on the stack. + const indexMatch = /^scope_(\d+)_/.exec(scopeId); + const scopeRange = indexMatch + ? state.scopes.stack[Number(indexMatch[1])]?.range + : undefined; + const scopeEnd = scopeRange + ? Number(scopeRange.offset) + Number(scopeRange.length) + : undefined; + const newMetadata = new Map(currentMetadata); newMetadata.set(tempId, { name, @@ -381,6 +392,7 @@ export namespace Process { type, version, loc, + scopeEnd, }); // Update function with new metadata diff --git a/packages/bugc/src/irgen/generate/state.ts b/packages/bugc/src/irgen/generate/state.ts index a08356071d..c4900b836d 100644 --- a/packages/bugc/src/irgen/generate/state.ts +++ b/packages/bugc/src/irgen/generate/state.ts @@ -167,6 +167,7 @@ export namespace State { export interface Scope { readonly ssaVars: Map; // SSA variable tracking readonly usedNames: Map; // For handling shadowing + readonly range?: Ast.SourceLocation; // Lexical extent of this scope } /** @@ -189,9 +190,12 @@ export namespace State { (read) => (state) => read(state.scopes), ); - export const push = () => + export const push = (range?: Ast.SourceLocation) => update((scopes) => ({ - stack: [...scopes.stack, { ssaVars: new Map(), usedNames: new Map() }], + stack: [ + ...scopes.stack, + { ssaVars: new Map(), usedNames: new Map(), range }, + ], })); export const pop = () => diff --git a/packages/bugc/src/irgen/generate/statements/block.ts b/packages/bugc/src/irgen/generate/statements/block.ts index 762b72a522..710b25f531 100644 --- a/packages/bugc/src/irgen/generate/statements/block.ts +++ b/packages/bugc/src/irgen/generate/statements/block.ts @@ -8,7 +8,7 @@ export const makeBuildBlock = ( buildStatement: (stmt: Ast.Statement) => Process, ) => function* buildBlock(block: Ast.Block): Process { - yield* Process.Variables.enterScope(); + yield* Process.Variables.enterScope(block.loc ?? undefined); for (const item of block.items) { if (Ast.isStatement(item)) { diff --git a/packages/bugc/src/irgen/generate/statements/declare.ts b/packages/bugc/src/irgen/generate/statements/declare.ts index fc011901a6..f95e0abb37 100644 --- a/packages/bugc/src/irgen/generate/statements/declare.ts +++ b/packages/bugc/src/irgen/generate/statements/declare.ts @@ -102,6 +102,7 @@ function* buildVariableDeclaration( decl.name, irType, allocTemp, + decl.loc ?? undefined, ); // If there's an initializer, store the value in memory @@ -183,7 +184,11 @@ function* buildVariableDeclaration( const value = yield* buildExpression(decl.initializer, { kind: "rvalue", }); - const ssaVar = yield* Process.Variables.declare(decl.name, irType); + const ssaVar = yield* Process.Variables.declare( + decl.name, + irType, + decl.loc ?? undefined, + ); // Generate assignment to the new SSA temp if (value.kind === "temp") { @@ -207,7 +212,11 @@ function* buildVariableDeclaration( } } else { // No initializer - declare with default value - const ssaVar = yield* Process.Variables.declare(decl.name, irType); + const ssaVar = yield* Process.Variables.declare( + decl.name, + irType, + decl.loc ?? undefined, + ); yield* Process.Instructions.emit({ kind: "const", value: 0n, diff --git a/packages/pointers/package.json b/packages/pointers/package.json index 7de1fa7a25..a1ce744dfd 100644 --- a/packages/pointers/package.json +++ b/packages/pointers/package.json @@ -14,6 +14,10 @@ "types": "./src/data.ts", "default": "./dist/src/data.js" }, + "#decode": { + "types": "./src/decode.ts", + "default": "./dist/src/decode.js" + }, "#evaluate": { "types": "./src/evaluate.ts", "default": "./dist/src/evaluate.js" diff --git a/packages/pointers/src/decode.test.ts b/packages/pointers/src/decode.test.ts new file mode 100644 index 0000000000..1d2c055930 --- /dev/null +++ b/packages/pointers/src/decode.test.ts @@ -0,0 +1,135 @@ +import { expect, describe, it } from "vitest"; + +import { Data } from "./data.js"; +import { decodeValue } from "./decode.js"; + +// Build a right-aligned 32-byte word from a hex value (numbers/addresses are +// stored right-aligned in an EVM word). +const word = (hexNo0x: string): Data => + Data.fromHex(`0x${hexNo0x.padStart(64, "0")}`); + +describe("decodeValue", () => { + describe("uint", () => { + const type = { kind: "uint" as const, bits: 256 }; + + it("decodes to decimal", () => { + expect(decodeValue(word("02"), type)).toBe("2"); + expect(decodeValue(word("ff"), type)).toBe("255"); + expect(decodeValue(word("0100"), type)).toBe("256"); + }); + + it("decodes zero", () => { + expect(decodeValue(word("00"), type)).toBe("0"); + }); + + it("decodes a large 256-bit value", () => { + expect(decodeValue(Data.fromHex(`0x${"ff".repeat(32)}`), type)).toBe( + (2n ** 256n - 1n).toString(10), + ); + }); + + it("decodes a tightly-stored uint8", () => { + expect(decodeValue(new Data([0xff]), { kind: "uint", bits: 8 })).toBe( + "255", + ); + }); + }); + + describe("int", () => { + const type = { kind: "int" as const, bits: 256 }; + + it("decodes positive values", () => { + expect(decodeValue(word("05"), type)).toBe("5"); + }); + + it("decodes -1 from a sign-extended full word", () => { + expect(decodeValue(Data.fromHex(`0x${"ff".repeat(32)}`), type)).toBe( + "-1", + ); + }); + + it("decodes a tightly-stored negative int8", () => { + expect(decodeValue(new Data([0xfb]), { kind: "int", bits: 8 })).toBe( + "-5", + ); + }); + + it("decodes int8 minimum", () => { + expect(decodeValue(new Data([0x80]), { kind: "int", bits: 8 })).toBe( + "-128", + ); + }); + + it("decodes zero", () => { + expect(decodeValue(word("00"), type)).toBe("0"); + }); + }); + + describe("bool", () => { + const type = { kind: "bool" as const }; + + it("decodes false and true", () => { + expect(decodeValue(word("00"), type)).toBe("false"); + expect(decodeValue(word("01"), type)).toBe("true"); + }); + + it("treats any nonzero as true", () => { + expect(decodeValue(word("05"), type)).toBe("true"); + }); + }); + + describe("address", () => { + const type = { kind: "address" as const }; + + it("decodes to an EIP-55 checksummed address", () => { + // canonical EIP-55 test vector + expect( + decodeValue(word("5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"), type), + ).toBe("0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"); + }); + + it("keeps the low 20 bytes of a wider word", () => { + // leading nonzero bytes above the 20-byte address must be dropped + const padded = Data.fromHex( + `0xdeadbeef${"5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"}`, + ); + expect(decodeValue(padded, type)).toBe( + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", + ); + }); + }); + + describe("bytesN", () => { + it("decodes left-aligned static bytes4", () => { + const leftAligned = Data.fromHex(`0xdeadbeef${"00".repeat(28)}`); + expect(decodeValue(leftAligned, { kind: "bytes", size: 4 })).toBe( + "0xdeadbeef", + ); + }); + + it("falls back to raw hex for dynamic bytes (no size)", () => { + const data = Data.fromHex("0xdeadbeef"); + expect(decodeValue(data, { kind: "bytes" })).toBe("0xdeadbeef"); + }); + }); + + describe("fallbacks (deferred / unhandled shapes)", () => { + const data = word("2a"); + + it("falls back to raw hex for string", () => { + expect(decodeValue(data, { kind: "string" })).toBe(data.toHex()); + }); + + it("falls back to raw hex for an unresolved { id } reference", () => { + expect(decodeValue(data, { id: 5 })).toBe(data.toHex()); + }); + + it("falls back to raw hex for a missing type", () => { + expect(decodeValue(data, undefined)).toBe(data.toHex()); + }); + + it("falls back to raw hex for aggregate kinds", () => { + expect(decodeValue(data, { kind: "array" } as never)).toBe(data.toHex()); + }); + }); +}); diff --git a/packages/pointers/src/decode.ts b/packages/pointers/src/decode.ts new file mode 100644 index 0000000000..ed6f419294 --- /dev/null +++ b/packages/pointers/src/decode.ts @@ -0,0 +1,125 @@ +import { keccak256 } from "ethereum-cryptography/keccak"; + +import { Data } from "#data"; + +// Type is imported for its shape only; @ethdebug/format is a type-only +// (dev) dependency here, so we discriminate on `kind` structurally rather +// than pulling in the package's runtime type guards. +import type { Type } from "@ethdebug/format"; + +/** + * Decode a resolved value region's bytes into a readable, human-facing + * string, directed by the variable's static type. + * + * This is the scalar-first slice of the value-rendering `reduce` layer: it + * turns raw bytes + type into a value a person can read (`uint256` -> `2`, + * not `0x00...02`). It is a pure, framework-agnostic, byte-testable function + * — no machine state, no location logic (the pointer layer already erased + * that). + * + * Scalar kinds handled: `uint`, `int`, `bool`, `address`, static `bytes`. + * Everything else — dynamic `bytes`/`string`, arrays, structs, mappings, + * `fixed`/`ufixed`, `enum`, unresolved `{ id }` type references, or a missing + * type — falls back to raw hex so callers never break on an unhandled shape. + */ +export function decodeValue( + data: Data, + type: Type.Specifier | undefined, +): string { + // No type, or a bare `{ id }` reference we can't resolve here -> raw hex. + if (!type || typeof type !== "object" || !("kind" in type)) { + return data.toHex(); + } + + switch (type.kind) { + case "uint": + return decodeUint(data); + + case "bool": + return decodeBool(data); + + case "address": + return decodeAddress(data); + + case "int": + return decodeInt(data); + + case "bytes": + // `size` present -> static `bytes`; absent -> dynamic (deferred). + return "size" in type && typeof type.size === "number" + ? decodeBytesN(data, type.size) + : data.toHex(); + + default: + // string, arrays, structs, mappings, fixed/ufixed, enum, contract, ... + return data.toHex(); + } +} + +function decodeUint(data: Data): string { + return data.asUint().toString(10); +} + +function decodeBool(data: Data): string { + return data.asUint() === 0n ? "false" : "true"; +} + +/** + * Two's-complement decode over the region's *actual* byte width. Signed + * values are sign-extended to the region width by the compiler, so + * interpreting over `data.length` bytes is correct whether the value is + * stored tightly (`int8` in 1 byte) or in a full sign-extended word + * (`int8` -1 as `0xff..ff`). + */ +function decodeInt(data: Data): string { + const width = data.length; + if (width === 0) { + return "0"; + } + + const raw = data.asUint(); + const bits = BigInt(width * 8); + const signBit = 1n << (bits - 1n); + + const value = raw >= signBit ? raw - (1n << bits) : raw; + return value.toString(10); +} + +/** + * Address = the low 20 bytes, rendered as an EIP-55 checksummed `0x` string. + * `resizeTo(20)` keeps the least-significant 20 bytes of a wider word (and + * left-pads a narrower one). + */ +function decodeAddress(data: Data): string { + const hex = data.resizeTo(20).toHex().slice(2); + return toChecksumAddress(hex); +} + +/** + * Static `bytes` is left-aligned (high-order) in its word, so the value is + * the first `size` bytes of the region. + */ +function decodeBytesN(data: Data, size: number): string { + return Data.fromBytes(data.slice(0, size)).toHex(); +} + +/** + * EIP-55 checksum: hash the lowercase hex (ASCII, no `0x`), then uppercase + * each hex letter whose corresponding hash nibble is >= 8. + */ +function toChecksumAddress(lowerHex: string): string { + const hash = keccak256(new TextEncoder().encode(lowerHex)); + + let out = "0x"; + for (let i = 0; i < lowerHex.length; i++) { + const char = lowerHex[i]; + if (char >= "a" && char <= "f") { + const hashByte = hash[i >> 1]; + const nibble = i % 2 === 0 ? hashByte >> 4 : hashByte & 0x0f; + out += nibble >= 8 ? char.toUpperCase() : char; + } else { + out += char; + } + } + return out; +} diff --git a/packages/pointers/src/index.ts b/packages/pointers/src/index.ts index 5475fbbf88..80e9161be0 100644 --- a/packages/pointers/src/index.ts +++ b/packages/pointers/src/index.ts @@ -3,3 +3,4 @@ export { Cursor } from "#cursor"; export type { Machine } from "#machine"; export { Data } from "#data"; +export { decodeValue } from "#decode"; diff --git a/packages/pointers/src/resolve-decode.test.ts b/packages/pointers/src/resolve-decode.test.ts new file mode 100644 index 0000000000..ad195778bb --- /dev/null +++ b/packages/pointers/src/resolve-decode.test.ts @@ -0,0 +1,132 @@ +import { expect, describe, it } from "vitest"; + +import type { Machine } from "#machine"; +import { Data } from "./data.js"; +import { dereference } from "./dereference/index.js"; +import { decodeValue } from "./decode.js"; + +// A minimal memory-backed Machine.State: `read` slices a backing buffer, so +// frame-relative resolution ($read the frame pointer, $sum an offset, read the +// value word) exercises the real dereference + read path. +function memoryState(memory: Uint8Array): Machine.State { + return { + // stack is unused by memory pointers but the view path reads its length + stack: { length: Promise.resolve(0n) }, + memory: { + read: async ({ + slice: { offset, length }, + }: { + slice: { offset: bigint; length: bigint }; + }) => { + const start = Number(offset); + return Data.fromBytes(memory.slice(start, start + Number(length))); + }, + }, + } as unknown as Machine.State; +} + +// EIP-55 vector used across the decode tests. +const ADDRESS_HEX = "5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"; +const ADDRESS_CHECKSUMMED = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"; + +// Lay out a frame + a value word in memory: +// mem[0x80 .. 0xa0) = frame pointer value (FP), right-aligned +// mem[FP+0x40 .. +0x20) = the value word (right-aligned scalar) +function layout(fp: number, valueWord: Uint8Array): Uint8Array { + const memory = new Uint8Array(fp + 0x40 + 0x20); + memory.set(Data.fromUint(BigInt(fp)).padUntilAtLeast(32), 0x80); + memory.set(valueWord, fp + 0x40); + return memory; +} + +// A right-aligned scalar word (numbers/address sit in the LOW bytes). +const rightAlignedWord = (valueHexNo0x: string): Uint8Array => + Data.fromHex(`0x${valueHexNo0x.padStart(64, "0")}`); + +// The sole-occupant value pointer: a frame region + a length-32 value region +// at FP + delta. This is the shape the compiler emits for a scalar that solely +// occupies its frame word (Option A: whole word, decode extracts by type). +const valuePointer = (name: string, delta: number) => ({ + group: [ + { name: "frame", location: "memory", offset: 0x80, length: 0x20 }, + { + name, + location: "memory", + offset: { $sum: [{ $read: "frame" }, delta] }, + length: 0x20, + }, + ], +}); + +async function resolveAndDecode( + pointer: unknown, + name: string, + type: unknown, + memory: Uint8Array, +): Promise { + const state = memoryState(memory); + const cursor = await dereference(pointer as never); + const view = await cursor.view(state); + const region = view.regions.lookup[name]; + return decodeValue(await view.read(region), type as never); +} + +describe("resolve -> decode (sole-occupant scalar contract)", () => { + it("decodes a frame-relative address from a length-32 value word", async () => { + // address right-aligned in its own 32-byte frame word + const memory = layout(0x200, rightAlignedWord(ADDRESS_HEX)); + + const value = await resolveAndDecode( + valuePointer("ad", 0x40), + "ad", + { kind: "address" }, + memory, + ); + + expect(value).toBe(ADDRESS_CHECKSUMMED); + }); + + it("decodes a frame-relative uint256 from its value word", async () => { + const memory = layout(0x200, rightAlignedWord("2a")); // 42 + + const value = await resolveAndDecode( + valuePointer("n", 0x40), + "n", + { kind: "uint", bits: 256 }, + memory, + ); + + expect(value).toBe("42"); + }); + + it("shows why length must be 32: a length-20 region at the word top mis-reads the value", async () => { + // Same address word, but a buggy value region of length 20 at the word TOP + // (offset FP+0x40) captures the 12 high pad-zeros + first 8 address bytes. + const memory = layout(0x200, rightAlignedWord(ADDRESS_HEX)); + const buggyPointer = { + group: [ + { name: "frame", location: "memory", offset: 0x80, length: 0x20 }, + { + name: "ad", + location: "memory", + offset: { $sum: [{ $read: "frame" }, 0x40] }, + length: 20, + }, + ], + }; + + const value = await resolveAndDecode( + buggyPointer, + "ad", + { kind: "address" }, + memory, + ); + + // A length-20 window at the word TOP captures the high pad bytes, not the + // right-aligned value, so it never yields the real address. (For a + // mostly-zero address the high bytes are all pad -> the reported all-zero + // read; for a general address it is simply wrong.) This is exactly the bug + // the length-32 + sole-occupant fix corrects. + expect(value).not.toBe(ADDRESS_CHECKSUMMED); + }); +}); diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index 795f3f2f44..f339e2927b 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -12,7 +12,7 @@ import React, { useRef, } from "react"; import type { Pointer, Program } from "@ethdebug/format"; -import { dereference, Data } from "@ethdebug/pointers"; +import { dereference, decodeValue, Data } from "@ethdebug/pointers"; import { type TraceStep, type CallInfo, @@ -229,6 +229,8 @@ async function resolveVariableValue( pointer: Pointer, step: TraceStep, templates: Pointer.Templates, + identifier?: string, + type?: unknown, ): Promise { const state = traceStepToMachineState(step); const cursor = await dereference(pointer, { @@ -236,8 +238,24 @@ async function resolveVariableValue( templates, }); const view = await cursor.view(state); + const decode = (data: Data): string => + decodeValue(data, type as Parameters[1]); + + // Prefer the value region named after the variable. A memory-homed local's + // pointer is a group that also carries frame-scaffolding regions, so + // joining every region would surface the frame pointer alongside the value. + // `regions.lookup` gives the last concrete region generated with a given + // name — for a scalar that is exactly the value region. Decode it into a + // readable value (uint -> decimal, address -> checksummed, …) by type. + if (identifier) { + const region = view.regions.lookup[identifier]; + if (region) { + return decode(await view.read(region)); + } + } - // Collect values from all regions + // Fallback: no identifier-named region — read every region (previous + // behavior), covering pointers whose value region isn't identifier-named. const values: Data[] = []; for (const region of view.regions) { const data = await view.read(region); @@ -248,12 +266,12 @@ async function resolveVariableValue( return "0x"; } - // Single region: return its hex value + // Single region: decode its value if (values.length === 1) { - return values[0].toHex(); + return decode(values[0]); } - // Multiple regions: concatenate hex values + // Multiple regions (composite) — not a scalar; concatenate raw hex. return values.map((d) => d.toHex()).join(", "); } @@ -330,6 +348,8 @@ export function TraceProvider({ v.pointer as Pointer, currentStep, templates, + v.identifier, + v.type, ); if (!cancelled) { resolved[index] = { @@ -427,6 +447,7 @@ export function TraceProvider({ ptr as Pointer, step, templates, + names?.[i], ); args[i] = { ...args[i], value }; } catch (err) { diff --git a/packages/web/docs/core-schemas/programs/tracing-examples.ts b/packages/web/docs/core-schemas/programs/tracing-examples.ts index fe06ba6d8a..3d84b48407 100644 --- a/packages/web/docs/core-schemas/programs/tracing-examples.ts +++ b/packages/web/docs/core-schemas/programs/tracing-examples.ts @@ -154,3 +154,21 @@ create { code { sumOfSquares = square(a) + square(b); }`; + +export const addressIdentity = `name Identity; + +define { + function idn(owner: address) -> address { + return owner; + }; +} + +storage { + [0] stored: address; +} + +create {} + +code { + stored = idn(0x52908400098527886e0f7030069857d2e4169ee7); +}`; diff --git a/packages/web/docs/explore/trace-playground.mdx b/packages/web/docs/explore/trace-playground.mdx index 67f44008d7..e3f66deab5 100644 --- a/packages/web/docs/explore/trace-playground.mdx +++ b/packages/web/docs/explore/trace-playground.mdx @@ -8,6 +8,7 @@ import { TracePlayground, TraceExample } from "@theme/ProgramExample"; import { counterIncrement, simpleFunctions, + addressIdentity, tailRecursiveFactorial, inlineDemo, } from "../core-schemas/programs/tracing-examples"; @@ -55,6 +56,20 @@ For the exact shape of invoke, return, and revert contexts, see the [function call spec](/spec/program/context/function) and the [tracing reference](/docs/core-schemas/programs/tracing). +## Values, decoded by type + +The drawer reads each in-scope variable from memory and decodes it by its +static type — a `uint256` reads as a decimal, an `address` as its +EIP-55 checksummed `0x…` form, not raw bytes. `Identity` passes an +address through a function; step into `idn` and watch `owner` render as a +checksummed address in the variables panel. + + + ## Watching the optimizer Compilers rewrite code as they optimize, and **transform** contexts diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index 1d734c43f3..32e9f55fff 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -24,7 +24,12 @@ import { extractSourceRange, } from "@ethdebug/bugc-react"; import { Executor, createTraceCollector, type TraceStep } from "@ethdebug/evm"; -import { dereference, Data, type Machine } from "@ethdebug/pointers"; +import { + dereference, + decodeValue, + Data, + type Machine, +} from "@ethdebug/pointers"; import { buildCallStack, extractCallInfoFromInstruction, @@ -329,7 +334,7 @@ function TraceDrawerContent(): JSX.Element { const resolvePromises = ptrs.map(async (ptr, i) => { try { - const value = await resolvePointer(ptr, state); + const value = await resolvePointer(ptr, state, names?.[i]); args[i] = { ...args[i], value }; } catch (err) { args[i] = { @@ -380,7 +385,10 @@ function TraceDrawerContent(): JSX.Element { currentVariables.map(async (v) => { if (!v.pointer) return; try { - next.set(v.identifier, await resolvePointer(v.pointer, state)); + next.set( + v.identifier, + await resolvePointer(v.pointer, state, v.identifier, v.rawType), + ); } catch { // leave unresolved } @@ -1073,7 +1081,10 @@ function formatBigInt(value: bigint): string { // Variable type extracted from debug context interface Variable { identifier: string; + /** Formatted type string, for display. */ type?: string; + /** Raw ethdebug type specifier, for value decoding. */ + rawType?: unknown; pointer?: unknown; } @@ -1094,7 +1105,7 @@ function VariablesDisplay({
{variable.identifier} {value !== undefined && ( - {formatAsDecimal(value)} + {value} )} {variable.type && ( {variable.type} @@ -1203,6 +1214,7 @@ function extractVariables(context: unknown): Variable[] { variables.push({ identifier: String(variable.identifier), type: variable.type ? formatType(variable.type) : undefined, + rawType: variable.type, pointer: variable.pointer, }); } @@ -1390,25 +1402,44 @@ function traceStepToState( } /** - * Resolve a single pointer against a machine state. + * Resolve a single pointer against a machine state, decoding the value + * region into a readable string when the variable's type is known. */ async function resolvePointer( pointer: unknown, state: Machine.State, + identifier?: string, + type?: unknown, ): Promise { const cursor = await dereference( pointer as Parameters[0], { state, templates: {} }, ); const view = await cursor.view(state); + const decode = (data: Data): string => + decodeValue(data, type as Parameters[1]); + + // Prefer the value region named after the variable. A memory-homed local's + // pointer is a group that also carries frame-scaffolding regions, so + // joining every region would surface the frame pointer alongside the value. + if (identifier) { + const region = view.regions.lookup[identifier]; + if (region) { + return decode(await view.read(region)); + } + } + // Fallback: no identifier-named region — read every region (covers + // pointers whose value region isn't identifier-named, e.g. anonymous + // single-region storage locals). const values: Data[] = []; for (const region of view.regions) { values.push(await view.read(region)); } if (values.length === 0) return "0x"; - if (values.length === 1) return values[0].toHex(); + if (values.length === 1) return decode(values[0]); + // Composite (multiple regions) — not a scalar; show raw hex words. return values.map((d) => d.toHex()).join(", "); }