|
| 1 | +/** |
| 2 | + * Verifies that inlined virtual-activation invoke/return contexts are |
| 3 | + * BRACKETED on the emitted bytecode, not smeared across every op. |
| 4 | + * |
| 5 | + * An IR instruction lowers to N EVM micro-ops. evmgen must attach the |
| 6 | + * `invoke` discriminator to only the FIRST emitted op of the |
| 7 | + * invoke-bearing instruction and the `return` discriminator to only the |
| 8 | + * LAST emitted op of the return-bearing instruction, while keeping the |
| 9 | + * `transform: ["inline"]` membership marker on ALL body ops. |
| 10 | + * |
| 11 | + * Without bracketing, the tracer's push/pop reconstruction sees every |
| 12 | + * body op as both a push and a pop -> phantom frames. |
| 13 | + */ |
| 14 | +import { describe, it, expect } from "vitest"; |
| 15 | + |
| 16 | +import { compile } from "#compiler"; |
| 17 | +import { executeProgram } from "#test/evm/behavioral"; |
| 18 | +import type * as Format from "@ethdebug/format"; |
| 19 | +import { Program } from "@ethdebug/format"; |
| 20 | + |
| 21 | +const { Context } = Program; |
| 22 | + |
| 23 | +type OptLevel = 0 | 1 | 2 | 3; |
| 24 | + |
| 25 | +async function runtimeInstructions(source: string, level: OptLevel) { |
| 26 | + const result = await compile({ |
| 27 | + to: "bytecode", |
| 28 | + source, |
| 29 | + optimizer: { level }, |
| 30 | + }); |
| 31 | + if (!result.success) { |
| 32 | + const errors = result.messages.error ?? []; |
| 33 | + throw new Error( |
| 34 | + `Compilation failed at level ${level}:\n` + |
| 35 | + errors |
| 36 | + .map((e: { message?: string }) => e.message ?? String(e)) |
| 37 | + .join("\n"), |
| 38 | + ); |
| 39 | + } |
| 40 | + return result.value.bytecode.runtimeInstructions; |
| 41 | +} |
| 42 | + |
| 43 | +/** Flatten a context into leaves, unwrapping gather/pick. */ |
| 44 | +function leaves(ctx: Format.Program.Context): Format.Program.Context[] { |
| 45 | + if (Context.isGather(ctx)) return ctx.gather.flatMap(leaves); |
| 46 | + if ("pick" in ctx && Array.isArray((ctx as { pick: unknown[] }).pick)) { |
| 47 | + return (ctx as { pick: Format.Program.Context[] }).pick.flatMap(leaves); |
| 48 | + } |
| 49 | + return [ctx]; |
| 50 | +} |
| 51 | + |
| 52 | +/** Per-op discriminator/marker presence, reaching nested pick/gather. */ |
| 53 | +function flags(instr: { debug?: { context?: Format.Program.Context } }) { |
| 54 | + const ctx = instr.debug?.context; |
| 55 | + if (!ctx) return { invoke: false, return: false, inline: false }; |
| 56 | + const all = [ctx, ...leaves(ctx)]; |
| 57 | + return { |
| 58 | + invoke: all.some((c) => Context.isInvoke(c)), |
| 59 | + return: all.some((c) => Context.isReturn(c)), |
| 60 | + inline: all.some( |
| 61 | + (c) => Context.isTransform(c) && c.transform.includes("inline"), |
| 62 | + ), |
| 63 | + }; |
| 64 | +} |
| 65 | + |
| 66 | +function tally(instrs: ReturnType<typeof flags>[]) { |
| 67 | + let invoke = 0, |
| 68 | + ret = 0, |
| 69 | + both = 0, |
| 70 | + inline = 0; |
| 71 | + for (const f of instrs) { |
| 72 | + if (f.invoke) invoke += 1; |
| 73 | + if (f.return) ret += 1; |
| 74 | + if (f.invoke && f.return) both += 1; |
| 75 | + if (f.inline) inline += 1; |
| 76 | + } |
| 77 | + return { invoke, ret, both, inline }; |
| 78 | +} |
| 79 | + |
| 80 | +// The exact fixture the UI reported mis-rendering: a leaf helper |
| 81 | +// inlined at two sites. |
| 82 | +const dblTwoSites = `name Multi; |
| 83 | +define { function dbl(x: uint256) -> uint256 { return x + x; }; } |
| 84 | +storage { [0] r: uint256; } |
| 85 | +create { r = 0; } |
| 86 | +code { |
| 87 | + let a = dbl(5); |
| 88 | + let b = dbl(10); |
| 89 | + r = a + b; |
| 90 | +}`; |
| 91 | + |
| 92 | +// A multi-instruction body: entry (t = x + x) differs from exit |
| 93 | +// (t * x), so invoke and return live on distinct IR instructions. |
| 94 | +const multiInstrBody = `name Poly; |
| 95 | +define { function poly(x: uint256) -> uint256 { let t = x + x; return t * x; }; } |
| 96 | +storage { [0] a: uint256; [1] r: uint256; } |
| 97 | +create { a = 3; r = 0; } |
| 98 | +code { r = poly(a); }`; |
| 99 | + |
| 100 | +describe("inlined invoke/return are bracketed on emitted bytecode", () => { |
| 101 | + it("dbl@2-sites: one push and one pop per site, never both on an op", async () => { |
| 102 | + const instrs = await runtimeInstructions(dblTwoSites, 2); |
| 103 | + const t = tally(instrs.map(flags)); |
| 104 | + // Two inlined sites => exactly one invoke op and one return op each. |
| 105 | + expect(t.invoke).toBe(2); |
| 106 | + expect(t.ret).toBe(2); |
| 107 | + // No op may be both a push and a pop (that breaks push/pop). |
| 108 | + expect(t.both).toBe(0); |
| 109 | + // Membership marker stays on every body op (more than the 4 |
| 110 | + // boundary ops). |
| 111 | + expect(t.inline).toBeGreaterThan(4); |
| 112 | + }); |
| 113 | + |
| 114 | + it("dbl@2-sites: each site's invoke op precedes its return op", async () => { |
| 115 | + const instrs = await runtimeInstructions(dblTwoSites, 2); |
| 116 | + const seq = instrs |
| 117 | + .map((instr, i) => ({ i, f: flags(instr) })) |
| 118 | + .filter(({ f }) => f.invoke || f.return) |
| 119 | + .map(({ f }) => (f.invoke ? "invoke" : "return")); |
| 120 | + // Bracketed order across two sites: push,pop,push,pop. |
| 121 | + expect(seq).toEqual(["invoke", "return", "invoke", "return"]); |
| 122 | + }); |
| 123 | + |
| 124 | + it("multi-instruction body: invoke on entry, return on exit, both=0", async () => { |
| 125 | + const instrs = await runtimeInstructions(multiInstrBody, 2); |
| 126 | + const t = tally(instrs.map(flags)); |
| 127 | + expect(t.invoke).toBe(1); |
| 128 | + expect(t.ret).toBe(1); |
| 129 | + expect(t.both).toBe(0); |
| 130 | + }); |
| 131 | + |
| 132 | + it("preserves runtime behavior at every level", async () => { |
| 133 | + for (const level of [0, 1, 2, 3] as const) { |
| 134 | + const res = await executeProgram(dblTwoSites, { |
| 135 | + calldata: "", |
| 136 | + optimizationLevel: level, |
| 137 | + }); |
| 138 | + expect(res.callSuccess).toBe(true); |
| 139 | + // dbl(5)=10, dbl(10)=20, r=30 |
| 140 | + expect(await res.getStorage(0n)).toBe(30n); |
| 141 | + } |
| 142 | + }); |
| 143 | +}); |
| 144 | + |
| 145 | +// A self-tail-recursive accumulator: TCO turns the recursive call |
| 146 | +// into a single back-edge JUMP that legitimately carries BOTH invoke |
| 147 | +// and return on its one op (end one iteration + begin the next). |
| 148 | +const tailRecursive = `name TailSum; |
| 149 | +define { |
| 150 | + function sum(n: uint256, acc: uint256) -> uint256 { |
| 151 | + if (n == 0) { return acc; } |
| 152 | + else { return sum(n - 1, acc + n); } |
| 153 | + }; |
| 154 | +} |
| 155 | +storage { [0] result: uint256; } |
| 156 | +create { result = 0; } |
| 157 | +code { result = sum(5, 0); }`; |
| 158 | + |
| 159 | +// Mutually recursive functions never inline, so their calls stay real |
| 160 | +// (invoke on a 1-op JUMP, return on a 1-op JUMPDEST). |
| 161 | +const mutualRecursion = `name EvenOdd; |
| 162 | +define { |
| 163 | + function isEven(n: uint256) -> uint256 { |
| 164 | + if (n == 0) { return 1; } else { return isOdd(n - 1); } |
| 165 | + }; |
| 166 | + function isOdd(n: uint256) -> uint256 { |
| 167 | + if (n == 0) { return 0; } else { return isEven(n - 1); } |
| 168 | + }; |
| 169 | +} |
| 170 | +storage { [0] result: uint256; } |
| 171 | +create { result = 0; } |
| 172 | +code { result = isEven(4); }`; |
| 173 | + |
| 174 | +describe("bracketing is a no-op for single-op invoke/return carriers", () => { |
| 175 | + it("tailcall back-edge keeps its combined invoke+return on one op", async () => { |
| 176 | + // The back-edge JUMP is a single op carrying both markers; bracketing |
| 177 | + // to first-op/last-op is first==last, so both must survive. |
| 178 | + const instrs = await runtimeInstructions(tailRecursive, 2); |
| 179 | + const t = tally(instrs.map(flags)); |
| 180 | + expect(t.both).toBeGreaterThanOrEqual(1); |
| 181 | + }); |
| 182 | + |
| 183 | + it("real (non-inlined) calls never carry both on an op", async () => { |
| 184 | + const instrs = await runtimeInstructions(mutualRecursion, 2); |
| 185 | + const t = tally(instrs.map(flags)); |
| 186 | + // Real calls put invoke on a 1-op JUMP and return on a 1-op JUMPDEST, |
| 187 | + // distinct ops — the fix must not fabricate a both. |
| 188 | + expect(t.both).toBe(0); |
| 189 | + expect(t.invoke).toBeGreaterThan(0); |
| 190 | + expect(t.ret).toBeGreaterThan(0); |
| 191 | + }); |
| 192 | +}); |
0 commit comments