+
{formatBanner(currentCallInfo)}
diff --git a/packages/programs-react/src/components/CallStackDisplay.css b/packages/programs-react/src/components/CallStackDisplay.css
index 9143b8d76..90afee044 100644
--- a/packages/programs-react/src/components/CallStackDisplay.css
+++ b/packages/programs-react/src/components/CallStackDisplay.css
@@ -48,3 +48,15 @@
.call-stack-parens {
color: var(--programs-text-muted, #888);
}
+
+.call-stack-tailcall {
+ margin-left: 4px;
+ padding: 0 5px;
+ border-radius: 8px;
+ font-size: 0.8em;
+ font-weight: 500;
+ white-space: nowrap;
+ background: var(--programs-transform-bg, #f3ecff);
+ color: var(--programs-transform-text, #8250df);
+ border: 1px solid var(--programs-transform-accent, #a475f9);
+}
diff --git a/packages/programs-react/src/components/CallStackDisplay.tsx b/packages/programs-react/src/components/CallStackDisplay.tsx
index 09e2bf7aa..983f75be1 100644
--- a/packages/programs-react/src/components/CallStackDisplay.tsx
+++ b/packages/programs-react/src/components/CallStackDisplay.tsx
@@ -94,6 +94,14 @@ export function CallStackDisplay({
({formatArgs(frame, resolvedCallStack)})
+ {frame.isTailCall && (
+
+ ⮌ tail call
+
+ )}
))}
diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx
index 581b72143..795f3f2f4 100644
--- a/packages/programs-react/src/components/TraceContext.tsx
+++ b/packages/programs-react/src/components/TraceContext.tsx
@@ -118,6 +118,8 @@ export interface ResolvedCallInfo {
panic?: number;
/** Resolved pointer refs */
pointerRefs: ResolvedPointerRef[];
+ /** True when a tailcall transform is present (TCO). */
+ isTailCall?: boolean;
}
/**
@@ -136,6 +138,8 @@ export interface ResolvedCallFrame {
value?: string;
error?: string;
}>;
+ /** True when this frame was (re)entered via a tail call. */
+ isTailCall?: boolean;
}
/**
@@ -382,6 +386,7 @@ export function TraceProvider({
identifier: frame.identifier,
stepIndex: frame.stepIndex,
callType: frame.callType,
+ isTailCall: frame.isTailCall,
resolvedArgs: argCacheRef.current.get(frame.stepIndex),
}));
setResolvedCallStack(initial);
@@ -477,6 +482,7 @@ export function TraceProvider({
callType: extractedCallInfo.callType,
argumentNames: extractedCallInfo.argumentNames,
panic: extractedCallInfo.panic,
+ isTailCall: extractedCallInfo.isTailCall,
pointerRefs: extractedCallInfo.pointerRefs.map((ref) => ({
label: ref.label,
pointer: ref.pointer,
diff --git a/packages/programs-react/src/index.ts b/packages/programs-react/src/index.ts
index 6253933c2..8d8610771 100644
--- a/packages/programs-react/src/index.ts
+++ b/packages/programs-react/src/index.ts
@@ -59,6 +59,7 @@ export {
findInstructionAtPc,
extractVariablesFromInstruction,
extractCallInfoFromInstruction,
+ extractTransformFromInstruction,
buildPcToInstructionMap,
buildCallStack,
type CallInfo,
diff --git a/packages/programs-react/src/utils/index.ts b/packages/programs-react/src/utils/index.ts
index a79f07b08..e6dfdbefb 100644
--- a/packages/programs-react/src/utils/index.ts
+++ b/packages/programs-react/src/utils/index.ts
@@ -18,6 +18,7 @@ export {
findInstructionAtPc,
extractVariablesFromInstruction,
extractCallInfoFromInstruction,
+ extractTransformFromInstruction,
buildPcToInstructionMap,
buildCallStack,
type TraceStep,
diff --git a/packages/programs-react/src/utils/mockTrace.test.ts b/packages/programs-react/src/utils/mockTrace.test.ts
new file mode 100644
index 000000000..49ea5e568
--- /dev/null
+++ b/packages/programs-react/src/utils/mockTrace.test.ts
@@ -0,0 +1,117 @@
+/**
+ * Tests for trace context extraction, transform (tailcall)
+ * detection, and call-stack construction.
+ */
+
+import { describe, it, expect } from "vitest";
+import type { Program } from "@ethdebug/format";
+import {
+ extractTransformFromInstruction,
+ extractCallInfoFromInstruction,
+ buildCallStack,
+ buildPcToInstructionMap,
+ type TraceStep,
+} from "./mockTrace.js";
+
+/** Build a minimal instruction with a context at an offset. */
+function instr(offset: number, context: unknown): Program.Instruction {
+ return {
+ offset,
+ operation: { mnemonic: "JUMPDEST", arguments: [] },
+ context,
+ } as unknown as Program.Instruction;
+}
+
+describe("extractTransformFromInstruction", () => {
+ it("returns identifiers from a direct transform context", () => {
+ const i = instr(0, { transform: ["tailcall"] });
+ expect(extractTransformFromInstruction(i)).toEqual(["tailcall"]);
+ });
+
+ it("finds transform identifiers nested inside a gather", () => {
+ const i = instr(0, {
+ gather: [
+ { return: { identifier: "sum" } },
+ { invoke: { jump: true, identifier: "sum" } },
+ { transform: ["tailcall"] },
+ ],
+ });
+ expect(extractTransformFromInstruction(i)).toEqual(["tailcall"]);
+ });
+
+ it("collects multiple identifiers across nested contexts", () => {
+ const i = instr(0, {
+ gather: [{ transform: ["inline"] }, { transform: ["tailcall"] }],
+ });
+ expect(extractTransformFromInstruction(i).sort()).toEqual([
+ "inline",
+ "tailcall",
+ ]);
+ });
+
+ it("returns an empty array when no transform is present", () => {
+ const i = instr(0, { invoke: { jump: true, identifier: "sum" } });
+ expect(extractTransformFromInstruction(i)).toEqual([]);
+ });
+});
+
+describe("extractCallInfoFromInstruction tailcall flag", () => {
+ it("marks isTailCall when a tailcall transform is present", () => {
+ const i = instr(0, {
+ gather: [
+ { return: { identifier: "sum" } },
+ { invoke: { jump: true, identifier: "sum" } },
+ { transform: ["tailcall"] },
+ ],
+ });
+ const info = extractCallInfoFromInstruction(i);
+ expect(info?.isTailCall).toBe(true);
+ });
+
+ it("leaves isTailCall falsy for a plain invoke", () => {
+ const i = instr(0, { invoke: { jump: true, identifier: "sum" } });
+ const info = extractCallInfoFromInstruction(i);
+ expect(info?.isTailCall).toBeFalsy();
+ });
+});
+
+describe("buildCallStack TCO frame replacement", () => {
+ const trace: TraceStep[] = [
+ { pc: 0, opcode: "JUMPDEST" }, // entry invoke → push sum
+ { pc: 10, opcode: "JUMP" }, // TCO back-edge → replace frame
+ ];
+
+ const program = {
+ instructions: [
+ instr(0, { invoke: { jump: true, identifier: "sum" } }),
+ instr(10, {
+ gather: [
+ { return: { identifier: "sum" } },
+ { invoke: { jump: true, identifier: "sum" } },
+ { transform: ["tailcall"] },
+ ],
+ }),
+ ],
+ } as unknown as Program;
+
+ const pcToInstruction = buildPcToInstructionMap(program);
+
+ it("keeps the stack depth stable across a tail call", () => {
+ const stack = buildCallStack(trace, pcToInstruction, 1);
+ // Without the fix, the return-first gather pops to empty.
+ expect(stack).toHaveLength(1);
+ });
+
+ it("replaces the top frame and marks it as a tail call", () => {
+ const stack = buildCallStack(trace, pcToInstruction, 1);
+ expect(stack[0].identifier).toBe("sum");
+ expect(stack[0].isTailCall).toBe(true);
+ expect(stack[0].stepIndex).toBe(1);
+ });
+
+ it("does not mark a normal (pre-tailcall) frame", () => {
+ const stack = buildCallStack(trace, pcToInstruction, 0);
+ expect(stack).toHaveLength(1);
+ expect(stack[0].isTailCall).toBeFalsy();
+ });
+});
diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts
index 26a912fc7..9279c85b4 100644
--- a/packages/programs-react/src/utils/mockTrace.ts
+++ b/packages/programs-react/src/utils/mockTrace.ts
@@ -2,7 +2,7 @@
* Utilities for creating mock execution traces.
*/
-import type { Program } from "@ethdebug/format";
+import { Program } from "@ethdebug/format";
/**
* A single step in an execution trace.
@@ -119,6 +119,49 @@ export interface CallInfo {
label: string;
pointer: unknown;
}>;
+ /**
+ * True when a `tailcall` transform is present on the same
+ * instruction — the call was realized as a tail-call
+ * (TCO), reusing the current frame rather than nesting.
+ */
+ isTailCall?: boolean;
+}
+
+/**
+ * Extract compiler `transform` annotation identifiers
+ * (e.g. "tailcall", "inline") from an instruction's context
+ * tree, walking gather/pick composites.
+ */
+export function extractTransformFromInstruction(
+ instruction: Program.Instruction,
+): string[] {
+ if (!instruction.context) {
+ return [];
+ }
+ return extractTransformFromContext(instruction.context);
+}
+
+function extractTransformFromContext(context: Program.Context): string[] {
+ if (Program.Context.isTransform(context)) {
+ return context.transform;
+ }
+
+ // gather/pick are still key-probed here, matching the
+ // sibling extractors in this file (a broader guard
+ // migration is tracked separately).
+ const ctx = context as unknown as Record
;
+
+ if ("gather" in ctx && Array.isArray(ctx.gather)) {
+ return (ctx.gather as Program.Context[]).flatMap(
+ extractTransformFromContext,
+ );
+ }
+
+ if ("pick" in ctx && Array.isArray(ctx.pick)) {
+ return (ctx.pick as Program.Context[]).flatMap(extractTransformFromContext);
+ }
+
+ return [];
}
/**
@@ -131,7 +174,14 @@ export function extractCallInfoFromInstruction(
if (!instruction.context) {
return undefined;
}
- return extractCallInfoFromContext(instruction.context);
+ const info = extractCallInfoFromContext(instruction.context);
+ if (!info) {
+ return undefined;
+ }
+ const isTailCall = extractTransformFromContext(instruction.context).includes(
+ "tailcall",
+ );
+ return isTailCall ? { ...info, isTailCall: true } : info;
}
function extractCallInfoFromContext(
@@ -274,6 +324,22 @@ export interface CallFrame {
argumentNames?: string[];
/** Individual argument pointers for value resolution */
argumentPointers?: unknown[];
+ /**
+ * True when this frame was (re)entered via a tail call
+ * (TCO). The frame was reused in place rather than nested.
+ */
+ isTailCall?: boolean;
+}
+
+/**
+ * Determine the call type of a raw invoke record from its
+ * discriminant key.
+ */
+function invokeCallType(inv: Record): CallFrame["callType"] {
+ if ("jump" in inv) return "internal";
+ if ("message" in inv) return "external";
+ if ("create" in inv) return "create";
+ return undefined;
}
/**
@@ -299,6 +365,34 @@ export function buildCallStack(
continue;
}
+ if (callInfo.isTailCall) {
+ // 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
+ // activation. Replace the top frame in place (depth is
+ // unchanged) rather than popping then pushing. Pull the
+ // new iteration's identity from the invoke leaf, since
+ // the return leaf may be surfaced first.
+ const ctx = instruction.context as Record;
+ const inv = findInvokeField(ctx);
+ const argResult = extractArgInfo(instruction);
+ const invId = inv?.identifier as string | undefined;
+ const frame: CallFrame = {
+ identifier: invId ?? callInfo.identifier,
+ stepIndex: i,
+ callType: inv ? invokeCallType(inv) : callInfo.callType,
+ argumentNames: argResult?.names,
+ argumentPointers: argResult?.pointers,
+ isTailCall: true,
+ };
+ if (stack.length > 0) {
+ stack[stack.length - 1] = frame;
+ } else {
+ stack.push(frame);
+ }
+ continue;
+ }
+
if (callInfo.kind === "invoke") {
// The compiler emits invoke on both the caller JUMP
// and callee entry JUMPDEST for the same call. These
diff --git a/packages/web/src/theme/ProgramExample/CallInfoPanel.css b/packages/web/src/theme/ProgramExample/CallInfoPanel.css
index 75cd06511..b2835861c 100644
--- a/packages/web/src/theme/ProgramExample/CallInfoPanel.css
+++ b/packages/web/src/theme/ProgramExample/CallInfoPanel.css
@@ -27,6 +27,12 @@
border-left: 3px solid var(--programs-revert-accent, #cf222e);
}
+.call-info-banner-tailcall {
+ background: var(--programs-transform-bg, #f3ecff);
+ color: var(--programs-transform-text, #8250df);
+ border-left: 3px solid var(--programs-transform-accent, #a475f9);
+}
+
.call-info-refs {
display: flex;
flex-direction: column;
diff --git a/packages/web/src/theme/ProgramExample/CallStackDisplay.css b/packages/web/src/theme/ProgramExample/CallStackDisplay.css
index 9143b8d76..90afee044 100644
--- a/packages/web/src/theme/ProgramExample/CallStackDisplay.css
+++ b/packages/web/src/theme/ProgramExample/CallStackDisplay.css
@@ -48,3 +48,15 @@
.call-stack-parens {
color: var(--programs-text-muted, #888);
}
+
+.call-stack-tailcall {
+ margin-left: 4px;
+ padding: 0 5px;
+ border-radius: 8px;
+ font-size: 0.8em;
+ font-weight: 500;
+ white-space: nowrap;
+ background: var(--programs-transform-bg, #f3ecff);
+ color: var(--programs-transform-text, #8250df);
+ border: 1px solid var(--programs-transform-accent, #a475f9);
+}