Skip to main content

ethrex_vm/backends/levm/
mod.rs

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