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