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