Skip to content

fix(precompile): free precompile output instead of retaining it for t… - #110

Open
Gabriel-Trintinalia wants to merge 1 commit into
Consensys-Incorporated:mainfrom
Gabriel-Trintinalia:fix/precompile-output-leak
Open

fix(precompile): free precompile output instead of retaining it for t…#110
Gabriel-Trintinalia wants to merge 1 commit into
Consensys-Incorporated:mainfrom
Gabriel-Trintinalia:fix/precompile-output-leak

Conversation

@Gabriel-Trintinalia

@Gabriel-Trintinalia Gabriel-Trintinalia commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Problem

The interpreter stores return data as a borrowed slice (ReturnDataImpl.data), so a precompile's heap-allocated output has no owner and is never freed. A 30M-gas identity benchmark allocates 1,773 buffers totalling 300 MiB and releases none. At 60M the ~490 MiB guest heap is exhausted and the block cannot execute.

The resulting failure names the wrong thing entirely. Out of heap, analyzeLegacy falls back to an empty jump table, so the EIP-7002 withdrawal contract has no valid JUMPDESTs, its post-block system call halts on invalid_jump, and the block is rejected as SystemContractCallFailed with a wrong root — blaming the identity precompile and the 7002 contract, neither of which is at fault. Nothing in the output mentions memory.

Change

Hold at most one output at a time; free the previous when the next arrives. Sound because a frame can only read its return data between its own calls, and any subsequent call — precompile or sub-frame — overwrites it, so no live pointer is stranded.

Ownership is one module-wide rule, not a per-precompile flag:

a non-empty output is always heap-allocated and the caller frees it; empty outputs are static and exempt by their length

Only the KZG return value needed changing (the one non-empty static). The other 23 precompiles are untouched.

Results

before after
identity 60M 498 MiB → OOM, wrong root validates, 3 MiB
identity 30M heap 446 MiB 3 MiB
mainnet block heap 24 MiB 24 MiB
suite validated TOTAL cells
unit tests 394/394
Amsterdam 0010M 2458/2458 +0.013%
mainnet 500-block 500/500 +0.001%

PRECOMPILES cells are byte-identical on both suites — nothing about precompile execution changed, only what happens to the output afterwards.

Alternatives measured and rejected

  • owned: bool flag with newOwned/newBorrowed, reclassifying all 24 sites: twice the diff (10 files, +87/−27 vs 5 files, +43/−2) and marginally slower for the per-call branch.
  • Copy the output into a caller-owned buffer: +76% trace cells at 60M. Cleaner invariants, but moving bytes is expensive in a zkVM even where it is free natively — worth recording, since it inverts the usual intuition.

Known gap

A future precompile returning a non-empty static would violate the rule and corrupt the heap, and unlike the flag version the compiler cannot catch it. A debug assertion (allocator bounds check) would close this if wanted.

Not included

The heap exhaustion is also swallowed elsewhere — analyzeLegacy, the EIP-7708 transfer log, LOG topics, and 22 precompile sites reporting OOM as OutOfGas. That reporting problem is #99's subject and is being handled there.

🤖 Generated with Claude Code


Note

Medium Risk
Touches interpreter/host precompile dispatch and allocator ownership; incorrect free/double-free would corrupt memory, but the single-slot model matches EVM return-data visibility and tests report identical precompile traces.

Overview
Fixes a memory leak where heap-allocated precompile return data was never freed because the interpreter only borrows return-data slices. Heavy identity-precompile workloads could retain hundreds of MiB and OOM the zkVM guest heap, surfacing as misleading block failures elsewhere.

Host now keeps at most one outstanding precompile buffer in last_output, frees the prior buffer before each new precompile call, and documents a module-wide rule in PrecompileOutput: non-empty bytes are always heap-owned and freed when no longer observable; empty outputs stay static. Frame.execute and MainnetHandler.executeFrame call defer host.releaseOutput() so the last buffer is released when execution ends.

The KZG point-evaluation precompile is updated to heap-duplicate its fixed 64-byte return value so it follows the same ownership rule as other non-empty outputs. Precompile semantics and trace cells are unchanged—only lifecycle of output buffers.

Reviewed by Cursor Bugbot for commit 334a941. Bugbot is set up for automated code reviews on this repo. Configure here.

…he block

The interpreter stores return data as a borrowed slice
(ReturnDataImpl.data), so a precompile's heap-allocated output had no owner
and was never freed. A 30M-gas identity benchmark allocated 1,773 buffers
totalling 300 MiB and released none of them; at 60M the guest heap is
exhausted and the block cannot be executed at all.

The failure is unrecognisable as memory pressure. Out of heap, analyzeLegacy
falls back to an empty jump table, so the EIP-7002 withdrawal contract has no
valid JUMPDESTs, its post-block system call halts on invalid_jump, and the
block is rejected as SystemContractCallFailed with a wrong root — naming the
identity precompile and the 7002 contract, neither of which is at fault.

Hold at most one output at a time and free the previous one when the next
arrives. That is sound because a frame can only read its return data between
its own calls, and any subsequent call — precompile or sub-frame —
overwrites it, so no live pointer is stranded.

Ownership is one module-wide rule rather than a per-precompile flag: a
non-empty output is always heap-allocated and the caller frees it, empty
outputs are static and exempt by their length. Only the KZG return value
needed changing (it was the one non-empty static); the other 23 precompiles
are untouched. An earlier version carried an `owned: bool` with
newOwned/newBorrowed constructors and reclassified all 24 sites — twice the
diff, and marginally slower for the per-call branch.

Copying the output into a caller-owned buffer was also measured and
rejected: it costs +76% trace cells at 60M, because moving bytes is
expensive in a zkVM even where it would be free natively.

Heap on the identity benchmark: 446 MiB -> 3 MiB at 30M; 60M goes from
unexecutable to validating. Real blocks are unaffected either way (24 MiB
before and after) since they make few precompile calls.

Verified: 394/394 unit tests; Amsterdam 0010M 2458/2458 validated at
+0.013% cells; mainnet 500-block 500/500 validated at +0.001%.
PRECOMPILES cells are byte-identical on both — nothing about precompile
execution changed, only what happens to the output afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant