fix(levm): perform the EIP-8272 native write on every path into the predeploy - #7086
Conversation
Greptile SummaryThe PR routes direct Hegota transactions to the codeless EIP-8272 recent-root predeploy through its native write and accounts for intrinsic plus write gas. It also removes reverted EIP-8037 body logs from per-frame receipt results.
Confidence Score: 5/5The PR appears safe to merge, with the direct predeploy path and reverted-log cleanup preserving the relevant execution and receipt invariants. The new dispatch is fork- and address-gated, validates the native-write inputs, charges intrinsic and write gas, records the BAL touch, and the new frame-log boundary follows frame result ordering.
|
| Filename | Overview |
|---|---|
| crates/vm/levm/src/vm.rs | Adds the direct EIP-8272 predeploy execution path and synchronizes per-frame logs with EIP-8037 body rollback; no actionable defect was established. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
Tx[Top-level transaction] --> Fork{Hegota and recipient 0x8272?}
Fork -- No --> Fast{Simple-transfer fast path?}
Fast -- Yes --> Transfer[Complete codeless transfer]
Fast -- No --> EVM[Normal execution]
Fork -- Yes --> Validate{64-byte calldata, zero value, enough gas?}
Validate -- No --> Revert[Return reverted execution result]
Validate -- Yes --> Touch[Record BAL address touch]
Touch --> Write[Perform native recent-root storage write]
Write --> Charge[Charge intrinsic gas plus native-write gas]
Charge --> Success[Return successful execution result]
Reviews (1): Last reviewed commit: "fix(levm): perform the EIP-8272 native w..." | Re-trigger Greptile
ElFantasma
left a comment
There was a problem hiding this comment.
Two things: the dispatch isn't top-level-only as the name asserts, and there's a fourth change here the description doesn't mention.
| if self.env.config.fork >= Fork::Hegota | ||
| && self.current_call_frame.to == ethrex_common::types::frame_tx_recent_root() | ||
| { | ||
| return self.run_top_level_recent_root_write(); |
There was a problem hiding this comment.
run_execution isn't entered only for top-level transactions — it has three other call sites in this same file:
:1558— the top-level transaction, the case you're targeting:2261— inside the frame-tx execution loop:3016— the other frame path
So this guard fires for any frame whose to is the predeploy, not just a transaction sent straight to it. An EIP-8141 frame that calls RECENT_ROOT_ADDRESS now routes here instead of wherever it went before, which is a behaviour change the description doesn't claim and the function name (run_top_level_...) actively denies.
The gas comment inside makes the same assumption explicit:
gas_remainingenters this frame already net of intrinsic gas, so what the transaction owes is always measured againstgas_limit, never the write cost alone.
That holds for the top-level frame, where gas_limit spans intrinsic. For a nested frame entered at :2261/:3016, gas_limit is the frame's own allocation and gas_remaining starts equal to it, so gas_limit - gas_remaining collapses to just the write cost — a different quantity than the one the comment describes, reached through the same code.
I haven't traced what a predeploy-targeted frame did before this change, so I can't say whether the new path is wrong for it or merely undocumented. Worth confirming, since "undercharged by exactly its intrinsic gas" is the bug this PR exists to fix and the frame path is where the asymmetry would hide. If it is meant to be top-level only, the guard needs a depth/call-site condition to match the name.
There was a problem hiding this comment.
The guard is reachable only from :1558. A frame targeting the predeploy never gets to run_execution: the predeploy is codeless, so both frame call sites take the bytecode.is_empty() branch into execute_default_code, which intercepts frame_tx_recent_root() on its first line and returns execute_recent_root_frame without re-entering. Reaching the CallFrame branch needs non-empty bytecode or a 7702 delegation indicator, and a system account has neither. So the gas comment holds on every path that actually arrives here. I added a comment saying so, since nothing in the function did.
| all_logs.truncate(body_logs_start); | ||
| if let Some(ctx) = self.frame_tx_context.as_mut() { | ||
| for (_, _, logs) in ctx.frame_results.iter_mut().skip(body_frame_start) { | ||
| logs.clear(); |
There was a problem hiding this comment.
This is a fourth change, and the description lists three. It's also the one with the widest blast radius: clearing per-frame receipt logs on a body unroll changes what the consensus receipt carries, and your own comment says the header bloom is built from those logs.
That's a separate bug from the EIP-8272 native write — different EIP, different failure mode (a bloom committing to logs that didn't survive, versus a root that never gets written). It stands on its own and reads correct to me: all_logs.truncate(body_logs_start) already dropped them from the transaction-level set, so leaving them in frame_results was an inconsistency between the two views.
Two asks, no rework implied:
- Add it to the description. A reviewer comparing the diff against "three parts" will assume they've miscounted, and a consensus change to receipt contents shouldn't arrive unannounced.
- Say whether it was observed or found by inspection. The EIP-8272 half has cross-client evidence on the Nethermind devnet; if this half also produced a divergence, that's worth recording, and if it didn't, it's the part most worth a second opinion since there's no unit coverage here either.
There was a problem hiding this comment.
Added to the description. It was found by inspection while reading the unroll path, not from an observed divergence, and it has no unit coverage, so it is the least supported part of the diff.
3c7ce5f to
72b463b
Compare
03cd41c to
62fe816
Compare
RECENT_ROOT_ADDRESScarries no runtime bytecode, so the 64-bytesalt || rootwrite is executed natively. That native handler was only reachable from the CALL opcode: a transaction sent straight to the predeploy took the codeless-recipient fast path inexecute()and succeeded without writing anything, which makes it impossible for a root source to publish a root the ordinary way.Three parts:
is_simple_transfer_fast_pathexcludes the predeploy, so the call reachesrun_execution.run_executiondispatches a top-level call into the predeploy to the native write, next to the precompile branch, and records the address as BAL-touched.gas_limit - gas_remainingrather than the bare write cost, so intrinsic gas is included. Reporting onlyRECENT_ROOT_WRITE_GASundercharged the transaction by exactly its intrinsic gas and produced a block-level access-list balance mismatch.Verified on a Nethermind/ethrex devnet: after this change a root written through a plain transaction commits the same entry hash under the same storage key on both clients, and the block carrying it has the same hash.
There is no unit coverage here because levm has no in-crate VM harness to drive a top-level transaction, so the coverage is cross-client instead: two scenarios on a two-client devnet, one writing a root through a plain transaction and one through an EIP-8141 SENDER frame targeting the predeploy. Both compare the receipt, the block hash, the state root and the block-level access-list hash between the clients, and both fail without this change.
A fourth change rides along, and it is a separate defect from the native write. When an EIP-7906 assertion discards the body, the transaction-level log set was truncated but the per-frame receipt logs were left in place, so the header bloom committed to logs the receipt no longer carried.
frame_resultsnow drops them too. Found by inspection while reading the unroll path, not by an observed divergence, and there is no unit coverage for it, so it is the part most worth a second opinion.