Skip to main content

ethrex_vm/backends/levm/
mod.rs

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