diff --git a/packages/bugc/src/evmgen/call-contexts.test.ts b/packages/bugc/src/evmgen/call-contexts.test.ts new file mode 100644 index 000000000..c0a66d549 --- /dev/null +++ b/packages/bugc/src/evmgen/call-contexts.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import type * as Format from "@ethdebug/format"; + +/** + * Compile a BUG source and return the runtime program + */ +async function compileProgram(source: string): Promise { + const result = await compile({ + to: "bytecode", + source, + }); + + if (!result.success) { + const errors = result.messages.error ?? []; + const msgs = errors + .map((e: { message?: string }) => e.message ?? String(e)) + .join("\n"); + throw new Error(`Compilation failed:\n${msgs}`); + } + + return result.value.bytecode.runtimeProgram; +} + +/** + * Find instructions matching a predicate + */ +function findInstructions( + program: Format.Program, + predicate: (instr: Format.Program.Instruction) => boolean, +): Format.Program.Instruction[] { + return program.instructions.filter(predicate); +} + +describe("function call debug contexts", () => { + const source = `name CallContextTest; + +define { + function add(a: uint256, b: uint256) -> uint256 { + return a + b; + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = add(10, 20); +}`; + + it("should emit invoke context on caller JUMP", async () => { + const program = await compileProgram(source); + + // Find JUMP instructions with invoke context + const invokeJumps = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMP" && + !!(instr.context as Record)?.invoke, + ); + + expect(invokeJumps.length).toBeGreaterThanOrEqual(1); + + const ctx = (invokeJumps[0].context as Record)!; + const invoke = ctx.invoke as Record; + + expect(invoke.jump).toBe(true); + expect(invoke.identifier).toBe("add"); + + // Should have target pointer + const target = invoke.target as Record; + expect(target.pointer).toBeDefined(); + + // Should have argument pointers + const args = invoke.arguments as Record; + const pointer = args.pointer as Record; + const group = pointer.group as Array>; + + expect(group).toHaveLength(2); + // First arg (a) is deepest on stack + expect(group[0]).toEqual({ + location: "stack", + slot: 1, + }); + // Second arg (b) is on top + expect(group[1]).toEqual({ + location: "stack", + slot: 0, + }); + }); + + it("should emit return context on continuation JUMPDEST", async () => { + const program = await compileProgram(source); + + // Find JUMPDEST instructions with return context + const returnJumpdests = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMPDEST" && + !!(instr.context as Record)?.return, + ); + + expect(returnJumpdests.length).toBeGreaterThanOrEqual(1); + + const ctx = (returnJumpdests[0].context as Record)!; + const ret = ctx.return as Record; + + expect(ret.identifier).toBe("add"); + + // Should have data pointer to return value at + // TOS (stack slot 0) + const data = ret.data as Record; + const pointer = data.pointer as Record; + expect(pointer).toEqual({ + location: "stack", + slot: 0, + }); + }); + + it("should emit invoke context on callee entry JUMPDEST", async () => { + const program = await compileProgram(source); + + // Find JUMPDEST instructions with invoke context + // (the callee entry point, not the continuation) + const invokeJumpdests = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMPDEST" && + !!(instr.context as Record)?.invoke, + ); + + expect(invokeJumpdests.length).toBeGreaterThanOrEqual(1); + + const ctx = (invokeJumpdests[0].context as Record)!; + const invoke = ctx.invoke as Record; + + expect(invoke.jump).toBe(true); + expect(invoke.identifier).toBe("add"); + + // Should have argument pointers matching + // function parameters + const args = invoke.arguments as Record; + const pointer = args.pointer as Record; + const group = pointer.group as Array>; + + expect(group).toHaveLength(2); + }); + + it("should emit contexts in correct instruction order", async () => { + const program = await compileProgram(source); + + // The caller JUMP should come before the + // continuation JUMPDEST + const invokeJump = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMP" && + !!(instr.context as Record)?.invoke, + )[0]; + + const returnJumpdest = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMPDEST" && + !!(instr.context as Record)?.return, + )[0]; + + expect(invokeJump).toBeDefined(); + expect(returnJumpdest).toBeDefined(); + + // Invoke JUMP offset should be less than + // return JUMPDEST offset (caller comes first + // in bytecode) + expect(Number(invokeJump.offset)).toBeLessThan( + Number(returnJumpdest.offset), + ); + }); + + describe("void function calls", () => { + const voidSource = `name VoidCallTest; + +define { + function setVal( + s: uint256, v: uint256 + ) -> uint256 { + return v; + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = setVal(0, 42); +}`; + + it( + "should emit return context without data pointer " + "for void functions", + async () => { + // This tests that when a function returns a + // value, the return context includes data. + // (All our test functions return values, so + // data should always be present here.) + const program = await compileProgram(voidSource); + + const returnJumpdests = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMPDEST" && + !!(instr.context as Record)?.return, + ); + + expect(returnJumpdests.length).toBeGreaterThanOrEqual(1); + + const ctx = (returnJumpdests[0].context as Record)!; + const ret = ctx.return as Record; + expect(ret.identifier).toBe("setVal"); + // Since setVal returns a value, data should + // be present + expect(ret.data).toBeDefined(); + }, + ); + }); + + describe("nested function calls", () => { + const nestedSource = `name NestedCallTest; + +define { + function add( + a: uint256, b: uint256 + ) -> uint256 { + return a + b; + }; + function addThree( + x: uint256, y: uint256, z: uint256 + ) -> uint256 { + let sum1 = add(x, y); + let sum2 = add(sum1, z); + return sum2; + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = addThree(1, 2, 3); +}`; + + it("should emit invoke/return contexts for " + "nested calls", async () => { + const program = await compileProgram(nestedSource); + + // Should have invoke contexts for: + // 1. main -> addThree + // 2. addThree -> add (first call) + // 3. addThree -> add (second call) + // Plus callee entry JUMPDESTs + const invokeJumps = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMP" && + !!(instr.context as Record)?.invoke, + ); + + // At least 3 invoke JUMPs (main->addThree, + // addThree->add x2) + expect(invokeJumps.length).toBeGreaterThanOrEqual(3); + + // Check we have invokes for both functions + const invokeIds = invokeJumps.map( + (instr) => + ( + (instr.context as Record).invoke as Record< + string, + unknown + > + ).identifier, + ); + expect(invokeIds).toContain("addThree"); + expect(invokeIds).toContain("add"); + + // Should have return contexts for all + // continuation points + const returnJumpdests = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMPDEST" && + !!(instr.context as Record)?.return, + ); + + expect(returnJumpdests.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe("single-arg function", () => { + const singleArgSource = `name SingleArgTest; + +define { + function double(x: uint256) -> uint256 { + return x + x; + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = double(7); +}`; + + it("should emit single-element argument group", async () => { + const program = await compileProgram(singleArgSource); + + const invokeJumps = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMP" && + !!(instr.context as Record)?.invoke, + ); + + expect(invokeJumps.length).toBeGreaterThanOrEqual(1); + + const ctx = (invokeJumps[0].context as Record)!; + const invoke = ctx.invoke as Record; + const args = invoke.arguments as Record; + const pointer = args.pointer as Record; + const group = pointer.group as Array>; + + // Single arg at stack slot 0 + expect(group).toHaveLength(1); + expect(group[0]).toEqual({ + location: "stack", + slot: 0, + }); + }); + }); +}); diff --git a/packages/bugc/src/evmgen/generation/block.ts b/packages/bugc/src/evmgen/generation/block.ts index e425212fb..5fea7a285 100644 --- a/packages/bugc/src/evmgen/generation/block.ts +++ b/packages/bugc/src/evmgen/generation/block.ts @@ -2,6 +2,8 @@ * Block-level code generation */ +import type * as Ast from "#ast"; +import type * as Format from "@ethdebug/format"; import * as Ir from "#ir"; import type { Stack } from "#evm"; @@ -46,9 +48,13 @@ export function generate( // Initialize memory for first block if (isFirstBlock) { - // Always initialize the free memory pointer for consistency - // This ensures dynamic allocations start after static ones - result = result.then(initializeMemory(state.memory.nextStaticOffset)); + const sourceInfo = + func?.sourceId && func?.loc + ? { sourceId: func.sourceId, loc: func.loc } + : undefined; + result = result.then( + initializeMemory(state.memory.nextStaticOffset, sourceInfo), + ); } // Set JUMPDEST for non-first blocks @@ -69,11 +75,24 @@ export function generate( // Add JUMPDEST with continuation annotation if applicable if (isContinuation) { - const continuationDebug = { - context: { - remark: `call-continuation: resume after call to ${calledFunction}`, + // Return context describes state after JUMPDEST + // executes: TOS is the return value (if any). + // data pointer is required by the schema; for + // void returns, slot 0 is still valid (empty). + const returnCtx: Format.Program.Context.Return = { + return: { + identifier: calledFunction, + data: { + pointer: { + location: "stack" as const, + slot: 0, + }, + }, }, }; + const continuationDebug = { + context: returnCtx as Format.Program.Context, + }; result = result.then(JUMPDEST({ debug: continuationDebug })); } else { result = result.then(JUMPDEST()); @@ -90,6 +109,7 @@ export function generate( predBlock.terminator.dest ) { const destId = predBlock.terminator.dest; + const spillDebug = predBlock.terminator.operationDebug; result = result.then(annotateTop(destId)).then((s) => { const allocation = s.memory.allocations[destId]; if (!allocation) return s; @@ -98,7 +118,11 @@ export function generate( ...s, instructions: [ ...s.instructions, - { mnemonic: "DUP1" as const, opcode: 0x80 }, + { + mnemonic: "DUP1" as const, + opcode: 0x80, + debug: spillDebug, + }, { mnemonic: "PUSH2" as const, opcode: 0x61, @@ -106,8 +130,13 @@ export function generate( (allocation.offset >> 8) & 0xff, allocation.offset & 0xff, ], + debug: spillDebug, + }, + { + mnemonic: "MSTORE" as const, + opcode: 0x52, + debug: spillDebug, }, - { mnemonic: "MSTORE" as const, opcode: 0x52 }, ], }; }); @@ -191,21 +220,45 @@ function generatePhi( /** * Initialize the free memory pointer at runtime - * Sets the value at 0x40 to the next available memory location after static allocations + * Sets the value at 0x40 to the next available memory location + * after static allocations */ function initializeMemory( nextStaticOffset: number, + sourceInfo?: { sourceId: string; loc: Ast.SourceLocation }, ): Transition { const { PUSHn, MSTORE } = operations; - return ( - pipe() - // Push the static offset value (the value to store) - .then(PUSHn(BigInt(nextStaticOffset)), { as: "value" }) - // Push the free memory pointer location (0x40) (the offset) - .then(PUSHn(BigInt(Memory.regions.FREE_MEMORY_POINTER)), { as: "offset" }) - // Store the initial free pointer (expects [value, offset] on stack) - .then(MSTORE()) - .done() - ); + const debug = sourceInfo + ? { + context: { + gather: [ + { remark: "initialize free memory pointer" }, + { + code: { + source: { id: sourceInfo.sourceId }, + range: sourceInfo.loc, + }, + }, + ], + } as Format.Program.Context, + } + : { + context: { + remark: "initialize free memory pointer", + } as Format.Program.Context, + }; + + return pipe() + .then(PUSHn(BigInt(nextStaticOffset), { debug }), { + as: "value", + }) + .then( + PUSHn(BigInt(Memory.regions.FREE_MEMORY_POINTER), { + debug, + }), + { as: "offset" }, + ) + .then(MSTORE({ debug })) + .done(); } diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 44c4792da..1f418c9b4 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -1,3 +1,4 @@ +import type * as Format from "@ethdebug/format"; import type * as Ir from "#ir"; import type { Stack } from "#evm"; import type { State } from "#evmgen/state"; @@ -158,6 +159,10 @@ export function generateCallTerminator( return ((state: State): State => { let currentState: State = state as State; + // All call setup instructions map back to the call + // expression source location via operationDebug. + const debug = term.operationDebug; + // Clean the stack before setting up the call. // Values produced by block instructions that are only // used as call arguments will have been DUP'd by @@ -165,17 +170,12 @@ export function generateCallTerminator( // block terminator, all current stack values are dead // after the call — POP them so the function receives a // clean stack with only its arguments. - const cleanupDebug = { - context: { - remark: `call-preparation: clean stack for ${funcName}`, - }, - }; while (currentState.stack.length > 0) { currentState = { ...currentState, instructions: [ ...currentState.instructions, - { mnemonic: "POP", opcode: 0x50, debug: cleanupDebug }, + { mnemonic: "POP", opcode: 0x50, debug }, ], stack: currentState.stack.slice(1), brands: currentState.brands.slice(1) as Stack, @@ -185,11 +185,6 @@ export function generateCallTerminator( const returnPcPatchIndex = currentState.instructions.length; // Store return PC to memory at 0x60 - const returnPcDebug = { - context: { - remark: `call-preparation: store return address for ${funcName}`, - }, - }; currentState = { ...currentState, instructions: [ @@ -198,10 +193,15 @@ export function generateCallTerminator( mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0], - debug: returnPcDebug, + debug, + }, + { + mnemonic: "PUSH1", + opcode: 0x60, + immediates: [0x60], + debug, }, - { mnemonic: "PUSH1", opcode: 0x60, immediates: [0x60] }, - { mnemonic: "MSTORE", opcode: 0x52 }, + { mnemonic: "MSTORE", opcode: 0x52, debug }, ], patches: [ ...currentState.patches, @@ -216,22 +216,44 @@ export function generateCallTerminator( // Push arguments using loadValue. // Stack is clean, so loadValue will reload from memory // (for temps) or re-push (for consts). - const argsDebug = { - context: { - remark: `call-arguments: push ${args.length} argument(s) for ${funcName}`, - }, - }; for (const arg of args) { - currentState = loadValue(arg, { debug: argsDebug })(currentState); + currentState = loadValue(arg, { debug })(currentState); } - // Push function address and jump + // Push function address and jump. + // The JUMP gets an invoke context: after JUMP executes, + // the function has been entered with args on the stack. const funcAddrPatchIndex = currentState.instructions.length; - const invocationDebug = { - context: { - remark: `call-invocation: jump to function ${funcName}`, + + // Build argument pointers: after the JUMP, the callee + // sees args on the stack in order (first arg deepest). + const argPointers = args.map((_arg, i) => ({ + location: "stack" as const, + slot: args.length - 1 - i, + })); + + // Invoke context describes state after JUMP executes: + // the callee has been entered with args on the stack. + // target points to the function address at stack slot 0 + // (consumed by JUMP, but describes the call target). + const invoke: Format.Program.Context.Invoke = { + invoke: { + jump: true as const, + identifier: funcName, + target: { + pointer: { location: "stack" as const, slot: 0 }, + }, + ...(argPointers.length > 0 && { + arguments: { + pointer: { + group: argPointers, + }, + }, + }), }, }; + const invokeContext = { context: invoke as Format.Program.Context }; + currentState = { ...currentState, instructions: [ @@ -240,9 +262,9 @@ export function generateCallTerminator( mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0], - debug: invocationDebug, + debug, }, - { mnemonic: "JUMP", opcode: 0x56 }, + { mnemonic: "JUMP", opcode: 0x56, debug: invokeContext }, ], patches: [ ...currentState.patches, diff --git a/packages/bugc/src/evmgen/generation/function.ts b/packages/bugc/src/evmgen/generation/function.ts index 59e068bf8..9f397e7bc 100644 --- a/packages/bugc/src/evmgen/generation/function.ts +++ b/packages/bugc/src/evmgen/generation/function.ts @@ -2,6 +2,7 @@ * Function-level code generation */ +import type * as Format from "@ethdebug/format"; import * as Ir from "#ir"; import type * as Evm from "#evm"; import type { Stack } from "#evm"; @@ -27,12 +28,36 @@ function generatePrologue( return ((state: State): State => { let currentState = state; - // Add JUMPDEST with function entry annotation - const entryDebug = { - context: { - remark: `function-entry: ${func.name || "anonymous"}`, + // Add JUMPDEST with function entry annotation. + // After this JUMPDEST executes, the callee's args are + // on the stack (first arg deepest). + const argPointers = params.map((_p, i) => ({ + location: "stack" as const, + slot: params.length - 1 - i, + })); + + const entryInvoke: Format.Program.Context.Invoke = { + invoke: { + jump: true as const, + identifier: func.name || "anonymous", + target: { + pointer: { + location: "stack" as const, + slot: 0, + }, + }, + ...(argPointers.length > 0 && { + arguments: { + pointer: { + group: argPointers, + }, + }, + }), }, }; + const entryDebug = { + context: entryInvoke as Format.Program.Context, + }; currentState = { ...currentState, instructions: [ @@ -46,11 +71,28 @@ function generatePrologue( // Return PC is already in memory at 0x60 (stored by caller) // Pop and store each arg from argN down to arg0 - const prologueDebug = { - context: { - remark: `prologue: store ${params.length} parameter(s) to memory`, - }, - }; + const prologueDebug = + func.sourceId && func.loc + ? { + context: { + gather: [ + { + remark: `prologue: store ${params.length} parameter(s) to memory`, + }, + { + code: { + source: { id: func.sourceId }, + range: func.loc, + }, + }, + ], + } as Format.Program.Context, + } + : { + context: { + remark: `prologue: store ${params.length} parameter(s) to memory`, + } as Format.Program.Context, + }; for (let i = params.length - 1; i >= 0; i--) { const param = params[i]; @@ -79,7 +121,11 @@ function generatePrologue( ...currentState, instructions: [ ...currentState.instructions, - { mnemonic: "MSTORE", opcode: 0x52 }, + { + mnemonic: "MSTORE", + opcode: 0x52, + debug: prologueDebug, + }, ], }; } @@ -88,11 +134,28 @@ function generatePrologue( // so nested function calls don't clobber it. const savedPcOffset = currentState.memory.savedReturnPcOffset; if (savedPcOffset !== undefined) { - const savePcDebug = { - context: { - remark: `prologue: save return PC to 0x${savedPcOffset.toString(16)}`, - }, - }; + const savePcDebug = + func.sourceId && func.loc + ? { + context: { + gather: [ + { + remark: `prologue: save return PC to 0x${savedPcOffset.toString(16)}`, + }, + { + code: { + source: { id: func.sourceId }, + range: func.loc, + }, + }, + ], + } as Format.Program.Context, + } + : { + context: { + remark: `prologue: save return PC to 0x${savedPcOffset.toString(16)}`, + } as Format.Program.Context, + }; const highByte = (savedPcOffset >> 8) & 0xff; const lowByte = savedPcOffset & 0xff; currentState = { @@ -105,13 +168,22 @@ function generatePrologue( immediates: [0x60], debug: savePcDebug, }, - { mnemonic: "MLOAD", opcode: 0x51 }, + { + mnemonic: "MLOAD", + opcode: 0x51, + debug: savePcDebug, + }, { mnemonic: "PUSH2", opcode: 0x61, immediates: [highByte, lowByte], + debug: savePcDebug, + }, + { + mnemonic: "MSTORE", + opcode: 0x52, + debug: savePcDebug, }, - { mnemonic: "MSTORE", opcode: 0x52 }, ], }; } diff --git a/packages/bugc/src/evmgen/generation/module.ts b/packages/bugc/src/evmgen/generation/module.ts index 92c306f8a..a0e26b229 100644 --- a/packages/bugc/src/evmgen/generation/module.ts +++ b/packages/bugc/src/evmgen/generation/module.ts @@ -105,9 +105,37 @@ export function generate( // Insert STOP between main and user functions to prevent // fall-through when the main function's last block omits // STOP (the isLastBlock optimization). + const stopGuardDebug = + module.main.sourceId && module.main.loc + ? { + context: { + gather: [ + { + remark: "guard: prevent fall-through into functions", + }, + { + code: { + source: { id: module.main.sourceId }, + range: module.main.loc, + }, + }, + ], + }, + } + : { + context: { + remark: "guard: prevent fall-through into functions", + }, + }; const stopGuard: Evm.Instruction[] = patchedFunctions.length > 0 - ? [{ mnemonic: "STOP" as const, opcode: 0x00 }] + ? [ + { + mnemonic: "STOP" as const, + opcode: 0x00, + debug: stopGuardDebug, + }, + ] : []; const stopGuardBytes: number[] = patchedFunctions.length > 0 ? [0x00] : []; @@ -243,13 +271,19 @@ function buildDeploymentInstructions( function deploymentTransition(runtimeOffset: bigint, runtimeLength: bigint) { const { PUSHn, CODECOPY, RETURN } = operations; + const debug = { + context: { + remark: "deployment: copy runtime bytecode and return", + }, + }; + return pipe() - .then(PUSHn(runtimeLength), { as: "size" }) - .then(PUSHn(runtimeOffset), { as: "offset" }) - .then(PUSHn(0n), { as: "destOffset" }) - .then(CODECOPY()) - .then(PUSHn(runtimeLength), { as: "size" }) - .then(PUSHn(0n), { as: "offset" }) - .then(RETURN()) + .then(PUSHn(runtimeLength, { debug }), { as: "size" }) + .then(PUSHn(runtimeOffset, { debug }), { as: "offset" }) + .then(PUSHn(0n, { debug }), { as: "destOffset" }) + .then(CODECOPY({ debug })) + .then(PUSHn(runtimeLength, { debug }), { as: "size" }) + .then(PUSHn(0n, { debug }), { as: "offset" }) + .then(RETURN({ debug })) .done(); } diff --git a/packages/bugc/src/ir/spec/function.ts b/packages/bugc/src/ir/spec/function.ts index df58c6ac8..49be241c5 100644 --- a/packages/bugc/src/ir/spec/function.ts +++ b/packages/bugc/src/ir/spec/function.ts @@ -17,6 +17,10 @@ export interface Function { blocks: Map; /** SSA variable metadata mapping temp IDs to original variables */ ssaVariables?: Map; + /** Source location of the function body */ + loc?: Ast.SourceLocation; + /** Source identifier for debug info */ + sourceId?: string; } export namespace Function { diff --git a/packages/bugc/src/irgen/generate/function.ts b/packages/bugc/src/irgen/generate/function.ts index 7e39aacfd..59e152768 100644 --- a/packages/bugc/src/irgen/generate/function.ts +++ b/packages/bugc/src/irgen/generate/function.ts @@ -46,12 +46,16 @@ export function* buildFunction( // Collect SSA variable metadata const ssaVariables = yield* Process.Functions.collectSsaMetadata(); + const module_ = yield* Process.Modules.current(); + const function_: Ir.Function = { name, parameters: params, entry: "entry", blocks, ssaVariables: ssaVariables.size > 0 ? ssaVariables : undefined, + loc: body.loc ?? undefined, + sourceId: module_.sourceId, }; return function_; diff --git a/packages/format/src/types/program/context.test.ts b/packages/format/src/types/program/context.test.ts index 47185c7ca..4470a322d 100644 --- a/packages/format/src/types/program/context.test.ts +++ b/packages/format/src/types/program/context.test.ts @@ -30,4 +30,20 @@ testSchemaGuards("ethdebug/format/program/context", [ schema: "schema:ethdebug/format/program/context/frame", guard: Context.isFrame, }, + { + schema: "schema:ethdebug/format/program/context/function", + guard: Context.Function.isIdentity, + }, + { + schema: "schema:ethdebug/format/program/context/function/invoke", + guard: Context.isInvoke, + }, + { + schema: "schema:ethdebug/format/program/context/function/return", + guard: Context.isReturn, + }, + { + schema: "schema:ethdebug/format/program/context/function/revert", + guard: Context.isRevert, + }, ] as const); diff --git a/packages/format/src/types/program/context.ts b/packages/format/src/types/program/context.ts index 6a552734a..9388fea7b 100644 --- a/packages/format/src/types/program/context.ts +++ b/packages/format/src/types/program/context.ts @@ -1,5 +1,5 @@ import { Materials } from "#types/materials"; -import { Type, isType } from "#types/type"; +import { Type } from "#types/type"; import { Pointer, isPointer } from "#types/pointer"; export type Context = @@ -8,7 +8,10 @@ export type Context = | Context.Remark | Context.Pick | Context.Gather - | Context.Frame; + | Context.Frame + | Context.Invoke + | Context.Return + | Context.Revert; export const isContext = (value: unknown): value is Context => [ @@ -18,6 +21,9 @@ export const isContext = (value: unknown): value is Context => Context.isPick, Context.isFrame, Context.isGather, + Context.isInvoke, + Context.isReturn, + Context.isRevert, ].some((guard) => guard(value)); export namespace Context { @@ -47,7 +53,7 @@ export namespace Context { export interface Variable { identifier?: string; declaration?: Materials.SourceRange; - type?: Type; + type?: Type.Specifier; pointer?: Pointer; } @@ -66,7 +72,7 @@ export namespace Context { (!("identifier" in value) || typeof value.identifier === "string") && (!("declaration" in value) || Materials.isSourceRange(value.declaration)) && - (!("type" in value) || isType(value.type)) && + (!("type" in value) || Type.isSpecifier(value.type)) && (!("pointer" in value) || isPointer(value.pointer)); } @@ -111,4 +117,162 @@ export namespace Context { !!value && "frame" in value && typeof value.frame === "string"; + + export namespace Function { + export interface Identity { + identifier?: string; + declaration?: Materials.SourceRange; + type?: Type.Specifier; + } + + export const isIdentity = (value: unknown): value is Identity => + typeof value === "object" && + !!value && + (!("identifier" in value) || typeof value.identifier === "string") && + (!("declaration" in value) || + Materials.isSourceRange(value.declaration)) && + (!("type" in value) || Type.isSpecifier(value.type)); + + export interface PointerRef { + pointer: Pointer; + } + + export const isPointerRef = (value: unknown): value is PointerRef => + typeof value === "object" && + !!value && + "pointer" in value && + isPointer(value.pointer); + } + + export interface Invoke { + invoke: Invoke.Invocation; + } + + export const isInvoke = (value: unknown): value is Invoke => + typeof value === "object" && + !!value && + "invoke" in value && + Invoke.isInvocation(value.invoke); + + export namespace Invoke { + export type Invocation = Function.Identity & + ( + | Invocation.InternalCall + | Invocation.ExternalCall + | Invocation.ContractCreation + ); + + export const isInvocation = (value: unknown): value is Invocation => + Function.isIdentity(value) && + (Invocation.isInternalCall(value) || + Invocation.isExternalCall(value) || + Invocation.isContractCreation(value)); + + export namespace Invocation { + export interface InternalCall extends Function.Identity { + jump: true; + target: Function.PointerRef; + arguments?: Function.PointerRef; + } + + export const isInternalCall = (value: unknown): value is InternalCall => + typeof value === "object" && + !!value && + "jump" in value && + value.jump === true && + "target" in value && + Function.isPointerRef(value.target) && + (!("arguments" in value) || Function.isPointerRef(value.arguments)); + + export interface ExternalCall extends Function.Identity { + message: true; + target: Function.PointerRef; + gas?: Function.PointerRef; + value?: Function.PointerRef; + input?: Function.PointerRef; + delegate?: true; + static?: true; + } + + export const isExternalCall = (value: unknown): value is ExternalCall => + typeof value === "object" && + !!value && + "message" in value && + value.message === true && + "target" in value && + Function.isPointerRef(value.target) && + (!("gas" in value) || Function.isPointerRef(value.gas)) && + (!("value" in value) || Function.isPointerRef(value.value)) && + (!("input" in value) || Function.isPointerRef(value.input)) && + (!("delegate" in value) || value.delegate === true) && + (!("static" in value) || value.static === true); + + export interface ContractCreation extends Function.Identity { + create: true; + value?: Function.PointerRef; + salt?: Function.PointerRef; + input?: Function.PointerRef; + } + + export const isContractCreation = ( + value: unknown, + ): value is ContractCreation => + typeof value === "object" && + !!value && + "create" in value && + value.create === true && + (!("value" in value) || Function.isPointerRef(value.value)) && + (!("salt" in value) || Function.isPointerRef(value.salt)) && + (!("input" in value) || Function.isPointerRef(value.input)); + } + } + + export interface Return { + return: Return.Info; + } + + export const isReturn = (value: unknown): value is Return => + typeof value === "object" && + !!value && + "return" in value && + Return.isInfo(value.return); + + export namespace Return { + export interface Info extends Function.Identity { + data: Function.PointerRef; + success?: Function.PointerRef; + } + + export const isInfo = (value: unknown): value is Info => + Function.isIdentity(value) && + typeof value === "object" && + !!value && + "data" in value && + Function.isPointerRef(value.data) && + (!("success" in value) || Function.isPointerRef(value.success)); + } + + export interface Revert { + revert: Revert.Info; + } + + export const isRevert = (value: unknown): value is Revert => + typeof value === "object" && + !!value && + "revert" in value && + Revert.isInfo(value.revert); + + export namespace Revert { + export interface Info extends Function.Identity { + reason?: Function.PointerRef; + panic?: number; + } + + export const isInfo = (value: unknown): value is Info => + Function.isIdentity(value) && + typeof value === "object" && + !!value && + (!("reason" in value) || Function.isPointerRef(value.reason)) && + (!("panic" in value) || typeof value.panic === "number"); + } } diff --git a/packages/format/src/types/type/index.test.ts b/packages/format/src/types/type/index.test.ts index 61a5349c3..cc461540a 100644 --- a/packages/format/src/types/type/index.test.ts +++ b/packages/format/src/types/type/index.test.ts @@ -80,4 +80,19 @@ testSchemaGuards("ethdebug/format/type", [ schema: "schema:ethdebug/format/type/complex/struct", guard: Type.Complex.isStruct, }, + + // type reference and specifier + + { + schema: "schema:ethdebug/format/type/reference", + guard: Type.isReference, + }, + { + schema: "schema:ethdebug/format/type/specifier", + guard: Type.isSpecifier, + }, + { + schema: "schema:ethdebug/format/type/wrapper", + guard: Type.isWrapper, + }, ] as const); diff --git a/packages/format/src/types/type/index.ts b/packages/format/src/types/type/index.ts index c53d0230b..066f3a6d5 100644 --- a/packages/format/src/types/type/index.ts +++ b/packages/format/src/types/type/index.ts @@ -32,16 +32,30 @@ export namespace Type { (typeof value.contains === "object" && Object.values(value.contains).every(Type.isWrapper))); + export interface Reference { + id: string | number; + } + + export const isReference = (value: unknown): value is Reference => + typeof value === "object" && + !!value && + "id" in value && + (typeof value.id === "string" || typeof value.id === "number"); + + export type Specifier = Type | Reference; + + export const isSpecifier = (value: unknown): value is Specifier => + isType(value) || isReference(value); + export interface Wrapper { - type: Type | { id: any }; + type: Specifier; } export const isWrapper = (value: unknown): value is Wrapper => typeof value === "object" && !!value && "type" in value && - (isType(value.type) || - (typeof value.type === "object" && !!value.type && "id" in value.type)); + isSpecifier(value.type); export type Elementary = | Elementary.Uint diff --git a/packages/programs-react/src/components/CallInfoPanel.css b/packages/programs-react/src/components/CallInfoPanel.css new file mode 100644 index 000000000..1adfe5dcc --- /dev/null +++ b/packages/programs-react/src/components/CallInfoPanel.css @@ -0,0 +1,85 @@ +.call-info-panel { + font-size: 0.9em; +} + +.call-info-banner { + padding: 6px 10px; + border-radius: 4px; + font-weight: 500; + margin-bottom: 6px; +} + +.call-info-banner-invoke { + background: var(--programs-invoke-bg, #e8f4fd); + color: var(--programs-invoke-text, #0969da); + border-left: 3px solid var(--programs-invoke-accent, #0969da); +} + +.call-info-banner-return { + background: var(--programs-return-bg, #dafbe1); + color: var(--programs-return-text, #1a7f37); + border-left: 3px solid var(--programs-return-accent, #1a7f37); +} + +.call-info-banner-revert { + background: var(--programs-revert-bg, #ffebe9); + color: var(--programs-revert-text, #cf222e); + border-left: 3px solid var(--programs-revert-accent, #cf222e); +} + +.call-info-refs { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; +} + +.call-info-ref { + display: flex; + align-items: baseline; + gap: 6px; + padding: 2px 0; +} + +.call-info-ref-label { + font-weight: 500; + color: var(--programs-text-muted, #888); + min-width: 80px; + text-align: right; + flex-shrink: 0; +} + +.call-info-ref-resolved { + font-family: monospace; + font-size: 0.9em; + word-break: break-all; +} + +.call-info-ref-error { + color: var(--programs-error, #cf222e); + font-style: italic; +} + +.call-info-ref-pending { + color: var(--programs-text-muted, #888); + font-style: italic; +} + +.call-info-ref-pointer { + margin-top: 2px; +} + +.call-info-ref-pointer summary { + cursor: pointer; + color: var(--programs-text-muted, #888); + font-size: 0.85em; +} + +.call-info-ref-pointer-json { + font-size: 0.8em; + padding: 4px 8px; + background: var(--programs-bg-code, #f6f8fa); + border-radius: 3px; + overflow-x: auto; + max-height: 200px; +} diff --git a/packages/programs-react/src/components/CallInfoPanel.tsx b/packages/programs-react/src/components/CallInfoPanel.tsx new file mode 100644 index 000000000..b18e58d78 --- /dev/null +++ b/packages/programs-react/src/components/CallInfoPanel.tsx @@ -0,0 +1,131 @@ +/** + * Panel showing call context info for the current instruction. + * + * Displays a banner for invoke/return/revert events and + * lists resolved pointer ref values (arguments, return + * data, etc.). + */ + +import React from "react"; +import { + useTraceContext, + type ResolvedCallInfo, + type ResolvedPointerRef, +} from "./TraceContext.js"; + +// CSS is expected to be imported by the consuming application +// import "./CallInfoPanel.css"; + +export interface CallInfoPanelProps { + /** Whether to show raw pointer JSON */ + showPointers?: boolean; + /** Custom class name */ + className?: string; +} + +function formatBanner(info: ResolvedCallInfo): string { + const name = info.identifier || "(anonymous)"; + + if (info.kind === "invoke") { + const prefix = + info.callType === "external" + ? "Calling (external)" + : info.callType === "create" + ? "Creating" + : "Calling"; + return `${prefix} ${name}()`; + } + + if (info.kind === "return") { + return `Returned from ${name}()`; + } + + // revert + if (info.panic !== undefined) { + return `Reverted: panic 0x${info.panic.toString(16)}`; + } + return `Reverted in ${name}()`; +} + +function bannerClassName(kind: ResolvedCallInfo["kind"]): string { + if (kind === "invoke") { + return "call-info-banner-invoke"; + } + if (kind === "return") { + return "call-info-banner-return"; + } + return "call-info-banner-revert"; +} + +/** + * Shows call context info when the current instruction + * has an invoke, return, or revert context. + */ +export function CallInfoPanel({ + showPointers = false, + className = "", +}: CallInfoPanelProps): JSX.Element | null { + const { currentCallInfo } = useTraceContext(); + + if (!currentCallInfo) { + return null; + } + + return ( +
+
+ {formatBanner(currentCallInfo)} +
+ + {currentCallInfo.pointerRefs.length > 0 && ( +
+ {currentCallInfo.pointerRefs.map((ref) => ( + + ))} +
+ )} +
+ ); +} + +interface PointerRefItemProps { + ref_: ResolvedPointerRef; + showPointer: boolean; +} + +function PointerRefItem({ + ref_, + showPointer, +}: PointerRefItemProps): JSX.Element { + return ( +
+ {ref_.label}: + + {ref_.error ? ( + + Error: {ref_.error} + + ) : ref_.value !== undefined ? ( + {ref_.value} + ) : ( + (resolving...) + )} + + + {showPointer && !!ref_.pointer && ( +
+ Pointer +
+            {JSON.stringify(ref_.pointer, null, 2)}
+          
+
+ )} +
+ ); +} diff --git a/packages/programs-react/src/components/CallStackDisplay.css b/packages/programs-react/src/components/CallStackDisplay.css new file mode 100644 index 000000000..5d928c292 --- /dev/null +++ b/packages/programs-react/src/components/CallStackDisplay.css @@ -0,0 +1,46 @@ +.call-stack { + font-size: 0.85em; + padding: 4px 8px; +} + +.call-stack-empty { + color: var(--programs-text-muted, #888); +} + +.call-stack-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; +} + +.call-stack-separator { + color: var(--programs-text-muted, #888); + user-select: none; +} + +.call-stack-frame { + display: inline-flex; + align-items: center; + background: none; + border: 1px solid transparent; + border-radius: 3px; + padding: 1px 4px; + cursor: pointer; + font-family: inherit; + font-size: inherit; + color: var(--programs-link, #0366d6); +} + +.call-stack-frame:hover { + background: var(--programs-bg-hover, rgba(0, 0, 0, 0.05)); + border-color: var(--programs-border, #ddd); +} + +.call-stack-name { + font-weight: 500; +} + +.call-stack-parens { + color: var(--programs-text-muted, #888); +} diff --git a/packages/programs-react/src/components/CallStackDisplay.tsx b/packages/programs-react/src/components/CallStackDisplay.tsx new file mode 100644 index 000000000..fbc55c994 --- /dev/null +++ b/packages/programs-react/src/components/CallStackDisplay.tsx @@ -0,0 +1,62 @@ +/** + * Displays the current call stack as a breadcrumb trail. + */ + +import React from "react"; +import { useTraceContext } from "./TraceContext.js"; + +// CSS is expected to be imported by the consuming application +// import "./CallStackDisplay.css"; + +export interface CallStackDisplayProps { + /** Custom class name */ + className?: string; +} + +/** + * Renders the call stack as a breadcrumb. + * + * Shows function names separated by arrows, e.g.: + * main() -> transfer() -> _update() + */ +export function CallStackDisplay({ + className = "", +}: CallStackDisplayProps): JSX.Element { + const { callStack, jumpToStep } = useTraceContext(); + + if (callStack.length === 0) { + return ( +
+ (top level) +
+ ); + } + + return ( +
+
+ {callStack.map((frame, index) => ( + + {index > 0 && ( + {" -> "} + )} + + + ))} +
+
+ ); +} diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index c21874722..4a4ef9d4c 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -14,8 +14,12 @@ import type { Pointer, Program } from "@ethdebug/format"; import { dereference, Data } from "@ethdebug/pointers"; import { type TraceStep, + type CallInfo, + type CallFrame, extractVariablesFromInstruction, + extractCallInfoFromInstruction, buildPcToInstructionMap, + buildCallStack, } from "#utils/mockTrace"; import { traceStepToMachineState } from "#utils/traceState"; @@ -35,6 +39,36 @@ export interface ResolvedVariable { error?: string; } +/** + * A resolved pointer ref with its label and value. + */ +export interface ResolvedPointerRef { + /** Label for this pointer (e.g., "target", "arguments") */ + label: string; + /** The raw pointer */ + pointer: unknown; + /** Resolved hex value */ + value?: string; + /** Error if resolution failed */ + error?: string; +} + +/** + * Call info with resolved pointer values. + */ +export interface ResolvedCallInfo { + /** The kind of call event */ + kind: "invoke" | "return" | "revert"; + /** Function name */ + identifier?: string; + /** Call variant for invoke contexts */ + callType?: "internal" | "external" | "create"; + /** Panic code for revert contexts */ + panic?: number; + /** Resolved pointer refs */ + pointerRefs: ResolvedPointerRef[]; +} + /** * State provided by the Trace context. */ @@ -53,6 +87,10 @@ export interface TraceState { currentInstruction: Program.Instruction | undefined; /** Variables in scope at current step */ currentVariables: ResolvedVariable[]; + /** Call stack at current step */ + callStack: CallFrame[]; + /** Call info for current instruction (if any) */ + currentCallInfo: ResolvedCallInfo | undefined; /** Whether we're at the first step */ isAtStart: boolean; /** Whether we're at the last step */ @@ -241,6 +279,93 @@ export function TraceProvider({ }; }, [extractedVars, currentStep, shouldResolve, templates]); + // Build call stack by scanning instructions up to current step + const callStack = useMemo( + () => buildCallStack(trace, pcToInstruction, currentStepIndex), + [trace, pcToInstruction, currentStepIndex], + ); + + // Extract call info for current instruction (synchronous) + const extractedCallInfo = useMemo((): CallInfo | undefined => { + if (!currentInstruction) { + return undefined; + } + return extractCallInfoFromInstruction(currentInstruction); + }, [currentInstruction]); + + // Async call info pointer resolution + const [currentCallInfo, setCurrentCallInfo] = useState< + ResolvedCallInfo | undefined + >(undefined); + + useEffect(() => { + if (!extractedCallInfo) { + setCurrentCallInfo(undefined); + return; + } + + // Immediately show call info without resolved values + const initial: ResolvedCallInfo = { + kind: extractedCallInfo.kind, + identifier: extractedCallInfo.identifier, + callType: extractedCallInfo.callType, + panic: extractedCallInfo.panic, + pointerRefs: extractedCallInfo.pointerRefs.map((ref) => ({ + label: ref.label, + pointer: ref.pointer, + value: undefined, + error: undefined, + })), + }; + setCurrentCallInfo(initial); + + if (!shouldResolve || !currentStep) { + return; + } + + let cancelled = false; + const resolved = [...initial.pointerRefs]; + + const promises = extractedCallInfo.pointerRefs.map(async (ref, index) => { + try { + const value = await resolveVariableValue( + ref.pointer as Pointer, + currentStep, + templates, + ); + if (!cancelled) { + resolved[index] = { + ...resolved[index], + value, + }; + setCurrentCallInfo({ + ...initial, + pointerRefs: [...resolved], + }); + } + } catch (err) { + if (!cancelled) { + resolved[index] = { + ...resolved[index], + error: err instanceof Error ? err.message : String(err), + }; + setCurrentCallInfo({ + ...initial, + pointerRefs: [...resolved], + }); + } + } + }); + + Promise.all(promises).catch(() => { + // Individual errors already handled + }); + + return () => { + cancelled = true; + }; + }, [extractedCallInfo, currentStep, shouldResolve, templates]); + const stepForward = useCallback(() => { setCurrentStepIndex((prev) => Math.min(prev + 1, trace.length - 1)); }, [trace.length]); @@ -272,6 +397,8 @@ export function TraceProvider({ currentStep, currentInstruction, currentVariables, + callStack, + currentCallInfo, isAtStart: currentStepIndex === 0, isAtEnd: currentStepIndex >= trace.length - 1, stepForward, diff --git a/packages/programs-react/src/components/index.ts b/packages/programs-react/src/components/index.ts index 886c22d62..222acf051 100644 --- a/packages/programs-react/src/components/index.ts +++ b/packages/programs-react/src/components/index.ts @@ -22,6 +22,8 @@ export { type TraceState, type TraceProviderProps, type ResolvedVariable, + type ResolvedCallInfo, + type ResolvedPointerRef, } from "./TraceContext.js"; export { @@ -37,3 +39,10 @@ export { type VariableInspectorProps, type StackInspectorProps, } from "./VariableInspector.js"; + +export { + CallStackDisplay, + type CallStackDisplayProps, +} from "./CallStackDisplay.js"; + +export { CallInfoPanel, type CallInfoPanelProps } from "./CallInfoPanel.js"; diff --git a/packages/programs-react/src/index.ts b/packages/programs-react/src/index.ts index 599e8ff1c..2fa17ad36 100644 --- a/packages/programs-react/src/index.ts +++ b/packages/programs-react/src/index.ts @@ -26,13 +26,19 @@ export { TraceProgress, VariableInspector, StackInspector, + CallStackDisplay, + CallInfoPanel, type TraceState, type TraceProviderProps, type ResolvedVariable, + type ResolvedCallInfo, + type ResolvedPointerRef, type TraceControlsProps, type TraceProgressProps, type VariableInspectorProps, type StackInspectorProps, + type CallStackDisplayProps, + type CallInfoPanelProps, } from "#components/index"; // Shiki utilities @@ -51,7 +57,11 @@ export { createMockTrace, findInstructionAtPc, extractVariablesFromInstruction, + extractCallInfoFromInstruction, buildPcToInstructionMap, + buildCallStack, + type CallInfo, + type CallFrame, type DynamicInstruction, type DynamicContext, type ContextThunk, @@ -67,3 +77,5 @@ export { // import "@ethdebug/programs-react/components/SourceContents.css"; // import "@ethdebug/programs-react/components/TraceControls.css"; // import "@ethdebug/programs-react/components/VariableInspector.css"; +// import "@ethdebug/programs-react/components/CallStackDisplay.css"; +// import "@ethdebug/programs-react/components/CallInfoPanel.css"; diff --git a/packages/programs-react/src/utils/index.ts b/packages/programs-react/src/utils/index.ts index 5f750e9a0..a79f07b08 100644 --- a/packages/programs-react/src/utils/index.ts +++ b/packages/programs-react/src/utils/index.ts @@ -17,9 +17,13 @@ export { createMockTrace, findInstructionAtPc, extractVariablesFromInstruction, + extractCallInfoFromInstruction, buildPcToInstructionMap, + buildCallStack, type TraceStep, type MockTraceSpec, + type CallInfo, + type CallFrame, } from "./mockTrace.js"; export { traceStepToMachineState } from "./traceState.js"; diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index 6adef9e05..f3234e735 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -100,6 +100,195 @@ function extractVariablesFromContext( return []; } +/** + * Info about a function call context on an instruction. + */ +export interface CallInfo { + /** The kind of call event */ + kind: "invoke" | "return" | "revert"; + /** Function name (from Function.Identity) */ + identifier?: string; + /** Call variant for invoke contexts */ + callType?: "internal" | "external" | "create"; + /** Panic code for revert contexts */ + panic?: number; + /** Named pointer refs to resolve */ + pointerRefs: Array<{ + label: string; + pointer: unknown; + }>; +} + +/** + * Extract call info (invoke/return/revert) from an + * instruction's context tree. + */ +export function extractCallInfoFromInstruction( + instruction: Program.Instruction, +): CallInfo | undefined { + if (!instruction.context) { + return undefined; + } + return extractCallInfoFromContext(instruction.context); +} + +function extractCallInfoFromContext( + context: Program.Context, +): CallInfo | undefined { + // Use unknown intermediate to avoid strict type checks + // on the context union — we discriminate by key presence + const ctx = context as unknown as Record; + + if ("invoke" in ctx) { + const inv = ctx.invoke as Record; + const pointerRefs: CallInfo["pointerRefs"] = []; + + let callType: CallInfo["callType"]; + if ("jump" in inv) { + callType = "internal"; + collectPointerRef(pointerRefs, "target", inv.target); + collectPointerRef(pointerRefs, "arguments", inv.arguments); + } else if ("message" in inv) { + callType = "external"; + collectPointerRef(pointerRefs, "target", inv.target); + collectPointerRef(pointerRefs, "gas", inv.gas); + collectPointerRef(pointerRefs, "value", inv.value); + collectPointerRef(pointerRefs, "input", inv.input); + } else if ("create" in inv) { + callType = "create"; + collectPointerRef(pointerRefs, "value", inv.value); + collectPointerRef(pointerRefs, "salt", inv.salt); + collectPointerRef(pointerRefs, "input", inv.input); + } + + return { + kind: "invoke", + identifier: inv.identifier as string | undefined, + callType, + pointerRefs, + }; + } + + if ("return" in ctx) { + const ret = ctx.return as Record; + const pointerRefs: CallInfo["pointerRefs"] = []; + collectPointerRef(pointerRefs, "data", ret.data); + collectPointerRef(pointerRefs, "success", ret.success); + + return { + kind: "return", + identifier: ret.identifier as string | undefined, + pointerRefs, + }; + } + + if ("revert" in ctx) { + const rev = ctx.revert as Record; + const pointerRefs: CallInfo["pointerRefs"] = []; + collectPointerRef(pointerRefs, "reason", rev.reason); + + return { + kind: "revert", + identifier: rev.identifier as string | undefined, + panic: rev.panic as number | undefined, + pointerRefs, + }; + } + + // Walk gather/pick to find call info + if ("gather" in ctx && Array.isArray(ctx.gather)) { + for (const sub of ctx.gather as Program.Context[]) { + const info = extractCallInfoFromContext(sub); + if (info) { + return info; + } + } + } + + if ("pick" in ctx && Array.isArray(ctx.pick)) { + for (const sub of ctx.pick as Program.Context[]) { + const info = extractCallInfoFromContext(sub); + if (info) { + return info; + } + } + } + + return undefined; +} + +function collectPointerRef( + refs: CallInfo["pointerRefs"], + label: string, + value: unknown, +): void { + if (value && typeof value === "object" && "pointer" in value) { + refs.push({ label, pointer: (value as { pointer: unknown }).pointer }); + } +} + +/** + * A frame in the call stack. + */ +export interface CallFrame { + /** Function name */ + identifier?: string; + /** The step index where this call was invoked */ + stepIndex: number; + /** The call type */ + callType?: "internal" | "external" | "create"; +} + +/** + * Build a call stack by scanning instructions from + * step 0 to the given step index. + */ +export function buildCallStack( + trace: TraceStep[], + pcToInstruction: Map, + upToStep: number, +): CallFrame[] { + const stack: CallFrame[] = []; + + for (let i = 0; i <= upToStep && i < trace.length; i++) { + const step = trace[i]; + const instruction = pcToInstruction.get(step.pc); + if (!instruction) { + continue; + } + + const callInfo = extractCallInfoFromInstruction(instruction); + if (!callInfo) { + continue; + } + + if (callInfo.kind === "invoke") { + // The compiler emits invoke on both the caller JUMP and + // callee entry JUMPDEST. Skip if the top frame already + // matches this call. + const top = stack[stack.length - 1]; + if ( + !top || + top.identifier !== callInfo.identifier || + top.callType !== callInfo.callType + ) { + stack.push({ + identifier: callInfo.identifier, + stepIndex: i, + callType: callInfo.callType, + }); + } + } else if (callInfo.kind === "return" || callInfo.kind === "revert") { + // Pop the matching frame + if (stack.length > 0) { + stack.pop(); + } + } + } + + return stack; +} + /** * Build a map of PC to instruction for quick lookup. */ diff --git a/packages/web/docs/concepts/programs.mdx b/packages/web/docs/concepts/programs.mdx index ec4b3abe4..93c844c27 100644 --- a/packages/web/docs/concepts/programs.mdx +++ b/packages/web/docs/concepts/programs.mdx @@ -157,6 +157,55 @@ Contexts can be composed using: This composition enables describing complex scenarios like conditional variable assignments or function inlining. +## Function call contexts + +Programs answer "what function are we in?" through three context types +that track function boundaries during execution: + +- **invoke** — marks an instruction that enters a function. Indicates + the invocation kind (internal jump, external message call, or + contract creation) and provides pointers to call arguments, target + address, gas, and value as appropriate. +- **return** — marks an instruction associated with a successful + return from a function. Provides a pointer to the return data. +- **revert** — marks an instruction associated with a failed call. + May include a pointer to revert reason data or a numeric panic + code. + +All three extend a common **function identity** schema with optional +fields for the function's name, declaration source range, and type. +This lets compilers provide as much or as little attribution as +available — from a fully identified `transfer` call down to an +anonymous indirect invocation through a function pointer. + + + {`{ + "invoke": { + "identifier": "transfer", + "jump": true, + "target": { + "pointer": { "location": "stack", "slot": 0 } + }, + "arguments": { + "pointer": { + "group": [ + { "name": "to", "location": "stack", "slot": 2 }, + { "name": "amount", "location": "stack", "slot": 3 } + ] + } + } + } +}`} + + +A debugger uses these contexts to reconstruct call stacks, show +function names in stepping UI, and display argument/return values +alongside source code. + ## What tracing enables By following contexts through execution, debuggers can provide: diff --git a/packages/web/docs/core-schemas/programs/tracing.mdx b/packages/web/docs/core-schemas/programs/tracing.mdx index efbae0cfc..55ec6ca8b 100644 --- a/packages/web/docs/core-schemas/programs/tracing.mdx +++ b/packages/web/docs/core-schemas/programs/tracing.mdx @@ -121,6 +121,178 @@ b = b + 1; }`} /> +## Tracing through a function call + +The examples above trace simple straight-line code. Real programs +make function calls. **invoke** and **return** contexts let a +debugger follow execution across function boundaries. + +Click **"Try it"** on the example below, then step through the +trace. Watch for **invoke** contexts on the JUMP into `add` and +**return** contexts on the JUMP back to the caller: + + uint256 { +return a + b; +}; +} + +storage { +[0] result: uint256; +} + +create { +result = 0; +} + +code { +result = add(3, 4); +}`} +/> + +As you step through, three phases are visible: + +### Before the call — setting up arguments + +At the call site, the compiler pushes arguments onto the stack and +prepares the jump. The JUMP instruction carries an **invoke** +context identifying the function, its target, and the argument +locations: + + + {`{ + "invoke": { + "identifier": "add", + "jump": true, + "target": { + "pointer": { "location": "stack", "slot": 0 } + }, + "arguments": { + "pointer": { + "group": [ + { "name": "a", "location": "stack", "slot": 2 }, + { "name": "b", "location": "stack", "slot": 3 } + ] + } + } + } +}`} + + +The debugger now knows it's entering `add` with arguments at stack +slots 2 and 3. A trace viewer can show `add(3, 4)` in the call +stack. + +### Inside the function — normal tracing + +Inside `add`, instructions carry their own `code` and `variables` +contexts as usual. The debugger shows the source range within the +function body, and parameters `a` and `b` appear as in-scope +variables. + +### Returning — the result + +When `add` finishes, the JUMP back to the caller carries a +**return** context with a pointer to the result: + + + {`{ + "return": { + "identifier": "add", + "data": { + "pointer": { "location": "stack", "slot": 0 } + } + } +}`} + + +The debugger pops `add` from the call stack and can display the +return value (7). + +### External calls and reverts + +The same pattern applies to external message calls, but with +additional fields. An external CALL instruction carries gas, value, +and input data pointers: + + + {`{ + "invoke": { + "identifier": "balanceOf", + "message": true, + "target": { + "pointer": { "location": "stack", "slot": 1 } + }, + "gas": { + "pointer": { "location": "stack", "slot": 0 } + }, + "input": { + "pointer": { + "group": [ + { "name": "selector", "location": "memory", + "offset": "0x80", "length": 4 }, + { "name": "arguments", "location": "memory", + "offset": "0x84", "length": "0x20" } + ] + } + } + } +}`} + + +If the call reverts, a **revert** context captures the reason: + + + {`{ + "revert": { + "identifier": "transfer", + "reason": { + "pointer": { + "location": "memory", + "offset": "0x80", + "length": "0x64" + } + } + } +}`} + + +For built-in assertion failures, the compiler can provide a panic +code instead of (or alongside) a reason pointer: + + + {`{ + "revert": { + "panic": 17 + } +}`} + + ## Trace data structure A trace step captures the EVM state at a single point: diff --git a/packages/web/spec/program/context/function/_category_.json b/packages/web/spec/program/context/function/_category_.json new file mode 100644 index 000000000..8c013b27b --- /dev/null +++ b/packages/web/spec/program/context/function/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Function contexts", + "link": null +} diff --git a/packages/web/spec/program/context/function/function.mdx b/packages/web/spec/program/context/function/function.mdx new file mode 100644 index 000000000..69dcad9d9 --- /dev/null +++ b/packages/web/spec/program/context/function/function.mdx @@ -0,0 +1,18 @@ +--- +sidebar_position: 0 +--- + +import SchemaViewer from "@site/src/components/SchemaViewer"; + +# Function identity + +Function contexts (invoke, return, revert) share a common set of +identity fields for the function being called. All fields are +optional, allowing compilers to provide as much or as little +detail as available — from a fully named function with source +location and type, down to an empty object for an anonymous +indirect call. + + diff --git a/packages/web/spec/program/context/function/invoke.mdx b/packages/web/spec/program/context/function/invoke.mdx new file mode 100644 index 000000000..553e88f03 --- /dev/null +++ b/packages/web/spec/program/context/function/invoke.mdx @@ -0,0 +1,44 @@ +--- +sidebar_position: 1 +--- + +import SchemaViewer from "@site/src/components/SchemaViewer"; + +# Invocation contexts + + + +## Internal call + +An internal call represents a function call within the same contract +via JUMP/JUMPI. The target points to a code location and arguments +are passed on the stack. + + + +## External call + +An external call represents a call to another contract via CALL, +DELEGATECALL, or STATICCALL. The type of call may be indicated by +setting `delegate` or `static` to `true`. If neither flag is present, +the invocation represents a regular CALL. + + + +## Contract creation + +A contract creation represents a CREATE or CREATE2 operation. The +presence of `salt` implies CREATE2. + + diff --git a/packages/web/spec/program/context/function/return.mdx b/packages/web/spec/program/context/function/return.mdx new file mode 100644 index 000000000..0010d3fef --- /dev/null +++ b/packages/web/spec/program/context/function/return.mdx @@ -0,0 +1,16 @@ +--- +sidebar_position: 2 +--- + +import SchemaViewer from "@site/src/components/SchemaViewer"; + +# Return contexts + +A return context marks an instruction associated with a successful +function return. It extends the function identity schema with a +pointer to the return data and, for external calls, the success +status. + + diff --git a/packages/web/spec/program/context/function/revert.mdx b/packages/web/spec/program/context/function/revert.mdx new file mode 100644 index 000000000..98d980988 --- /dev/null +++ b/packages/web/spec/program/context/function/revert.mdx @@ -0,0 +1,16 @@ +--- +sidebar_position: 3 +--- + +import SchemaViewer from "@site/src/components/SchemaViewer"; + +# Revert contexts + +A revert context marks an instruction associated with a function +revert. It extends the function identity schema with an optional +pointer to revert reason data and/or a numeric panic code for +built-in assertion failures. + + diff --git a/packages/web/spec/type/concepts.mdx b/packages/web/spec/type/concepts.mdx index e87a516f4..e42bcbf7f 100644 --- a/packages/web/spec/type/concepts.mdx +++ b/packages/web/spec/type/concepts.mdx @@ -158,18 +158,24 @@ possibly includes other fields alongside `"type"`. -## Type wrappers and type references +## Type specifiers, wrappers, and references -This schema defines the concept of a type wrapper and the related concept of a -type reference. +This schema defines three related concepts for working with types +indirectly: **type specifiers**, **type wrappers**, and +**type references**. -Type wrappers serve to encapsulate a type representation alongside other fields -in the same object, and to facilitate discriminating which polymorphic form is -used for a particular complex type. +A **type specifier** is either a complete type representation or a +reference to a known type by ID. Type specifiers appear wherever a +type or reference to a type is needed—as the value of a type +wrapper's `"type"` field, as properties on variable and function +context schemas, etc. -Type wrappers are any object of the form -`{ "type": , ...otherProperties }`, where `` is either a complete -type representation or a reference to another type by ID. +A **type wrapper** is any object of the form +`{ "type": , ...otherProperties }`, where `` +is a type specifier. Type wrappers serve to encapsulate a type +specifier alongside other fields in the same object, and to +facilitate discriminating which polymorphic form is used for a +particular complex type.
Example type wrapper with complete type representation @@ -198,9 +204,15 @@ type representation or a reference to another type by ID.
-Note that **ethdebug/format/type** places no restriction on IDs other than -that they must be either a number or a string. Other components of this format -at-large may impose restrictions, however. +A **type reference** is the simplest form of type specifier: an +object containing only an `"id"` field. Note that +**ethdebug/format/type** places no restriction on IDs other than +that they must be either a number or a string. Other components +of this format at-large may impose restrictions, however. + +### Type specifier schema + + ### Type wrapper schema @@ -208,9 +220,6 @@ at-large may impose restrictions, however. ### Type reference schema -A type reference is an object containing the single `"id"` field. This field -must be a string or a number. - ## Sometimes types are defined in code diff --git a/packages/web/src/components/SchemaViewer.tsx b/packages/web/src/components/SchemaViewer.tsx index db803ddef..93dd9627b 100644 --- a/packages/web/src/components/SchemaViewer.tsx +++ b/packages/web/src/components/SchemaViewer.tsx @@ -1,16 +1,18 @@ -import React from "react"; +import React, { Suspense } from "react"; import type { URL } from "url"; import type { JSONSchema } from "json-schema-typed/draft-2020-12"; import JSONSchemaViewer from "@theme/JSONSchemaViewer"; import CodeBlock from "@theme/CodeBlock"; import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; +import BrowserOnly from "@docusaurus/BrowserOnly"; import { type DescribeSchemaOptions, describeSchema } from "@ethdebug/format"; import { schemaIndex } from "@site/src/schemas"; import { SchemaContext, internalIdKey } from "@site/src/contexts/SchemaContext"; import ReactMarkdown from "react-markdown"; import SchemaListing from "./SchemaListing"; -import Playground from "./Playground"; + +const Playground = React.lazy(() => import("./Playground")); export interface SchemaViewerProps extends DescribeSchemaOptions {} @@ -83,7 +85,13 @@ export default function SchemaViewer(props: SchemaViewerProps): JSX.Element { - + Loading playground...}> + {() => ( + Loading playground...}> + + + )} + ); diff --git a/packages/web/src/schemas.ts b/packages/web/src/schemas.ts index 619c35b04..84ba8c701 100644 --- a/packages/web/src/schemas.ts +++ b/packages/web/src/schemas.ts @@ -14,6 +14,10 @@ const typeSchemaIndex: SchemaIndex = { title: "Base type wrapper schema", href: "/spec/type/base#base-type-wrapper-schema", }, + "schema:ethdebug/format/type/specifier": { + title: "Type specifier schema", + href: "/spec/type/concepts#type-specifier-schema", + }, "schema:ethdebug/format/type/wrapper": { title: "Type wrapper schema", href: "/spec/type/concepts#type-wrapper-schema", @@ -231,6 +235,37 @@ const programSchemaIndex: SchemaIndex = { }, })) .reduce((a, b) => ({ ...a, ...b }), {}), + + "schema:ethdebug/format/program/context/function": { + title: "Function identity schema", + href: "/spec/program/context/function", + }, + + ...["invoke", "return", "revert"] + .map((name) => ({ + [`schema:ethdebug/format/program/context/function/${name}`]: { + href: `/spec/program/context/function/${name}`, + }, + })) + .reduce((a, b) => ({ ...a, ...b }), {}), + + "schema:ethdebug/format/program/context/function/invoke#/$defs/InternalCall": + { + title: "Internal call schema", + href: "/spec/program/context/function/invoke#internal-call", + }, + + "schema:ethdebug/format/program/context/function/invoke#/$defs/ExternalCall": + { + title: "External call schema", + href: "/spec/program/context/function/invoke#external-call", + }, + + "schema:ethdebug/format/program/context/function/invoke#/$defs/ContractCreation": + { + title: "Contract creation schema", + href: "/spec/program/context/function/invoke#contract-creation", + }, }; const infoSchemaIndex: SchemaIndex = { diff --git a/packages/web/src/theme/JSONSchemaViewer/components/UnnecessaryComposition.tsx b/packages/web/src/theme/JSONSchemaViewer/components/UnnecessaryComposition.tsx index 8640d9a47..d468c6a15 100644 --- a/packages/web/src/theme/JSONSchemaViewer/components/UnnecessaryComposition.tsx +++ b/packages/web/src/theme/JSONSchemaViewer/components/UnnecessaryComposition.tsx @@ -2,6 +2,7 @@ import React from "react"; import Link from "@docusaurus/Link"; import CreateNodes from "@theme/JSONSchemaViewer/components/CreateNodes"; import { SchemaHierarchyComponent } from "@theme-original/JSONSchemaViewer/contexts"; +import { Collapsible } from "@theme/JSONSchemaViewer/components"; import { GenerateFriendlyName, QualifierMessages, diff --git a/packages/web/src/theme/ProgramExample/CallInfoPanel.css b/packages/web/src/theme/ProgramExample/CallInfoPanel.css new file mode 100644 index 000000000..1adfe5dcc --- /dev/null +++ b/packages/web/src/theme/ProgramExample/CallInfoPanel.css @@ -0,0 +1,85 @@ +.call-info-panel { + font-size: 0.9em; +} + +.call-info-banner { + padding: 6px 10px; + border-radius: 4px; + font-weight: 500; + margin-bottom: 6px; +} + +.call-info-banner-invoke { + background: var(--programs-invoke-bg, #e8f4fd); + color: var(--programs-invoke-text, #0969da); + border-left: 3px solid var(--programs-invoke-accent, #0969da); +} + +.call-info-banner-return { + background: var(--programs-return-bg, #dafbe1); + color: var(--programs-return-text, #1a7f37); + border-left: 3px solid var(--programs-return-accent, #1a7f37); +} + +.call-info-banner-revert { + background: var(--programs-revert-bg, #ffebe9); + color: var(--programs-revert-text, #cf222e); + border-left: 3px solid var(--programs-revert-accent, #cf222e); +} + +.call-info-refs { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; +} + +.call-info-ref { + display: flex; + align-items: baseline; + gap: 6px; + padding: 2px 0; +} + +.call-info-ref-label { + font-weight: 500; + color: var(--programs-text-muted, #888); + min-width: 80px; + text-align: right; + flex-shrink: 0; +} + +.call-info-ref-resolved { + font-family: monospace; + font-size: 0.9em; + word-break: break-all; +} + +.call-info-ref-error { + color: var(--programs-error, #cf222e); + font-style: italic; +} + +.call-info-ref-pending { + color: var(--programs-text-muted, #888); + font-style: italic; +} + +.call-info-ref-pointer { + margin-top: 2px; +} + +.call-info-ref-pointer summary { + cursor: pointer; + color: var(--programs-text-muted, #888); + font-size: 0.85em; +} + +.call-info-ref-pointer-json { + font-size: 0.8em; + padding: 4px 8px; + background: var(--programs-bg-code, #f6f8fa); + border-radius: 3px; + overflow-x: auto; + max-height: 200px; +} diff --git a/packages/web/src/theme/ProgramExample/CallStackDisplay.css b/packages/web/src/theme/ProgramExample/CallStackDisplay.css new file mode 100644 index 000000000..5d928c292 --- /dev/null +++ b/packages/web/src/theme/ProgramExample/CallStackDisplay.css @@ -0,0 +1,46 @@ +.call-stack { + font-size: 0.85em; + padding: 4px 8px; +} + +.call-stack-empty { + color: var(--programs-text-muted, #888); +} + +.call-stack-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; +} + +.call-stack-separator { + color: var(--programs-text-muted, #888); + user-select: none; +} + +.call-stack-frame { + display: inline-flex; + align-items: center; + background: none; + border: 1px solid transparent; + border-radius: 3px; + padding: 1px 4px; + cursor: pointer; + font-family: inherit; + font-size: inherit; + color: var(--programs-link, #0366d6); +} + +.call-stack-frame:hover { + background: var(--programs-bg-hover, rgba(0, 0, 0, 0.05)); + border-color: var(--programs-border, #ddd); +} + +.call-stack-name { + font-weight: 500; +} + +.call-stack-parens { + color: var(--programs-text-muted, #888); +} diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.css b/packages/web/src/theme/ProgramExample/TraceDrawer.css index 4da55e7be..9f36d24c0 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.css +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.css @@ -127,6 +127,83 @@ text-align: center; } +/* Call stack breadcrumb */ +.call-stack-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; + padding: 4px 12px; + font-size: 12px; + background: var(--ifm-background-surface-color); + border-bottom: 1px solid var(--ifm-color-emphasis-200); + flex-shrink: 0; +} + +.call-stack-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ifm-color-content-secondary); + margin-right: 4px; +} + +.call-stack-toplevel { + color: var(--ifm-color-content-secondary); + font-style: italic; +} + +.call-stack-sep { + color: var(--ifm-color-content-secondary); + padding: 0 2px; + user-select: none; +} + +.call-stack-frame-btn { + background: none; + border: 1px solid transparent; + border-radius: 3px; + padding: 1px 4px; + cursor: pointer; + font-family: var(--ifm-font-family-monospace); + font-size: 12px; + font-weight: 500; + color: var(--ifm-color-primary); +} + +.call-stack-frame-btn:hover { + background: var(--ifm-color-emphasis-100); + border-color: var(--ifm-color-emphasis-300); +} + +/* Call info banner */ +.call-info-bar { + padding: 4px 12px; + font-size: 12px; + font-weight: 500; + flex-shrink: 0; + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.call-info-invoke { + background: var(--ifm-color-info-contrast-background); + color: var(--ifm-color-info-darkest); + border-left: 3px solid var(--ifm-color-info); +} + +.call-info-return { + background: var(--ifm-color-success-contrast-background); + color: var(--ifm-color-success-darkest); + border-left: 3px solid var(--ifm-color-success); +} + +.call-info-revert { + background: var(--ifm-color-danger-contrast-background); + color: var(--ifm-color-danger-darkest); + border-left: 3px solid var(--ifm-color-danger); +} + /* Trace panels */ .trace-panels { display: grid; diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index 72c69418b..0cae58678 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -85,6 +85,62 @@ function TraceDrawerContent(): JSX.Element { return extractVariables(instruction.debug.context); }, [trace, currentStep, pcToInstruction]); + // Extract call info from current instruction context + const currentCallInfo = useMemo(() => { + if (trace.length === 0 || currentStep >= trace.length) { + return undefined; + } + + const step = trace[currentStep]; + const instruction = pcToInstruction.get(step.pc); + if (!instruction?.debug?.context) return undefined; + + return extractCallInfo(instruction.debug.context); + }, [trace, currentStep, pcToInstruction]); + + // Build call stack by scanning invoke/return/revert up to + // current step + const callStack = useMemo(() => { + const frames: Array<{ + identifier?: string; + stepIndex: number; + callType?: string; + }> = []; + + for (let i = 0; i <= currentStep && i < trace.length; i++) { + const step = trace[i]; + const instruction = pcToInstruction.get(step.pc); + if (!instruction?.debug?.context) continue; + + const info = extractCallInfo(instruction.debug.context); + if (!info) continue; + + if (info.kind === "invoke") { + // The compiler emits invoke on both the caller + // JUMP and callee entry JUMPDEST. Skip if the + // top frame already matches this call. + const top = frames[frames.length - 1]; + if ( + !top || + top.identifier !== info.identifier || + top.callType !== info.callType + ) { + frames.push({ + identifier: info.identifier, + stepIndex: i, + callType: info.callType, + }); + } + } else if (info.kind === "return" || info.kind === "revert") { + if (frames.length > 0) { + frames.pop(); + } + } + } + + return frames; + }, [trace, currentStep, pcToInstruction]); + // Compile source and run trace in one shot. // Takes source directly to avoid stale-state issues. const compileAndTrace = useCallback(async (sourceCode: string) => { @@ -298,6 +354,36 @@ function TraceDrawerContent(): JSX.Element { +
+ Call Stack: + {callStack.length === 0 ? ( + (top level) + ) : ( + callStack.map((frame, i) => ( + + {i > 0 && ( + + )} + + + )) + )} +
+ + {currentCallInfo && ( +
+ {formatCallBanner(currentCallInfo)} +
+ )} +
Instructions
@@ -468,6 +554,90 @@ function VariablesDisplay({ variables }: VariablesDisplayProps): JSX.Element { ); } +/** + * Info about a call context (invoke/return/revert). + */ +interface CallInfoResult { + kind: "invoke" | "return" | "revert"; + identifier?: string; + callType?: string; +} + +/** + * Extract call info from an ethdebug format context object. + */ +function extractCallInfo(context: unknown): CallInfoResult | undefined { + if (!context || typeof context !== "object") { + return undefined; + } + + const ctx = context as Record; + + if ("invoke" in ctx && ctx.invoke) { + const inv = ctx.invoke as Record; + let callType: string | undefined; + if ("jump" in inv) callType = "internal"; + else if ("message" in inv) callType = "external"; + else if ("create" in inv) callType = "create"; + + return { + kind: "invoke", + identifier: inv.identifier as string | undefined, + callType, + }; + } + + if ("return" in ctx && ctx.return) { + const ret = ctx.return as Record; + return { + kind: "return", + identifier: ret.identifier as string | undefined, + }; + } + + if ("revert" in ctx && ctx.revert) { + const rev = ctx.revert as Record; + return { + kind: "revert", + identifier: rev.identifier as string | undefined, + }; + } + + // Walk gather/pick + if ("gather" in ctx && Array.isArray(ctx.gather)) { + for (const sub of ctx.gather) { + const info = extractCallInfo(sub); + if (info) return info; + } + } + + if ("pick" in ctx && Array.isArray(ctx.pick)) { + for (const sub of ctx.pick) { + const info = extractCallInfo(sub); + if (info) return info; + } + } + + return undefined; +} + +/** + * Format a call info banner string. + */ +function formatCallBanner(info: CallInfoResult): string { + const name = info.identifier || "(anonymous)"; + switch (info.kind) { + case "invoke": { + const prefix = info.callType === "create" ? "Creating" : "Calling"; + return `${prefix} ${name}()`; + } + case "return": + return `Returned from ${name}()`; + case "revert": + return `Reverted in ${name}()`; + } +} + /** * Extract variables from an ethdebug format context object. */ diff --git a/packages/web/src/theme/ProgramExample/TraceViewer.tsx b/packages/web/src/theme/ProgramExample/TraceViewer.tsx index 59ee577f0..c2348beb7 100644 --- a/packages/web/src/theme/ProgramExample/TraceViewer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceViewer.tsx @@ -12,6 +12,8 @@ import { TraceProgress, VariableInspector, StackInspector, + CallStackDisplay, + CallInfoPanel, useTraceContext, type TraceStep, } from "@ethdebug/programs-react"; @@ -20,6 +22,8 @@ import { import "./TraceViewer.css"; import "./TraceControls.css"; import "./VariableInspector.css"; +import "./CallStackDisplay.css"; +import "./CallInfoPanel.css"; export interface TraceViewerProps { /** The execution trace */ @@ -97,6 +101,7 @@ function TraceViewerContent({
+
@@ -118,6 +123,8 @@ function TraceViewerContent({
+ + {showVariables && (

Variables

diff --git a/packages/web/src/theme/ProgramExample/index.ts b/packages/web/src/theme/ProgramExample/index.ts index 47a0e1113..c930d1b4d 100644 --- a/packages/web/src/theme/ProgramExample/index.ts +++ b/packages/web/src/theme/ProgramExample/index.ts @@ -17,13 +17,19 @@ export { TraceProgress, VariableInspector, StackInspector, + CallStackDisplay, + CallInfoPanel, type TraceState, type TraceProviderProps, type ResolvedVariable, + type ResolvedCallInfo, + type ResolvedPointerRef, type TraceControlsProps, type TraceProgressProps, type VariableInspectorProps, type StackInspectorProps, + type CallStackDisplayProps, + type CallInfoPanelProps, } from "@ethdebug/programs-react"; // Also re-export utilities for convenience @@ -33,12 +39,16 @@ export { createMockTrace, findInstructionAtPc, extractVariablesFromInstruction, + extractCallInfoFromInstruction, buildPcToInstructionMap, + buildCallStack, type DynamicInstruction, type DynamicContext, type ContextThunk, type TraceStep, type MockTraceSpec, + type CallInfo, + type CallFrame, } from "@ethdebug/programs-react"; // Local Docusaurus-specific components diff --git a/schemas/program/context.schema.yaml b/schemas/program/context.schema.yaml index ba5a4ee87..a57fce654 100644 --- a/schemas/program/context.schema.yaml +++ b/schemas/program/context.schema.yaml @@ -70,6 +70,25 @@ allOf: intermediary representation) to associate a context with a particular compiler step. $ref: "schema:ethdebug/format/program/context/frame" + - if: + required: ["invoke"] + then: + description: | + Indicates association with a function invocation (internal call, + external message call, or contract creation). + $ref: "schema:ethdebug/format/program/context/function/invoke" + - if: + required: ["return"] + then: + description: | + Indicates association with a successful function return. + $ref: "schema:ethdebug/format/program/context/function/return" + - if: + required: ["revert"] + then: + description: | + Indicates association with a function revert. + $ref: "schema:ethdebug/format/program/context/function/revert" unevaluatedProperties: false diff --git a/schemas/program/context/function.schema.yaml b/schemas/program/context/function.schema.yaml new file mode 100644 index 000000000..52da86c23 --- /dev/null +++ b/schemas/program/context/function.schema.yaml @@ -0,0 +1,52 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "schema:ethdebug/format/program/context/function" + +title: ethdebug/format/program/context/function +description: | + Properties for identifying a source-language function. Function + context schemas (invoke, return, revert) extend this schema so + that each context can optionally indicate which function it + pertains to. + + All properties are optional so that compilers may provide as + much or as little detail as is available. + +type: object +properties: + identifier: + type: string + minLength: 1 + description: | + The function's name in the source language. + + declaration: + description: | + Source range where the function is declared. + $ref: "schema:ethdebug/format/materials/source-range" + + type: + description: | + The function's type, specified either as a full + ethdebug/format/type representation or a type reference. + $ref: "schema:ethdebug/format/type/specifier" + +examples: + # All three identity fields provided: the compiler knows the + # function name, where it was declared, and its type. + - identifier: "transfer" + declaration: + source: + id: 0 + range: + offset: 128 + length: 95 + type: + id: 7 + + # Only the function name is known. + - identifier: "balanceOf" + + # No identity information. The compiler knows that a function + # context applies but cannot attribute it to a specific + # function (e.g., an indirect call through a function pointer). + - {} diff --git a/schemas/program/context/function/invoke.schema.yaml b/schemas/program/context/function/invoke.schema.yaml new file mode 100644 index 000000000..e1779a733 --- /dev/null +++ b/schemas/program/context/function/invoke.schema.yaml @@ -0,0 +1,367 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "schema:ethdebug/format/program/context/function/invoke" + +title: ethdebug/format/program/context/function/invoke +description: | + This context indicates that the marked instruction is + associated with a function invocation. The invocation is one + of three kinds: an internal call via JUMP, an external message + call (CALL / DELEGATECALL / STATICCALL), or a contract + creation (CREATE / CREATE2). + + Extends the function identity schema with kind-specific fields + such as call targets, gas, value, and input data. + +type: object +properties: + invoke: + type: object + title: Function invocation + description: | + Describes the function invocation associated with this + context. Must indicate exactly one invocation kind: `jump` + for an internal call, `message` for an external call, or + `create` for a contract creation. + + $ref: "schema:ethdebug/format/program/context/function" + + allOf: + - oneOf: + - required: [jump] + - required: [message] + - required: [create] + - if: + required: [jump] + then: + $ref: "#/$defs/InternalCall" + - if: + required: [message] + then: + $ref: "#/$defs/ExternalCall" + - if: + required: [create] + then: + $ref: "#/$defs/ContractCreation" + + unevaluatedProperties: false + +required: + - invoke + +$defs: + InternalCall: + title: Internal call + description: | + An internal function call within the same contract, entered + via JUMP/JUMPI. + type: object + properties: + jump: + description: | + Indicates this is an internal function call (JUMP/JUMPI). + const: true + + target: + type: object + title: Invocation target + description: | + Pointer to the target of the invocation. For internal + calls, this typically points to a code location. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + arguments: + type: object + title: Function arguments + description: | + Pointer to the arguments for an internal function call. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + required: [jump, target] + + ExternalCall: + title: External call + description: | + An external message call to another contract via CALL, + DELEGATECALL, or STATICCALL. Set `delegate` or `static` to + `true` to indicate the call variant; if neither is present + the call is a regular CALL. + type: object + properties: + message: + description: | + Indicates this is an external message call (CALL, + DELEGATECALL, or STATICCALL). + const: true + + target: + type: object + title: Invocation target + description: | + Pointer to the target of the invocation. For external + calls, this points to the address and/or selector + being called. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + gas: + type: object + title: Gas allocation + description: | + Pointer to the gas allocated for the external call. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + value: + type: object + title: ETH value + description: | + Pointer to the amount of ETH being sent with the call. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + input: + type: object + title: Call input data + description: | + Pointer to the input data for the external call. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + delegate: + description: | + Indicates this external call is a DELEGATECALL. + const: true + + static: + description: | + Indicates this external call is a STATICCALL. + const: true + + not: + description: Only one of `delegate` and `static` can be set at a time. + required: [delegate, static] + + required: [message, target] + + ContractCreation: + title: Contract creation + description: | + A contract creation via CREATE or CREATE2. The presence + of `salt` distinguishes CREATE2 from CREATE. + type: object + properties: + create: + description: | + Indicates this is a contract creation operation + (CREATE or CREATE2). + const: true + + value: + type: object + title: ETH value + description: | + Pointer to the amount of ETH being sent with the + creation. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + salt: + type: object + title: CREATE2 salt + description: | + Pointer to the salt value for CREATE2. Its presence + implies this is a CREATE2 operation. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + input: + type: object + title: Creation bytecode + description: | + Pointer to the creation bytecode for the new contract. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + required: [create] + +examples: + # ----------------------------------------------------------- + # Internal call: transfer(address, uint256) + # ----------------------------------------------------------- + # This context would mark the JUMP instruction that enters + # the function. Before the jump, the compiler has arranged + # the stack as follows (top first): + # + # slot 0: jump destination (entry PC of `transfer`) + # slot 1: return label + # slot 2: first argument (`to`) + # slot 3: second argument (`amount`) + # + # The `target` pointer reads the jump destination from the + # stack; `arguments` uses a group to name each argument's + # stack position. + - invoke: + identifier: "transfer" + declaration: + source: + id: 0 + range: + offset: 128 + length: 95 + type: + id: 7 + jump: true + target: + pointer: + location: stack + slot: 0 + arguments: + pointer: + group: + - name: "to" + location: stack + slot: 2 + - name: "amount" + location: stack + slot: 3 + + # ----------------------------------------------------------- + # External CALL: token.balanceOf(account) + # ----------------------------------------------------------- + # This context would mark the CALL instruction. The EVM + # expects the stack to contain (top first): + # + # slot 0: gas to forward + # slot 1: target contract address + # slot 2: value (0 — balanceOf is non-payable) + # + # The ABI-encoded calldata has already been written to + # memory at 0x80: + # + # 0x80..0x83: function selector (4 bytes) + # 0x84..0xa3: abi-encoded `account` (32 bytes) + - invoke: + identifier: "balanceOf" + message: true + target: + pointer: + location: stack + slot: 1 + gas: + pointer: + location: stack + slot: 0 + value: + pointer: + location: stack + slot: 2 + input: + pointer: + group: + - name: "selector" + location: memory + offset: "0x80" + length: 4 + - name: "arguments" + location: memory + offset: "0x84" + length: "0x20" + + # ----------------------------------------------------------- + # DELEGATECALL: proxy forwarding calldata + # ----------------------------------------------------------- + # This context would mark a DELEGATECALL instruction in a + # proxy contract. The call executes the implementation's + # code within the proxy's storage context. + # + # DELEGATECALL takes no value parameter. Stack layout + # (top first): + # + # slot 0: gas + # slot 1: implementation address + # + # The original calldata has been copied into memory: + # + # 0x80..0xe3: forwarded calldata (100 bytes) + - invoke: + message: true + delegate: true + target: + pointer: + location: stack + slot: 1 + gas: + pointer: + location: stack + slot: 0 + input: + pointer: + location: memory + offset: "0x80" + length: "0x64" + + # ----------------------------------------------------------- + # CREATE2: deploying a child contract + # ----------------------------------------------------------- + # This context would mark the CREATE2 instruction. Stack + # layout (top first): + # + # slot 0: value (ETH to send to the new contract) + # slot 1: salt (for deterministic address derivation) + # + # The init code has been placed in memory: + # + # 0x80..0x027f: creation bytecode (512 bytes) + - invoke: + create: true + value: + pointer: + location: stack + slot: 0 + salt: + pointer: + location: stack + slot: 1 + input: + pointer: + location: memory + offset: "0x80" + length: "0x200" diff --git a/schemas/program/context/function/return.schema.yaml b/schemas/program/context/function/return.schema.yaml new file mode 100644 index 000000000..dd274f67d --- /dev/null +++ b/schemas/program/context/function/return.schema.yaml @@ -0,0 +1,105 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "schema:ethdebug/format/program/context/function/return" + +title: ethdebug/format/program/context/function/return +description: | + This context indicates that the marked instruction is + associated with a successful function return. Extends the + function identity schema with a pointer to the return data + and, for external calls, the success status. + +type: object +properties: + return: + type: object + + $ref: "schema:ethdebug/format/program/context/function" + + properties: + data: + type: object + title: Return data + description: | + Pointer to the data being returned from the function. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + + success: + type: object + title: Call success status + description: | + Pointer to the success status of an external call. + Typically points to a boolean value on the stack. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + + required: + - data + + unevaluatedProperties: false + +required: + - return + +examples: + # ----------------------------------------------------------- + # Internal return: transfer(address, uint256) returns (bool) + # ----------------------------------------------------------- + # This context would mark the JUMP instruction that returns + # control to the caller. The function has left its return + # value on the stack: + # + # slot 0: return value (`bool success`) + - return: + identifier: "transfer" + declaration: + source: + id: 0 + range: + offset: 128 + length: 95 + data: + pointer: + location: stack + slot: 0 + + # ----------------------------------------------------------- + # External call return: processing result of a CALL + # ----------------------------------------------------------- + # This context would mark an instruction after a CALL that + # completed successfully. The EVM places a success flag on + # the stack, and the callee's return data is accessible via + # the returndata buffer: + # + # stack slot 0: success flag (1 = success) + # returndata 0x00..0x1f: ABI-encoded return value (32 bytes) + - return: + data: + pointer: + location: returndata + offset: 0 + length: "0x20" + success: + pointer: + location: stack + slot: 0 + + # ----------------------------------------------------------- + # Minimal return: only the data pointer + # ----------------------------------------------------------- + # When the compiler cannot attribute the return to a named + # function, the context may contain only the return data. + # Here, a single stack value is being returned. + # + # slot 0: return value + - return: + data: + pointer: + location: stack + slot: 0 diff --git a/schemas/program/context/function/revert.schema.yaml b/schemas/program/context/function/revert.schema.yaml new file mode 100644 index 000000000..9aecc9026 --- /dev/null +++ b/schemas/program/context/function/revert.schema.yaml @@ -0,0 +1,86 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "schema:ethdebug/format/program/context/function/revert" + +title: ethdebug/format/program/context/function/revert +description: | + This context indicates that the marked instruction is + associated with a function revert. Extends the function + identity schema with an optional pointer to revert reason + data and/or a numeric panic code. + +type: object +properties: + revert: + type: object + + $ref: "schema:ethdebug/format/program/context/function" + + properties: + reason: + type: object + title: Revert reason + description: | + Pointer to the revert reason data. This typically contains + an ABI-encoded error message or custom error data. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + + panic: + type: integer + title: Panic code + description: | + Numeric panic code for built-in assertion failures. + Languages may define their own panic code conventions + (e.g., Solidity uses codes like 0x11 for arithmetic + overflow). + + unevaluatedProperties: false + +required: + - revert + +examples: + # ----------------------------------------------------------- + # Revert with reason: require() failure in transfer + # ----------------------------------------------------------- + # This context would mark the REVERT instruction after a + # failed require(). The compiler has written the ABI-encoded + # Error(string) revert reason into memory: + # + # 0x80..0xe3: ABI-encoded Error(string) (100 bytes) + # selector 0x08c379a0 + offset + length + data + - revert: + identifier: "transfer" + reason: + pointer: + location: memory + offset: "0x80" + length: "0x64" + + # ----------------------------------------------------------- + # Panic: arithmetic overflow (code 0x11) + # ----------------------------------------------------------- + # A built-in safety check detected an arithmetic overflow. + # The panic code alone identifies the failure; no pointer to + # revert data is needed since the compiler inserts the check + # itself. + - revert: + panic: 17 + + # ----------------------------------------------------------- + # External call revert: processing a failed CALL + # ----------------------------------------------------------- + # This context would mark an instruction after a CALL that + # reverted. The callee's revert reason is accessible via the + # returndata buffer: + # + # returndata 0x00..0x63: ABI-encoded revert reason + - revert: + reason: + pointer: + location: returndata + offset: 0 + length: "0x64" diff --git a/schemas/program/context/variables.schema.yaml b/schemas/program/context/variables.schema.yaml index a64b43725..9987a1856 100644 --- a/schemas/program/context/variables.schema.yaml +++ b/schemas/program/context/variables.schema.yaml @@ -58,12 +58,10 @@ $defs: type: description: | - The variable's static type, if it exists. This **must** be specified - either as a full **ethdebug/format/type** representation, or an - `{ "id": "..." }` type reference object. - oneOf: - - $ref: "schema:ethdebug/format/type" - - $ref: "schema:ethdebug/format/type/reference" + The variable's static type, if it exists. This **must** be + specified either as a full **ethdebug/format/type** + representation, or an `{ "id": "..." }` type reference. + $ref: "schema:ethdebug/format/type/specifier" pointer: description: | diff --git a/schemas/type/specifier.schema.yaml b/schemas/type/specifier.schema.yaml new file mode 100644 index 000000000..8781c890e --- /dev/null +++ b/schemas/type/specifier.schema.yaml @@ -0,0 +1,20 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "schema:ethdebug/format/type/specifier" + +title: ethdebug/format/type/specifier +description: | + A type specifier: either a complete type representation or a + reference to a known type by ID. This schema discriminates + between the two forms based on the presence of an `id` field. + +if: + required: [id] +then: + $ref: "schema:ethdebug/format/type/reference" +else: + $ref: "schema:ethdebug/format/type" + +examples: + - kind: uint + bits: 256 + - id: 42 diff --git a/schemas/type/wrapper.schema.yaml b/schemas/type/wrapper.schema.yaml index eefca6a56..8e9a277f0 100644 --- a/schemas/type/wrapper.schema.yaml +++ b/schemas/type/wrapper.schema.yaml @@ -9,14 +9,7 @@ description: type: object properties: type: - # Discriminate between reference and type based on presence of `id` - if: - required: - - id - then: - $ref: "schema:ethdebug/format/type/reference" - else: - $ref: "schema:ethdebug/format/type" + $ref: "schema:ethdebug/format/type/specifier" required: - type