Skip to main content

ethrex_vm/backends/levm/
mod.rs

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