diff --git a/packages/programs-react/package.json b/packages/programs-react/package.json index 2d207f5b3..0baa592be 100644 --- a/packages/programs-react/package.json +++ b/packages/programs-react/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@ethdebug/format": "^0.1.0-0", + "@ethdebug/pointers": "^0.1.0-0", "@shikijs/langs": "^2.5.0", "@shikijs/themes": "^2.5.0", "shiki": "^2.5.0" diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index a5201bfd0..c21874722 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -7,14 +7,17 @@ import React, { useContext, useState, useCallback, + useEffect, useMemo, } from "react"; -import type { Program } from "@ethdebug/format"; +import type { Pointer, Program } from "@ethdebug/format"; +import { dereference, Data } from "@ethdebug/pointers"; import { type TraceStep, extractVariablesFromInstruction, buildPcToInstructionMap, } from "#utils/mockTrace"; +import { traceStepToMachineState } from "#utils/traceState"; /** * A variable with its resolved value. @@ -93,10 +96,50 @@ export interface TraceProviderProps { program: Program; /** Initial step index (default: 0) */ initialStepIndex?: number; + /** Pointer templates for dereference (default: {}) */ + templates?: Pointer.Templates; + /** Whether to resolve variable values (default: true) */ + resolveVariables?: boolean; /** Children to render */ children: React.ReactNode; } +/** + * Resolve a single variable's pointer against machine + * state, returning the hex-formatted value. + */ +async function resolveVariableValue( + pointer: Pointer, + step: TraceStep, + templates: Pointer.Templates, +): Promise { + const state = traceStepToMachineState(step); + const cursor = await dereference(pointer, { + state, + templates, + }); + const view = await cursor.view(state); + + // Collect values from all regions + const values: Data[] = []; + for (const region of view.regions) { + const data = await view.read(region); + values.push(data); + } + + if (values.length === 0) { + return "0x"; + } + + // Single region: return its hex value + if (values.length === 1) { + return values[0].toHex(); + } + + // Multiple regions: concatenate hex values + return values.map((d) => d.toHex()).join(", "); +} + /** * Provides trace context to child components. */ @@ -104,6 +147,8 @@ export function TraceProvider({ trace, program, initialStepIndex = 0, + templates = {}, + resolveVariables: shouldResolve = true, children, }: TraceProviderProps): JSX.Element { const [currentStepIndex, setCurrentStepIndex] = useState( @@ -120,23 +165,81 @@ export function TraceProvider({ ? pcToInstruction.get(currentStep.pc) : undefined; - // Extract variables from current instruction - const currentVariables = useMemo(() => { + // Extract variable metadata (synchronous) + const extractedVars = useMemo(() => { if (!currentInstruction) { return []; } + return extractVariablesFromInstruction(currentInstruction); + }, [currentInstruction]); + + // Async variable resolution + const [currentVariables, setCurrentVariables] = useState( + [], + ); + + useEffect(() => { + if (extractedVars.length === 0) { + setCurrentVariables([]); + return; + } - const vars = extractVariablesFromInstruction(currentInstruction); - return vars.map((v) => ({ + // Immediately show variables with no values + const initial: ResolvedVariable[] = extractedVars.map((v) => ({ identifier: v.identifier, type: v.type, pointer: v.pointer, - // Value resolution would require the full @ethdebug/pointers machinery - // For now we just show the variable metadata value: undefined, error: undefined, })); - }, [currentInstruction]); + setCurrentVariables(initial); + + if (!shouldResolve || !currentStep) { + return; + } + + // Track whether effect is still current + let cancelled = false; + + // Resolve each variable with a pointer in parallel + const resolved = [...initial]; + const promises = extractedVars.map(async (v, index) => { + if (!v.pointer) { + return; + } + + try { + const value = await resolveVariableValue( + v.pointer as Pointer, + currentStep, + templates, + ); + if (!cancelled) { + resolved[index] = { + ...resolved[index], + value, + }; + setCurrentVariables([...resolved]); + } + } catch (err) { + if (!cancelled) { + resolved[index] = { + ...resolved[index], + error: err instanceof Error ? err.message : String(err), + }; + setCurrentVariables([...resolved]); + } + } + }); + + Promise.all(promises).catch(() => { + // Individual errors already handled above + }); + + return () => { + cancelled = true; + }; + }, [extractedVars, currentStep, shouldResolve, templates]); const stepForward = useCallback(() => { setCurrentStepIndex((prev) => Math.min(prev + 1, trace.length - 1)); diff --git a/packages/programs-react/src/index.ts b/packages/programs-react/src/index.ts index aa32365c2..599e8ff1c 100644 --- a/packages/programs-react/src/index.ts +++ b/packages/programs-react/src/index.ts @@ -57,6 +57,7 @@ export { type ContextThunk, type FindSourceRangeOptions, type ResolverOptions, + traceStepToMachineState, type TraceStep, type MockTraceSpec, } from "#utils/index"; diff --git a/packages/programs-react/src/utils/index.ts b/packages/programs-react/src/utils/index.ts index 9e24ab67b..5f750e9a0 100644 --- a/packages/programs-react/src/utils/index.ts +++ b/packages/programs-react/src/utils/index.ts @@ -21,3 +21,5 @@ export { type TraceStep, type MockTraceSpec, } from "./mockTrace.js"; + +export { traceStepToMachineState } from "./traceState.js"; diff --git a/packages/programs-react/src/utils/traceState.ts b/packages/programs-react/src/utils/traceState.ts new file mode 100644 index 000000000..8b5643a6d --- /dev/null +++ b/packages/programs-react/src/utils/traceState.ts @@ -0,0 +1,144 @@ +/** + * Adapter for converting TraceStep to Machine.State. + * + * Bridges the trace data from programs-react into the + * Machine.State interface required by @ethdebug/pointers + * for pointer dereferencing. + */ + +import { type Machine, Data } from "@ethdebug/pointers"; +import type { TraceStep } from "./mockTrace.js"; + +/** + * Convert a TraceStep into a Machine.State suitable for + * pointer dereferencing. + * + * @param step - The trace step with EVM state + * @returns A Machine.State backed by the step's data + */ +export function traceStepToMachineState(step: TraceStep): Machine.State { + // Build stack entries (Data objects, 32-byte padded) + const stackEntries = (step.stack || []).map((entry) => + typeof entry === "string" + ? Data.fromHex(entry).padUntilAtLeast(32) + : Data.fromUint(entry).padUntilAtLeast(32), + ); + + // Parse memory from hex string + const memoryData = step.memory ? Data.fromHex(step.memory) : Data.zero(); + + // Build storage map (normalized 32-byte keys) + const storageMap = new Map(); + for (const [slot, value] of Object.entries(step.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 = Number(depth); + if (index >= stackEntries.length) { + throw new Error( + `Stack underflow: depth ${depth} ` + + `exceeds stack size ${stackEntries.length}`, + ); + } + const entry = stackEntries[index]; + if (!slice) { + return entry; + } + const { offset, length } = slice; + return Data.fromBytes( + entry.slice(Number(offset), Number(offset + length)), + ); + }, + }; + + const memory = makeBytesReader(memoryData); + + const storage: 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)), + ); + }, + }; + + // Returndata from the step, if available + const returndataData = step.returndata + ? Data.fromHex(step.returndata) + : Data.zero(); + + return { + get traceIndex() { + return Promise.resolve(0n); + }, + get programCounter() { + return Promise.resolve(BigInt(step.pc)); + }, + get opcode() { + return Promise.resolve(step.opcode); + }, + stack, + memory, + storage, + calldata: makeBytesReader(Data.zero()), + returndata: makeBytesReader(returndataData), + code: makeBytesReader(Data.zero()), + transient: makeEmptyWordsReader(), + }; +} + +/** + * Create a Machine.State.Bytes reader from a Data buffer. + */ +function makeBytesReader(data: Data): Machine.State.Bytes { + return { + 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) { + // Zero-pad reads beyond the buffer + 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)); + }, + }; +} + +/** + * Create an empty Machine.State.Words reader (returns + * zero for all slots). + */ +function makeEmptyWordsReader(): Machine.State.Words { + return { + 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)), + ); + }, + }; +}