diff --git a/packages/programs-react/src/utils/mockTrace.test.ts b/packages/programs-react/src/utils/mockTrace.test.ts index f9901e6a6..e56a8ea1c 100644 --- a/packages/programs-react/src/utils/mockTrace.test.ts +++ b/packages/programs-react/src/utils/mockTrace.test.ts @@ -8,6 +8,7 @@ import type { Program } from "@ethdebug/format"; import { extractTransformFromInstruction, extractCallInfoFromInstruction, + extractCallEvents, buildCallStack, buildPcToInstructionMap, type TraceStep, @@ -194,10 +195,13 @@ describe("flat (production) TCO back-edge shape", () => { // inlined body with a virtual invoke on the entry-first // instruction and a virtual return on the exit-last instruction; // every inlined instruction carries transform:["inline"]. The -// call stack must reconstruct the virtual frame, tag it, and -// tear it down when execution leaves the inlined body — so it -// reads distinctly from a real call and never leaks a phantom -// frame into caller code. +// call stack reconstructs the virtual frame via close-after +// push/pop (a frame is visible AT its return-bearing instruction +// and popped on advance), tags it, and — belt-and-suspenders — +// tears down any trailing virtual frame the moment execution +// reaches an instruction whose inline-marker count is below the +// open virtual depth. So it reads distinctly from a real call and +// never leaks a phantom frame into caller code. describe("inline virtual activations", () => { const entryInvoke = { code: { source: { id: "0" }, range: { offset: 0, length: 1 } }, @@ -216,6 +220,14 @@ describe("inline virtual activations", () => { const callerMark = { code: { source: { id: "0" }, range: { offset: 3, length: 1 } }, }; + // A body that emits to a single EVM op: invoke and return + // co-locate on one instruction (the degenerate bracketed case). + const singleOpBody = { + code: { source: { id: "0" }, range: { offset: 0, length: 1 } }, + transform: ["inline"], + invoke: { jump: true, identifier: "dbl" }, + return: { identifier: "dbl" }, + }; describe("extractCallInfoFromInstruction inline flag", () => { it("marks isInline on a virtual (inline) invoke", () => { @@ -238,13 +250,31 @@ describe("inline virtual activations", () => { }); }); - describe("buildCallStack virtual frame lifetime", () => { + describe("extractCallEvents exposes both discriminators", () => { + it("returns invoke then return, in order, for a co-located context", () => { + const events = extractCallEvents(instr(0, singleOpBody)); + expect(events.map((e) => e.kind)).toEqual(["invoke", "return"]); + expect(events.every((e) => e.isInline)).toBe(true); + }); + + it("returns a single invoke event for a pure invoke", () => { + const events = extractCallEvents(instr(0, entryInvoke)); + expect(events.map((e) => e.kind)).toEqual(["invoke"]); + }); + + it("returns a single return event for a pure return", () => { + const events = extractCallEvents(instr(0, exitReturn)); + expect(events.map((e) => e.kind)).toEqual(["return"]); + }); + }); + + describe("buildCallStack virtual frame lifetime (close-after)", () => { // A single inlined body: entry / body / exit / caller. const trace: TraceStep[] = [ { pc: 0, opcode: "PUSH1" }, // entry invoke → push virtual dbl { pc: 1, opcode: "ADD" }, // inlined body instruction - { pc: 2, opcode: "MSTORE" }, // exit return → pop - { pc: 3, opcode: "JUMPDEST" }, // caller code (no inline marker) + { pc: 2, opcode: "MSTORE" }, // exit return (still inside frame) + { pc: 3, opcode: "JUMPDEST" }, // caller code (frame gone) ]; const program = { instructions: [ @@ -269,17 +299,39 @@ describe("inline virtual activations", () => { expect(stack[0].isInline).toBe(true); }); - it("pops the virtual frame at the exit return", () => { + it("still shows the frame AT the exit return (close-after)", () => { const stack = buildCallStack(trace, pcToInstruction, 2); - expect(stack).toHaveLength(0); + expect(stack).toHaveLength(1); + expect(stack[0].isInline).toBe(true); }); - it("does not leak a phantom frame into caller code", () => { + it("pops the frame once execution advances past the return", () => { const stack = buildCallStack(trace, pcToInstruction, 3); expect(stack).toHaveLength(0); }); }); + describe("single-op inlined body (co-located invoke+return)", () => { + const trace: TraceStep[] = [ + { pc: 0, opcode: "PUSH1" }, // the whole body: invoke+return + { pc: 1, opcode: "JUMPDEST" }, // caller code + ]; + const program = { + instructions: [instr(0, singleOpBody), instr(1, callerMark)], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("shows the virtual frame AT the single body op", () => { + const stack = buildCallStack(trace, pcToInstruction, 0); + expect(stack).toHaveLength(1); + expect(stack[0].isInline).toBe(true); + }); + + it("pops after advancing off the body op", () => { + expect(buildCallStack(trace, pcToInstruction, 1)).toHaveLength(0); + }); + }); + describe("two gap-separated inline sites of the same helper", () => { const trace: TraceStep[] = [ { pc: 0, opcode: "PUSH1" }, // site 1 entry @@ -305,6 +357,7 @@ describe("inline virtual activations", () => { const stack = buildCallStack(trace, pcToInstruction, 3); expect(stack).toHaveLength(1); expect(stack[0].isInline).toBe(true); + expect(stack[0].stepIndex).toBe(3); }); it("is empty after both sites — no accumulation", () => { @@ -313,6 +366,110 @@ describe("inline virtual activations", () => { }); }); + describe("two ADJACENT inline sites split by the return", () => { + // No caller gap between sites: the return marker (not the + // membership guard, which can't see a boundary between two + // inline-marked instructions) is what closes site 1 before + // site 2 opens. + const trace: TraceStep[] = [ + { pc: 0, opcode: "PUSH1" }, // site 1 entry + { pc: 1, opcode: "MSTORE" }, // site 1 exit + { pc: 2, opcode: "PUSH1" }, // site 2 entry (immediately) + { pc: 3, opcode: "MSTORE" }, // site 2 exit + { pc: 5, opcode: "JUMPDEST" }, // caller + ]; + const program = { + instructions: [ + instr(0, entryInvoke), + instr(1, exitReturn), + instr(2, entryInvoke), + instr(3, exitReturn), + instr(5, callerMark), + ], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("does not merge or accumulate — one frame, rooted at site 2", () => { + const stack = buildCallStack(trace, pcToInstruction, 2); + expect(stack).toHaveLength(1); + expect(stack[0].stepIndex).toBe(2); + }); + + it("is empty after both sites", () => { + expect(buildCallStack(trace, pcToInstruction, 4)).toHaveLength(0); + }); + }); + + describe("marker-keyed dedup", () => { + // A real call and an inlined body of the SAME name on + // consecutive steps must NOT be merged by the caller-JUMP / + // callee-JUMPDEST dedup — they are distinct activations. + const realInvoke = { invoke: { jump: true, identifier: "dbl" } }; + const trace: TraceStep[] = [ + { pc: 0, opcode: "JUMP" }, // real invoke of dbl + { pc: 1, opcode: "PUSH1" }, // virtual (inline) invoke of dbl + ]; + const program = { + instructions: [instr(0, realInvoke), instr(1, entryInvoke)], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("keeps a real and a virtual dbl as two separate frames", () => { + const stack = buildCallStack(trace, pcToInstruction, 1); + expect(stack).toHaveLength(2); + expect(stack[0].isInline).toBeFalsy(); + expect(stack[1].isInline).toBe(true); + }); + }); + + describe("nested inlining (double inline marker)", () => { + // Helper A inlined into helper B which is itself inlined: + // A's body instructions are members of both bodies and carry + // transform:["inline","inline"]. Two virtual frames stack; the + // inner returns first, leaving the outer. + const entryB = { + transform: ["inline"], + invoke: { jump: true, identifier: "B" }, + }; + const entryA = { + transform: ["inline", "inline"], + invoke: { jump: true, identifier: "A" }, + }; + const exitA = { + transform: ["inline", "inline"], + return: { identifier: "A" }, + }; + const bodyB = { transform: ["inline"] }; // back to just B's body + const trace: TraceStep[] = [ + { pc: 0, opcode: "PUSH1" }, // enter B + { pc: 1, opcode: "PUSH1" }, // enter A (inside B) + { pc: 2, opcode: "MSTORE" }, // exit A + { pc: 3, opcode: "ADD" }, // back in B only + ]; + const program = { + instructions: [ + instr(0, entryB), + instr(1, entryA), + instr(2, exitA), + instr(3, bodyB), + ], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("stacks two virtual frames inside the inner body", () => { + const stack = buildCallStack(trace, pcToInstruction, 1); + expect(stack).toHaveLength(2); + expect(stack[0].identifier).toBe("B"); + expect(stack[1].identifier).toBe("A"); + }); + + it("drops to the outer frame after the inner returns", () => { + const stack = buildCallStack(trace, pcToInstruction, 3); + expect(stack).toHaveLength(1); + expect(stack[0].identifier).toBe("B"); + }); + }); + describe("defensive membership guard", () => { // A virtual invoke whose exit return never arrives (residual // smear / dropped marker): the frame must still be torn down @@ -341,13 +498,38 @@ describe("inline virtual activations", () => { }); }); - it("leaves a real call frame's isInline falsy", () => { - const trace: TraceStep[] = [{ pc: 0, opcode: "JUMPDEST" }]; + describe("real calls (regression: close-after applies uniformly)", () => { + // A real call: caller JUMP + callee JUMPDEST (deduped), then a + // return. The frame is visible at its return step and popped on + // advance — same close-after rule as virtual frames. + const trace: TraceStep[] = [ + { pc: 0, opcode: "JUMP" }, // caller invoke + { pc: 1, opcode: "JUMPDEST" }, // callee entry invoke (dedup) + { pc: 2, opcode: "JUMP" }, // callee return + { pc: 3, opcode: "JUMPDEST" }, // back in caller + ]; const program = { - instructions: [instr(0, { invoke: { jump: true, identifier: "f" } })], + instructions: [ + instr(0, { invoke: { jump: true, identifier: "f" } }), + instr(1, { invoke: { jump: true, identifier: "f" } }), + instr(2, { return: { identifier: "f" } }), + instr(3, { code: { source: { id: "0" }, range: {} } }), + ], } as unknown as Program; const pcToInstruction = buildPcToInstructionMap(program); - const stack = buildCallStack(trace, pcToInstruction, 0); - expect(stack[0].isInline).toBeFalsy(); + + it("collapses the caller/callee invoke double into one frame", () => { + expect(buildCallStack(trace, pcToInstruction, 1)).toHaveLength(1); + }); + + it("still shows the frame AT its return instruction", () => { + const stack = buildCallStack(trace, pcToInstruction, 2); + expect(stack).toHaveLength(1); + expect(stack[0].isInline).toBeFalsy(); + }); + + it("pops the real frame on advancing past the return", () => { + expect(buildCallStack(trace, pcToInstruction, 3)).toHaveLength(0); + }); }); }); diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index eb9c55581..fe5ac7fb0 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -171,117 +171,136 @@ function extractTransformFromContext(context: Program.Context): string[] { } /** - * Extract call info (invoke/return/revert) from an - * instruction's context tree. + * Extract the primary call event (invoke/return/revert) from an + * instruction's context tree, decorated with transform flags. + * + * A context can legitimately carry BOTH an invoke and a return + * (e.g. a tail-call back-edge, or an inlined body that emits to a + * single instruction). This accessor returns just the first event + * for display banners; call-stack reconstruction uses + * {@link extractCallEvents}, which surfaces every event so a + * co-located return is never swallowed by the invoke. */ export function extractCallInfoFromInstruction( instruction: Program.Instruction, ): CallInfo | undefined { + return extractCallEvents(instruction)[0]; +} + +/** + * Extract ALL call events (invoke/return/revert) from an + * instruction's context tree, in document order (invoke before + * return within one context), decorated with the instruction's + * transform flags. Returns [] when there is no call context. + */ +export function extractCallEvents( + instruction: Program.Instruction, +): CallInfo[] { if (!instruction.context) { - return undefined; + return []; } - const info = extractCallInfoFromContext(instruction.context); - if (!info) { - return undefined; + const events = collectCallInfos(instruction.context); + if (events.length === 0) { + return []; } const transforms = extractTransformFromContext(instruction.context); - const decorated: CallInfo = { ...info }; - if (transforms.includes("tailcall")) { - decorated.isTailCall = true; - } - if (transforms.includes("inline")) { - decorated.isInline = true; + const isTailCall = transforms.includes("tailcall"); + const isInline = transforms.includes("inline"); + if (!isTailCall && !isInline) { + return events; } - return decorated; + return events.map((e) => ({ + ...e, + ...(isTailCall ? { isTailCall: true } : {}), + ...(isInline ? { isInline: true } : {}), + })); } -function extractCallInfoFromContext( - context: Program.Context, -): CallInfo | undefined { +/** + * Collect the invoke/return/revert events carried by a context + * tree, in order. Invoke precedes return within a single context; + * gather/pick children are visited in sequence. + */ +function collectCallInfos(context: Program.Context): CallInfo[] { // Use unknown intermediate to avoid strict type checks // on the context union — we discriminate by key presence const ctx = context as unknown as Record; + const out: CallInfo[] = []; 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, - }; + out.push(parseInvoke(ctx.invoke as Record)); } - 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, - }; + out.push(parseReturn(ctx.return as Record)); } - 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, - }; + out.push(parseRevert(ctx.revert as Record)); } - // Walk gather/pick to find call info - if ("gather" in ctx && Array.isArray(ctx.gather)) { + if (Array.isArray(ctx.gather)) { for (const sub of ctx.gather as Program.Context[]) { - const info = extractCallInfoFromContext(sub); - if (info) { - return info; - } + out.push(...collectCallInfos(sub)); } } - - if ("pick" in ctx && Array.isArray(ctx.pick)) { + if (Array.isArray(ctx.pick)) { for (const sub of ctx.pick as Program.Context[]) { - const info = extractCallInfoFromContext(sub); - if (info) { - return info; - } + out.push(...collectCallInfos(sub)); } } - return undefined; + return out; +} + +function parseInvoke(inv: Record): CallInfo { + const pointerRefs: CallInfo["pointerRefs"] = []; + + let callType: CallInfo["callType"]; + if ("jump" in inv) { + callType = "internal"; + collectPointerRef(pointerRefs, "target", inv.target); + collectPointerRef(pointerRefs, "arguments", inv.arguments); + } else if ("message" in inv) { + callType = "external"; + collectPointerRef(pointerRefs, "target", inv.target); + collectPointerRef(pointerRefs, "gas", inv.gas); + collectPointerRef(pointerRefs, "value", inv.value); + collectPointerRef(pointerRefs, "input", inv.input); + } else if ("create" in inv) { + callType = "create"; + collectPointerRef(pointerRefs, "value", inv.value); + collectPointerRef(pointerRefs, "salt", inv.salt); + collectPointerRef(pointerRefs, "input", inv.input); + } + + return { + kind: "invoke", + identifier: inv.identifier as string | undefined, + callType, + argumentNames: extractArgNamesFromInvoke(inv), + pointerRefs, + }; +} + +function parseReturn(ret: Record): CallInfo { + const pointerRefs: CallInfo["pointerRefs"] = []; + collectPointerRef(pointerRefs, "data", ret.data); + collectPointerRef(pointerRefs, "success", ret.success); + return { + kind: "return", + identifier: ret.identifier as string | undefined, + pointerRefs, + }; +} + +function parseRevert(rev: Record): CallInfo { + 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, + }; } function extractArgNamesFromInvoke( @@ -380,13 +399,13 @@ export function buildCallStack( // Per-instruction inline membership drives the defensive // guard below: an inlined body's instructions all carry - // transform:["inline"], so a virtual frame is only valid - // while that marker holds. - const isInlineInstr = - extractTransformFromInstruction(instruction).includes("inline"); - const callInfo = extractCallInfoFromInstruction(instruction); + // transform:["inline"] (nested inlining stacks the marker), so + // the count bounds how many virtual frames may legitimately be + // open on this instruction. + const transforms = extractTransformFromInstruction(instruction); + const inlineCount = transforms.filter((t) => t === "inline").length; - if (callInfo?.isTailCall) { + if (transforms.includes("tailcall")) { // A TCO back-edge carries both return and invoke on a // single instruction: the previous iteration returns // and the next iteration is invoked, reusing the same @@ -399,9 +418,9 @@ export function buildCallStack( const argResult = extractArgInfo(instruction); const invId = inv?.identifier as string | undefined; const frame: CallFrame = { - identifier: invId ?? callInfo.identifier, + identifier: invId, stepIndex: i, - callType: inv ? invokeCallType(inv) : callInfo.callType, + callType: inv ? invokeCallType(inv) : undefined, argumentNames: argResult?.names, argumentPointers: argResult?.pointers, isTailCall: true, @@ -414,58 +433,71 @@ export function buildCallStack( continue; } - if (callInfo?.kind === "invoke") { - // The compiler emits invoke on both the caller JUMP - // and callee entry JUMPDEST for the same call. These - // occur on consecutive trace steps. Only skip if the - // top frame matches AND was pushed on the immediately - // preceding step — otherwise this is a new call (e.g. - // recursion with the same function name). - const top = stack[stack.length - 1]; - const isDuplicate = - top && - top.identifier === callInfo.identifier && - top.callType === callInfo.callType && - top.stepIndex === i - 1; - if (isDuplicate) { - // Use the callee entry step for resolution — - // the argument pointers reference stack slots - // that are valid at the JUMPDEST, not the JUMP. - // Argument names also live on the callee entry. - const argResult = extractArgInfo(instruction); - top.stepIndex = i; - top.argumentNames = argResult?.names ?? top.argumentNames; - top.argumentPointers = argResult?.pointers; - } else { - const argResult = extractArgInfo(instruction); - stack.push({ - identifier: callInfo.identifier, - stepIndex: i, - callType: callInfo.callType, - argumentNames: argResult?.names, - argumentPointers: argResult?.pointers, - // Tag virtual activations so the widget can render - // them distinctly from real calls. - ...(callInfo.isInline ? { isInline: true } : {}), - }); - } - } else if (callInfo?.kind === "return" || callInfo?.kind === "revert") { - // Pop the matching frame - if (stack.length > 0) { - stack.pop(); + // A context may carry more than one event (invoke + return), + // e.g. an inlined body that emits to a single instruction. + // Process them in order: an invoke opens a frame INCLUSIVE of + // its instruction; a return closes it AFTER its instruction + // (close-after) — so the frame is still shown while parked on + // the return-bearing instruction and popped only on advance. + for (const event of extractCallEvents(instruction)) { + if (event.kind === "invoke") { + // The compiler emits invoke on both the caller JUMP and + // callee entry JUMPDEST for a REAL call, on consecutive + // steps — collapse that double. Key the dedup on the + // inline marker so a virtual invoke never merges with an + // adjacent real invoke of the same name (and vice versa). + const top = stack[stack.length - 1]; + const isDuplicate = + top && + top.identifier === event.identifier && + top.callType === event.callType && + top.stepIndex === i - 1 && + !!top.isInline === !!event.isInline; + if (isDuplicate) { + // Use the callee entry step for resolution — argument + // pointers/names live on the JUMPDEST, not the JUMP. + const argResult = extractArgInfo(instruction); + top.stepIndex = i; + top.argumentNames = argResult?.names ?? top.argumentNames; + top.argumentPointers = argResult?.pointers; + } else { + const argResult = extractArgInfo(instruction); + stack.push({ + identifier: event.identifier, + stepIndex: i, + callType: event.callType, + argumentNames: argResult?.names, + argumentPointers: argResult?.pointers, + // Tag virtual activations so the widget can render + // them distinctly from real calls. + ...(event.isInline ? { isInline: true } : {}), + }); + } + } else if (event.kind === "return" || event.kind === "revert") { + // close-after: defer the pop until we advance past this + // step, so the frame is visible AT its return instruction. + if (i < upToStep && stack.length > 0) { + stack.pop(); + } } } - // Defensive membership guard: a virtual (inline) frame must - // not stay open once execution leaves the inlined body. If - // the current instruction carries no inline marker, tear down - // any trailing virtual frames — belt-and-suspenders against a - // dropped or incomplete virtual return so a phantom activation - // can never leak into caller code. - if (!isInlineInstr) { - while (stack.length > 0 && stack[stack.length - 1].isInline) { - stack.pop(); - } + // Defensive membership guard: virtual frames beyond the + // instruction's inline-marker count are stale — belt-and- + // suspenders against a dropped or incomplete virtual return so + // a phantom activation can never leak into caller code (or + // linger after an inner inlined body has ended). + let trailingVirtual = 0; + for (let k = stack.length - 1; k >= 0 && stack[k].isInline; k--) { + trailingVirtual++; + } + while ( + trailingVirtual > inlineCount && + stack.length > 0 && + stack[stack.length - 1].isInline + ) { + stack.pop(); + trailingVirtual--; } }