A self-recursive BUG function is miscompiled: the recursive call is set up with a corrupted operand stack, so the callee receives the wrong arguments and the whole computation returns garbage. The sum below returns 1 for sum(1, 50) where it should return 51, and in fact returns 1 for every sum(n, acc) with n >= 1 at every optimization level.
name S;
define {
function sum(n: uint256, acc: uint256) -> uint256 {
if (n == 0) { return acc; } else { return sum(n - 1, acc + n); }
};
}
storage { [0] r: uint256; }
create { r = 0; }
code { r = sum(1, 50); }
A step-trace of the runtime bytecode shows the recursive JUMP entering the callee with the operand stack [0, 0, 1] instead of the expected two-argument [0, 51]. Two things go wrong at the call site. Leftover stack values from the caller's own comparison and branch are never removed before the call: the pre-call cleanup pops by the length of evmgen's tracked stack, which undercounts the real runtime leftovers. And the computed argument acc + n comes out as 1 rather than 51. The callee therefore reads n = 0, acc = 1, immediately takes its base case, and returns 1, which then propagates unchanged up through every frame.
The root cause is that evmgen threads its tracked operand stack linearly through block layout order rather than through the control-flow graph. generate in packages/bugc/src/evmgen/generation/function.ts reduces over layout.order, passing each block the previous block's exit stack, and its own comment already flags the shortcut: "This is simplified - real implementation would track actual control flow." Meanwhile loadValue duplicates any operand that is already on the stack and leaves the original behind, so blocks routinely accumulate leftovers. Because those leftovers are not reflected in the tracked model, every consumer that reaches for the stack by tracked length — the pre-call cleanup, the argument loads, the return epilogue — can act on a runtime stack that no longer matches. Recursion exercises this immediately: caller and callee share the same layout and frame offsets, so nothing masks the desync.
The same root has a second, currently-dormant manifestation. A block reachable from multiple predecessors with differing stack depths would also desync the tracked model from the runtime stack, even without recursion, because linear threading picks up whichever predecessor happens to sit before it in layout order rather than reconciling the real predecessors. It stays latent today only because the programs that reach such blocks happen to arrive with matching stack shapes — but it is the same defect, not a separate one, and a proper fix should resolve both.
This was found while fixing a sibling defect in the return epilogue, tracked in #274. There, the stack-cleanup rotation returned a stale branch condition instead of the return value whenever two or more leftovers sat below it, so a return in a taken branch yielded the condition rather than the intended value. That fix is correct and lands on its own — it makes non-recursive branch-returns compute correctly — but it explicitly does not address this issue. The recursive-call miscompilation sits upstream of the epilogue, in how the operand stack is tracked and cleaned across blocks and calls, and #274 leaves it untouched by design.
This needs design discussion before anyone implements it; it is not a quick patch. The durable direction is CFG-aware stack tracking: compute each block's entry stack from its actual predecessors, require predecessors to agree on stack shape at merge points, and either stop leaking dead values (liveness-aware loading that consumes on last use) or clean them deterministically from a known depth. Patching the pre-call cleanup or the epilogue in isolation would only paper over individual symptoms of the shared root.
A self-recursive BUG function is miscompiled: the recursive call is set up with a corrupted operand stack, so the callee receives the wrong arguments and the whole computation returns garbage. The
sumbelow returns 1 forsum(1, 50)where it should return 51, and in fact returns 1 for everysum(n, acc)withn >= 1at every optimization level.A step-trace of the runtime bytecode shows the recursive JUMP entering the callee with the operand stack
[0, 0, 1]instead of the expected two-argument[0, 51]. Two things go wrong at the call site. Leftover stack values from the caller's own comparison and branch are never removed before the call: the pre-call cleanup pops by the length of evmgen's tracked stack, which undercounts the real runtime leftovers. And the computed argumentacc + ncomes out as 1 rather than 51. The callee therefore readsn = 0,acc = 1, immediately takes its base case, and returns 1, which then propagates unchanged up through every frame.The root cause is that evmgen threads its tracked operand stack linearly through block layout order rather than through the control-flow graph.
generateinpackages/bugc/src/evmgen/generation/function.tsreduces overlayout.order, passing each block the previous block's exit stack, and its own comment already flags the shortcut: "This is simplified - real implementation would track actual control flow." MeanwhileloadValueduplicates any operand that is already on the stack and leaves the original behind, so blocks routinely accumulate leftovers. Because those leftovers are not reflected in the tracked model, every consumer that reaches for the stack by tracked length — the pre-call cleanup, the argument loads, the return epilogue — can act on a runtime stack that no longer matches. Recursion exercises this immediately: caller and callee share the same layout and frame offsets, so nothing masks the desync.The same root has a second, currently-dormant manifestation. A block reachable from multiple predecessors with differing stack depths would also desync the tracked model from the runtime stack, even without recursion, because linear threading picks up whichever predecessor happens to sit before it in layout order rather than reconciling the real predecessors. It stays latent today only because the programs that reach such blocks happen to arrive with matching stack shapes — but it is the same defect, not a separate one, and a proper fix should resolve both.
This was found while fixing a sibling defect in the return epilogue, tracked in #274. There, the stack-cleanup rotation returned a stale branch condition instead of the return value whenever two or more leftovers sat below it, so a
returnin a taken branch yielded the condition rather than the intended value. That fix is correct and lands on its own — it makes non-recursive branch-returns compute correctly — but it explicitly does not address this issue. The recursive-call miscompilation sits upstream of the epilogue, in how the operand stack is tracked and cleaned across blocks and calls, and #274 leaves it untouched by design.This needs design discussion before anyone implements it; it is not a quick patch. The durable direction is CFG-aware stack tracking: compute each block's entry stack from its actual predecessors, require predecessors to agree on stack shape at merge points, and either stop leaking dead values (liveness-aware loading that consumes on last use) or clean them deterministically from a known depth. Patching the pre-call cleanup or the epilogue in isolation would only paper over individual symptoms of the shared root.