Skip to main content

reth_engine_tree/tree/payload_processor/bal/
execute.rs

1//! BAL executor.
2//!
3//! Read `execute_block` as two execution paths over the same parent state.
4//!
5//! Worker states run transactions speculatively. Each worker gets one fresh cache-filling database
6//! from `make_db(true)`, installs the received BAL, sets the transaction BAL index for each
7//! streamed transaction, and returns uncommitted transaction results.
8//!
9//! The canonical state owns block effects. It runs the normal pre/post block hooks, commits
10//! worker results in transaction order, tracks block gas admission, and builds the BAL that this
11//! execution actually produced.
12//!
13//! The rebuilt BAL is returned to the outer payload validator for consensus post-execution
14//! validation. This module only logs the first divergence between the received BAL and the BAL
15//! rebuilt from canonical execution.
16
17use super::{ordered_outputs::ordered_worker_outputs, worker, BalExecutionError};
18use alloy_eip7928::{
19    bal::{Bal as AlloyBal, DecodedBal},
20    compute_block_access_list_hash, BlockAccessList,
21};
22use alloy_evm::{
23    block::{BlockExecutionError, BlockExecutor, BlockValidationError, TxResult},
24    Evm,
25};
26use alloy_primitives::Address;
27use crossbeam_channel::{Receiver, Sender};
28use reth_engine_primitives::BlockAccessListDecodeError;
29use reth_evm::{execute::ExecutableTxFor, ConfigureEvm, Database, EvmEnvFor, ExecutionCtxFor};
30use reth_primitives_traits::ReceiptTy;
31use reth_provider::BlockExecutionOutput;
32use reth_tasks::Runtime;
33use revm::{
34    context::{result::ResultAndState, Block},
35    database::{states::bundle_state::BundleRetention, State},
36    state::bal::Bal as RevmBal,
37};
38use std::sync::Arc;
39
40use crate::tree::payload_processor::receipt_root_task::IndexedReceipt;
41
42/// Executes one block on the BAL path using the runtime's persistent BAL worker pool.
43#[expect(clippy::too_many_arguments, clippy::type_complexity)]
44pub fn execute_block<'a, Evm, Tx, Err, DB, MakeDb>(
45    runtime: &Runtime,
46    evm_config: &'a Evm,
47    make_db: &'a MakeDb,
48    input_bal: Arc<DecodedBal>,
49    evm_env: EvmEnvFor<Evm>,
50    ctx: ExecutionCtxFor<'a, Evm>,
51    transaction_count: usize,
52    txs: Receiver<(usize, Result<Tx, Err>)>,
53    receipt_tx: Sender<IndexedReceipt<ReceiptTy<Evm::Primitives>>>,
54) -> Result<
55    (BlockExecutionOutput<ReceiptTy<Evm::Primitives>>, Vec<Address>, BlockAccessList),
56    BalExecutionError,
57>
58where
59    Evm: ConfigureEvm + 'static,
60    Tx: ExecutableTxFor<Evm> + Send + 'a,
61    Err: core::error::Error + Send + Sync + 'static,
62    DB: Database + Send + 'a,
63    MakeDb: Fn(bool) -> Result<DB, BalExecutionError> + Sync + 'a,
64    ReceiptTy<Evm::Primitives>: Clone,
65{
66    let worker_pool = runtime.bal_streaming_pool();
67    let worker_count = worker_pool.current_num_threads().max(1).min(transaction_count);
68
69    worker_pool.in_place_scope(|scope| {
70        execute_block_inner(
71            scope,
72            evm_config,
73            make_db,
74            input_bal,
75            evm_env,
76            ctx,
77            transaction_count,
78            txs,
79            receipt_tx,
80            worker_count,
81        )
82    })
83}
84
85#[expect(clippy::too_many_arguments, clippy::type_complexity)]
86fn execute_block_inner<'scope, Evm, Tx, Err, DB, MakeDb>(
87    scope: &rayon::Scope<'scope>,
88    evm_config: &'scope Evm,
89    make_db: &'scope MakeDb,
90    input_bal: Arc<DecodedBal>,
91    evm_env: EvmEnvFor<Evm>,
92    ctx: ExecutionCtxFor<'scope, Evm>,
93    transaction_count: usize,
94    txs: Receiver<(usize, Result<Tx, Err>)>,
95    receipt_tx: Sender<IndexedReceipt<ReceiptTy<Evm::Primitives>>>,
96    worker_count: usize,
97) -> Result<
98    (BlockExecutionOutput<ReceiptTy<Evm::Primitives>>, Vec<Address>, BlockAccessList),
99    BalExecutionError,
100>
101where
102    Evm: ConfigureEvm + 'scope,
103    Tx: ExecutableTxFor<Evm> + Send + 'scope,
104    Err: core::error::Error + Send + Sync + 'static,
105    DB: Database + Send + 'scope,
106    MakeDb: Fn(bool) -> Result<DB, BalExecutionError> + Sync + 'scope,
107    ReceiptTy<Evm::Primitives>: Clone,
108{
109    let bal = input_bal.as_bal();
110    let input_bal_revm = convert_alloy_to_revm_bal(bal)?;
111
112    let block_gas_limit = evm_env.block_env.gas_limit();
113    let enable_amsterdam_eip8037 = evm_env.cfg_env.enable_amsterdam_eip8037;
114    let tx_gas_limit_cap = evm_env.cfg_env.tx_gas_limit_cap;
115    let mut canonical_state = State::builder()
116        .with_database(make_db(false)?)
117        .with_bundle_update()
118        .with_bal_builder()
119        .build();
120    canonical_state
121        .bal_state
122        .bal_builder
123        .as_mut()
124        .expect("with_bal_builder set")
125        .accounts
126        .reserve(bal.len());
127
128    let (block_result, senders) = {
129        let (result_tx, result_rx) = crossbeam_channel::unbounded();
130        let (abort_guard, abort_rx) = AbortGuard::new();
131
132        for _ in 0..worker_count {
133            worker::spawn_worker(
134                scope,
135                txs.clone(),
136                abort_rx.clone(),
137                result_tx.clone(),
138                evm_config,
139                make_db,
140                Arc::clone(&input_bal_revm),
141                evm_env.clone(),
142                ctx.clone(),
143            );
144        }
145        drop(result_tx);
146
147        let mut gas_tracker =
148            BlockGasTracker::new(block_gas_limit, enable_amsterdam_eip8037, tx_gas_limit_cap);
149        let evm = evm_config.evm_with_env(&mut canonical_state, evm_env);
150        let mut canonical_executor = evm_config.create_executor_with_state(evm, ctx.clone());
151
152        canonical_executor.apply_pre_execution_changes()?;
153        let mut senders = Vec::with_capacity(transaction_count);
154        let mut last_sent_len = 0usize;
155        for output in ordered_worker_outputs(&result_rx, transaction_count) {
156            let output = output?;
157
158            gas_tracker.validate_tx_limit(output.tx_gas_limit)?;
159            gas_tracker.record_result(output.result.result());
160            canonical_executor.evm_mut().db_mut().bump_bal_index();
161
162            let _ = canonical_executor.commit_transaction(output.result);
163            senders.push(output.signer);
164
165            let current_len = canonical_executor.receipts().len();
166            if current_len > last_sent_len {
167                last_sent_len = current_len;
168                if let Some(receipt) = canonical_executor.receipts().last() {
169                    let tx_index = current_len - 1;
170                    let _ = receipt_tx.send(IndexedReceipt::new(tx_index, receipt.clone()));
171                }
172            }
173        }
174        drop(abort_guard);
175
176        canonical_executor.evm_mut().db_mut().bump_bal_index();
177        let block_result = canonical_executor.apply_post_execution_changes()?;
178        (block_result, senders)
179    };
180
181    let built_bal = take_built_bal_and_log_divergence(&mut canonical_state, bal);
182
183    canonical_state.merge_transitions(BundleRetention::Reverts);
184    Ok((
185        BlockExecutionOutput { state: canonical_state.take_bundle(), result: block_result },
186        senders,
187        built_bal,
188    ))
189}
190
191fn convert_alloy_to_revm_bal(alloy_bal: &AlloyBal) -> Result<Arc<RevmBal>, BalExecutionError> {
192    // Convert the BAL from alloy to a BAL that can be consumed by revm, that is more amenable
193    // for state lookups.
194    //
195    // This is failable.
196    //
197    // This is due to bytecodes. A transaction can attempt to deploy illegal bytecodes, e.g. due to
198    // EIP-3541 or more specifically due to EIP-7702.
199    //
200    // During serial execution this check happens before the bytecode is deployed and if the check
201    // is triggered then the execution is reverted, and as such no actual code change event takes
202    // place. Therefore, if we do observe such a bytecode in a BAL then that means the BAL is
203    // invalid as no legal execution should've led to this bytecode deployment.
204    let received_bal_revm =
205        RevmBal::clone_from_alloy(alloy_bal.as_vec()).map_err(BlockAccessListDecodeError::new)?;
206    Ok(Arc::new(received_bal_revm))
207}
208
209fn take_built_bal_and_log_divergence<DB>(
210    canonical_state: &mut State<DB>,
211    received_bal: &AlloyBal,
212) -> BlockAccessList
213where
214    DB: Database,
215{
216    let built_bal = canonical_state.take_built_alloy_bal().expect("with_bal_builder set");
217    if tracing::enabled!(target: "engine::tree::payload_processor::bal", tracing::Level::DEBUG) &&
218        built_bal.as_slice() != received_bal.as_slice()
219    {
220        let rebuilt = compute_block_access_list_hash(built_bal.as_slice());
221        let expected = compute_block_access_list_hash(received_bal.as_slice());
222        let div = received_bal.diff(built_bal.as_slice());
223        tracing::debug!(
224            target: "engine::tree::payload_processor::bal",
225            %rebuilt,
226            %expected,
227            %div,
228            "first BAL divergence",
229        );
230    }
231
232    built_bal
233}
234
235/// Closes the abort channel on drop, waking scoped workers before the scope exits.
236struct AbortGuard {
237    _tx: Sender<()>,
238}
239
240impl AbortGuard {
241    fn new() -> (Self, Receiver<()>) {
242        let (tx, rx) = crossbeam_channel::bounded(0);
243        (Self { _tx: tx }, rx)
244    }
245}
246
247/// Mirrors `EthBlockExecutor`'s gas admission checks in the ordered BAL commit loop.
248#[derive(Debug)]
249struct BlockGasTracker {
250    block_gas_limit: u64,
251    enable_amsterdam_eip8037: bool,
252    tx_gas_limit_cap: Option<u64>,
253    cumulative_tx_gas_used: u64,
254    block_regular_gas_used: u64,
255    block_state_gas_used: u64,
256}
257
258impl BlockGasTracker {
259    const fn new(
260        block_gas_limit: u64,
261        enable_amsterdam_eip8037: bool,
262        tx_gas_limit_cap: Option<u64>,
263    ) -> Self {
264        Self {
265            block_gas_limit,
266            enable_amsterdam_eip8037,
267            tx_gas_limit_cap,
268            cumulative_tx_gas_used: 0,
269            block_regular_gas_used: 0,
270            block_state_gas_used: 0,
271        }
272    }
273
274    /// Verifies that the transaction's gas limit fits the block's remaining gas budget(s): the
275    /// admission check `EthBlockExecutor::execute_transaction_without_commit` performs before
276    /// executing a transaction.
277    ///
278    /// The commit loop never calls that entry point — workers execute speculatively and their
279    /// results are committed directly via `commit_transaction` — so the check must be replayed
280    /// here for BAL and serial execution to reach the same block validity verdict.
281    ///
282    /// Pre-Amsterdam there is one budget: the tx gas limit, capped by `tx_gas_limit_cap`
283    /// (EIP-7825), must fit `block_gas_limit - cumulative_tx_gas_used`.
284    ///
285    /// Amsterdam (EIP-8037) splits gas into two lanes, each budgeted at `block_gas_limit`:
286    /// - regular: the capped tx gas limit must fit the remaining regular budget
287    /// - state: the full, uncapped tx gas limit must fit the remaining state budget, since state
288    ///   gas is drawn from the reservoir above `tx_gas_limit_cap` (execution-specs
289    ///   `check_block_gas_capacity`)
290    fn validate_tx_limit(&self, tx_gas_limit: u64) -> Result<(), BlockExecutionError> {
291        let block_gas_used = if self.enable_amsterdam_eip8037 {
292            self.block_regular_gas_used
293        } else {
294            self.cumulative_tx_gas_used
295        };
296        let block_available_gas = self.block_gas_limit.saturating_sub(block_gas_used);
297        let tx_min_gas_limit =
298            self.tx_gas_limit_cap.map_or(tx_gas_limit, |cap| tx_gas_limit.min(cap));
299
300        if tx_min_gas_limit > block_available_gas {
301            return Err(BlockValidationError::TransactionGasLimitMoreThanAvailableBlockGas {
302                transaction_gas_limit: tx_gas_limit,
303                block_available_gas,
304            }
305            .into());
306        }
307
308        if self.enable_amsterdam_eip8037 {
309            let state_gas_available =
310                self.block_gas_limit.saturating_sub(self.block_state_gas_used);
311            if tx_gas_limit > state_gas_available {
312                return Err(BlockValidationError::TransactionGasLimitMoreThanAvailableBlockGas {
313                    transaction_gas_limit: tx_gas_limit,
314                    block_available_gas: state_gas_available,
315                }
316                .into());
317            }
318        }
319
320        Ok(())
321    }
322
323    const fn record_result<H>(&mut self, result: &ResultAndState<H>) {
324        let gas = result.result.gas();
325        self.cumulative_tx_gas_used = self.cumulative_tx_gas_used.saturating_add(gas.tx_gas_used());
326        self.block_regular_gas_used =
327            self.block_regular_gas_used.saturating_add(gas.block_regular_gas_used());
328        self.block_state_gas_used =
329            self.block_state_gas_used.saturating_add(gas.block_state_gas_used());
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::tree::error::{InsertBlockErrorKind, InsertBlockValidationError};
337    use alloy_consensus::{BlockHeader, Header};
338    use alloy_eip7928::{
339        bal::Bal as AlloyBal, AccountChanges, BlockAccessIndex, BlockAccessList, CodeChange,
340    };
341    use alloy_eips::{
342        eip2935::{HISTORY_STORAGE_ADDRESS, HISTORY_STORAGE_CODE},
343        eip4788::{BEACON_ROOTS_ADDRESS, BEACON_ROOTS_CODE},
344        eip7002::{WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_PREDEPLOY_CODE},
345    };
346    use alloy_primitives::{keccak256, B256, U256};
347    use reth_ethereum_primitives::{Block, BlockBody, Receipt, TransactionSigned};
348    use reth_evm_ethereum::EthEvmConfig;
349    use reth_primitives_traits::{Block as _, Recovered, SealedBlock};
350    use reth_revm::db::BundleState;
351    use reth_tasks::Runtime;
352    use revm::{
353        database::{CacheDB, EmptyDB},
354        state::{AccountInfo, Bytecode},
355    };
356    use std::convert::Infallible;
357
358    /// Wraps a `BlockAccessList` into an `Arc<DecodedBal>` by RLP-encoding the BAL.
359    fn to_arc_decoded(bal: BlockAccessList) -> Arc<DecodedBal> {
360        let alloy_bal: AlloyBal = bal.into();
361        let raw = alloy_rlp::encode(&alloy_bal).into();
362        Arc::new(DecodedBal::new(alloy_bal, raw))
363    }
364
365    /// Builds an in-memory canonical DB pre-populated with the post-Cancun system contracts
366    /// that `apply_pre_execution_changes` calls: beacon roots (EIP-4788), withdrawal requests
367    /// (EIP-7002), and historical block hashes (EIP-2935).
368    fn system_contracts_db() -> CacheDB<EmptyDB> {
369        let mut db = CacheDB::<EmptyDB>::new(Default::default());
370        db.insert_account_info(
371            BEACON_ROOTS_ADDRESS,
372            AccountInfo {
373                balance: U256::ZERO,
374                nonce: 1,
375                code_hash: keccak256(BEACON_ROOTS_CODE.clone()),
376                code: Some(Bytecode::new_raw(BEACON_ROOTS_CODE.clone())),
377                account_id: None,
378            },
379        );
380        db.insert_account_info(
381            WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS,
382            AccountInfo {
383                balance: U256::ZERO,
384                nonce: 1,
385                code_hash: keccak256(WITHDRAWAL_REQUEST_PREDEPLOY_CODE.clone()),
386                code: Some(Bytecode::new_raw(WITHDRAWAL_REQUEST_PREDEPLOY_CODE.clone())),
387                account_id: None,
388            },
389        );
390        db.insert_account_info(
391            HISTORY_STORAGE_ADDRESS,
392            AccountInfo {
393                balance: U256::ZERO,
394                nonce: 1,
395                code_hash: keccak256(HISTORY_STORAGE_CODE.clone()),
396                code: Some(Bytecode::new_raw(HISTORY_STORAGE_CODE.clone())),
397                account_id: None,
398            },
399        );
400        db
401    }
402
403    /// Builds a minimal sealed block (empty body, Amsterdam-ready header) for tests.
404    fn empty_amsterdam_block(header_bal_hash: B256) -> SealedBlock<Block> {
405        empty_amsterdam_block_with_gas_limit(header_bal_hash, 30_000_000)
406    }
407
408    fn empty_amsterdam_block_with_gas_limit(
409        header_bal_hash: B256,
410        gas_limit: u64,
411    ) -> SealedBlock<Block> {
412        let header = Header {
413            timestamp: 1,
414            number: 1,
415            gas_limit,
416            parent_beacon_block_root: Some(B256::ZERO),
417            withdrawals_root: Some(alloy_consensus::EMPTY_ROOT_HASH),
418            requests_hash: Some(alloy_eips::eip7685::EMPTY_REQUESTS_HASH),
419            excess_blob_gas: Some(0),
420            blob_gas_used: Some(0),
421            block_access_list_hash: Some(header_bal_hash),
422            ..Header::default()
423        };
424        let block = Block {
425            header,
426            body: BlockBody {
427                transactions: vec![],
428                ommers: vec![],
429                withdrawals: Some(vec![].into()),
430            },
431        };
432        block.seal_slow()
433    }
434
435    /// Runs only the canonical phases (pre-exec → post-exec, no txs) against a fresh
436    /// `system_contracts_db()` to compute the composed BAL a block produces. Used to build
437    /// the "reference" received BAL for the happy-path test below.
438    ///
439    /// This intentionally mirrors what `execute_block` does internally,
440    /// but without any hash check — the output is the BAL itself, not a pass/fail signal.
441    fn reference_bal_for_empty_block(evm_config: &EthEvmConfig) -> BlockAccessList {
442        use revm::database::State as RevmState;
443
444        let db = system_contracts_db();
445        let mut state =
446            RevmState::builder().with_database(db).with_bundle_update().with_bal_builder().build();
447
448        // Any header_bal_hash on the reference block is fine — we don't check it here.
449        let block = empty_amsterdam_block(B256::ZERO);
450        {
451            let mut executor =
452                evm_config.executor_for_block(&mut state, &block).expect("build executor");
453            executor.apply_pre_execution_changes().expect("pre-exec");
454            executor.evm_mut().db_mut().bump_bal_index();
455            executor.apply_post_execution_changes().expect("post-exec");
456        }
457        state.take_built_alloy_bal().expect("with_bal_builder was set")
458    }
459
460    #[test]
461    fn invalid_bal_bytecode_is_validation_error() {
462        let alloy_bal = vec![AccountChanges {
463            address: Address::ZERO,
464            code_changes: vec![CodeChange::new(
465                BlockAccessIndex::new(1),
466                vec![0xef, 0x01, 0xde].into(),
467            )],
468            ..Default::default()
469        }]
470        .into();
471
472        let error = convert_alloy_to_revm_bal(&alloy_bal).unwrap_err();
473        assert!(matches!(&error, BalExecutionError::BlockAccessListDecode(_)));
474        let error = InsertBlockErrorKind::from(error);
475        assert!(matches!(&error, InsertBlockErrorKind::BlockAccessListDecode(_)));
476        assert!(matches!(
477            error.ensure_validation_error(),
478            Ok(InsertBlockValidationError::BlockAccessListDecode(_))
479        ));
480    }
481
482    #[test]
483    fn empty_block_happy_path_round_trip() {
484        // Two-pass end-to-end:
485        //   1. Build the canonical BAL an empty Amsterdam block produces (via
486        //      `reference_bal_for_empty_block`).
487        //   2. Hash it, stamp the header, and run `execute_block` with that BAL. Every check must
488        //      pass (A, B, D, F).
489        let evm_config = EthEvmConfig::mainnet();
490
491        let input_bal = reference_bal_for_empty_block(&evm_config);
492        let bal_hash = alloy_eip7928::compute_block_access_list_hash(&input_bal);
493        // Sanity: reference BAL is non-empty (system calls populated it).
494        assert!(!input_bal.is_empty(), "empty BAL means system calls didn't record state");
495
496        let block = empty_amsterdam_block(bal_hash);
497
498        let result = run_execute_block(
499            &Runtime::test(),
500            evm_config,
501            db_factory(system_contracts_db()),
502            to_arc_decoded(input_bal),
503            &block,
504            Vec::<Recovered<TransactionSigned>>::new(),
505        );
506
507        match result {
508            Ok(output) => {
509                assert!(output.receipts.is_empty(), "empty block → no receipts");
510            }
511            Err(e) => panic!("expected success, got {e:?}"),
512        }
513    }
514
515    fn db_factory(
516        db: CacheDB<EmptyDB>,
517    ) -> impl Fn() -> Result<CacheDB<EmptyDB>, BalExecutionError> + Sync {
518        move || Ok(db.clone())
519    }
520
521    fn tx_stream<Tx>(txs: Vec<Tx>) -> Receiver<(usize, Result<Tx, Infallible>)> {
522        let (tx, rx) = crossbeam_channel::unbounded();
523        for (index, transaction) in txs.into_iter().enumerate() {
524            tx.send((index, Ok(transaction))).unwrap();
525        }
526        rx
527    }
528
529    fn run_execute_block<Tx, DB, MakeDb>(
530        runtime: &Runtime,
531        evm_config: EthEvmConfig,
532        make_db: MakeDb,
533        input_bal: Arc<DecodedBal>,
534        block: &SealedBlock<Block>,
535        txs: Vec<Tx>,
536    ) -> Result<BlockExecutionOutput<Receipt>, BalExecutionError>
537    where
538        Tx: ExecutableTxFor<EthEvmConfig> + Send,
539        DB: Database + Send,
540        MakeDb: Fn() -> Result<DB, BalExecutionError> + Sync,
541    {
542        run_execute_block_full(runtime, evm_config, make_db, input_bal, block, txs)
543            .map(|(output, _)| output)
544    }
545
546    fn run_execute_block_full<Tx, DB, MakeDb>(
547        runtime: &Runtime,
548        evm_config: EthEvmConfig,
549        make_db: MakeDb,
550        input_bal: Arc<DecodedBal>,
551        block: &SealedBlock<Block>,
552        txs: Vec<Tx>,
553    ) -> Result<(BlockExecutionOutput<Receipt>, BlockAccessList), BalExecutionError>
554    where
555        Tx: ExecutableTxFor<EthEvmConfig> + Send,
556        DB: Database + Send,
557        MakeDb: Fn() -> Result<DB, BalExecutionError> + Sync,
558    {
559        let transaction_count = txs.len();
560        let (receipt_tx, _receipt_rx) = crossbeam_channel::unbounded();
561        let evm_env = evm_config.evm_env(block.header()).unwrap();
562        let execution_ctx = evm_config.context_for_block(block).unwrap();
563        let make_db = |_: bool| make_db();
564        execute_block(
565            runtime,
566            &evm_config,
567            &make_db,
568            input_bal,
569            evm_env,
570            execution_ctx,
571            transaction_count,
572            tx_stream(txs),
573            receipt_tx,
574        )
575        .map(|(output, _, built_bal)| (output, built_bal))
576    }
577
578    /// Inserts `AccountInfo { nonce: 0, balance }` for `addr` into the canonical DB.
579    fn insert_funded(db: &mut CacheDB<EmptyDB>, addr: alloy_primitives::Address, balance: U256) {
580        db.insert_account_info(
581            addr,
582            AccountInfo { nonce: 0, balance, code_hash: B256::ZERO, code: None, account_id: None },
583        );
584    }
585
586    /// Runs the canonical path on a block with real txs (no hash check) and returns the
587    /// composed BAL. Used to build the reference BAL for happy-path multi-tx tests.
588    fn reference_bal_for_block<Tx>(
589        evm_config: &EthEvmConfig,
590        mut db: CacheDB<EmptyDB>,
591        block: &SealedBlock<Block>,
592        txs: Vec<Tx>,
593    ) -> BlockAccessList
594    where
595        Tx: ExecutableTxFor<EthEvmConfig>,
596    {
597        use revm::database::State as RevmState;
598
599        let mut state = RevmState::builder()
600            .with_database(&mut db)
601            .with_bundle_update()
602            .with_bal_builder()
603            .build();
604
605        {
606            let mut executor =
607                evm_config.executor_for_block(&mut state, block).expect("build executor");
608            executor.apply_pre_execution_changes().expect("pre-exec");
609            for (i, tx) in txs.into_iter().enumerate() {
610                executor.evm_mut().db_mut().bump_bal_index();
611                executor
612                    .execute_transaction(tx)
613                    .unwrap_or_else(|e| panic!("tx {i} failed during reference build: {e:?}"));
614            }
615            executor.evm_mut().db_mut().bump_bal_index();
616            executor.apply_post_execution_changes().expect("post-exec");
617        }
618        state.take_built_alloy_bal().expect("with_bal_builder was set")
619    }
620
621    #[test]
622    fn multi_tx_happy_path_round_trip() {
623        // End-to-end with two value transfers from distinct senders to the same recipient.
624        //
625        // 1. Fund alice and bob in a fresh canonical DB.
626        // 2. Sign tx1 (alice → carol, 100 wei) and tx2 (bob → carol, 200 wei).
627        // 3. Build the reference BAL by running the block through a canonical executor with
628        //    `with_bal_builder`.
629        // 4. Feed that BAL into `execute_block` and assert 2 receipts + no rejections.
630        use alloy_consensus::TxLegacy;
631        use alloy_primitives::TxKind;
632        use reth_chainspec::MAINNET;
633        use reth_ethereum_primitives::Transaction;
634        use reth_primitives_traits::crypto::secp256k1::public_key_to_address;
635        use reth_testing_utils::generators::{generate_key, rng, sign_tx_with_key_pair};
636
637        let evm_config = EthEvmConfig::mainnet();
638        let carol: alloy_primitives::Address = alloy_primitives::Address::from([0xCA; 20]);
639        let sender_balance = U256::from(alloy_consensus::constants::ETH_TO_WEI);
640
641        // Generate keypairs + derive sender addresses.
642        let alice_kp = generate_key(&mut rng());
643        let alice = public_key_to_address(alice_kp.public_key());
644        let bob_kp = generate_key(&mut rng());
645        let bob = public_key_to_address(bob_kp.public_key());
646
647        // Pre-block DB: system contracts + funded senders.
648        let mut pre_block_db = system_contracts_db();
649        insert_funded(&mut pre_block_db, alice, sender_balance);
650        insert_funded(&mut pre_block_db, bob, sender_balance);
651
652        // Sign txs.
653        let chain_id = MAINNET.chain.id();
654        let gas_price = 1u128; // flat low price; block has no base fee in our test header.
655        let tx1 = sign_tx_with_key_pair(
656            alice_kp,
657            Transaction::Legacy(TxLegacy {
658                chain_id: Some(chain_id),
659                nonce: 0,
660                gas_price,
661                gas_limit: 21_000,
662                to: TxKind::Call(carol),
663                value: U256::from(100u64),
664                input: Default::default(),
665            }),
666        );
667        let tx2 = sign_tx_with_key_pair(
668            bob_kp,
669            Transaction::Legacy(TxLegacy {
670                chain_id: Some(chain_id),
671                nonce: 0,
672                gas_price,
673                gas_limit: 21_000,
674                to: TxKind::Call(carol),
675                value: U256::from(200u64),
676                input: Default::default(),
677            }),
678        );
679        let recovered1 = Recovered::new_unchecked(tx1, alice);
680        let recovered2 = Recovered::new_unchecked(tx2, bob);
681
682        // Reference BAL: run the block canonically through a separate executor.
683        let block_for_ref = empty_amsterdam_block(B256::ZERO);
684        let reference_bal = reference_bal_for_block::<Recovered<TransactionSigned>>(
685            &evm_config,
686            {
687                // Separate fresh DB for the reference run so we don't pollute canonical_db.
688                let mut db = system_contracts_db();
689                db.insert_account_info(
690                    alice,
691                    AccountInfo {
692                        nonce: 0,
693                        balance: sender_balance,
694                        code_hash: B256::ZERO,
695                        code: None,
696                        account_id: None,
697                    },
698                );
699                db.insert_account_info(
700                    bob,
701                    AccountInfo {
702                        nonce: 0,
703                        balance: sender_balance,
704                        code_hash: B256::ZERO,
705                        code: None,
706                        account_id: None,
707                    },
708                );
709                db
710            },
711            &block_for_ref,
712            vec![recovered1.clone(), recovered2.clone()],
713        );
714        assert!(!reference_bal.is_empty(), "expected BAL entries from pre-exec + txs");
715
716        let bal_hash = alloy_eip7928::compute_block_access_list_hash(&reference_bal);
717        let block = empty_amsterdam_block(bal_hash);
718
719        let result = run_execute_block(
720            &Runtime::test(),
721            evm_config,
722            db_factory(pre_block_db),
723            to_arc_decoded(reference_bal),
724            &block,
725            vec![recovered1, recovered2],
726        );
727
728        match result {
729            Ok(output) => {
730                assert_eq!(output.receipts.len(), 2, "expected 2 receipts");
731                assert!(output.gas_used >= 2 * 21_000, "expected at least 42k gas used");
732            }
733            Err(e) => panic!("expected success, got {e:?}"),
734        }
735    }
736
737    // ============================================================================
738    // Shadow-mode harness — runs a block through the serial `BasicBlockExecutor`
739    // and the BAL path, asserts byte-equal outputs.
740    // ============================================================================
741
742    /// Output of one path in a shadow run. Both serial and BAL paths produce this shape so
743    /// the harness can compare field-by-field.
744    #[derive(Debug)]
745    struct ShadowOutput {
746        bundle_state: BundleState,
747        receipts: Vec<reth_ethereum_primitives::Receipt>,
748        gas_used: u64,
749        requests: alloy_eips::eip7685::Requests,
750    }
751
752    /// Runs the block through the serial path and captures its full output.
753    ///
754    /// Uses a manual state + executor (not `BasicBlockExecutor::execute_one`) so we can both
755    /// (a) capture the composed BAL for the BAL-path input and (b) pull the bundle out after.
756    fn run_serial_path(
757        evm_config: &EthEvmConfig,
758        canonical_db: CacheDB<EmptyDB>,
759        block: &SealedBlock<Block>,
760        txs: &[Recovered<TransactionSigned>],
761    ) -> (ShadowOutput, BlockAccessList) {
762        use revm::database::State as RevmState;
763
764        let mut state = RevmState::builder()
765            .with_database(canonical_db)
766            .with_bundle_update()
767            .with_bal_builder()
768            .build();
769
770        let block_result = {
771            let mut executor =
772                evm_config.executor_for_block(&mut state, block).expect("build serial executor");
773            executor.apply_pre_execution_changes().expect("serial pre-exec");
774            for (i, tx) in txs.iter().cloned().enumerate() {
775                executor.evm_mut().db_mut().bump_bal_index();
776                executor
777                    .execute_transaction(tx)
778                    .unwrap_or_else(|e| panic!("serial tx {i} failed: {e:?}"));
779            }
780            executor.evm_mut().db_mut().bump_bal_index();
781            executor.apply_post_execution_changes().expect("serial post-exec")
782        };
783
784        let bal = state.take_built_alloy_bal().expect("with_bal_builder was set");
785        state.merge_transitions(BundleRetention::Reverts);
786        let bundle_state = state.take_bundle();
787
788        (
789            ShadowOutput {
790                bundle_state,
791                receipts: block_result.receipts,
792                gas_used: block_result.gas_used,
793                requests: block_result.requests,
794            },
795            bal,
796        )
797    }
798
799    /// Shadow harness. Runs the block through both paths; asserts byte-equal outputs.
800    fn assert_shadow_equal(
801        evm_config: EthEvmConfig,
802        canonical_db_template: CacheDB<EmptyDB>,
803        block_header_only: SealedBlock<Block>,
804        txs: Vec<Recovered<TransactionSigned>>,
805    ) {
806        // Serial run: also produces the reference BAL we'll feed to the BAL path.
807        let (serial, reference_bal) =
808            run_serial_path(&evm_config, canonical_db_template.clone(), &block_header_only, &txs);
809
810        // BAL path: stamp the hash of the reference BAL onto the header.
811        let bal_hash = alloy_eip7928::compute_block_access_list_hash(&reference_bal);
812        let block =
813            empty_amsterdam_block_with_gas_limit(bal_hash, block_header_only.header().gas_limit());
814
815        let bal_out = run_execute_block(
816            &Runtime::test(),
817            evm_config,
818            db_factory(canonical_db_template),
819            to_arc_decoded(reference_bal),
820            &block,
821            txs,
822        )
823        .unwrap_or_else(|e| panic!("BAL path failed: {e:?}"));
824
825        // Byte-equal assertions. Any divergence surfaces the specific field that broke.
826        assert_eq!(
827            serial.receipts, bal_out.receipts,
828            "receipts diverge between serial and BAL paths",
829        );
830        assert_eq!(
831            serial.gas_used, bal_out.gas_used,
832            "gas_used differs: serial {} vs bal {}",
833            serial.gas_used, bal_out.gas_used,
834        );
835        assert_eq!(
836            serial.requests, bal_out.requests,
837            "requests (EIP-7685) diverge between serial and BAL paths",
838        );
839        assert_eq!(
840            serial.bundle_state, bal_out.state,
841            "bundle_state diverges — the canonical state transitions don't match",
842        );
843    }
844
845    #[test]
846    fn shadow_empty_block() {
847        // System calls only — no txs. Both paths should produce identical system-call
848        // side effects in their BundleState (beacon roots storage, history storage, etc.).
849        assert_shadow_equal(
850            EthEvmConfig::mainnet(),
851            system_contracts_db(),
852            empty_amsterdam_block(B256::ZERO),
853            Vec::new(),
854        );
855    }
856
857    #[test]
858    fn shadow_multi_value_transfer() {
859        // Two senders → same recipient. Byte-equal across paths means: worker-produced
860        // diffs commit identically to a directly-executed serial path.
861        use alloy_consensus::TxLegacy;
862        use alloy_primitives::TxKind;
863        use reth_chainspec::MAINNET;
864        use reth_ethereum_primitives::Transaction;
865        use reth_primitives_traits::crypto::secp256k1::public_key_to_address;
866        use reth_testing_utils::generators::{generate_key, rng, sign_tx_with_key_pair};
867
868        let evm_config = EthEvmConfig::mainnet();
869        let carol: alloy_primitives::Address = alloy_primitives::Address::from([0xCA; 20]);
870        let sender_balance = U256::from(alloy_consensus::constants::ETH_TO_WEI);
871
872        let alice_kp = generate_key(&mut rng());
873        let alice = public_key_to_address(alice_kp.public_key());
874        let bob_kp = generate_key(&mut rng());
875        let bob = public_key_to_address(bob_kp.public_key());
876
877        let mut db = system_contracts_db();
878        insert_funded(&mut db, alice, sender_balance);
879        insert_funded(&mut db, bob, sender_balance);
880
881        let chain_id = MAINNET.chain.id();
882        let make_tx = |kp, to, value, nonce: u64| {
883            sign_tx_with_key_pair(
884                kp,
885                Transaction::Legacy(TxLegacy {
886                    chain_id: Some(chain_id),
887                    nonce,
888                    gas_price: 1,
889                    gas_limit: 21_000,
890                    to: TxKind::Call(to),
891                    value: U256::from(value),
892                    input: Default::default(),
893                }),
894            )
895        };
896        let tx1 = Recovered::new_unchecked(make_tx(alice_kp, carol, 100u64, 0), alice);
897        let tx2 = Recovered::new_unchecked(make_tx(bob_kp, carol, 200u64, 0), bob);
898
899        assert_shadow_equal(evm_config, db, empty_amsterdam_block(B256::ZERO), vec![tx1, tx2]);
900    }
901
902    #[test]
903    fn rejects_tx_gas_limit_that_exceeds_remaining_block_gas() {
904        // Each worker sees an empty block, so both transactions fit individually. The ordered
905        // commit loop must still reject tx2 because tx1's committed gas leaves too little
906        // block gas for tx2's gas limit.
907        use alloy_consensus::TxLegacy;
908        use alloy_evm::block::BlockValidationError;
909        use alloy_primitives::TxKind;
910        use reth_chainspec::MAINNET;
911        use reth_ethereum_primitives::Transaction;
912        use reth_primitives_traits::crypto::secp256k1::public_key_to_address;
913        use reth_testing_utils::generators::{generate_key, rng, sign_tx_with_key_pair};
914
915        let evm_config = EthEvmConfig::mainnet();
916        let carol: alloy_primitives::Address = alloy_primitives::Address::from([0xCA; 20]);
917        let sender_balance = U256::from(alloy_consensus::constants::ETH_TO_WEI);
918        let block_gas_limit = 1_000_000;
919        let tx_gas_limit = 990_000;
920
921        let alice_kp = generate_key(&mut rng());
922        let alice = public_key_to_address(alice_kp.public_key());
923        let bob_kp = generate_key(&mut rng());
924        let bob = public_key_to_address(bob_kp.public_key());
925
926        let mut pre_block_db = system_contracts_db();
927        insert_funded(&mut pre_block_db, alice, sender_balance);
928        insert_funded(&mut pre_block_db, bob, sender_balance);
929
930        let chain_id = MAINNET.chain.id();
931        let make_tx = |kp, value| {
932            sign_tx_with_key_pair(
933                kp,
934                Transaction::Legacy(TxLegacy {
935                    chain_id: Some(chain_id),
936                    nonce: 0,
937                    gas_price: 1,
938                    gas_limit: tx_gas_limit,
939                    to: TxKind::Call(carol),
940                    value: U256::from(value),
941                    input: Default::default(),
942                }),
943            )
944        };
945        let tx1 = Recovered::new_unchecked(make_tx(alice_kp, 100u64), alice);
946        let tx2 = Recovered::new_unchecked(make_tx(bob_kp, 200u64), bob);
947
948        // Build the reference BAL under a generous gas limit so both workers can execute.
949        // Replaying the same BAL under `block_gas_limit` below should reject in the ordered
950        // commit loop before tx2 is committed.
951        let reference_block = empty_amsterdam_block(B256::ZERO);
952        let reference_bal = reference_bal_for_block(
953            &evm_config,
954            pre_block_db.clone(),
955            &reference_block,
956            vec![tx1.clone(), tx2.clone()],
957        );
958        let bal_hash = alloy_eip7928::compute_block_access_list_hash(&reference_bal);
959        let low_gas_block = empty_amsterdam_block_with_gas_limit(bal_hash, block_gas_limit);
960
961        let result = run_execute_block(
962            &Runtime::test(),
963            evm_config,
964            db_factory(pre_block_db),
965            to_arc_decoded(reference_bal),
966            &low_gas_block,
967            vec![tx1, tx2],
968        );
969
970        match result {
971            Err(BalExecutionError::Execution(err)) => assert!(matches!(
972                err.as_validation(),
973                Some(BlockValidationError::TransactionGasLimitMoreThanAvailableBlockGas { .. })
974            )),
975            Err(err) => panic!("expected block gas validation error, got {err:?}"),
976            Ok(_) => panic!("expected block gas validation error, got Ok"),
977        }
978    }
979
980    #[test]
981    fn shadow_tx_with_revert() {
982        // A tx that reverts in a deployed contract. Both paths must produce identical receipts
983        // (success = false, gas charged, state rolled back except for gas payment + nonce bump).
984        //
985        // Deploys `0x60006000fd` (PUSH1 0 PUSH1 0 REVERT) at `revert_contract`. Sender calls
986        // it; the call reverts; fees + nonce still apply.
987        use alloy_consensus::TxLegacy;
988        use alloy_primitives::{keccak256, Bytes, TxKind};
989        use reth_chainspec::MAINNET;
990        use reth_ethereum_primitives::Transaction;
991        use reth_primitives_traits::crypto::secp256k1::public_key_to_address;
992        use reth_testing_utils::generators::{generate_key, rng, sign_tx_with_key_pair};
993
994        let evm_config = EthEvmConfig::mainnet();
995        let revert_contract: alloy_primitives::Address =
996            alloy_primitives::Address::from([0xDE; 20]);
997        let sender_balance = U256::from(alloy_consensus::constants::ETH_TO_WEI);
998
999        let alice_kp = generate_key(&mut rng());
1000        let alice = public_key_to_address(alice_kp.public_key());
1001
1002        // Deploy the revert contract bytecode.
1003        let revert_code: Bytes = Bytes::from_static(&[0x60, 0x00, 0x60, 0x00, 0xfd]);
1004        let code_hash = keccak256(&revert_code);
1005        let mut db = system_contracts_db();
1006        insert_funded(&mut db, alice, sender_balance);
1007        db.insert_account_info(
1008            revert_contract,
1009            AccountInfo {
1010                nonce: 1,
1011                balance: U256::ZERO,
1012                code_hash,
1013                code: Some(Bytecode::new_raw(revert_code)),
1014                account_id: None,
1015            },
1016        );
1017
1018        let tx = Recovered::new_unchecked(
1019            sign_tx_with_key_pair(
1020                alice_kp,
1021                Transaction::Legacy(TxLegacy {
1022                    chain_id: Some(MAINNET.chain.id()),
1023                    nonce: 0,
1024                    gas_price: 1,
1025                    gas_limit: 50_000,
1026                    to: TxKind::Call(revert_contract),
1027                    value: U256::ZERO,
1028                    input: Default::default(),
1029                }),
1030            ),
1031            alice,
1032        );
1033
1034        assert_shadow_equal(evm_config, db, empty_amsterdam_block(B256::ZERO), vec![tx]);
1035    }
1036
1037    #[test]
1038    fn shadow_tx_with_sstore() {
1039        // Tx calls a deployed contract that does `SSTORE(0, 0x42)`. The storage write must
1040        // commit identically across serial and BAL paths even though the canonical state applies
1041        // a diff produced by a worker EVM.
1042        //
1043        // Bytecode: PUSH1 0x42, PUSH1 0x00, SSTORE, STOP → `0x60 0x42 0x60 0x00 0x55 0x00`.
1044        use alloy_consensus::TxLegacy;
1045        use alloy_primitives::{keccak256, Bytes, TxKind};
1046        use reth_chainspec::MAINNET;
1047        use reth_ethereum_primitives::Transaction;
1048        use reth_primitives_traits::crypto::secp256k1::public_key_to_address;
1049        use reth_testing_utils::generators::{generate_key, rng, sign_tx_with_key_pair};
1050
1051        let evm_config = EthEvmConfig::mainnet();
1052        let sstore_contract: alloy_primitives::Address =
1053            alloy_primitives::Address::from([0x55; 20]);
1054        let sender_balance = U256::from(alloy_consensus::constants::ETH_TO_WEI);
1055
1056        let alice_kp = generate_key(&mut rng());
1057        let alice = public_key_to_address(alice_kp.public_key());
1058
1059        // Deploy the SSTORE contract.
1060        let sstore_code: Bytes = Bytes::from_static(&[0x60, 0x42, 0x60, 0x00, 0x55, 0x00]);
1061        let code_hash = keccak256(&sstore_code);
1062        let mut db = system_contracts_db();
1063        insert_funded(&mut db, alice, sender_balance);
1064        db.insert_account_info(
1065            sstore_contract,
1066            AccountInfo {
1067                nonce: 1,
1068                balance: U256::ZERO,
1069                code_hash,
1070                code: Some(Bytecode::new_raw(sstore_code)),
1071                account_id: None,
1072            },
1073        );
1074
1075        let tx = Recovered::new_unchecked(
1076            sign_tx_with_key_pair(
1077                alice_kp,
1078                Transaction::Legacy(TxLegacy {
1079                    chain_id: Some(MAINNET.chain.id()),
1080                    nonce: 0,
1081                    gas_price: 1,
1082                    gas_limit: 100_000,
1083                    to: TxKind::Call(sstore_contract),
1084                    value: U256::ZERO,
1085                    input: Default::default(),
1086                }),
1087            ),
1088            alice,
1089        );
1090
1091        assert_shadow_equal(evm_config, db, empty_amsterdam_block(B256::ZERO), vec![tx]);
1092    }
1093
1094    #[test]
1095    fn returns_built_bal_for_final_hash_mismatch() {
1096        // Build the BAL an empty block actually produces, then append a phantom address
1097        // that execution never touches. The rebuilt BAL omits it, and the outer consensus
1098        // validator is responsible for comparing that rebuilt hash to the header commitment.
1099        use alloy_eip7928::AccountChanges;
1100
1101        let evm_config = EthEvmConfig::mainnet();
1102
1103        // Real BAL the block would produce.
1104        let real_bal = reference_bal_for_empty_block(&evm_config);
1105        assert!(!real_bal.is_empty(), "reference BAL must be non-empty");
1106
1107        // Tamper: append a phantom address not accessed during execution.
1108        let phantom = alloy_primitives::Address::from([0xFF; 20]);
1109        let mut tampered_entries: Vec<AccountChanges> = real_bal;
1110        tampered_entries.push(AccountChanges::new(phantom));
1111        let tampered_bal: alloy_eip7928::bal::Bal = alloy_eip7928::bal::Bal::new(tampered_entries);
1112
1113        // Stamp the tampered BAL's hash on the block header.
1114        let tampered_block_access_list: BlockAccessList = tampered_bal.clone().into();
1115        let tampered_hash =
1116            alloy_eip7928::compute_block_access_list_hash(&tampered_block_access_list);
1117        let block = empty_amsterdam_block(tampered_hash);
1118
1119        let received = {
1120            let raw = alloy_rlp::encode(&tampered_bal).into();
1121            Arc::new(DecodedBal::new(tampered_bal, raw))
1122        };
1123
1124        let result = run_execute_block_full(
1125            &Runtime::test(),
1126            evm_config,
1127            db_factory(system_contracts_db()),
1128            received,
1129            &block,
1130            Vec::<Recovered<TransactionSigned>>::new(),
1131        );
1132
1133        match result {
1134            Ok((_, built_bal)) => {
1135                let rebuilt = alloy_eip7928::compute_block_access_list_hash(&built_bal);
1136                assert_ne!(rebuilt, tampered_hash, "rebuilt and header hashes must differ");
1137            }
1138            Err(e) => panic!("expected success with rebuilt BAL, got {e:?}"),
1139        }
1140    }
1141
1142    #[test]
1143    fn canonical_make_db_failure() {
1144        // A make_db that always fails must surface as Provider before any workers are
1145        // spawned or the BAL is processed.
1146        let evm_config = EthEvmConfig::mainnet();
1147        let block = empty_amsterdam_block(B256::ZERO);
1148
1149        let failing_make_db = || -> Result<CacheDB<EmptyDB>, BalExecutionError> {
1150            Err(reth_provider::ProviderError::BestBlockNotFound.into())
1151        };
1152
1153        let result = run_execute_block(
1154            &Runtime::test(),
1155            evm_config,
1156            failing_make_db,
1157            to_arc_decoded(BlockAccessList::default()),
1158            &block,
1159            Vec::<Recovered<TransactionSigned>>::new(),
1160        );
1161
1162        assert!(
1163            matches!(result, Err(BalExecutionError::Provider(_))),
1164            "expected Provider error from canonical make_db failure, got {result:?}",
1165        );
1166    }
1167
1168    #[test]
1169    fn worker_tx_recovery_error_becomes_validation_error() {
1170        // A tx recovery failure fed into the worker channel must surface as
1171        // a block validation error. Uses execute_block directly since tx_stream hardcodes
1172        // Infallible and cannot inject errors.
1173        let evm_config = EthEvmConfig::mainnet();
1174        let block = empty_amsterdam_block(B256::ZERO);
1175
1176        let (tx_tx, tx_rx) = crossbeam_channel::unbounded::<(
1177            usize,
1178            Result<Recovered<TransactionSigned>, std::io::Error>,
1179        )>();
1180        tx_tx.send((0, Err(std::io::Error::other("sig fail")))).unwrap();
1181        drop(tx_tx);
1182
1183        let (receipt_tx, _receipt_rx) = crossbeam_channel::unbounded();
1184        let evm_env = evm_config.evm_env(block.header()).unwrap();
1185        let execution_ctx = evm_config.context_for_block(&block).unwrap();
1186        let make_db = db_factory(system_contracts_db());
1187        let make_db = |_: bool| make_db();
1188
1189        let result = execute_block(
1190            &Runtime::test(),
1191            &evm_config,
1192            &make_db,
1193            to_arc_decoded(BlockAccessList::default()),
1194            evm_env,
1195            execution_ctx,
1196            1, // transaction_count = 1 → exactly one worker spawned
1197            tx_rx,
1198            receipt_tx,
1199        );
1200
1201        assert!(
1202            matches!(result, Err(BalExecutionError::Execution(BlockExecutionError::Validation(_)))),
1203            "expected validation error from tx recovery failure, got {result:?}",
1204        );
1205    }
1206
1207    #[test]
1208    fn gas_tracker_non_amsterdam_uses_cumulative_gas() {
1209        // A half-state-gas result keeps both Amsterdam budgets (regular and state) at
1210        // 300_000 used while cumulative_tx_gas_used is 600_000, so a second tx that fits
1211        // within the block limit but not the remaining cumulative budget proves that
1212        // non-Amsterdam reads cumulative_tx_gas_used while Amsterdam does not.
1213        use revm::{
1214            context::result::{
1215                ExecResultAndState, ExecutionResult, Output, ResultGas, SuccessReason,
1216            },
1217            state::EvmState,
1218        };
1219
1220        let block_gas_limit = 1_000_000u64;
1221        let first_tx_gas = 600_000u64;
1222        let second_tx_gas_limit = 500_000u64; // fits in total limit but not after cumulative
1223                                              // deduction
1224
1225        let gas = ResultGas::new_with_state_gas(first_tx_gas, 0, 0, first_tx_gas / 2);
1226        let fake_result: ResultAndState<revm::context::result::HaltReason> =
1227            ExecResultAndState::new(
1228                ExecutionResult::Success {
1229                    reason: SuccessReason::Return,
1230                    gas,
1231                    logs: vec![],
1232                    output: Output::Call(Default::default()),
1233                },
1234                EvmState::default(),
1235            );
1236
1237        // Non-Amsterdam: block_available_gas = 1_000_000 - 600_000 = 400_000 → reject 500_000.
1238        let mut non_amsterdam = BlockGasTracker::new(block_gas_limit, false, None);
1239        non_amsterdam.record_result(&fake_result);
1240        assert!(
1241            non_amsterdam.validate_tx_limit(second_tx_gas_limit).is_err(),
1242            "non-Amsterdam tracker must reject tx that exceeds remaining cumulative gas",
1243        );
1244
1245        // Amsterdam: both regular and state budgets have 700_000 left → accept 500_000.
1246        let mut amsterdam = BlockGasTracker::new(block_gas_limit, true, None);
1247        amsterdam.record_result(&fake_result);
1248        assert!(
1249            amsterdam.validate_tx_limit(second_tx_gas_limit).is_ok(),
1250            "Amsterdam tracker must accept the same tx since per-dimension budgets still fit",
1251        );
1252    }
1253
1254    #[test]
1255    fn gas_tracker_amsterdam_enforces_state_gas_budget() {
1256        // An all-state-gas result leaves the regular budget untouched, so only the
1257        // state-gas admission check can reject the second transaction. The tx's full gas
1258        // limit counts against the state budget — tx_gas_limit_cap does not bound it.
1259        use revm::{
1260            context::result::{
1261                ExecResultAndState, ExecutionResult, Output, ResultGas, SuccessReason,
1262            },
1263            state::EvmState,
1264        };
1265
1266        let block_gas_limit = 1_000_000u64;
1267        let first_tx_state_gas = 600_000u64;
1268        let second_tx_gas_limit = 500_000u64; // exceeds the remaining 400_000 state budget
1269
1270        let gas = ResultGas::new_with_state_gas(first_tx_state_gas, 0, 0, first_tx_state_gas);
1271        let fake_result: ResultAndState<revm::context::result::HaltReason> =
1272            ExecResultAndState::new(
1273                ExecutionResult::Success {
1274                    reason: SuccessReason::Return,
1275                    gas,
1276                    logs: vec![],
1277                    output: Output::Call(Default::default()),
1278                },
1279                EvmState::default(),
1280            );
1281
1282        // Regular budget is full (block_regular_gas_used = 0) but the state budget has
1283        // only 400_000 left → reject 500_000.
1284        let mut amsterdam = BlockGasTracker::new(block_gas_limit, true, None);
1285        amsterdam.record_result(&fake_result);
1286        assert!(
1287            amsterdam.validate_tx_limit(second_tx_gas_limit).is_err(),
1288            "Amsterdam tracker must reject tx whose gas limit exceeds the remaining state budget",
1289        );
1290        assert!(
1291            amsterdam.validate_tx_limit(400_000).is_ok(),
1292            "Amsterdam tracker must accept tx whose gas limit exactly fits the state budget",
1293        );
1294
1295        // With a cap of 400_000 the capped regular check passes, but the full 500_000
1296        // limit still counts against the state budget → reject.
1297        let mut capped = BlockGasTracker::new(block_gas_limit, true, Some(400_000));
1298        capped.record_result(&fake_result);
1299        assert!(
1300            capped.validate_tx_limit(second_tx_gas_limit).is_err(),
1301            "tx_gas_limit_cap must not bound the state-gas admission check",
1302        );
1303    }
1304
1305    #[test]
1306    fn gas_tracker_caps_oversized_tx_gas_limit_at_tx_gas_limit_cap() {
1307        // A tx with gas_limit above TX_GAS_LIMIT_CAP (EIP-7825) is admitted when the
1308        // capped value fits in the remaining block gas and rejected when it does not.
1309        use revm::{
1310            context::result::{
1311                ExecResultAndState, ExecutionResult, Output, ResultGas, SuccessReason,
1312            },
1313            primitives::eip7825::TX_GAS_LIMIT_CAP,
1314            state::EvmState,
1315        };
1316
1317        let block_gas_limit = 30_000_000u64;
1318        let oversized = TX_GAS_LIMIT_CAP + 1_000_000; // 17_777_216 — above the cap
1319
1320        // Case 1: fresh block, no prior gas consumed.
1321        // tx_min_gas_limit = TX_GAS_LIMIT_CAP (16_777_216) ≤ block_available_gas (30M) → Ok.
1322        let tracker = BlockGasTracker::new(block_gas_limit, false, Some(TX_GAS_LIMIT_CAP));
1323        assert!(
1324            tracker.validate_tx_limit(oversized).is_ok(),
1325            "oversized tx must pass when capped limit fits in block gas",
1326        );
1327
1328        // Case 2: prior tx consumed 20M, leaving 10M available.
1329        // tx_min_gas_limit = TX_GAS_LIMIT_CAP (16_777_216) > block_available_gas (10M) → Err.
1330        let prior_gas = 20_000_000u64;
1331        let gas = ResultGas::new_with_state_gas(prior_gas, 0, 0, prior_gas);
1332        let fake_result: ResultAndState<revm::context::result::HaltReason> =
1333            ExecResultAndState::new(
1334                ExecutionResult::Success {
1335                    reason: SuccessReason::Return,
1336                    gas,
1337                    logs: vec![],
1338                    output: Output::Call(Default::default()),
1339                },
1340                EvmState::default(),
1341            );
1342
1343        let mut tracker = BlockGasTracker::new(block_gas_limit, false, Some(TX_GAS_LIMIT_CAP));
1344        tracker.record_result(&fake_result);
1345        assert!(
1346            tracker.validate_tx_limit(oversized).is_err(),
1347            "oversized tx must be rejected when capped limit exceeds remaining block gas",
1348        );
1349    }
1350}