From 5eb5e9a14efb006fd4acc88eeceeb1219914e60d Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 16 Jul 2026 17:23:15 -0400 Subject: [PATCH 1/3] pointers: add scalar-first type-directed value decoder --- packages/pointers/package.json | 4 + packages/pointers/src/decode.test.ts | 135 +++++++++++++++++++ packages/pointers/src/decode.ts | 125 +++++++++++++++++ packages/pointers/src/index.ts | 1 + packages/pointers/src/resolve-decode.test.ts | 132 ++++++++++++++++++ 5 files changed, 397 insertions(+) create mode 100644 packages/pointers/src/decode.test.ts create mode 100644 packages/pointers/src/decode.ts create mode 100644 packages/pointers/src/resolve-decode.test.ts 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); + }); +}); From ba49f46e31bf3f4d58586944de8e59ee0798d40d Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 16 Jul 2026 17:25:47 -0400 Subject: [PATCH 2/3] bugc: sound per-instruction local-variable debug snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit a `variables` context at each instruction naming the in-scope locals and, for each, a pointer to the memory word holding its current value — a per-instruction snapshot the tracer resolves and decodes. A sub-word scalar (address, bool, intN/uintN, bytesN) is homed by a full-word MSTORE, so its value occupies the whole 32-byte word. Name the full word and let the type-directed decoder extract the meaningful bytes, rather than the type's narrow byte width at the word start — which, for a right-aligned value, is the zero padding, so an address or bool would otherwise resolve to zero. uint256 was already correct (its width is a full word). Attach the pointer only when the local solely occupies its word: the allocator byte-packs several sub-word scalars into one word and their full-word stores clobber each other, so a packed sub-word local has no recoverable value — it is emitted type-only ("in scope, no value") until codegen stops byte-packing sub-word scalars. --- .../debug/local-variables.resolve.test.ts | 142 +++++ .../debug/local-variables.soundness.test.ts | 422 +++++++++++++++ .../src/evmgen/debug/local-variables.test.ts | 154 ++++++ .../bugc/src/evmgen/debug/local-variables.ts | 487 ++++++++++++++++++ .../generation/control-flow/terminator.ts | 17 + packages/bugc/src/evmgen/pass.ts | 6 + packages/bugc/src/ir/spec/function.ts | 8 + packages/bugc/src/irgen/generate/process.ts | 12 + packages/bugc/src/irgen/generate/state.ts | 8 +- .../src/irgen/generate/statements/block.ts | 2 +- .../src/irgen/generate/statements/declare.ts | 13 +- 11 files changed, 1266 insertions(+), 5 deletions(-) create mode 100644 packages/bugc/src/evmgen/debug/local-variables.resolve.test.ts create mode 100644 packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts create mode 100644 packages/bugc/src/evmgen/debug/local-variables.test.ts create mode 100644 packages/bugc/src/evmgen/debug/local-variables.ts 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, From a682ed6d65fdef50b79bb5c157703e372e47520e Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 16 Jul 2026 17:51:50 -0400 Subject: [PATCH 3/3] web: render local variables as decoded, single values Resolve each local to the pointer region holding its current value rather than every region it has occupied, narrow the trace drawer's local rendering to that region, and decode the resolved bytes to a typed value (replacing the raw formatAsDecimal fallback). Add an address round-trip trace-playground example (`idn(owner: address)`) so a decoded, EIP-55-checksummed non-uint value is visible when stepping into the call. --- .../src/components/TraceContext.tsx | 31 ++++++++++--- .../core-schemas/programs/tracing-examples.ts | 18 ++++++++ .../web/docs/explore/trace-playground.mdx | 15 +++++++ .../src/theme/ProgramExample/TraceDrawer.tsx | 43 ++++++++++++++++--- 4 files changed, 96 insertions(+), 11 deletions(-) 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(", "); }