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