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