From d0bef64ce1e830d5954ba8faf66e5186ced66aeb Mon Sep 17 00:00:00 2001 From: Edgar Luque Date: Fri, 10 Apr 2026 16:01:56 +0200 Subject: [PATCH 1/4] fix(l1): add reverse check in BAL withdrawal validation validate_bal_withdrawal_index only checked BAL->DB direction (every BAL claim at the withdrawal index matches the DB). A malicious builder could omit a withdrawal recipient from the BAL, causing the BAL-derived state root to exclude the withdrawal balance change while passing validation. Add Part B (DB->BAL): for each account modified during the withdrawal/request phase, verify the BAL has a corresponding entry. This mirrors the bidirectional validation already done in validate_tx_execution. --- crates/vm/backends/levm/mod.rs | 122 ++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 3 deletions(-) diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index 5687821e0e0..c5b52a8c106 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -338,7 +338,7 @@ impl LEVM { // post-withdrawal/request state. #[allow(clippy::cast_possible_truncation)] let withdrawal_idx = (block.body.transactions.len() as u16) + 1; - Self::validate_bal_withdrawal_index(db, bal, withdrawal_idx)?; + Self::validate_bal_withdrawal_index(db, bal, withdrawal_idx, &validation_index)?; // Mark storage_reads that occurred during the withdrawal/request phase. if !unread_storage_reads.is_empty() { @@ -1475,13 +1475,22 @@ impl LEVM { /// Validates BAL entries at the withdrawal index against actual post-withdrawal state. /// /// After `process_withdrawals` + `extract_all_requests_levm` run on the BAL-seeded - /// DB, `current_accounts_state` reflects the actual state. Any BAL claim at the - /// withdrawal index that doesn't match is either a mismatch or extraneous. + /// DB, `current_accounts_state` reflects the actual state. Validation is bidirectional: + /// + /// Part A (BAL -> DB): every BAL claim at the withdrawal index must match the DB. + /// Part B (DB -> BAL): every account modified during the withdrawal/request phase + /// must have a corresponding BAL entry. Without this reverse check, a + /// malicious builder could omit a withdrawal recipient from the BAL, + /// causing the BAL-derived state root to exclude the withdrawal balance + /// change. fn validate_bal_withdrawal_index( db: &GeneralizedDatabase, bal: &BlockAccessList, withdrawal_idx: u16, + index: &BalAddressIndex, ) -> Result<(), EvmError> { + // Part A: For each BAL account with changes at the withdrawal index, + // verify the DB matches. for acct in bal.accounts() { let addr = acct.address; let actual = db.current_accounts_state.get(&addr); @@ -1573,6 +1582,113 @@ impl LEVM { } } } + + // Part B: For each account modified during the withdrawal/request phase, + // verify it has a corresponding BAL entry claiming the change. + for (addr, account) in &db.current_accounts_state { + if account.is_unmodified() { + continue; + } + + let Some(&bal_acct_idx) = index.addr_to_idx.get(addr) else { + // Account modified during withdrawal/request phase but absent + // from BAL entirely. Compare with pre-state (store) to + // distinguish genuine mutations from warm-access artifacts. + let pre = db + .store + .get_account_state(*addr) + .ok() + .map(|a| (a.balance, a.nonce, a.code_hash)) + .unwrap_or_default(); + let post = ( + account.info.balance, + account.info.nonce, + account.info.code_hash, + ); + if pre != post { + return Err(EvmError::Custom(format!( + "BAL validation failed for withdrawal: account {addr:?} was modified \ + during withdrawal/request phase but is absent from BAL" + ))); + } + continue; + }; + + let acct = &bal.accounts()[bal_acct_idx]; + + // Balance: if BAL has no change at withdrawal_idx, the withdrawal + // phase must not have changed it relative to the last BAL entry. + if !has_exact_change_balance(&acct.balance_changes, withdrawal_idx) { + let seeded = acct + .balance_changes + .last() + .map(|c| c.post_balance) + .unwrap_or_else(|| { + db.store + .get_account_state(*addr) + .map(|a| a.balance) + .unwrap_or_default() + }); + if account.info.balance != seeded { + return Err(EvmError::Custom(format!( + "BAL validation failed for withdrawal: account {addr:?} balance \ + changed during withdrawal/request phase ({}) but BAL has no \ + balance change at index {withdrawal_idx} (last_bal={seeded})", + account.info.balance + ))); + } + } + + // Nonce + if !has_exact_change_nonce(&acct.nonce_changes, withdrawal_idx) { + let seeded = acct + .nonce_changes + .last() + .map(|c| c.post_nonce) + .unwrap_or_else(|| { + db.store + .get_account_state(*addr) + .map(|a| a.nonce) + .unwrap_or_default() + }); + if account.info.nonce != seeded { + return Err(EvmError::Custom(format!( + "BAL validation failed for withdrawal: account {addr:?} nonce \ + changed during withdrawal/request phase ({}) but BAL has no \ + nonce change at index {withdrawal_idx} (last_bal={seeded})", + account.info.nonce + ))); + } + } + + // Code + if !has_exact_change_code(&acct.code_changes, withdrawal_idx) { + let seeded_hash = acct + .code_changes + .last() + .map(|c| { + if c.new_code.is_empty() { + *EMPTY_KECCACK_HASH + } else { + ethrex_common::utils::keccak(&c.new_code) + } + }) + .unwrap_or_else(|| { + db.store + .get_account_state(*addr) + .map(|a| a.code_hash) + .unwrap_or(*EMPTY_KECCACK_HASH) + }); + if account.info.code_hash != seeded_hash { + return Err(EvmError::Custom(format!( + "BAL validation failed for withdrawal: account {addr:?} code \ + changed during withdrawal/request phase but BAL has no \ + code change at index {withdrawal_idx}" + ))); + } + } + } + Ok(()) } From c67cdb060c07e0fa5e477286b9f01ebdecee5a0a Mon Sep 17 00:00:00 2001 From: Edgar Luque Date: Fri, 10 Apr 2026 16:40:42 +0200 Subject: [PATCH 2/4] fix(l1): add storage reverse check in BAL withdrawal validation Part B was missing storage validation. System contract calls (extract_all_requests_levm) run during the withdrawal/request phase and can write storage. Without this check, a malicious builder could omit storage changes from the BAL. Also check storage for accounts absent from BAL entirely. --- crates/vm/backends/levm/mod.rs | 54 +++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index c5b52a8c106..804926d7d81 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -16,6 +16,7 @@ use ethrex_common::types::block_access_list::{ }; use ethrex_common::types::fee_config::FeeConfig; use ethrex_common::types::{AuthorizationTuple, Code, EIP7702Transaction}; +use ethrex_common::utils::u256_from_big_endian_const; use ethrex_common::{ Address, BigEndianHash, H256, U256, types::{ @@ -1441,7 +1442,7 @@ impl LEVM { // Storage: for each slot in execution state, check it's expected for (key_h256, &value) in &account.storage { - let slot_u256 = U256::from_big_endian(key_h256.as_bytes()); + let slot_u256 = u256_from_big_endian_const(key_h256.0); // EIP-7928 requires storage_changes sorted by slot, so use binary search. let pos = acct .storage_changes @@ -1611,6 +1612,22 @@ impl LEVM { during withdrawal/request phase but is absent from BAL" ))); } + // Also check storage: if any slot differs from pre-state, + // the account should have been in the BAL. + for (key_h256, &value) in &account.storage { + let pre_value = db + .store + .get_storage_value(*addr, *key_h256) + .unwrap_or_default(); + if value != pre_value { + return Err(EvmError::Custom(format!( + "BAL validation failed for withdrawal: account {addr:?} storage \ + slot {} changed during withdrawal/request phase but is absent \ + from BAL", + u256_from_big_endian_const(key_h256.0) + ))); + } + } continue; }; @@ -1687,6 +1704,41 @@ impl LEVM { ))); } } + + // Storage: for each slot in the withdrawal/request-phase state, + // verify the BAL has a corresponding entry or the value is unchanged. + for (key_h256, &value) in &account.storage { + let slot_u256 = u256_from_big_endian_const(key_h256.0); + let pos = acct + .storage_changes + .partition_point(|sc| sc.slot < slot_u256); + if pos < acct.storage_changes.len() && acct.storage_changes[pos].slot == slot_u256 { + let sc = &acct.storage_changes[pos]; + if !has_exact_change_storage(&sc.slot_changes, withdrawal_idx) { + // No BAL entry at withdrawal_idx; compare against + // last BAL entry (the seeded value). + let seeded = + sc.slot_changes + .last() + .map(|c| c.post_value) + .unwrap_or_else(|| { + db.store + .get_storage_value(*addr, *key_h256) + .unwrap_or_default() + }); + if value != seeded { + return Err(EvmError::Custom(format!( + "BAL validation failed for withdrawal: account {addr:?} \ + storage slot {slot_u256} changed during withdrawal/request \ + phase ({value}) but BAL has no change at index \ + {withdrawal_idx} (last_bal={seeded})" + ))); + } + } + } + // Slot not in BAL storage_changes at all: loaded from store + // during the withdrawal/request phase. Skip. + } } Ok(()) From 43623343c13aa29bad2185437aae82863112e09f Mon Sep 17 00:00:00 2001 From: Edgar Date: Tue, 14 Apr 2026 10:17:41 +0200 Subject: [PATCH 3/4] fix(l1): close storage gap and improve diagnostics in BAL withdrawal reverse check Add else-branch to catch storage slots absent from BAL storage_changes by verifying against store pre-state. Add diagnostic values to code hash mismatch error message. --- crates/vm/backends/levm/mod.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index 804926d7d81..eea1407f949 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -1700,7 +1700,9 @@ impl LEVM { return Err(EvmError::Custom(format!( "BAL validation failed for withdrawal: account {addr:?} code \ changed during withdrawal/request phase but BAL has no \ - code change at index {withdrawal_idx}" + code change at index {withdrawal_idx} \ + (actual={:?}, last_bal={seeded_hash:?})", + account.info.code_hash ))); } } @@ -1735,9 +1737,22 @@ impl LEVM { ))); } } + } else { + // Slot not in BAL storage_changes at all: verify it + // wasn't actually mutated during the withdrawal/request phase. + let pre_value = db + .store + .get_storage_value(*addr, *key_h256) + .unwrap_or_default(); + if value != pre_value { + return Err(EvmError::Custom(format!( + "BAL validation failed for withdrawal: account {addr:?} \ + storage slot {slot_u256} changed during withdrawal/request \ + phase ({value}) but slot is absent from BAL storage_changes \ + (pre={pre_value})" + ))); + } } - // Slot not in BAL storage_changes at all: loaded from store - // during the withdrawal/request phase. Skip. } } From 28f3e58677e453f14c05c72562a601b422f70b5b Mon Sep 17 00:00:00 2001 From: MrAzteca Date: Thu, 16 Apr 2026 20:54:44 +0200 Subject: [PATCH 4/4] fix(l1): defer Amsterdam block gas overflow check to post-execution (#6486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - The Hive consume-engine Amsterdam tests for EIP-7778 and EIP-8037 were failing because ethrex's per-tx gas limit checks were incompatible with Amsterdam's new gas accounting rules. - **EIP-7778** uses pre-refund gas for block accounting, so cumulative pre-refund gas can exceed the block gas limit even when a block builder correctly included all transactions. - **EIP-8037** introduces 2D gas accounting (`block_gas = max(regular, state)`), meaning cumulative total gas (regular + state) can legally exceed the block gas limit. - The fix skips the per-tx cumulative gas check for Amsterdam and adds a **post-execution** block-level overflow check using `max(sum_regular, sum_state)` in all three execution paths (sequential, pipeline, parallel). ## Local test results - **200/201** EIP-7778 + EIP-8037 Hive consume-engine tests pass - **105/105** EIP-7778 + EIP-8037 EF blockchain tests pass (4 + 101) - The single remaining Hive failure (`test_block_regular_gas_limit[exceed=True]`) expects `TransactionException.GAS_ALLOWANCE_EXCEEDED` but we return `BlockException.GAS_USED_OVERFLOW` — the block is correctly rejected, just with a different error classification. ## Test plan - [x] All EIP-7778 EF blockchain tests pass locally - [x] All EIP-8037 EF blockchain tests pass locally - [x] 200/201 Hive consume-engine Amsterdam tests pass locally - [ ] Full CI Amsterdam Hive suite passes --------- Co-authored-by: Claude Sonnet 4.5 --- crates/blockchain/payload.rs | 36 ++++++++-- crates/vm/backends/levm/mod.rs | 118 ++++++++++++++++++++------------- 2 files changed, 103 insertions(+), 51 deletions(-) diff --git a/crates/blockchain/payload.rs b/crates/blockchain/payload.rs index c3cc0a0dc52..1748a07cc2a 100644 --- a/crates/blockchain/payload.rs +++ b/crates/blockchain/payload.rs @@ -833,10 +833,35 @@ pub fn apply_plain_transaction( // EIP-8037 (Amsterdam+): track regular and state gas separately let tx_state_gas = report.state_gas_used; let tx_regular_gas = report.gas_used.saturating_sub(tx_state_gas); - context.block_regular_gas_used = context + + // Compute new totals before committing them + let new_regular = context .block_regular_gas_used .saturating_add(tx_regular_gas); - context.block_state_gas_used = context.block_state_gas_used.saturating_add(tx_state_gas); + let new_state = context.block_state_gas_used.saturating_add(tx_state_gas); + + // EIP-8037 (Amsterdam+): post-execution block gas overflow check + // Reject the transaction if adding it would cause max(regular, state) to exceed the gas limit + if context.is_amsterdam && new_regular.max(new_state) > context.payload.header.gas_limit { + // Rollback transaction state before returning error: + // 1. Undo DB mutations (nonce, balance, storage, etc.) + // 2. Revert cumulative gas counter inflation + // This ensures the next transaction executes against clean state. + context.vm.undo_last_tx()?; + context.cumulative_gas_spent -= report.gas_spent; + + return Err(EvmError::Custom(format!( + "block gas limit exceeded (state gas overflow): \ + max({new_regular}, {new_state}) = {} > gas_limit {}", + new_regular.max(new_state), + context.payload.header.gas_limit + )) + .into()); + } + + // Commit the new totals + context.block_regular_gas_used = new_regular; + context.block_state_gas_used = new_state; if context.is_amsterdam { debug!( @@ -852,15 +877,14 @@ pub fn apply_plain_transaction( } // Update remaining_gas for block gas limit checks. - // EIP-8037 (Amsterdam+): per-tx check only validates regular gas against block limit. - // State gas is NOT checked per-tx; block-end validation enforces - // max(block_regular, block_state) <= gas_limit. + // EIP-8037 (Amsterdam+): remaining_gas reflects both regular and state gas dimensions. + // For pre-tx heuristic checks, this ensures we reject txs when either dimension is full. if context.is_amsterdam { context.remaining_gas = context .payload .header .gas_limit - .saturating_sub(context.block_regular_gas_used); + .saturating_sub(new_regular.max(new_state)); } else { context.remaining_gas = context.remaining_gas.saturating_sub(report.gas_used); } diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index 9500c2c83b7..70381b50650 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -31,7 +31,6 @@ use ethrex_levm::account::{AccountStatus, LevmAccount}; use ethrex_levm::call_frame::Stack; use ethrex_levm::constants::{ POST_OSAKA_GAS_LIMIT_CAP, STACK_LIMIT, SYS_CALL_GAS_LIMIT, TX_BASE_COST, - TX_MAX_GAS_LIMIT_AMSTERDAM, }; use ethrex_levm::db::Database; use ethrex_levm::db::gen_db::{CacheDB, GeneralizedDatabase}; @@ -126,18 +125,13 @@ impl LEVM { })?; for (tx_idx, (tx, tx_sender)) in transactions_with_sender.into_iter().enumerate() { - // Pre-tx gas limit guard per EIP-8037/EIP-7825: - // Amsterdam: check min(TX_MAX_GAS_LIMIT, tx.gas) against regular gas only. - // State gas is NOT checked per-tx; block-end validation enforces - // max(block_regular, block_state) <= gas_limit. - // Pre-Amsterdam: check tx.gas against cumulative_gas_used (post-refund sum). - if is_amsterdam { - check_gas_limit( - block_regular_gas_used, - tx.gas_limit().min(TX_MAX_GAS_LIMIT_AMSTERDAM), - block.header.gas_limit, - )?; - } else { + // Pre-tx gas limit guard: + // Pre-Amsterdam: reject tx if cumulative post-refund gas + tx.gas > block limit. + // Amsterdam+: skip — EIP-8037's 2D gas model means cumulative gas (regular + + // state) can legally exceed the block gas limit as long as + // max(sum_regular, sum_state) stays within it. Block-level overflow is + // detected post-execution. + if !is_amsterdam { check_gas_limit(cumulative_gas_used, tx.gas_limit(), block.header.gas_limit)?; } @@ -175,6 +169,20 @@ impl LEVM { report.gas_used, report.gas_spent, ); + + // DoS protection: early exit if either regular or state gas exceeds the limit. + // Since block_gas_used = max(regular, state), if either component exceeds + // the limit, we know the block is invalid and can safely reject without + // violating EIP-8037 semantics. + if block_regular_gas_used > block.header.gas_limit + || block_state_gas_used > block.header.gas_limit + { + return Err(EvmError::Transaction(format!( + "Gas allowance exceeded: Block gas used overflow: \ + block_gas_used {block_gas_used} > block_gas_limit {}", + block.header.gas_limit + ))); + } } else { block_gas_used = block_gas_used.saturating_add(report.gas_used); } @@ -189,6 +197,17 @@ impl LEVM { receipts.push(receipt); } + // EIP-7778 (Amsterdam+): block-level gas overflow check. + // Per-tx checks are skipped for Amsterdam because block gas is computed + // from pre-refund values; overflow can only be detected after execution. + if is_amsterdam && block_gas_used > block.header.gas_limit { + return Err(EvmError::Transaction(format!( + "Gas allowance exceeded: Block gas used overflow: \ + block_gas_used {block_gas_used} > block_gas_limit {}", + block.header.gas_limit + ))); + } + // Set BAL index for post-execution phase (requests + withdrawals, uint16) // Order must match geth: requests (system calls) BEFORE withdrawals. if is_amsterdam { @@ -424,18 +443,13 @@ impl LEVM { let mut tx_since_last_flush = 2; for (tx_idx, (tx, tx_sender)) in transactions_with_sender.into_iter().enumerate() { - // Pre-tx gas limit guard per EIP-8037/EIP-7825: - // Amsterdam: check min(TX_MAX_GAS_LIMIT, tx.gas) against regular gas only. - // State gas is NOT checked per-tx; block-end validation enforces - // max(block_regular, block_state) <= gas_limit. - // Pre-Amsterdam: check tx.gas against cumulative_gas_used (post-refund sum). - if is_amsterdam { - check_gas_limit( - block_regular_gas_used, - tx.gas_limit().min(TX_MAX_GAS_LIMIT_AMSTERDAM), - block.header.gas_limit, - )?; - } else { + // Pre-tx gas limit guard: + // Pre-Amsterdam: reject tx if cumulative post-refund gas + tx.gas > block limit. + // Amsterdam+: skip — EIP-8037's 2D gas model means cumulative gas (regular + + // state) can legally exceed the block gas limit as long as + // max(sum_regular, sum_state) stays within it. Block-level overflow is + // detected post-execution. + if !is_amsterdam { check_gas_limit(cumulative_gas_used, tx.gas_limit(), block.header.gas_limit)?; } @@ -483,6 +497,20 @@ impl LEVM { if is_amsterdam { // Amsterdam+: block gas = max(regular_sum, state_sum) block_gas_used = block_regular_gas_used.max(block_state_gas_used); + + // DoS protection: early exit if either regular or state gas exceeds the limit. + // Since block_gas_used = max(regular, state), if either component exceeds + // the limit, we know the block is invalid and can safely reject without + // violating EIP-8037 semantics. + if block_regular_gas_used > block.header.gas_limit + || block_state_gas_used > block.header.gas_limit + { + return Err(EvmError::Transaction(format!( + "Gas allowance exceeded: Block gas used overflow: \ + block_gas_used {block_gas_used} > block_gas_limit {}", + block.header.gas_limit + ))); + } } else { block_gas_used = block_gas_used.saturating_add(report.gas_used); } @@ -497,6 +525,17 @@ impl LEVM { receipts.push(receipt); } + // EIP-7778 (Amsterdam+): block-level gas overflow check. + // Per-tx checks are skipped for Amsterdam because block gas is computed + // from pre-refund values; overflow can only be detected after execution. + if is_amsterdam && block_gas_used > block.header.gas_limit { + return Err(EvmError::Transaction(format!( + "Gas allowance exceeded: Block gas used overflow: \ + block_gas_used {block_gas_used} > block_gas_limit {}", + block.header.gas_limit + ))); + } + #[cfg(feature = "perf_opcode_timings")] { let mut timings = OPCODE_TIMINGS.lock().expect("poison"); @@ -972,32 +1011,21 @@ impl LEVM { // balance in the BAL won't match execution that ran all txs). let mut block_regular_gas_used = 0_u64; let mut block_state_gas_used = 0_u64; - for (tx_idx, _, report, _, _, _) in &exec_results { - // Per-tx check: only regular gas is checked per-tx (EIP-8037/EIP-7825). - // State gas is validated at block end via max(regular, state) <= gas_limit. - let tx_gas_limit = txs_with_sender[*tx_idx].0.gas_limit(); - check_gas_limit( - block_regular_gas_used, - tx_gas_limit.min(TX_MAX_GAS_LIMIT_AMSTERDAM), - header.gas_limit, - )?; + for (_, _, report, _, _, _) in &exec_results { let tx_state_gas = report.state_gas_used; let tx_regular_gas = report.gas_used.saturating_sub(tx_state_gas); block_regular_gas_used = block_regular_gas_used.saturating_add(tx_regular_gas); block_state_gas_used = block_state_gas_used.saturating_add(tx_state_gas); - // Post-tx check: needed because all txs are already executed — if the last tx - // pushes actual gas over the limit, there's no next iteration to catch it - // like the sequential path does. - let running_block_gas_after = block_regular_gas_used.max(block_state_gas_used); - if running_block_gas_after > header.gas_limit { - return Err(EvmError::Transaction(format!( - "Gas allowance exceeded: \ - used {running_block_gas_after} > block limit {}", - header.gas_limit - ))); - } } let block_gas_used = block_regular_gas_used.max(block_state_gas_used); + // EIP-7778: block-level overflow check using pre-refund gas. + if block_gas_used > header.gas_limit { + return Err(EvmError::Transaction(format!( + "Gas allowance exceeded: Block gas used overflow: \ + block_gas_used {block_gas_used} > block_gas_limit {}", + header.gas_limit + ))); + } // 4. Per-tx BAL validation — now safe to run after gas limit is confirmed OK. // Also mark off storage_reads that appear in per-tx execution state.