Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion crates/vm/levm/src/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2058,6 +2058,7 @@ impl<'a> VM<'a> {
// depth-driven rather than a single revert/commit.
let mut body_substate_depth: Option<usize> = None;
let mut body_logs_start: usize = 0;
let mut body_frame_start: usize = 0;
let mut state_gas_used_at_body_entry: i64 = 0;
let mut post_tx_reverted = false;

Expand Down Expand Up @@ -2165,6 +2166,7 @@ impl<'a> VM<'a> {
body_substate_depth = Some(self.substate.backup_depth());
body_backup.bal_checkpoint = self.db.bal_recorder.as_ref().map(|r| r.checkpoint());
body_logs_start = all_logs.len();
body_frame_start = frame_idx;
state_gas_used_at_body_entry = self.state_gas_used;
}
absorbing_body = is_body_frame;
Expand Down Expand Up @@ -2693,8 +2695,16 @@ impl<'a> VM<'a> {
}
crate::utils::restore_cache_state(self.db, mem::take(&mut body_backup))?;
// Logs the body emitted are gone with its state. The prefix's logs
// (an APPROVE-side EIP-7708 transfer log, say) survive.
// (an APPROVE-side EIP-7708 transfer log, say) survive. The per-frame
// receipts keep their status and gas but lose those logs too — the
// consensus receipt carries only them, so the header bloom is built from
// them and would otherwise commit to logs that no longer happened.
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.

}
}
// EIP-8037: the body was unrolled, so it created no state and owes no
// state gas. Mirrors the atomic-batch unroll, which drops the state
// gas accumulated since batch entry for the same reason. EIP-7906 does
Expand Down Expand Up @@ -3443,6 +3453,57 @@ impl<'a> VM<'a> {
self.env.config.fork,
self.vm_type,
)
// EIP-8272's predeploy is codeless too, but a call into it performs the native
// recent-root write, so it must reach `run_execution`.
&& !(self.env.config.fork >= Fork::Hegota
&& self.current_call_frame.to == ethrex_common::types::frame_tx_recent_root())
}

/// EIP-8272 native write for a transaction whose recipient is the predeploy itself.
fn run_top_level_recent_root_write(&mut self) -> Result<ContextResult, VMError> {
let recent_root_addr = ethrex_common::types::frame_tx_recent_root();
if let Some(recorder) = self.db.bal_recorder.as_mut() {
recorder.record_touched_address(recent_root_addr);
}

let gas_limit = self.current_call_frame.gas_limit;
let revert = |gas_used: u64| ContextResult {
result: TxResult::Revert(VMError::RevertOpcode),
gas_used,
gas_spent: gas_used,
output: Bytes::new(),
};

// `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.
#[expect(clippy::as_conversions, reason = "gas_remaining is non-negative here")]
let consumed = |frame: &crate::call_frame::CallFrame| {
frame.gas_limit.saturating_sub(frame.gas_remaining.max(0) as u64)
};

if self.current_call_frame.calldata.len() != 64 || !self.current_call_frame.msg_value.is_zero() {
return Ok(revert(consumed(&self.current_call_frame)));
}
if self.current_call_frame.gas_remaining < crate::gas_cost::RECENT_ROOT_WRITE_GAS as i64 {
self.current_call_frame.gas_remaining = 0;
return Ok(revert(gas_limit));
}

let caller = self.current_call_frame.msg_sender;
let calldata = self.current_call_frame.calldata.clone();
let (salt, root) = calldata.split_at(32);
self.recent_root_native_write(caller, salt, root)?;
self.current_call_frame.gas_remaining = self
.current_call_frame
.gas_remaining
.saturating_sub(crate::gas_cost::RECENT_ROOT_WRITE_GAS as i64);
let gas_used = consumed(&self.current_call_frame);
Ok(ContextResult {
result: TxResult::Success,
gas_used,
gas_spent: gas_used,
output: Bytes::new(),
})
}

/// Main execution loop.
Expand Down Expand Up @@ -3481,6 +3542,17 @@ impl<'a> VM<'a> {
// `refill_frame_state_gas`). Set in-region by `prepare_execution`.
let top_frame_new_account_charged = self.value_new_account_charged;

// EIP-8272: the predeploy is codeless, so a transaction sent straight to it would
// otherwise run as a transfer to an ordinary account and write nothing. Same native
// write the CALL-opcode and frame paths perform. Only the top-level entry reaches
// here: a frame targeting the predeploy is codeless, so it takes the default-code
// path, where `execute_default_code` performs the write and never calls back in.
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.

}

#[expect(clippy::as_conversions, reason = "remaining gas conversion")]
if precompiles::is_precompile(
&self.current_call_frame.to,
Expand Down