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