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