From f77281459d5562c81201e87919f34cd753c6a5d6 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 2 Apr 2026 07:31:08 -0400 Subject: [PATCH] Resolve argument values in call stack display Store argument pointers on call stack frames and resolve them against historical machine state at each frame's invoke step. Display as "add(a: 3, b: 4)" in both CallStackDisplay and TraceDrawer breadcrumbs. When the compiler emits duplicate invoke contexts (caller JUMP + callee JUMPDEST), use the callee entry step for resolution since argument pointers reference stack slots valid at the JUMPDEST, not the JUMP. Fix stack peek indexing in both traceStepToMachineState and traceStepToState: depth 0 should read from the top of the stack (last array element), not the bottom. The evm package stores stacks bottom-first. Values are cached by step index to avoid re-resolving unchanged frames. Small values (<=9999) display as decimal, larger values as hex. --- .../src/components/CallStackDisplay.tsx | 43 ++- .../src/components/TraceContext.tsx | 113 +++++++ .../programs-react/src/components/index.ts | 1 + packages/programs-react/src/index.ts | 1 + .../programs-react/src/utils/mockTrace.ts | 35 ++- .../programs-react/src/utils/traceState.ts | 4 +- .../src/theme/ProgramExample/TraceDrawer.tsx | 277 +++++++++++++++++- 7 files changed, 448 insertions(+), 26 deletions(-) diff --git a/packages/programs-react/src/components/CallStackDisplay.tsx b/packages/programs-react/src/components/CallStackDisplay.tsx index 7f9b087c6..09e2bf7aa 100644 --- a/packages/programs-react/src/components/CallStackDisplay.tsx +++ b/packages/programs-react/src/components/CallStackDisplay.tsx @@ -19,10 +19,49 @@ export interface CallStackDisplayProps { * Shows function names separated by arrows, e.g.: * main() -> transfer() -> _update() */ +function formatArgs( + frame: { identifier?: string; stepIndex: number }, + resolvedCallStack: Array<{ + stepIndex: number; + resolvedArgs?: Array<{ + name: string; + value?: string; + }>; + }>, +): string { + const resolved = resolvedCallStack.find( + (r) => r.stepIndex === frame.stepIndex, + ); + if (!resolved?.resolvedArgs) { + return ""; + } + return resolved.resolvedArgs + .map((arg) => { + if (arg.value === undefined) { + return arg.name; + } + const decimal = formatAsDecimal(arg.value); + return `${arg.name}: ${decimal}`; + }) + .join(", "); +} + +function formatAsDecimal(hex: string): string { + try { + const n = BigInt(hex); + if (n <= 9999n) { + return n.toString(); + } + return hex; + } catch { + return hex; + } +} + export function CallStackDisplay({ className = "", }: CallStackDisplayProps): JSX.Element { - const { callStack, jumpToStep } = useTraceContext(); + const { callStack, resolvedCallStack, jumpToStep } = useTraceContext(); if (callStack.length === 0) { return ( @@ -53,7 +92,7 @@ export function CallStackDisplay({ {frame.identifier || "(anonymous)"} - ({frame.argumentNames ? frame.argumentNames.join(", ") : ""}) + ({formatArgs(frame, resolvedCallStack)}) diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index c086daf26..581b72143 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useMemo, + useRef, } from "react"; import type { Pointer, Program } from "@ethdebug/format"; import { dereference, Data } from "@ethdebug/pointers"; @@ -119,6 +120,24 @@ export interface ResolvedCallInfo { pointerRefs: ResolvedPointerRef[]; } +/** + * A call frame with resolved argument values. + */ +export interface ResolvedCallFrame { + /** Function name */ + identifier?: string; + /** The step index where this call was invoked */ + stepIndex: number; + /** The call type */ + callType?: "internal" | "external" | "create"; + /** Argument names paired with resolved values */ + resolvedArgs?: Array<{ + name: string; + value?: string; + error?: string; + }>; +} + /** * State provided by the Trace context. */ @@ -139,6 +158,8 @@ export interface TraceState { currentVariables: ResolvedVariable[]; /** Call stack at current step */ callStack: CallFrame[]; + /** Call stack with resolved argument values */ + resolvedCallStack: ResolvedCallFrame[]; /** Call info for current instruction (if any) */ currentCallInfo: ResolvedCallInfo | undefined; /** Whether we're at the first step */ @@ -339,6 +360,97 @@ export function TraceProvider({ [trace, pcToInstruction, currentStepIndex], ); + // Resolve argument values for call stack frames. + // Cache by stepIndex so we don't re-resolve frames that + // haven't changed when the user steps forward. + const argCacheRef = useRef>( + new Map(), + ); + + const [resolvedCallStack, setResolvedCallStack] = useState< + ResolvedCallFrame[] + >([]); + + useEffect(() => { + if (callStack.length === 0) { + setResolvedCallStack([]); + return; + } + + // Build initial resolved frames using cached values + const initial: ResolvedCallFrame[] = callStack.map((frame) => ({ + identifier: frame.identifier, + stepIndex: frame.stepIndex, + callType: frame.callType, + resolvedArgs: argCacheRef.current.get(frame.stepIndex), + })); + setResolvedCallStack(initial); + + if (!shouldResolve) { + return; + } + + let cancelled = false; + const resolved = [...initial]; + + // Resolve frames that aren't cached yet + const promises = callStack.map(async (frame, index) => { + if (argCacheRef.current.has(frame.stepIndex)) { + return; + } + + const names = frame.argumentNames; + const pointers = frame.argumentPointers; + if (!pointers || pointers.length === 0) { + return; + } + + const step = trace[frame.stepIndex]; + if (!step) { + return; + } + + const args: NonNullable = pointers.map( + (_, i) => ({ + name: names?.[i] ?? `_${i}`, + }), + ); + + const resolvePromises = pointers.map(async (ptr, i) => { + try { + const value = await resolveVariableValue( + ptr as Pointer, + step, + templates, + ); + args[i] = { ...args[i], value }; + } catch (err) { + args[i] = { + ...args[i], + error: err instanceof Error ? err.message : String(err), + }; + } + }); + + await Promise.all(resolvePromises); + + if (!cancelled) { + argCacheRef.current.set(frame.stepIndex, args); + resolved[index] = { + ...resolved[index], + resolvedArgs: args, + }; + setResolvedCallStack([...resolved]); + } + }); + + Promise.all(promises).catch(() => {}); + + return () => { + cancelled = true; + }; + }, [callStack, shouldResolve, trace, templates]); + // Extract call info for current instruction (synchronous) const extractedCallInfo = useMemo((): CallInfo | undefined => { if (!currentInstruction) { @@ -488,6 +600,7 @@ export function TraceProvider({ currentInstruction, currentVariables, callStack, + resolvedCallStack, currentCallInfo, isAtStart: currentStepIndex === 0, isAtEnd: currentStepIndex >= trace.length - 1, diff --git a/packages/programs-react/src/components/index.ts b/packages/programs-react/src/components/index.ts index 222acf051..404294b74 100644 --- a/packages/programs-react/src/components/index.ts +++ b/packages/programs-react/src/components/index.ts @@ -23,6 +23,7 @@ export { type TraceProviderProps, type ResolvedVariable, type ResolvedCallInfo, + type ResolvedCallFrame, type ResolvedPointerRef, } from "./TraceContext.js"; diff --git a/packages/programs-react/src/index.ts b/packages/programs-react/src/index.ts index 2fa17ad36..6253933c2 100644 --- a/packages/programs-react/src/index.ts +++ b/packages/programs-react/src/index.ts @@ -32,6 +32,7 @@ export { type TraceProviderProps, type ResolvedVariable, type ResolvedCallInfo, + type ResolvedCallFrame, type ResolvedPointerRef, type TraceControlsProps, type TraceProgressProps, diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index 23d8483ed..ddbee9dfa 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -272,6 +272,8 @@ export interface CallFrame { callType?: "internal" | "external" | "create"; /** Named arguments (from invoke context) */ argumentNames?: string[]; + /** Individual argument pointers for value resolution */ + argumentPointers?: unknown[]; } /** @@ -310,12 +312,21 @@ export function buildCallStack( top.identifier === callInfo.identifier && top.callType === callInfo.callType && top.stepIndex === i - 1; - if (!isDuplicate) { + if (isDuplicate) { + // Use the callee entry step for resolution — + // the argument pointers reference stack slots + // that are valid at the JUMPDEST, not the JUMP + const argResult = extractArgInfo(instruction); + top.stepIndex = i; + top.argumentPointers = argResult?.pointers; + } else { + const argResult = extractArgInfo(instruction); stack.push({ identifier: callInfo.identifier, stepIndex: i, callType: callInfo.callType, - argumentNames: extractArgNames(instruction), + argumentNames: argResult?.names, + argumentPointers: argResult?.pointers, }); } } else if (callInfo.kind === "return" || callInfo.kind === "revert") { @@ -330,16 +341,15 @@ export function buildCallStack( } /** - * Extract argument names from an instruction's invoke - * context, if present. + * Extract argument names and pointers from an + * instruction's invoke context, if present. */ -function extractArgNames( +function extractArgInfo( instruction: Program.Instruction, -): string[] | undefined { +): { names?: string[]; pointers?: unknown[] } | undefined { const ctx = instruction.context as Record | undefined; if (!ctx) return undefined; - // Find the invoke field (may be nested in gather) const invoke = findInvokeField(ctx); if (!invoke) return undefined; @@ -353,18 +363,23 @@ function extractArgNames( if (!Array.isArray(group)) return undefined; const names: string[] = []; - let hasAny = false; + const pointers: unknown[] = []; + let hasAnyName = false; for (const entry of group) { const name = entry.name as string | undefined; if (name) { names.push(name); - hasAny = true; + hasAnyName = true; } else { names.push("_"); } + pointers.push(entry); } - return hasAny ? names : undefined; + return { + names: hasAnyName ? names : undefined, + pointers, + }; } function findInvokeField( diff --git a/packages/programs-react/src/utils/traceState.ts b/packages/programs-react/src/utils/traceState.ts index 8b5643a6d..a62141383 100644 --- a/packages/programs-react/src/utils/traceState.ts +++ b/packages/programs-react/src/utils/traceState.ts @@ -39,8 +39,8 @@ export function traceStepToMachineState(step: TraceStep): Machine.State { return Promise.resolve(BigInt(stackEntries.length)); }, async peek({ depth, slice }) { - const index = Number(depth); - if (index >= stackEntries.length) { + const index = stackEntries.length - 1 - Number(depth); + if (index < 0 || index >= stackEntries.length) { throw new Error( `Stack underflow: depth ${depth} ` + `exceeds stack size ${stackEntries.length}`, diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index f14dcf3c4..e4cff9529 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -8,7 +8,13 @@ * - Step-through trace visualization */ -import React, { useState, useCallback, useEffect, useMemo } from "react"; +import React, { + useState, + useCallback, + useEffect, + useMemo, + useRef, +} from "react"; import BrowserOnly from "@docusaurus/BrowserOnly"; import { compile as bugCompile, Severity, type Evm } from "@ethdebug/bugc"; import { @@ -18,6 +24,7 @@ import { extractSourceRange, } from "@ethdebug/bugc-react"; import { Executor, createTraceCollector, type TraceStep } from "@ethdebug/evm"; +import { dereference, Data, type Machine } from "@ethdebug/pointers"; import { Drawer } from "@theme/Drawer"; import { useTracePlayground } from "./TracePlaygroundContext"; @@ -106,6 +113,7 @@ function TraceDrawerContent(): JSX.Element { stepIndex: number; callType?: string; argumentNames?: string[]; + argumentPointers?: unknown[]; }> = []; for (let i = 0; i <= currentStep && i < trace.length; i++) { @@ -129,12 +137,19 @@ function TraceDrawerContent(): JSX.Element { top.identifier === info.identifier && top.callType === info.callType && top.stepIndex === i - 1; - if (!isDuplicate) { + if (isDuplicate) { + // Use the callee entry step for resolution — + // argument pointers reference stack slots + // valid at the JUMPDEST, not the JUMP + top.stepIndex = i; + top.argumentPointers = info.argumentPointers; + } else { frames.push({ identifier: info.identifier, stepIndex: i, callType: info.callType, argumentNames: info.argumentNames, + argumentPointers: info.argumentPointers, }); } } else if (info.kind === "return" || info.kind === "revert") { @@ -147,6 +162,79 @@ function TraceDrawerContent(): JSX.Element { return frames; }, [trace, currentStep, pcToInstruction]); + // Resolve argument values for call stack frames + const argCacheRef = useRef>(new Map()); + + const [resolvedArgs, setResolvedArgs] = useState>( + new Map(), + ); + + useEffect(() => { + if (callStack.length === 0) { + setResolvedArgs(new Map()); + return; + } + + // Initialize with cached values + const initial = new Map(); + for (const frame of callStack) { + const cached = argCacheRef.current.get(frame.stepIndex); + if (cached) { + initial.set(frame.stepIndex, cached); + } + } + setResolvedArgs(new Map(initial)); + + let cancelled = false; + + const promises = callStack.map(async (frame) => { + if (argCacheRef.current.has(frame.stepIndex)) { + return; + } + + const ptrs = frame.argumentPointers; + const names = frame.argumentNames; + if (!ptrs || ptrs.length === 0) return; + + const step = trace[frame.stepIndex]; + if (!step) return; + + const state = traceStepToState(step, storage); + const args: ResolvedArg[] = ptrs.map((_, i) => ({ + name: names?.[i] ?? `_${i}`, + })); + + const resolvePromises = ptrs.map(async (ptr, i) => { + try { + const value = await resolvePointer(ptr, state); + args[i] = { ...args[i], value }; + } catch (err) { + args[i] = { + ...args[i], + error: err instanceof Error ? err.message : String(err), + }; + } + }); + + await Promise.all(resolvePromises); + + if (!cancelled) { + argCacheRef.current.set(frame.stepIndex, args); + setResolvedArgs((prev) => { + const next = new Map(prev); + next.set(frame.stepIndex, args); + return next; + }); + } + }); + + Promise.all(promises).catch(() => {}); + + return () => { + cancelled = true; + }; + }, [callStack, trace, storage]); + // Compile source and run trace in one shot. // Takes source directly to avoid stale-state issues. const compileAndTrace = useCallback(async (sourceCode: string) => { @@ -431,10 +519,7 @@ function TraceDrawerContent(): JSX.Element { type="button" > {frame.identifier || "(anonymous)"}( - {frame.argumentNames - ? frame.argumentNames.join(", ") - : ""} - ) + {formatFrameArgs(frame, resolvedArgs)}) )) @@ -627,6 +712,7 @@ interface CallInfoResult { identifier?: string; callType?: string; argumentNames?: string[]; + argumentPointers?: unknown[]; } /** @@ -646,11 +732,13 @@ function extractCallInfo(context: unknown): CallInfoResult | undefined { else if ("message" in inv) callType = "external"; else if ("create" in inv) callType = "create"; + const argInfo = extractArgInfoFromInvoke(inv); return { kind: "invoke", identifier: inv.identifier as string | undefined, callType, - argumentNames: extractArgNamesFromInvoke(inv), + argumentNames: argInfo?.names, + argumentPointers: argInfo?.pointers, }; } @@ -708,9 +796,9 @@ function formatCallBanner(info: CallInfoResult): string { } } -function extractArgNamesFromInvoke( +function extractArgInfoFromInvoke( inv: Record, -): string[] | undefined { +): { names?: string[]; pointers?: unknown[] } | undefined { const args = inv.arguments as Record | undefined; if (!args) return undefined; @@ -721,18 +809,23 @@ function extractArgNamesFromInvoke( if (!Array.isArray(group)) return undefined; const names: string[] = []; - let hasAny = false; + const pointers: unknown[] = []; + let hasAnyName = false; for (const entry of group) { const name = entry.name as string | undefined; if (name) { names.push(name); - hasAny = true; + hasAnyName = true; } else { names.push("_"); } + pointers.push(entry); } - return hasAny ? names : undefined; + return { + names: hasAnyName ? names : undefined, + pointers, + }; } /** @@ -808,4 +901,164 @@ function formatType(type: unknown): string { return JSON.stringify(type); } +function formatFrameArgs( + frame: { + stepIndex: number; + argumentNames?: string[]; + }, + resolved: Map, +): string { + const args = resolved.get(frame.stepIndex); + if (!args) { + return frame.argumentNames ? frame.argumentNames.join(", ") : ""; + } + return args + .map((arg) => { + if (arg.value === undefined) return arg.name; + const decimal = formatAsDecimal(arg.value); + return `${arg.name}: ${decimal}`; + }) + .join(", "); +} + +function formatAsDecimal(hex: string): string { + try { + const n = BigInt(hex); + if (n <= 9999n) return n.toString(); + return hex; + } catch { + return hex; + } +} + +/** + * Convert an evm TraceStep + storage into a Machine.State + * for pointer dereferencing. + */ +function traceStepToState( + step: TraceStep, + storage: Record, +): Machine.State { + const stackEntries = step.stack.map((v) => + Data.fromUint(v).padUntilAtLeast(32), + ); + + const memoryData = step.memory ? Data.fromBytes(step.memory) : Data.zero(); + + const storageMap = new Map(); + for (const [slot, value] of Object.entries(storage)) { + const key = Data.fromHex(slot).padUntilAtLeast(32).toHex(); + storageMap.set(key, Data.fromHex(value).padUntilAtLeast(32)); + } + + const stack: Machine.State.Stack = { + get length() { + return Promise.resolve(BigInt(stackEntries.length)); + }, + async peek({ depth, slice }) { + const index = stackEntries.length - 1 - Number(depth); + if (index < 0 || index >= stackEntries.length) { + throw new Error(`Stack underflow: depth ${depth}`); + } + const entry = stackEntries[index]; + if (!slice) return entry; + const { offset, length } = slice; + return Data.fromBytes( + entry.slice(Number(offset), Number(offset + length)), + ); + }, + }; + + const makeBytesReader = (data: Data): Machine.State.Bytes => ({ + get length() { + return Promise.resolve(BigInt(data.length)); + }, + async read({ slice }) { + const { offset, length } = slice; + const start = Number(offset); + const end = start + Number(length); + if (end > data.length) { + const result = new Uint8Array(Number(length)); + const available = Math.max(0, data.length - start); + if (available > 0 && start < data.length) { + result.set(data.slice(start, start + available), 0); + } + return Data.fromBytes(result); + } + return Data.fromBytes(data.slice(start, end)); + }, + }); + + const storageReader: Machine.State.Words = { + async read({ slot, slice }) { + const key = slot.padUntilAtLeast(32).toHex(); + const value = storageMap.get(key) || Data.zero().padUntilAtLeast(32); + if (!slice) return value; + const { offset, length } = slice; + return Data.fromBytes( + value.slice(Number(offset), Number(offset + length)), + ); + }, + }; + + const emptyWords: Machine.State.Words = { + async read({ slice }) { + const value = Data.zero().padUntilAtLeast(32); + if (!slice) return value; + const { offset, length } = slice; + return Data.fromBytes( + value.slice(Number(offset), Number(offset + length)), + ); + }, + }; + + return { + get traceIndex() { + return Promise.resolve(0n); + }, + get programCounter() { + return Promise.resolve(BigInt(step.pc)); + }, + get opcode() { + return Promise.resolve(step.opcode); + }, + stack, + memory: makeBytesReader(memoryData), + storage: storageReader, + calldata: makeBytesReader(Data.zero()), + returndata: makeBytesReader(Data.zero()), + code: makeBytesReader(Data.zero()), + transient: emptyWords, + }; +} + +/** + * Resolve a single pointer against a machine state. + */ +async function resolvePointer( + pointer: unknown, + state: Machine.State, +): Promise { + const cursor = await dereference( + pointer as Parameters[0], + { state, templates: {} }, + ); + const view = await cursor.view(state); + + 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(); + return values.map((d) => d.toHex()).join(", "); +} + +interface ResolvedArg { + name: string; + value?: string; + error?: string; +} + export default TraceDrawer;