Skip to main content

ethrex_vm/backends/levm/
mod.rs

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