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