From 6e70aca5d83ec4c2e148978c584746c8733dd6ab Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 16 Jul 2026 02:23:02 -0400 Subject: [PATCH 1/4] programs-react: resolve a local to its value region, not every region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveVariableValue joined every region of a resolved pointer, so a memory-homed local — whose pointer is a group carrying a frame scaffolding region plus the value region — rendered as two hex words (the frame pointer and the value) instead of one. Select the value region by the variable's identifier via Cursor.Regions.lookup (the last concrete region with that name) and read just that region. Falls back to the previous all-regions behavior when no identifier-named region exists, so nothing else regresses. This is the scalar-first slice of the value-reduce layer; it also sets up string/array locals later. Verified against a real trace: a frame-relative local resolved to a single clean value (0xaaaa) instead of frame-pointer + value. --- .../src/components/TraceContext.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index 795f3f2f44..60230250c1 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -229,6 +229,7 @@ async function resolveVariableValue( pointer: Pointer, step: TraceStep, templates: Pointer.Templates, + identifier?: string, ): Promise { const state = traceStepToMachineState(step); const cursor = await dereference(pointer, { @@ -237,7 +238,20 @@ async function resolveVariableValue( }); const view = await cursor.view(state); - // Collect values from all regions + // 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. + if (identifier) { + const region = view.regions.lookup[identifier]; + if (region) { + return (await view.read(region)).toHex(); + } + } + + // 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); @@ -330,6 +344,7 @@ export function TraceProvider({ v.pointer as Pointer, currentStep, templates, + v.identifier, ); if (!cancelled) { resolved[index] = { @@ -427,6 +442,7 @@ export function TraceProvider({ ptr as Pointer, step, templates, + names?.[i], ); args[i] = { ...args[i], value }; } catch (err) { From b94b8ea47f1f84d9c9a48c40b25b28111f4494ad Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 16 Jul 2026 03:46:20 -0400 Subject: [PATCH 2/4] web: narrow the trace-drawer's local rendering to the value region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs trace playground's drawer resolves variable/argument values with its own resolvePointer, which had the same all-regions-join bug as programs-react's resolveVariableValue — a memory-homed local rendered as frame pointer + value. Apply the identical scalar-first reduce here: select the value region by the variable/argument identifier via Cursor.Regions.lookup, falling back to the all-regions read when there is no identifier-named region. This is the surface the trace-playground preview actually shows, so the combined preview now renders locals as a single clean value. --- .../src/theme/ProgramExample/TraceDrawer.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index 1d734c43f3..fba92ec130 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -329,7 +329,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 +380,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), + ); } catch { // leave unresolved } @@ -1395,6 +1398,7 @@ function traceStepToState( async function resolvePointer( pointer: unknown, state: Machine.State, + identifier?: string, ): Promise { const cursor = await dereference( pointer as Parameters[0], @@ -1402,6 +1406,19 @@ async function resolvePointer( ); const view = await cursor.view(state); + // 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 (await view.read(region)).toHex(); + } + } + + // 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)); From 5b1e20d0bd72dd4fd3f4b2ad257a3233fdc6cc4e Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 16 Jul 2026 03:46:34 -0400 Subject: [PATCH 3/4] pointers: add scalar-first type-directed value decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds decodeValue(data, type): a pure, framework-agnostic function that turns a resolved value region's bytes + the variable's static type into a readable string (uint256 -> "2", not 0x00..02) — the scalar-first slice of the value-rendering reduce layer. Handles uint (decimal), int (two's-complement over the region width), bool, address (EIP-55 checksummed), and static bytesN (left-aligned). Dynamic bytes/string, aggregates, fixed/ufixed, enum, unresolved {id} type refs, and missing types fall back to raw hex so callers never break. --- packages/pointers/package.json | 4 + packages/pointers/src/decode.test.ts | 135 +++++++++++++++++++++++++++ packages/pointers/src/decode.ts | 125 +++++++++++++++++++++++++ packages/pointers/src/index.ts | 1 + 4 files changed, 265 insertions(+) create mode 100644 packages/pointers/src/decode.test.ts create mode 100644 packages/pointers/src/decode.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"; From 040a987b295aebf480842b43f8d51ec60c37dd64 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 16 Jul 2026 17:20:50 -0400 Subject: [PATCH 4/4] web/programs-react: decode resolved local values by type Wire debugger's decodeValue into both variable resolvers so a located local renders as a readable value instead of raw hex: uint -> decimal, int -> signed, bool -> true/false, address -> checksummed, static bytesN -> hex; composite/unknown fall back to hex. The value region is already narrowed by identifier (the reduce); this decodes that region's bytes with the variable's static type. - programs-react resolveVariableValue: thread the variable type, decode the selected/single region. - TraceDrawer resolvePointer: same, keeping the raw type specifier on the extracted Variable; drop the render-side formatAsDecimal since decodeValue returns the final display string. Verified on a real trace: uint locals a/b render as 43690/48059. --- .../src/components/TraceContext.tsx | 17 +++++++----- .../src/theme/ProgramExample/TraceDrawer.tsx | 26 ++++++++++++++----- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index 60230250c1..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, @@ -230,6 +230,7 @@ async function resolveVariableValue( step: TraceStep, templates: Pointer.Templates, identifier?: string, + type?: unknown, ): Promise { const state = traceStepToMachineState(step); const cursor = await dereference(pointer, { @@ -237,16 +238,19 @@ 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. + // 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 (await view.read(region)).toHex(); + return decode(await view.read(region)); } } @@ -262,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(", "); } @@ -345,6 +349,7 @@ export function TraceProvider({ currentStep, templates, v.identifier, + v.type, ); if (!cancelled) { resolved[index] = { diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index fba92ec130..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, @@ -382,7 +387,7 @@ function TraceDrawerContent(): JSX.Element { try { next.set( v.identifier, - await resolvePointer(v.pointer, state, v.identifier), + await resolvePointer(v.pointer, state, v.identifier, v.rawType), ); } catch { // leave unresolved @@ -1076,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; } @@ -1097,7 +1105,7 @@ function VariablesDisplay({
{variable.identifier} {value !== undefined && ( - {formatAsDecimal(value)} + {value} )} {variable.type && ( {variable.type} @@ -1206,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, }); } @@ -1393,18 +1402,22 @@ 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 @@ -1412,7 +1425,7 @@ async function resolvePointer( if (identifier) { const region = view.regions.lookup[identifier]; if (region) { - return (await view.read(region)).toHex(); + return decode(await view.read(region)); } } @@ -1425,7 +1438,8 @@ async function resolvePointer( } 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(", "); }