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;