fix(evm): consume all gas when a frame halts exceptionally - #605
Conversation
The three places that finalise an evmc_result all ended with
Result.gas_left = <instance>.getGas();
which overwrites whatever the halt paths had already decided. The instance's
gas counter is not authoritative after a halt: the interpreter zeroes the
*frame's* gas (interpreter.cpp) and the JIT trap path never touches gas at
all, so an exceptional halt handed the caller back gas the EVM had already
burned. evmone applies the opposite rule in make_execution_result - "an
exceptional halt consumes all gas; only a success or revert keeps gas_left" -
and that is the consensus behaviour.
Route the three sites through one helper that applies that rule. Observed on
mainnet blocks 25818502 and 25818530, where a nested frame halted with
EVMC_INVALID_MEMORY_ACCESS and returned 339011 of its 440977 gas limit.
This is necessary but not sufficient for those two blocks: the halt itself is
a separate JIT defect (a null-pointer dereference inside JIT'd code that the
trap handler reports as out-of-bounds memory). Both blocks replay correctly
under mode=interpreter, so the remaining defect is JIT-only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187PTseLY2NbzxKrnstDWwS
There was a problem hiding this comment.
🟢 Approval recommended
The change is small, consistent across the identified finalization sites, and directly enforces the intended consensus rule without altering SUCCESS/REVERT behavior.
Pull request overview
This PR fixes EVMC gas accounting so that an exceptionally halted EVM frame always reports gas_left = 0 (consuming all frame gas), matching consensus behavior and aligning with evmone’s result finalization rule. It centralizes the rule in a shared helper and applies it at the EVMC result finalization points that previously overwrote halt-path decisions with Instance::getGas().
Changes:
- Add
zen::evm::setFrameGasLeft()helper implementing “only SUCCESS/REVERT retain gas; otherwise gas_left = 0”. - Route interpreter and JIT EVMC result finalization in
dt_evmc_vm.cppthrough the helper. - Route
Runtime::callEVMMainOnPhysStack()EVMC result finalization through the helper.
File summaries
| File | Description |
|---|---|
| src/vm/dt_evmc_vm.cpp | Uses shared helper when finalizing evmc::Result so exceptional halts no longer leak remaining gas back to the caller. |
| src/runtime/runtime.cpp | Applies the same finalization rule when the runtime completes an EVM execution on the physical stack. |
| src/evm/evm.h | Introduces the shared helper implementing the terminal gas rule for EVMC results. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
⚡ Performance Regression Check Results✅ Performance Check Passed (interpreter)Performance Benchmark Results (threshold: 25%)
Summary: 194 benchmarks, 0 regressions ✅ Performance Check Passed (multipass)Performance Benchmark Results (threshold: 25%)
Summary: 194 benchmarks, 0 regressions |
The revmc entry in the same document argues that a branch pin stops being reproducible the moment the branch moves, and then the DTVM row named a branch. `03b542e` is the tree the measured library was actually built from, and it is now pushed to the fork, so the reader can reach it. Also names what that commit carries beyond the cache branch: the module-cache bound of §5, and the two engine fixes that went upstream as DTVMStack/DTVM#605 and paradigmxyz#604. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018osheVEXr1SywFtbYFjHAc
|
Updated one sentence in section 5: the null-pointer dereference described there as "still-open" has since been located and fixed in #607. Noting the edit rather than leaving it silent. Nothing about this PR changes — it is independent of both #607 and #604, and concerns only the rule that an exceptional halt consumes all gas. The two blocks it mentions are context for how the rule violation surfaced, not something this PR claims to fix. |
1. Does this PR affect any open issues?(Y/N) and add issue references (e.g. "fix #123", "re #123".):
2. What is the scope of this PR (e.g. component or file name):
Exceptional-halt gas accounting at the three
evmc_resultfinalisation sites:src/vm/dt_evmc_vm.cppandsrc/runtime/runtime.cpp, with the shared rule addedas a helper in
src/evm/evm.h.3. Provide a description of the PR(e.g. more details, effects, motivations or doc link):
The three places that finalise an
evmc_resultall ended withwhich unconditionally overwrites whatever the halt paths had already decided. The
instance's gas counter is not authoritative after a halt: the interpreter zeroes
the frame's gas, and the JIT trap path never touches gas at all. So an
exceptional halt handed the caller back gas the EVM had already burned.
evmone states the rule directly in
make_execution_result:This routes the three sites through one helper that applies that rule.
How it surfaced: mainnet witness replay of blocks 25818502 and 25818530, where a
nested frame halted with
EVMC_INVALID_MEMORY_ACCESSand returned 339011 of its440977 gas limit. The embedder rejects any halted result that retained gas, which
is what made it visible.
4. Are there any breaking changes?(Y/N) and describe the breaking changes(e.g. more details, motivations or doc link):
A caller that observed non-zero
gas_lefton an exceptionally halted frame nowobserves zero. That is the consensus behaviour and the reason for the change; any
consumer relying on the old value was relying on a defect. Success and revert
results are unaffected, and under the interpreter the frame's gas was already
zeroed — the difference is that the frame's decision now survives to the caller.
5. Are there test cases for these changes?(Y/N) select and add more details, references or doc links:
Validation on a GCC 12 / LLVM 15 Release multipass build, configured to match the
existing EVM configuration (
ZEN_ENABLE_EVM,ZEN_ENABLE_MULTIPASS_JIT,ZEN_ENABLE_VIRTUAL_STACK,ZEN_ENABLE_CPU_EXCEPTIONall ON):./tools/format.sh check: PASS338d123: PASStools/easm2bytecode.sh): 209/209ctest: 11/12 test binaries passThe one failure,
solidityContractTests(7 cases), issolc not foundin thisenvironment. It fails identically on a pristine
338d123build configured thesame way, which was built and run side by side for exactly this comparison — so
it is environmental, not a regression from this change. Every other binary
passes, including
evmDifferentialTests,evmJitFrontendTests,evmStateTests,evmInterpTests,evmModuleCacheTests,evmFallbackExecutionTestsandevmProfileGuidedJITTests.Behavioural check: mainnet witness replay through the EVMC interface. Before, a
nested frame halting with
EVMC_INVALID_MEMORY_ACCESSreturned 339011 of a 440977gas limit; after, it returns zero, which is what the embedder's halt check
expects.
Note on the two blocks above: this change is necessary but not sufficient for
them. The halt itself is a separate JIT defect — a null-pointer dereference in
generated code — which was still unlocated when this PR was opened and has since
been found and fixed in #607. This PR does not fix those blocks and does not
depend on #607; it corrects the gas rule that their halt happened to expose.
6. Release note