From 98db7663752a51f7f10e86f73da777d051e839c9 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 16 Jul 2026 17:51:50 -0400 Subject: [PATCH] web: render local variables as decoded, single values Resolve each local to the pointer region named after it, rather than every region of its pointer, and decode the region's bytes to a value by the local's type (replacing the raw formatAsDecimal fallback). The decoder lives with the display, in programs-react's utils, and decodes the exact bytes that bugc's pointers name: uint and int as decimal, bool, and address with an EIP-55 checksum. Other kinds show as hex. Add an address round-trip trace-playground example (`idn(owner: address)`) so a decoded, checksummed address is visible when stepping into the call. --- packages/programs-react/CHANGELOG.md | 7 +++ packages/programs-react/package.json | 1 + .../src/components/TraceContext.tsx | 29 ++++++++++-- packages/programs-react/src/index.ts | 1 + .../src/utils/decodeValue.test.ts | 42 +++++++++++++++++ .../programs-react/src/utils/decodeValue.ts | 46 +++++++++++++++++++ packages/programs-react/src/utils/index.ts | 2 + .../core-schemas/programs/tracing-examples.ts | 18 ++++++++ .../web/docs/explore/trace-playground.mdx | 15 ++++++ .../src/theme/ProgramExample/TraceDrawer.tsx | 36 +++++++++++++-- 10 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 packages/programs-react/src/utils/decodeValue.test.ts create mode 100644 packages/programs-react/src/utils/decodeValue.ts diff --git a/packages/programs-react/CHANGELOG.md b/packages/programs-react/CHANGELOG.md index 2124c451f3..03a991e7c8 100644 --- a/packages/programs-react/CHANGELOG.md +++ b/packages/programs-react/CHANGELOG.md @@ -7,6 +7,12 @@ root [`CHANGELOG.md`](../../CHANGELOG.md). ## Unreleased +### Changed + +- The trace shows the value of a local variable as one value decoded by its + type, read from the region named after the variable, instead of the hex of + all the regions of its pointer ([#270]). + ## 0.1.0-preview.1 — 2026-10-03 Updated `@ethdebug/format` to `0.1.0-draft.1` and `@ethdebug/pointers` to @@ -35,6 +41,7 @@ The version scheme changed: prerelease versions are now `preview.`, and First publication. +[#270]: https://github.com/ethdebug/format/pull/270 [#298]: https://github.com/ethdebug/format/pull/298 [#299]: https://github.com/ethdebug/format/pull/299 [#300]: https://github.com/ethdebug/format/pull/300 diff --git a/packages/programs-react/package.json b/packages/programs-react/package.json index c0e8a2a698..de03f2d8ec 100644 --- a/packages/programs-react/package.json +++ b/packages/programs-react/package.json @@ -54,6 +54,7 @@ "@ethdebug/pointers": "^0.1.0-preview.1", "@shikijs/langs": "^2.5.0", "@shikijs/themes": "^2.5.0", + "ethereum-cryptography": "^2.2.1", "shiki": "^2.5.0" }, "devDependencies": { diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index 943a3eec44..aa738877d8 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -23,6 +23,7 @@ import { buildCallStack, } from "#utils/mockTrace"; import { traceStepToMachineState } from "#utils/traceState"; +import { decodeValue } from "#utils/decodeValue"; import { effectiveContextForStep } from "#utils/effectiveContext"; /** @@ -230,6 +231,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, { @@ -237,8 +240,23 @@ async function resolveVariableValue( templates, }); const view = await cursor.view(state); + const decode = (data: Data): string => decodeValue(data, type); + + // 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); @@ -249,12 +267,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(", "); } @@ -357,6 +375,8 @@ export function TraceProvider({ v.pointer as Pointer, currentStep, templates, + v.identifier, + v.type, ); if (!cancelled) { resolved[index] = { @@ -460,6 +480,7 @@ export function TraceProvider({ ptr as Pointer, step, templates, + names?.[i], ); args[i] = { ...args[i], value }; } catch (err) { diff --git a/packages/programs-react/src/index.ts b/packages/programs-react/src/index.ts index 426c4731d4..f732e39bf7 100644 --- a/packages/programs-react/src/index.ts +++ b/packages/programs-react/src/index.ts @@ -72,6 +72,7 @@ export { traceStepToMachineState, effectiveContextForStep, type EffectiveContextInput, + decodeValue, type TraceStep, type MockTraceSpec, } from "#utils/index"; diff --git a/packages/programs-react/src/utils/decodeValue.test.ts b/packages/programs-react/src/utils/decodeValue.test.ts new file mode 100644 index 0000000000..231f859b81 --- /dev/null +++ b/packages/programs-react/src/utils/decodeValue.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { Data } from "@ethdebug/pointers"; + +import { decodeValue } from "./decodeValue.js"; + +const bytes = (hex: string) => Data.fromHex(hex); + +describe("decodeValue", () => { + it("decodes uint to decimal", () => { + expect(decodeValue(bytes("0xc8"), { kind: "uint", bits: 8 })).toBe("200"); + expect( + decodeValue(bytes(`0x${"ff".repeat(32)}`), { kind: "uint", bits: 256 }), + ).toBe((2n ** 256n - 1n).toString(10)); + }); + + it("decodes int as two's complement over the region width", () => { + const int8 = { kind: "int", bits: 8 }; + expect(decodeValue(bytes("0x05"), int8)).toBe("5"); + expect(decodeValue(bytes("0xfb"), int8)).toBe("-5"); + expect(decodeValue(bytes("0x80"), int8)).toBe("-128"); + }); + + it("decodes bool", () => { + expect(decodeValue(bytes("0x00"), { kind: "bool" })).toBe("false"); + expect(decodeValue(bytes("0x01"), { kind: "bool" })).toBe("true"); + }); + + it("decodes address with an EIP-55 checksum", () => { + expect( + decodeValue(bytes("0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"), { + kind: "address", + }), + ).toBe("0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"); + }); + + it("shows other kinds, or no type, as hex", () => { + const data = bytes("0xabcdef12"); + expect(decodeValue(data, { kind: "bytes", size: 4 })).toBe("0xabcdef12"); + expect(decodeValue(data, { kind: "string" })).toBe("0xabcdef12"); + expect(decodeValue(data, undefined)).toBe("0xabcdef12"); + }); +}); diff --git a/packages/programs-react/src/utils/decodeValue.ts b/packages/programs-react/src/utils/decodeValue.ts new file mode 100644 index 0000000000..702e511657 --- /dev/null +++ b/packages/programs-react/src/utils/decodeValue.ts @@ -0,0 +1,46 @@ +/** + * Display-only value decoding for the trace views. It turns the bytes of + * a local's value region into a readable string, by the local's type. + * + * bugc's pointers name exactly a scalar's bytes (an address is 20 bytes, + * a bool is 1 byte), so this decodes the region as given. Kinds other + * than uint, int, bool and address show as hex. + */ +import { keccak256 } from "ethereum-cryptography/keccak"; +import type { Data } from "@ethdebug/pointers"; + +export function decodeValue(data: Data, type: unknown): string { + const kind = (type as { kind?: unknown } | undefined)?.kind; + switch (kind) { + case "uint": + return data.asUint().toString(10); + case "int": + return decodeInt(data); + case "bool": + return data.asUint() === 0n ? "false" : "true"; + case "address": + return checksum(data.resizeTo(20).toHex().slice(2)); + default: + return data.toHex(); + } +} + +/** Two's complement over the region's byte width. */ +function decodeInt(data: Data): string { + if (data.length === 0) return "0"; + const bits = BigInt(data.length * 8); + const raw = data.asUint(); + const value = raw >= 1n << (bits - 1n) ? raw - (1n << bits) : raw; + return value.toString(10); +} + +/** EIP-55 checksum of a lowercase hex address (no `0x`). */ +function checksum(lowerHex: string): string { + const hash = keccak256(new TextEncoder().encode(lowerHex)); + let out = "0x"; + for (let i = 0; i < lowerHex.length; i++) { + const nibble = i % 2 === 0 ? hash[i >> 1] >> 4 : hash[i >> 1] & 0x0f; + out += nibble >= 8 ? lowerHex[i].toUpperCase() : lowerHex[i]; + } + return out; +} diff --git a/packages/programs-react/src/utils/index.ts b/packages/programs-react/src/utils/index.ts index 881167bb3e..3f2dc626be 100644 --- a/packages/programs-react/src/utils/index.ts +++ b/packages/programs-react/src/utils/index.ts @@ -33,3 +33,5 @@ export { effectiveContextForStep, type EffectiveContextInput, } from "./effectiveContext.js"; + +export { decodeValue } from "./decodeValue.js"; 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 24923b0192..ab94ecbc5d 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -27,6 +27,7 @@ import { Executor, createTraceCollector, type TraceStep } from "@ethdebug/evm"; import { dereference, Data, type Machine } from "@ethdebug/pointers"; import { buildCallStack, + decodeValue, effectiveContextForStep, extractCallInfoFromInstruction, extractTransformFromInstruction, @@ -349,7 +350,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] = { @@ -400,7 +401,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 } @@ -1093,7 +1097,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; } @@ -1114,7 +1121,7 @@ function VariablesDisplay({
{variable.identifier} {value !== undefined && ( - {formatAsDecimal(value)} + {value} )} {variable.type && ( {variable.type} @@ -1223,6 +1230,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, }); } @@ -1410,25 +1418,43 @@ 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); + + // 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(", "); }