diff --git a/packages/bugc-react/src/components/BytecodeView.css b/packages/bugc-react/src/components/BytecodeView.css index b66318e6e..21f11820e 100644 --- a/packages/bugc-react/src/components/BytecodeView.css +++ b/packages/bugc-react/src/components/BytecodeView.css @@ -113,3 +113,82 @@ .opcode-line .immediates { color: var(--bugc-syntax-number); } + +/* Context badges for invoke/return/revert */ +.context-badge { + cursor: pointer; + padding: 0.0625rem 0.25rem; + border-radius: 3px; + font-size: 0.75rem; + font-weight: 600; + user-select: none; + display: inline-block; + min-width: 1.2rem; + text-align: center; + transition: all 0.15s ease; +} + +.context-badge-invoke { + color: var(--bugc-accent-blue); + background-color: var(--bugc-accent-blue-bg); +} + +.context-badge-invoke:hover { + background-color: var(--bugc-accent-blue); + color: var(--bugc-bg-primary); +} + +.context-badge-return { + color: var(--bugc-accent-green); + background-color: var(--bugc-accent-green-bg); +} + +.context-badge-return:hover { + background-color: var(--bugc-accent-green); + color: var(--bugc-bg-primary); +} + +.context-badge-revert { + color: var(--bugc-accent-red); + background-color: rgba(207, 34, 46, 0.1); +} + +.context-badge-revert:hover { + background-color: var(--bugc-accent-red); + color: var(--bugc-bg-primary); +} + +/* Inline context labels */ +.context-label { + font-size: 0.75rem; + font-style: italic; + margin-left: 0.5rem; +} + +.context-label-invoke { + color: var(--bugc-accent-blue); +} + +.context-label-return { + color: var(--bugc-accent-green); +} + +.context-label-revert { + color: var(--bugc-accent-red); +} + +/* Highlight rows with call contexts */ +.opcode-line.context-invoke { + border-left: 2px solid var(--bugc-accent-blue); + padding-left: calc(0.5rem - 2px); +} + +.opcode-line.context-return { + border-left: 2px solid var(--bugc-accent-green); + padding-left: calc(0.5rem - 2px); +} + +.opcode-line.context-revert { + border-left: 2px solid var(--bugc-accent-red); + padding-left: calc(0.5rem - 2px); +} diff --git a/packages/bugc-react/src/components/BytecodeView.tsx b/packages/bugc-react/src/components/BytecodeView.tsx index 441b0f5e0..cf6b42ca7 100644 --- a/packages/bugc-react/src/components/BytecodeView.tsx +++ b/packages/bugc-react/src/components/BytecodeView.tsx @@ -5,10 +5,29 @@ import React from "react"; import type { Evm } from "@ethdebug/bugc"; import type { BytecodeOutput, SourceRange } from "#types"; -import { extractSourceRange } from "#utils/debugUtils"; +import { + extractSourceRange, + classifyContext, + summarizeContext, + type ContextKind, + type DeclarationRange, +} from "#utils/debugUtils"; import { useEthdebugTooltip } from "#hooks/useEthdebugTooltip"; import { EthdebugTooltip } from "./EthdebugTooltip.js"; +function contextBadgeLabel(kind: ContextKind): string { + switch (kind) { + case "invoke": + return "\u279c"; // arrow right + case "return": + return "\u21b5"; // return arrow + case "revert": + return "\u2717"; // x mark + default: + return "\u2139"; // info + } +} + /** * Props for BytecodeView component. */ @@ -17,16 +36,20 @@ export interface BytecodeViewProps { bytecode: BytecodeOutput; /** Callback when hovering over an opcode with source ranges */ onOpcodeHover?: (ranges: SourceRange[]) => void; + /** Callback when clicking a context badge with a declaration */ + onDeclarationClick?: (decl: DeclarationRange) => void; } interface InstructionsViewProps { instructions: Evm.Instruction[]; onOpcodeHover?: (ranges: SourceRange[]) => void; + onDeclarationClick?: (decl: DeclarationRange) => void; } function InstructionsView({ instructions, onOpcodeHover, + onDeclarationClick, }: InstructionsViewProps): JSX.Element { const { tooltip, @@ -47,12 +70,35 @@ function InstructionsView({ onOpcodeHover?.([]); }; + const formatTooltipContent = (instruction: Evm.Instruction): string => { + const ctx = instruction.debug?.context; + if (!ctx) return ""; + + const summary = summarizeContext(ctx); + const lines: string[] = []; + + if ( + summary.kind === "invoke" || + summary.kind === "return" || + summary.kind === "revert" + ) { + lines.push(summary.label); + if (summary.details) { + lines.push(` (${summary.details})`); + } + lines.push(""); + } + + lines.push(JSON.stringify(ctx, null, 2)); + return lines.join("\n"); + }; + const handleDebugIconMouseEnter = ( e: React.MouseEvent, instruction: Evm.Instruction, ) => { if (instruction.debug?.context) { - showTooltip(e, JSON.stringify(instruction.debug.context, null, 2)); + showTooltip(e, formatTooltipContent(instruction)); } }; @@ -61,7 +107,22 @@ function InstructionsView({ instruction: Evm.Instruction, ) => { if (instruction.debug?.context) { - pinTooltip(e, JSON.stringify(instruction.debug.context, null, 2)); + pinTooltip(e, formatTooltipContent(instruction)); + } + }; + + const handleBadgeClick = ( + e: React.MouseEvent, + instruction: Evm.Instruction, + ) => { + const ctx = instruction.debug?.context; + if (!ctx) return; + + const summary = summarizeContext(ctx); + if (summary.declaration && onDeclarationClick) { + onDeclarationClick(summary.declaration); + } else { + pinTooltip(e, formatTooltipContent(instruction)); } }; @@ -73,22 +134,43 @@ function InstructionsView({ const sourceRanges = extractSourceRange(instruction.debug?.context); const hasDebugInfo = !!instruction.debug?.context; + const kind: ContextKind = hasDebugInfo + ? classifyContext(instruction.debug?.context) + : "other"; + const isCallContext = + kind === "invoke" || kind === "return" || kind === "revert"; return (
handleOpcodeMouseEnter(sourceRanges)} onMouseLeave={handleOpcodeMouseLeave} > - {hasDebugInfo ? ( + {isCallContext ? ( + handleDebugIconMouseEnter(e, instruction)} + onMouseLeave={hideTooltip} + onClick={(e) => handleBadgeClick(e, instruction)} + title={summarizeContext(instruction.debug?.context).label} + > + {contextBadgeLabel(kind)} + + ) : hasDebugInfo ? ( handleDebugIconMouseEnter(e, instruction)} onMouseLeave={hideTooltip} onClick={(e) => handleDebugIconClick(e, instruction)} > - ℹ + {"\u2139"} ) : ( @@ -103,6 +185,11 @@ function InstructionsView({ .join("")} )} + {isCallContext && ( + + {summarizeContext(instruction.debug?.context).label} + + )}
); })} @@ -135,6 +222,7 @@ function InstructionsView({ export function BytecodeView({ bytecode, onOpcodeHover, + onDeclarationClick, }: BytecodeViewProps): JSX.Element { const runtimeHex = Array.from(bytecode.runtime) .map((b) => b.toString(16).padStart(2, "0")) @@ -169,6 +257,7 @@ export function BytecodeView({ )} @@ -196,6 +285,7 @@ export function BytecodeView({ diff --git a/packages/bugc-react/src/index.ts b/packages/bugc-react/src/index.ts index b4151f575..91c68799b 100644 --- a/packages/bugc-react/src/index.ts +++ b/packages/bugc-react/src/index.ts @@ -50,6 +50,12 @@ export { extractSourceRange, formatDebugContext, hasSourceRange, + classifyContext, + summarizeContext, + formatCallSignature, + type ContextKind, + type ContextSummary, + type DeclarationRange, // IR debug utilities extractInstructionDebug, extractTerminatorDebug, diff --git a/packages/bugc-react/src/utils/debugUtils.ts b/packages/bugc-react/src/utils/debugUtils.ts index bf7485558..2b7c36e4b 100644 --- a/packages/bugc-react/src/utils/debugUtils.ts +++ b/packages/bugc-react/src/utils/debugUtils.ts @@ -93,3 +93,241 @@ export function formatDebugContext(context: unknown): string { export function hasSourceRange(context: unknown): boolean { return extractSourceRange(context).length > 0; } + +/** + * Kinds of function call contexts. + */ +export type ContextKind = + | "invoke" + | "return" + | "revert" + | "remark" + | "code" + | "other"; + +/** + * Classify a debug context by its top-level discriminant. + */ +export function classifyContext(context: unknown): ContextKind { + if (!context || typeof context !== "object") { + return "other"; + } + + const ctx = context as Record; + + if ("invoke" in ctx) return "invoke"; + if ("return" in ctx) return "return"; + if ("revert" in ctx) return "revert"; + if ("remark" in ctx) return "remark"; + if ("code" in ctx) return "code"; + + // Check inside gather — a gather of contexts inherits + // the kind of its function-call child if present + if ("gather" in ctx && Array.isArray(ctx.gather)) { + for (const item of ctx.gather) { + const kind = classifyContext(item); + if (kind === "invoke" || kind === "return" || kind === "revert") { + return kind; + } + } + } + + return "other"; +} + +/** + * A declaration source range for click-to-source. + */ +export interface DeclarationRange { + sourceId: string; + offset: number; + length: number; +} + +/** + * Summary of a function call context for display. + */ +export interface ContextSummary { + kind: ContextKind; + label: string; + functionName?: string; + argumentNames?: string[]; + details?: string; + declaration?: DeclarationRange; +} + +/** + * Extract a human-readable summary from a debug context. + */ +export function summarizeContext(context: unknown): ContextSummary { + const kind = classifyContext(context); + const ctx = context as Record; + + switch (kind) { + case "invoke": { + const invoke = findNestedField(ctx, "invoke") as + | Record + | undefined; + const name = (invoke?.identifier as string) ?? "unknown"; + const callType = invoke?.jump + ? "internal" + : invoke?.message + ? "external" + : invoke?.create + ? "create" + : ""; + const argNames = extractArgumentNames(invoke); + const declaration = extractDeclaration(invoke); + const paramList = argNames.length > 0 ? `(${argNames.join(", ")})` : "()"; + return { + kind, + label: `invoke ${name}${paramList}`, + functionName: name, + argumentNames: argNames.length > 0 ? argNames : undefined, + details: callType ? `${callType} call` : undefined, + declaration, + }; + } + + case "return": { + const ret = findNestedField(ctx, "return") as + | Record + | undefined; + const name = (ret?.identifier as string) ?? "unknown"; + const declaration = extractDeclaration(ret); + return { + kind, + label: `return ${name}()`, + functionName: name, + declaration, + }; + } + + case "revert": { + const rev = findNestedField(ctx, "revert") as + | Record + | undefined; + const name = (rev?.identifier as string) ?? "unknown"; + const panic = rev?.panic as number | undefined; + const declaration = extractDeclaration(rev); + return { + kind, + label: `revert ${name}()`, + functionName: name, + details: panic !== undefined ? `panic(${panic})` : undefined, + declaration, + }; + } + + case "remark": + return { + kind, + label: ctx.remark as string, + }; + + case "code": + return { kind, label: "source mapping" }; + + default: + return { kind, label: "debug info" }; + } +} + +/** + * Format a function call signature with param names. + * + * Returns "name(a, b)" if names are available, + * "name()" otherwise. + */ +export function formatCallSignature( + identifier: string | undefined, + argNames?: string[], +): string { + const name = identifier || "(anonymous)"; + if (argNames && argNames.length > 0) { + return `${name}(${argNames.join(", ")})`; + } + return `${name}()`; +} + +/** + * Extract argument names from an invoke context's + * arguments pointer group. + */ +function extractArgumentNames( + invoke: Record | undefined, +): string[] { + if (!invoke) return []; + + const args = invoke.arguments as Record | undefined; + if (!args) return []; + + const pointer = args.pointer as Record | undefined; + if (!pointer) return []; + + const group = pointer.group as Array> | undefined; + if (!Array.isArray(group)) return []; + + const names: string[] = []; + let hasAnyName = false; + for (const entry of group) { + const name = entry.name as string | undefined; + if (name) { + names.push(name); + hasAnyName = true; + } else { + names.push("_"); + } + } + + return hasAnyName ? names : []; +} + +/** + * Extract a declaration source range from a function + * identity object (invoke.invoke, return.return, etc.). + */ +function extractDeclaration( + identity: Record | undefined, +): DeclarationRange | undefined { + if (!identity) return undefined; + + const decl = identity.declaration as Record | undefined; + if (!decl) return undefined; + + const source = decl.source as Record | undefined; + const range = decl.range as Record | undefined; + + if ( + !source || + !range || + typeof source.id !== "string" || + typeof range.offset !== "number" || + typeof range.length !== "number" + ) { + return undefined; + } + + return { + sourceId: source.id, + offset: range.offset, + length: range.length, + }; +} + +/** + * Find a field by name, searching inside gather arrays. + */ +function findNestedField(ctx: Record, field: string): unknown { + if (field in ctx) return ctx[field]; + + if ("gather" in ctx && Array.isArray(ctx.gather)) { + for (const item of ctx.gather) { + if (item && typeof item === "object" && field in item) { + return (item as Record)[field]; + } + } + } + + return undefined; +} diff --git a/packages/bugc-react/src/utils/index.ts b/packages/bugc-react/src/utils/index.ts index 335e2e091..8eabbc46b 100644 --- a/packages/bugc-react/src/utils/index.ts +++ b/packages/bugc-react/src/utils/index.ts @@ -6,6 +6,12 @@ export { extractSourceRange, formatDebugContext, hasSourceRange, + classifyContext, + summarizeContext, + formatCallSignature, + type ContextKind, + type ContextSummary, + type DeclarationRange, } from "./debugUtils.js"; export { diff --git a/packages/bugc/src/evmgen/analysis/memory.test.ts b/packages/bugc/src/evmgen/analysis/memory.test.ts index 8760ed3b8..03a09fdeb 100644 --- a/packages/bugc/src/evmgen/analysis/memory.test.ts +++ b/packages/bugc/src/evmgen/analysis/memory.test.ts @@ -89,12 +89,12 @@ describe("Memory Planning", () => { // Phi destination %3 should be allocated memory expect("%3" in memory.allocations).toBe(true); - // %1 is allocated first at 0x80, then %3 at 0xa0 (160) - expect(memory.allocations["%3"].offset).toBe(0xa0); + // %1 is allocated first at 0xa0, then %3 at 0xc0 + expect(memory.allocations["%3"].offset).toBe(0xc0); // Cross-block value %1 should also be allocated expect("%1" in memory.allocations).toBe(true); - expect(memory.allocations["%1"].offset).toBe(0x80); + expect(memory.allocations["%1"].offset).toBe(0xa0); }); it("should allocate memory for cross-block values", () => { diff --git a/packages/bugc/src/evmgen/analysis/memory.ts b/packages/bugc/src/evmgen/analysis/memory.ts index 9b455c743..7137d1b87 100644 --- a/packages/bugc/src/evmgen/analysis/memory.ts +++ b/packages/bugc/src/evmgen/analysis/memory.ts @@ -30,11 +30,24 @@ export { MemoryError as Error }; * EVM memory layout following Solidity conventions */ export const regions = { - SCRATCH_SPACE_1: 0x00, // 0x00-0x1f: First scratch space slot - SCRATCH_SPACE_2: 0x20, // 0x20-0x3f: Second scratch space slot - FREE_MEMORY_POINTER: 0x40, // 0x40-0x5f: Dynamic memory pointer - ZERO_SLOT: 0x60, // 0x60-0x7f: Zero slot (reserved) - STATIC_MEMORY_START: 0x80, // 0x80+: Static allocations start here + SCRATCH_SPACE_1: 0x00, // 0x00-0x1f: First scratch space + SCRATCH_SPACE_2: 0x20, // 0x20-0x3f: Second scratch space + FREE_MEMORY_POINTER: 0x40, // 0x40-0x5f: Dynamic memory ptr + RETURN_PC_SCRATCH: 0x60, // 0x60-0x7f: Caller→callee return PC + FRAME_POINTER: 0x80, // 0x80-0x9f: Current frame base address + STATIC_MEMORY_START: 0xa0, // 0xa0+: Static allocations +} as const; + +/** + * Call frame header layout (relative to frame base). + * Each user-function call allocates a frame from FMP; + * the first two slots store the saved frame pointer + * and saved return PC. + */ +export const frameHeader = { + SAVED_FP: 0x00, + SAVED_RETURN_PC: 0x20, + LOCALS_START: 0x40, } as const; export interface Allocation { @@ -93,10 +106,10 @@ export namespace Module { result.main = mainMemory.value; // Process user-defined functions. - // Each function's allocations start after the previous - // function's end, so all simultaneously-active frames - // have non-overlapping memory. - let nextFuncOffset = result.main.nextStaticOffset; + // Each function gets its own call frame allocated from + // FMP at runtime. Offsets are relative to the frame + // base, starting after the frame header (saved FP + + // saved return PC). for (const [name, func] of module.functions) { const funcLiveness = liveness.functions[name]; if (!funcLiveness) { @@ -109,13 +122,12 @@ export namespace Module { } const funcMemory = Function.plan(func, funcLiveness, { isUserFunction: true, - startOffset: nextFuncOffset, + startOffset: frameHeader.LOCALS_START, }); if (!funcMemory.success) { return funcMemory; } result.functions[name] = funcMemory.value; - nextFuncOffset = funcMemory.value.nextStaticOffset; } return Result.ok(result); @@ -124,16 +136,22 @@ export namespace Module { export namespace Function { export interface Info { - /** Memory allocation info for each value that needs allocation */ + /** Memory allocation info for each value */ allocations: Record; - /** Next available memory offset after all static allocations */ + /** Next available memory offset after static allocations */ nextStaticOffset: number; /** - * Offset where this function saves its return PC. - * Only set for user-defined functions that need to - * preserve their return address across internal calls. + * Frame-relative offset for saved return PC. + * Only set for user-defined functions. */ savedReturnPcOffset?: number; + /** + * Total frame size in bytes. When set, all allocation + * offsets are relative to the frame base (loaded from + * FRAME_POINTER at 0x80). Frames are allocated from + * FMP on entry and deallocated on return. + */ + frameSize?: number; } /** @@ -212,19 +230,22 @@ export namespace Function { nextStaticOffset = currentSlotOffset; } - // Reserve a slot for the saved return PC in user - // functions. This is needed because nested calls - // overwrite memory[0x60] with their own return PC. + // For user functions, the saved return PC lives at a + // fixed offset in the frame header. frameSize is the + // total frame allocation (header + locals), rounded + // up to a slot boundary. let savedReturnPcOffset: number | undefined; + let frameSize: number | undefined; if (options.isUserFunction) { - savedReturnPcOffset = nextStaticOffset; - nextStaticOffset += SLOT_SIZE; + savedReturnPcOffset = frameHeader.SAVED_RETURN_PC; + frameSize = nextStaticOffset; } return Result.ok({ allocations, nextStaticOffset, savedReturnPcOffset, + frameSize, }); } catch (error) { return Result.err( diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index 7f842ee01..baf774dbc 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -196,6 +196,76 @@ code { expect(await result.getStorage(1n)).toBe(15n); }); + it("should return correct value from if/else branches", async () => { + const source = `name MultiBlockReturn; + +define { + function max(a: uint256, b: uint256) -> uint256 { + if (a > b) { + return a; + } else { + return b; + } + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = max(10, 20); +}`; + + const result = await executeProgram(source, { + calldata: "", + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(20n); + }); + + it("should return correct value from both branches", async () => { + const source = `name BothBranches; + +define { + function max(a: uint256, b: uint256) -> uint256 { + if (a > b) { + return a; + } else { + return b; + } + }; +} + +storage { + [0] r1: uint256; + [1] r2: uint256; +} + +create { + r1 = 0; + r2 = 0; +} + +code { + r1 = max(10, 20); + r2 = max(30, 5); +}`; + + const result = await executeProgram(source, { + calldata: "", + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(20n); + expect(await result.getStorage(1n)).toBe(30n); + }); + it("should call a function from another function", async () => { const source = `name FuncFromFunc; @@ -262,6 +332,63 @@ code { }); }); + describe("recursion", () => { + it("should support recursive function calls", async () => { + const source = `name RecursionTest; + +define { + function succ(n: uint256) -> uint256 { + return n + 1; + }; + function count( + n: uint256, target: uint256 + ) -> uint256 { + if (n < target) { + return count(succ(n), target); + } else { + return n; + } + }; +} + +storage { [0] result: uint256; } +create { result = 0; } +code { result = count(0, 5); }`; + + const result = await executeProgram(source, { + calldata: "", + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(5n); + }); + + it("should support simple self-recursion", async () => { + const source = `name SimpleRecursion; + +define { + function factorial(n: uint256) -> uint256 { + if (n < 2) { + return 1; + } else { + return n * factorial(n - 1); + } + }; +} + +storage { [0] result: uint256; } +create { result = 0; } +code { result = factorial(5); }`; + + const result = await executeProgram(source, { + calldata: "", + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(120n); + }); + }); + describe("loops", () => { it("should execute a for loop", async () => { const source = `name Loop; 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..244509e91 --- /dev/null +++ b/packages/bugc/src/evmgen/call-contexts.test.ts @@ -0,0 +1,392 @@ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import type * as Format from "@ethdebug/format"; +import { Program } from "@ethdebug/format"; + +const { Context } = Program; +const { Invocation } = Context.Invoke; + +type InternalCall = Format.Program.Context.Invoke.Invocation.InternalCall; + +/** + * 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 with a given mnemonic whose context + * satisfies a type guard + */ +function findInstructionsWithContext( + program: Format.Program, + mnemonic: string, + guard: (value: unknown) => value is C, +): (Format.Program.Instruction & { context: C })[] { + return program.instructions.filter( + ( + instr, + ): instr is Format.Program.Instruction & { + context: C; + } => instr.operation?.mnemonic === mnemonic && guard(instr.context), + ); +} + +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); + + const invokeJumps = findInstructionsWithContext( + program, + "JUMP", + Context.isInvoke, + ); + + expect(invokeJumps.length).toBeGreaterThanOrEqual(1); + + const { invoke } = invokeJumps[0].context; + expect(Invocation.isInternalCall(invoke)).toBe(true); + + const call = invoke as InternalCall; + expect(call.jump).toBe(true); + expect(call.identifier).toBe("add"); + + // Should have declaration source range + expect(invoke.declaration).toBeDefined(); + expect(invoke.declaration!.source).toEqual({ id: "0" }); + expect(invoke.declaration!.range).toBeDefined(); + expect(typeof invoke.declaration!.range!.offset).toBe("number"); + expect(typeof invoke.declaration!.range!.length).toBe("number"); + + // Should have target pointer + expect(call.target.pointer).toBeDefined(); + + // Should have argument pointers + expect(call.arguments).toBeDefined(); + const group = (call.arguments!.pointer as { group: unknown[] }).group; + + expect(group).toHaveLength(2); + // First arg (a) is deepest on stack + expect(group[0]).toEqual({ + name: "a", + location: "stack", + slot: 1, + }); + // Second arg (b) is on top + expect(group[1]).toEqual({ + name: "b", + location: "stack", + slot: 0, + }); + }); + + it("should emit return context on continuation JUMPDEST", async () => { + const program = await compileProgram(source); + + const returnJumpdests = findInstructionsWithContext( + program, + "JUMPDEST", + Context.isReturn, + ); + + expect(returnJumpdests.length).toBeGreaterThanOrEqual(1); + + const { return: ret } = returnJumpdests[0].context; + + expect(ret.identifier).toBe("add"); + + // Should have declaration source range + expect(ret.declaration).toBeDefined(); + expect(ret.declaration!.source).toEqual({ id: "0" }); + + // Should have data pointer to return value at + // TOS (stack slot 0) + expect(ret.data.pointer).toEqual({ + location: "stack", + slot: 0, + }); + }); + + it("should emit invoke context on callee entry JUMPDEST", async () => { + const program = await compileProgram(source); + + // The callee entry point, not the continuation + const invokeJumpdests = findInstructionsWithContext( + program, + "JUMPDEST", + Context.isInvoke, + ); + + expect(invokeJumpdests.length).toBeGreaterThanOrEqual(1); + + const { invoke } = invokeJumpdests[0].context; + expect(Invocation.isInternalCall(invoke)).toBe(true); + + const call = invoke as InternalCall; + expect(call.jump).toBe(true); + expect(call.identifier).toBe("add"); + + // Should have argument pointers matching + // function parameters + expect(call.arguments).toBeDefined(); + const group = (call.arguments!.pointer as { group: unknown[] }).group; + + 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 = findInstructionsWithContext( + program, + "JUMP", + Context.isInvoke, + )[0]; + + const returnJumpdest = findInstructionsWithContext( + program, + "JUMPDEST", + Context.isReturn, + )[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 with data pointer " + + "for value-returning functions", + async () => { + const program = await compileProgram(voidSource); + + const returnJumpdests = findInstructionsWithContext( + program, + "JUMPDEST", + Context.isReturn, + ); + + expect(returnJumpdests.length).toBeGreaterThanOrEqual(1); + + const { return: ret } = returnJumpdests[0].context; + 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 = findInstructionsWithContext( + program, + "JUMP", + Context.isInvoke, + ); + + // 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.invoke.identifier, + ); + expect(invokeIds).toContain("addThree"); + expect(invokeIds).toContain("add"); + + // Should have return contexts for all + // continuation points + const returnJumpdests = findInstructionsWithContext( + program, + "JUMPDEST", + Context.isReturn, + ); + + 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 = findInstructionsWithContext( + program, + "JUMP", + Context.isInvoke, + ); + + expect(invokeJumps.length).toBeGreaterThanOrEqual(1); + + const { invoke } = invokeJumps[0].context; + expect(Invocation.isInternalCall(invoke)).toBe(true); + + const call = invoke as InternalCall; + expect(call.arguments).toBeDefined(); + const group = (call.arguments!.pointer as { group: unknown[] }).group; + + // Single arg at stack slot 0 + expect(group).toHaveLength(1); + expect(group[0]).toEqual({ + name: "x", + location: "stack", + slot: 0, + }); + }); + }); + + describe("return epilogue source maps", () => { + it( + "should map return epilogue instructions " + "to source location", + async () => { + const program = await compileProgram(source); + + // The return epilogue is PUSH/MLOAD/JUMP inside + // user functions. Find JUMP instructions that are + // NOT invoke contexts — these are return jumps. + const returnJumps = program.instructions.filter( + (instr) => + instr.operation?.mnemonic === "JUMP" && + !Context.isInvoke(instr.context), + ); + + // Should have at least one return JUMP (from add) + expect(returnJumps.length).toBeGreaterThanOrEqual(1); + + // The return JUMP should have a code context with + // source location (not just a remark) + const returnJump = returnJumps[0]; + expect(returnJump.context).toBeDefined(); + const ctx = returnJump.context as Record; + expect(ctx.code).toBeDefined(); + const code = ctx.code as Record; + expect(code.source).toEqual({ id: "0" }); + expect(code.range).toBeDefined(); + }, + ); + }); +}); diff --git a/packages/bugc/src/evmgen/generation/block.ts b/packages/bugc/src/evmgen/generation/block.ts index e425212fb..7c9f71247 100644 --- a/packages/bugc/src/evmgen/generation/block.ts +++ b/packages/bugc/src/evmgen/generation/block.ts @@ -2,7 +2,10 @@ * Block-level code generation */ +import type * as Ast from "#ast"; +import type * as Format from "@ethdebug/format"; import * as Ir from "#ir"; +import type * as Evm from "#evm"; import type { Stack } from "#evm"; import { Error, ErrorCode } from "#evmgen/errors"; @@ -28,6 +31,7 @@ export function generate( isFirstBlock: boolean = false, isUserFunction: boolean = false, func?: Ir.Function, + functions?: Map, ): Transition { const { JUMPDEST } = operations; @@ -44,11 +48,17 @@ 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)); + // Initialize memory for first block of main/create. + // User functions allocate frames in the prologue + // instead — re-initializing here would clobber FP/FMP. + if (isFirstBlock && !isUserFunction) { + 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 +79,33 @@ 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 calledFunc = functions?.get(calledFunction); + const declaration = + calledFunc?.loc && calledFunc?.sourceId + ? { + source: { id: calledFunc.sourceId }, + range: calledFunc.loc, + } + : undefined; + const returnCtx: Format.Program.Context.Return = { + return: { + identifier: calledFunction, + ...(declaration ? { declaration } : {}), + 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,24 +122,31 @@ 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; - // Spill return value to memory: DUP1, PUSH offset, MSTORE + // Spill return value to memory. + // DUP1 keeps the value; compute address; MSTORE. return { ...s, instructions: [ ...s.instructions, - { mnemonic: "DUP1" as const, opcode: 0x80 }, { - mnemonic: "PUSH2" as const, - opcode: 0x61, - immediates: [ - (allocation.offset >> 8) & 0xff, - allocation.offset & 0xff, - ], + mnemonic: "DUP1" as const, + opcode: 0x80, + debug: spillDebug, + }, + ...computeAddress( + allocation.offset, + s.memory.frameSize !== undefined, + spillDebug, + ), + { + mnemonic: "MSTORE" as const, + opcode: 0x52, + debug: spillDebug, }, - { mnemonic: "MSTORE" as const, opcode: 0x52 }, ], }; }); @@ -128,7 +167,9 @@ export function generate( // Process terminator // Handle call terminators specially (they cross function boundaries) if (block.terminator.kind === "call") { - result = result.then(generateCallTerminator(block.terminator)); + result = result.then( + generateCallTerminator(block.terminator, functions), + ); } else { result = result.then( generateTerminator(block.terminator, isLastBlock, isUserFunction), @@ -159,7 +200,7 @@ function generatePhi( phi: Ir.Block.Phi, predecessor: string, ): Transition { - const { PUSHn, MSTORE } = operations; + const { PUSHn, ADD, MLOAD, MSTORE } = operations; const source = phi.sources.get(predecessor); if (!source) { @@ -181,8 +222,20 @@ function generatePhi( `Phi destination ${phi.dest} not allocated`, ); } + if (state.memory.frameSize !== undefined) { + return builder + .then(PUSHn(BigInt(Memory.regions.FRAME_POINTER)), { as: "offset" }) + .then(MLOAD(), { as: "b" }) + .then(PUSHn(BigInt(allocation.offset)), { + as: "a", + }) + .then(ADD(), { as: "offset" }) + .then(MSTORE()); + } return builder - .then(PUSHn(BigInt(allocation.offset)), { as: "offset" }) + .then(PUSHn(BigInt(allocation.offset)), { + as: "offset", + }) .then(MSTORE()); }) .done() @@ -191,21 +244,110 @@ 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; + 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, + }; + + const { PUSH0 } = 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()) + .then(PUSHn(BigInt(nextStaticOffset), { debug }), { + as: "value", + }) + .then( + PUSHn(BigInt(Memory.regions.FREE_MEMORY_POINTER), { + debug, + }), + { as: "offset" }, + ) + .then(MSTORE({ debug })) + // Initialize frame pointer to 0 (no active frame) + .then(PUSH0({ debug }), { as: "value" }) + .then(PUSHn(BigInt(Memory.regions.FRAME_POINTER), { debug }), { + as: "offset", + }) + .then(MSTORE({ debug })) .done() ); } + +/** + * Emit instructions to compute a memory address. + * + * For frame-based functions, emits PUSH FP; MLOAD; + * PUSH offset; ADD. For absolute mode, emits PUSH2 + * with the offset encoded directly. + */ +function computeAddress( + offset: number, + isFrameBased: boolean, + debug: Evm.Instruction["debug"], +): Evm.Instruction[] { + if (isFrameBased) { + return [ + ...pushImm(Memory.regions.FRAME_POINTER, debug), + { mnemonic: "MLOAD" as const, opcode: 0x51, debug }, + ...pushImm(offset, debug), + { mnemonic: "ADD" as const, opcode: 0x01, debug }, + ]; + } + return [ + { + mnemonic: "PUSH2" as const, + opcode: 0x61, + immediates: [(offset >> 8) & 0xff, offset & 0xff], + debug, + }, + ]; +} + +/** PUSH an integer as the smallest PUSHn. */ +function pushImm( + value: number, + debug: Evm.Instruction["debug"], +): Evm.Instruction[] { + if (value === 0) { + return [{ mnemonic: "PUSH0", opcode: 0x5f, debug }]; + } + const bytes: number[] = []; + let v = value; + while (v > 0) { + bytes.unshift(v & 0xff); + v >>= 8; + } + const n = bytes.length; + return [ + { + mnemonic: `PUSH${n}`, + opcode: 0x5f + n, + immediates: bytes, + debug, + }, + ]; +} diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 44c4792da..82df52806 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -1,6 +1,9 @@ +import type * as Format from "@ethdebug/format"; import type * as Ir from "#ir"; +import type * as Evm from "#evm"; import type { Stack } from "#evm"; import type { State } from "#evmgen/state"; +import { Memory } from "#evmgen/analysis"; import { type Transition, operations, pipe } from "#evmgen/operations"; @@ -13,8 +16,8 @@ export function generateTerminator( term: Ir.Block.Terminator, isLastBlock: boolean = false, isUserFunction: boolean = false, -): Transition { - const { PUSHn, PUSH2, MSTORE, MLOAD, RETURN, STOP, JUMP, JUMPI } = operations; +): Transition { + const { PUSHn, PUSH2, MSTORE, RETURN, STOP, JUMP, JUMPI } = operations; switch (term.kind) { case "return": { @@ -23,28 +26,12 @@ export function generateTerminator( // on TOS from the previous instruction in the block. This avoids an // unnecessary DUP that would leave an extra value on the stack. if (isUserFunction) { - // Load return PC from the saved slot (not 0x60, - // which may have been overwritten by nested calls). - return pipe() - .peek((state, builder) => { - const pcOffset = state.memory.savedReturnPcOffset ?? 0x60; - const returnDebug = { - context: { - remark: term.value - ? "function-return: return with value" - : "function-return: void return", - }, - }; - return builder - .then(PUSHn(BigInt(pcOffset), { debug: returnDebug }), { - as: "offset", - }) - .then(MLOAD({ debug: returnDebug }), { - as: "counter", - }) - .then(JUMP({ debug: returnDebug })); - }) - .done() as unknown as Transition; + // Internal function return epilogue. + // Uses the same imperative pattern as + // generateCallTerminator — returns + // Transition to erase output type. + const debug = term.operationDebug; + return generateReturnEpilogue(term.value, debug); } // Contract return (main function or create) @@ -146,18 +133,25 @@ export function generateTerminator( } /** - * Generate code for a call terminator - handled specially since it crosses function boundaries + * Generate code for a call terminator - handled specially + * since it crosses function boundaries */ export function generateCallTerminator( term: Extract, + functions?: Map, ): Transition { const funcName = term.function; const args = term.arguments; const cont = term.continuation; + const targetFunc = functions?.get(funcName); 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 +159,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 +174,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 +182,15 @@ export function generateCallTerminator( mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0], - debug: returnPcDebug, + debug, }, - { mnemonic: "PUSH1", opcode: 0x60, immediates: [0x60] }, - { mnemonic: "MSTORE", opcode: 0x52 }, + { + mnemonic: "PUSH1", + opcode: 0x60, + immediates: [0x60], + debug, + }, + { mnemonic: "MSTORE", opcode: 0x52, debug }, ], patches: [ ...currentState.patches, @@ -216,22 +205,59 @@ 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 params = targetFunc?.parameters; + const argPointers = args.map((_arg, i) => ({ + ...(params?.[i]?.name ? { name: params[i].name } : {}), + location: "stack" as const, + slot: args.length - 1 - i, + })); + + // Build declaration source range if available + const declaration = + targetFunc?.loc && targetFunc?.sourceId + ? { + source: { id: targetFunc.sourceId }, + range: targetFunc.loc, + } + : undefined; + + // 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, + ...(declaration ? { declaration } : {}), + 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 +266,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, @@ -274,3 +300,138 @@ export function generateCallTerminator( return currentState; }) as Transition; } + +/** + * Generate return epilogue for user-defined functions. + * + * Loads the return value (if any), cleans stale stack + * values from predecessor blocks, then loads the saved + * return PC and jumps back to the caller. + */ +function generateReturnEpilogue( + value: Ir.Value | undefined, + debug: Ir.Block.Debug, +): Transition { + return ((state: State): State => { + let s: State = state as State; + + // Load return value onto the stack if present. + if (value) { + s = loadValue(value, { debug })(s); + } + + // Pop stale values, keeping only the return value + // (if any). Multi-block functions can accumulate + // leftover values (e.g. branch condition results) + // from predecessor blocks. + const keep = value ? 1 : 0; + while (s.stack.length > keep) { + if (keep > 0 && s.stack.length > 1) { + const depth = s.stack.length - 1; + s = { + ...s, + instructions: [ + ...s.instructions, + { + mnemonic: `SWAP${depth}`, + opcode: 0x8f + depth, + debug, + }, + ], + stack: [s.stack[depth], ...s.stack.slice(1, depth), s.stack[0]], + brands: [ + s.brands[depth], + ...s.brands.slice(1, depth), + s.brands[0], + ] as Stack, + }; + } + s = { + ...s, + instructions: [ + ...s.instructions, + { mnemonic: "POP", opcode: 0x50, debug }, + ], + stack: s.stack.slice(1), + brands: s.brands.slice(1) as Stack, + }; + } + + // Deallocate frame and jump to saved return PC. + // + // fp = mem[FRAME_POINTER] + // return_pc = mem[fp + SAVED_RETURN_PC] + // old_fp = mem[fp + SAVED_FP] (= mem[fp]) + // mem[FRAME_POINTER] = old_fp + // mem[FREE_MEMORY_POINTER] = fp (deallocate) + // JUMP return_pc + const FP = Memory.regions.FRAME_POINTER; + const FMP = Memory.regions.FREE_MEMORY_POINTER; + const pcOff = Memory.frameHeader.SAVED_RETURN_PC; + + s = { + ...s, + instructions: [ + ...s.instructions, + // fp + ...pushImm(FP, debug), + { mnemonic: "MLOAD", opcode: 0x51, debug }, + // Stack: [fp, ...] + + // return_pc = mem[fp + SAVED_RETURN_PC] + { mnemonic: "DUP1", opcode: 0x80, debug }, + ...pushImm(pcOff, debug), + { mnemonic: "ADD", opcode: 0x01, debug }, + { mnemonic: "MLOAD", opcode: 0x51, debug }, + // Stack: [return_pc, fp, ...] + + // old_fp = mem[fp] (SAVED_FP offset is 0) + { mnemonic: "SWAP1", opcode: 0x90, debug }, + // Stack: [fp, return_pc, ...] + { mnemonic: "DUP1", opcode: 0x80, debug }, + { mnemonic: "MLOAD", opcode: 0x51, debug }, + // Stack: [old_fp, fp, return_pc, ...] + + // mem[FRAME_POINTER] = old_fp + { mnemonic: "DUP1", opcode: 0x80, debug }, + ...pushImm(FP, debug), + { mnemonic: "MSTORE", opcode: 0x52, debug }, + // Stack: [old_fp, fp, return_pc, ...] + + // mem[FREE_MEMORY_POINTER] = fp (deallocate) + { mnemonic: "POP", opcode: 0x50, debug }, + // Stack: [fp, return_pc, ...] + ...pushImm(FMP, debug), + { mnemonic: "MSTORE", opcode: 0x52, debug }, + // Stack: [return_pc, ...] + + // JUMP + { mnemonic: "JUMP", opcode: 0x56, debug }, + ], + }; + + return s; + }) as Transition; +} + +/** PUSH an integer as the smallest PUSHn. */ +function pushImm(value: number, debug: Ir.Block.Debug): Evm.Instruction[] { + if (value === 0) { + return [{ mnemonic: "PUSH0", opcode: 0x5f, debug }]; + } + const bytes: number[] = []; + let v = value; + while (v > 0) { + bytes.unshift(v & 0xff); + v >>= 8; + } + const n = bytes.length; + return [ + { + mnemonic: `PUSH${n}`, + opcode: 0x5f + n, + immediates: bytes, + debug, + }, + ]; +} diff --git a/packages/bugc/src/evmgen/generation/function.test.ts b/packages/bugc/src/evmgen/generation/function.test.ts index e25339452..be209d8c0 100644 --- a/packages/bugc/src/evmgen/generation/function.test.ts +++ b/packages/bugc/src/evmgen/generation/function.test.ts @@ -46,9 +46,10 @@ describe("Function.generate", () => { const { instructions } = generate(func, memory, layout); - // Should have memory initialization (PUSH1 0x80, PUSH1 0x40, MSTORE) followed by PUSH1 42 - // No JUMPDEST for entry with no predecessors, no STOP since it's the last block - expect(instructions).toHaveLength(4); + // Memory init: FMP (PUSH 0x80, PUSH 0x40, MSTORE) + // then FP=0 (PUSH0, PUSH 0x80, MSTORE) + // then PUSH1 42 + expect(instructions).toHaveLength(7); expect(instructions[0]).toMatchObject({ mnemonic: "PUSH1", immediates: [0x80], @@ -61,6 +62,16 @@ describe("Function.generate", () => { mnemonic: "MSTORE", }); expect(instructions[3]).toMatchObject({ + mnemonic: "PUSH0", + }); + expect(instructions[4]).toMatchObject({ + mnemonic: "PUSH1", + immediates: [0x80], + }); + expect(instructions[5]).toMatchObject({ + mnemonic: "MSTORE", + }); + expect(instructions[6]).toMatchObject({ mnemonic: "PUSH1", immediates: [42], }); diff --git a/packages/bugc/src/evmgen/generation/function.ts b/packages/bugc/src/evmgen/generation/function.ts index 59e068bf8..896f3498f 100644 --- a/packages/bugc/src/evmgen/generation/function.ts +++ b/packages/bugc/src/evmgen/generation/function.ts @@ -2,12 +2,13 @@ * 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"; import type { State } from "#evmgen/state"; -import type { Layout, Memory } from "#evmgen/analysis"; +import { type Layout, Memory } from "#evmgen/analysis"; import type { Error as EvmgenError } from "#evmgen/errors"; import * as Block from "./block.js"; @@ -27,12 +28,47 @@ 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) => ({ + ...(p.name ? { name: p.name } : {}), + location: "stack" as const, + slot: params.length - 1 - i, + })); + + // Build declaration source range if available + const declaration = + func.loc && func.sourceId + ? { + source: { id: func.sourceId }, + range: func.loc, + } + : undefined; + + const entryInvoke: Format.Program.Context.Invoke = { + invoke: { + jump: true as const, + identifier: func.name || "anonymous", + ...(declaration ? { declaration } : {}), + 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: [ @@ -41,79 +77,28 @@ function generatePrologue( ], }; - // Store each parameter to memory and pop from stack - // Stack layout on entry: [arg0, arg1, ..., argN] - // 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`, - }, - }; - - for (let i = params.length - 1; i >= 0; i--) { - const param = params[i]; - const allocation = currentState.memory.allocations[param.tempId]; - - if (!allocation) continue; - - // Push memory offset - const highByte = (allocation.offset >> 8) & 0xff; - const lowByte = allocation.offset & 0xff; - currentState = { - ...currentState, - instructions: [ - ...currentState.instructions, - { - mnemonic: "PUSH2", - opcode: 0x61, - immediates: [highByte, lowByte], - debug: prologueDebug, - }, - ], - }; - - // MSTORE pops arg and offset - currentState = { - ...currentState, - instructions: [ - ...currentState.instructions, - { mnemonic: "MSTORE", opcode: 0x52 }, - ], - }; - } - - // Save the return PC from 0x60 to a dedicated slot - // 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 highByte = (savedPcOffset >> 8) & 0xff; - const lowByte = savedPcOffset & 0xff; - currentState = { - ...currentState, - instructions: [ - ...currentState.instructions, - { - mnemonic: "PUSH1", - opcode: 0x60, - immediates: [0x60], - debug: savePcDebug, - }, - { mnemonic: "MLOAD", opcode: 0x51 }, - { - mnemonic: "PUSH2", - opcode: 0x61, - immediates: [highByte, lowByte], - }, - { mnemonic: "MSTORE", opcode: 0x52 }, - ], - }; + const d = makePrologueDebug(func); + const frameSize = currentState.memory.frameSize; + + if (frameSize !== undefined) { + // Allocate call frame from FMP and save context. + // Stack on entry: [argN, ..., arg1, arg0] + currentState = emitFrameAlloc(currentState, frameSize, d); + // Stack: [new_fp, argN, ..., arg0] + + // Pop new_fp — it's now stored at FRAME_POINTER. + currentState = emit(currentState, d, { mnemonic: "POP", opcode: 0x50 }); + // Stack: [argN, ..., arg0] + + // Store each param to its frame-relative slot. + // PUSH FP; MLOAD; PUSH offset; ADD produces the + // address on top; the arg is second; MSTORE + // consumes both. + for (let i = params.length - 1; i >= 0; i--) { + const alloc = currentState.memory.allocations[params[i].tempId]; + if (!alloc) continue; + currentState = emitFpRelativeStore(currentState, alloc.offset, d); + } } // Return with empty stack @@ -125,6 +110,181 @@ function generatePrologue( }) as Transition; } +// ----- prologue helpers ----- + +const { FRAME_POINTER, FREE_MEMORY_POINTER, RETURN_PC_SCRATCH } = + Memory.regions; +const { SAVED_RETURN_PC } = Memory.frameHeader; + +type Debug = Evm.Instruction["debug"]; +type Inst = Omit; + +/** Append instructions to state, attaching debug. */ +function emit( + state: State, + debug: Debug, + ...instrs: Inst[] +): State { + return { + ...state, + instructions: [ + ...state.instructions, + ...instrs.map((i) => ({ ...i, debug })), + ], + }; +} + +/** PUSH an integer as the smallest PUSHn. */ +function pushImm(value: number, debug: Debug): Evm.Instruction[] { + if (value === 0) { + return [{ mnemonic: "PUSH0", opcode: 0x5f, debug }]; + } + const bytes: number[] = []; + let v = value; + while (v > 0) { + bytes.unshift(v & 0xff); + v >>= 8; + } + const n = bytes.length; + return [ + { + mnemonic: `PUSH${n}`, + opcode: 0x5f + n, + immediates: bytes, + debug, + }, + ]; +} + +/** + * Emit frame allocation sequence. + * + * Pushes new_fp onto the stack, bumps FMP, saves old FP + * and return PC into the new frame, updates FRAME_POINTER. + * + * Stack effect: [...args] → [new_fp, ...args] + */ +function emitFrameAlloc( + state: State, + frameSize: number, + debug: Debug, +): State { + let s = state; + + // new_fp = mem[FMP] + s = emit(s, debug, ...pushImm(FREE_MEMORY_POINTER, debug), { + mnemonic: "MLOAD", + opcode: 0x51, + }); + // Stack: [new_fp, args...] + + // mem[FMP] = new_fp + frameSize + s = emit( + s, + debug, + { mnemonic: "DUP1", opcode: 0x80 }, + ...pushImm(frameSize, debug), + { mnemonic: "ADD", opcode: 0x01 }, + ...pushImm(FREE_MEMORY_POINTER, debug), + { mnemonic: "MSTORE", opcode: 0x52 }, + ); + + // frame[SAVED_FP] = old FP + // old_fp = mem[FRAME_POINTER] + s = emit( + s, + debug, + ...pushImm(FRAME_POINTER, debug), + { mnemonic: "MLOAD", opcode: 0x51 }, + // Stack: [old_fp, new_fp, args...] + { mnemonic: "DUP2", opcode: 0x81 }, + // Stack: [new_fp, old_fp, new_fp, args...] + { mnemonic: "MSTORE", opcode: 0x52 }, + // MSTORE(offset=new_fp, value=old_fp) + // → stores old_fp at new_fp+0 (SAVED_FP=0) + // Stack: [new_fp, args...] + ); + + // mem[FRAME_POINTER] = new_fp + s = emit( + s, + debug, + { mnemonic: "DUP1", opcode: 0x80 }, + ...pushImm(FRAME_POINTER, debug), + { mnemonic: "MSTORE", opcode: 0x52 }, + ); + + // frame[SAVED_RETURN_PC] = mem[RETURN_PC_SCRATCH] + s = emit( + s, + debug, + ...pushImm(RETURN_PC_SCRATCH, debug), + { mnemonic: "MLOAD", opcode: 0x51 }, + // Stack: [return_pc, new_fp, args...] + { mnemonic: "DUP2", opcode: 0x81 }, + // Stack: [new_fp, return_pc, new_fp, args...] + ...pushImm(SAVED_RETURN_PC, debug), + { mnemonic: "ADD", opcode: 0x01 }, + // Stack: [new_fp+0x20, return_pc, new_fp, args...] + { mnemonic: "MSTORE", opcode: 0x52 }, + // MSTORE(offset=new_fp+0x20, value=return_pc) + // Stack: [new_fp, args...] + ); + + return s; +} + +/** + * Emit FP-relative MSTORE. + * + * Computes mem[FRAME_POINTER] + offset and stores the + * current TOS value there. Consumes TOS. + * + * Stack effect: [value, ...] → [...] + */ +function emitFpRelativeStore( + state: State, + offset: number, + debug: Debug, +): State { + // Stack: [value, ...] + // → PUSH FP; MLOAD; PUSH offset; ADD + // Stack: [fp+offset, value, ...] + // → MSTORE (offset=fp+offset, value=value) + // Stack: [...] + return emit( + state, + debug, + ...pushImm(FRAME_POINTER, debug), + { mnemonic: "MLOAD", opcode: 0x51 }, + ...pushImm(offset, debug), + { mnemonic: "ADD", opcode: 0x01 }, + { mnemonic: "MSTORE", opcode: 0x52 }, + ); +} + +function makePrologueDebug(func: Ir.Function): Debug { + return func.sourceId && func.loc + ? { + context: { + gather: [ + { remark: "prologue: allocate call frame" }, + { + code: { + source: { id: func.sourceId }, + range: func.loc, + }, + }, + ], + } as Format.Program.Context, + } + : { + context: { + remark: "prologue: allocate call frame", + } as Format.Program.Context, + }; +} + /** * Generate bytecode for a function */ @@ -132,7 +292,10 @@ export function generate( func: Ir.Function, memory: Memory.Function.Info, layout: Layout.Function.Info, - options: { isUserFunction?: boolean } = {}, + options: { + isUserFunction?: boolean; + functions?: Map; + } = {}, ): { instructions: Evm.Instruction[]; bytecode: number[]; @@ -180,6 +343,7 @@ export function generate( isFirstBlock, options.isUserFunction || false, func, + options.functions, )(state); }, stateAfterPrologue, diff --git a/packages/bugc/src/evmgen/generation/instructions/binary.ts b/packages/bugc/src/evmgen/generation/instructions/binary.ts index 3298d5681..5bbcd0704 100644 --- a/packages/bugc/src/evmgen/generation/instructions/binary.ts +++ b/packages/bugc/src/evmgen/generation/instructions/binary.ts @@ -6,8 +6,22 @@ import { type Transition, operations, pipe, rebrand } from "#evmgen/operations"; import { loadValue, storeValueIfNeeded } from "../values/index.js"; -const { ADD, SUB, MUL, DIV, MOD, EQ, LT, GT, AND, OR, ISZERO, SHL, SHR } = - operations; +const { + ADD, + SUB, + MUL, + DIV, + MOD, + EQ, + LT, + GT, + AND, + OR, + ISZERO, + SHL, + SHR, + SWAP1, +} = operations; /** * Generate code for binary operations @@ -23,10 +37,25 @@ export function generateBinary( ) => State; } = { add: ADD({ debug }), - sub: SUB({ debug }), + // Non-commutative ops: operands load as [right=a, + // left=b] but EVM computes TOS op TOS-1 = right op + // left. SWAP1 puts left on TOS before the operation. + sub: pipe() + .then(SWAP1({ debug })) + .then(rebrand<"b", "a", "a", "b">({ 1: "a", 2: "b" })) + .then(SUB({ debug })) + .done(), mul: MUL({ debug }), - div: DIV({ debug }), - mod: MOD({ debug }), + div: pipe() + .then(SWAP1({ debug })) + .then(rebrand<"b", "a", "a", "b">({ 1: "a", 2: "b" })) + .then(DIV({ debug })) + .done(), + mod: pipe() + .then(SWAP1({ debug })) + .then(rebrand<"b", "a", "a", "b">({ 1: "a", 2: "b" })) + .then(MOD({ debug })) + .done(), shl: pipe() .then(rebrand<"a", "shift", "b", "value">({ 1: "shift", 2: "value" })) .then(SHL({ debug })) diff --git a/packages/bugc/src/evmgen/generation/module.ts b/packages/bugc/src/evmgen/generation/module.ts index 92c306f8a..b5b5e014b 100644 --- a/packages/bugc/src/evmgen/generation/module.ts +++ b/packages/bugc/src/evmgen/generation/module.ts @@ -32,6 +32,7 @@ export function generate( module.main, memory.main, blocks.main, + { functions: module.functions }, ); // Collect all warnings @@ -53,6 +54,7 @@ export function generate( if (funcMemory && funcLayout) { const funcResult = Function.generate(func, funcMemory, funcLayout, { isUserFunction: true, + functions: module.functions, }); functionResults.push({ name, @@ -105,9 +107,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 +273,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/evmgen/generation/values/load.ts b/packages/bugc/src/evmgen/generation/values/load.ts index ecfe7d88d..bab4bc209 100644 --- a/packages/bugc/src/evmgen/generation/values/load.ts +++ b/packages/bugc/src/evmgen/generation/values/load.ts @@ -2,17 +2,23 @@ import type * as Ir from "#ir"; import * as Evm from "#evm"; import type { Stack } from "#evm"; import { type Transition, operations, pipe } from "#evmgen/operations"; +import { Memory } from "#evmgen/analysis"; import { valueId, annotateTop } from "./identify.js"; /** - * Load a value onto the stack + * Load a value onto the stack. + * + * When the function uses call frames (memory.frameSize is + * set), allocation offsets are relative to the frame base + * stored at FRAME_POINTER (0x80). The load sequence becomes + * PUSH 0x80 / MLOAD / PUSH offset / ADD / MLOAD. */ export const loadValue = ( value: Ir.Value, options?: Evm.InstructionOptions, ): Transition => { - const { PUSHn, DUPn, MLOAD } = operations; + const { PUSHn, DUPn, ADD, MLOAD } = operations; const id = valueId(value); @@ -35,6 +41,18 @@ export const loadValue = ( // Check if in memory if (id in state.memory.allocations) { const offset = state.memory.allocations[id].offset; + if (state.memory.frameSize !== undefined) { + // FP-relative: load frame base, add offset + return builder + .then(PUSHn(BigInt(Memory.regions.FRAME_POINTER), options), { + as: "offset", + }) + .then(MLOAD(options), { as: "b" }) + .then(PUSHn(BigInt(offset), options), { as: "a" }) + .then(ADD(options), { as: "offset" }) + .then(MLOAD(options)) + .then(annotateTop(id)); + } return builder .then(PUSHn(BigInt(offset), options), { as: "offset" }) .then(MLOAD(options)) diff --git a/packages/bugc/src/evmgen/generation/values/store.ts b/packages/bugc/src/evmgen/generation/values/store.ts index eb813a34c..66751733f 100644 --- a/packages/bugc/src/evmgen/generation/values/store.ts +++ b/packages/bugc/src/evmgen/generation/values/store.ts @@ -1,17 +1,23 @@ import * as Evm from "#evm"; import type { Stack } from "#evm"; import { type Transition, operations, pipe } from "#evmgen/operations"; +import { Memory } from "#evmgen/analysis"; import { annotateTop } from "./identify.js"; /** - * Store a value to memory if it needs to be persisted + * Store a value to memory if it needs to be persisted. + * + * When the function uses call frames (memory.frameSize is + * set), the store address is FP-relative: load frame base + * from FRAME_POINTER, add the allocation offset, then + * MSTORE. */ export const storeValueIfNeeded = ( destId: string, options?: Evm.InstructionOptions, ): Transition => { - const { PUSHn, DUP2, SWAP1, MSTORE } = operations; + const { PUSHn, ADD, DUP2, SWAP1, MLOAD, MSTORE } = operations; return ( pipe() @@ -22,8 +28,30 @@ export const storeValueIfNeeded = ( if (allocation === undefined) { return builder; } + if (state.memory.frameSize !== undefined) { + // FP-relative store: + // stack: [value, ...] + // → PUSH FP_SLOT / MLOAD / PUSH offset / ADD + // → [addr, value, ...] + // → DUP2 / SWAP1 / MSTORE + // → [value, ...] + return builder + .then(PUSHn(BigInt(Memory.regions.FRAME_POINTER), options), { + as: "offset", + }) + .then(MLOAD(options), { as: "b" }) + .then(PUSHn(BigInt(allocation.offset), options), { + as: "a", + }) + .then(ADD(options), { as: "offset" }) + .then(DUP2(options)) + .then(SWAP1(options)) + .then(MSTORE(options)); + } return builder - .then(PUSHn(BigInt(allocation.offset), options), { as: "offset" }) + .then(PUSHn(BigInt(allocation.offset), options), { + as: "offset", + }) .then(DUP2(options)) .then(SWAP1(options)) .then(MSTORE(options)); diff --git a/packages/bugc/src/ir/spec/function.ts b/packages/bugc/src/ir/spec/function.ts index df58c6ac8..93d72d8c8 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 declaration */ + loc?: Ast.SourceLocation; + /** Source ID for debug info (inherited from module) */ + 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/bugc/src/irgen/generate/module.ts b/packages/bugc/src/irgen/generate/module.ts index dcc41e778..dd3fd0ea3 100644 --- a/packages/bugc/src/irgen/generate/module.ts +++ b/packages/bugc/src/irgen/generate/module.ts @@ -72,6 +72,9 @@ export function* buildModule( buildFunction(funcDecl.name, parameters, funcDecl.body), ); if (func) { + const moduleState = yield* Process.Modules.current(); + func.loc = funcDecl.loc ?? undefined; + func.sourceId = moduleState.sourceId; yield* Process.Functions.addToModule(funcDecl.name, func); } } 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..75cd06511 --- /dev/null +++ b/packages/programs-react/src/components/CallInfoPanel.css @@ -0,0 +1,89 @@ +.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-value { + display: inline; +} + +.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..09da2b3d7 --- /dev/null +++ b/packages/programs-react/src/components/CallInfoPanel.tsx @@ -0,0 +1,134 @@ +/** + * 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)"; + const params = info.argumentNames + ? `(${info.argumentNames.join(", ")})` + : "()"; + + if (info.kind === "invoke") { + const prefix = + info.callType === "external" + ? "Calling (external)" + : info.callType === "create" + ? "Creating" + : "Calling"; + return `${prefix} ${name}${params}`; + } + + 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..9143b8d76 --- /dev/null +++ b/packages/programs-react/src/components/CallStackDisplay.css @@ -0,0 +1,50 @@ +.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-empty-text { + font-style: italic; +} + +.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..7f9b087c6 --- /dev/null +++ b/packages/programs-react/src/components/CallStackDisplay.tsx @@ -0,0 +1,64 @@ +/** + * 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..d737372ae 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,38 @@ 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"; + /** Named arguments (from invoke context) */ + argumentNames?: string[]; + /** Panic code for revert contexts */ + panic?: number; + /** Resolved pointer refs */ + pointerRefs: ResolvedPointerRef[]; +} + /** * State provided by the Trace context. */ @@ -53,6 +89,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 +281,94 @@ 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, + argumentNames: extractedCallInfo.argumentNames, + 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 +400,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..78af909c3 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -100,6 +100,288 @@ 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"; + /** Named arguments (from invoke context) */ + argumentNames?: string[]; + /** 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); + } + + // Extract argument names from group entries + const argNames = extractArgNamesFromInvoke(inv); + + return { + kind: "invoke", + identifier: inv.identifier as string | undefined, + callType, + argumentNames: argNames, + 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 extractArgNamesFromInvoke( + inv: Record, +): string[] | undefined { + const args = inv.arguments as Record | undefined; + if (!args) return undefined; + + const pointer = args.pointer as Record | undefined; + if (!pointer) return undefined; + + const group = pointer.group as Array> | undefined; + if (!Array.isArray(group)) return undefined; + + const names: string[] = []; + let hasAny = false; + for (const entry of group) { + const name = entry.name as string | undefined; + if (name) { + names.push(name); + hasAny = true; + } else { + names.push("_"); + } + } + + return hasAny ? names : 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"; + /** Named arguments (from invoke context) */ + argumentNames?: string[]; +} + +/** + * 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, + argumentNames: extractArgNames(instruction), + }); + } + } else if (callInfo.kind === "return" || callInfo.kind === "revert") { + // Pop the matching frame + if (stack.length > 0) { + stack.pop(); + } + } + } + + return stack; +} + +/** + * Extract argument names from an instruction's invoke + * context, if present. + */ +function extractArgNames( + instruction: Program.Instruction, +): string[] | undefined { + const ctx = instruction.context as Record | undefined; + if (!ctx) return undefined; + + // Find the invoke field (may be nested in gather) + const invoke = findInvokeField(ctx); + if (!invoke) return undefined; + + const args = invoke.arguments as Record | undefined; + if (!args) return undefined; + + const pointer = args.pointer as Record | undefined; + if (!pointer) return undefined; + + const group = pointer.group as Array> | undefined; + if (!Array.isArray(group)) return undefined; + + const names: string[] = []; + let hasAny = false; + for (const entry of group) { + const name = entry.name as string | undefined; + if (name) { + names.push(name); + hasAny = true; + } else { + names.push("_"); + } + } + + return hasAny ? names : undefined; +} + +function findInvokeField( + ctx: Record, +): Record | undefined { + if ("invoke" in ctx) { + return ctx.invoke as Record; + } + if ("gather" in ctx && Array.isArray(ctx.gather)) { + for (const item of ctx.gather) { + if (item && typeof item === "object" && "invoke" in item) { + return (item as Record).invoke as Record< + string, + unknown + >; + } + } + } + return undefined; +} + /** * 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-examples.ts b/packages/web/docs/core-schemas/programs/tracing-examples.ts index 19cd1b936..12013c33e 100644 --- a/packages/web/docs/core-schemas/programs/tracing-examples.ts +++ b/packages/web/docs/core-schemas/programs/tracing-examples.ts @@ -51,3 +51,50 @@ code { a = a + 1; b = b + 1; }`; + +export const functionCallAndReturn = `name Adder; + +define { + function add(a: uint256, b: uint256) -> uint256 { + return a + b; + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = add(3, 4); +}`; + +export const recursiveCount = `name Counter; + +define { + function succ(n: uint256) -> uint256 { + return n + 1; + }; + function count(n: uint256, target: uint256) -> uint256 { + if (n < target) { + return count(succ(n), target); + } else { + return n; + } + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = count(0, 5); +}`; diff --git a/packages/web/docs/core-schemas/programs/tracing.mdx b/packages/web/docs/core-schemas/programs/tracing.mdx index 7d1fadc86..125ae3c8f 100644 --- a/packages/web/docs/core-schemas/programs/tracing.mdx +++ b/packages/web/docs/core-schemas/programs/tracing.mdx @@ -9,6 +9,8 @@ import { counterIncrement, thresholdCheck, multipleStorageSlots, + functionCallAndReturn, + recursiveCount, } from "./tracing-examples"; # Tracing execution @@ -78,6 +80,171 @@ This example shows working with multiple storage locations: source={multipleStorageSlots} /> +## 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: + + + +Recursive calls produce nested invoke/return pairs. In this +example, `count` calls `succ` and then calls itself repeatedly +until `n` reaches `target`. Each recursive call adds a frame +to the call stack: + + + +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: @@ -196,6 +363,8 @@ function decodeValue(bytes: Data, type: Type): string { ## Learn more +- [Function context schemas](/spec/program/context/function) for the + invoke, return, and revert specifications - [Instructions documentation](./instructions) for understanding instruction records - [Variables documentation](./variables) for variable structure and lifetime 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..e2a8450f0 --- /dev/null +++ b/packages/web/spec/program/context/function/function.mdx @@ -0,0 +1,22 @@ +--- +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. See +[invoke](/spec/program/context/function/invoke), +[return](/spec/program/context/function/return), and +[revert](/spec/program/context/function/revert) for the +individual context schemas. + + 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..d017c2cc0 --- /dev/null +++ b/packages/web/spec/program/context/function/invoke.mdx @@ -0,0 +1,57 @@ +--- +sidebar_position: 1 +--- + +import SchemaViewer from "@site/src/components/SchemaViewer"; + +# Invocation contexts + +An invoke context marks an instruction that enters a function. +The schema distinguishes three kinds of invocation — internal +calls (via JUMP), external message calls (CALL/DELEGATECALL/ +STATICCALL), and contract creations (CREATE/CREATE2) — each +with fields appropriate to the call mechanism. + +All variants extend the [function identity](/spec/program/context/function) +schema and may include pointers to the call target, arguments, +gas, value, and input data as applicable. See +[Tracing execution](/docs/core-schemas/programs/tracing) for +worked examples showing how debuggers use invoke and return +contexts to reconstruct call stacks. + + + +## 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..e03668a1b --- /dev/null +++ b/packages/web/spec/program/context/function/return.mdx @@ -0,0 +1,59 @@ +--- +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](/spec/program/context/function) schema with +a pointer to the return data and, for external calls, the +success status. + + + +## Return data + +The `data` field is required and contains a pointer to the value +being returned. For internal calls this typically points to a +stack location; for external calls it points into the +`returndata` buffer. + +## Internal return + +An internal return marks the JUMP instruction that transfers +control back to the caller within the same contract. The +function leaves its return value on the stack, so `data` points +to the relevant stack slot. + +Internal returns do not use the `success` field—internal calls +either return normally or revert, with no separate success flag. + +## External call return + +An external call return marks an instruction after a CALL, +DELEGATECALL, or STATICCALL that completed successfully. The +EVM places a success flag on the stack (1 for success, 0 for +failure), and the callee's output is accessible via the +returndata buffer. + +The `success` field is specific to external call returns. It +contains a pointer to the boolean success value on the stack, +letting the debugger distinguish successful returns from +reverts at the EVM level. + +## Field optionality + +The `data` pointer is always required—every return context must +indicate where the return value can be found. The `success` +field is optional and only meaningful for external calls. + +Function identity fields (`identifier`, `declaration`, `type`) +are all optional. A compiler may omit them when it cannot +attribute the return to a specific named function, for instance +when returning from a fallback function or an anonymous code +path. 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..564620b7a --- /dev/null +++ b/packages/web/spec/program/context/function/revert.mdx @@ -0,0 +1,58 @@ +--- +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](/spec/program/context/function) schema with +an optional pointer to revert reason data and/or a numeric +panic code for built-in assertion failures. + + + +## Reason-based revert + +The `reason` field contains a pointer to the revert reason data +in memory or, for external calls, in the returndata buffer. +This typically holds an ABI-encoded error: a `require()` failure +produces an `Error(string)` payload, while custom errors produce +their own ABI-encoded selector and arguments. + +For an internal revert, `reason` points into memory where the +compiler has written the encoded error. For an external call +that reverted, `reason` points into the `returndata` buffer +containing the callee's revert output. + +## Panic codes + +The `panic` field carries a numeric code for built-in assertion +failures that the compiler inserts automatically. These do not +originate from explicit `revert` or `require` statements—they +guard against conditions like arithmetic overflow or +out-of-bounds array access. + +Languages define their own panic code conventions. For example, +Solidity uses `0x01` for failed `assert()`, `0x11` for +arithmetic overflow, and `0x32` for out-of-bounds indexing. + +A revert context may include `panic` alone (when no reason +pointer is needed), `reason` alone, or both. + +## Field optionality + +Both `reason` and `panic` are optional. A revert context is +valid with either, both, or neither—a bare `revert: {}` is +permitted when the compiler knows a revert occurred but has no +further detail. + +Function identity fields (`identifier`, `declaration`, `type`) +are also optional. A compiler may omit them when the revert +cannot be attributed to a specific function, such as a +top-level `require` guard or an unattributed external call +failure. diff --git a/packages/web/spec/type/concepts.mdx b/packages/web/spec/type/concepts.mdx index e87a516f4..0d3c4105d 100644 --- a/packages/web/spec/type/concepts.mdx +++ b/packages/web/spec/type/concepts.mdx @@ -55,12 +55,12 @@ places additional constraints in addition to what the base schema specifies. ## Elementary vs. complex types Type representations in this schema fall into one of two `class`es: either -`"elementary"` or `"complex"`. Type representations express this disinction in +`"elementary"` or `"complex"`. Type representations express this distinction in two ways (the optional `"class"` field, and the absence or existence of a `"contains"` field). - Elementary types do not compose any other types. For example, `uint256` is an - elementary type. `string` may be an elementary type for languages that whose + elementary type. `string` may be an elementary type for languages whose semantics treat strings differently than simply an array of characters (like Solidity does). @@ -82,7 +82,7 @@ of three forms: All three forms of composition polymorphically use the `"contains"` field. As described in -[Type wrappers and type references](#type-wrappers-and-type-references) +[Type specifiers, wrappers, and references](#type-specifiers-wrappers-and-references) below, complex types compose other types by way of wrapper objects of the form `{ "type": ... }`, which possibly includes other fields alongside `"type"`. @@ -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..75cd06511 --- /dev/null +++ b/packages/web/src/theme/ProgramExample/CallInfoPanel.css @@ -0,0 +1,89 @@ +.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-value { + display: inline; +} + +.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..9143b8d76 --- /dev/null +++ b/packages/web/src/theme/ProgramExample/CallStackDisplay.css @@ -0,0 +1,50 @@ +.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-empty-text { + font-style: italic; +} + +.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..088fa8d3f 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -85,6 +85,64 @@ 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; + argumentNames?: 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, + argumentNames: info.argumentNames, + }); + } + } 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 +356,40 @@ function TraceDrawerContent(): JSX.Element { +
+ Call Stack: + {callStack.length === 0 ? ( + (top level) + ) : ( + callStack.map((frame, i) => ( + + {i > 0 && ( + + )} + + + )) + )} +
+ + {currentCallInfo && ( +
+ {formatCallBanner(currentCallInfo)} +
+ )} +
Instructions
@@ -468,6 +560,122 @@ function VariablesDisplay({ variables }: VariablesDisplayProps): JSX.Element { ); } +/** + * Info about a call context (invoke/return/revert). + */ +interface CallInfoResult { + kind: "invoke" | "return" | "revert"; + identifier?: string; + callType?: string; + argumentNames?: 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, + argumentNames: extractArgNamesFromInvoke(inv), + }; + } + + 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)"; + const params = info.argumentNames + ? `(${info.argumentNames.join(", ")})` + : "()"; + switch (info.kind) { + case "invoke": { + const prefix = info.callType === "create" ? "Creating" : "Calling"; + return `${prefix} ${name}${params}`; + } + case "return": + return `Returned from ${name}()`; + case "revert": + return `Reverted in ${name}()`; + } +} + +function extractArgNamesFromInvoke( + inv: Record, +): string[] | undefined { + const args = inv.arguments as Record | undefined; + if (!args) return undefined; + + const pointer = args.pointer as Record | undefined; + if (!pointer) return undefined; + + const group = pointer.group as Array> | undefined; + if (!Array.isArray(group)) return undefined; + + const names: string[] = []; + let hasAny = false; + for (const entry of group) { + const name = entry.name as string | undefined; + if (name) { + names.push(name); + hasAny = true; + } else { + names.push("_"); + } + } + + return hasAny ? names : undefined; +} + /** * 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