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            disable_sender_eoa_check: false,
3003            is_system_call: false,
3004        };
3005
3006        Ok(env)
3007    }
3008
3009    pub fn execute_tx(
3010        // The transaction to execute.
3011        tx: &Transaction,
3012        // The transaction's recovered address
3013        tx_sender: Address,
3014        // The block header for the current block.
3015        block_header: &BlockHeader,
3016        db: &mut GeneralizedDatabase,
3017        vm_type: VMType,
3018        crypto: &dyn Crypto,
3019        stateless_validator: Option<&dyn StatelessValidator>,
3020    ) -> Result<ExecutionReport, EvmError> {
3021        let env = Self::setup_env(tx, tx_sender, block_header, db, vm_type)?;
3022        let mut vm = VM::new(
3023            env,
3024            db,
3025            tx,
3026            LevmCallTracer::disabled(),
3027            vm_type,
3028            crypto,
3029            stateless_validator,
3030        )?;
3031
3032        vm.execute().map_err(VMError::into)
3033    }
3034
3035    // Like execute_tx but allows reusing the stack pool. Takes the block-invariant
3036    // `config`/`chain_id` precomputed once per block (see `setup_env_with_config`).
3037    #[allow(clippy::too_many_arguments)]
3038    fn execute_tx_in_block(
3039        // The transaction to execute.
3040        tx: &Transaction,
3041        // The transaction's recovered address
3042        tx_sender: Address,
3043        // The block header for the current block.
3044        block_header: &BlockHeader,
3045        db: &mut GeneralizedDatabase,
3046        vm_type: VMType,
3047        base_blob_fee_per_gas: U256,
3048        stack_pool: &mut Vec<Stack>,
3049        memory_pool: &mut Vec<Memory>,
3050        disable_balance_check: bool,
3051        crypto: &dyn Crypto,
3052        config: EVMConfig,
3053        chain_id: u64,
3054        stateless_validator: Option<&dyn StatelessValidator>,
3055    ) -> Result<ExecutionReport, EvmError> {
3056        let mut env = Self::setup_env_with_config(
3057            tx,
3058            tx_sender,
3059            block_header,
3060            config,
3061            chain_id,
3062            vm_type,
3063            base_blob_fee_per_gas,
3064        )?;
3065        env.disable_balance_check = disable_balance_check;
3066        // Draw the root frame's stack and memory buffer from the shared pools (and adopt the
3067        // stacks for sub-frames), then return them afterwards so the next tx reuses them instead
3068        // of allocating + zeroing a fresh 32 KB stack and a fresh memory buffer per transaction.
3069        let mut vm = VM::new_pooled(
3070            env,
3071            db,
3072            tx,
3073            LevmCallTracer::disabled(),
3074            vm_type,
3075            crypto,
3076            stateless_validator,
3077            stack_pool,
3078            memory_pool,
3079        )?;
3080        let result = vm.execute().map_err(VMError::into);
3081        // Runs on both success and error paths (execute borrowed `vm` mutably but left it intact).
3082        vm.reclaim_into(stack_pool, memory_pool);
3083        result
3084    }
3085
3086    pub fn undo_last_tx(db: &mut GeneralizedDatabase) -> Result<(), EvmError> {
3087        db.undo_last_transaction()?;
3088        Ok(())
3089    }
3090
3091    pub fn simulate_tx_from_generic(
3092        // The transaction to execute.
3093        tx: &GenericTransaction,
3094        // The block header for the current block.
3095        block_header: &BlockHeader,
3096        db: &mut GeneralizedDatabase,
3097        vm_type: VMType,
3098        crypto: &dyn Crypto,
3099        stateless_validator: Option<&dyn StatelessValidator>,
3100    ) -> Result<ExecutionResult, EvmError> {
3101        // `env_from_generic` already relaxes the admission-only validations for every
3102        // simulation RPC, the gas allowance included. `block_gas_limit` keeps the block's
3103        // real value there: the GASLIMIT opcode reads it, so overwriting it would make
3104        // 0x45 return a number that appears nowhere on chain.
3105        let mut env = env_from_generic(tx, block_header, db, vm_type)?;
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
3824pub(crate) fn adjust_disabled_base_fee(env: &mut Environment) {
3825    if env.gas_price == U256::zero() {
3826        env.base_fee_per_gas = U256::zero();
3827    }
3828    // Same for the blob fee: a call object that opts out by passing `maxFeePerBlobGas: 0`
3829    // must not be rejected for undercutting the block's blob base fee.
3830    // `base_blob_fee_per_gas` is the field the validation and the up-front cost both read
3831    // (`validate_max_fee_per_blob_gas`, `deduct_caller`), and it is computed in
3832    // `env_from_generic` before this runs, so it is what has to be lowered. Clearing
3833    // `block_excess_blob_gas` instead had no effect: nothing reads that field, and the
3834    // blob base fee derived from a zero excess still has a floor of 1, which a zero fee
3835    // cap undercuts just the same.
3836    if env
3837        .tx_max_fee_per_blob_gas
3838        .is_some_and(|v| v == U256::zero())
3839    {
3840        env.base_blob_fee_per_gas = U256::zero();
3841    }
3842}
3843
3844/// When l2 fees are disabled (ie. env.gas_price = 0), set fee configs to None to avoid breaking failing fee deductions
3845fn adjust_disabled_l2_fees(env: &Environment, vm_type: VMType) -> VMType {
3846    if env.gas_price == U256::zero()
3847        && let VMType::L2(fee_config) = vm_type
3848    {
3849        // Don't deduct fees if no gas price is set
3850        return VMType::L2(FeeConfig {
3851            operator_fee_config: None,
3852            l1_fee_config: None,
3853            ..fee_config
3854        });
3855    }
3856    vm_type
3857}
3858
3859pub(crate) fn env_from_generic(
3860    tx: &GenericTransaction,
3861    header: &BlockHeader,
3862    db: &GeneralizedDatabase,
3863    vm_type: VMType,
3864) -> Result<Environment, VMError> {
3865    let chain_config = db.store.get_chain_config()?;
3866    let gas_price =
3867        calculate_gas_price_for_generic(tx, header.base_fee_per_gas.unwrap_or(INITIAL_BASE_FEE));
3868    let block_excess_blob_gas = header.excess_blob_gas;
3869    let config = EVMConfig::new_from_chain_config(&chain_config, header);
3870
3871    // slot_number: default a missing value to zero exactly like
3872    // `setup_env_with_config` (the block-execution env builder) does, rather
3873    // than erroring on Amsterdam+. A canonical Amsterdam header always carries
3874    // a slot — `validate_prague_header_fields` rejects one that doesn't, the
3875    // genesis builder fills in Some(0), and engine_newPayloadV5 requires the
3876    // field — so this branch is not reachable on a well-formed chain. It is
3877    // defense in depth, and it belongs on the execution side (where a missing
3878    // slot would be a consensus fault) rather than in simulation, whose job is
3879    // to predict what execution does: a header the executor would happily run
3880    // with SLOTNUM reading 0 must not make eth_call fail. That divergence is
3881    // reachable if a devnet's fork timestamps are moved so that Amsterdam
3882    // retroactively covers already-stored pre-Amsterdam headers.
3883    // For L2 chains, slot_number is always 0.
3884    let slot_number = if let VMType::L2(_) = vm_type {
3885        U256::zero()
3886    } else {
3887        header.slot_number.map(U256::from).unwrap_or(U256::zero())
3888    };
3889
3890    Ok(Environment {
3891        origin: tx.from.0.into(),
3892        gas_limit: tx
3893            .gas
3894            .unwrap_or(get_max_allowed_gas_limit(header.gas_limit, config.fork)), // Ensure tx doesn't fail due to gas limit
3895        config,
3896        block_number: header.number,
3897        coinbase: header.coinbase,
3898        timestamp: header.timestamp,
3899        prev_randao: Some(header.prev_randao),
3900        slot_number,
3901        chain_id: chain_config.chain_id.into(),
3902        base_fee_per_gas: header.base_fee_per_gas.unwrap_or_default().into(),
3903        base_blob_fee_per_gas: get_base_fee_per_blob_gas(block_excess_blob_gas, &config)?,
3904        gas_price,
3905        block_excess_blob_gas,
3906        block_blob_gas_used: header.blob_gas_used,
3907        tx_blob_hashes: tx.blob_versioned_hashes.clone(),
3908        tx_max_priority_fee_per_gas: tx.max_priority_fee_per_gas.map(U256::from),
3909        tx_max_fee_per_gas: tx.max_fee_per_gas.map(U256::from),
3910        tx_max_fee_per_blob_gas: tx.max_fee_per_blob_gas,
3911        tx_nonce: tx.nonce.unwrap_or_default(),
3912        block_gas_limit: header.gas_limit,
3913        difficulty: header.difficulty,
3914        is_privileged: false,
3915        fee_token: tx.fee_token,
3916        disable_balance_check: false,
3917        // Every `env_from_generic` caller is a simulation RPC (eth_call,
3918        // eth_estimateGas, eth_createAccessList, debug_traceCall). Those run
3919        // relaxed messages with no nonce enforcement: a call object without
3920        // `nonce` defaults `tx_nonce` to 0 above, which the hook would otherwise
3921        // reject for any sender whose nonce is nonzero.
3922        disable_nonce_check: true,
3923        // Same reasoning: a caller passing a `gas` above the block allowance or above the
3924        // EIP-7825 per-transaction cap still expects an answer, because nothing is being
3925        // submitted. `eth_createAccessList` enforced both until now, which rejected call
3926        // objects that the other simulation RPCs answered, and which tools that pass the
3927        // block gas limit as `gas` hit routinely.
3928        disable_gas_allowance_check: true,
3929        // Same reasoning: a simulated call carries no signature, so EIP-3607 has nothing
3930        // to protect, and a contract as `from` is a normal thing to simulate.
3931        disable_sender_eoa_check: true,
3932        is_system_call: false,
3933    })
3934}
3935
3936/// Build a synthetic `Transaction` from a `GenericTransaction` for simulation paths.
3937///
3938/// Split out from `vm_from_generic` so the caller owns the resulting `Transaction` for at least
3939/// the VM's lifetime — `VM` now borrows its tx (`&'a Transaction`) instead of cloning it.
3940///
3941/// The envelope fields are carried over even though execution reads almost none of them,
3942/// because the L2 prices its L1 data fee from `Transaction::length()` — the RLP-encoded byte
3943/// count — and reserves that as gas before execution. Leaving them at their defaults made a
3944/// simulated transaction encode up to ~104 bytes shorter than the signed one it stands for
3945/// (a zero `U256` is one RLP byte, a real signature component is 33), so `eth_estimateGas`
3946/// under-reserved the L1 fee and the transaction it was estimating ran out of gas. The
3947/// signature is stamped at full width for the same reason, and deliberately at the maximum:
3948/// a real component can be shorter after leading-zero trimming, and over-reserving the L1
3949/// fee is safe where under-reserving is not.
3950fn generic_tx_to_transaction(tx: &GenericTransaction) -> Result<Transaction, VMError> {
3951    // Not read during execution — the sender comes from `env.origin` — so this only has to
3952    // encode to the same width as a real signature.
3953    let (signature_r, signature_s) = (U256::MAX, U256::MAX);
3954    let nonce = tx.nonce.unwrap_or_default();
3955    let gas_limit = tx.gas.unwrap_or_default();
3956    let max_fee_per_gas = tx.max_fee_per_gas.unwrap_or_default();
3957    let max_priority_fee_per_gas = tx.max_priority_fee_per_gas.unwrap_or_default();
3958    let chain_id = tx.chain_id.unwrap_or_default();
3959
3960    Ok(match &tx.authorization_list {
3961        Some(authorization_list) => Transaction::EIP7702Transaction(EIP7702Transaction {
3962            to: match tx.to {
3963                TxKind::Call(to) => to,
3964                TxKind::Create => {
3965                    return Err(InternalError::msg("Generic Tx cannot be create type").into());
3966                }
3967            },
3968            value: tx.value,
3969            data: tx.input.clone(),
3970            access_list: tx
3971                .access_list
3972                .iter()
3973                .map(|list| (list.address, list.storage_keys.clone()))
3974                .collect(),
3975            authorization_list: authorization_list
3976                .iter()
3977                .map(|auth| Into::<AuthorizationTuple>::into(auth.clone()))
3978                .collect(),
3979            nonce,
3980            gas_limit,
3981            max_fee_per_gas,
3982            max_priority_fee_per_gas,
3983            chain_id,
3984            signature_r,
3985            signature_s,
3986            ..Default::default()
3987        }),
3988        None => Transaction::EIP1559Transaction(EIP1559Transaction {
3989            to: tx.to.clone(),
3990            value: tx.value,
3991            data: tx.input.clone(),
3992            access_list: tx
3993                .access_list
3994                .iter()
3995                .map(|list| (list.address, list.storage_keys.clone()))
3996                .collect(),
3997            nonce,
3998            gas_limit,
3999            max_fee_per_gas,
4000            max_priority_fee_per_gas,
4001            chain_id,
4002            signature_r,
4003            signature_s,
4004            ..Default::default()
4005        }),
4006    })
4007}
4008
4009fn vm_from_generic<'a>(
4010    tx: &'a Transaction,
4011    env: Environment,
4012    db: &'a mut GeneralizedDatabase,
4013    vm_type: VMType,
4014    crypto: &'a dyn Crypto,
4015    stateless_validator: Option<&'a dyn StatelessValidator>,
4016) -> Result<VM<'a>, VMError> {
4017    let vm_type = adjust_disabled_l2_fees(&env, vm_type);
4018    VM::new(
4019        env,
4020        db,
4021        tx,
4022        LevmCallTracer::disabled(),
4023        vm_type,
4024        crypto,
4025        stateless_validator,
4026    )
4027}
4028
4029pub fn get_max_allowed_gas_limit(block_gas_limit: u64, fork: Fork) -> u64 {
4030    // EIP-7825 imposes a flat per-tx gas cap (POST_OSAKA_GAS_LIMIT_CAP) from
4031    // Osaka through the BPO forks. Amsterdam supersedes it with the EIP-8037 2D
4032    // gas model: tx.gas may exceed the flat cap (the excess funds the state-gas
4033    // reservoir) and is instead bounded by block_gas_limit on the state
4034    // dimension, so capping the estimateGas ceiling at the flat value would
4035    // prevent convergence for state-heavy creations. Mirror the Osaka-only
4036    // gating used at the other cap sites (block validation, default_hook).
4037    if fork >= Fork::Osaka && fork < Fork::Amsterdam {
4038        POST_OSAKA_GAS_LIMIT_CAP
4039    } else {
4040        block_gas_limit
4041    }
4042}
4043
4044/// Format a balance diff (signed wei) and try to identify it as a multiple of
4045/// well-known EIP-8037 state-gas constants (NEW_ACCOUNT, STORAGE_SET, AUTH_*),
4046/// scaled by a plausible gas_price. Best-effort hint to triage gas-accounting
4047/// drifts at a glance.
4048///
4049/// `dead_code` allowed: only reached via the L1 BAL-validation path, which is
4050/// not exercised in the L2 build profile so the per-crate analysis flags it.
4051#[allow(dead_code)]
4052fn describe_balance_diff(expected: U256, actual: U256) -> String {
4053    let (sign, mag) = if expected >= actual {
4054        ("+", expected - actual)
4055    } else {
4056        ("-", actual - expected)
4057    };
4058    let Ok(mag_u128) = u128::try_from(mag) else {
4059        return format!("{sign}{mag}");
4060    };
4061    if mag_u128 == 0 {
4062        return "0".to_string();
4063    }
4064    let cpsb: u128 = 1530;
4065    // EIP-8037 state-byte constants
4066    let consts = [
4067        ("NEW_ACCOUNT", 120u128),
4068        ("STORAGE_SET", 64),
4069        ("AUTH_BASE", 23),
4070        ("AUTH_TOTAL", 143),
4071    ];
4072    // Try common test gas_prices first, then 1 wei/gas as fallback.
4073    for &gp in &[10u128, 1, 7, 100, 1000, 1_000_000_000] {
4074        if !mag_u128.is_multiple_of(gp) {
4075            continue;
4076        }
4077        let gas = mag_u128 / gp;
4078        if !gas.is_multiple_of(cpsb) {
4079            continue;
4080        }
4081        let bytes = gas / cpsb;
4082        for (name, c) in consts {
4083            if bytes.is_multiple_of(c) {
4084                let n = bytes / c;
4085                return format!(
4086                    "{sign}{mag_u128} wei (= {gas} gas at {gp} wei/gas = {n}× {name}_state_gas)"
4087                );
4088            }
4089        }
4090    }
4091    format!("{sign}{mag_u128} wei")
4092}
4093
4094// Exercises the rayon-parallel-BAL execution path (and shares its
4095// `rayon`-gated imports), so it only builds when that feature is on.
4096#[cfg(all(test, feature = "rayon"))]
4097mod bal_tests {
4098    use super::*;
4099    use ethrex_common::H256;
4100    use ethrex_common::types::AccountState;
4101    use ethrex_common::types::block_access_list::{
4102        AccountChanges, BalanceChange, NonceChange, SlotChange, StorageChange,
4103    };
4104    use ethrex_levm::errors::DatabaseError;
4105
4106    fn addr(byte: u8) -> Address {
4107        let mut a = Address::zero();
4108        a.0[19] = byte;
4109        a
4110    }
4111
4112    /// Minimal in-memory store for testing bal_to_account_updates.
4113    struct MockStore {
4114        accounts: FxHashMap<Address, AccountState>,
4115    }
4116
4117    impl MockStore {
4118        fn new() -> Self {
4119            Self {
4120                accounts: FxHashMap::default(),
4121            }
4122        }
4123
4124        fn with_account(mut self, address: Address, state: AccountState) -> Self {
4125            self.accounts.insert(address, state);
4126            self
4127        }
4128    }
4129
4130    impl Database for MockStore {
4131        fn get_account_state(&self, address: Address) -> Result<AccountState, DatabaseError> {
4132            Ok(self.accounts.get(&address).copied().unwrap_or_default())
4133        }
4134        fn get_storage_value(&self, _: Address, _: H256) -> Result<U256, DatabaseError> {
4135            Ok(U256::zero())
4136        }
4137        fn get_block_hash(&self, _: u64) -> Result<H256, DatabaseError> {
4138            Ok(H256::zero())
4139        }
4140        fn get_chain_config(&self) -> Result<ethrex_common::types::ChainConfig, DatabaseError> {
4141            Err(DatabaseError::Custom("not implemented".into()))
4142        }
4143        fn get_account_code(&self, _: H256) -> Result<ethrex_common::types::Code, DatabaseError> {
4144            Ok(ethrex_common::types::Code::from_bytecode(
4145                Bytes::new(),
4146                &ethrex_crypto::NativeCrypto,
4147            ))
4148        }
4149        fn get_code_metadata(
4150            &self,
4151            _: H256,
4152        ) -> Result<ethrex_common::types::CodeMetadata, DatabaseError> {
4153            Ok(ethrex_common::types::CodeMetadata { length: 0 })
4154        }
4155    }
4156
4157    #[test]
4158    fn test_bal_to_account_updates_basic() {
4159        // Account with balance + nonce + storage changes → correct AccountUpdate
4160        let address = addr(1);
4161        let store = MockStore::new().with_account(
4162            address,
4163            AccountState {
4164                balance: U256::from(100),
4165                nonce: 5,
4166                code_hash: *EMPTY_KECCAK_HASH,
4167                storage_root: H256::zero(),
4168            },
4169        );
4170
4171        let bal = BlockAccessList::from_accounts(vec![
4172            AccountChanges::new(address)
4173                .with_balance_changes(vec![
4174                    BalanceChange::new(1, U256::from(90)),
4175                    BalanceChange::new(2, U256::from(80)),
4176                ])
4177                .with_nonce_changes(vec![NonceChange::new(1, 6)])
4178                .with_storage_changes(vec![SlotChange::with_changes(
4179                    U256::from(42),
4180                    vec![StorageChange::new(1, U256::from(999))],
4181                )]),
4182        ]);
4183
4184        let updates = LEVM::bal_to_account_updates(&bal, &store).unwrap();
4185        assert_eq!(updates.len(), 1);
4186        let u = &updates[0];
4187        assert_eq!(u.address, address);
4188        assert!(!u.removed);
4189        let info = u.info.as_ref().unwrap();
4190        // Last balance entry wins
4191        assert_eq!(info.balance, U256::from(80));
4192        assert_eq!(info.nonce, 6);
4193        assert_eq!(info.code_hash, *EMPTY_KECCAK_HASH);
4194        // Storage
4195        let key = ethrex_common::utils::u256_to_h256(U256::from(42));
4196        assert_eq!(*u.added_storage.get(&key).unwrap(), U256::from(999));
4197    }
4198
4199    #[test]
4200    fn test_bal_to_account_updates_highest_index_wins() {
4201        // Multiple changes per field: the last entry (highest index) wins.
4202        let address = addr(2);
4203        let store = MockStore::new().with_account(
4204            address,
4205            AccountState {
4206                balance: U256::from(1000),
4207                nonce: 0,
4208                code_hash: *EMPTY_KECCAK_HASH,
4209                storage_root: H256::zero(),
4210            },
4211        );
4212
4213        let bal = BlockAccessList::from_accounts(vec![
4214            AccountChanges::new(address).with_balance_changes(vec![
4215                BalanceChange::new(1, U256::from(900)),
4216                BalanceChange::new(2, U256::from(800)),
4217                BalanceChange::new(3, U256::from(700)),
4218            ]),
4219        ]);
4220
4221        let updates = LEVM::bal_to_account_updates(&bal, &store).unwrap();
4222        assert_eq!(updates.len(), 1);
4223        assert_eq!(updates[0].info.as_ref().unwrap().balance, U256::from(700));
4224    }
4225
4226    #[test]
4227    fn test_bal_to_account_updates_reads_only_skipped() {
4228        // Account with only storage_reads and no writes → no AccountUpdate.
4229        let address = addr(3);
4230        let store = MockStore::new();
4231
4232        let bal = BlockAccessList::from_accounts(vec![
4233            AccountChanges::new(address).with_storage_reads(vec![U256::from(1)]),
4234        ]);
4235
4236        let updates = LEVM::bal_to_account_updates(&bal, &store).unwrap();
4237        assert!(updates.is_empty());
4238    }
4239
4240    #[test]
4241    fn test_bal_to_account_updates_removal() {
4242        // Account removal (EIP-161): post-state empty but pre-state existed.
4243        let address = addr(4);
4244        let store = MockStore::new().with_account(
4245            address,
4246            AccountState {
4247                balance: U256::from(50),
4248                nonce: 1,
4249                code_hash: *EMPTY_KECCAK_HASH,
4250                storage_root: H256::zero(),
4251            },
4252        );
4253
4254        let bal = BlockAccessList::from_accounts(vec![
4255            AccountChanges::new(address)
4256                .with_balance_changes(vec![BalanceChange::new(1, U256::zero())])
4257                .with_nonce_changes(vec![NonceChange::new(1, 0)]),
4258        ]);
4259
4260        let updates = LEVM::bal_to_account_updates(&bal, &store).unwrap();
4261        assert_eq!(updates.len(), 1);
4262        assert!(updates[0].removed);
4263    }
4264
4265    #[test]
4266    fn test_bal_to_account_updates_storage_zero() {
4267        // Storage slot set to 0 → included in added_storage (valid trie deletion).
4268        let address = addr(5);
4269        let store = MockStore::new();
4270
4271        let bal = BlockAccessList::from_accounts(vec![
4272            AccountChanges::new(address).with_storage_changes(vec![SlotChange::with_changes(
4273                U256::from(7),
4274                vec![StorageChange::new(1, U256::zero())],
4275            )]),
4276        ]);
4277
4278        let updates = LEVM::bal_to_account_updates(&bal, &store).unwrap();
4279        assert_eq!(updates.len(), 1);
4280        let key = ethrex_common::utils::u256_to_h256(U256::from(7));
4281        assert_eq!(*updates[0].added_storage.get(&key).unwrap(), U256::zero());
4282    }
4283
4284    #[test]
4285    fn test_bal_to_account_updates_code_deployment() {
4286        // Code deployment → correct code_hash computed.
4287        let address = addr(6);
4288        let store = MockStore::new();
4289        let code = Bytes::from(vec![0x60, 0x00, 0x60, 0x00, 0xf3]); // PUSH0 PUSH0 RETURN
4290        let expected_hash =
4291            ethrex_common::types::Code::from_bytecode(code.clone(), &ethrex_crypto::NativeCrypto)
4292                .hash;
4293
4294        let bal = BlockAccessList::from_accounts(vec![
4295            AccountChanges::new(address)
4296                .with_code_changes(vec![
4297                    ethrex_common::types::block_access_list::CodeChange::new(1, code.clone()),
4298                ])
4299                .with_nonce_changes(vec![NonceChange::new(1, 1)]),
4300        ]);
4301
4302        let updates = LEVM::bal_to_account_updates(&bal, &store).unwrap();
4303        assert_eq!(updates.len(), 1);
4304        let u = &updates[0];
4305        assert_eq!(u.info.as_ref().unwrap().code_hash, expected_hash);
4306        assert_eq!(u.code.as_ref().unwrap().code(), &code[..]);
4307    }
4308}
4309
4310#[cfg(test)]
4311mod system_call_coinbase_tests {
4312    //! Regression tests for the system-call coinbase collision. When a block's
4313    //! fee recipient (coinbase) equals the system contract being called,
4314    //! `generic_system_contract_levm`'s post-call coinbase restore must NOT clobber
4315    //! the storage write the system call just made; otherwise the write is dropped
4316    //! from the emitted state updates and the state root diverges from other clients.
4317    use super::*;
4318    use ethrex_common::types::{AccountState, AccountUpdate, ChainConfig, Code, CodeMetadata};
4319    use ethrex_crypto::NativeCrypto;
4320    use ethrex_levm::db::Database;
4321    use ethrex_levm::errors::DatabaseError;
4322    use std::sync::Arc;
4323
4324    // EIP-2935 history-contract runtime bytecode.
4325    const HISTORY_RUNTIME_CODE: &str = concat!(
4326        "3373fffffffffffffffffffffffffffffffffffffffe1460465760203603604257",
4327        "5f35600143038111604257611fff81430311604257611fff900654",
4328        "5f5260205ff35b5f5ffd5b5f35611fff60014303065500",
4329    );
4330
4331    struct Store {
4332        chain_config: ChainConfig,
4333        history_code: Code,
4334    }
4335
4336    impl Database for Store {
4337        fn get_account_state(&self, address: Address) -> Result<AccountState, DatabaseError> {
4338            if address == HISTORY_STORAGE_ADDRESS.address {
4339                return Ok(AccountState {
4340                    nonce: 1,
4341                    code_hash: self.history_code.hash,
4342                    ..Default::default()
4343                });
4344            }
4345            Ok(AccountState::default())
4346        }
4347        fn get_storage_value(&self, _: Address, _: H256) -> Result<U256, DatabaseError> {
4348            Ok(U256::zero())
4349        }
4350        fn get_block_hash(&self, _: u64) -> Result<H256, DatabaseError> {
4351            Ok(H256::zero())
4352        }
4353        fn get_chain_config(&self) -> Result<ChainConfig, DatabaseError> {
4354            Ok(self.chain_config)
4355        }
4356        fn get_account_code(&self, code_hash: H256) -> Result<Code, DatabaseError> {
4357            if code_hash == self.history_code.hash {
4358                return Ok(self.history_code.clone());
4359            }
4360            Ok(Code::default())
4361        }
4362        fn get_code_metadata(&self, code_hash: H256) -> Result<CodeMetadata, DatabaseError> {
4363            let length = if code_hash == self.history_code.hash {
4364                self.history_code.len() as u64
4365            } else {
4366                0
4367            };
4368            Ok(CodeMetadata { length })
4369        }
4370    }
4371
4372    fn history_code() -> Code {
4373        let bytes = hex::decode(HISTORY_RUNTIME_CODE).expect("history runtime code is valid hex");
4374        Code::from_bytecode(Bytes::from(bytes), &NativeCrypto)
4375    }
4376
4377    fn prague_db() -> GeneralizedDatabase {
4378        GeneralizedDatabase::new(Arc::new(Store {
4379            chain_config: ChainConfig {
4380                prague_time: Some(0),
4381                ..Default::default()
4382            },
4383            history_code: history_code(),
4384        }))
4385    }
4386
4387    fn parent_hash_value(parent_hash: H256) -> U256 {
4388        U256::from_big_endian(parent_hash.as_bytes())
4389    }
4390
4391    fn history_slot(block_number: u64) -> H256 {
4392        H256::from_low_u64_be((block_number - 1) % 8191)
4393    }
4394
4395    /// Run the EIP-2935 system call for block 42 with the given fee recipient and
4396    /// return (slot value cached on the history contract, emitted state updates,
4397    /// parent hash).
4398    fn run_history_update(coinbase: Address) -> (Option<U256>, Vec<AccountUpdate>, H256) {
4399        let mut db = prague_db();
4400        let parent_hash = H256::from_low_u64_be(0x2935);
4401        let block_number = 42;
4402        let header = BlockHeader {
4403            parent_hash,
4404            coinbase,
4405            number: block_number,
4406            timestamp: 1,
4407            ..Default::default()
4408        };
4409
4410        LEVM::process_block_hash_history(&header, &mut db, VMType::L1, &NativeCrypto)
4411            .expect("history system call executes");
4412
4413        let slot = history_slot(block_number);
4414        let stored_value = db
4415            .current_accounts_state
4416            .get(&HISTORY_STORAGE_ADDRESS.address)
4417            .and_then(|account| account.storage.get(&slot).copied());
4418        let updates =
4419            LEVM::get_state_transitions(&mut db).expect("state transitions are generated");
4420
4421        (stored_value, updates, parent_hash)
4422    }
4423
4424    fn assert_history_write_emitted(coinbase: Address) {
4425        let (stored_value, updates, parent_hash) = run_history_update(coinbase);
4426        let slot = history_slot(42);
4427        assert_eq!(
4428            stored_value,
4429            Some(parent_hash_value(parent_hash)),
4430            "history storage must hold the parent hash after the system call"
4431        );
4432        assert!(
4433            updates.iter().any(|update| {
4434                update.address == HISTORY_STORAGE_ADDRESS.address
4435                    && update.added_storage.get(&slot) == Some(&parent_hash_value(parent_hash))
4436            }),
4437            "the history-contract storage write must be emitted as a state update"
4438        );
4439    }
4440
4441    #[test]
4442    fn ordinary_coinbase_preserves_history_storage_write() {
4443        assert_history_write_emitted(Address::from_low_u64_be(0xbeef));
4444    }
4445
4446    /// Regression: a fee recipient equal to the EIP-2935 history contract must not
4447    /// cause the system call's storage write to be dropped by the coinbase restore.
4448    #[test]
4449    fn history_address_coinbase_preserves_history_storage_write() {
4450        assert_history_write_emitted(HISTORY_STORAGE_ADDRESS.address);
4451    }
4452}
4453
4454/// Tests for EIP-8079 burned_fees computation in execute_block (LStar-gated).
4455///
4456/// Shares the non-guest execution path's `rayon`-gated imports
4457/// (`EMPTY_KECCAK_HASH`, `FxHashMap`, `Database`), so it only builds with rayon.
4458#[cfg(all(test, feature = "rayon"))]
4459mod burned_fees_tests {
4460    use super::*;
4461    use ethrex_common::{
4462        constants::{DEFAULT_OMMERS_HASH, DEFAULT_REQUESTS_HASH},
4463        types::{AccountState, Block, BlockBody, BlockHeader, ChainConfig, Code, CodeMetadata},
4464        utils::keccak,
4465    };
4466    use ethrex_crypto::NativeCrypto;
4467    use ethrex_levm::errors::DatabaseError;
4468    use ethrex_rlp::encode::PayloadRLPEncode;
4469
4470    // ── helpers ─────────────────────────────────────────────────────────────
4471
4472    fn lstar_chain_config() -> ChainConfig {
4473        ChainConfig {
4474            chain_id: 1,
4475            homestead_block: Some(0),
4476            eip150_block: Some(0),
4477            eip155_block: Some(0),
4478            eip158_block: Some(0),
4479            byzantium_block: Some(0),
4480            constantinople_block: Some(0),
4481            petersburg_block: Some(0),
4482            istanbul_block: Some(0),
4483            berlin_block: Some(0),
4484            london_block: Some(0),
4485            terminal_total_difficulty: Some(0),
4486            terminal_total_difficulty_passed: true,
4487            shanghai_time: Some(0),
4488            cancun_time: Some(0),
4489            prague_time: Some(0),
4490            amsterdam_time: Some(0),
4491            lstar_time: Some(0),
4492            ..Default::default()
4493        }
4494    }
4495
4496    fn amsterdam_chain_config() -> ChainConfig {
4497        ChainConfig {
4498            chain_id: 1,
4499            homestead_block: Some(0),
4500            eip150_block: Some(0),
4501            eip155_block: Some(0),
4502            eip158_block: Some(0),
4503            byzantium_block: Some(0),
4504            constantinople_block: Some(0),
4505            petersburg_block: Some(0),
4506            istanbul_block: Some(0),
4507            berlin_block: Some(0),
4508            london_block: Some(0),
4509            terminal_total_difficulty: Some(0),
4510            terminal_total_difficulty_passed: true,
4511            shanghai_time: Some(0),
4512            cancun_time: Some(0),
4513            prague_time: Some(0),
4514            amsterdam_time: Some(0),
4515            lstar_time: None, // no LStar → burned_fees must be None
4516            ..Default::default()
4517        }
4518    }
4519
4520    // ── mock database ────────────────────────────────────────────────────────
4521
4522    struct MockDB {
4523        chain_config: ChainConfig,
4524        accounts: FxHashMap<Address, (AccountState, Bytes)>,
4525        /// Pre-set storage values (address, slot) → value.
4526        storage: FxHashMap<(Address, H256), U256>,
4527    }
4528
4529    impl MockDB {
4530        fn new(chain_config: ChainConfig) -> Self {
4531            Self {
4532                chain_config,
4533                accounts: FxHashMap::default(),
4534                storage: FxHashMap::default(),
4535            }
4536        }
4537
4538        fn with_account(mut self, address: Address, balance: U256, code: Option<Bytes>) -> Self {
4539            let code_bytes = code.unwrap_or_default();
4540            let code_hash = if code_bytes.is_empty() {
4541                *EMPTY_KECCAK_HASH
4542            } else {
4543                keccak(&code_bytes)
4544            };
4545            let state = AccountState {
4546                nonce: 0,
4547                balance,
4548                storage_root: H256::zero(),
4549                code_hash,
4550            };
4551            self.accounts.insert(address, (state, code_bytes));
4552            self
4553        }
4554
4555        fn with_storage(mut self, address: Address, slot: H256, value: U256) -> Self {
4556            self.storage.insert((address, slot), value);
4557            self
4558        }
4559    }
4560
4561    impl Database for MockDB {
4562        fn get_account_state(&self, address: Address) -> Result<AccountState, DatabaseError> {
4563            Ok(self
4564                .accounts
4565                .get(&address)
4566                .map(|(s, _)| *s)
4567                .unwrap_or_default())
4568        }
4569
4570        fn get_storage_value(&self, address: Address, slot: H256) -> Result<U256, DatabaseError> {
4571            Ok(self
4572                .storage
4573                .get(&(address, slot))
4574                .copied()
4575                .unwrap_or_default())
4576        }
4577
4578        fn get_block_hash(&self, _: u64) -> Result<H256, DatabaseError> {
4579            Ok(H256::zero())
4580        }
4581
4582        fn get_chain_config(&self) -> Result<ChainConfig, DatabaseError> {
4583            Ok(self.chain_config)
4584        }
4585
4586        fn get_account_code(&self, code_hash: H256) -> Result<Code, DatabaseError> {
4587            let crypto = NativeCrypto;
4588            for (state, bytecode) in self.accounts.values() {
4589                if state.code_hash == code_hash && !bytecode.is_empty() {
4590                    return Ok(Code::from_bytecode(bytecode.clone(), &crypto));
4591                }
4592            }
4593            Ok(Code::from_bytecode(Bytes::new(), &crypto))
4594        }
4595
4596        fn get_code_metadata(&self, code_hash: H256) -> Result<CodeMetadata, DatabaseError> {
4597            for (state, bytecode) in self.accounts.values() {
4598                if state.code_hash == code_hash {
4599                    return Ok(CodeMetadata {
4600                        length: bytecode.len() as u64,
4601                    });
4602                }
4603            }
4604            Ok(CodeMetadata { length: 0 })
4605        }
4606    }
4607
4608    // ── signing ──────────────────────────────────────────────────────────────
4609
4610    const PRIVATE_KEY: [u8; 32] = [
4611        0x4c, 0x08, 0x83, 0xa6, 0x91, 0x02, 0x93, 0x7d, 0x62, 0x31, 0x47, 0x1b, 0x5d, 0xbb, 0x62,
4612        0x04, 0xfe, 0x51, 0x29, 0x61, 0x70, 0x82, 0x79, 0x2a, 0xe4, 0x68, 0xd0, 0x1a, 0x3f, 0x36,
4613        0x23, 0x18,
4614    ];
4615
4616    fn sender_address() -> Address {
4617        use k256::ecdsa::SigningKey;
4618        let signing_key = SigningKey::from_bytes(&PRIVATE_KEY.into()).expect("valid key");
4619        let uncompressed_pub = signing_key.verifying_key().to_encoded_point(false);
4620        let pub_hash = keccak(&uncompressed_pub.as_bytes()[1..]);
4621        Address::from_slice(&pub_hash.0[12..])
4622    }
4623
4624    /// Build a signed EIP-1559 ETH transfer.
4625    /// base_fee = BASE_FEE, gas_limit = 21_000 → gas_spent = 21_000 (no refunds).
4626    fn signed_eth_transfer(to: Address, nonce: u64, base_fee: u64) -> Transaction {
4627        use k256::ecdsa::SigningKey;
4628
4629        let tx = EIP1559Transaction {
4630            chain_id: 1,
4631            nonce,
4632            max_priority_fee_per_gas: 1,
4633            max_fee_per_gas: base_fee + 10,
4634            gas_limit: 21_000,
4635            to: TxKind::Call(to),
4636            value: U256::zero(),
4637            ..Default::default()
4638        };
4639
4640        // Signing hash: [0x02] || RLP(chain_id, nonce, max_priority, max_fee, gas, to, value, data, access_list)
4641        let mut buf = vec![0x02u8];
4642        tx.encode_payload(&mut buf);
4643        let hash = keccak(&buf);
4644
4645        let signing_key = SigningKey::from_bytes(&PRIVATE_KEY.into()).expect("valid key");
4646        let (sig, rid) = signing_key
4647            .sign_prehash_recoverable(&hash.0)
4648            .expect("sign ok");
4649        let sig_bytes = sig.to_bytes();
4650
4651        Transaction::EIP1559Transaction(EIP1559Transaction {
4652            signature_r: U256::from_big_endian(&sig_bytes[..32]),
4653            signature_s: U256::from_big_endian(&sig_bytes[32..]),
4654            signature_y_parity: rid.to_byte() != 0,
4655            ..tx
4656        })
4657    }
4658
4659    // STOP opcode: system contracts need non-empty code (EIP-7002/7251 check).
4660    const STOP_BYTECODE: &[u8] = &[0x00];
4661
4662    fn make_block_with_tx(tx: Transaction, base_fee: u64) -> Block {
4663        let header = BlockHeader {
4664            parent_hash: H256::zero(),
4665            ommers_hash: *DEFAULT_OMMERS_HASH,
4666            number: 1,
4667            gas_limit: 1_000_000,
4668            timestamp: 1,
4669            base_fee_per_gas: Some(base_fee),
4670            withdrawals_root: Some(H256::zero()),
4671            requests_hash: Some(*DEFAULT_REQUESTS_HASH),
4672            // No parent_beacon_block_root → skip beacon-root system call.
4673            parent_beacon_block_root: None,
4674            ..Default::default()
4675        };
4676        let body = BlockBody {
4677            transactions: vec![tx],
4678            ommers: vec![],
4679            withdrawals: Some(vec![]),
4680        };
4681        Block::new(header, body)
4682    }
4683
4684    fn make_gen_db(chain_config: ChainConfig) -> GeneralizedDatabase {
4685        let sender = sender_address();
4686        let stop = Bytes::from_static(STOP_BYTECODE);
4687        let mock = MockDB::new(chain_config)
4688            // Fund the sender with enough ETH to pay for gas + priority fee.
4689            .with_account(sender, U256::from(1_000_000_000u64), None)
4690            // Prague system contracts require non-empty code (EIP-7002/7251).
4691            .with_account(
4692                WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS.address,
4693                U256::zero(),
4694                Some(stop.clone()),
4695            )
4696            .with_account(
4697                CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS.address,
4698                U256::zero(),
4699                Some(stop.clone()),
4700            )
4701            // EIP-8282 (Amsterdam) builder predeploys require non-empty code.
4702            .with_account(
4703                BUILDER_DEPOSIT_CONTRACT_ADDRESS.address,
4704                U256::zero(),
4705                Some(stop.clone()),
4706            )
4707            .with_account(
4708                BUILDER_EXIT_CONTRACT_ADDRESS.address,
4709                U256::zero(),
4710                Some(stop),
4711            );
4712        GeneralizedDatabase::new(Arc::new(mock))
4713    }
4714
4715    // Contract bytecode: PUSH1 0x00, PUSH1 0x00, SSTORE, STOP
4716    // Clears storage slot 0.  When the slot's initial value is non-zero, the
4717    // EIP-3529 "SSTORE_CLEARS_SCHEDULE" refund of 4800 gas is applied, making
4718    // gas_used > gas_spent so block_gas_used ≠ cumulative_gas_used.
4719    const SSTORE_CLEAR_BYTECODE: &[u8] = &[0x60, 0x00, 0x60, 0x00, 0x55, 0x00];
4720
4721    /// Build a signed EIP-1559 call to `to` with an explicit gas limit.
4722    fn signed_call_tx(to: Address, nonce: u64, base_fee: u64, gas_limit: u64) -> Transaction {
4723        use k256::ecdsa::SigningKey;
4724
4725        let tx = EIP1559Transaction {
4726            chain_id: 1,
4727            nonce,
4728            max_priority_fee_per_gas: 1,
4729            max_fee_per_gas: base_fee + 10,
4730            gas_limit,
4731            to: TxKind::Call(to),
4732            value: U256::zero(),
4733            ..Default::default()
4734        };
4735
4736        let mut buf = vec![0x02u8];
4737        tx.encode_payload(&mut buf);
4738        let hash = keccak(&buf);
4739
4740        let signing_key = SigningKey::from_bytes(&PRIVATE_KEY.into()).expect("valid key");
4741        let (sig, rid) = signing_key
4742            .sign_prehash_recoverable(&hash.0)
4743            .expect("sign ok");
4744        let sig_bytes = sig.to_bytes();
4745
4746        Transaction::EIP1559Transaction(EIP1559Transaction {
4747            signature_r: U256::from_big_endian(&sig_bytes[..32]),
4748            signature_s: U256::from_big_endian(&sig_bytes[32..]),
4749            signature_y_parity: rid.to_byte() != 0,
4750            ..tx
4751        })
4752    }
4753
4754    /// DB with SSTORE_CLEAR_BYTECODE deployed at `contract_addr` and slot 0 = 1.
4755    /// The sender has extra ETH to cover the higher gas_limit in the call tx.
4756    fn make_gen_db_sstore_clear(
4757        chain_config: ChainConfig,
4758        contract_addr: Address,
4759    ) -> GeneralizedDatabase {
4760        let sender = sender_address();
4761        let stop = Bytes::from_static(STOP_BYTECODE);
4762        let mock = MockDB::new(chain_config)
4763            .with_account(sender, U256::from(100_000_000_000u64), None)
4764            .with_account(
4765                WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS.address,
4766                U256::zero(),
4767                Some(stop.clone()),
4768            )
4769            .with_account(
4770                CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS.address,
4771                U256::zero(),
4772                Some(stop.clone()),
4773            )
4774            // EIP-8282 (Amsterdam) builder predeploys require non-empty code.
4775            .with_account(
4776                BUILDER_DEPOSIT_CONTRACT_ADDRESS.address,
4777                U256::zero(),
4778                Some(stop.clone()),
4779            )
4780            .with_account(
4781                BUILDER_EXIT_CONTRACT_ADDRESS.address,
4782                U256::zero(),
4783                Some(stop),
4784            )
4785            .with_account(
4786                contract_addr,
4787                U256::zero(),
4788                Some(Bytes::from_static(SSTORE_CLEAR_BYTECODE)),
4789            )
4790            // Slot 0 = 1 so SSTORE(0, 0) transitions 1→0, triggering EIP-3529 refund.
4791            .with_storage(contract_addr, H256::zero(), U256::one());
4792        GeneralizedDatabase::new(Arc::new(mock))
4793    }
4794
4795    // ── tests ────────────────────────────────────────────────────────────────
4796
4797    #[test]
4798    fn test_burned_fees_block_aggregate() {
4799        use std::sync::atomic::AtomicUsize;
4800        use std::sync::mpsc;
4801
4802        const BASE_FEE: u64 = 10;
4803        // Simple ETH transfer, no blobs (blob_gas_used = 0, blob_base_fee = 0).
4804        // The exact gas charged depends on the active fork's gas model, so this test
4805        // verifies the burned_fees FORMULA against the block's actual post-refund gas
4806        // (rather than a hardcoded amount) plus the execute_block ↔ execute_block_pipeline
4807        // cross-path consistency and the LStar gate. The refund-sensitive case lives in
4808        // test_burned_fees_post_refund_discriminating.
4809
4810        let recipient = Address::from_low_u64_be(0xBEEF);
4811        let tx = signed_eth_transfer(recipient, 0, BASE_FEE);
4812        let block = make_block_with_tx(tx, BASE_FEE);
4813        let crypto = NativeCrypto;
4814
4815        // (a) execute_block path
4816        let mut db1 = make_gen_db(lstar_chain_config());
4817        let (result, _bal) = LEVM::execute_block(&block, &mut db1, VMType::L1, &crypto, None)
4818            .expect("execute_block succeeded");
4819        let cumulative = result
4820            .receipts
4821            .last()
4822            .map(|r| r.cumulative_gas_used)
4823            .unwrap_or(0);
4824        let expected = compute_burned_fees(BASE_FEE, cumulative, 0, 0);
4825        assert_eq!(
4826            result.burned_fees,
4827            Some(expected),
4828            "execute_block: burned_fees must equal base_fee * post-refund gas ({cumulative})"
4829        );
4830
4831        // (b) execute_block_pipeline path (sequential: no BAL provided)
4832        let mut db2 = make_gen_db(lstar_chain_config());
4833        let (tx_sender, _rx) = mpsc::channel();
4834        let queue_len = AtomicUsize::new(0);
4835        let tx2 = signed_eth_transfer(recipient, 0, BASE_FEE);
4836        let block2 = make_block_with_tx(tx2, BASE_FEE);
4837        let (result_pipeline, _bal) = LEVM::execute_block_pipeline(
4838            &block2,
4839            &mut db2,
4840            VMType::L1,
4841            Some(tx_sender),
4842            &queue_len,
4843            &crypto,
4844            None, // no BAL → sequential path
4845            false,
4846            None,
4847        )
4848        .expect("execute_block_pipeline succeeded");
4849        assert_eq!(
4850            result_pipeline.burned_fees, result.burned_fees,
4851            "execute_block_pipeline: burned_fees must match execute_block (production/verification consistency)"
4852        );
4853
4854        // (c) pre-LStar → None
4855        let tx3 = signed_eth_transfer(recipient, 0, BASE_FEE);
4856        let block3 = make_block_with_tx(tx3, BASE_FEE);
4857        let mut db3 = make_gen_db(amsterdam_chain_config());
4858        let (result_pre_lstar, _) =
4859            LEVM::execute_block(&block3, &mut db3, VMType::L1, &crypto, None)
4860                .expect("pre-LStar execute_block succeeded");
4861        assert_eq!(
4862            result_pre_lstar.burned_fees, None,
4863            "pre-LStar: burned_fees must be None"
4864        );
4865    }
4866
4867    /// Discriminating test: a transaction with an EIP-3529 SSTORE refund makes
4868    /// `block_gas_used` (pre-refund) differ from `cumulative_gas_used` (post-refund).
4869    /// The correct `burned_fees` uses post-refund gas; using `block_gas_used` would
4870    /// over-count by `base_fee * refund_amount`.
4871    ///
4872    /// Gas analysis (Prague + Amsterdam era):
4873    ///   intrinsic = 21 000
4874    ///   PUSH1 + PUSH1 = 6
4875    ///   SSTORE cold (original=1, new=0): cold-access 2100 + warm-reset 2900 = 5 000
4876    ///     refund = SSTORE_CLEARS_SCHEDULE = 4 800 (≤ 26 006 / 5 = 5 201, so full refund)
4877    ///   gas_used = 26 006, gas_spent = 21 206
4878    ///
4879    /// With the wrong code (block_gas_used = 26 006):
4880    ///   burned_fees = 10 * 26 006 = 260 060  ← test FAILS
4881    /// After the fix (cumulative_gas_used = 21 206):
4882    ///   burned_fees = 10 * 21 206 = 212 060  ← test PASSES
4883    #[test]
4884    fn test_burned_fees_post_refund_discriminating() {
4885        use std::sync::atomic::AtomicUsize;
4886        use std::sync::mpsc;
4887
4888        const BASE_FEE: u64 = 10;
4889        // Gas limit must exceed gas_used (26 006); 100 000 is ample.
4890        const TX_GAS_LIMIT: u64 = 100_000;
4891
4892        let contract_addr = Address::from_low_u64_be(0xC0DEBEEF);
4893        let crypto = NativeCrypto;
4894
4895        // ── (a) execute_block path (site 1) ───────────────────────────────────
4896        let tx_a = signed_call_tx(contract_addr, 0, BASE_FEE, TX_GAS_LIMIT);
4897        let block_a = make_block_with_tx(tx_a, BASE_FEE);
4898        let mut db_a = make_gen_db_sstore_clear(lstar_chain_config(), contract_addr);
4899        let (result_a, _) = LEVM::execute_block(&block_a, &mut db_a, VMType::L1, &crypto, None)
4900            .expect("execute_block with SSTORE clear succeeded");
4901
4902        let cumulative_a = result_a.receipts[0].cumulative_gas_used;
4903        // Discriminating guard: the refund must have happened.
4904        assert!(
4905            result_a.block_gas_used > cumulative_a,
4906            "SSTORE refund must cause block_gas_used ({}) > cumulative_gas_used ({})",
4907            result_a.block_gas_used,
4908            cumulative_a,
4909        );
4910        // Correct burned_fees uses post-refund cumulative_gas_used, not block_gas_used.
4911        assert_eq!(
4912            result_a.burned_fees,
4913            Some(BASE_FEE * cumulative_a),
4914            "execute_block: burned_fees must use post-refund gas (cumulative={}, block_gas_used={})",
4915            cumulative_a,
4916            result_a.block_gas_used,
4917        );
4918
4919        // ── (b) execute_block_pipeline sequential path (site 3, no BAL) ──────
4920        let tx_b = signed_call_tx(contract_addr, 0, BASE_FEE, TX_GAS_LIMIT);
4921        let block_b = make_block_with_tx(tx_b, BASE_FEE);
4922        let mut db_b = make_gen_db_sstore_clear(lstar_chain_config(), contract_addr);
4923        let (tx_sender_b, _rx_b) = mpsc::channel();
4924        let queue_len_b = AtomicUsize::new(0);
4925        let (result_b, _) = LEVM::execute_block_pipeline(
4926            &block_b,
4927            &mut db_b,
4928            VMType::L1,
4929            Some(tx_sender_b),
4930            &queue_len_b,
4931            &crypto,
4932            None, // no BAL → sequential path (site 3)
4933            false,
4934            None,
4935        )
4936        .expect("execute_block_pipeline sequential with SSTORE clear succeeded");
4937
4938        let cumulative_b = result_b.receipts[0].cumulative_gas_used;
4939        assert!(
4940            result_b.block_gas_used > cumulative_b,
4941            "sequential pipeline: SSTORE refund must be present",
4942        );
4943        assert_eq!(
4944            result_b.burned_fees,
4945            Some(BASE_FEE * cumulative_b),
4946            "execute_block_pipeline (sequential): burned_fees must use post-refund gas",
4947        );
4948
4949        // ── (c) execute_block_pipeline parallel path (site 2, with BAL) ──────
4950        // Generate BAL via execute_block, then re-execute using that BAL.
4951        let tx_c = signed_call_tx(contract_addr, 0, BASE_FEE, TX_GAS_LIMIT);
4952        let block_c = make_block_with_tx(tx_c, BASE_FEE);
4953        let mut db_c1 = make_gen_db_sstore_clear(lstar_chain_config(), contract_addr);
4954        let (_, bal_c) = LEVM::execute_block(&block_c, &mut db_c1, VMType::L1, &crypto, None)
4955            .expect("BAL generation succeeded");
4956        let bal_c = bal_c.expect("LStar must produce a BAL");
4957
4958        let mut db_c2 = make_gen_db_sstore_clear(lstar_chain_config(), contract_addr);
4959        let (tx_sender_c, _rx_c) = mpsc::channel();
4960        let queue_len_c = AtomicUsize::new(0);
4961        let (result_c, _) = LEVM::execute_block_pipeline(
4962            &block_c,
4963            &mut db_c2,
4964            VMType::L1,
4965            Some(tx_sender_c),
4966            &queue_len_c,
4967            &crypto,
4968            Some(std::sync::Arc::new(bal_c)), // ← parallel path (site 2)
4969            true,
4970            None,
4971        )
4972        .expect("execute_block_pipeline parallel with SSTORE clear succeeded");
4973
4974        let cumulative_c = result_c.receipts[0].cumulative_gas_used;
4975        assert!(
4976            result_c.block_gas_used > cumulative_c,
4977            "parallel pipeline: SSTORE refund must be present",
4978        );
4979        assert_eq!(
4980            result_c.burned_fees,
4981            Some(BASE_FEE * cumulative_c),
4982            "execute_block_pipeline (parallel): burned_fees must use post-refund gas",
4983        );
4984
4985        // ── cross-path consistency ─────────────────────────────────────────────
4986        assert_eq!(
4987            result_a.burned_fees, result_b.burned_fees,
4988            "execute_block vs sequential pipeline: burned_fees must match",
4989        );
4990        assert_eq!(
4991            result_a.burned_fees, result_c.burned_fees,
4992            "execute_block vs parallel pipeline: burned_fees must match",
4993        );
4994
4995        // ── (d) pre-LStar → None ──────────────────────────────────────────────
4996        let tx_d = signed_call_tx(contract_addr, 0, BASE_FEE, TX_GAS_LIMIT);
4997        let block_d = make_block_with_tx(tx_d, BASE_FEE);
4998        let mut db_d = make_gen_db_sstore_clear(amsterdam_chain_config(), contract_addr);
4999        let (result_d, _) = LEVM::execute_block(&block_d, &mut db_d, VMType::L1, &crypto, None)
5000            .expect("pre-LStar with SSTORE clear succeeded");
5001        assert_eq!(
5002            result_d.burned_fees, None,
5003            "pre-LStar: burned_fees must be None"
5004        );
5005    }
5006}
5007
5008#[cfg(test)]
5009mod simulated_tx_encoding_tests {
5010    //! The L2 prices its L1 data fee from `Transaction::length()` and reserves that
5011    //! as gas before execution, so a simulated transaction must never encode shorter
5012    //! than the signed one it stands for — otherwise `eth_estimateGas` reserves less
5013    //! L1 fee gas than the transaction it is estimating will need, and that
5014    //! transaction runs out of gas at exactly the estimate.
5015    //!
5016    //! This was reached through the L2 fee-token integration test, which submits the
5017    //! estimate verbatim. It only surfaced once the estimate became exact; the search's
5018    //! former 1.5% tolerance had been absorbing the shortfall.
5019    use super::*;
5020    use ethrex_common::types::GenericTransaction;
5021    use ethrex_rlp::encode::RLPEncode;
5022
5023    /// The transaction the SDK signs and sends after estimating, with signature
5024    /// components at the width secp256k1 actually produces.
5025    fn signed_equivalent(gas_limit: u64, data: Bytes) -> Transaction {
5026        Transaction::EIP1559Transaction(EIP1559Transaction {
5027            chain_id: 9_999,
5028            nonce: 42,
5029            max_priority_fee_per_gas: 1_000_000_000,
5030            max_fee_per_gas: 2_000_000_000,
5031            gas_limit,
5032            to: TxKind::Create,
5033            value: U256::from(1u64),
5034            data,
5035            access_list: vec![],
5036            signature_y_parity: true,
5037            signature_r: U256::MAX,
5038            signature_s: U256::MAX,
5039            ..Default::default()
5040        })
5041    }
5042
5043    fn generic(gas_limit: u64, data: Bytes) -> GenericTransaction {
5044        GenericTransaction {
5045            to: TxKind::Create,
5046            value: U256::from(1u64),
5047            input: data,
5048            nonce: Some(42),
5049            gas: Some(gas_limit),
5050            max_fee_per_gas: Some(2_000_000_000),
5051            max_priority_fee_per_gas: Some(1_000_000_000),
5052            chain_id: Some(9_999),
5053            ..Default::default()
5054        }
5055    }
5056
5057    #[test]
5058    fn simulated_tx_never_encodes_shorter_than_the_signed_one() {
5059        for (gas_limit, data) in [
5060            (21_000u64, Bytes::new()),
5061            (150_000, Bytes::from_static(&[0x60, 0x00, 0x60, 0x00])),
5062            (5_000_000, Bytes::from(vec![0xab; 4_096])),
5063        ] {
5064            let simulated = generic_tx_to_transaction(&generic(gas_limit, data.clone()))
5065                .expect("conversion should succeed");
5066            let signed = signed_equivalent(gas_limit, data.clone());
5067            assert!(
5068                simulated.length() >= signed.length(),
5069                "simulated tx encodes {} bytes, signed one {} — the L2 would under-reserve \
5070                 the L1 fee by the difference (gas_limit {gas_limit}, {} data bytes)",
5071                simulated.length(),
5072                signed.length(),
5073                data.len(),
5074            );
5075        }
5076    }
5077}