Skip to content

fix(levm): perform the EIP-8272 native write on every path into the predeploy - #7086

Open
AnkushinDaniil wants to merge 2 commits into
lambdaclass:hegota-devnetfrom
AnkushinDaniil:daniil/eip8272-native-write-paths
Open

fix(levm): perform the EIP-8272 native write on every path into the predeploy#7086
AnkushinDaniil wants to merge 2 commits into
lambdaclass:hegota-devnetfrom
AnkushinDaniil:daniil/eip8272-native-write-paths

Conversation

@AnkushinDaniil

@AnkushinDaniil AnkushinDaniil commented Aug 2, 2026

Copy link
Copy Markdown

RECENT_ROOT_ADDRESS carries no runtime bytecode, so the 64-byte salt || root write 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 in execute() 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_path excludes the predeploy, so the call reaches run_execution.
  • run_execution dispatches a top-level call into the predeploy to the native write, next to the precompile branch, and records the address as BAL-touched.
  • The write reports gas_limit - gas_remaining rather than the bare write cost, so intrinsic gas is included. Reporting only RECENT_ROOT_WRITE_GAS undercharged 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_results now 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.

@AnkushinDaniil
AnkushinDaniil requested a review from a team as a code owner August 2, 2026 04:33
@github-actions github-actions Bot added the external-contributor PR opened by a contributor outside the team label Aug 2, 2026
@greptile-apps

greptile-apps Bot commented Aug 2, 2026

Copy link
Copy Markdown

Greptile Summary

The 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.

  • Excludes the recent-root predeploy from the codeless-recipient transfer fast path.
  • Adds top-level validation, gas charging, BAL touch recording, and native storage-write dispatch.
  • Clears reverted body logs from both aggregate execution logs and per-frame results.

Confidence Score: 5/5

The 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.

Important Files Changed

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]
Loading

Reviews (1): Last reviewed commit: "fix(levm): perform the EIP-8272 native w..." | Re-trigger Greptile

@ElFantasma ElFantasma left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/vm/levm/src/vm.rs
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_remaining enters this frame already net of intrinsic gas, so what the transaction owes is always measured against gas_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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/vm/levm/src/vm.rs
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.
  2. 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@AnkushinDaniil
AnkushinDaniil force-pushed the daniil/eip8272-native-write-paths branch from 03cd41c to 62fe816 Compare August 8, 2026 17:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

external-contributor PR opened by a contributor outside the team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants