Skip to main content

ethrex_vm/backends/levm/
mod.rs

1pub mod db;
2mod tracing;
3
4use super::{
5    BlockExecutionResult, ExecutionGasCap, FrameValidationOutcome, TxGasBreakdown,
6    compute_burned_fees,
7};
8use crate::system_contracts::{
9    AMSTERDAM_REQUEST_PREDEPLOYS, BEACON_ROOTS_ADDRESS, BUILDER_DEPOSIT_CONTRACT_ADDRESS,
10    BUILDER_EXIT_CONTRACT_ADDRESS, CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS,
11    EXPIRY_VERIFIER_PREDEPLOY, EXPIRY_VERIFIER_RUNTIME_BYTECODE, HISTORY_STORAGE_ADDRESS,
12    PRAGUE_SYSTEM_CONTRACTS, SYSTEM_ADDRESS, WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS,
13};
14use crate::{EvmError, ExecutionResult};
15use bytes::Bytes;
16use ethrex_common::H256;
17#[cfg(feature = "rayon")]
18use ethrex_common::constants::EMPTY_KECCAK_HASH;
19#[cfg(feature = "rayon")]
20use ethrex_common::seeded_hash::{SeededHashMap, SeededHashSet};
21#[cfg(feature = "rayon")]
22use ethrex_common::types::TxType;
23use ethrex_common::types::block_access_list::BlockAccessList;
24#[cfg(feature = "rayon")]
25use ethrex_common::types::block_access_list::{
26    AccountChanges, BalAddressIndex, find_exact_change_balance, find_exact_change_code,
27    find_exact_change_nonce, find_exact_change_storage, has_exact_change_balance,
28    has_exact_change_code, has_exact_change_nonce, has_exact_change_storage,
29};
30use ethrex_common::types::fee_config::FeeConfig;
31use ethrex_common::types::{AuthorizationTuple, EIP7702Transaction};
32use ethrex_common::types::{Code, InitcodeBuffer};
33#[cfg(feature = "rayon")]
34use ethrex_common::utils::u256_from_big_endian_const;
35use ethrex_common::{
36    Address, U256,
37    types::{
38        AccessList, AccountUpdate, Block, BlockHeader, EIP1559Transaction, Fork, FrameReceipt,
39        GWEI_TO_WEI, GenericTransaction, INITIAL_BASE_FEE, Log, Receipt, Transaction, TxKind,
40        Withdrawal, requests::Requests,
41    },
42};
43#[cfg(feature = "rayon")]
44use ethrex_common::{
45    BigEndianHash, constants::AMSTERDAM_MAX_CODE_SIZE, validate_bal_code_sizes,
46    validate_block_access_list_size, validate_header_bal_indices,
47};
48use ethrex_crypto::Crypto;
49use ethrex_levm::EVMConfig;
50use ethrex_levm::StatelessValidator;
51#[cfg(feature = "rayon")]
52use ethrex_levm::account::AccountStatus;
53use ethrex_levm::call_frame::Stack;
54use ethrex_levm::constants::{
55    POST_OSAKA_GAS_LIMIT_CAP, STACK_LIMIT, SYS_CALL_GAS_LIMIT, TX_MAX_GAS_LIMIT_AMSTERDAM,
56};
57use ethrex_levm::db::gen_db::GeneralizedDatabase;
58#[cfg(feature = "rayon")]
59use ethrex_levm::db::gen_db::{
60    LazyBalCursor, code_from_bal, post_value_at_or_before, seed_one_address_info_from_bal,
61};
62#[cfg(feature = "rayon")]
63use ethrex_levm::db::{Database, gen_db::CacheDB};
64use ethrex_levm::errors::{InternalError, TxValidationError};
65use ethrex_levm::memory::Memory;
66#[cfg(feature = "perf_opcode_timings")]
67use ethrex_levm::timings::{OPCODE_TIMINGS, PRECOMPILES_TIMINGS};
68use ethrex_levm::tracing::LevmCallTracer;
69use ethrex_levm::utils::get_base_fee_per_blob_gas;
70use ethrex_levm::utils::intrinsic_gas_floor;
71use ethrex_levm::validation_observer::FrameSimViolation;
72use ethrex_levm::vm::VMType;
73use ethrex_levm::{
74    Environment,
75    errors::{ExecutionReport, TxResult, VMError},
76    vm::VM,
77};
78#[cfg(feature = "rayon")]
79use rayon::iter::{IntoParallelIterator, ParallelIterator};
80#[cfg(feature = "rayon")]
81use rayon::slice::ParallelSlice;
82#[cfg(feature = "rayon")]
83use rustc_hash::FxHashMap;
84use std::cmp::min;
85use std::sync::Arc;
86#[cfg(feature = "rayon")]
87use std::sync::atomic::AtomicBool;
88use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
89use std::sync::mpsc::Sender;
90
91/// Accounts per task in [`LEVM::warm_block_from_bal`]. Small enough that the first
92/// warmed states and code reach the executor within a few point reads, large enough
93/// to keep the per-chunk batch read worthwhile.
94#[cfg(feature = "rayon")]
95const BAL_WARM_CHUNK_ACCOUNTS: usize = 64;
96
97/// EIP-7928 `block_access_index` of the pre-block system calls.
98/// Only the parallel BAL path reads it, and that path is rayon-gated.
99#[cfg(feature = "rayon")]
100const PRE_BLOCK_BAL_INDEX: u32 = 0;
101
102/// EIP-8079 `burned_fees` for an LStar block: `base_fee · post_refund_gas +
103/// blob_base_fee · blob_gas_used`. `post_refund_gas` is the block's post-refund
104/// Σ gas_spent (the running `cumulative_gas_used`, equivalently the last
105/// receipt's `cumulative_gas_used`). Callers gate this on `is_lstar` and wrap
106/// the result in `Some`. Extracted so the production and verification paths
107/// (`execute_block` and both `execute_block_pipeline` arms) derive the burn
108/// identically.
109fn lstar_burned_fees(
110    chain_config: &ethrex_common::types::ChainConfig,
111    header: &ethrex_common::types::BlockHeader,
112    post_refund_gas: u64,
113) -> u64 {
114    let evm_cfg = EVMConfig::new_from_chain_config(chain_config, header);
115    let blob_base_fee = get_base_fee_per_blob_gas(header.excess_blob_gas, &evm_cfg)
116        .map(|v| u64::try_from(v).unwrap_or(u64::MAX))
117        .unwrap_or(0);
118    let blob_gas_used = header.blob_gas_used.unwrap_or(0);
119    compute_burned_fees(
120        header.base_fee_per_gas.unwrap_or(0),
121        // EIP-8079: burn basis is post-refund Σ gas_spent.
122        post_refund_gas,
123        blob_base_fee,
124        blob_gas_used,
125    )
126}
127
128/// The struct implements the following functions:
129/// [LEVM::execute_block]
130/// [LEVM::execute_tx]
131/// [LEVM::get_state_transitions]
132/// [LEVM::process_withdrawals]
133#[derive(Debug)]
134pub struct LEVM;
135
136/// Build the per-frame receipts (EIP-8141) for a frame transaction from an
137/// execution report's `frame_results`. Returns `None` when the report carries
138/// no frame results.
139fn frame_receipts_from(
140    frame_results: Option<Vec<(u8, u64, Vec<Log>)>>,
141) -> Option<Vec<FrameReceipt>> {
142    frame_results.map(|results| {
143        results
144            .into_iter()
145            .map(|(status, gas_used, logs)| FrameReceipt {
146                status,
147                gas_used,
148                logs,
149            })
150            .collect()
151    })
152}
153
154/// Checks that adding `tx_gas_limit` to `block_gas_used` doesn't exceed `block_gas_limit`.
155fn check_gas_limit(
156    block_gas_used: u64,
157    tx_gas_limit: u64,
158    block_gas_limit: u64,
159) -> Result<(), EvmError> {
160    if tx_gas_limit > block_gas_limit.saturating_sub(block_gas_used) {
161        return Err(EvmError::Transaction(format!(
162            "Gas allowance exceeded: \
163             used {block_gas_used} + tx limit {tx_gas_limit} > block limit {block_gas_limit}"
164        )));
165    }
166    Ok(())
167}
168
169/// Upper bound on the gas a block can legitimately spend across all its transactions.
170///
171/// Per transaction `gas_used = regular + state`, so
172/// `sum(gas_used) = sum(regular) + sum(state) <= 2 * max(sum(regular), sum(state))`.
173/// That max is exactly the block's reported `gas_used`, which a valid block keeps
174/// within its gas limit, so no valid block spends more than twice its limit in total.
175pub fn block_work_budget(block_gas_limit: u64) -> u64 {
176    block_gas_limit.saturating_mul(2)
177}
178
179/// Reject a block whose transactions cannot all be admitted, before executing any of
180/// them.
181///
182/// Ordered gas admission ([`check_2d_gas_allowance`]) needs each transaction's real
183/// gas, so on the parallel path it can only run once every transaction has executed
184/// and its report is held in memory. This bound needs none of that: every transaction
185/// spends at least its EIP-7623/7976 floor, so a block whose floors already exceed
186/// [`block_work_budget`] cannot pass admission whatever it executes to.
187///
188/// Sound in the accepting direction: for any block that could be valid,
189/// `sum(floor) <= sum(gas_used) <= block_work_budget`, so this never rejects a block
190/// ordered admission would have accepted.
191pub fn check_minimum_block_work<'a>(
192    txs_with_sender: impl IntoIterator<Item = (&'a Transaction, Address)>,
193    fork: Fork,
194    block_gas_limit: u64,
195) -> Result<(), EvmError> {
196    let budget = block_work_budget(block_gas_limit);
197    let mut floor_total = 0_u64;
198    for (tx, sender) in txs_with_sender {
199        // The floor is computed from the transaction's own fields, so failing to
200        // compute it (an overflow) makes the block invalid rather than a transient
201        // error to retry.
202        let floor = intrinsic_gas_floor(tx, sender, fork)
203            .map_err(|e| EvmError::Transaction(format!("intrinsic gas floor: {e}")))?;
204        floor_total = floor_total.saturating_add(floor);
205        if floor_total > budget {
206            return Err(EvmError::Transaction(format!(
207                "Gas allowance exceeded: minimum gas of the block's transactions \
208                 {floor_total} exceeds the block work budget {budget} \
209                 (block_gas_limit={block_gas_limit})"
210            )));
211        }
212    }
213    Ok(())
214}
215
216/// Gas spent so far by the transactions parallel execution has finished, per EIP-8037
217/// dimension, so it can stop once the block is over its gas limit.
218///
219/// Ordered admission ([`check_2d_gas_allowance`]) only runs after every transaction
220/// has executed, so without this a block that is over its gas limit still costs its
221/// full execution, which can be many times the work of any valid block.
222///
223/// Sound in the accepting direction: a valid block keeps the sum of each dimension
224/// within its gas limit, and the transactions finished so far are a subset of the
225/// block, so their sums cannot exceed the limit either.
226#[derive(Default)]
227pub struct CompletedGas {
228    regular: AtomicU64,
229    state: AtomicU64,
230}
231
232impl CompletedGas {
233    /// Rejects once the finished transactions exceed the gas limit in either dimension.
234    pub fn check(&self, block_gas_limit: u64) -> Result<(), EvmError> {
235        let regular = self.regular.load(Ordering::Relaxed);
236        let state = self.state.load(Ordering::Relaxed);
237        if regular > block_gas_limit || state > block_gas_limit {
238            return Err(EvmError::Transaction(format!(
239                "Gas allowance exceeded: transactions completed during parallel execution \
240                 used regular={regular} state={state}, over block_gas_limit={block_gas_limit}"
241            )));
242        }
243        Ok(())
244    }
245
246    /// Adds one finished transaction's gas, then applies [`Self::check`].
247    pub fn record(
248        &self,
249        regular_gas: u64,
250        state_gas: u64,
251        block_gas_limit: u64,
252    ) -> Result<(), EvmError> {
253        self.regular.fetch_add(regular_gas, Ordering::Relaxed);
254        self.state.fetch_add(state_gas, Ordering::Relaxed);
255        self.check(block_gas_limit)
256    }
257}
258
259/// EIP-8037 (Amsterdam+, execution-specs PR #2703) per-tx 2D inclusion check.
260///
261/// A tx is rejected (block invalid) if its worst-case contribution to either
262/// dimension exceeds the remaining budget at tx inclusion time:
263///
264/// - regular dim: `min(TX_MAX_GAS_LIMIT, tx.gas) > block_gas_limit - block_regular_gas_used`
265/// - state dim:   `tx.gas > block_gas_limit - block_state_gas_used`
266///
267/// The full `tx.gas` is used in both dimensions (only the regular dimension is
268/// capped at `TX_MAX_GAS_LIMIT`); intrinsic underfunding is rejected separately
269/// in transaction validation, not here. Mirrors
270/// `src/ethereum/forks/amsterdam/fork.py` `check_transaction` at the
271/// `tests-glamsterdam-devnet@v7.1.0` spec.
272///
273/// Note: `block_gas_used_regular` here equals EELS's `block_output.block_gas_used`
274/// because our `report.gas_used` already reflects `max(raw_regular, calldata_floor)`
275/// per-tx — i.e. the floor is applied before aggregation, not after. Keep this in
276/// sync with the aggregation loop in [`execute_block_parallel`].
277pub fn check_2d_gas_allowance(
278    tx: &Transaction,
279    block_gas_used_regular: u64,
280    block_gas_used_state: u64,
281    block_gas_limit: u64,
282) -> Result<(), EvmError> {
283    let tx_gas = tx.gas_limit();
284    let regular_available = block_gas_limit.saturating_sub(block_gas_used_regular);
285    let state_available = block_gas_limit.saturating_sub(block_gas_used_state);
286
287    // Regular dim: worst-case regular contribution = full tx.gas, capped at
288    // TX_MAX_GAS_LIMIT. The spec uses the full tx gas with no intrinsic
289    // subtraction; intrinsic underfunding is rejected separately in transaction
290    // validation, not by this inclusion check.
291    let regular_contrib = tx_gas.min(TX_MAX_GAS_LIMIT_AMSTERDAM);
292    if regular_contrib > regular_available {
293        return Err(EvmError::Transaction(format!(
294            "Gas allowance exceeded: regular dim worst-case {regular_contrib} > \
295             available {regular_available} (block_gas_used_regular={block_gas_used_regular}, \
296             block_gas_limit={block_gas_limit})"
297        )));
298    }
299
300    // State dim: worst-case state contribution = full tx.gas.
301    let state_contrib = tx_gas;
302    if state_contrib > state_available {
303        return Err(EvmError::Transaction(format!(
304            "Gas allowance exceeded: state dim worst-case {state_contrib} > \
305             available {state_available} (block_gas_used_state={block_gas_used_state}, \
306             block_gas_limit={block_gas_limit})"
307        )));
308    }
309
310    Ok(())
311}
312
313/// Error type for BAL validation failures, distinguishing state mismatches
314/// from database errors.
315///
316/// Public so [`LEVM::validate_tx_execution`] is directly callable (and its
317/// error variants inspectable) from unit tests outside this crate.
318#[cfg(feature = "rayon")]
319#[derive(Debug, thiserror::Error)]
320pub enum BalValidationError {
321    #[error("{0}")]
322    Mismatch(String),
323    #[error("{0}")]
324    Database(String),
325}
326
327/// Runs `check` on every entry of `map` and returns the failure of the lowest key.
328///
329/// A seeded map walks in a different order on every node and every run, so stopping at the
330/// first failure the walk meets would name a different offender for the same invalid block.
331/// A map that passes is walked once, in its own order; only a failure pays for sorting the
332/// keys and walking again. `check` only reads, so the second walk sees what the first did.
333#[cfg(feature = "rayon")]
334fn first_failure_by_key<K: Ord, V, S, E>(
335    map: &std::collections::HashMap<K, V, S>,
336    mut check: impl FnMut(&K, &V) -> Result<(), E>,
337) -> Result<(), E> {
338    if map
339        .iter()
340        .try_for_each(|(key, value)| check(key, value))
341        .is_ok()
342    {
343        return Ok(());
344    }
345    let mut entries: Vec<(&K, &V)> = map.iter().collect();
346    entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
347    entries
348        .into_iter()
349        .try_for_each(|(key, value)| check(key, value))
350}
351
352impl LEVM {
353    /// Execute a block and return the execution result.
354    ///
355    /// Also records and returns the Block Access List (EIP-7928) for Amsterdam+ forks.
356    /// The BAL will be `None` for pre-Amsterdam forks.
357    pub fn execute_block(
358        block: &Block,
359        db: &mut GeneralizedDatabase,
360        vm_type: VMType,
361        crypto: &dyn Crypto,
362        stateless_validator: Option<&dyn StatelessValidator>,
363    ) -> Result<(BlockExecutionResult, Option<BlockAccessList>), EvmError> {
364        let chain_config = db.store.get_chain_config()?;
365        let is_amsterdam = chain_config.is_amsterdam_activated(block.header.timestamp);
366        // EIP-8079 (LStar+): compute burned fees
367        let is_lstar = chain_config.is_lstar_activated(block.header.timestamp);
368
369        // EIP-7928 BlockAccessIndex is uint32. Block validity forbids >= 2^32 txs
370        // long before we'd reach this point, but guard the invariant explicitly
371        // so any upstream bug that inflates tx counts panics in debug instead of
372        // silently producing a `u32::MAX` index.
373        debug_assert!(
374            block.body.transactions.len() < u32::MAX as usize,
375            "tx count overflows u32 BlockAccessIndex"
376        );
377
378        // Enable BAL recording for Amsterdam+ forks
379        if is_amsterdam {
380            db.enable_bal_recording();
381            // Set index 0 for pre-execution phase (system contracts)
382            db.set_bal_index(0);
383        }
384
385        Self::prepare_block(block, db, vm_type, crypto)?;
386
387        // Block-invariant EVM config + chain id + base blob fee, computed once and
388        // reused by every tx (mirrors `execute_block_pipeline`): avoids a per-tx
389        // chain-config copy, fork/blob-schedule recompute, and `fake_exponential` call.
390        let evm_config = EVMConfig::new_from_chain_config(&chain_config, &block.header);
391        let chain_id = chain_config.chain_id;
392        let base_blob_fee_per_gas =
393            get_base_fee_per_blob_gas(block.header.excess_blob_gas, &evm_config)?;
394        // Stack, memory and initcode buffer pools reused across txs (each tx draws from them and
395        // reclaims into them).
396        let mut shared_stack_pool = Vec::with_capacity(STACK_LIMIT);
397        let mut shared_memory_pool = Vec::with_capacity(1);
398        let mut shared_initcode_pool = Vec::new();
399
400        let n_txs = block.body.transactions.len();
401        let mut receipts = Vec::with_capacity(n_txs);
402        let mut tx_gas_breakdowns: Vec<TxGasBreakdown> = Vec::with_capacity(n_txs);
403        // Cumulative gas for receipts (POST-REFUND per EIP-7778)
404        let mut cumulative_gas_used = 0_u64;
405        // Block gas accounting (PRE-REFUND for Amsterdam+ per EIP-7778)
406        let mut block_gas_used = 0_u64;
407        // EIP-8037 (Amsterdam+): track regular and state gas separately for block-level max()
408        let mut block_regular_gas_used = 0_u64;
409        let mut block_state_gas_used = 0_u64;
410        let transactions_with_sender =
411            block
412                .body
413                .get_transactions_with_sender(crypto)
414                .map_err(|error| {
415                    EvmError::Transaction(format!("Couldn't recover addresses with error: {error}"))
416                })?;
417
418        for (tx_idx, (tx, tx_sender)) in transactions_with_sender.into_iter().enumerate() {
419            // Pre-tx gas limit guard:
420            // Pre-Amsterdam: reject tx if cumulative post-refund gas + tx.gas > block limit.
421            // Amsterdam+: skip — EIP-8037's 2D gas model means cumulative gas (regular +
422            // state) can legally exceed the block gas limit as long as
423            // max(sum_regular, sum_state) stays within it. Block-level overflow is
424            // detected post-execution.
425            if !is_amsterdam {
426                check_gas_limit(cumulative_gas_used, tx.gas_limit(), block.header.gas_limit)?;
427            }
428
429            // EIP-8037 (Amsterdam+, PR #2703): per-tx 2D inclusion check.
430            if is_amsterdam {
431                check_2d_gas_allowance(
432                    tx,
433                    block_regular_gas_used,
434                    block_state_gas_used,
435                    block.header.gas_limit,
436                )?;
437            }
438
439            // Set BAL index for this transaction (1-indexed per EIP-7928)
440            if is_amsterdam {
441                let bal_index = u32::try_from(tx_idx + 1).unwrap_or(u32::MAX);
442                db.set_bal_index(bal_index);
443
444                // Record tx sender for BAL. The recipient is recorded when the prepare
445                // region loads it (default_hook), per the EIP-7928 v7.1.0 update: an
446                // EIP-7702 auth halt before the recipient load must exclude it.
447                if let Some(recorder) = db.bal_recorder_mut() {
448                    recorder.record_touched_address(tx_sender);
449                }
450            }
451
452            let report = Self::execute_tx_in_block(
453                tx,
454                tx_sender,
455                &block.header,
456                db,
457                vm_type,
458                base_blob_fee_per_gas,
459                &mut shared_stack_pool,
460                &mut shared_memory_pool,
461                &mut shared_initcode_pool,
462                false,
463                crypto,
464                evm_config,
465                chain_id,
466                stateless_validator,
467            )?;
468
469            tx_gas_breakdowns.push(TxGasBreakdown::from_report(
470                tx_idx,
471                tx.hash(crypto),
472                &report,
473            ));
474
475            // EIP-7778: gas_spent (POST-REFUND) for receipt cumulative_gas_used
476            cumulative_gas_used += report.gas_spent;
477
478            // EIP-8037 (Amsterdam+): block_gas_used = max(sum_regular, sum_state)
479            // For pre-Amsterdam, state_gas_used is always 0 so gas_used == regular_gas.
480            let tx_state_gas = report.state_gas_used;
481            let tx_regular_gas = report.gas_used.saturating_sub(tx_state_gas);
482            block_regular_gas_used = block_regular_gas_used.saturating_add(tx_regular_gas);
483            block_state_gas_used = block_state_gas_used.saturating_add(tx_state_gas);
484
485            if is_amsterdam {
486                // Amsterdam+: block gas = max(regular_sum, state_sum)
487                block_gas_used = block_regular_gas_used.max(block_state_gas_used);
488                ::tracing::debug!(
489                    "EIP-8037 validate tx[{tx_idx}]: regular={tx_regular_gas} state={tx_state_gas} gas_used={} gas_spent={} block_regular={block_regular_gas_used} block_state={block_state_gas_used} block_max={block_gas_used}",
490                    report.gas_used,
491                    report.gas_spent,
492                );
493
494                // DoS protection: early exit if either regular or state gas exceeds the limit.
495                // Since block_gas_used = max(regular, state), if either component exceeds
496                // the limit, we know the block is invalid and can safely reject without
497                // violating EIP-8037 semantics.
498                if block_regular_gas_used > block.header.gas_limit
499                    || block_state_gas_used > block.header.gas_limit
500                {
501                    return Err(EvmError::Transaction(format!(
502                        "Gas allowance exceeded: Block gas used overflow: \
503                         block_gas_used {block_gas_used} > block_gas_limit {}",
504                        block.header.gas_limit
505                    )));
506                }
507            } else {
508                block_gas_used = block_gas_used.saturating_add(report.gas_used);
509            }
510
511            let mut receipt = Receipt::new(
512                tx.tx_type(),
513                matches!(report.result, TxResult::Success),
514                cumulative_gas_used,
515                report.logs,
516            );
517
518            // For frame transactions, propagate payer and per-frame receipts
519            if matches!(tx, Transaction::FrameTransaction(_)) {
520                receipt.payer = report.payer_address;
521                receipt.frame_receipts = frame_receipts_from(report.frame_results);
522            }
523
524            receipts.push(receipt);
525        }
526
527        // EIP-7778 (Amsterdam+): block-level gas overflow check.
528        // Per-tx checks are skipped for Amsterdam because block gas is computed
529        // from pre-refund values; overflow can only be detected after execution.
530        if is_amsterdam && block_gas_used > block.header.gas_limit {
531            return Err(EvmError::Transaction(format!(
532                "Gas allowance exceeded: Block gas used overflow: \
533                 block_gas_used {block_gas_used} > block_gas_limit {}",
534                block.header.gas_limit
535            )));
536        }
537
538        // Set BAL index for post-execution phase (withdrawals + requests)
539        //
540        // Withdrawals are applied BEFORE the request system calls. EIP-7002 places its
541        // system call "at the end of processing any execution block ... after processing
542        // all transactions and after performing the block body withdrawal requests
543        // validations", and EELS `apply_body` calls `process_withdrawals` then
544        // `process_general_purpose_requests`. The order is observable only when a
545        // withdrawal credits an address whose own system call then spends that balance in
546        // the same block, which no real predeploy does; the EEST case
547        // `bal_withdrawals_and_dequeues_net_balance_at_last_index` constructs exactly that.
548        // geth runs its request system calls first (`PostExecution` precedes `Finalize`),
549        // so this follows the spec rather than geth.
550        if is_amsterdam {
551            let post_tx_index =
552                u32::try_from(block.body.transactions.len() + 1).unwrap_or(u32::MAX);
553            db.set_bal_index(post_tx_index);
554
555            // Record ALL withdrawal recipients for BAL per EIP-7928:
556            // "Withdrawal recipients regardless of amount"
557            // The amount filter only applies to balance_changes, not touched_addresses
558            if let Some(withdrawals) = &block.body.withdrawals
559                && let Some(recorder) = db.bal_recorder_mut()
560            {
561                recorder.extend_touched_addresses(withdrawals.iter().map(|w| w.address));
562            }
563        }
564
565        if let Some(withdrawals) = &block.body.withdrawals {
566            Self::process_withdrawals(db, withdrawals)?;
567        }
568
569        // TODO: I don't like deciding the behavior based on the VMType here.
570        // TODO2: Revise this, apparently extract_all_requests_levm is not called
571        // in L2 execution, but its implementation behaves differently based on this.
572        let requests = match vm_type {
573            VMType::L1 => extract_all_requests_levm(&receipts, db, &block.header, vm_type, crypto)?,
574            VMType::L2(_) => Default::default(),
575        };
576
577        // Extract BAL if recording was enabled
578        let bal = db.take_bal();
579
580        Ok((
581            BlockExecutionResult {
582                receipts,
583                requests,
584                block_gas_used,
585                // cumulative_gas_used is accumulated as += report.gas_spent (post-refund).
586                burned_fees: is_lstar
587                    .then(|| lstar_burned_fees(&chain_config, &block.header, cumulative_gas_used)),
588                tx_gas_breakdowns,
589            },
590            bal,
591        ))
592    }
593
594    /// `merkleizer` is `Some` on the streaming (non-BAL) path; the BAL validation path
595    /// passes `None` because the caller merkleizes optimistically from the input BAL and
596    /// the EVM-side `bal_to_account_updates` send is then redundant work.
597    #[allow(clippy::too_many_arguments)]
598    pub fn execute_block_pipeline(
599        block: &Block,
600        db: &mut GeneralizedDatabase,
601        vm_type: VMType,
602        merkleizer: Option<Sender<Vec<AccountUpdate>>>,
603        queue_length: &AtomicUsize,
604        crypto: &dyn Crypto,
605        header_bal: Option<Arc<BlockAccessList>>,
606        bal_parallel_exec_enabled: bool,
607        stateless_validator: Option<&dyn StatelessValidator>,
608    ) -> Result<(BlockExecutionResult, Option<BlockAccessList>), EvmError> {
609        let chain_config = db.store.get_chain_config()?;
610        let is_amsterdam = chain_config.is_amsterdam_activated(block.header.timestamp);
611        let is_lstar = chain_config.is_lstar_activated(block.header.timestamp);
612        // Block-invariant EVM config + chain id, computed once and reused by every tx
613        // (avoids a per-tx chain-config dyn-dispatch copy + fork/blob-schedule recompute).
614        let evm_config = EVMConfig::new_from_chain_config(&chain_config, &block.header);
615        let chain_id = chain_config.chain_id;
616
617        // EIP-7928 BlockAccessIndex invariant — see `execute_block` for rationale.
618        debug_assert!(
619            block.body.transactions.len() < u32::MAX as usize,
620            "tx count overflows u32 BlockAccessIndex"
621        );
622
623        let transactions_with_sender =
624            block
625                .body
626                .get_transactions_with_sender(crypto)
627                .map_err(|error| {
628                    EvmError::Transaction(format!("Couldn't recover addresses with error: {error}"))
629                })?;
630
631        #[cfg(not(feature = "rayon"))]
632        // Without rayon there is no parallel BAL path, so these are unused.
633        // Adding dummy let to avoid unused warnings.
634        let _ = (header_bal, bal_parallel_exec_enabled);
635        #[cfg(feature = "rayon")]
636        // When BAL is provided (Amsterdam+ validation path): use parallel execution.
637        // The `is_amsterdam` gate is required: `execute_block_parallel` (and the
638        // optimistic merkleization it feeds) is only correct on Amsterdam+; a
639        // pre-Amsterdam call here in release would skip the inner debug_assert.
640        // `--no-bal-parallel-exec` opts out and falls through to the sequential pipeline below.
641        if let Some(bal) = header_bal
642            && is_amsterdam
643            && bal_parallel_exec_enabled
644        {
645            // Validate header BAL structural properties before execution.
646            // This catches index-out-of-bounds early, before wasting execution time.
647            // Note: the EIP-7928 item-count cap is deferred until after transaction
648            // processing so that transaction-level errors (e.g. gas allowance exceeded)
649            // take priority, matching the reference implementation's validation order.
650            validate_header_bal_indices(&bal, block.body.transactions.len())
651                .map_err(|e| EvmError::Custom(e.to_string()))?;
652            // Each transaction that loads an account builds its code from the BAL, so an
653            // oversized code change must be rejected before any of them run.
654            validate_bal_code_sizes(&bal, AMSTERDAM_MAX_CODE_SIZE)
655                .map_err(|e| EvmError::Custom(format!("BAL validation failed: {e}")))?;
656
657            // Shadow-record the system phases: their account cache also holds internal
658            // loads the recorder never sees, so it can't say what they touched.
659            db.enable_bal_recording();
660            db.set_bal_index(PRE_BLOCK_BAL_INDEX);
661            let prepare_result = Self::prepare_block(block, db, vm_type, crypto);
662            let pre_block_bal = db.take_bal().unwrap_or_default();
663            prepare_result?;
664
665            // Build validation index once — shared across parallel execution and post-exec seeding.
666            let validation_index = Arc::new(bal.build_validation_index());
667
668            // Validate supplied-BAL entries at the pre-execution index (0). The
669            // per-tx (`validate_tx_execution`) and withdrawal
670            // (`validate_bal_withdrawal_index`) validators never reach index 0,
671            // so a spurious no-op entry there would otherwise pass the parallel
672            // path (it leaves the state root unchanged). Must run before
673            // `get_state_transitions_tx` drains `current_accounts_state`.
674            Self::validate_bal_pre_exec_index(db, &bal, &validation_index)?;
675
676            // Drain system call state and snapshot for per-tx db seeding
677            LEVM::get_state_transitions_tx(db)?;
678            let system_seed = Arc::new(std::mem::take(&mut db.initial_accounts_state));
679
680            let parallel_result = Self::execute_block_parallel(
681                block,
682                &transactions_with_sender,
683                db,
684                vm_type,
685                Arc::clone(&bal),
686                merkleizer.as_ref(),
687                queue_length,
688                system_seed,
689                &pre_block_bal,
690                crypto,
691                Arc::clone(&validation_index),
692                stateless_validator,
693            );
694
695            // If parallel execution failed (e.g. BAL validation), still check system
696            // contracts — SystemContractCallFailed takes priority over BAL errors.
697            // The BAL may be inconsistent for blocks that are fundamentally invalid
698            // due to a failing system contract.
699            let (
700                receipts,
701                block_gas_used,
702                mut unread_storage_reads,
703                mut unaccessed_pure_accounts,
704                tx_gas_breakdowns,
705            ) = match parallel_result {
706                Ok(result) => result,
707                Err(parallel_err) => {
708                    let last_tx_idx =
709                        u32::try_from(block.body.transactions.len()).unwrap_or(u32::MAX);
710                    if Self::seed_db_from_bal(
711                        db,
712                        &bal,
713                        last_tx_idx,
714                        &validation_index.accounts_by_min_index,
715                    )
716                    .is_ok()
717                        && let VMType::L1 = vm_type
718                        && let Err(e @ EvmError::SystemContractCallFailed(_)) =
719                            extract_all_requests_levm(&[], db, &block.header, vm_type, crypto)
720                    {
721                        return Err(e);
722                    }
723                    return Err(parallel_err);
724                }
725            };
726
727            // Seed main db with post-tx state (excluding withdrawal effects) so
728            // request extraction system calls see user-queued requests on predeploys.
729            // Withdrawal index is n_txs+1 in BAL; we use n_txs to avoid double-applying
730            // withdrawal balances (process_withdrawals handles those below).
731            let last_tx_idx = u32::try_from(block.body.transactions.len()).unwrap_or(u32::MAX);
732            // Eager seed retained: lazy_bal cursor is per-tx only; outer DB has no cursor.
733            Self::seed_db_from_bal(
734                db,
735                &bal,
736                last_tx_idx,
737                &validation_index.accounts_by_min_index,
738            )?;
739
740            let withdrawal_bal_idx = u32::try_from(block.body.transactions.len())
741                .map(|n| n.saturating_add(1))
742                .unwrap_or(u32::MAX);
743            db.enable_bal_recording();
744            db.set_bal_index(withdrawal_bal_idx);
745
746            // Withdrawals precede the request system calls; see the ordering note on
747            // the sequential path above. The db was seeded at `last_tx_idx` (post-tx,
748            // pre-withdrawal), so applying withdrawals here and then extracting
749            // requests lets the system calls observe the withdrawal credits.
750            let post_block_result = (|| -> Result<Vec<Requests>, EvmError> {
751                if let Some(withdrawals) = &block.body.withdrawals {
752                    Self::process_withdrawals(db, withdrawals)?;
753                }
754
755                let requests = match vm_type {
756                    VMType::L1 => {
757                        extract_all_requests_levm(&receipts, db, &block.header, vm_type, crypto)?
758                    }
759                    VMType::L2(_) => Default::default(),
760                };
761                Ok(requests)
762            })();
763            let post_block_bal = db.take_bal().unwrap_or_default();
764            let requests = post_block_result?;
765            // State transitions for merkleizer come from bal_to_account_updates,
766            // not from db — no need to call send_state_transitions_tx here.
767
768            // Validate BAL entries at the withdrawal index against actual
769            // post-withdrawal/request state. `saturating_add(1)` prevents a
770            // release-build wrap if `n == u32::MAX` (debug_assert on tx count
771            // catches this upstream, but belt-and-braces).
772            let withdrawal_idx = u32::try_from(block.body.transactions.len())
773                .map(|n| n.saturating_add(1))
774                .unwrap_or(u32::MAX);
775            Self::validate_bal_withdrawal_index(db, &bal, withdrawal_idx, &validation_index)?;
776
777            // Mark storage_reads that occurred during the withdrawal/request phase.
778            if !unread_storage_reads.is_empty() {
779                for (addr, acct) in &db.current_accounts_state {
780                    for key in acct.storage.keys() {
781                        unread_storage_reads.remove(&(*addr, *key));
782                    }
783                }
784            }
785
786            // Mark pure-access accounts touched during the withdrawal/request phase.
787            // All withdrawal recipients (including 0-amount) are marked because the
788            // BAL recorder calls extend_touched_addresses for them, even though
789            // process_withdrawals only calls get_account_mut for amount > 0.
790            if !unaccessed_pure_accounts.is_empty() {
791                if let Some(withdrawals) = &block.body.withdrawals {
792                    for w in withdrawals {
793                        unaccessed_pure_accounts.remove(&w.address);
794                    }
795                }
796                for canon in post_block_bal.accounts() {
797                    // EIP-7928: only user-tx touches legitimize a bare SYSTEM_ADDRESS entry.
798                    if canon.address == SYSTEM_ADDRESS {
799                        continue;
800                    }
801                    unaccessed_pure_accounts.remove(&canon.address);
802                }
803            }
804
805            Self::validate_bal_covers(
806                &db.current_accounts_state,
807                &post_block_bal,
808                &bal,
809                &validation_index,
810                "the withdrawal/request phase",
811                withdrawal_bal_idx,
812            )?;
813
814            // EIP-7928 records withdrawal recipients regardless of amount, but
815            // `process_withdrawals` only loads the account for amount > 0.
816            if let Some(withdrawals) = &block.body.withdrawals {
817                for w in withdrawals {
818                    if !validation_index.addr_to_idx.contains_key(&w.address) {
819                        return Err(EvmError::Custom(format!(
820                            "BAL validation failed: withdrawal recipient {:?} is missing \
821                             from BAL",
822                            w.address
823                        )));
824                    }
825                }
826            }
827
828            // Any remaining unread storage_reads are extraneous BAL entries. The set is
829            // seeded and walks in a different order on every run, so name the lowest
830            // leftover: every node then reports the same entry for the same block.
831            if let Some((addr, key)) = unread_storage_reads.iter().min() {
832                let slot = ethrex_common::BigEndianHash::into_uint(key);
833                return Err(EvmError::Custom(format!(
834                    "BAL validation failed: storage_read for account {addr:?} slot \
835                     {slot} was never actually read during block execution"
836                )));
837            }
838
839            // Any remaining pure-access accounts were never accessed during execution.
840            // Lowest first, as above.
841            if let Some(addr) = unaccessed_pure_accounts.iter().min() {
842                return Err(EvmError::Custom(format!(
843                    "BAL validation failed: account {addr:?} has no mutations \
844                     and no storage reads but was never accessed during block execution"
845                )));
846            }
847
848            // Only the Engine API checked ordering. Two changes at one index pass the
849            // `find_exact_change_*` lookups (first entry) while synthesis uses the last.
850            bal.validate_ordering()
851                .map_err(|e| EvmError::Custom(format!("BAL validation failed: {e}")))?;
852
853            // EIP-7928 size cap: validated after execution so that transaction-level
854            // errors (e.g. gas allowance exceeded) take priority.
855            validate_block_access_list_size(&block.header, &chain_config, &bal)
856                .map_err(|e| EvmError::Custom(e.to_string()))?;
857
858            // EIP-8079: burn basis is post-refund Σ gas_spent.
859            // Each receipt's cumulative_gas_used += report.gas_spent (post-refund);
860            // the last receipt holds the block total. Compute before `receipts` is moved.
861            let post_refund_gas = receipts.last().map(|r| r.cumulative_gas_used).unwrap_or(0);
862            let burned_fees_par =
863                is_lstar.then(|| lstar_burned_fees(&chain_config, &block.header, post_refund_gas));
864
865            return Ok((
866                BlockExecutionResult {
867                    receipts,
868                    requests,
869                    block_gas_used,
870                    burned_fees: burned_fees_par,
871                    tx_gas_breakdowns,
872                },
873                None,
874            ));
875        }
876
877        // Sequential path (existing code, for block production and non-Amsterdam).
878        // The non-BAL caller always provides a Sender; the BAL path returned above.
879        // Surface a missing Sender as a normal error instead of panicking, so a
880        // future refactor that reshapes the BAL branch can't silently break the
881        // contract and bring down the executor thread.
882        let Some(merkleizer) = merkleizer else {
883            return Err(EvmError::Custom(
884                "sequential execution path called without a merkleizer Sender".to_string(),
885            ));
886        };
887        if is_amsterdam {
888            db.enable_bal_recording();
889            // Set index 0 for pre-execution phase (system contracts)
890            db.set_bal_index(0);
891        }
892
893        Self::prepare_block(block, db, vm_type, crypto)?;
894
895        // Compute base blob fee once for the entire block (block-invariant).
896        let base_blob_fee_per_gas =
897            get_base_fee_per_blob_gas(block.header.excess_blob_gas, &evm_config)?;
898
899        let mut shared_stack_pool = Vec::with_capacity(STACK_LIMIT);
900        // Holds at most one root memory buffer at a time (each tx pops one and reclaims one).
901        let mut shared_memory_pool = Vec::with_capacity(1);
902        // CREATE initcode buffers, reused across txs like the stacks.
903        let mut shared_initcode_pool = Vec::new();
904
905        let n_txs = block.body.transactions.len();
906        let mut receipts = Vec::with_capacity(n_txs);
907        let mut tx_gas_breakdowns: Vec<TxGasBreakdown> = Vec::with_capacity(n_txs);
908        // Cumulative gas for receipts (POST-REFUND per EIP-7778)
909        let mut cumulative_gas_used = 0_u64;
910        // Block gas accounting (PRE-REFUND for Amsterdam+ per EIP-7778)
911        let mut block_gas_used = 0_u64;
912        // EIP-8037 (Amsterdam+): track regular and state gas separately for block-level max()
913        let mut block_regular_gas_used = 0_u64;
914        let mut block_state_gas_used = 0_u64;
915        // Starts at 2 to account for the two precompile calls done in `Self::prepare_block`.
916        // The value itself can be safely changed.
917        let mut tx_since_last_flush = 2;
918
919        for (tx_idx, (tx, tx_sender)) in transactions_with_sender.into_iter().enumerate() {
920            // Pre-tx gas limit guard:
921            // Pre-Amsterdam: reject tx if cumulative post-refund gas + tx.gas > block limit.
922            // Amsterdam+: skip — EIP-8037's 2D gas model means cumulative gas (regular +
923            // state) can legally exceed the block gas limit as long as
924            // max(sum_regular, sum_state) stays within it. Block-level overflow is
925            // detected post-execution.
926            if !is_amsterdam {
927                check_gas_limit(cumulative_gas_used, tx.gas_limit(), block.header.gas_limit)?;
928            }
929
930            // EIP-8037 (Amsterdam+, PR #2703): per-tx 2D inclusion check.
931            if is_amsterdam {
932                check_2d_gas_allowance(
933                    tx,
934                    block_regular_gas_used,
935                    block_state_gas_used,
936                    block.header.gas_limit,
937                )?;
938            }
939
940            // Set BAL index for this transaction (1-indexed per EIP-7928)
941            if is_amsterdam {
942                let bal_index = u32::try_from(tx_idx + 1).unwrap_or(u32::MAX);
943                db.set_bal_index(bal_index);
944
945                // Record tx sender for BAL. The recipient is recorded when the prepare
946                // region loads it (default_hook), per the EIP-7928 v7.1.0 update: an
947                // EIP-7702 auth halt before the recipient load must exclude it.
948                if let Some(recorder) = db.bal_recorder_mut() {
949                    recorder.record_touched_address(tx_sender);
950                }
951            }
952
953            let report = Self::execute_tx_in_block(
954                tx,
955                tx_sender,
956                &block.header,
957                db,
958                vm_type,
959                base_blob_fee_per_gas,
960                &mut shared_stack_pool,
961                &mut shared_memory_pool,
962                &mut shared_initcode_pool,
963                false,
964                crypto,
965                evm_config,
966                chain_id,
967                stateless_validator,
968            )?;
969
970            tx_gas_breakdowns.push(TxGasBreakdown::from_report(
971                tx_idx,
972                tx.hash(crypto),
973                &report,
974            ));
975
976            if queue_length.load(Ordering::Relaxed) == 0 && tx_since_last_flush > 5 {
977                LEVM::send_state_transitions_tx(&merkleizer, db, queue_length)?;
978                tx_since_last_flush = 0;
979            } else {
980                tx_since_last_flush += 1;
981            }
982
983            // EIP-7778: gas_spent (POST-REFUND) for receipt cumulative_gas_used
984            cumulative_gas_used += report.gas_spent;
985
986            // EIP-8037 (Amsterdam+): block_gas_used = max(sum_regular, sum_state)
987            // For pre-Amsterdam, state_gas_used is always 0 so gas_used == regular_gas.
988            let tx_state_gas = report.state_gas_used;
989            let tx_regular_gas = report.gas_used.saturating_sub(tx_state_gas);
990            block_regular_gas_used = block_regular_gas_used.saturating_add(tx_regular_gas);
991            block_state_gas_used = block_state_gas_used.saturating_add(tx_state_gas);
992
993            if is_amsterdam {
994                // Amsterdam+: block gas = max(regular_sum, state_sum)
995                block_gas_used = block_regular_gas_used.max(block_state_gas_used);
996
997                // DoS protection: early exit if either regular or state gas exceeds the limit.
998                // Since block_gas_used = max(regular, state), if either component exceeds
999                // the limit, we know the block is invalid and can safely reject without
1000                // violating EIP-8037 semantics.
1001                if block_regular_gas_used > block.header.gas_limit
1002                    || block_state_gas_used > block.header.gas_limit
1003                {
1004                    return Err(EvmError::Transaction(format!(
1005                        "Gas allowance exceeded: Block gas used overflow: \
1006                         block_gas_used {block_gas_used} > block_gas_limit {}",
1007                        block.header.gas_limit
1008                    )));
1009                }
1010            } else {
1011                block_gas_used = block_gas_used.saturating_add(report.gas_used);
1012            }
1013
1014            let mut receipt = Receipt::new(
1015                tx.tx_type(),
1016                matches!(report.result, TxResult::Success),
1017                cumulative_gas_used,
1018                report.logs,
1019            );
1020
1021            // For frame transactions, propagate payer and per-frame receipts
1022            if matches!(tx, Transaction::FrameTransaction(_)) {
1023                receipt.payer = report.payer_address;
1024                receipt.frame_receipts = frame_receipts_from(report.frame_results);
1025            }
1026
1027            receipts.push(receipt);
1028        }
1029
1030        // EIP-7778 (Amsterdam+): block-level gas overflow check.
1031        // Per-tx checks are skipped for Amsterdam because block gas is computed
1032        // from pre-refund values; overflow can only be detected after execution.
1033        if is_amsterdam && block_gas_used > block.header.gas_limit {
1034            return Err(EvmError::Transaction(format!(
1035                "Gas allowance exceeded: Block gas used overflow: \
1036                 block_gas_used {block_gas_used} > block_gas_limit {}",
1037                block.header.gas_limit
1038            )));
1039        }
1040
1041        #[cfg(feature = "perf_opcode_timings")]
1042        {
1043            let mut timings = OPCODE_TIMINGS.lock().expect("poison");
1044            timings.inc_tx_count(receipts.len());
1045            timings.inc_block_count();
1046            ::tracing::info!("{}", timings.info_pretty());
1047            let precompiles_timings = PRECOMPILES_TIMINGS.lock().expect("poison");
1048            ::tracing::info!("{}", precompiles_timings.info_pretty());
1049        }
1050
1051        if queue_length.load(Ordering::Relaxed) == 0 {
1052            LEVM::send_state_transitions_tx(&merkleizer, db, queue_length)?;
1053        }
1054
1055        // Set BAL index for post-execution phase (withdrawals + requests); see the
1056        // ordering note on the sequential path above.
1057        if is_amsterdam {
1058            let post_tx_index =
1059                u32::try_from(block.body.transactions.len() + 1).unwrap_or(u32::MAX);
1060            db.set_bal_index(post_tx_index);
1061
1062            // Record ALL withdrawal recipients for BAL per EIP-7928
1063            if let Some(withdrawals) = &block.body.withdrawals
1064                && let Some(recorder) = db.bal_recorder_mut()
1065            {
1066                recorder.extend_touched_addresses(withdrawals.iter().map(|w| w.address));
1067            }
1068        }
1069
1070        // Withdrawals precede the request system calls; see the ordering note on the
1071        // sequential path above.
1072        if let Some(withdrawals) = &block.body.withdrawals {
1073            Self::process_withdrawals(db, withdrawals)?;
1074        }
1075
1076        // TODO: I don't like deciding the behavior based on the VMType here.
1077        // TODO2: Revise this, apparently extract_all_requests_levm is not called
1078        // in L2 execution, but its implementation behaves differently based on this.
1079        let requests = match vm_type {
1080            VMType::L1 => extract_all_requests_levm(&receipts, db, &block.header, vm_type, crypto)?,
1081            VMType::L2(_) => Default::default(),
1082        };
1083        LEVM::send_state_transitions_tx(&merkleizer, db, queue_length)?;
1084
1085        // Extract BAL if recording was enabled
1086        let bal = db.take_bal();
1087
1088        Ok((
1089            BlockExecutionResult {
1090                receipts,
1091                requests,
1092                block_gas_used,
1093                // cumulative_gas_used is accumulated as += report.gas_spent (post-refund).
1094                burned_fees: is_lstar
1095                    .then(|| lstar_burned_fees(&chain_config, &block.header, cumulative_gas_used)),
1096                tx_gas_breakdowns,
1097            },
1098            bal,
1099        ))
1100    }
1101
1102    ///
1103    /// For each account in the BAL, extracts the **final** post-block state
1104    /// (highest `block_access_index` entry per field) and builds an AccountUpdate.
1105    /// State comes entirely from the BAL — no execution needed.
1106    #[cfg(feature = "rayon")]
1107    fn bal_to_account_updates(
1108        bal: &BlockAccessList,
1109        store: &dyn Database,
1110    ) -> Result<Vec<AccountUpdate>, EvmError> {
1111        use ethrex_common::types::AccountInfo;
1112
1113        let mut updates = Vec::new();
1114
1115        // Batch prefetch all accounts with writes so per-account lookups are cache hits
1116        let write_addrs: Vec<Address> = bal
1117            .accounts()
1118            .iter()
1119            .filter(|ac| {
1120                !ac.balance_changes.is_empty()
1121                    || !ac.nonce_changes.is_empty()
1122                    || !ac.code_changes.is_empty()
1123                    || !ac.storage_changes.is_empty()
1124            })
1125            .map(|ac| ac.address)
1126            .collect();
1127        // Best-effort: the loop below reads every account it needs through
1128        // `get_account_state`, so a failed warm costs cache hits and nothing else.
1129        // Propagating it here would let a prefetch turn into a block-processing error.
1130        let _ = store.prefetch_accounts(&write_addrs);
1131
1132        for acct_changes in bal.accounts() {
1133            let addr = acct_changes.address;
1134
1135            // Skip accounts with only reads and no writes
1136            let has_writes = !acct_changes.balance_changes.is_empty()
1137                || !acct_changes.nonce_changes.is_empty()
1138                || !acct_changes.code_changes.is_empty()
1139                || !acct_changes.storage_changes.is_empty();
1140            if !has_writes {
1141                continue;
1142            }
1143
1144            // Load pre-state for unchanged fields (cache hit after prefetch)
1145            let prestate = store
1146                .get_account_state(addr)
1147                .map_err(|e| EvmError::Custom(format!("bal_to_account_updates: {e}")))?;
1148
1149            // Final balance: last entry (highest index) or prestate
1150            let balance = acct_changes
1151                .balance_changes
1152                .last()
1153                .map(|c| c.post_balance)
1154                .unwrap_or(prestate.balance);
1155
1156            // Final nonce: last entry or prestate
1157            let nonce = acct_changes
1158                .nonce_changes
1159                .last()
1160                .map(|c| c.post_nonce)
1161                .unwrap_or(prestate.nonce);
1162
1163            // Final code: last entry or prestate
1164            let (code_hash, code) = if let Some(c) = acct_changes.code_changes.last() {
1165                code_from_bal(&c.new_code)
1166            } else {
1167                (prestate.code_hash, None)
1168            };
1169
1170            // Storage: per slot, last entry (highest index)
1171            let mut added_storage = SeededHashMap::with_capacity_and_hasher(
1172                acct_changes.storage_changes.len(),
1173                Default::default(),
1174            );
1175            for slot_change in &acct_changes.storage_changes {
1176                if let Some(last) = slot_change.slot_changes.last() {
1177                    let key = ethrex_common::utils::u256_to_h256(slot_change.slot);
1178                    added_storage.insert(key, last.post_value);
1179                }
1180            }
1181
1182            // Detect account removal (EIP-161): post-state empty but pre-state existed
1183            let post_empty = balance.is_zero() && nonce == 0 && code_hash == *EMPTY_KECCAK_HASH;
1184            let pre_empty = prestate.balance.is_zero()
1185                && prestate.nonce == 0
1186                && prestate.code_hash == *EMPTY_KECCAK_HASH;
1187            let removed = post_empty && !pre_empty;
1188
1189            let balance_changed = acct_changes
1190                .balance_changes
1191                .last()
1192                .is_some_and(|c| c.post_balance != prestate.balance);
1193            let nonce_changed = acct_changes
1194                .nonce_changes
1195                .last()
1196                .is_some_and(|c| c.post_nonce != prestate.nonce);
1197            let code_changed = acct_changes.code_changes.last().is_some();
1198            let acc_info_updated = balance_changed || nonce_changed || code_changed;
1199
1200            if !removed && !acc_info_updated && added_storage.is_empty() {
1201                continue;
1202            }
1203
1204            let info = if acc_info_updated {
1205                Some(AccountInfo {
1206                    code_hash,
1207                    balance,
1208                    nonce,
1209                })
1210            } else {
1211                None
1212            };
1213
1214            let update = AccountUpdate {
1215                address: addr,
1216                removed,
1217                info,
1218                code,
1219                added_storage,
1220                // EIP-6780 restricts SELFDESTRUCT to the creation tx, so
1221                // cross-tx storage wipes can't happen. For the rare same-tx
1222                // destroy+recreate case at a reused address, EIP-7928 records
1223                // individual slot zeroing in storage_changes (each old slot → 0),
1224                // so `added_storage` already contains those zeroed entries and
1225                // the trie update is correct without setting removed_storage.
1226                removed_storage: false,
1227            };
1228            updates.push(update);
1229        }
1230
1231        Ok(updates)
1232    }
1233
1234    /// Validates a system phase the per-tx shadow recorder doesn't cover, against
1235    /// `phase_bal`, the BAL that phase's own recorder built. Every canonical entry must
1236    /// be in the supplied BAL with matching values, and nothing may be declared at
1237    /// `phase_idx` that the phase didn't do. The state root can't catch a mismatch:
1238    /// the parallel merkleizer derives it from the supplied BAL.
1239    #[cfg(feature = "rayon")]
1240    fn validate_bal_covers(
1241        cache: &CacheDB,
1242        phase_bal: &BlockAccessList,
1243        bal: &BlockAccessList,
1244        index: &BalAddressIndex,
1245        phase: &str,
1246        phase_idx: u32,
1247    ) -> Result<(), EvmError> {
1248        // No SYSTEM_ADDRESS exemption: the recorder already drops it inside system calls,
1249        // so it only reaches `phase_bal` when something else changed it.
1250        for canon in phase_bal.accounts() {
1251            let addr = canon.address;
1252            let Some(supplied) = index
1253                .addr_to_idx
1254                .get(&addr)
1255                .and_then(|i| bal.accounts().get(*i))
1256            else {
1257                return Err(EvmError::Custom(format!(
1258                    "BAL validation failed: account {addr:?} was accessed during {phase} \
1259                     but is missing from BAL"
1260                )));
1261            };
1262
1263            // By value, not membership, so a write demoted to a read is caught.
1264            for canon_slot in &canon.storage_changes {
1265                let slot = canon_slot.slot;
1266                let supplied_changes = supplied
1267                    .storage_changes
1268                    .binary_search_by(|sc| sc.slot.cmp(&slot))
1269                    .ok()
1270                    .and_then(|i| supplied.storage_changes.get(i))
1271                    .map(|sc| sc.slot_changes.as_slice())
1272                    .unwrap_or_default();
1273                for canon_change in &canon_slot.slot_changes {
1274                    let idx = canon_change.block_access_index;
1275                    match find_exact_change_storage(supplied_changes, idx) {
1276                        Some(v) if v == canon_change.post_value => {}
1277                        Some(v) => {
1278                            return Err(EvmError::Custom(format!(
1279                                "BAL validation failed: account {addr:?} storage slot {slot} \
1280                                 was written during {phase} to {} but the BAL declares {v} \
1281                                 at index {idx}",
1282                                canon_change.post_value
1283                            )));
1284                        }
1285                        None => {
1286                            return Err(EvmError::Custom(format!(
1287                                "BAL validation failed: account {addr:?} storage slot {slot} \
1288                                 was written during {phase} to {} but the BAL declares no \
1289                                 change at index {idx}",
1290                                canon_change.post_value
1291                            )));
1292                        }
1293                    }
1294                }
1295            }
1296
1297            for canon_change in &canon.balance_changes {
1298                let idx = canon_change.block_access_index;
1299                let expected = canon_change.post_balance;
1300                if find_exact_change_balance(&supplied.balance_changes, idx) != Some(expected) {
1301                    return Err(EvmError::Custom(format!(
1302                        "BAL validation failed: account {addr:?} balance changed to {expected} \
1303                         during {phase} but the BAL declares no matching change at index {idx}"
1304                    )));
1305                }
1306            }
1307            for canon_change in &canon.nonce_changes {
1308                let idx = canon_change.block_access_index;
1309                let expected = canon_change.post_nonce;
1310                if find_exact_change_nonce(&supplied.nonce_changes, idx) != Some(expected) {
1311                    return Err(EvmError::Custom(format!(
1312                        "BAL validation failed: account {addr:?} nonce changed to {expected} \
1313                         during {phase} but the BAL declares no matching change at index {idx}"
1314                    )));
1315                }
1316            }
1317            for canon_change in &canon.code_changes {
1318                let idx = canon_change.block_access_index;
1319                if find_exact_change_code(&supplied.code_changes, idx)
1320                    != Some(&canon_change.new_code)
1321                {
1322                    return Err(EvmError::Custom(format!(
1323                        "BAL validation failed: account {addr:?} code changed during {phase} \
1324                         but the BAL declares no matching change at index {idx}"
1325                    )));
1326                }
1327            }
1328
1329            // A read shows up as a change if a later transaction writes the slot.
1330            for &slot in &canon.storage_reads {
1331                if supplied.storage_reads.binary_search(&slot).is_err()
1332                    && supplied
1333                        .storage_changes
1334                        .binary_search_by(|sc| sc.slot.cmp(&slot))
1335                        .is_err()
1336                {
1337                    return Err(EvmError::Custom(format!(
1338                        "BAL validation failed: storage slot {slot} of account {addr:?} was \
1339                         read during {phase} but is missing from BAL"
1340                    )));
1341                }
1342            }
1343        }
1344
1345        // Nothing extra at the phase's own index: a no-op change there matches the
1346        // cache, so `validate_bal_withdrawal_index` alone accepts it.
1347        let canonical: SeededHashMap<Address, &AccountChanges> = phase_bal
1348            .accounts()
1349            .iter()
1350            .map(|a| (a.address, a))
1351            .collect();
1352        for supplied in bal.accounts() {
1353            let addr = supplied.address;
1354            let canon = canonical.get(&addr).copied();
1355            for sc in &supplied.storage_changes {
1356                if find_exact_change_storage(&sc.slot_changes, phase_idx).is_none() {
1357                    continue;
1358                }
1359                let declared_canonically = canon.is_some_and(|c| {
1360                    c.storage_changes
1361                        .binary_search_by(|cs| cs.slot.cmp(&sc.slot))
1362                        .ok()
1363                        .and_then(|i| c.storage_changes.get(i))
1364                        .is_some_and(|cs| {
1365                            find_exact_change_storage(&cs.slot_changes, phase_idx).is_some()
1366                        })
1367                });
1368                if !declared_canonically {
1369                    return Err(EvmError::Custom(format!(
1370                        "BAL validation failed: account {addr:?} declares a storage change \
1371                         for slot {} at index {phase_idx} but {phase} did not write it",
1372                        sc.slot
1373                    )));
1374                }
1375            }
1376            if find_exact_change_balance(&supplied.balance_changes, phase_idx).is_some()
1377                && canon.is_none_or(|c| {
1378                    find_exact_change_balance(&c.balance_changes, phase_idx).is_none()
1379                })
1380            {
1381                return Err(EvmError::Custom(format!(
1382                    "BAL validation failed: account {addr:?} declares a balance change at \
1383                     index {phase_idx} but {phase} did not change its balance"
1384                )));
1385            }
1386            if find_exact_change_nonce(&supplied.nonce_changes, phase_idx).is_some()
1387                && canon
1388                    .is_none_or(|c| find_exact_change_nonce(&c.nonce_changes, phase_idx).is_none())
1389            {
1390                return Err(EvmError::Custom(format!(
1391                    "BAL validation failed: account {addr:?} declares a nonce change at \
1392                     index {phase_idx} but {phase} did not change its nonce"
1393                )));
1394            }
1395            if find_exact_change_code(&supplied.code_changes, phase_idx).is_some()
1396                && canon
1397                    .is_none_or(|c| find_exact_change_code(&c.code_changes, phase_idx).is_none())
1398            {
1399                return Err(EvmError::Custom(format!(
1400                    "BAL validation failed: account {addr:?} declares a code change at \
1401                     index {phase_idx} but {phase} did not change its code"
1402                )));
1403            }
1404        }
1405
1406        // Backstop from the cache, independent of the recorder. The cache is seeded, so a
1407        // rejection names the lowest offending account and slot.
1408        first_failure_by_key(cache, |addr, acct| {
1409            if *addr == SYSTEM_ADDRESS {
1410                return Ok(());
1411            }
1412            let supplied = match index
1413                .addr_to_idx
1414                .get(addr)
1415                .and_then(|i| bal.accounts().get(*i))
1416            {
1417                Some(a) => a,
1418                // Cached storage means a real access; info-only probes load no slots.
1419                None if !acct.storage.is_empty() => {
1420                    return Err(EvmError::Custom(format!(
1421                        "BAL validation failed: account {addr:?} was accessed during {phase} \
1422                         but is missing from BAL"
1423                    )));
1424                }
1425                None => return Ok(()),
1426            };
1427            // storage_reads is strictly ascending, as at the per-tx check; binary
1428            // search fails closed on an unsorted list, so release stays sound.
1429            debug_assert!(
1430                supplied.storage_reads.windows(2).all(|w| w[0] < w[1]),
1431                "storage_reads must be strictly ascending for binary_search"
1432            );
1433            first_failure_by_key(&acct.storage, |key, _| {
1434                let slot = ethrex_common::BigEndianHash::into_uint(key);
1435                if supplied.storage_reads.binary_search(&slot).is_err()
1436                    && supplied
1437                        .storage_changes
1438                        .binary_search_by(|sc| sc.slot.cmp(&slot))
1439                        .is_err()
1440                {
1441                    return Err(EvmError::Custom(format!(
1442                        "BAL validation failed: storage slot {slot} of account {addr:?} was \
1443                         read during {phase} but is missing from BAL"
1444                    )));
1445                }
1446                Ok(())
1447            })
1448        })
1449    }
1450
1451    /// Eager BAL prefix seed — used only by the outer DB path (parallel-execution
1452    /// fallback recovery and post-tx outer seed before request extraction).
1453    /// Per-tx parallel execution uses `LazyBalCursor` in `execute_block_parallel`;
1454    /// see also `seed_one_address_info_from_bal` and `seed_one_storage_slot_from_bal`
1455    /// in `ethrex_levm::db::gen_db`.
1456    ///
1457    /// Pre-seed a GeneralizedDatabase with BAL-derived state for a specific tx.
1458    ///
1459    /// For each BAL-modified account, applies accumulated diffs with
1460    /// `block_access_index <= max_idx` on top of the loaded pre-block state.
1461    /// This matches geth's approach: each parallel tx sees the state as if
1462    /// all previous txs had already executed (via BAL intermediate values).
1463    ///
1464    /// `max_idx` is the BAL block_access_index of the last tx whose effects
1465    /// should be visible. BAL indexing: 0 = system calls, 1 = tx 0, 2 = tx 1, ...
1466    /// For tx at index `i`, pass `max_idx = i` (diffs with index <= i = system + txs 0..i-1).
1467    #[cfg(feature = "rayon")]
1468    fn seed_db_from_bal(
1469        db: &mut GeneralizedDatabase,
1470        bal: &BlockAccessList,
1471        max_idx: u32,
1472        accounts_by_min_index: &[(u32, usize)],
1473    ) -> Result<(), EvmError> {
1474        let end = accounts_by_min_index.partition_point(|(min_idx, _)| *min_idx <= max_idx);
1475        let bal_accounts = bal.accounts();
1476        for &(_, acct_idx) in &accounts_by_min_index[..end] {
1477            seed_one_address_info_from_bal(db, bal, acct_idx, max_idx)
1478                .map_err(|e| EvmError::Custom(format!("seed_db_from_bal: {e}")))?;
1479
1480            let acct_changes = &bal_accounts[acct_idx];
1481            if acct_changes.storage_changes.is_empty() {
1482                continue;
1483            }
1484            let any_storage = acct_changes.storage_changes.iter().any(|sc| {
1485                sc.slot_changes
1486                    .first()
1487                    .is_some_and(|c| c.block_access_index <= max_idx)
1488            });
1489            if !any_storage {
1490                continue;
1491            }
1492            let addr = acct_changes.address;
1493            if !db.current_accounts_state.contains_key(&addr) {
1494                db.get_account(addr)
1495                    .map_err(|e| EvmError::Custom(format!("seed storage: {e}")))?;
1496            }
1497            let acc = db
1498                .get_account_mut(addr)
1499                .map_err(|e| EvmError::Custom(format!("seed storage mut: {e}")))?;
1500            for sc in &acct_changes.storage_changes {
1501                if let Some(value) = post_value_at_or_before(sc, max_idx) {
1502                    acc.storage
1503                        .insert(ethrex_common::utils::u256_to_h256(sc.slot), value);
1504                }
1505            }
1506        }
1507        Ok(())
1508    }
1509
1510    /// Execute block transactions in parallel using BAL-derived state.
1511    /// Only called for Amsterdam+ blocks when the header BAL is available.
1512    ///
1513    /// Each tx runs independently on its own database pre-seeded with BAL
1514    /// intermediate state (geth-style). State for the merkleizer comes from
1515    /// `bal_to_account_updates`, not from tx execution.
1516    #[cfg(feature = "rayon")]
1517    #[allow(clippy::too_many_arguments, clippy::type_complexity)]
1518    fn execute_block_parallel(
1519        block: &Block,
1520        txs_with_sender: &[(&Transaction, Address)],
1521        db: &mut GeneralizedDatabase,
1522        vm_type: VMType,
1523        bal: Arc<BlockAccessList>,
1524        merkleizer: Option<&Sender<Vec<AccountUpdate>>>,
1525        queue_length: &AtomicUsize,
1526        system_seed: Arc<CacheDB>,
1527        pre_block_bal: &BlockAccessList,
1528        crypto: &dyn Crypto,
1529        validation_index: Arc<BalAddressIndex>,
1530        stateless_validator: Option<&dyn StatelessValidator>,
1531    ) -> Result<
1532        (
1533            Vec<Receipt>,
1534            u64,
1535            SeededHashSet<(Address, H256)>,
1536            SeededHashSet<Address>,
1537            Vec<TxGasBreakdown>,
1538        ),
1539        EvmError,
1540    > {
1541        let store = db.store.clone();
1542        let header = &block.header;
1543        let n_txs = txs_with_sender.len();
1544        // BAL-seeded parallel execution is only reachable on Amsterdam+ (callers
1545        // gate on is_amsterdam before providing a header BAL). We recompute the
1546        // flag here to gate the 2D inclusion check explicitly, keeping the
1547        // invariant checkable rather than implicit.
1548        let chain_config = store.get_chain_config()?;
1549        let is_amsterdam = chain_config.is_amsterdam_activated(header.timestamp);
1550        // Block-invariant EVM config + chain id, computed once and shared across the
1551        // parallel workers (both are `Copy` + `Send`/`Sync`).
1552        let evm_config = EVMConfig::new_from_chain_config(&chain_config, header);
1553        let chain_id = chain_config.chain_id;
1554        // Block-invariant base blob fee, computed once and shared across workers.
1555        let base_blob_fee_per_gas = get_base_fee_per_blob_gas(header.excess_blob_gas, &evm_config)?;
1556        debug_assert!(
1557            is_amsterdam,
1558            "execute_block_parallel invoked on non-Amsterdam block"
1559        );
1560
1561        // 1. Convert BAL → AccountUpdates and send to merkleizer (single batch).
1562        // Skipped when the caller merkleizes optimistically from the input BAL; the
1563        // conversion is then redundant work (and does pre-state reads we don't need).
1564        if let Some(merkleizer) = merkleizer {
1565            let account_updates = Self::bal_to_account_updates(&bal, store.as_ref())?;
1566            merkleizer
1567                .send(account_updates)
1568                .map_err(|e| EvmError::Custom(format!("merkleizer send failed: {e}")))?;
1569            queue_length.fetch_add(1, Ordering::Relaxed);
1570        }
1571
1572        // Build a checklist of all BAL storage_reads. Entries are removed as they
1573        // are actually read during execution phases. Anything left over is extraneous.
1574        let mut unread_storage_reads: SeededHashSet<(Address, H256)> = Default::default();
1575        // Build a checklist of BAL "pure-access" accounts: entries with no mutations
1576        // and no storage reads. These must be accessed via load_account during execution.
1577        let mut unaccessed_pure_accounts: SeededHashSet<Address> = Default::default();
1578        for acct in bal.accounts() {
1579            for &slot in &acct.storage_reads {
1580                let key = ethrex_common::utils::u256_to_h256(slot);
1581                unread_storage_reads.insert((acct.address, key));
1582            }
1583            let is_pure = acct.storage_changes.is_empty()
1584                && acct.storage_reads.is_empty()
1585                && acct.balance_changes.is_empty()
1586                && acct.nonce_changes.is_empty()
1587                && acct.code_changes.is_empty();
1588            if is_pure {
1589                unaccessed_pure_accounts.insert(acct.address);
1590            }
1591        }
1592
1593        // Mark pure-access accounts that were touched during system calls, per the
1594        // recorder: `system_seed` also holds unrecorded internal loads.
1595        // EIP-7928: SYSTEM_ADDRESS is excluded from BAL entries created by system calls
1596        // (only user-tx touches legitimize it). Keep it in `unaccessed_pure_accounts` so a
1597        // BAL that carries a bare SYSTEM_ADDRESS entry without a corresponding user-tx
1598        // touch is rejected as extraneous.
1599        for canon in pre_block_bal.accounts() {
1600            if canon.address == SYSTEM_ADDRESS {
1601                continue;
1602            }
1603            unaccessed_pure_accounts.remove(&canon.address);
1604        }
1605
1606        // Mark storage reads that occurred during system calls (prepare_block).
1607        unread_storage_reads.retain(|(addr, key)| {
1608            !system_seed
1609                .get(addr)
1610                .is_some_and(|a| a.storage.contains_key(key))
1611        });
1612
1613        // Already-owned Arcs from the caller; per-thread/per-tx uses below are pointer clones.
1614        let arc_bal = bal;
1615        let arc_idx = validation_index;
1616
1617        // 2. Execute all txs in parallel (embarrassingly parallel, BAL-seeded).
1618        //    BAL validation runs INSIDE the par_iter closure (parallel) but its
1619        //    errors are deferred via Option<EvmError> so the post-par_iter
1620        //    gas-limit check still takes priority (GAS_USED_OVERFLOW must beat
1621        //    BAL mismatch on blocks exceeding the gas limit; the BAL is built
1622        //    assuming rejected txs, so miner balance in the BAL won't match
1623        //    execution that ran all txs).
1624        //
1625        //    The closure also precomputes the small (Vec<(Address, H256)>,
1626        //    Vec<Address>) inputs needed to update the shared
1627        //    `unread_storage_reads` / `unaccessed_pure_accounts` sets, so the
1628        //    serial pass after par_iter is just hash-set ops; current_state
1629        //    and codes never cross the rayon boundary.
1630        type TxExecResult = (
1631            usize,
1632            TxType,
1633            ExecutionReport,
1634            SeededHashSet<Address>, // accessed_accounts tracker (coarse)
1635            Vec<(Address, H256)>,   // reads_satisfied: (addr, slot) loaded during this tx
1636            Vec<Address>,           // destroyed: accounts selfdestructed during this tx
1637            Option<EvmError>,       // deferred BAL validation error
1638        );
1639
1640        // Ordered gas admission (step 3 below) can only reject a block once every
1641        // transaction has been executed and its report retained. Two checks make an
1642        // over-limit block cheap to reject, and both hold for any block that could be
1643        // valid.
1644        //
1645        // Before any execution: the transactions' minimum gas alone may already rule
1646        // the block out.
1647        check_minimum_block_work(
1648            txs_with_sender.iter().map(|(tx, sender)| (*tx, *sender)),
1649            chain_config.fork(header.timestamp),
1650            header.gas_limit,
1651        )?;
1652
1653        // During execution: stop starting transactions once the finished ones are over
1654        // the gas limit, which bounds both the work done and the reports retained.
1655        let completed_gas = CompletedGas::default();
1656
1657        let exec_results: Result<Vec<TxExecResult>, EvmError> = (0..n_txs)
1658            .into_par_iter()
1659            .map(|tx_idx| -> Result<_, EvmError> {
1660                completed_gas.check(header.gas_limit)?;
1661                let (tx, sender) = &txs_with_sender[tx_idx];
1662                // Small capacity hint — per-tx DBs materialize only touched accounts via lazy_bal cursor.
1663                let mut tx_db = GeneralizedDatabase::new_with_shared_base_and_capacity(
1664                    store.clone(),
1665                    system_seed.clone(),
1666                    32,
1667                );
1668                tx_db.lazy_bal = Some(LazyBalCursor {
1669                    bal: arc_bal.clone(),
1670                    bal_index: u32::try_from(tx_idx + 1).unwrap_or(u32::MAX),
1671                    index: arc_idx.clone(),
1672                });
1673                // Small capacity: parallel txs rarely nest >8 call frames, and
1674                // over-allocating per-tx wastes memory across many rayon tasks.
1675                let mut stack_pool = Vec::with_capacity(8);
1676                // Holds at most one root memory buffer (popped + reclaimed per tx).
1677                let mut memory_pool = Vec::with_capacity(1);
1678                // Per-tx like the pools above: buffers are reused only within this tx.
1679                let mut initcode_pool = Vec::new();
1680
1681                // Enable accessed_accounts tracker (coarse) for `unaccessed_pure_accounts`
1682                // diagnostics. Safe to over-report: used only to REMOVE entries from a
1683                // extraneous-entry checklist.
1684                tx_db.accessed_accounts = Some(SeededHashSet::with_capacity_and_hasher(
1685                    16,
1686                    Default::default(),
1687                ));
1688
1689                // Enable a shadow BAL recorder on this per-tx db. The recorder is gated
1690                // at the same gas-check points as the builder path, giving us an exact
1691                // EIP-7928 access signal (missing-account and missing-storage-read
1692                // detection). Per-tx recorder — no cross-task contention.
1693                tx_db.enable_bal_recording();
1694                let bal_index = u32::try_from(tx_idx + 1).unwrap_or(u32::MAX);
1695                tx_db.set_bal_index(bal_index);
1696                // Record tx sender for BAL. The recipient is recorded when the prepare
1697                // region loads it (default_hook), per the EIP-7928 v7.1.0 update: an
1698                // EIP-7702 auth halt before the recipient load must exclude it.
1699                if let Some(recorder) = tx_db.bal_recorder_mut() {
1700                    recorder.record_touched_address(*sender);
1701                }
1702
1703                let report = LEVM::execute_tx_in_block(
1704                    tx,
1705                    *sender,
1706                    header,
1707                    &mut tx_db,
1708                    vm_type,
1709                    base_blob_fee_per_gas,
1710                    &mut stack_pool,
1711                    &mut memory_pool,
1712                    &mut initcode_pool,
1713                    false,
1714                    crypto,
1715                    evm_config,
1716                    chain_id,
1717                    stateless_validator,
1718                )?;
1719                let mut report = report;
1720
1721                // Block validation builds receipts from `logs` and never reads the
1722                // top-level return data, so holding it until the collect below would
1723                // retain a per-transaction buffer nothing downstream consumes.
1724                report.output = Bytes::new();
1725
1726                // Same regular/state split as the ordered admission loop below.
1727                completed_gas.record(
1728                    report.gas_used.saturating_sub(report.state_gas_used),
1729                    report.state_gas_used,
1730                    header.gas_limit,
1731                )?;
1732
1733                let current_state = std::mem::take(&mut tx_db.current_accounts_state);
1734                let codes = std::mem::take(&mut tx_db.codes);
1735                let mut tracked = tx_db.accessed_accounts.take().unwrap_or_default();
1736                // `tx_initial` maps each genuinely-written slot to its start-of-tx
1737                // value (see `take_tx_initial_storage`). It serves the storage checks two
1738                // ways: its keys are the written slots, and its values are the seeded
1739                // pre-state the validator would otherwise re-read from the store. The
1740                // recorder already stores it as a map of this type, so it is moved out here
1741                // (no per-tx rebuild).
1742                let (shadow_touched, shadow_reads, tx_initial) = tx_db
1743                    .bal_recorder
1744                    .take()
1745                    .map(|mut r| {
1746                        (
1747                            r.take_touched_addresses(),
1748                            r.take_storage_reads(),
1749                            r.take_tx_initial_storage(),
1750                        )
1751                    })
1752                    .unwrap_or_default();
1753                // Genuine pure reads = observed reads minus written slots. A slot read
1754                // then written stays in `shadow_reads` (the recorder promotes it only in
1755                // `build()`, which the shadow path skips) and SSTORE always records an
1756                // implicit read — so this subtraction is required for correctness, not
1757                // just efficiency: without it a written slot would be treated as a pure
1758                // read and its BAL entry skipped. The execution->BAL check skips the
1759                // DB-backed seeded lookup for genuine reads: a read never diverges from
1760                // its pre-tx value, so there is nothing to validate.
1761                let pure_reads: SeededHashSet<(Address, U256)> = shadow_reads
1762                    .iter()
1763                    .copied()
1764                    .filter(|k| !tx_initial.contains_key(k))
1765                    .collect();
1766
1767                // Precompute the per-tx inputs the serial pass uses to update
1768                // the shared unread_storage_reads set. Selfdestruct clears
1769                // storage from the final state, so a destroyed account's
1770                // slots are no longer visible in `current_state`. That does
1771                // NOT mean every BAL storage_reads entry for the account is
1772                // legitimate: a phantom (never-read) declared read must
1773                // still be rejected. Only slots the shadow recorder actually
1774                // observed (`shadow_reads`, the EIP-7928 access authority)
1775                // are folded into `reads_satisfied` below, once `destroyed`
1776                // is populated.
1777                // Rough avg storage slots per touched account; over-allocation
1778                // is cheap compared to 2-3 reallocations on the hot path.
1779                let mut reads_satisfied: Vec<(Address, H256)> =
1780                    Vec::with_capacity(current_state.len() * 4);
1781                // `destroyed` stays empty on the typical block (selfdestruct
1782                // is rare post-EIP-6780), so `Vec::new()` (no allocation) is
1783                // optimal here.
1784                let mut destroyed: Vec<Address> = Vec::new();
1785                for (addr, acct) in &current_state {
1786                    if matches!(
1787                        acct.status,
1788                        AccountStatus::Destroyed | AccountStatus::DestroyedModified
1789                    ) {
1790                        destroyed.push(*addr);
1791                    } else {
1792                        for key in acct.storage.keys() {
1793                            reads_satisfied.push((*addr, *key));
1794                        }
1795                    }
1796                }
1797                // A destroyed account's genuinely-read slots (per the shadow
1798                // recorder) still satisfy their BAL storage_reads entry even
1799                // though the storage itself is gone from `current_state`. A
1800                // slot the BAL merely *declares* as read but this tx never
1801                // actually touched is deliberately left unsatisfied here: it
1802                // stays in `unread_storage_reads` and is rejected by the
1803                // leftover-reads check as a phantom read.
1804                for (addr, slot) in &shadow_reads {
1805                    if destroyed.contains(addr) {
1806                        reads_satisfied.push((*addr, ethrex_common::utils::u256_to_h256(*slot)));
1807                    }
1808                }
1809
1810                // Run BAL validation inline. Errors are DEFERRED: stored in
1811                // Option<EvmError> so the serial gas-limit check below still
1812                // takes priority. Borrow current_state / codes during the
1813                // validation closure, then drop them before returning so
1814                // they don't cross the rayon boundary.
1815                let deferred_bal_err: Option<EvmError> = (|| -> Result<(), EvmError> {
1816                    let bal_idx = u32::try_from(tx_idx + 1).unwrap_or(u32::MAX);
1817                    let seed_idx = u32::try_from(tx_idx).unwrap_or(u32::MAX);
1818                    Self::validate_tx_execution(
1819                        bal_idx,
1820                        seed_idx,
1821                        &current_state,
1822                        &codes,
1823                        &arc_bal,
1824                        &arc_idx,
1825                        &system_seed,
1826                        &store,
1827                        &pure_reads,
1828                        &tx_initial,
1829                    )
1830                    .map_err(|e| match e {
1831                        // A store failure during validation is infrastructure, not a
1832                        // consensus mismatch — surface it as EvmError::DB so it isn't
1833                        // mislabelled "BAL validation failed".
1834                        BalValidationError::Database(msg) => {
1835                            EvmError::DB(format!("BAL validation for tx {tx_idx}: {msg}"))
1836                        }
1837                        BalValidationError::Mismatch(msg) => EvmError::Custom(format!(
1838                            "BAL validation failed for tx {tx_idx}: {msg}"
1839                        )),
1840                    })?;
1841
1842                    // EIP-7928 (Group B): missing-access detection via shadow recorder.
1843                    for addr in &shadow_touched {
1844                        if !arc_idx.addr_to_idx.contains_key(addr) {
1845                            return Err(EvmError::Custom(format!(
1846                                "BAL validation failed for tx {tx_idx}: account {addr:?} was \
1847                                 accessed during execution but is missing from BAL"
1848                            )));
1849                        }
1850                    }
1851                    for (addr, slot) in &shadow_reads {
1852                        let Some(&bal_acct_idx) = arc_idx.addr_to_idx.get(addr) else {
1853                            // Already caught by the touched-address check above.
1854                            continue;
1855                        };
1856                        let acct = &arc_bal.accounts()[bal_acct_idx];
1857                        let in_changes = acct
1858                            .storage_changes
1859                            .binary_search_by(|sc| sc.slot.cmp(slot))
1860                            .is_ok();
1861                        // storage_reads is validated strictly-ascending, so binary search
1862                        // (matches storage_changes above) instead of a linear scan. The
1863                        // import path doesn't call `validate_ordering`, so assert the
1864                        // precondition in debug builds; `binary_search` fails closed on
1865                        // an unsorted list (it can only miss a present slot -> over-reject,
1866                        // never accept a missing one), so release stays sound regardless.
1867                        debug_assert!(
1868                            acct.storage_reads.windows(2).all(|w| w[0] < w[1]),
1869                            "storage_reads must be strictly ascending for binary_search"
1870                        );
1871                        let in_reads = acct.storage_reads.binary_search(slot).is_ok();
1872                        if !in_changes && !in_reads {
1873                            return Err(EvmError::Custom(format!(
1874                                "BAL validation failed for tx {tx_idx}: storage slot {slot} of \
1875                                 account {addr:?} was read during execution but is missing from \
1876                                 BAL (no storage_changes or storage_reads entry)"
1877                            )));
1878                        }
1879                    }
1880                    Ok(())
1881                })()
1882                .err();
1883
1884                drop(current_state);
1885                drop(codes);
1886
1887                // The shadow BAL recorder's touched addresses are ethrex's exact
1888                // EELS `account_reads` analog: an address is recorded here iff EELS
1889                // would add it to `account_reads` (e.g. a CREATE target read by
1890                // `is_account_alive` before an OOG state-gas charge). The coarse
1891                // `accessed_accounts` tracker only sees `load_account` calls, which
1892                // can miss such a target when the frame OOGs before materializing it.
1893                // Fold the recorder touches into `tracked` so the pure-access
1894                // checklist is reduced by everything ethrex legitimately accessed.
1895                tracked.extend(shadow_touched.iter().copied());
1896
1897                Ok((
1898                    tx_idx,
1899                    tx.tx_type(),
1900                    report,
1901                    tracked,
1902                    reads_satisfied,
1903                    destroyed,
1904                    deferred_bal_err,
1905                ))
1906            })
1907            .collect();
1908
1909        let mut exec_results = exec_results?;
1910
1911        // `IndexedParallelIterator` (via `(0..n_txs).into_par_iter()`) preserves
1912        // source-index order through `.map().collect()`, so `exec_results` is
1913        // already sorted. The sort is kept as a defensive guard against a future
1914        // refactor swapping in an unordered iterator; `sort_unstable_by_key` on
1915        // an already-sorted slice is near-linear via pdqsort, so the cost is
1916        // negligible.
1917        exec_results.sort_unstable_by_key(|(idx, _, _, _, _, _, _)| *idx);
1918
1919        // 3. Gas limit check — must happen BEFORE BAL validation errors so that
1920        //    blocks exceeding the gas limit produce GAS_USED_OVERFLOW instead of
1921        //    a BAL mismatch error. EIP-8037 PR #2703: also enforce the per-tx
1922        //    2D inclusion check against running block totals.
1923        let mut block_regular_gas_used = 0_u64;
1924        let mut block_state_gas_used = 0_u64;
1925        let mut tx_gas_breakdowns: Vec<TxGasBreakdown> = Vec::with_capacity(exec_results.len());
1926        for (tx_idx, _, report, _, _, _, _) in &exec_results {
1927            let (tx, _tx_sender) = txs_with_sender
1928                .get(*tx_idx)
1929                .ok_or_else(|| EvmError::Custom(format!("tx index {tx_idx} out of bounds")))?;
1930            if is_amsterdam {
1931                check_2d_gas_allowance(
1932                    tx,
1933                    block_regular_gas_used,
1934                    block_state_gas_used,
1935                    header.gas_limit,
1936                )?;
1937            }
1938
1939            tx_gas_breakdowns.push(TxGasBreakdown::from_report(
1940                *tx_idx,
1941                tx.hash(crypto),
1942                report,
1943            ));
1944
1945            let tx_state_gas = report.state_gas_used;
1946            let tx_regular_gas = report.gas_used.saturating_sub(tx_state_gas);
1947            block_regular_gas_used = block_regular_gas_used.saturating_add(tx_regular_gas);
1948            block_state_gas_used = block_state_gas_used.saturating_add(tx_state_gas);
1949        }
1950        let block_gas_used = block_regular_gas_used.max(block_state_gas_used);
1951        // EIP-7778: block-level overflow check using pre-refund gas.
1952        if block_gas_used > header.gas_limit {
1953            return Err(EvmError::Transaction(format!(
1954                "Gas allowance exceeded: Block gas used overflow: \
1955                 block_gas_used {block_gas_used} > block_gas_limit {}",
1956                header.gas_limit
1957            )));
1958        }
1959
1960        // 4. Surface the BAL errors held back until the gas-limit check has passed:
1961        //    system-call coverage, then the first deferred per-tx error in tx order.
1962        Self::validate_bal_covers(
1963            &system_seed,
1964            pre_block_bal,
1965            &arc_bal,
1966            &arc_idx,
1967            "system calls",
1968            PRE_BLOCK_BAL_INDEX,
1969        )?;
1970
1971        for (_, _, _, _, _, _, deferred) in &mut exec_results {
1972            if let Some(err) = deferred.take() {
1973                return Err(err);
1974            }
1975        }
1976
1977        // 5. Apply per-tx reads_satisfied / tracked to the shared sets (cheap
1978        //    hash-set ops; preserves prior semantics). Note: this does NOT
1979        //    blanket-clear a destroyed account's entries — only slots it
1980        //    genuinely read (already folded into reads_satisfied above,
1981        //    including destroyed-account reads via shadow_reads) are
1982        //    removed. A phantom storage_reads entry declared on a destroyed
1983        //    account (never actually read) stays in unread_storage_reads and
1984        //    is rejected by the leftover-reads check below.
1985        for (_, _, _, tracked_accounts, reads_satisfied, _, _) in &exec_results {
1986            if !unread_storage_reads.is_empty() {
1987                for pair in reads_satisfied {
1988                    unread_storage_reads.remove(pair);
1989                }
1990            }
1991            // The coinbase is always accessed during fee finalization (geth's
1992            // readerTracker records it), even when the miner fee is zero and
1993            // ethrex skips the load_account call.
1994            if !unaccessed_pure_accounts.is_empty() {
1995                unaccessed_pure_accounts.remove(&header.coinbase);
1996                for addr in tracked_accounts {
1997                    unaccessed_pure_accounts.remove(addr);
1998                }
1999            }
2000        }
2001
2002        // 6. Build receipts in tx order.
2003        let mut receipts = Vec::with_capacity(n_txs);
2004        let mut cumulative_gas_used = 0_u64;
2005        for (_, tx_type, report, _, _, _, _) in exec_results {
2006            cumulative_gas_used += report.gas_spent;
2007            let mut receipt = Receipt::new(
2008                tx_type,
2009                matches!(report.result, TxResult::Success),
2010                cumulative_gas_used,
2011                report.logs,
2012            );
2013            if tx_type == TxType::Frame {
2014                receipt.payer = report.payer_address;
2015                receipt.frame_receipts = frame_receipts_from(report.frame_results);
2016            }
2017            receipts.push(receipt);
2018        }
2019
2020        Ok((
2021            receipts,
2022            block_gas_used,
2023            unread_storage_reads,
2024            unaccessed_pure_accounts,
2025            tx_gas_breakdowns,
2026        ))
2027    }
2028
2029    /// Gets the seeded balance for an account at `seed_idx` from BAL, falling
2030    /// back to system_seed/store if no BAL entry exists before that index.
2031    #[cfg(feature = "rayon")]
2032    fn seeded_balance(
2033        seed_idx: u32,
2034        acct: &ethrex_common::types::block_access_list::AccountChanges,
2035        system_seed: &CacheDB,
2036        store: &Arc<dyn Database>,
2037    ) -> Result<U256, BalValidationError> {
2038        let pos = acct
2039            .balance_changes
2040            .partition_point(|c| c.block_access_index <= seed_idx);
2041        if pos > 0 {
2042            Ok(acct.balance_changes[pos - 1].post_balance)
2043        } else if let Some(a) = system_seed.get(&acct.address) {
2044            Ok(a.info.balance)
2045        } else {
2046            store
2047                .get_account_state(acct.address)
2048                .map(|a| a.balance)
2049                .map_err(|e| {
2050                    BalValidationError::Database(format!(
2051                        "DB error reading balance for {:?}: {e}",
2052                        acct.address
2053                    ))
2054                })
2055        }
2056    }
2057
2058    /// Gets the seeded code hash for an account at `seed_idx` from BAL, falling
2059    /// back to system_seed/store if no BAL entry exists before that index.
2060    #[cfg(feature = "rayon")]
2061    fn seeded_code_hash(
2062        seed_idx: u32,
2063        acct: &ethrex_common::types::block_access_list::AccountChanges,
2064        system_seed: &CacheDB,
2065        store: &Arc<dyn Database>,
2066    ) -> Result<H256, BalValidationError> {
2067        let pos = acct
2068            .code_changes
2069            .partition_point(|c| c.block_access_index <= seed_idx);
2070        if pos > 0 {
2071            let seeded_code = &acct.code_changes[pos - 1].new_code;
2072            Ok(if seeded_code.is_empty() {
2073                *EMPTY_KECCAK_HASH
2074            } else {
2075                ethrex_common::utils::keccak(seeded_code)
2076            })
2077        } else if let Some(a) = system_seed.get(&acct.address) {
2078            Ok(a.info.code_hash)
2079        } else {
2080            store
2081                .get_account_state(acct.address)
2082                .map(|a| a.code_hash)
2083                .map_err(|e| {
2084                    BalValidationError::Database(format!(
2085                        "DB error reading account state for {:?}: {e}",
2086                        acct.address
2087                    ))
2088                })
2089        }
2090    }
2091
2092    /// Gets the seeded nonce for an account at `seed_idx` from BAL, falling
2093    /// back to system_seed/store if no BAL entry exists before that index.
2094    #[cfg(feature = "rayon")]
2095    fn seeded_nonce(
2096        seed_idx: u32,
2097        acct: &ethrex_common::types::block_access_list::AccountChanges,
2098        system_seed: &CacheDB,
2099        store: &Arc<dyn Database>,
2100    ) -> Result<u64, BalValidationError> {
2101        let pos = acct
2102            .nonce_changes
2103            .partition_point(|c| c.block_access_index <= seed_idx);
2104        if pos > 0 {
2105            Ok(acct.nonce_changes[pos - 1].post_nonce)
2106        } else if let Some(a) = system_seed.get(&acct.address) {
2107            Ok(a.info.nonce)
2108        } else {
2109            store
2110                .get_account_state(acct.address)
2111                .map(|a| a.nonce)
2112                .map_err(|e| {
2113                    BalValidationError::Database(format!(
2114                        "DB error reading nonce for {:?}: {e}",
2115                        acct.address
2116                    ))
2117                })
2118        }
2119    }
2120
2121    /// Start-of-tx `(balance, nonce, code_hash)` for `acct`: each field resolves
2122    /// from its own BAL change history at or before `seed_idx`, then the
2123    /// pre-system-call snapshot, then a single shared store read for whatever
2124    /// remains. Semantically identical to calling `seeded_balance` +
2125    /// `seeded_nonce` + `seeded_code_hash`, but reads the store at most once — the
2126    /// PART A no-op checks need all three for the same account, and an account
2127    /// with no BAL history before this tx would otherwise read it three times.
2128    #[cfg(feature = "rayon")]
2129    fn seeded_account_triple(
2130        seed_idx: u32,
2131        acct: &ethrex_common::types::block_access_list::AccountChanges,
2132        system_seed: &CacheDB,
2133        store: &Arc<dyn Database>,
2134    ) -> Result<(U256, u64, H256), BalValidationError> {
2135        let bpos = acct
2136            .balance_changes
2137            .partition_point(|c| c.block_access_index <= seed_idx);
2138        let npos = acct
2139            .nonce_changes
2140            .partition_point(|c| c.block_access_index <= seed_idx);
2141        let cpos = acct
2142            .code_changes
2143            .partition_point(|c| c.block_access_index <= seed_idx);
2144
2145        let mut balance = (bpos > 0).then(|| acct.balance_changes[bpos - 1].post_balance);
2146        let mut nonce = (npos > 0).then(|| acct.nonce_changes[npos - 1].post_nonce);
2147        let mut code_hash = (cpos > 0).then(|| {
2148            let c = &acct.code_changes[cpos - 1].new_code;
2149            if c.is_empty() {
2150                *EMPTY_KECCAK_HASH
2151            } else {
2152                ethrex_common::utils::keccak(c)
2153            }
2154        });
2155
2156        // Fields with no BAL history fall back to the pre-system-call snapshot.
2157        if let Some(a) = system_seed.get(&acct.address) {
2158            balance.get_or_insert(a.info.balance);
2159            nonce.get_or_insert(a.info.nonce);
2160            code_hash.get_or_insert(a.info.code_hash);
2161        }
2162
2163        // Anything still unresolved comes from the store (read once).
2164        if balance.is_none() || nonce.is_none() || code_hash.is_none() {
2165            let s = store.get_account_state(acct.address).map_err(|e| {
2166                BalValidationError::Database(format!(
2167                    "DB error reading account state for {:?}: {e}",
2168                    acct.address
2169                ))
2170            })?;
2171            balance.get_or_insert(s.balance);
2172            nonce.get_or_insert(s.nonce);
2173            code_hash.get_or_insert(s.code_hash);
2174        }
2175
2176        Ok((
2177            balance.unwrap_or_default(),
2178            nonce.unwrap_or_default(),
2179            code_hash.unwrap_or(*EMPTY_KECCAK_HASH),
2180        ))
2181    }
2182
2183    /// Start-of-tx value for a slot with no BAL `slot_changes` history to consult:
2184    /// the recorder's `tx_initial` fast-path (a slot the EVM genuinely wrote this
2185    /// tx), else the pre-tx state (system_seed, then store). Used by the
2186    /// execution->BAL check for a slot absent from `storage_changes`.
2187    #[cfg(feature = "rayon")]
2188    fn seeded_storage_pre_value(
2189        addr: Address,
2190        key: H256,
2191        slot: U256,
2192        system_seed: &CacheDB,
2193        store: &Arc<dyn Database>,
2194        tx_initial: &SeededHashMap<(Address, U256), U256>,
2195    ) -> Result<U256, BalValidationError> {
2196        if let Some(v) = tx_initial.get(&(addr, slot)) {
2197            return Ok(*v);
2198        }
2199        Self::storage_from_seed_or_store(addr, key, slot, system_seed, store)
2200    }
2201
2202    /// Pre-tx value for a slot from the in-memory snapshot, falling back to the
2203    /// store. Shared tail of the two `seeded_storage*` helpers.
2204    #[cfg(feature = "rayon")]
2205    fn storage_from_seed_or_store(
2206        addr: Address,
2207        key: H256,
2208        slot: U256,
2209        system_seed: &CacheDB,
2210        store: &Arc<dyn Database>,
2211    ) -> Result<U256, BalValidationError> {
2212        if let Some(v) = system_seed
2213            .get(&addr)
2214            .and_then(|a| a.storage.get(&key))
2215            .copied()
2216        {
2217            Ok(v)
2218        } else {
2219            store.get_storage_value(addr, key).map_err(|e| {
2220                BalValidationError::Database(format!(
2221                    "DB error reading storage for {addr:?} slot {slot}: {e}"
2222                ))
2223            })
2224        }
2225    }
2226
2227    /// Gets the seeded value for a storage slot at `seed_idx` from BAL, falling
2228    /// back to system_seed/store if no BAL entry exists before that index.
2229    ///
2230    /// Fast path: for a slot the EVM genuinely wrote this tx, `tx_initial` already
2231    /// holds the start-of-tx value it captured during execution — identical to what
2232    /// this function would otherwise recompute — so return it and skip the lookup.
2233    #[cfg(feature = "rayon")]
2234    fn seeded_storage(
2235        seed_idx: u32,
2236        sc: &ethrex_common::types::block_access_list::SlotChange,
2237        addr: Address,
2238        key: H256,
2239        system_seed: &CacheDB,
2240        store: &Arc<dyn Database>,
2241        tx_initial: &SeededHashMap<(Address, U256), U256>,
2242    ) -> Result<U256, BalValidationError> {
2243        if let Some(v) = tx_initial.get(&(addr, sc.slot)) {
2244            return Ok(*v);
2245        }
2246        let pos = sc
2247            .slot_changes
2248            .partition_point(|c| c.block_access_index <= seed_idx);
2249        if pos > 0 {
2250            Ok(sc.slot_changes[pos - 1].post_value)
2251        } else {
2252            Self::storage_from_seed_or_store(addr, key, sc.slot, system_seed, store)
2253        }
2254    }
2255
2256    /// Validates that a tx's post-execution state matches BAL claims.
2257    ///
2258    /// Replaces the previous snapshot->diff->validate approach:
2259    /// - No HashMap clone needed (reconstructs seeded values from BAL)
2260    /// - Uses pre-built index for O(1) account lookups
2261    /// - Uses binary search on sorted change lists
2262    ///
2263    /// `bal_idx`: block_access_index for this tx (tx_idx + 1)
2264    /// `seed_idx`: max BAL index used for seeding (= tx_idx = bal_idx - 1)
2265    /// `current_state`: post-execution account state from per-tx DB
2266    /// `codes`: code cache from per-tx DB (for code change validation)
2267    /// `bal`: the block access list
2268    /// `index`: pre-built validation index
2269    /// `system_seed`: pre-system-call state snapshot (for extraneous entry detection)
2270    /// `store`: database (fallback for pre-state lookups)
2271    /// `pure_reads`: slots the shadow recorder observed as pure reads (never
2272    ///   written); the execution->BAL check skips the seeded pre-state lookup for these since a
2273    ///   genuine read cannot diverge from its pre-tx value. Pass an empty set to
2274    ///   validate every slot unconditionally (e.g. from unit tests).
2275    /// `tx_initial`: start-of-tx storage values the EVM already captured for
2276    ///   written slots (`(addr, slot) -> pre_value`); used as the storage seed
2277    ///   fast-path so `seeded_storage` avoids a store read. Pass an empty map to
2278    ///   force the store fallback (e.g. from unit tests).
2279    ///
2280    /// Exposed as `pub` (rather than crate-private) solely so the direct
2281    /// `validate_tx_execution` unit tests in the `ethrex-test` crate
2282    /// (`test/tests/blockchain/bal_validate_tx_execution_tests.rs`) can call it.
2283    #[cfg(feature = "rayon")]
2284    #[allow(clippy::too_many_arguments)]
2285    pub fn validate_tx_execution(
2286        bal_idx: u32,
2287        seed_idx: u32,
2288        current_state: &CacheDB,
2289        codes: &SeededHashMap<H256, Code>,
2290        bal: &BlockAccessList,
2291        index: &BalAddressIndex,
2292        system_seed: &CacheDB,
2293        store: &Arc<dyn Database>,
2294        pure_reads: &SeededHashSet<(Address, U256)>,
2295        tx_initial: &SeededHashMap<(Address, U256), U256>,
2296    ) -> Result<(), BalValidationError> {
2297        // BAL -> execution: for each BAL account with changes at bal_idx,
2298        // verify execution produced the matching post-state (and that no
2299        // claimed change is a no-op equal to the start-of-tx value).
2300        if let Some(active_accounts) = index.tx_to_accounts.get(&bal_idx) {
2301            for &acct_inner_idx in active_accounts {
2302                let acct = &bal.accounts()[acct_inner_idx];
2303                let addr = acct.address;
2304                let actual = current_state.get(&addr);
2305                // Start-of-tx (balance, nonce, code_hash), computed once per account
2306                // and shared by the balance/nonce/code no-op checks below so an
2307                // account with no BAL history isn't read from the store three times.
2308                let mut seeded_info: Option<(U256, u64, H256)> = None;
2309
2310                // Balance
2311                if let Some(expected) = find_exact_change_balance(&acct.balance_changes, bal_idx) {
2312                    match actual {
2313                        Some(a) if a.info.balance == expected => {
2314                            // No-op check: a BAL balance change is only canonical if
2315                            // it actually differs from the start-of-tx seeded balance
2316                            // (the builder MUST NOT record round-tripped balances).
2317                            if seeded_info.is_none() {
2318                                seeded_info = Some(Self::seeded_account_triple(
2319                                    seed_idx,
2320                                    acct,
2321                                    system_seed,
2322                                    store,
2323                                )?);
2324                            }
2325                            let seeded = seeded_info.unwrap_or_default().0;
2326                            if expected == seeded {
2327                                return Err(BalValidationError::Mismatch(format!(
2328                                    "account {addr:?} has spurious no-op BAL balance change \
2329                                     at index {bal_idx}: post==pre=={expected}"
2330                                )));
2331                            }
2332                        }
2333                        Some(a) => {
2334                            return Err(BalValidationError::Mismatch(format!(
2335                                "account {addr:?} balance mismatch at index {bal_idx}: BAL={expected}, exec={} (diff={})",
2336                                a.info.balance,
2337                                describe_balance_diff(expected, a.info.balance),
2338                            )));
2339                        }
2340                        None => {
2341                            // Account absent from execution state, yet the BAL declares a
2342                            // balance change for it at this tx. Never canonical: either it
2343                            // differs from the start-of-tx seeded balance (a real mismatch)
2344                            // or equals it (a spurious no-op the builder must not record).
2345                            // Reject both to match the sequential path; the state root does
2346                            // NOT catch the no-op case (post==pre leaves the root unchanged).
2347                            let seeded = Self::seeded_balance(seed_idx, acct, system_seed, store)?;
2348                            if expected == seeded {
2349                                return Err(BalValidationError::Mismatch(format!(
2350                                    "account {addr:?} has spurious no-op BAL balance change \
2351                                     at index {bal_idx} for an account absent from execution \
2352                                     state: post==pre=={expected}"
2353                                )));
2354                            }
2355                            // Dump full BAL entry for diagnosis
2356                            let all_bal_indices: Vec<u32> = acct
2357                                .balance_changes
2358                                .iter()
2359                                .map(|c| c.block_access_index)
2360                                .collect();
2361                            let all_nonce_indices: Vec<u32> = acct
2362                                .nonce_changes
2363                                .iter()
2364                                .map(|c| c.block_access_index)
2365                                .collect();
2366                            let all_storage_indices: Vec<(u32, u64)> = acct
2367                                .storage_changes
2368                                .iter()
2369                                .flat_map(|sc| {
2370                                    sc.slot_changes
2371                                        .iter()
2372                                        .map(|c| (c.block_access_index, sc.slot.low_u64()))
2373                                })
2374                                .collect();
2375                            let code_indices: Vec<u32> = acct
2376                                .code_changes
2377                                .iter()
2378                                .map(|c| c.block_access_index)
2379                                .collect();
2380                            return Err(BalValidationError::Mismatch(format!(
2381                                "account {addr:?} has BAL balance change at {bal_idx} \
2382                                 but not in execution state (expected={expected}, pre={seeded}, \
2383                                 all_bal_idx={all_bal_indices:?}, nonce_idx={all_nonce_indices:?}, \
2384                                 storage_idx={all_storage_indices:?}, code_idx={code_indices:?})"
2385                            )));
2386                        }
2387                    }
2388                }
2389
2390                // Nonce
2391                if let Some(expected) = find_exact_change_nonce(&acct.nonce_changes, bal_idx) {
2392                    match actual {
2393                        Some(a) if a.info.nonce == expected => {
2394                            // No-op check: nonces are only ever recorded via a strict
2395                            // increment (or a one-time fixed-value predeploy install
2396                            // that also strictly increases), so a canonical BAL nonce
2397                            // change can never equal the start-of-tx seeded nonce.
2398                            if seeded_info.is_none() {
2399                                seeded_info = Some(Self::seeded_account_triple(
2400                                    seed_idx,
2401                                    acct,
2402                                    system_seed,
2403                                    store,
2404                                )?);
2405                            }
2406                            let seeded = seeded_info.unwrap_or_default().1;
2407                            if expected == seeded {
2408                                return Err(BalValidationError::Mismatch(format!(
2409                                    "account {addr:?} has spurious no-op BAL nonce change \
2410                                     at index {bal_idx}: post==pre=={expected}"
2411                                )));
2412                            }
2413                        }
2414                        Some(a) => {
2415                            return Err(BalValidationError::Mismatch(format!(
2416                                "account {addr:?} nonce mismatch at index {bal_idx}: BAL={expected}, exec={}",
2417                                a.info.nonce
2418                            )));
2419                        }
2420                        None => {
2421                            // Account absent from execution state, yet the BAL declares a
2422                            // nonce change: reject the no-op (post==pre) and the mismatch
2423                            // alike (see the balance arm rationale).
2424                            let seeded = Self::seeded_nonce(seed_idx, acct, system_seed, store)?;
2425                            if expected == seeded {
2426                                return Err(BalValidationError::Mismatch(format!(
2427                                    "account {addr:?} has spurious no-op BAL nonce change \
2428                                     at index {bal_idx} for an account absent from execution \
2429                                     state: post==pre=={expected}"
2430                                )));
2431                            }
2432                            return Err(BalValidationError::Mismatch(format!(
2433                                "account {addr:?} has BAL nonce change at {bal_idx} \
2434                                 but not in execution state (expected={expected}, pre={seeded})"
2435                            )));
2436                        }
2437                    }
2438                }
2439
2440                // Code
2441                if let Some(expected_code) = find_exact_change_code(&acct.code_changes, bal_idx) {
2442                    match actual {
2443                        Some(a) => {
2444                            let actual_code = if let Some(c) = codes.get(&a.info.code_hash) {
2445                                c.code_bytes()
2446                            } else {
2447                                let c = store.get_account_code(a.info.code_hash).map_err(|e| {
2448                                    BalValidationError::Database(format!(
2449                                        "DB error reading account code for {addr:?}: {e}"
2450                                    ))
2451                                })?;
2452                                c.code_bytes()
2453                            };
2454                            if actual_code != *expected_code {
2455                                return Err(BalValidationError::Mismatch(format!(
2456                                    "account {addr:?} code mismatch at index {bal_idx}"
2457                                )));
2458                            }
2459                            // No-op check: a BAL code change is only canonical if
2460                            // it actually differs from the start-of-tx seeded code.
2461                            if seeded_info.is_none() {
2462                                seeded_info = Some(Self::seeded_account_triple(
2463                                    seed_idx,
2464                                    acct,
2465                                    system_seed,
2466                                    store,
2467                                )?);
2468                            }
2469                            let seeded_hash = seeded_info.unwrap_or_default().2;
2470                            if a.info.code_hash == seeded_hash {
2471                                return Err(BalValidationError::Mismatch(format!(
2472                                    "account {addr:?} has spurious no-op BAL code change \
2473                                     at index {bal_idx}"
2474                                )));
2475                            }
2476                        }
2477                        None => {
2478                            // No-op check: compare against pre-state code.
2479                            // Try system_seed + codes cache first, then fall
2480                            // back to store (consistent with balance/nonce).
2481                            let code_hash = if let Some(a) = system_seed.get(&addr) {
2482                                a.info.code_hash
2483                            } else {
2484                                store
2485                                    .get_account_state(addr)
2486                                    .map(|a| a.code_hash)
2487                                    .map_err(|e| {
2488                                        BalValidationError::Database(format!(
2489                                            "DB error reading account state for {addr:?}: {e}"
2490                                        ))
2491                                    })?
2492                            };
2493                            let pre_code = if let Some(c) = codes.get(&code_hash) {
2494                                c.code_bytes()
2495                            } else {
2496                                let c = store.get_account_code(code_hash).map_err(|e| {
2497                                    BalValidationError::Database(format!(
2498                                        "DB error reading account code for hash \
2499                                             {code_hash:?}: {e}"
2500                                    ))
2501                                })?;
2502                                c.code_bytes()
2503                            };
2504                            if *expected_code == pre_code {
2505                                // Spurious no-op: BAL code change equals pre-state code
2506                                // for an account absent from execution state. Reject to
2507                                // match the sequential path (the state root won't catch it).
2508                                return Err(BalValidationError::Mismatch(format!(
2509                                    "account {addr:?} has spurious no-op BAL code change \
2510                                     at index {bal_idx} for an account absent from execution state"
2511                                )));
2512                            }
2513                            return Err(BalValidationError::Mismatch(format!(
2514                                "account {addr:?} has BAL code change at {bal_idx} \
2515                                 but not in execution state"
2516                            )));
2517                        }
2518                    }
2519                }
2520
2521                // Storage
2522                for sc in &acct.storage_changes {
2523                    if let Some(expected_value) =
2524                        find_exact_change_storage(&sc.slot_changes, bal_idx)
2525                    {
2526                        let key = ethrex_common::utils::u256_to_h256(sc.slot);
2527                        let actual_value = actual.and_then(|a| a.storage.get(&key)).copied();
2528                        if actual_value != Some(expected_value) {
2529                            // Slot absent from execution state. Compare against the
2530                            // start-of-tx seeded value: an entry equal to it is a
2531                            // spurious no-op (post==pre) that the builder must not
2532                            // record. Reject it to match the sequential path — the
2533                            // state root won't catch it since the value is unchanged.
2534                            if actual.is_none() || actual_value.is_none() {
2535                                let seeded = Self::seeded_storage(
2536                                    seed_idx,
2537                                    sc,
2538                                    addr,
2539                                    key,
2540                                    system_seed,
2541                                    store,
2542                                    tx_initial,
2543                                )?;
2544                                if expected_value == seeded {
2545                                    return Err(BalValidationError::Mismatch(format!(
2546                                        "account {addr:?} has spurious no-op BAL storage \
2547                                         change for slot {} at index {bal_idx} for a slot \
2548                                         absent from execution state: post==pre=={expected_value}",
2549                                        sc.slot
2550                                    )));
2551                                }
2552                            }
2553                            return Err(BalValidationError::Mismatch(format!(
2554                                "account {addr:?} storage slot {} mismatch at index {bal_idx}: \
2555                                 BAL={expected_value}, exec={actual_value:?}",
2556                                sc.slot
2557                            )));
2558                        }
2559                        // Matching arm: slot present in execution state and equal
2560                        // to expected. Reject spurious no-op entries (post == the
2561                        // start-of-tx seeded value).
2562                        let seeded = Self::seeded_storage(
2563                            seed_idx,
2564                            sc,
2565                            addr,
2566                            key,
2567                            system_seed,
2568                            store,
2569                            tx_initial,
2570                        )?;
2571                        if expected_value == seeded {
2572                            return Err(BalValidationError::Mismatch(format!(
2573                                "account {addr:?} has spurious no-op BAL storage change for \
2574                                 slot {} at index {bal_idx}",
2575                                sc.slot
2576                            )));
2577                        }
2578                    }
2579                }
2580            }
2581        }
2582
2583        // execution -> BAL: for each account modified by execution, verify every
2584        // mutation it produced is claimed by the BAL (no unrecorded change). The state is
2585        // seeded, so a rejection names the lowest offending account and slot.
2586        first_failure_by_key(current_state, |addr, account| {
2587            if account.is_unmodified() {
2588                return Ok(());
2589            }
2590
2591            let Some(&bal_acct_idx) = index.addr_to_idx.get(addr) else {
2592                // Account is Modified but absent from BAL. Could be a warm-access
2593                // artifact (get_account_mut without value changes) or a genuine
2594                // missing entry. Compare with pre-execution state to distinguish.
2595                let pre = system_seed
2596                    .get(addr)
2597                    .map(|a| (a.info.balance, a.info.nonce, a.info.code_hash))
2598                    .or_else(|| {
2599                        store
2600                            .get_account_state(*addr)
2601                            .ok()
2602                            .map(|a| (a.balance, a.nonce, a.code_hash))
2603                    })
2604                    .unwrap_or_default();
2605                let post = (
2606                    account.info.balance,
2607                    account.info.nonce,
2608                    account.info.code_hash,
2609                );
2610                if pre != post {
2611                    return Err(BalValidationError::Mismatch(format!(
2612                        "account {addr:?} was modified by execution but is absent from BAL"
2613                    )));
2614                }
2615                return Ok(());
2616            };
2617
2618            let acct = &bal.accounts()[bal_acct_idx];
2619
2620            // Balance: if BAL has no change at bal_idx, execution must not have changed it
2621            if !has_exact_change_balance(&acct.balance_changes, bal_idx) {
2622                let seeded = Self::seeded_balance(seed_idx, acct, system_seed, store)?;
2623                if account.info.balance != seeded {
2624                    return Err(BalValidationError::Mismatch(format!(
2625                        "account {addr:?} balance changed by execution ({}) but BAL has no \
2626                         balance change at index {bal_idx} (seeded={seeded})",
2627                        account.info.balance
2628                    )));
2629                }
2630            }
2631
2632            // Nonce: same pattern
2633            if !has_exact_change_nonce(&acct.nonce_changes, bal_idx) {
2634                let seeded = Self::seeded_nonce(seed_idx, acct, system_seed, store)?;
2635                if account.info.nonce != seeded {
2636                    return Err(BalValidationError::Mismatch(format!(
2637                        "account {addr:?} nonce changed by execution ({}) but BAL has no \
2638                         nonce change at index {bal_idx} (seeded={seeded})",
2639                        account.info.nonce
2640                    )));
2641                }
2642            }
2643
2644            // Code: same pattern — use keccak256 of the raw bytes directly to
2645            // avoid reconstructing a full Code object (seed_db_from_bal already
2646            // did that work; here we only need the hash for comparison).
2647            if !has_exact_change_code(&acct.code_changes, bal_idx) {
2648                let seeded_hash = Self::seeded_code_hash(seed_idx, acct, system_seed, store)?;
2649                if account.info.code_hash != seeded_hash {
2650                    return Err(BalValidationError::Mismatch(format!(
2651                        "account {addr:?} code changed by execution but BAL has no \
2652                         code change at index {bal_idx} (seeded_hash={seeded_hash:?})"
2653                    )));
2654                }
2655            }
2656
2657            // Storage: for each slot in execution state, check it's expected
2658            first_failure_by_key(&account.storage, |key_h256, &value| {
2659                let slot_u256 = u256_from_big_endian_const(key_h256.0);
2660                // EIP-7928 requires storage_changes sorted by slot, so use binary search.
2661                let pos = acct
2662                    .storage_changes
2663                    .partition_point(|sc| sc.slot < slot_u256);
2664                if pos < acct.storage_changes.len() && acct.storage_changes[pos].slot == slot_u256 {
2665                    let sc = &acct.storage_changes[pos];
2666                    if !has_exact_change_storage(&sc.slot_changes, bal_idx) {
2667                        // Compare against the start-of-tx value. `seeded_storage` falls
2668                        // back to system_seed -> store when the slot has no BAL change
2669                        // at or before this tx (seeded_pos == 0), so a write at this tx
2670                        // whose BAL entry was omitted is still caught rather than skipped.
2671                        let seeded = Self::seeded_storage(
2672                            seed_idx,
2673                            sc,
2674                            *addr,
2675                            *key_h256,
2676                            system_seed,
2677                            store,
2678                            tx_initial,
2679                        )?;
2680                        if value != seeded {
2681                            return Err(BalValidationError::Mismatch(format!(
2682                                "account {addr:?} storage slot {slot_u256} changed by \
2683                                 execution ({value}) but BAL has no change at index \
2684                                 {bal_idx} (seeded={seeded})"
2685                            )));
2686                        }
2687                    }
2688                }
2689                // Slot not in BAL storage_changes (a slot is in at most one of
2690                // storage_changes/storage_reads per EIP-7928 ordering rules). A value
2691                // that diverges from the start-of-tx baseline means execution WROTE the
2692                // slot but the write was not recorded in storage_changes — non-canonical.
2693                // A genuine read leaves value == pre_value and passes. This catches both
2694                // an entirely-absent slot AND a real write mis-declared under
2695                // storage_reads: the shadow-reads check in execute_block_parallel only
2696                // verifies the slot is present in *some* list, never that a changed value
2697                // was recorded in the correct one.
2698                //
2699                // Fast path: the recorder proved this slot a pure read (writes are
2700                // excluded from `pure_reads`), so its value equals the pre-tx value by
2701                // construction — skip the DB-backed seeded lookup entirely.
2702                else if !pure_reads.contains(&(*addr, slot_u256)) {
2703                    // This slot has no `storage_changes` entry, so there is no BAL
2704                    // slot history to seed from; take the start-of-tx value straight
2705                    // from the recorder fast-path / pre-tx state. A written slot hits
2706                    // `tx_initial` and skips the store read.
2707                    let pre_value = Self::seeded_storage_pre_value(
2708                        *addr,
2709                        *key_h256,
2710                        slot_u256,
2711                        system_seed,
2712                        store,
2713                        tx_initial,
2714                    )?;
2715                    if value != pre_value {
2716                        let declared_as_read = acct.storage_reads.contains(&slot_u256);
2717                        return Err(BalValidationError::Mismatch(format!(
2718                            "account {addr:?} storage slot {slot_u256} was written by \
2719                             execution ({value}) but has no storage_changes entry at index \
2720                             {bal_idx} (pre_value={pre_value}, declared_as_read={declared_as_read})"
2721                        )));
2722                    }
2723                }
2724                Ok(())
2725            })
2726        })
2727    }
2728
2729    /// Validates BAL entries at the withdrawal index against actual post-withdrawal state.
2730    ///
2731    /// After `process_withdrawals` + `extract_all_requests_levm` run on the BAL-seeded
2732    /// DB, `current_accounts_state` reflects the actual state. Validation is bidirectional:
2733    ///
2734    /// BAL -> DB: every BAL claim at the withdrawal index must match the DB.
2735    /// DB -> BAL: every account modified during the withdrawal/request phase
2736    ///         must have a corresponding BAL entry. Without this reverse check, a
2737    ///         malicious builder could omit a withdrawal recipient from the BAL,
2738    ///         causing the BAL-derived state root to exclude the withdrawal balance
2739    ///         change.
2740    #[cfg(feature = "rayon")]
2741    fn validate_bal_withdrawal_index(
2742        db: &GeneralizedDatabase,
2743        bal: &BlockAccessList,
2744        withdrawal_idx: u32,
2745        index: &BalAddressIndex,
2746    ) -> Result<(), EvmError> {
2747        // BAL -> DB: For each BAL account with changes at the withdrawal index,
2748        //         verify the DB matches.
2749        for acct in bal.accounts() {
2750            let addr = acct.address;
2751            let actual = db.current_accounts_state.get(&addr);
2752
2753            // Balance
2754            if let Some(expected) = find_exact_change_balance(&acct.balance_changes, withdrawal_idx)
2755            {
2756                match actual {
2757                    Some(a) if a.info.balance == expected => {}
2758                    Some(a) => {
2759                        return Err(EvmError::Custom(format!(
2760                            "BAL validation failed for withdrawal: account {addr:?} balance \
2761                             mismatch at index {withdrawal_idx}: BAL={expected}, actual={}",
2762                            a.info.balance
2763                        )));
2764                    }
2765                    None => {
2766                        return Err(EvmError::Custom(format!(
2767                            "BAL validation failed for withdrawal: account {addr:?} has \
2768                             balance change at index {withdrawal_idx} but was not touched \
2769                             by withdrawal/request phase"
2770                        )));
2771                    }
2772                }
2773            }
2774
2775            // Nonce
2776            if let Some(expected) = find_exact_change_nonce(&acct.nonce_changes, withdrawal_idx) {
2777                match actual {
2778                    Some(a) if a.info.nonce == expected => {}
2779                    Some(a) => {
2780                        return Err(EvmError::Custom(format!(
2781                            "BAL validation failed for withdrawal: account {addr:?} nonce \
2782                             mismatch at index {withdrawal_idx}: BAL={expected}, actual={}",
2783                            a.info.nonce
2784                        )));
2785                    }
2786                    None => {
2787                        return Err(EvmError::Custom(format!(
2788                            "BAL validation failed for withdrawal: account {addr:?} has \
2789                             nonce change at index {withdrawal_idx} but was not touched \
2790                             by withdrawal/request phase"
2791                        )));
2792                    }
2793                }
2794            }
2795
2796            // Code
2797            if let Some(expected_code) = find_exact_change_code(&acct.code_changes, withdrawal_idx)
2798            {
2799                let code_hash = if expected_code.is_empty() {
2800                    *EMPTY_KECCAK_HASH
2801                } else {
2802                    ethrex_common::utils::keccak(expected_code)
2803                };
2804                match actual {
2805                    Some(a) if a.info.code_hash == code_hash => {}
2806                    Some(_) => {
2807                        return Err(EvmError::Custom(format!(
2808                            "BAL validation failed for withdrawal: account {addr:?} code \
2809                             mismatch at index {withdrawal_idx}"
2810                        )));
2811                    }
2812                    None => {
2813                        return Err(EvmError::Custom(format!(
2814                            "BAL validation failed for withdrawal: account {addr:?} has \
2815                             code change at index {withdrawal_idx} but was not touched \
2816                             by withdrawal/request phase"
2817                        )));
2818                    }
2819                }
2820            }
2821
2822            // Storage writes
2823            for sc in &acct.storage_changes {
2824                if let Some(expected_value) =
2825                    find_exact_change_storage(&sc.slot_changes, withdrawal_idx)
2826                {
2827                    let key = ethrex_common::utils::u256_to_h256(sc.slot);
2828                    let actual_value = actual.and_then(|a| a.storage.get(&key)).copied();
2829                    if actual_value != Some(expected_value) {
2830                        return Err(EvmError::Custom(format!(
2831                            "BAL validation failed for withdrawal: account {addr:?} storage \
2832                             slot {} mismatch at index {withdrawal_idx}: BAL={expected_value}, \
2833                             actual={actual_value:?}",
2834                            sc.slot
2835                        )));
2836                    }
2837                }
2838            }
2839        }
2840
2841        // DB -> BAL: For each account modified during the withdrawal/request phase,
2842        //         verify it has a corresponding BAL entry claiming the change. The state
2843        //         is seeded, so a rejection names the lowest offending account and slot.
2844        first_failure_by_key(&db.current_accounts_state, |addr, account| {
2845            if account.is_unmodified() {
2846                return Ok(());
2847            }
2848
2849            let Some(&bal_acct_idx) = index.addr_to_idx.get(addr) else {
2850                // Account modified during withdrawal/request phase but absent
2851                // from BAL entirely. Compare with pre-state (store) to
2852                // distinguish genuine mutations from warm-access artifacts.
2853                let pre_state = db.store.get_account_state(*addr).map_err(|e| {
2854                    EvmError::Custom(format!(
2855                        "BAL validation failed for withdrawal: db error reading \
2856                         account {addr:?}: {e}"
2857                    ))
2858                })?;
2859                let pre = (pre_state.balance, pre_state.nonce, pre_state.code_hash);
2860                let post = (
2861                    account.info.balance,
2862                    account.info.nonce,
2863                    account.info.code_hash,
2864                );
2865                if pre != post {
2866                    return Err(EvmError::Custom(format!(
2867                        "BAL validation failed for withdrawal: account {addr:?} was modified \
2868                         during withdrawal/request phase but is absent from BAL"
2869                    )));
2870                }
2871                // Also check storage: if any slot differs from pre-state,
2872                // the account should have been in the BAL.
2873                return first_failure_by_key(&account.storage, |key_h256, &value| {
2874                    let pre_value = db.store.get_storage_value(*addr, *key_h256).map_err(|e| {
2875                        EvmError::Custom(format!(
2876                            "BAL validation failed for withdrawal: db error reading \
2877                                 storage {addr:?}[{}]: {e}",
2878                            u256_from_big_endian_const(key_h256.0)
2879                        ))
2880                    })?;
2881                    if value != pre_value {
2882                        return Err(EvmError::Custom(format!(
2883                            "BAL validation failed for withdrawal: account {addr:?} storage \
2884                             slot {} changed during withdrawal/request phase but is absent \
2885                             from BAL",
2886                            u256_from_big_endian_const(key_h256.0)
2887                        )));
2888                    }
2889                    Ok(())
2890                });
2891            };
2892
2893            let acct = &bal.accounts()[bal_acct_idx];
2894
2895            // Balance: if BAL has no change at withdrawal_idx, the withdrawal
2896            // phase must not have changed it relative to the last BAL entry.
2897            if !has_exact_change_balance(&acct.balance_changes, withdrawal_idx) {
2898                let seeded = match acct.balance_changes.last() {
2899                    Some(c) => c.post_balance,
2900                    None => {
2901                        db.store
2902                            .get_account_state(*addr)
2903                            .map_err(|e| {
2904                                EvmError::Custom(format!(
2905                                    "BAL validation failed for withdrawal: db error reading \
2906                                 account {addr:?}: {e}"
2907                                ))
2908                            })?
2909                            .balance
2910                    }
2911                };
2912                if account.info.balance != seeded {
2913                    return Err(EvmError::Custom(format!(
2914                        "BAL validation failed for withdrawal: account {addr:?} balance \
2915                         changed during withdrawal/request phase ({}) but BAL has no \
2916                         balance change at index {withdrawal_idx} (last_bal={seeded})",
2917                        account.info.balance
2918                    )));
2919                }
2920            }
2921
2922            // Nonce
2923            if !has_exact_change_nonce(&acct.nonce_changes, withdrawal_idx) {
2924                let seeded = match acct.nonce_changes.last() {
2925                    Some(c) => c.post_nonce,
2926                    None => {
2927                        db.store
2928                            .get_account_state(*addr)
2929                            .map_err(|e| {
2930                                EvmError::Custom(format!(
2931                                    "BAL validation failed for withdrawal: db error reading \
2932                                 account {addr:?}: {e}"
2933                                ))
2934                            })?
2935                            .nonce
2936                    }
2937                };
2938                if account.info.nonce != seeded {
2939                    return Err(EvmError::Custom(format!(
2940                        "BAL validation failed for withdrawal: account {addr:?} nonce \
2941                         changed during withdrawal/request phase ({}) but BAL has no \
2942                         nonce change at index {withdrawal_idx} (last_bal={seeded})",
2943                        account.info.nonce
2944                    )));
2945                }
2946            }
2947
2948            // Code
2949            if !has_exact_change_code(&acct.code_changes, withdrawal_idx) {
2950                let seeded_hash = match acct.code_changes.last() {
2951                    Some(c) if c.new_code.is_empty() => *EMPTY_KECCAK_HASH,
2952                    Some(c) => ethrex_common::utils::keccak(&c.new_code),
2953                    None => {
2954                        db.store
2955                            .get_account_state(*addr)
2956                            .map_err(|e| {
2957                                EvmError::Custom(format!(
2958                                    "BAL validation failed for withdrawal: db error reading \
2959                                 account {addr:?}: {e}"
2960                                ))
2961                            })?
2962                            .code_hash
2963                    }
2964                };
2965                if account.info.code_hash != seeded_hash {
2966                    return Err(EvmError::Custom(format!(
2967                        "BAL validation failed for withdrawal: account {addr:?} code \
2968                         changed during withdrawal/request phase but BAL has no \
2969                         code change at index {withdrawal_idx} \
2970                         (actual={:?}, last_bal={seeded_hash:?})",
2971                        account.info.code_hash
2972                    )));
2973                }
2974            }
2975
2976            // Storage: for each slot in the withdrawal/request-phase state,
2977            // verify the BAL has a corresponding entry or the value is unchanged.
2978            first_failure_by_key(&account.storage, |key_h256, &value| {
2979                let slot_u256 = u256_from_big_endian_const(key_h256.0);
2980                let pos = acct
2981                    .storage_changes
2982                    .partition_point(|sc| sc.slot < slot_u256);
2983                if pos < acct.storage_changes.len() && acct.storage_changes[pos].slot == slot_u256 {
2984                    let sc = &acct.storage_changes[pos];
2985                    if !has_exact_change_storage(&sc.slot_changes, withdrawal_idx) {
2986                        // No BAL entry at withdrawal_idx; compare against
2987                        // last BAL entry (the seeded value).
2988                        let seeded = match sc.slot_changes.last() {
2989                            Some(c) => c.post_value,
2990                            None => db.store.get_storage_value(*addr, *key_h256).map_err(|e| {
2991                                EvmError::Custom(format!(
2992                                    "BAL validation failed for withdrawal: db error reading \
2993                                     storage {addr:?}[{slot_u256}]: {e}"
2994                                ))
2995                            })?,
2996                        };
2997                        if value != seeded {
2998                            return Err(EvmError::Custom(format!(
2999                                "BAL validation failed for withdrawal: account {addr:?} \
3000                                 storage slot {slot_u256} changed during withdrawal/request \
3001                                 phase ({value}) but BAL has no change at index \
3002                                 {withdrawal_idx} (last_bal={seeded})"
3003                            )));
3004                        }
3005                    }
3006                } else {
3007                    // Slot not in BAL storage_changes at all: verify it
3008                    // wasn't actually mutated during the withdrawal/request phase.
3009                    let pre_value = db.store.get_storage_value(*addr, *key_h256).map_err(|e| {
3010                        EvmError::Custom(format!(
3011                            "BAL validation failed for withdrawal: db error reading \
3012                             storage {addr:?}[{slot_u256}]: {e}"
3013                        ))
3014                    })?;
3015                    if value != pre_value {
3016                        return Err(EvmError::Custom(format!(
3017                            "BAL validation failed for withdrawal: account {addr:?} \
3018                             storage slot {slot_u256} changed during withdrawal/request \
3019                             phase ({value}) but slot is absent from BAL storage_changes \
3020                             (pre={pre_value})"
3021                        )));
3022                    }
3023                }
3024                Ok(())
3025            })
3026        })
3027    }
3028
3029    /// Validates supplied-BAL entries at the pre-execution index (0) on the
3030    /// parallel import path. `validate_tx_execution` covers indices `1..=n` and
3031    /// `validate_bal_withdrawal_index` covers `n+1`, but index 0 — the pre-block
3032    /// system calls (EIP-2935 block-hash history, EIP-4788 beacon root) — had no
3033    /// validator. A spurious no-op entry at index 0 (`post == pre-block value`,
3034    /// e.g. a `balance: 0` change for an untouched empty account) leaves the
3035    /// BAL-derived state root unchanged, so it slipped past both the downstream
3036    /// `validate_state_root` check and content validation: the parallel path
3037    /// accepted a block the sequential/full-rebuild path rejects.
3038    ///
3039    /// Must run right after `prepare_block`, while `current_accounts_state` still
3040    /// holds the pre-exec changes (`get_state_transitions_tx` drains it). The
3041    /// check is BAL -> DB only: an index-0 entry for an account the pre-exec
3042    /// phase never touched, whose value diverges from the post-`prepare_block`
3043    /// state, or whose value equals the pre-block value (a no-op), is rejected.
3044    /// Omissions and genuine divergences change the state root and are already
3045    /// caught by `validate_state_root`; the no-op case is the one this closes.
3046    #[cfg(feature = "rayon")]
3047    fn validate_bal_pre_exec_index(
3048        db: &GeneralizedDatabase,
3049        bal: &BlockAccessList,
3050        index: &BalAddressIndex,
3051    ) -> Result<(), EvmError> {
3052        let Some(active_accounts) = index.tx_to_accounts.get(&PRE_BLOCK_BAL_INDEX) else {
3053            return Ok(());
3054        };
3055
3056        // Pre-block seed: index-0 changes have no BAL history before them, so the
3057        // start-of-block value comes straight from the store, which on the import
3058        // path is still the unmodified parent state. Returns (balance, nonce,
3059        // code_hash); absent accounts default to (0, 0, EMPTY_KECCAK_HASH).
3060        let pre_account = |addr: Address| -> Result<(U256, u64, H256), EvmError> {
3061            let s = db.store.get_account_state(addr).map_err(|e| {
3062                EvmError::Custom(format!(
3063                    // "system_tx" names the pre-execution system-call phase, block
3064                    // access index 0. The label is part of the wire contract, not just
3065                    // prose: the EEST exception mappers resolve
3066                    // BlockException.INVALID_BLOCK_ACCESS_LIST by matching
3067                    // "BAL validation failed for (tx N|system_tx|withdrawal)", so a
3068                    // phase name outside that set leaves a correct rejection classified
3069                    // as some other exception and the conformance test fails.
3070                    "BAL validation failed for system_tx: db error reading account {addr:?}: {e}"
3071                ))
3072            })?;
3073            Ok((s.balance, s.nonce, s.code_hash))
3074        };
3075
3076        for &acct_inner_idx in active_accounts {
3077            let acct = &bal.accounts()[acct_inner_idx];
3078            let addr = acct.address;
3079            let actual = db.current_accounts_state.get(&addr);
3080
3081            // Balance
3082            if let Some(expected) =
3083                find_exact_change_balance(&acct.balance_changes, PRE_BLOCK_BAL_INDEX)
3084            {
3085                match actual {
3086                    Some(a) if a.info.balance == expected => {
3087                        if expected == pre_account(addr)?.0 {
3088                            return Err(EvmError::Custom(format!(
3089                                "BAL validation failed for system_tx: account {addr:?} has spurious \
3090                                 no-op balance change at index 0: post==pre=={expected}"
3091                            )));
3092                        }
3093                    }
3094                    Some(a) => {
3095                        return Err(EvmError::Custom(format!(
3096                            "BAL validation failed for system_tx: account {addr:?} balance mismatch \
3097                             at index 0: BAL={expected}, actual={}",
3098                            a.info.balance
3099                        )));
3100                    }
3101                    None => {
3102                        return Err(EvmError::Custom(format!(
3103                            "BAL validation failed for system_tx: account {addr:?} has balance \
3104                             change at index 0 but was not touched by the pre-exec phase"
3105                        )));
3106                    }
3107                }
3108            }
3109
3110            // Nonce
3111            if let Some(expected) =
3112                find_exact_change_nonce(&acct.nonce_changes, PRE_BLOCK_BAL_INDEX)
3113            {
3114                match actual {
3115                    Some(a) if a.info.nonce == expected => {
3116                        if expected == pre_account(addr)?.1 {
3117                            return Err(EvmError::Custom(format!(
3118                                "BAL validation failed for system_tx: account {addr:?} has spurious \
3119                                 no-op nonce change at index 0: post==pre=={expected}"
3120                            )));
3121                        }
3122                    }
3123                    Some(a) => {
3124                        return Err(EvmError::Custom(format!(
3125                            "BAL validation failed for system_tx: account {addr:?} nonce mismatch \
3126                             at index 0: BAL={expected}, actual={}",
3127                            a.info.nonce
3128                        )));
3129                    }
3130                    None => {
3131                        return Err(EvmError::Custom(format!(
3132                            "BAL validation failed for system_tx: account {addr:?} has nonce \
3133                             change at index 0 but was not touched by the pre-exec phase"
3134                        )));
3135                    }
3136                }
3137            }
3138
3139            // Code
3140            if let Some(expected_code) =
3141                find_exact_change_code(&acct.code_changes, PRE_BLOCK_BAL_INDEX)
3142            {
3143                let expected_hash = if expected_code.is_empty() {
3144                    *EMPTY_KECCAK_HASH
3145                } else {
3146                    ethrex_common::utils::keccak(expected_code)
3147                };
3148                match actual {
3149                    Some(a) if a.info.code_hash == expected_hash => {
3150                        if expected_hash == pre_account(addr)?.2 {
3151                            return Err(EvmError::Custom(format!(
3152                                "BAL validation failed for system_tx: account {addr:?} has spurious \
3153                                 no-op code change at index 0"
3154                            )));
3155                        }
3156                    }
3157                    Some(_) => {
3158                        return Err(EvmError::Custom(format!(
3159                            "BAL validation failed for system_tx: account {addr:?} code mismatch \
3160                             at index 0"
3161                        )));
3162                    }
3163                    None => {
3164                        return Err(EvmError::Custom(format!(
3165                            "BAL validation failed for system_tx: account {addr:?} has code \
3166                             change at index 0 but was not touched by the pre-exec phase"
3167                        )));
3168                    }
3169                }
3170            }
3171
3172            // Storage
3173            for sc in &acct.storage_changes {
3174                let Some(expected_value) =
3175                    find_exact_change_storage(&sc.slot_changes, PRE_BLOCK_BAL_INDEX)
3176                else {
3177                    continue;
3178                };
3179                let key = ethrex_common::utils::u256_to_h256(sc.slot);
3180                let actual_value = actual.and_then(|a| a.storage.get(&key)).copied();
3181                if actual_value != Some(expected_value) {
3182                    return Err(EvmError::Custom(format!(
3183                        "BAL validation failed for system_tx: account {addr:?} storage slot {} \
3184                         mismatch at index 0: BAL={expected_value}, actual={actual_value:?}",
3185                        sc.slot
3186                    )));
3187                }
3188                let pre_value = db.store.get_storage_value(addr, key).map_err(|e| {
3189                    EvmError::Custom(format!(
3190                        "BAL validation failed for system_tx: db error reading storage {addr:?} \
3191                         slot {}: {e}",
3192                        sc.slot
3193                    ))
3194                })?;
3195                if expected_value == pre_value {
3196                    return Err(EvmError::Custom(format!(
3197                        "BAL validation failed for system_tx: account {addr:?} has spurious no-op \
3198                         storage change for slot {} at index 0: post==pre=={expected_value}",
3199                        sc.slot
3200                    )));
3201                }
3202            }
3203        }
3204
3205        Ok(())
3206    }
3207
3208    /// Pre-warms state by executing all transactions in parallel, grouped by sender.
3209    ///
3210    /// The `store` parameter should be a `CachingDatabase`-wrapped store so that
3211    /// parallel workers can benefit from shared caching. The same cache should
3212    /// be used by the sequential execution phase.
3213    #[cfg(feature = "rayon")]
3214    pub fn warm_block(
3215        block: &Block,
3216        store: Arc<dyn Database>,
3217        vm_type: VMType,
3218        crypto: &dyn Crypto,
3219        cancelled: &AtomicBool,
3220    ) -> Result<(), EvmError> {
3221        let txs_with_sender = block
3222            .body
3223            .get_transactions_with_sender(crypto)
3224            .map_err(|error| {
3225                EvmError::Transaction(format!("Couldn't recover addresses with error: {error}"))
3226            })?;
3227
3228        Self::warm_txs(
3229            &txs_with_sender,
3230            &block.header,
3231            store.clone(),
3232            vm_type,
3233            crypto,
3234            &|| cancelled.load(Ordering::Relaxed),
3235        )?;
3236
3237        let mut db = GeneralizedDatabase::new(store.clone());
3238
3239        if cancelled.load(Ordering::Relaxed) {
3240            return Ok(());
3241        }
3242
3243        for withdrawal in block
3244            .body
3245            .withdrawals
3246            .iter()
3247            .flatten()
3248            .filter(|withdrawal| withdrawal.amount > 0)
3249        {
3250            db.get_account_mut(withdrawal.address).map_err(|_| {
3251                EvmError::DB(format!(
3252                    "Withdrawal account {} not found",
3253                    withdrawal.address
3254                ))
3255            })?;
3256        }
3257        Ok(())
3258    }
3259
3260    /// Speculatively executes an arbitrary transaction set against `header`'s
3261    /// parent state to warm shared caches (used by the mempool prewarmer and
3262    /// `warm_block`). Sequential within a sender group, parallel across groups.
3263    /// `should_stop` is polled before each sender group and again before each
3264    /// transaction, so cancellation latency is bounded by one transaction's
3265    /// execution. Execution results are discarded — only cache population
3266    /// matters.
3267    #[cfg(feature = "rayon")]
3268    pub fn warm_txs(
3269        txs_with_sender: &[(&Transaction, Address)],
3270        header: &BlockHeader,
3271        store: Arc<dyn Database>,
3272        vm_type: VMType,
3273        crypto: &dyn Crypto,
3274        should_stop: &(dyn Fn() -> bool + Sync),
3275    ) -> Result<(), EvmError> {
3276        // Group by sender so nonce/balance changes propagate within a group
3277        // (inspired by Nethermind's per-sender prewarmer).
3278        let mut sender_groups: FxHashMap<Address, Vec<&Transaction>> = FxHashMap::default();
3279        for (tx, sender) in txs_with_sender {
3280            sender_groups.entry(*sender).or_default().push(tx);
3281        }
3282
3283        // Block-invariant EVM config + chain id, computed once and shared (by copy)
3284        // across the parallel warming workers.
3285        let chain_config = store.get_chain_config()?;
3286        let evm_config = EVMConfig::new_from_chain_config(&chain_config, header);
3287        let chain_id = chain_config.chain_id;
3288        // Block-invariant base blob fee, computed once and shared across workers.
3289        let base_blob_fee_per_gas = get_base_fee_per_blob_gas(header.excess_blob_gas, &evm_config)?;
3290
3291        // Parallel across sender groups, sequential within each group. The stack pool is reused
3292        // across all groups a worker handles (it is `Send`).
3293        sender_groups.into_par_iter().for_each_with(
3294            Vec::with_capacity(STACK_LIMIT),
3295            |stack_pool, (sender, txs)| {
3296                if should_stop() {
3297                    return;
3298                }
3299                // Memory holds an `Rc` (not `Send`), so its pool can't ride the `for_each_with`
3300                // init; keep it local to this group's run, where it still amortizes the buffer
3301                // alloc across the group's txs.
3302                let mut memory_pool = Vec::with_capacity(1);
3303                // Local to the group like `memory_pool`, reused across the group's txs.
3304                let mut initcode_pool = Vec::new();
3305                // Each sender group gets its own db instance for state propagation
3306                let mut group_db = GeneralizedDatabase::new(store.clone());
3307                for tx in txs {
3308                    if should_stop() {
3309                        return;
3310                    }
3311                    let _ = Self::execute_tx_in_block(
3312                        tx,
3313                        sender,
3314                        header,
3315                        &mut group_db,
3316                        vm_type,
3317                        base_blob_fee_per_gas,
3318                        stack_pool,
3319                        &mut memory_pool,
3320                        &mut initcode_pool,
3321                        true,
3322                        crypto,
3323                        evm_config,
3324                        chain_id,
3325                        // Warming discards results; the EXECUTE precompile is not run
3326                        // here (the real execution path carries the validator).
3327                        None,
3328                    );
3329                }
3330            },
3331        );
3332        Ok(())
3333    }
3334
3335    /// Flattened (address, slot) storage worklist for a BAL, in natural account
3336    /// order (slots grouped per account for storage-trie locality).
3337    #[cfg(feature = "rayon")]
3338    pub fn bal_storage_slots(bal: &BlockAccessList) -> Vec<(Address, H256)> {
3339        bal.accounts()
3340            .iter()
3341            .flat_map(|ac| {
3342                ac.all_storage_slots()
3343                    .map(move |slot| (ac.address, H256::from_uint(&slot)))
3344            })
3345            .collect()
3346    }
3347
3348    /// Concurrent block warmer for the BAL path: prefetches account states and
3349    /// contract code while execution runs.
3350    ///
3351    /// Storage slots are deliberately NOT warmed here. They are prefetched
3352    /// synchronously before the executor starts (see `bal_storage_slots` and the
3353    /// call site in `blockchain.rs`); warming them concurrently here let the
3354    /// executor race the warmer to the trie for SSTORE original values and cost
3355    /// ~22% of CPU. Keep storage warming synchronous and up front.
3356    #[cfg(feature = "rayon")]
3357    pub fn warm_block_from_bal(
3358        bal: &BlockAccessList,
3359        store: Arc<dyn Database>,
3360        cancelled: &AtomicBool,
3361    ) -> Result<(), EvmError> {
3362        let accounts = bal.accounts();
3363        if accounts.is_empty() {
3364            return Ok(());
3365        }
3366
3367        // Warm the accounts in chunks, in parallel, each chunk's states and then its
3368        // code, so that whatever is warmed first is usable by the executor right away.
3369        // Fetching every state as one batch before any code published nothing until
3370        // the whole batch was read: on a block whose transactions walk thousands of
3371        // distinct contracts, the executor cold-loaded each one itself while the
3372        // warmer read the same data in the background, and both finished together.
3373        // Storage slots are prefetched synchronously before the executor starts (see
3374        // `bal_storage_slots` at the call site), so only states and code are left here.
3375        accounts
3376            .par_chunks(BAL_WARM_CHUNK_ACCOUNTS)
3377            .try_for_each(|chunk| -> Result<(), EvmError> {
3378                if cancelled.load(Ordering::Relaxed) {
3379                    return Ok(());
3380                }
3381                let addresses: Vec<Address> = chunk.iter().map(|ac| ac.address).collect();
3382                store
3383                    .prefetch_accounts(&addresses)
3384                    .map_err(|e| EvmError::Custom(format!("prefetch_accounts: {e}")))?;
3385                for address in addresses {
3386                    if cancelled.load(Ordering::Relaxed) {
3387                        return Ok(());
3388                    }
3389                    if let Ok(state) = store.get_account_state(address)
3390                        && state.code_hash != *EMPTY_KECCAK_HASH
3391                    {
3392                        let _ = store.get_account_code(state.code_hash);
3393                    }
3394                }
3395                Ok(())
3396            })
3397    }
3398
3399    fn send_state_transitions_tx(
3400        merkleizer: &Sender<Vec<AccountUpdate>>,
3401        db: &mut GeneralizedDatabase,
3402        queue_length: &AtomicUsize,
3403    ) -> Result<(), EvmError> {
3404        let transitions = LEVM::get_state_transitions_tx(db)?;
3405        merkleizer
3406            .send(transitions)
3407            .map_err(|e| EvmError::Custom(format!("send failed: {e}")))?;
3408        queue_length.fetch_add(1, Ordering::Relaxed);
3409        Ok(())
3410    }
3411
3412    fn setup_env(
3413        tx: &Transaction,
3414        tx_sender: Address,
3415        block_header: &BlockHeader,
3416        db: &GeneralizedDatabase,
3417        vm_type: VMType,
3418    ) -> Result<Environment, EvmError> {
3419        // `chain_config` (a dyn-dispatch copy) and `EVMConfig`/fork/blob-schedule are
3420        // block-invariant; in a block loop, compute them once and use
3421        // `setup_env_with_config` instead. This single-tx entry point computes them here.
3422        let chain_config = db.store.get_chain_config()?;
3423        let config = EVMConfig::new_from_chain_config(&chain_config, block_header);
3424        let base_blob_fee_per_gas =
3425            get_base_fee_per_blob_gas(block_header.excess_blob_gas, &config)?;
3426        Self::setup_env_with_config(
3427            tx,
3428            tx_sender,
3429            block_header,
3430            config,
3431            chain_config.chain_id,
3432            vm_type,
3433            base_blob_fee_per_gas,
3434        )
3435    }
3436
3437    /// Per-tx `Environment` builder that takes the block-invariant `EVMConfig` and
3438    /// `chain_id` precomputed once per block, avoiding a per-tx `get_chain_config()`
3439    /// dyn-dispatch `ChainConfig` copy + `fork`/blob-schedule recompute.
3440    fn setup_env_with_config(
3441        tx: &Transaction,
3442        tx_sender: Address,
3443        block_header: &BlockHeader,
3444        config: EVMConfig,
3445        chain_id: u64,
3446        vm_type: VMType,
3447        base_blob_fee_per_gas: U256,
3448    ) -> Result<Environment, EvmError> {
3449        let gas_price: U256 = calculate_gas_price_for_tx(
3450            tx,
3451            block_header.base_fee_per_gas.unwrap_or_default(),
3452            &vm_type,
3453        )?;
3454
3455        let block_excess_blob_gas = block_header.excess_blob_gas;
3456        let env = Environment {
3457            origin: tx_sender,
3458            gas_limit: tx.gas_limit(),
3459            config,
3460            block_number: block_header.number,
3461            coinbase: block_header.coinbase,
3462            timestamp: block_header.timestamp,
3463            prev_randao: Some(block_header.prev_randao),
3464            slot_number: block_header
3465                .slot_number
3466                .map(U256::from)
3467                .unwrap_or(U256::zero()),
3468            chain_id: chain_id.into(),
3469            base_fee_per_gas: block_header.base_fee_per_gas.unwrap_or_default().into(),
3470            base_blob_fee_per_gas,
3471            gas_price,
3472            block_excess_blob_gas,
3473            block_blob_gas_used: block_header.blob_gas_used,
3474            tx_blob_hashes: tx.blob_versioned_hashes(),
3475            tx_max_priority_fee_per_gas: tx.max_priority_fee().map(U256::from),
3476            tx_max_fee_per_gas: tx.max_fee_per_gas().map(U256::from),
3477            tx_max_fee_per_blob_gas: tx.max_fee_per_blob_gas(),
3478            tx_nonce: tx.nonce(),
3479            block_gas_limit: block_header.gas_limit,
3480            difficulty: block_header.difficulty,
3481            is_privileged: matches!(tx, Transaction::PrivilegedL2Transaction(_)),
3482            fee_token: tx.fee_token(),
3483            disable_balance_check: false,
3484            disable_nonce_check: false,
3485            disable_gas_allowance_check: false,
3486            disable_sender_eoa_check: false,
3487            disable_execution_gas_cap: false,
3488            is_system_call: false,
3489        };
3490
3491        Ok(env)
3492    }
3493
3494    pub fn execute_tx(
3495        // The transaction to execute.
3496        tx: &Transaction,
3497        // The transaction's recovered address
3498        tx_sender: Address,
3499        // The block header for the current block.
3500        block_header: &BlockHeader,
3501        db: &mut GeneralizedDatabase,
3502        vm_type: VMType,
3503        crypto: &dyn Crypto,
3504        stateless_validator: Option<&dyn StatelessValidator>,
3505    ) -> Result<ExecutionReport, EvmError> {
3506        let env = Self::setup_env(tx, tx_sender, block_header, db, vm_type)?;
3507        let mut vm = VM::new(
3508            env,
3509            db,
3510            tx,
3511            LevmCallTracer::disabled(),
3512            vm_type,
3513            crypto,
3514            stateless_validator,
3515        )?;
3516
3517        vm.execute().map_err(VMError::into)
3518    }
3519
3520    // Like execute_tx but reuses the caller's pools of stacks, memory and initcode buffers,
3521    // which the block executors keep across their txs. Takes the block-invariant
3522    // `config`/`chain_id` precomputed once per block (see `setup_env_with_config`).
3523    #[allow(clippy::too_many_arguments)]
3524    fn execute_tx_in_block(
3525        // The transaction to execute.
3526        tx: &Transaction,
3527        // The transaction's recovered address
3528        tx_sender: Address,
3529        // The block header for the current block.
3530        block_header: &BlockHeader,
3531        db: &mut GeneralizedDatabase,
3532        vm_type: VMType,
3533        base_blob_fee_per_gas: U256,
3534        stack_pool: &mut Vec<Stack>,
3535        memory_pool: &mut Vec<Memory>,
3536        initcode_pool: &mut Vec<InitcodeBuffer>,
3537        disable_balance_check: bool,
3538        crypto: &dyn Crypto,
3539        config: EVMConfig,
3540        chain_id: u64,
3541        stateless_validator: Option<&dyn StatelessValidator>,
3542    ) -> Result<ExecutionReport, EvmError> {
3543        let mut env = Self::setup_env_with_config(
3544            tx,
3545            tx_sender,
3546            block_header,
3547            config,
3548            chain_id,
3549            vm_type,
3550            base_blob_fee_per_gas,
3551        )?;
3552        env.disable_balance_check = disable_balance_check;
3553        // Draw the root frame's stack and memory buffer from the shared pools (and adopt the
3554        // stacks and the initcode buffers for sub-frames), then return them
3555        // afterwards so the next tx reuses them instead of allocating + zeroing a fresh 32 KB
3556        // stack and a fresh memory buffer per transaction.
3557        let mut vm = VM::new_pooled(
3558            env,
3559            db,
3560            tx,
3561            LevmCallTracer::disabled(),
3562            vm_type,
3563            crypto,
3564            stateless_validator,
3565            stack_pool,
3566            memory_pool,
3567            initcode_pool,
3568        )?;
3569        let result = vm.execute().map_err(VMError::into);
3570        // Runs on both success and error paths (execute borrowed `vm` mutably but left it intact).
3571        vm.reclaim_into(stack_pool, memory_pool, initcode_pool);
3572        result
3573    }
3574
3575    pub fn undo_last_tx(db: &mut GeneralizedDatabase) -> Result<(), EvmError> {
3576        db.undo_last_transaction()?;
3577        Ok(())
3578    }
3579
3580    pub fn simulate_tx_from_generic(
3581        // The transaction to execute.
3582        tx: &GenericTransaction,
3583        // The block header for the current block.
3584        block_header: &BlockHeader,
3585        db: &mut GeneralizedDatabase,
3586        vm_type: VMType,
3587        crypto: &dyn Crypto,
3588        stateless_validator: Option<&dyn StatelessValidator>,
3589        execution_gas_cap: ExecutionGasCap,
3590    ) -> Result<ExecutionResult, EvmError> {
3591        // `env_from_generic` already relaxes the admission-only validations for every
3592        // simulation RPC, the gas allowance included. `block_gas_limit` keeps the block's
3593        // real value there: the GASLIMIT opcode reads it, so overwriting it would make
3594        // 0x45 return a number that appears nowhere on chain.
3595        let mut env = env_from_generic(tx, block_header, db, vm_type)?;
3596        env.disable_execution_gas_cap = execution_gas_cap == ExecutionGasCap::Lifted;
3597
3598        adjust_disabled_base_fee(&mut env);
3599
3600        let converted_tx = generic_tx_to_transaction(tx)?;
3601        let mut vm = vm_from_generic(&converted_tx, env, db, vm_type, crypto, stateless_validator)?;
3602
3603        vm.execute()
3604            .map(|value| value.into())
3605            .map_err(VMError::into)
3606    }
3607
3608    /// EIP-8141 mempool validation-prefix simulation (local peer policy, never
3609    /// consensus). Builds a VM like [`LEVM::execute_tx`] over a fresh
3610    /// simulation database, activates the [`ValidationObserver`] for the
3611    /// transaction's sender, runs ONLY the validation-prefix frames, and applies
3612    /// the admission assertions:
3613    ///   - no prefix frame reverted;
3614    ///   - each verify/pay frame that must APPROVE did (the prefix established a
3615    ///     payer);
3616    ///   - the deploy frame (if any) left non-empty code at the sender;
3617    ///   - total simulated prefix gas <= MAX_VERIFY_GAS;
3618    ///   - no validation-trace rule was violated.
3619    ///
3620    /// `canonical_paymaster_code_hash` is the pinned canonical paymaster code
3621    /// hash, when known (always `None` today, OQ1).
3622    #[allow(clippy::too_many_arguments)]
3623    pub fn simulate_frame_validation_prefix(
3624        tx: &Transaction,
3625        block_header: &BlockHeader,
3626        db: &mut GeneralizedDatabase,
3627        vm_type: VMType,
3628        crypto: &dyn Crypto,
3629        prefix: &ethrex_common::types::ValidationPrefix,
3630        _canonical_paymaster_code_hash: Option<H256>,
3631    ) -> Result<FrameValidationOutcome, EvmError> {
3632        use ethrex_common::types::FRAME_TX_MAX_VERIFY_GAS;
3633
3634        let frame_tx = match tx {
3635            Transaction::FrameTransaction(ft) => ft,
3636            _ => {
3637                return Err(EvmError::Custom(
3638                    "simulate_frame_validation_prefix requires a frame transaction".to_string(),
3639                ));
3640            }
3641        };
3642        let sender = frame_tx.sender;
3643
3644        let env = Self::setup_env(tx, sender, block_header, db, vm_type)?;
3645        let mut vm = VM::new(
3646            env,
3647            db,
3648            tx,
3649            LevmCallTracer::disabled(),
3650            vm_type,
3651            crypto,
3652            None,
3653        )?;
3654
3655        // OQ1: no canonical paymaster is resolvable, so the canonical pay-frame
3656        // exemption never fires (always `None`).
3657        let canonical_pay_frame: Option<usize> = None;
3658
3659        let sim = match vm.run_frame_validation_prefix(
3660            &prefix.frame_indices,
3661            prefix.deploy_index,
3662            canonical_pay_frame,
3663        ) {
3664            Ok(sim) => sim,
3665            Err(err) => {
3666                // A preamble / VM error means the prefix cannot be validated;
3667                // treat it as a (conservative) rejection rather than failing the
3668                // whole admission pipeline.
3669                return Ok(FrameValidationOutcome {
3670                    passed: false,
3671                    violation: Some(EvmError::from(err).to_string()),
3672                    reservation_ceiling: Self::frame_tx_reservation_ceiling(frame_tx),
3673                    accessed_paymaster: None,
3674                    touched_sender_slots: Vec::new(),
3675                });
3676            }
3677        };
3678
3679        let reservation_ceiling = Self::frame_tx_reservation_ceiling(frame_tx);
3680        let touched_sender_slots = vm.validation_observer.touched_sender_slots.clone();
3681        // The payer established by the prefix is the paymaster (OQ2: the
3682        // APPROVE-payment address is treated uniformly as "paymaster", including
3683        // the self-funded sender). Its canonical flag is always false (OQ1: no
3684        // canonical paymaster bytecode is resolvable), which is also why
3685        // `_canonical_paymaster_code_hash` is unused. The observer carries no
3686        // distinct paymaster, so derive it from the established payer.
3687        let accessed_paymaster = sim.payer_address.map(|payer| (payer, false));
3688
3689        // Assertion: a recorded trace violation fails validation.
3690        if let Some(violation) = &vm.validation_observer.violation {
3691            return Ok(FrameValidationOutcome {
3692                passed: false,
3693                violation: Some(format!("{violation:?}")),
3694                reservation_ceiling,
3695                accessed_paymaster,
3696                touched_sender_slots,
3697            });
3698        }
3699
3700        // Assertion: no prefix frame reverted.
3701        if sim.any_revert {
3702            return Ok(FrameValidationOutcome {
3703                passed: false,
3704                violation: Some("validation prefix frame reverted".to_string()),
3705                reservation_ceiling,
3706                accessed_paymaster,
3707                touched_sender_slots,
3708            });
3709        }
3710
3711        // Assertion: the prefix established a payer (verify/pay frames must
3712        // APPROVE-payment; otherwise the transaction has no payer).
3713        if sim.payer_address.is_none() {
3714            return Ok(FrameValidationOutcome {
3715                passed: false,
3716                violation: Some("validation prefix did not establish a payer".to_string()),
3717                reservation_ceiling,
3718                accessed_paymaster,
3719                touched_sender_slots,
3720            });
3721        }
3722
3723        // Assertion: a deploy frame must leave non-empty code at the sender.
3724        if prefix.deploy_index.is_some() {
3725            let code = vm.db.get_account_code(sender).map_err(VMError::from)?;
3726            if code.is_empty() {
3727                return Ok(FrameValidationOutcome {
3728                    passed: false,
3729                    violation: Some(format!("{:?}", FrameSimViolation::DeployInstalledNoCode)),
3730                    reservation_ceiling,
3731                    accessed_paymaster,
3732                    touched_sender_slots,
3733                });
3734            }
3735        }
3736
3737        // Assertion: total simulated prefix gas within the verify-gas budget.
3738        if sim.total_gas_used > FRAME_TX_MAX_VERIFY_GAS {
3739            return Ok(FrameValidationOutcome {
3740                passed: false,
3741                violation: Some(format!(
3742                    "validation prefix gas {} exceeds MAX_VERIFY_GAS {}",
3743                    sim.total_gas_used, FRAME_TX_MAX_VERIFY_GAS
3744                )),
3745                reservation_ceiling,
3746                accessed_paymaster,
3747                touched_sender_slots,
3748            });
3749        }
3750
3751        Ok(FrameValidationOutcome {
3752            passed: true,
3753            violation: None,
3754            reservation_ceiling,
3755            accessed_paymaster,
3756            touched_sender_slots,
3757        })
3758    }
3759
3760    /// Mempool reservation ceiling for a frame transaction:
3761    /// `max_gas * max_fee_per_gas + len(blob_hashes) * 131072 * max_fee_per_blob_gas`,
3762    /// saturating.
3763    ///
3764    /// The consensus `max_cost` that APPROVE collects prices blobs at the
3765    /// including block's `blob_base_fee` (EIP-8141 §Gas Accounting, TXPARAM 0x06;
3766    /// `load_tx_param` 0x06 in `opcode_handlers/frame_tx.rs`). That rate is not
3767    /// known at admission — the simulation runs against the current head, while
3768    /// execution charges the base fee of whichever later block includes the
3769    /// transaction — and the blob base fee moves per block, so pricing the
3770    /// reservation at the head's rate could reserve less than the eventual charge.
3771    /// `max_fee_per_blob_gas >= blob_base_fee` is an inclusion condition
3772    /// (EIP-8141 §Blob handling), so the declared max rate bounds every block that
3773    /// can include the transaction and keeps this a true ceiling.
3774    ///
3775    /// Intentionally saturating (not checked): the TXPARAM 0x06 consensus handler
3776    /// uses checked_mul/checked_add and halts on overflow. Saturating to
3777    /// `U256::MAX` here only makes the reservation larger, never smaller.
3778    fn frame_tx_reservation_ceiling(frame_tx: &ethrex_common::types::FrameTransaction) -> U256 {
3779        let gas_cost =
3780            U256::from(frame_tx.max_fee_per_gas).saturating_mul(U256::from(frame_tx.max_gas()));
3781        let blob_cost = U256::from(frame_tx.blob_versioned_hashes.len())
3782            .saturating_mul(U256::from(131072u64))
3783            .saturating_mul(frame_tx.max_fee_per_blob_gas);
3784        gas_cost.saturating_add(blob_cost)
3785    }
3786
3787    pub fn get_state_transitions(
3788        db: &mut GeneralizedDatabase,
3789    ) -> Result<Vec<AccountUpdate>, EvmError> {
3790        Ok(db.get_state_transitions()?)
3791    }
3792
3793    pub fn get_state_transitions_tx(
3794        db: &mut GeneralizedDatabase,
3795    ) -> Result<Vec<AccountUpdate>, EvmError> {
3796        Ok(db.get_state_transitions_tx()?)
3797    }
3798
3799    pub fn process_withdrawals(
3800        db: &mut GeneralizedDatabase,
3801        withdrawals: &[Withdrawal],
3802    ) -> Result<(), EvmError> {
3803        // For every withdrawal we increment the target account's balance
3804        for (address, increment) in withdrawals
3805            .iter()
3806            .filter(|withdrawal| withdrawal.amount > 0)
3807            .map(|w| (w.address, u128::from(w.amount) * u128::from(GWEI_TO_WEI)))
3808        {
3809            let account = db
3810                .get_account_mut(address)
3811                .map_err(|_| EvmError::DB(format!("Withdrawal account {address} not found")))?;
3812
3813            let initial_balance = account.info.balance;
3814            account.info.balance += increment.into();
3815            let new_balance = account.info.balance;
3816
3817            // Record balance change for BAL (EIP-7928)
3818            if let Some(recorder) = db.bal_recorder_mut() {
3819                recorder.set_initial_balance(address, initial_balance);
3820                recorder.record_balance_change(address, new_balance);
3821            }
3822        }
3823        Ok(())
3824    }
3825
3826    // SYSTEM CONTRACTS
3827    pub fn beacon_root_contract_call(
3828        block_header: &BlockHeader,
3829        db: &mut GeneralizedDatabase,
3830        vm_type: VMType,
3831        crypto: &dyn Crypto,
3832    ) -> Result<(), EvmError> {
3833        if let VMType::L2(_) = vm_type {
3834            return Err(EvmError::InvalidEVM(
3835                "beacon_root_contract_call should not be called for L2 VM".to_string(),
3836            ));
3837        }
3838
3839        let beacon_root = block_header.parent_beacon_block_root.ok_or_else(|| {
3840            EvmError::Header("parent_beacon_block_root field is missing".to_string())
3841        })?;
3842
3843        generic_system_contract_levm(
3844            block_header,
3845            Bytes::copy_from_slice(beacon_root.as_bytes()),
3846            db,
3847            BEACON_ROOTS_ADDRESS.address,
3848            SYSTEM_ADDRESS,
3849            vm_type,
3850            crypto,
3851        )?;
3852        Ok(())
3853    }
3854
3855    pub fn process_block_hash_history(
3856        block_header: &BlockHeader,
3857        db: &mut GeneralizedDatabase,
3858        vm_type: VMType,
3859        crypto: &dyn Crypto,
3860    ) -> Result<(), EvmError> {
3861        if let VMType::L2(_) = vm_type {
3862            return Err(EvmError::InvalidEVM(
3863                "process_block_hash_history should not be called for L2 VM".to_string(),
3864            ));
3865        }
3866
3867        generic_system_contract_levm(
3868            block_header,
3869            Bytes::copy_from_slice(block_header.parent_hash.as_bytes()),
3870            db,
3871            HISTORY_STORAGE_ADDRESS.address,
3872            SYSTEM_ADDRESS,
3873            vm_type,
3874            crypto,
3875        )?;
3876        Ok(())
3877    }
3878
3879    /// Install the canonical EIP-8141 expiry verifier runtime code at
3880    /// EXPIRY_VERIFIER on Hegota activation (EIP-8141: "At
3881    /// activation, clients must install..."). Idempotent: writes only when
3882    /// the existing code differs, so exactly one account update is produced
3883    /// (at the first Hegota block) and none afterwards.
3884    pub fn install_expiry_verifier_code(
3885        db: &mut GeneralizedDatabase,
3886        crypto: &dyn Crypto,
3887    ) -> Result<(), EvmError> {
3888        // Predeploy convention (matches the genesis predeploys 4788/2935/7002/7251).
3889        const PREDEPLOY_NONCE: u64 = 1;
3890
3891        let current = db.get_account_code(EXPIRY_VERIFIER_PREDEPLOY.address)?;
3892        if current.code() == EXPIRY_VERIFIER_RUNTIME_BYTECODE.as_slice() {
3893            return Ok(());
3894        }
3895        let code = Code::from_bytecode(
3896            Bytes::from_static(&EXPIRY_VERIFIER_RUNTIME_BYTECODE),
3897            crypto,
3898        );
3899        let code_hash = code.hash;
3900        // Record BAL code/nonce changes if recording is active, so a BAL
3901        // reconstructor reproduces the same post-state (it takes nonce from
3902        // prestate otherwise).
3903        if let Some(recorder) = db.bal_recorder_mut() {
3904            recorder.record_code_change(EXPIRY_VERIFIER_PREDEPLOY.address, code.code_bytes());
3905            recorder.record_nonce_change(EXPIRY_VERIFIER_PREDEPLOY.address, PREDEPLOY_NONCE);
3906        }
3907        let acc = db
3908            .get_account_mut(EXPIRY_VERIFIER_PREDEPLOY.address)
3909            .map_err(EvmError::from)?;
3910        acc.info.code_hash = code_hash;
3911        acc.info.nonce = PREDEPLOY_NONCE;
3912        db.codes.entry(code_hash).or_insert(code);
3913        Ok(())
3914    }
3915
3916    pub(crate) fn read_withdrawal_requests(
3917        block_header: &BlockHeader,
3918        db: &mut GeneralizedDatabase,
3919        vm_type: VMType,
3920        crypto: &dyn Crypto,
3921    ) -> Result<ExecutionReport, EvmError> {
3922        if let VMType::L2(_) = vm_type {
3923            return Err(EvmError::InvalidEVM(
3924                "read_withdrawal_requests should not be called for L2 VM".to_string(),
3925            ));
3926        }
3927
3928        let report = generic_system_contract_levm(
3929            block_header,
3930            Bytes::new(),
3931            db,
3932            WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS.address,
3933            SYSTEM_ADDRESS,
3934            vm_type,
3935            crypto,
3936        )?;
3937
3938        match report.result {
3939            TxResult::Success => Ok(report),
3940            // EIP-7002 specifies that a failed system call invalidates the entire block.
3941            TxResult::Revert(vm_error) => Err(EvmError::SystemContractCallFailed(format!(
3942                "REVERT when reading withdrawal requests with error: {vm_error:?}. According to EIP-7002, the revert of this system call invalidates the block.",
3943            ))),
3944        }
3945    }
3946
3947    pub(crate) fn dequeue_consolidation_requests(
3948        block_header: &BlockHeader,
3949        db: &mut GeneralizedDatabase,
3950        vm_type: VMType,
3951        crypto: &dyn Crypto,
3952    ) -> Result<ExecutionReport, EvmError> {
3953        if let VMType::L2(_) = vm_type {
3954            return Err(EvmError::InvalidEVM(
3955                "dequeue_consolidation_requests should not be called for L2 VM".to_string(),
3956            ));
3957        }
3958
3959        let report = generic_system_contract_levm(
3960            block_header,
3961            Bytes::new(),
3962            db,
3963            CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS.address,
3964            SYSTEM_ADDRESS,
3965            vm_type,
3966            crypto,
3967        )?;
3968
3969        match report.result {
3970            TxResult::Success => Ok(report),
3971            // EIP-7251 specifies that a failed system call invalidates the entire block.
3972            TxResult::Revert(vm_error) => Err(EvmError::SystemContractCallFailed(format!(
3973                "REVERT when dequeuing consolidation requests with error: {vm_error:?}. According to EIP-7251, the revert of this system call invalidates the block.",
3974            ))),
3975        }
3976    }
3977
3978    pub(crate) fn read_builder_deposit_requests(
3979        block_header: &BlockHeader,
3980        db: &mut GeneralizedDatabase,
3981        vm_type: VMType,
3982        crypto: &dyn Crypto,
3983    ) -> Result<ExecutionReport, EvmError> {
3984        if let VMType::L2(_) = vm_type {
3985            return Err(EvmError::InvalidEVM(
3986                "read_builder_deposit_requests should not be called for L2 VM".to_string(),
3987            ));
3988        }
3989
3990        let report = generic_system_contract_levm(
3991            block_header,
3992            Bytes::new(),
3993            db,
3994            BUILDER_DEPOSIT_CONTRACT_ADDRESS.address,
3995            SYSTEM_ADDRESS,
3996            vm_type,
3997            crypto,
3998        )?;
3999
4000        match report.result {
4001            TxResult::Success => Ok(report),
4002            // EIP-8282 specifies that a failed system call invalidates the entire block.
4003            TxResult::Revert(vm_error) => Err(EvmError::SystemContractCallFailed(format!(
4004                "REVERT when reading builder deposit requests with error: {vm_error:?}. According to EIP-8282, the revert of this system call invalidates the block.",
4005            ))),
4006        }
4007    }
4008
4009    pub(crate) fn dequeue_builder_exit_requests(
4010        block_header: &BlockHeader,
4011        db: &mut GeneralizedDatabase,
4012        vm_type: VMType,
4013        crypto: &dyn Crypto,
4014    ) -> Result<ExecutionReport, EvmError> {
4015        if let VMType::L2(_) = vm_type {
4016            return Err(EvmError::InvalidEVM(
4017                "dequeue_builder_exit_requests should not be called for L2 VM".to_string(),
4018            ));
4019        }
4020
4021        let report = generic_system_contract_levm(
4022            block_header,
4023            Bytes::new(),
4024            db,
4025            BUILDER_EXIT_CONTRACT_ADDRESS.address,
4026            SYSTEM_ADDRESS,
4027            vm_type,
4028            crypto,
4029        )?;
4030
4031        match report.result {
4032            TxResult::Success => Ok(report),
4033            // EIP-8282 specifies that a failed system call invalidates the entire block.
4034            TxResult::Revert(vm_error) => Err(EvmError::SystemContractCallFailed(format!(
4035                "REVERT when dequeuing builder exit requests with error: {vm_error:?}. According to EIP-8282, the revert of this system call invalidates the block.",
4036            ))),
4037        }
4038    }
4039
4040    pub fn create_access_list(
4041        mut tx: GenericTransaction,
4042        header: &BlockHeader,
4043        db: &mut GeneralizedDatabase,
4044        vm_type: VMType,
4045        crypto: &dyn Crypto,
4046    ) -> Result<(ExecutionResult, AccessList), VMError> {
4047        let mut env = env_from_generic(&tx, header, db, vm_type)?;
4048
4049        adjust_disabled_base_fee(&mut env);
4050
4051        let converted_tx = generic_tx_to_transaction(&tx)?;
4052        let mut vm = vm_from_generic(&converted_tx, env.clone(), db, vm_type, crypto, None)?;
4053
4054        vm.stateless_execute()?;
4055
4056        // Execute the tx again, now with the created access list.
4057        tx.access_list = vm.substate.make_access_list();
4058        let converted_tx = generic_tx_to_transaction(&tx)?;
4059        let mut vm = vm_from_generic(&converted_tx, env, db, vm_type, crypto, None)?;
4060
4061        let report = vm.stateless_execute()?;
4062
4063        Ok((
4064            report.into(),
4065            tx.access_list
4066                .into_iter()
4067                .map(|x| (x.address, x.storage_keys))
4068                .collect(),
4069        ))
4070    }
4071
4072    pub fn prepare_block(
4073        block: &Block,
4074        db: &mut GeneralizedDatabase,
4075        vm_type: VMType,
4076        crypto: &dyn Crypto,
4077    ) -> Result<(), EvmError> {
4078        let chain_config = db.store.get_chain_config()?;
4079        let block_header = &block.header;
4080        let fork = chain_config.fork(block_header.timestamp);
4081
4082        // TODO: I don't like deciding the behavior based on the VMType here.
4083        if let VMType::L2(_) = vm_type {
4084            return Ok(());
4085        }
4086
4087        // EIP-8141: the expiry verifier predeploy must exist from Hegota
4088        // activation onward. Idempotent install; also
4089        // hooked in apply_system_calls for the payload-build path.
4090        if fork >= Fork::Hegota {
4091            Self::install_expiry_verifier_code(db, crypto)?;
4092        }
4093
4094        if block_header.parent_beacon_block_root.is_some() && fork >= Fork::Cancun {
4095            Self::beacon_root_contract_call(block_header, db, vm_type, crypto)?;
4096        }
4097
4098        if fork >= Fork::Prague {
4099            //eip 2935: stores parent block hash in system contract
4100            Self::process_block_hash_history(block_header, db, vm_type, crypto)?;
4101        }
4102        Ok(())
4103    }
4104}
4105
4106pub fn generic_system_contract_levm(
4107    block_header: &BlockHeader,
4108    calldata: Bytes,
4109    db: &mut GeneralizedDatabase,
4110    contract_address: Address,
4111    system_address: Address,
4112    vm_type: VMType,
4113    crypto: &dyn Crypto,
4114) -> Result<ExecutionReport, EvmError> {
4115    let chain_config = db.store.get_chain_config()?;
4116    let config = EVMConfig::new_from_chain_config(&chain_config, block_header);
4117    let env = Environment {
4118        origin: system_address,
4119        // EIPs 2935, 4788, 7002 and 7251 dictate that the system calls have a gas
4120        // limit of 30 million and they do not use intrinsic gas. EELS
4121        // (process_unchecked_system_transaction) builds the message with
4122        // intrinsic_regular_gas=0 and gas=SYSTEM_TRANSACTION_GAS, so the contract
4123        // gets the full SYS_CALL_GAS_LIMIT. We match that by NOT padding the limit
4124        // here and by zeroing the intrinsic for system calls in the default hook.
4125        // (A previous `+ TX_BASE_COST` buffer assumed the flat 21000 Prague
4126        // intrinsic; EIP-2780 lowered Amsterdam's intrinsic to 15000, so the buffer
4127        // over-funded the frame by 6000 and let an OOG-by-1 system contract avoid
4128        // running out of gas.)
4129        gas_limit: SYS_CALL_GAS_LIMIT,
4130        block_number: block_header.number,
4131        coinbase: block_header.coinbase,
4132        timestamp: block_header.timestamp,
4133        prev_randao: Some(block_header.prev_randao),
4134        base_fee_per_gas: U256::zero(),
4135        gas_price: U256::zero(),
4136        block_excess_blob_gas: block_header.excess_blob_gas,
4137        block_blob_gas_used: block_header.blob_gas_used,
4138        // Use the actual block's gas_limit so EIP-8037 cost_per_state_byte is correct.
4139        // The gas-allowance check is bypassed via `is_system_call` below; feeding
4140        // i64::MAX here would make cpsb astronomically large and OOG any SSTORE
4141        // that charges state gas (e.g. EIP-2935, EIP-4788 new-slot writes).
4142        block_gas_limit: block_header.gas_limit,
4143        is_system_call: true,
4144        config,
4145        ..Default::default()
4146    };
4147
4148    // Invariant: with a zero gas price (and `is_system_call` making the hook
4149    // skip the sender path entirely) a system call leaves no SYSTEM_ADDRESS
4150    // state behind — no nonce bump, no balance change, not even a read. If
4151    // this ever becomes non-zero, the hook's system-call branches must be
4152    // revisited.
4153    debug_assert!(
4154        env.gas_price.is_zero() && env.base_fee_per_gas.is_zero(),
4155        "system calls must run with a zero gas price"
4156    );
4157
4158    // This check is not necessary in practice, since contract deployment has succesfully happened in all relevant testnets and mainnet
4159    // However, it's necessary to pass some of the Hive tests related to system contract deployment, which is why we have it
4160    // The error that should be returned for the relevant contracts is indicated in the following:
4161    // https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7002.md#empty-code-failure
4162    // https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7251.md#empty-code-failure
4163    // EIP-8282 applies the same empty-code-failure rule to the builder deposit/exit predeploys.
4164    // No extra fork guard is needed here: builder predeploy addresses only reach this function via
4165    // extract_all_requests_levm, which gates them on fork >= Amsterdam, so a Prague/Osaka block
4166    // never passes a builder address to this check.
4167    if PRAGUE_SYSTEM_CONTRACTS
4168        .iter()
4169        .chain(AMSTERDAM_REQUEST_PREDEPLOYS.iter())
4170        .any(|contract| contract.address == contract_address)
4171        && db.get_account_code(contract_address)?.is_empty()
4172    {
4173        return Err(EvmError::SystemContractCallFailed(format!(
4174            "System contract: {contract_address} has no code after deployment"
4175        )));
4176    };
4177
4178    let tx = &Transaction::EIP1559Transaction(EIP1559Transaction {
4179        to: TxKind::Call(contract_address),
4180        value: U256::zero(),
4181        data: calldata,
4182        ..Default::default()
4183    });
4184    // EIP-7928: Mark BAL recorder as in system call mode to filter SYSTEM_ADDRESS changes
4185    if let Some(recorder) = db.bal_recorder.as_mut() {
4186        recorder.enter_system_call();
4187    }
4188
4189    let result = VM::new(
4190        env,
4191        db,
4192        tx,
4193        LevmCallTracer::disabled(),
4194        vm_type,
4195        crypto,
4196        None,
4197    )
4198    .and_then(|mut vm| vm.execute())
4199    .map_err(EvmError::from);
4200
4201    // EIP-7928: Exit system call mode before restoring accounts (must run even on error)
4202    if let Some(recorder) = db.bal_recorder.as_mut() {
4203        recorder.exit_system_call();
4204    }
4205
4206    result
4207}
4208
4209#[allow(unreachable_code)]
4210#[allow(unused_variables)]
4211pub fn extract_all_requests_levm(
4212    receipts: &[Receipt],
4213    db: &mut GeneralizedDatabase,
4214    header: &BlockHeader,
4215    vm_type: VMType,
4216    crypto: &dyn Crypto,
4217) -> Result<Vec<Requests>, EvmError> {
4218    if let VMType::L2(_) = vm_type {
4219        return Err(EvmError::InvalidEVM(
4220            "extract_all_requests_levm should not be called for L2 VM".to_string(),
4221        ));
4222    }
4223
4224    let chain_config = db.store.get_chain_config()?;
4225    let fork = chain_config.fork(header.timestamp);
4226
4227    if fork < Fork::Prague {
4228        return Ok(Default::default());
4229    }
4230
4231    let withdrawals_data: Vec<u8> = LEVM::read_withdrawal_requests(header, db, vm_type, crypto)?
4232        .output
4233        .into();
4234    let consolidation_data: Vec<u8> =
4235        LEVM::dequeue_consolidation_requests(header, db, vm_type, crypto)?
4236            .output
4237            .into();
4238
4239    let deposits = Requests::from_deposit_receipts(chain_config.deposit_contract_address, receipts)
4240        .ok_or(EvmError::InvalidDepositRequest)?;
4241    let withdrawals = Requests::from_withdrawals_data(withdrawals_data);
4242    let consolidation = Requests::from_consolidation_data(consolidation_data);
4243
4244    let mut requests = vec![deposits, withdrawals, consolidation];
4245
4246    // EIP-8282 (Amsterdam): builder deposit (0x03) and builder exit (0x04) requests.
4247    // Prague (18) < Amsterdam (25), so this needs a separate explicit gate; appending
4248    // unconditionally after the Prague early-return above would emit these on Prague/Osaka blocks.
4249    if fork >= Fork::Amsterdam {
4250        // Grow to exactly 5 once, avoiding a realloc on each of the two pushes below.
4251        requests.reserve_exact(2);
4252        let builder_deposit_data: Vec<u8> =
4253            LEVM::read_builder_deposit_requests(header, db, vm_type, crypto)?
4254                .output
4255                .into();
4256        let builder_exit_data: Vec<u8> =
4257            LEVM::dequeue_builder_exit_requests(header, db, vm_type, crypto)?
4258                .output
4259                .into();
4260        requests.push(Requests::from_builder_deposit_data(builder_deposit_data));
4261        requests.push(Requests::from_builder_exit_data(builder_exit_data));
4262    }
4263
4264    Ok(requests)
4265}
4266
4267/// Calculating gas_price according to EIP-1559 rules
4268/// See https://github.com/ethereum/go-ethereum/blob/7ee9a6e89f59cee21b5852f5f6ffa2bcfc05a25f/internal/ethapi/transaction_args.go#L430
4269pub fn calculate_gas_price_for_generic(tx: &GenericTransaction, basefee: u64) -> U256 {
4270    if !tx.gas_price.is_zero() {
4271        // Legacy gas field was specified, use it
4272        tx.gas_price
4273    } else {
4274        // Backfill the legacy gas price for EVM execution, (zero if max_fee_per_gas is zero)
4275        min(
4276            tx.max_priority_fee_per_gas.unwrap_or(0) + basefee,
4277            tx.max_fee_per_gas.unwrap_or(0),
4278        )
4279        .into()
4280    }
4281}
4282
4283pub fn calculate_gas_price_for_tx(
4284    tx: &Transaction,
4285    mut fee_per_gas: u64,
4286    vm_type: &VMType,
4287) -> Result<U256, VMError> {
4288    let Some(max_priority_fee) = tx.max_priority_fee() else {
4289        // Legacy transaction
4290        return Ok(tx.gas_price());
4291    };
4292
4293    let max_fee_per_gas = tx.max_fee_per_gas().ok_or(VMError::TxValidation(
4294        TxValidationError::InsufficientMaxFeePerGas,
4295    ))?;
4296
4297    if let VMType::L2(fee_config) = vm_type
4298        && let Some(operator_fee_config) = &fee_config.operator_fee_config
4299    {
4300        fee_per_gas += operator_fee_config.operator_fee_per_gas;
4301    }
4302
4303    if fee_per_gas > max_fee_per_gas {
4304        return Err(VMError::TxValidation(
4305            TxValidationError::InsufficientMaxFeePerGas,
4306        ));
4307    }
4308
4309    Ok(min(max_priority_fee + fee_per_gas, max_fee_per_gas).into())
4310}
4311
4312/// When basefee tracking is disabled  (ie. env.disable_base_fee = true; env.disable_block_gas_limit = true;)
4313/// and no gas prices were specified, lower the basefee to 0 to avoid breaking EVM invariants (basefee < feecap)
4314/// See https://github.com/ethereum/go-ethereum/blob/00294e9d28151122e955c7db4344f06724295ec5/core/vm/evm.go#L137
4315pub(crate) fn adjust_disabled_base_fee(env: &mut Environment) {
4316    if env.gas_price == U256::zero() {
4317        env.base_fee_per_gas = U256::zero();
4318    }
4319    // Same for the blob fee: a call object that opts out by passing `maxFeePerBlobGas: 0`
4320    // must not be rejected for undercutting the block's blob base fee.
4321    // `base_blob_fee_per_gas` is the field the validation and the up-front cost both read
4322    // (`validate_max_fee_per_blob_gas`, `deduct_caller`), and it is computed in
4323    // `env_from_generic` before this runs, so it is what has to be lowered. Clearing
4324    // `block_excess_blob_gas` instead had no effect: nothing reads that field, and the
4325    // blob base fee derived from a zero excess still has a floor of 1, which a zero fee
4326    // cap undercuts just the same.
4327    if env
4328        .tx_max_fee_per_blob_gas
4329        .is_some_and(|v| v == U256::zero())
4330    {
4331        env.base_blob_fee_per_gas = U256::zero();
4332    }
4333}
4334
4335/// When l2 fees are disabled (ie. env.gas_price = 0), set fee configs to None to avoid breaking failing fee deductions
4336fn adjust_disabled_l2_fees(env: &Environment, vm_type: VMType) -> VMType {
4337    if env.gas_price == U256::zero()
4338        && let VMType::L2(fee_config) = vm_type
4339    {
4340        // Don't deduct fees if no gas price is set
4341        return VMType::L2(FeeConfig {
4342            operator_fee_config: None,
4343            l1_fee_config: None,
4344            ..fee_config
4345        });
4346    }
4347    vm_type
4348}
4349
4350pub(crate) fn env_from_generic(
4351    tx: &GenericTransaction,
4352    header: &BlockHeader,
4353    db: &GeneralizedDatabase,
4354    vm_type: VMType,
4355) -> Result<Environment, VMError> {
4356    let chain_config = db.store.get_chain_config()?;
4357    let gas_price =
4358        calculate_gas_price_for_generic(tx, header.base_fee_per_gas.unwrap_or(INITIAL_BASE_FEE));
4359    let block_excess_blob_gas = header.excess_blob_gas;
4360    let config = EVMConfig::new_from_chain_config(&chain_config, header);
4361
4362    // slot_number: default a missing value to zero exactly like
4363    // `setup_env_with_config` (the block-execution env builder) does, rather
4364    // than erroring on Amsterdam+. A canonical Amsterdam header always carries
4365    // a slot — `validate_prague_header_fields` rejects one that doesn't, the
4366    // genesis builder fills in Some(0), and engine_newPayloadV5 requires the
4367    // field — so this branch is not reachable on a well-formed chain. It is
4368    // defense in depth, and it belongs on the execution side (where a missing
4369    // slot would be a consensus fault) rather than in simulation, whose job is
4370    // to predict what execution does: a header the executor would happily run
4371    // with SLOTNUM reading 0 must not make eth_call fail. That divergence is
4372    // reachable if a devnet's fork timestamps are moved so that Amsterdam
4373    // retroactively covers already-stored pre-Amsterdam headers.
4374    // For L2 chains, slot_number is always 0.
4375    let slot_number = if let VMType::L2(_) = vm_type {
4376        U256::zero()
4377    } else {
4378        header.slot_number.map(U256::from).unwrap_or(U256::zero())
4379    };
4380
4381    Ok(Environment {
4382        origin: tx.from.0.into(),
4383        gas_limit: tx
4384            .gas
4385            .unwrap_or(get_max_allowed_gas_limit(header.gas_limit, config.fork)), // Ensure tx doesn't fail due to gas limit
4386        config,
4387        block_number: header.number,
4388        coinbase: header.coinbase,
4389        timestamp: header.timestamp,
4390        prev_randao: Some(header.prev_randao),
4391        slot_number,
4392        chain_id: chain_config.chain_id.into(),
4393        base_fee_per_gas: header.base_fee_per_gas.unwrap_or_default().into(),
4394        base_blob_fee_per_gas: get_base_fee_per_blob_gas(block_excess_blob_gas, &config)?,
4395        gas_price,
4396        block_excess_blob_gas,
4397        block_blob_gas_used: header.blob_gas_used,
4398        tx_blob_hashes: tx.blob_versioned_hashes.clone(),
4399        tx_max_priority_fee_per_gas: tx.max_priority_fee_per_gas.map(U256::from),
4400        tx_max_fee_per_gas: tx.max_fee_per_gas.map(U256::from),
4401        tx_max_fee_per_blob_gas: tx.max_fee_per_blob_gas,
4402        tx_nonce: tx.nonce.unwrap_or_default(),
4403        block_gas_limit: header.gas_limit,
4404        difficulty: header.difficulty,
4405        is_privileged: false,
4406        fee_token: tx.fee_token,
4407        disable_balance_check: false,
4408        // Every `env_from_generic` caller is a simulation RPC (eth_call,
4409        // eth_estimateGas, eth_createAccessList, debug_traceCall). Those run
4410        // relaxed messages with no nonce enforcement: a call object without
4411        // `nonce` defaults `tx_nonce` to 0 above, which the hook would otherwise
4412        // reject for any sender whose nonce is nonzero.
4413        disable_nonce_check: true,
4414        // Same reasoning: a caller passing a `gas` above the block allowance or above the
4415        // EIP-7825 per-transaction cap still expects an answer, because nothing is being
4416        // submitted. `eth_createAccessList` enforced both until now, which rejected call
4417        // objects that the other simulation RPCs answered, and which tools that pass the
4418        // block gas limit as `gas` hit routinely.
4419        disable_gas_allowance_check: true,
4420        // Same reasoning: a simulated call carries no signature, so EIP-3607 has nothing
4421        // to protect, and a contract as `from` is a normal thing to simulate.
4422        disable_sender_eoa_check: true,
4423        // Lifted per caller: `eth_call` and `debug_traceCall` may spend their whole gas
4424        // limit on execution after Amsterdam, `eth_estimateGas` and `eth_createAccessList`
4425        // keep the transaction rule so their answers hold for a real transaction.
4426        disable_execution_gas_cap: false,
4427        is_system_call: false,
4428    })
4429}
4430
4431/// Build a synthetic `Transaction` from a `GenericTransaction` for simulation paths.
4432///
4433/// Split out from `vm_from_generic` so the caller owns the resulting `Transaction` for at least
4434/// the VM's lifetime — `VM` now borrows its tx (`&'a Transaction`) instead of cloning it.
4435///
4436/// The envelope fields are carried over even though execution reads almost none of them,
4437/// because the L2 prices its L1 data fee from `Transaction::length()` — the RLP-encoded byte
4438/// count — and reserves that as gas before execution. Leaving them at their defaults made a
4439/// simulated transaction encode up to ~104 bytes shorter than the signed one it stands for
4440/// (a zero `U256` is one RLP byte, a real signature component is 33), so `eth_estimateGas`
4441/// under-reserved the L1 fee and the transaction it was estimating ran out of gas. The
4442/// signature is stamped at full width for the same reason, and deliberately at the maximum:
4443/// a real component can be shorter after leading-zero trimming, and over-reserving the L1
4444/// fee is safe where under-reserving is not.
4445fn generic_tx_to_transaction(tx: &GenericTransaction) -> Result<Transaction, VMError> {
4446    // Not read during execution — the sender comes from `env.origin` — so this only has to
4447    // encode to the same width as a real signature.
4448    let (signature_r, signature_s) = (U256::MAX, U256::MAX);
4449    let nonce = tx.nonce.unwrap_or_default();
4450    let gas_limit = tx.gas.unwrap_or_default();
4451    let max_fee_per_gas = tx.max_fee_per_gas.unwrap_or_default();
4452    let max_priority_fee_per_gas = tx.max_priority_fee_per_gas.unwrap_or_default();
4453    let chain_id = tx.chain_id.unwrap_or_default();
4454
4455    Ok(match &tx.authorization_list {
4456        Some(authorization_list) => Transaction::EIP7702Transaction(EIP7702Transaction {
4457            to: match tx.to {
4458                TxKind::Call(to) => to,
4459                TxKind::Create => {
4460                    return Err(InternalError::msg("Generic Tx cannot be create type").into());
4461                }
4462            },
4463            value: tx.value,
4464            data: tx.input.clone(),
4465            access_list: tx
4466                .access_list
4467                .iter()
4468                .map(|list| (list.address, list.storage_keys.clone()))
4469                .collect(),
4470            authorization_list: authorization_list
4471                .iter()
4472                .map(|auth| Into::<AuthorizationTuple>::into(auth.clone()))
4473                .collect(),
4474            nonce,
4475            gas_limit,
4476            max_fee_per_gas,
4477            max_priority_fee_per_gas,
4478            chain_id,
4479            signature_r,
4480            signature_s,
4481            ..Default::default()
4482        }),
4483        None => Transaction::EIP1559Transaction(EIP1559Transaction {
4484            to: tx.to.clone(),
4485            value: tx.value,
4486            data: tx.input.clone(),
4487            access_list: tx
4488                .access_list
4489                .iter()
4490                .map(|list| (list.address, list.storage_keys.clone()))
4491                .collect(),
4492            nonce,
4493            gas_limit,
4494            max_fee_per_gas,
4495            max_priority_fee_per_gas,
4496            chain_id,
4497            signature_r,
4498            signature_s,
4499            ..Default::default()
4500        }),
4501    })
4502}
4503
4504fn vm_from_generic<'a>(
4505    tx: &'a Transaction,
4506    env: Environment,
4507    db: &'a mut GeneralizedDatabase,
4508    vm_type: VMType,
4509    crypto: &'a dyn Crypto,
4510    stateless_validator: Option<&'a dyn StatelessValidator>,
4511) -> Result<VM<'a>, VMError> {
4512    let vm_type = adjust_disabled_l2_fees(&env, vm_type);
4513    VM::new(
4514        env,
4515        db,
4516        tx,
4517        LevmCallTracer::disabled(),
4518        vm_type,
4519        crypto,
4520        stateless_validator,
4521    )
4522}
4523
4524pub fn get_max_allowed_gas_limit(block_gas_limit: u64, fork: Fork) -> u64 {
4525    // EIP-7825 imposes a flat per-tx gas cap (POST_OSAKA_GAS_LIMIT_CAP) from
4526    // Osaka through the BPO forks. Amsterdam supersedes it with the EIP-8037 2D
4527    // gas model: tx.gas may exceed the flat cap (the excess funds the state-gas
4528    // reservoir) and is instead bounded by block_gas_limit on the state
4529    // dimension, so capping the estimateGas ceiling at the flat value would
4530    // prevent convergence for state-heavy creations. Mirror the Osaka-only
4531    // gating used at the other cap sites (block validation, default_hook).
4532    if fork >= Fork::Osaka && fork < Fork::Amsterdam {
4533        POST_OSAKA_GAS_LIMIT_CAP
4534    } else {
4535        block_gas_limit
4536    }
4537}
4538
4539/// Format a balance diff (signed wei) and try to identify it as a multiple of
4540/// well-known EIP-8037 state-gas constants (NEW_ACCOUNT, STORAGE_SET, AUTH_*),
4541/// scaled by a plausible gas_price. Best-effort hint to triage gas-accounting
4542/// drifts at a glance.
4543///
4544/// `dead_code` allowed: only reached via the L1 BAL-validation path, which is
4545/// not exercised in the L2 build profile so the per-crate analysis flags it.
4546#[allow(dead_code)]
4547fn describe_balance_diff(expected: U256, actual: U256) -> String {
4548    let (sign, mag) = if expected >= actual {
4549        ("+", expected - actual)
4550    } else {
4551        ("-", actual - expected)
4552    };
4553    let Ok(mag_u128) = u128::try_from(mag) else {
4554        return format!("{sign}{mag}");
4555    };
4556    if mag_u128 == 0 {
4557        return "0".to_string();
4558    }
4559    let cpsb: u128 = 1530;
4560    // EIP-8037 state-byte constants
4561    let consts = [
4562        ("NEW_ACCOUNT", 120u128),
4563        ("STORAGE_SET", 64),
4564        ("AUTH_BASE", 23),
4565        ("AUTH_TOTAL", 143),
4566    ];
4567    // Try common test gas_prices first, then 1 wei/gas as fallback.
4568    for &gp in &[10u128, 1, 7, 100, 1000, 1_000_000_000] {
4569        if !mag_u128.is_multiple_of(gp) {
4570            continue;
4571        }
4572        let gas = mag_u128 / gp;
4573        if !gas.is_multiple_of(cpsb) {
4574            continue;
4575        }
4576        let bytes = gas / cpsb;
4577        for (name, c) in consts {
4578            if bytes.is_multiple_of(c) {
4579                let n = bytes / c;
4580                return format!(
4581                    "{sign}{mag_u128} wei (= {gas} gas at {gp} wei/gas = {n}× {name}_state_gas)"
4582                );
4583            }
4584        }
4585    }
4586    format!("{sign}{mag_u128} wei")
4587}
4588
4589// Exercises the rayon-parallel-BAL execution path (and shares its
4590// `rayon`-gated imports), so it only builds when that feature is on.
4591#[cfg(all(test, feature = "rayon"))]
4592mod bal_tests {
4593    use super::*;
4594    use ethrex_common::H256;
4595    use ethrex_common::types::AccountState;
4596    use ethrex_common::types::block_access_list::{
4597        AccountChanges, BalanceChange, NonceChange, SlotChange, StorageChange,
4598    };
4599    use ethrex_levm::errors::DatabaseError;
4600
4601    fn addr(byte: u8) -> Address {
4602        let mut a = Address::zero();
4603        a.0[19] = byte;
4604        a
4605    }
4606
4607    /// Minimal in-memory store for testing bal_to_account_updates.
4608    struct MockStore {
4609        accounts: FxHashMap<Address, AccountState>,
4610    }
4611
4612    impl MockStore {
4613        fn new() -> Self {
4614            Self {
4615                accounts: FxHashMap::default(),
4616            }
4617        }
4618
4619        fn with_account(mut self, address: Address, state: AccountState) -> Self {
4620            self.accounts.insert(address, state);
4621            self
4622        }
4623    }
4624
4625    impl Database for MockStore {
4626        fn get_account_state(&self, address: Address) -> Result<AccountState, DatabaseError> {
4627            Ok(self.accounts.get(&address).copied().unwrap_or_default())
4628        }
4629        fn get_storage_value(&self, _: Address, _: H256) -> Result<U256, DatabaseError> {
4630            Ok(U256::zero())
4631        }
4632        fn get_block_hash(&self, _: u64) -> Result<H256, DatabaseError> {
4633            Ok(H256::zero())
4634        }
4635        fn get_chain_config(&self) -> Result<ethrex_common::types::ChainConfig, DatabaseError> {
4636            Err(DatabaseError::Custom("not implemented".into()))
4637        }
4638        fn get_account_code(&self, _: H256) -> Result<ethrex_common::types::Code, DatabaseError> {
4639            Ok(ethrex_common::types::Code::from_bytecode(
4640                Bytes::new(),
4641                &ethrex_crypto::NativeCrypto,
4642            ))
4643        }
4644        fn get_code_metadata(
4645            &self,
4646            _: H256,
4647        ) -> Result<ethrex_common::types::CodeMetadata, DatabaseError> {
4648            Ok(ethrex_common::types::CodeMetadata { length: 0 })
4649        }
4650    }
4651
4652    #[test]
4653    fn test_bal_to_account_updates_basic() {
4654        // Account with balance + nonce + storage changes → correct AccountUpdate
4655        let address = addr(1);
4656        let store = MockStore::new().with_account(
4657            address,
4658            AccountState {
4659                balance: U256::from(100),
4660                nonce: 5,
4661                code_hash: *EMPTY_KECCAK_HASH,
4662                storage_root: H256::zero(),
4663            },
4664        );
4665
4666        let bal = BlockAccessList::from_accounts(vec![
4667            AccountChanges::new(address)
4668                .with_balance_changes(vec![
4669                    BalanceChange::new(1, U256::from(90)),
4670                    BalanceChange::new(2, U256::from(80)),
4671                ])
4672                .with_nonce_changes(vec![NonceChange::new(1, 6)])
4673                .with_storage_changes(vec![SlotChange::with_changes(
4674                    U256::from(42),
4675                    vec![StorageChange::new(1, U256::from(999))],
4676                )]),
4677        ]);
4678
4679        let updates = LEVM::bal_to_account_updates(&bal, &store).unwrap();
4680        assert_eq!(updates.len(), 1);
4681        let u = &updates[0];
4682        assert_eq!(u.address, address);
4683        assert!(!u.removed);
4684        let info = u.info.as_ref().unwrap();
4685        // Last balance entry wins
4686        assert_eq!(info.balance, U256::from(80));
4687        assert_eq!(info.nonce, 6);
4688        assert_eq!(info.code_hash, *EMPTY_KECCAK_HASH);
4689        // Storage
4690        let key = ethrex_common::utils::u256_to_h256(U256::from(42));
4691        assert_eq!(*u.added_storage.get(&key).unwrap(), U256::from(999));
4692    }
4693
4694    #[test]
4695    fn test_bal_to_account_updates_highest_index_wins() {
4696        // Multiple changes per field: the last entry (highest index) wins.
4697        let address = addr(2);
4698        let store = MockStore::new().with_account(
4699            address,
4700            AccountState {
4701                balance: U256::from(1000),
4702                nonce: 0,
4703                code_hash: *EMPTY_KECCAK_HASH,
4704                storage_root: H256::zero(),
4705            },
4706        );
4707
4708        let bal = BlockAccessList::from_accounts(vec![
4709            AccountChanges::new(address).with_balance_changes(vec![
4710                BalanceChange::new(1, U256::from(900)),
4711                BalanceChange::new(2, U256::from(800)),
4712                BalanceChange::new(3, U256::from(700)),
4713            ]),
4714        ]);
4715
4716        let updates = LEVM::bal_to_account_updates(&bal, &store).unwrap();
4717        assert_eq!(updates.len(), 1);
4718        assert_eq!(updates[0].info.as_ref().unwrap().balance, U256::from(700));
4719    }
4720
4721    #[test]
4722    fn test_bal_to_account_updates_reads_only_skipped() {
4723        // Account with only storage_reads and no writes → no AccountUpdate.
4724        let address = addr(3);
4725        let store = MockStore::new();
4726
4727        let bal = BlockAccessList::from_accounts(vec![
4728            AccountChanges::new(address).with_storage_reads(vec![U256::from(1)]),
4729        ]);
4730
4731        let updates = LEVM::bal_to_account_updates(&bal, &store).unwrap();
4732        assert!(updates.is_empty());
4733    }
4734
4735    #[test]
4736    fn test_bal_to_account_updates_removal() {
4737        // Account removal (EIP-161): post-state empty but pre-state existed.
4738        let address = addr(4);
4739        let store = MockStore::new().with_account(
4740            address,
4741            AccountState {
4742                balance: U256::from(50),
4743                nonce: 1,
4744                code_hash: *EMPTY_KECCAK_HASH,
4745                storage_root: H256::zero(),
4746            },
4747        );
4748
4749        let bal = BlockAccessList::from_accounts(vec![
4750            AccountChanges::new(address)
4751                .with_balance_changes(vec![BalanceChange::new(1, U256::zero())])
4752                .with_nonce_changes(vec![NonceChange::new(1, 0)]),
4753        ]);
4754
4755        let updates = LEVM::bal_to_account_updates(&bal, &store).unwrap();
4756        assert_eq!(updates.len(), 1);
4757        assert!(updates[0].removed);
4758    }
4759
4760    #[test]
4761    fn test_bal_to_account_updates_storage_zero() {
4762        // Storage slot set to 0 → included in added_storage (valid trie deletion).
4763        let address = addr(5);
4764        let store = MockStore::new();
4765
4766        let bal = BlockAccessList::from_accounts(vec![
4767            AccountChanges::new(address).with_storage_changes(vec![SlotChange::with_changes(
4768                U256::from(7),
4769                vec![StorageChange::new(1, U256::zero())],
4770            )]),
4771        ]);
4772
4773        let updates = LEVM::bal_to_account_updates(&bal, &store).unwrap();
4774        assert_eq!(updates.len(), 1);
4775        let key = ethrex_common::utils::u256_to_h256(U256::from(7));
4776        assert_eq!(*updates[0].added_storage.get(&key).unwrap(), U256::zero());
4777    }
4778
4779    #[test]
4780    fn test_bal_to_account_updates_code_deployment() {
4781        // Code deployment → correct code_hash computed.
4782        let address = addr(6);
4783        let store = MockStore::new();
4784        let code = Bytes::from(vec![0x60, 0x00, 0x60, 0x00, 0xf3]); // PUSH0 PUSH0 RETURN
4785        let expected_hash =
4786            ethrex_common::types::Code::from_bytecode(code.clone(), &ethrex_crypto::NativeCrypto)
4787                .hash;
4788
4789        let bal = BlockAccessList::from_accounts(vec![
4790            AccountChanges::new(address)
4791                .with_code_changes(vec![
4792                    ethrex_common::types::block_access_list::CodeChange::new(1, code.clone()),
4793                ])
4794                .with_nonce_changes(vec![NonceChange::new(1, 1)]),
4795        ]);
4796
4797        let updates = LEVM::bal_to_account_updates(&bal, &store).unwrap();
4798        assert_eq!(updates.len(), 1);
4799        let u = &updates[0];
4800        assert_eq!(u.info.as_ref().unwrap().code_hash, expected_hash);
4801        assert_eq!(u.code.as_ref().unwrap().code(), &code[..]);
4802    }
4803}
4804
4805#[cfg(test)]
4806mod system_call_coinbase_tests {
4807    //! Regression tests for the system-call coinbase collision. When a block's
4808    //! fee recipient (coinbase) equals the system contract being called,
4809    //! `generic_system_contract_levm`'s post-call coinbase restore must NOT clobber
4810    //! the storage write the system call just made; otherwise the write is dropped
4811    //! from the emitted state updates and the state root diverges from other clients.
4812    use super::*;
4813    use ethrex_common::types::{AccountState, AccountUpdate, ChainConfig, Code, CodeMetadata};
4814    use ethrex_crypto::NativeCrypto;
4815    use ethrex_levm::db::Database;
4816    use ethrex_levm::errors::DatabaseError;
4817    use std::sync::Arc;
4818
4819    // EIP-2935 history-contract runtime bytecode.
4820    const HISTORY_RUNTIME_CODE: &str = concat!(
4821        "3373fffffffffffffffffffffffffffffffffffffffe1460465760203603604257",
4822        "5f35600143038111604257611fff81430311604257611fff900654",
4823        "5f5260205ff35b5f5ffd5b5f35611fff60014303065500",
4824    );
4825
4826    struct Store {
4827        chain_config: ChainConfig,
4828        history_code: Code,
4829    }
4830
4831    impl Database for Store {
4832        fn get_account_state(&self, address: Address) -> Result<AccountState, DatabaseError> {
4833            if address == HISTORY_STORAGE_ADDRESS.address {
4834                return Ok(AccountState {
4835                    nonce: 1,
4836                    code_hash: self.history_code.hash,
4837                    ..Default::default()
4838                });
4839            }
4840            Ok(AccountState::default())
4841        }
4842        fn get_storage_value(&self, _: Address, _: H256) -> Result<U256, DatabaseError> {
4843            Ok(U256::zero())
4844        }
4845        fn get_block_hash(&self, _: u64) -> Result<H256, DatabaseError> {
4846            Ok(H256::zero())
4847        }
4848        fn get_chain_config(&self) -> Result<ChainConfig, DatabaseError> {
4849            Ok(self.chain_config)
4850        }
4851        fn get_account_code(&self, code_hash: H256) -> Result<Code, DatabaseError> {
4852            if code_hash == self.history_code.hash {
4853                return Ok(self.history_code.clone());
4854            }
4855            Ok(Code::default())
4856        }
4857        fn get_code_metadata(&self, code_hash: H256) -> Result<CodeMetadata, DatabaseError> {
4858            let length = if code_hash == self.history_code.hash {
4859                self.history_code.len() as u64
4860            } else {
4861                0
4862            };
4863            Ok(CodeMetadata { length })
4864        }
4865    }
4866
4867    fn history_code() -> Code {
4868        let bytes = hex::decode(HISTORY_RUNTIME_CODE).expect("history runtime code is valid hex");
4869        Code::from_bytecode(Bytes::from(bytes), &NativeCrypto)
4870    }
4871
4872    fn prague_db() -> GeneralizedDatabase {
4873        GeneralizedDatabase::new(Arc::new(Store {
4874            chain_config: ChainConfig {
4875                prague_time: Some(0),
4876                ..Default::default()
4877            },
4878            history_code: history_code(),
4879        }))
4880    }
4881
4882    fn parent_hash_value(parent_hash: H256) -> U256 {
4883        U256::from_big_endian(parent_hash.as_bytes())
4884    }
4885
4886    fn history_slot(block_number: u64) -> H256 {
4887        H256::from_low_u64_be((block_number - 1) % 8191)
4888    }
4889
4890    /// Run the EIP-2935 system call for block 42 with the given fee recipient and
4891    /// return (slot value cached on the history contract, emitted state updates,
4892    /// parent hash).
4893    fn run_history_update(coinbase: Address) -> (Option<U256>, Vec<AccountUpdate>, H256) {
4894        let mut db = prague_db();
4895        let parent_hash = H256::from_low_u64_be(0x2935);
4896        let block_number = 42;
4897        let header = BlockHeader {
4898            parent_hash,
4899            coinbase,
4900            number: block_number,
4901            timestamp: 1,
4902            ..Default::default()
4903        };
4904
4905        LEVM::process_block_hash_history(&header, &mut db, VMType::L1, &NativeCrypto)
4906            .expect("history system call executes");
4907
4908        let slot = history_slot(block_number);
4909        let stored_value = db
4910            .current_accounts_state
4911            .get(&HISTORY_STORAGE_ADDRESS.address)
4912            .and_then(|account| account.storage.get(&slot).copied());
4913        let updates =
4914            LEVM::get_state_transitions(&mut db).expect("state transitions are generated");
4915
4916        (stored_value, updates, parent_hash)
4917    }
4918
4919    fn assert_history_write_emitted(coinbase: Address) {
4920        let (stored_value, updates, parent_hash) = run_history_update(coinbase);
4921        let slot = history_slot(42);
4922        assert_eq!(
4923            stored_value,
4924            Some(parent_hash_value(parent_hash)),
4925            "history storage must hold the parent hash after the system call"
4926        );
4927        assert!(
4928            updates.iter().any(|update| {
4929                update.address == HISTORY_STORAGE_ADDRESS.address
4930                    && update.added_storage.get(&slot) == Some(&parent_hash_value(parent_hash))
4931            }),
4932            "the history-contract storage write must be emitted as a state update"
4933        );
4934    }
4935
4936    #[test]
4937    fn ordinary_coinbase_preserves_history_storage_write() {
4938        assert_history_write_emitted(Address::from_low_u64_be(0xbeef));
4939    }
4940
4941    /// Regression: a fee recipient equal to the EIP-2935 history contract must not
4942    /// cause the system call's storage write to be dropped by the coinbase restore.
4943    #[test]
4944    fn history_address_coinbase_preserves_history_storage_write() {
4945        assert_history_write_emitted(HISTORY_STORAGE_ADDRESS.address);
4946    }
4947}
4948
4949/// Tests for EIP-8079 burned_fees computation in execute_block (LStar-gated).
4950///
4951/// Shares the non-guest execution path's `rayon`-gated imports
4952/// (`EMPTY_KECCAK_HASH`, `FxHashMap`, `Database`), so it only builds with rayon.
4953#[cfg(all(test, feature = "rayon"))]
4954mod burned_fees_tests {
4955    use super::*;
4956    use ethrex_common::{
4957        constants::{DEFAULT_OMMERS_HASH, DEFAULT_REQUESTS_HASH},
4958        types::{AccountState, Block, BlockBody, BlockHeader, ChainConfig, Code, CodeMetadata},
4959        utils::keccak,
4960    };
4961    use ethrex_crypto::NativeCrypto;
4962    use ethrex_levm::errors::DatabaseError;
4963    use ethrex_rlp::encode::PayloadRLPEncode;
4964
4965    // ── helpers ─────────────────────────────────────────────────────────────
4966
4967    fn lstar_chain_config() -> ChainConfig {
4968        ChainConfig {
4969            chain_id: 1,
4970            homestead_block: Some(0),
4971            eip150_block: Some(0),
4972            eip155_block: Some(0),
4973            eip158_block: Some(0),
4974            byzantium_block: Some(0),
4975            constantinople_block: Some(0),
4976            petersburg_block: Some(0),
4977            istanbul_block: Some(0),
4978            berlin_block: Some(0),
4979            london_block: Some(0),
4980            terminal_total_difficulty: Some(0),
4981            terminal_total_difficulty_passed: true,
4982            shanghai_time: Some(0),
4983            cancun_time: Some(0),
4984            prague_time: Some(0),
4985            amsterdam_time: Some(0),
4986            lstar_time: Some(0),
4987            ..Default::default()
4988        }
4989    }
4990
4991    fn amsterdam_chain_config() -> ChainConfig {
4992        ChainConfig {
4993            chain_id: 1,
4994            homestead_block: Some(0),
4995            eip150_block: Some(0),
4996            eip155_block: Some(0),
4997            eip158_block: Some(0),
4998            byzantium_block: Some(0),
4999            constantinople_block: Some(0),
5000            petersburg_block: Some(0),
5001            istanbul_block: Some(0),
5002            berlin_block: Some(0),
5003            london_block: Some(0),
5004            terminal_total_difficulty: Some(0),
5005            terminal_total_difficulty_passed: true,
5006            shanghai_time: Some(0),
5007            cancun_time: Some(0),
5008            prague_time: Some(0),
5009            amsterdam_time: Some(0),
5010            lstar_time: None, // no LStar → burned_fees must be None
5011            ..Default::default()
5012        }
5013    }
5014
5015    // ── mock database ────────────────────────────────────────────────────────
5016
5017    struct MockDB {
5018        chain_config: ChainConfig,
5019        accounts: FxHashMap<Address, (AccountState, Bytes)>,
5020        /// Pre-set storage values (address, slot) → value.
5021        storage: FxHashMap<(Address, H256), U256>,
5022    }
5023
5024    impl MockDB {
5025        fn new(chain_config: ChainConfig) -> Self {
5026            Self {
5027                chain_config,
5028                accounts: FxHashMap::default(),
5029                storage: FxHashMap::default(),
5030            }
5031        }
5032
5033        fn with_account(mut self, address: Address, balance: U256, code: Option<Bytes>) -> Self {
5034            let code_bytes = code.unwrap_or_default();
5035            let code_hash = if code_bytes.is_empty() {
5036                *EMPTY_KECCAK_HASH
5037            } else {
5038                keccak(&code_bytes)
5039            };
5040            let state = AccountState {
5041                nonce: 0,
5042                balance,
5043                storage_root: H256::zero(),
5044                code_hash,
5045            };
5046            self.accounts.insert(address, (state, code_bytes));
5047            self
5048        }
5049
5050        fn with_storage(mut self, address: Address, slot: H256, value: U256) -> Self {
5051            self.storage.insert((address, slot), value);
5052            self
5053        }
5054    }
5055
5056    impl Database for MockDB {
5057        fn get_account_state(&self, address: Address) -> Result<AccountState, DatabaseError> {
5058            Ok(self
5059                .accounts
5060                .get(&address)
5061                .map(|(s, _)| *s)
5062                .unwrap_or_default())
5063        }
5064
5065        fn get_storage_value(&self, address: Address, slot: H256) -> Result<U256, DatabaseError> {
5066            Ok(self
5067                .storage
5068                .get(&(address, slot))
5069                .copied()
5070                .unwrap_or_default())
5071        }
5072
5073        fn get_block_hash(&self, _: u64) -> Result<H256, DatabaseError> {
5074            Ok(H256::zero())
5075        }
5076
5077        fn get_chain_config(&self) -> Result<ChainConfig, DatabaseError> {
5078            Ok(self.chain_config)
5079        }
5080
5081        fn get_account_code(&self, code_hash: H256) -> Result<Code, DatabaseError> {
5082            let crypto = NativeCrypto;
5083            for (state, bytecode) in self.accounts.values() {
5084                if state.code_hash == code_hash && !bytecode.is_empty() {
5085                    return Ok(Code::from_bytecode(bytecode.clone(), &crypto));
5086                }
5087            }
5088            Ok(Code::from_bytecode(Bytes::new(), &crypto))
5089        }
5090
5091        fn get_code_metadata(&self, code_hash: H256) -> Result<CodeMetadata, DatabaseError> {
5092            for (state, bytecode) in self.accounts.values() {
5093                if state.code_hash == code_hash {
5094                    return Ok(CodeMetadata {
5095                        length: bytecode.len() as u64,
5096                    });
5097                }
5098            }
5099            Ok(CodeMetadata { length: 0 })
5100        }
5101    }
5102
5103    // ── signing ──────────────────────────────────────────────────────────────
5104
5105    const PRIVATE_KEY: [u8; 32] = [
5106        0x4c, 0x08, 0x83, 0xa6, 0x91, 0x02, 0x93, 0x7d, 0x62, 0x31, 0x47, 0x1b, 0x5d, 0xbb, 0x62,
5107        0x04, 0xfe, 0x51, 0x29, 0x61, 0x70, 0x82, 0x79, 0x2a, 0xe4, 0x68, 0xd0, 0x1a, 0x3f, 0x36,
5108        0x23, 0x18,
5109    ];
5110
5111    fn sender_address() -> Address {
5112        use k256::ecdsa::SigningKey;
5113        let signing_key = SigningKey::from_bytes(&PRIVATE_KEY.into()).expect("valid key");
5114        let uncompressed_pub = signing_key.verifying_key().to_encoded_point(false);
5115        let pub_hash = keccak(&uncompressed_pub.as_bytes()[1..]);
5116        Address::from_slice(&pub_hash.0[12..])
5117    }
5118
5119    /// Build a signed EIP-1559 ETH transfer.
5120    /// base_fee = BASE_FEE, gas_limit = 21_000 → gas_spent = 21_000 (no refunds).
5121    fn signed_eth_transfer(to: Address, nonce: u64, base_fee: u64) -> Transaction {
5122        use k256::ecdsa::SigningKey;
5123
5124        let tx = EIP1559Transaction {
5125            chain_id: 1,
5126            nonce,
5127            max_priority_fee_per_gas: 1,
5128            max_fee_per_gas: base_fee + 10,
5129            gas_limit: 21_000,
5130            to: TxKind::Call(to),
5131            value: U256::zero(),
5132            ..Default::default()
5133        };
5134
5135        // Signing hash: [0x02] || RLP(chain_id, nonce, max_priority, max_fee, gas, to, value, data, access_list)
5136        let mut buf = vec![0x02u8];
5137        tx.encode_payload(&mut buf);
5138        let hash = keccak(&buf);
5139
5140        let signing_key = SigningKey::from_bytes(&PRIVATE_KEY.into()).expect("valid key");
5141        let (sig, rid) = signing_key
5142            .sign_prehash_recoverable(&hash.0)
5143            .expect("sign ok");
5144        let sig_bytes = sig.to_bytes();
5145
5146        Transaction::EIP1559Transaction(EIP1559Transaction {
5147            signature_r: U256::from_big_endian(&sig_bytes[..32]),
5148            signature_s: U256::from_big_endian(&sig_bytes[32..]),
5149            signature_y_parity: rid.to_byte() != 0,
5150            ..tx
5151        })
5152    }
5153
5154    // STOP opcode: system contracts need non-empty code (EIP-7002/7251 check).
5155    const STOP_BYTECODE: &[u8] = &[0x00];
5156
5157    fn make_block_with_tx(tx: Transaction, base_fee: u64) -> Block {
5158        let header = BlockHeader {
5159            parent_hash: H256::zero(),
5160            ommers_hash: *DEFAULT_OMMERS_HASH,
5161            number: 1,
5162            gas_limit: 1_000_000,
5163            timestamp: 1,
5164            base_fee_per_gas: Some(base_fee),
5165            withdrawals_root: Some(H256::zero()),
5166            requests_hash: Some(*DEFAULT_REQUESTS_HASH),
5167            // No parent_beacon_block_root → skip beacon-root system call.
5168            parent_beacon_block_root: None,
5169            ..Default::default()
5170        };
5171        let body = BlockBody {
5172            transactions: vec![tx],
5173            ommers: vec![],
5174            withdrawals: Some(vec![]),
5175        };
5176        Block::new(header, body)
5177    }
5178
5179    fn make_gen_db(chain_config: ChainConfig) -> GeneralizedDatabase {
5180        let sender = sender_address();
5181        let stop = Bytes::from_static(STOP_BYTECODE);
5182        let mock = MockDB::new(chain_config)
5183            // Fund the sender with enough ETH to pay for gas + priority fee.
5184            .with_account(sender, U256::from(1_000_000_000u64), None)
5185            // Prague system contracts require non-empty code (EIP-7002/7251).
5186            .with_account(
5187                WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS.address,
5188                U256::zero(),
5189                Some(stop.clone()),
5190            )
5191            .with_account(
5192                CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS.address,
5193                U256::zero(),
5194                Some(stop.clone()),
5195            )
5196            // EIP-8282 (Amsterdam) builder predeploys require non-empty code.
5197            .with_account(
5198                BUILDER_DEPOSIT_CONTRACT_ADDRESS.address,
5199                U256::zero(),
5200                Some(stop.clone()),
5201            )
5202            .with_account(
5203                BUILDER_EXIT_CONTRACT_ADDRESS.address,
5204                U256::zero(),
5205                Some(stop),
5206            );
5207        GeneralizedDatabase::new(Arc::new(mock))
5208    }
5209
5210    // Contract bytecode: PUSH1 0x00, PUSH1 0x00, SSTORE, STOP
5211    // Clears storage slot 0.  When the slot's initial value is non-zero, the
5212    // EIP-3529 "SSTORE_CLEARS_SCHEDULE" refund of 4800 gas is applied, making
5213    // gas_used > gas_spent so block_gas_used ≠ cumulative_gas_used.
5214    const SSTORE_CLEAR_BYTECODE: &[u8] = &[0x60, 0x00, 0x60, 0x00, 0x55, 0x00];
5215
5216    /// Build a signed EIP-1559 call to `to` with an explicit gas limit.
5217    fn signed_call_tx(to: Address, nonce: u64, base_fee: u64, gas_limit: u64) -> Transaction {
5218        use k256::ecdsa::SigningKey;
5219
5220        let tx = EIP1559Transaction {
5221            chain_id: 1,
5222            nonce,
5223            max_priority_fee_per_gas: 1,
5224            max_fee_per_gas: base_fee + 10,
5225            gas_limit,
5226            to: TxKind::Call(to),
5227            value: U256::zero(),
5228            ..Default::default()
5229        };
5230
5231        let mut buf = vec![0x02u8];
5232        tx.encode_payload(&mut buf);
5233        let hash = keccak(&buf);
5234
5235        let signing_key = SigningKey::from_bytes(&PRIVATE_KEY.into()).expect("valid key");
5236        let (sig, rid) = signing_key
5237            .sign_prehash_recoverable(&hash.0)
5238            .expect("sign ok");
5239        let sig_bytes = sig.to_bytes();
5240
5241        Transaction::EIP1559Transaction(EIP1559Transaction {
5242            signature_r: U256::from_big_endian(&sig_bytes[..32]),
5243            signature_s: U256::from_big_endian(&sig_bytes[32..]),
5244            signature_y_parity: rid.to_byte() != 0,
5245            ..tx
5246        })
5247    }
5248
5249    /// DB with SSTORE_CLEAR_BYTECODE deployed at `contract_addr` and slot 0 = 1.
5250    /// The sender has extra ETH to cover the higher gas_limit in the call tx.
5251    fn make_gen_db_sstore_clear(
5252        chain_config: ChainConfig,
5253        contract_addr: Address,
5254    ) -> GeneralizedDatabase {
5255        let sender = sender_address();
5256        let stop = Bytes::from_static(STOP_BYTECODE);
5257        let mock = MockDB::new(chain_config)
5258            .with_account(sender, U256::from(100_000_000_000u64), None)
5259            .with_account(
5260                WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS.address,
5261                U256::zero(),
5262                Some(stop.clone()),
5263            )
5264            .with_account(
5265                CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS.address,
5266                U256::zero(),
5267                Some(stop.clone()),
5268            )
5269            // EIP-8282 (Amsterdam) builder predeploys require non-empty code.
5270            .with_account(
5271                BUILDER_DEPOSIT_CONTRACT_ADDRESS.address,
5272                U256::zero(),
5273                Some(stop.clone()),
5274            )
5275            .with_account(
5276                BUILDER_EXIT_CONTRACT_ADDRESS.address,
5277                U256::zero(),
5278                Some(stop),
5279            )
5280            .with_account(
5281                contract_addr,
5282                U256::zero(),
5283                Some(Bytes::from_static(SSTORE_CLEAR_BYTECODE)),
5284            )
5285            // Slot 0 = 1 so SSTORE(0, 0) transitions 1→0, triggering EIP-3529 refund.
5286            .with_storage(contract_addr, H256::zero(), U256::one());
5287        GeneralizedDatabase::new(Arc::new(mock))
5288    }
5289
5290    // ── tests ────────────────────────────────────────────────────────────────
5291
5292    #[test]
5293    fn test_burned_fees_block_aggregate() {
5294        use std::sync::atomic::AtomicUsize;
5295        use std::sync::mpsc;
5296
5297        const BASE_FEE: u64 = 10;
5298        // Simple ETH transfer, no blobs (blob_gas_used = 0, blob_base_fee = 0).
5299        // The exact gas charged depends on the active fork's gas model, so this test
5300        // verifies the burned_fees FORMULA against the block's actual post-refund gas
5301        // (rather than a hardcoded amount) plus the execute_block ↔ execute_block_pipeline
5302        // cross-path consistency and the LStar gate. The refund-sensitive case lives in
5303        // test_burned_fees_post_refund_discriminating.
5304
5305        let recipient = Address::from_low_u64_be(0xBEEF);
5306        let tx = signed_eth_transfer(recipient, 0, BASE_FEE);
5307        let block = make_block_with_tx(tx, BASE_FEE);
5308        let crypto = NativeCrypto;
5309
5310        // (a) execute_block path
5311        let mut db1 = make_gen_db(lstar_chain_config());
5312        let (result, _bal) = LEVM::execute_block(&block, &mut db1, VMType::L1, &crypto, None)
5313            .expect("execute_block succeeded");
5314        let cumulative = result
5315            .receipts
5316            .last()
5317            .map(|r| r.cumulative_gas_used)
5318            .unwrap_or(0);
5319        let expected = compute_burned_fees(BASE_FEE, cumulative, 0, 0);
5320        assert_eq!(
5321            result.burned_fees,
5322            Some(expected),
5323            "execute_block: burned_fees must equal base_fee * post-refund gas ({cumulative})"
5324        );
5325
5326        // (b) execute_block_pipeline path (sequential: no BAL provided)
5327        let mut db2 = make_gen_db(lstar_chain_config());
5328        let (tx_sender, _rx) = mpsc::channel();
5329        let queue_len = AtomicUsize::new(0);
5330        let tx2 = signed_eth_transfer(recipient, 0, BASE_FEE);
5331        let block2 = make_block_with_tx(tx2, BASE_FEE);
5332        let (result_pipeline, _bal) = LEVM::execute_block_pipeline(
5333            &block2,
5334            &mut db2,
5335            VMType::L1,
5336            Some(tx_sender),
5337            &queue_len,
5338            &crypto,
5339            None, // no BAL → sequential path
5340            false,
5341            None,
5342        )
5343        .expect("execute_block_pipeline succeeded");
5344        assert_eq!(
5345            result_pipeline.burned_fees, result.burned_fees,
5346            "execute_block_pipeline: burned_fees must match execute_block (production/verification consistency)"
5347        );
5348
5349        // (c) pre-LStar → None
5350        let tx3 = signed_eth_transfer(recipient, 0, BASE_FEE);
5351        let block3 = make_block_with_tx(tx3, BASE_FEE);
5352        let mut db3 = make_gen_db(amsterdam_chain_config());
5353        let (result_pre_lstar, _) =
5354            LEVM::execute_block(&block3, &mut db3, VMType::L1, &crypto, None)
5355                .expect("pre-LStar execute_block succeeded");
5356        assert_eq!(
5357            result_pre_lstar.burned_fees, None,
5358            "pre-LStar: burned_fees must be None"
5359        );
5360    }
5361
5362    /// Discriminating test: a transaction with an EIP-3529 SSTORE refund makes
5363    /// `block_gas_used` (pre-refund) differ from `cumulative_gas_used` (post-refund).
5364    /// The correct `burned_fees` uses post-refund gas; using `block_gas_used` would
5365    /// over-count by `base_fee * refund_amount`.
5366    ///
5367    /// Gas analysis (Prague + Amsterdam era):
5368    ///   intrinsic = 21 000
5369    ///   PUSH1 + PUSH1 = 6
5370    ///   SSTORE cold (original=1, new=0): cold-access 2100 + warm-reset 2900 = 5 000
5371    ///     refund = SSTORE_CLEARS_SCHEDULE = 4 800 (≤ 26 006 / 5 = 5 201, so full refund)
5372    ///   gas_used = 26 006, gas_spent = 21 206
5373    ///
5374    /// With the wrong code (block_gas_used = 26 006):
5375    ///   burned_fees = 10 * 26 006 = 260 060  ← test FAILS
5376    /// After the fix (cumulative_gas_used = 21 206):
5377    ///   burned_fees = 10 * 21 206 = 212 060  ← test PASSES
5378    #[test]
5379    fn test_burned_fees_post_refund_discriminating() {
5380        use std::sync::atomic::AtomicUsize;
5381        use std::sync::mpsc;
5382
5383        const BASE_FEE: u64 = 10;
5384        // Gas limit must exceed gas_used (26 006); 100 000 is ample.
5385        const TX_GAS_LIMIT: u64 = 100_000;
5386
5387        let contract_addr = Address::from_low_u64_be(0xC0DEBEEF);
5388        let crypto = NativeCrypto;
5389
5390        // ── (a) execute_block path (site 1) ───────────────────────────────────
5391        let tx_a = signed_call_tx(contract_addr, 0, BASE_FEE, TX_GAS_LIMIT);
5392        let block_a = make_block_with_tx(tx_a, BASE_FEE);
5393        let mut db_a = make_gen_db_sstore_clear(lstar_chain_config(), contract_addr);
5394        let (result_a, _) = LEVM::execute_block(&block_a, &mut db_a, VMType::L1, &crypto, None)
5395            .expect("execute_block with SSTORE clear succeeded");
5396
5397        let cumulative_a = result_a.receipts[0].cumulative_gas_used;
5398        // Discriminating guard: the refund must have happened.
5399        assert!(
5400            result_a.block_gas_used > cumulative_a,
5401            "SSTORE refund must cause block_gas_used ({}) > cumulative_gas_used ({})",
5402            result_a.block_gas_used,
5403            cumulative_a,
5404        );
5405        // Correct burned_fees uses post-refund cumulative_gas_used, not block_gas_used.
5406        assert_eq!(
5407            result_a.burned_fees,
5408            Some(BASE_FEE * cumulative_a),
5409            "execute_block: burned_fees must use post-refund gas (cumulative={}, block_gas_used={})",
5410            cumulative_a,
5411            result_a.block_gas_used,
5412        );
5413
5414        // ── (b) execute_block_pipeline sequential path (site 3, no BAL) ──────
5415        let tx_b = signed_call_tx(contract_addr, 0, BASE_FEE, TX_GAS_LIMIT);
5416        let block_b = make_block_with_tx(tx_b, BASE_FEE);
5417        let mut db_b = make_gen_db_sstore_clear(lstar_chain_config(), contract_addr);
5418        let (tx_sender_b, _rx_b) = mpsc::channel();
5419        let queue_len_b = AtomicUsize::new(0);
5420        let (result_b, _) = LEVM::execute_block_pipeline(
5421            &block_b,
5422            &mut db_b,
5423            VMType::L1,
5424            Some(tx_sender_b),
5425            &queue_len_b,
5426            &crypto,
5427            None, // no BAL → sequential path (site 3)
5428            false,
5429            None,
5430        )
5431        .expect("execute_block_pipeline sequential with SSTORE clear succeeded");
5432
5433        let cumulative_b = result_b.receipts[0].cumulative_gas_used;
5434        assert!(
5435            result_b.block_gas_used > cumulative_b,
5436            "sequential pipeline: SSTORE refund must be present",
5437        );
5438        assert_eq!(
5439            result_b.burned_fees,
5440            Some(BASE_FEE * cumulative_b),
5441            "execute_block_pipeline (sequential): burned_fees must use post-refund gas",
5442        );
5443
5444        // ── (c) execute_block_pipeline parallel path (site 2, with BAL) ──────
5445        // Generate BAL via execute_block, then re-execute using that BAL.
5446        let tx_c = signed_call_tx(contract_addr, 0, BASE_FEE, TX_GAS_LIMIT);
5447        let block_c = make_block_with_tx(tx_c, BASE_FEE);
5448        let mut db_c1 = make_gen_db_sstore_clear(lstar_chain_config(), contract_addr);
5449        let (_, bal_c) = LEVM::execute_block(&block_c, &mut db_c1, VMType::L1, &crypto, None)
5450            .expect("BAL generation succeeded");
5451        let bal_c = bal_c.expect("LStar must produce a BAL");
5452
5453        let mut db_c2 = make_gen_db_sstore_clear(lstar_chain_config(), contract_addr);
5454        let (tx_sender_c, _rx_c) = mpsc::channel();
5455        let queue_len_c = AtomicUsize::new(0);
5456        let (result_c, _) = LEVM::execute_block_pipeline(
5457            &block_c,
5458            &mut db_c2,
5459            VMType::L1,
5460            Some(tx_sender_c),
5461            &queue_len_c,
5462            &crypto,
5463            Some(std::sync::Arc::new(bal_c)), // ← parallel path (site 2)
5464            true,
5465            None,
5466        )
5467        .expect("execute_block_pipeline parallel with SSTORE clear succeeded");
5468
5469        let cumulative_c = result_c.receipts[0].cumulative_gas_used;
5470        assert!(
5471            result_c.block_gas_used > cumulative_c,
5472            "parallel pipeline: SSTORE refund must be present",
5473        );
5474        assert_eq!(
5475            result_c.burned_fees,
5476            Some(BASE_FEE * cumulative_c),
5477            "execute_block_pipeline (parallel): burned_fees must use post-refund gas",
5478        );
5479
5480        // ── cross-path consistency ─────────────────────────────────────────────
5481        assert_eq!(
5482            result_a.burned_fees, result_b.burned_fees,
5483            "execute_block vs sequential pipeline: burned_fees must match",
5484        );
5485        assert_eq!(
5486            result_a.burned_fees, result_c.burned_fees,
5487            "execute_block vs parallel pipeline: burned_fees must match",
5488        );
5489
5490        // ── (d) pre-LStar → None ──────────────────────────────────────────────
5491        let tx_d = signed_call_tx(contract_addr, 0, BASE_FEE, TX_GAS_LIMIT);
5492        let block_d = make_block_with_tx(tx_d, BASE_FEE);
5493        let mut db_d = make_gen_db_sstore_clear(amsterdam_chain_config(), contract_addr);
5494        let (result_d, _) = LEVM::execute_block(&block_d, &mut db_d, VMType::L1, &crypto, None)
5495            .expect("pre-LStar with SSTORE clear succeeded");
5496        assert_eq!(
5497            result_d.burned_fees, None,
5498            "pre-LStar: burned_fees must be None"
5499        );
5500    }
5501}
5502
5503#[cfg(test)]
5504mod simulated_tx_encoding_tests {
5505    //! The L2 prices its L1 data fee from `Transaction::length()` and reserves that
5506    //! as gas before execution, so a simulated transaction must never encode shorter
5507    //! than the signed one it stands for — otherwise `eth_estimateGas` reserves less
5508    //! L1 fee gas than the transaction it is estimating will need, and that
5509    //! transaction runs out of gas at exactly the estimate.
5510    //!
5511    //! This was reached through the L2 fee-token integration test, which submits the
5512    //! estimate verbatim. It only surfaced once the estimate became exact; the search's
5513    //! former 1.5% tolerance had been absorbing the shortfall.
5514    use super::*;
5515    use ethrex_common::types::GenericTransaction;
5516    use ethrex_rlp::encode::RLPEncode;
5517
5518    /// The transaction the SDK signs and sends after estimating, with signature
5519    /// components at the width secp256k1 actually produces.
5520    fn signed_equivalent(gas_limit: u64, data: Bytes) -> Transaction {
5521        Transaction::EIP1559Transaction(EIP1559Transaction {
5522            chain_id: 9_999,
5523            nonce: 42,
5524            max_priority_fee_per_gas: 1_000_000_000,
5525            max_fee_per_gas: 2_000_000_000,
5526            gas_limit,
5527            to: TxKind::Create,
5528            value: U256::from(1u64),
5529            data,
5530            access_list: vec![],
5531            signature_y_parity: true,
5532            signature_r: U256::MAX,
5533            signature_s: U256::MAX,
5534            ..Default::default()
5535        })
5536    }
5537
5538    fn generic(gas_limit: u64, data: Bytes) -> GenericTransaction {
5539        GenericTransaction {
5540            to: TxKind::Create,
5541            value: U256::from(1u64),
5542            input: data,
5543            nonce: Some(42),
5544            gas: Some(gas_limit),
5545            max_fee_per_gas: Some(2_000_000_000),
5546            max_priority_fee_per_gas: Some(1_000_000_000),
5547            chain_id: Some(9_999),
5548            ..Default::default()
5549        }
5550    }
5551
5552    #[test]
5553    fn simulated_tx_never_encodes_shorter_than_the_signed_one() {
5554        for (gas_limit, data) in [
5555            (21_000u64, Bytes::new()),
5556            (150_000, Bytes::from_static(&[0x60, 0x00, 0x60, 0x00])),
5557            (5_000_000, Bytes::from(vec![0xab; 4_096])),
5558        ] {
5559            let simulated = generic_tx_to_transaction(&generic(gas_limit, data.clone()))
5560                .expect("conversion should succeed");
5561            let signed = signed_equivalent(gas_limit, data.clone());
5562            assert!(
5563                simulated.length() >= signed.length(),
5564                "simulated tx encodes {} bytes, signed one {} — the L2 would under-reserve \
5565                 the L1 fee by the difference (gas_limit {gas_limit}, {} data bytes)",
5566                simulated.length(),
5567                signed.length(),
5568                data.len(),
5569            );
5570        }
5571    }
5572}
5573
5574#[cfg(all(test, feature = "rayon"))]
5575mod first_failure_by_key_tests {
5576    use super::first_failure_by_key;
5577    use ethrex_common::seeded_hash::SeededHashMap;
5578
5579    /// Entries that fail per map. Naming whichever one a seeded map walks to first would
5580    /// name the lowest with probability 1/256.
5581    const FAILING: u64 = 256;
5582
5583    #[test]
5584    fn names_the_lowest_failing_key() {
5585        // Even keys pass, odd keys fail: the lowest failure is 1, behind a passing 0.
5586        let map: SeededHashMap<u64, ()> = (0..2 * FAILING).map(|key| (key, ())).collect();
5587        let result = first_failure_by_key(&map, |key, _| match key % 2 {
5588            0 => Ok(()),
5589            _ => Err(*key),
5590        });
5591        assert_eq!(result, Err(1));
5592    }
5593
5594    #[test]
5595    fn walks_a_passing_map_once() {
5596        let map: SeededHashMap<u64, ()> = (0..FAILING).map(|key| (key, ())).collect();
5597        let mut checked = 0;
5598        let result: Result<(), ()> = first_failure_by_key(&map, |_, _| {
5599            checked += 1;
5600            Ok(())
5601        });
5602        assert_eq!(result, Ok(()));
5603        assert_eq!(checked, FAILING);
5604    }
5605
5606    #[test]
5607    fn names_the_lowest_failure_of_a_nested_map() {
5608        // The lowest failing outer key whose inner map fails, then the lowest inner key.
5609        let map: SeededHashMap<u64, SeededHashMap<u64, ()>> = (0..FAILING)
5610            .map(|outer| {
5611                let inner = (0..FAILING).map(|key| (key, ())).collect();
5612                (outer, inner)
5613            })
5614            .collect();
5615        let result = first_failure_by_key(&map, |outer, inner| {
5616            first_failure_by_key(inner, |key, _| match (*outer >= 7, *key >= 3) {
5617                (true, true) => Err((*outer, *key)),
5618                _ => Ok(()),
5619            })
5620        });
5621        assert_eq!(result, Err((7, 3)));
5622    }
5623}