arb_evm/
build.rs

1use alloy_consensus::{Transaction, TransactionEnvelope, TxReceipt};
2use alloy_eips::eip2718::{Encodable2718, Typed2718};
3use alloy_evm::{
4    Database, Evm, EvmFactory, RecoveredTx,
5    block::{
6        BlockExecutionError, BlockExecutionResult, BlockExecutor, BlockExecutorFactory,
7        BlockExecutorFor, ExecutableTx, OnStateHook, StateDB,
8    },
9    eth::{
10        EthBlockExecutionCtx, EthBlockExecutor, EthTxResult, receipt_builder::ReceiptBuilder,
11        spec::EthExecutorSpec,
12    },
13    tx::{FromRecoveredTx, FromTxWithEncoded},
14};
15use alloy_primitives::{Address, B256, Log, TxKind, U256, keccak256};
16use arb_chainspec;
17use arb_primitives::{
18    multigas::{MultiGas, NUM_RESOURCE_KIND},
19    signed_tx::ArbTransactionExt,
20    tx_types::ArbTxType,
21};
22use arbos::{
23    arbos_state::ArbosState,
24    burn::SystemBurner,
25    internal_tx::{self, InternalTxContext},
26    l1_pricing, retryables,
27    tx_processor::{
28        EndTxFeeDistribution, EndTxRetryableParams, SubmitRetryableParams, compute_poster_gas,
29        compute_submit_retryable_fees,
30    },
31    util::{self as arb_util, BalanceError, tx_type_has_poster_costs},
32};
33use reth_evm::TransactionEnvMut;
34use revm::{
35    context::{TxEnv, result::ExecutionResult},
36    database::State,
37    inspector::Inspector,
38};
39
40use crate::{
41    context::ArbBlockExecutionCtx,
42    executor::DefaultArbOsHooks,
43    hooks::{ArbOsHooks, EndTxContext},
44    state_overlay::StateOverlay,
45};
46
47/// Extension trait for transaction environments that support gas price mutation.
48///
49/// Arbitrum needs to cap the gas price to the base fee when dropping tips,
50/// which requires mutating fields not exposed by the standard `TransactionEnv` trait.
51pub trait ArbTransactionEnv: TransactionEnvMut {
52    /// Set the effective gas price (max_fee_per_gas for EIP-1559, gas_price for legacy).
53    fn set_gas_price(&mut self, gas_price: u128);
54    /// Set the max priority fee per gas (tip cap).
55    fn set_gas_priority_fee(&mut self, fee: Option<u128>);
56    /// Set the transaction value.
57    fn set_value(&mut self, value: U256);
58}
59
60impl ArbTransactionEnv for TxEnv {
61    fn set_gas_price(&mut self, gas_price: u128) {
62        self.gas_price = gas_price;
63    }
64    fn set_gas_priority_fee(&mut self, fee: Option<u128>) {
65        self.gas_priority_fee = fee;
66    }
67    fn set_value(&mut self, value: U256) {
68        self.value = value;
69    }
70}
71
72/// Extension trait for draining scheduled transactions from the executor.
73///
74/// After executing a SubmitRetryable or a manual Redeem precompile call,
75/// auto-redeem retry transactions may be queued. The block producer must
76/// drain and re-inject them in the same block.
77pub trait ArbScheduledTxDrain {
78    /// Drain any scheduled transactions (e.g. auto-redeem retry txs) produced
79    /// by the most recently committed transaction.
80    fn drain_scheduled_txs(&mut self) -> Vec<Vec<u8>>;
81}
82
83impl<'a, Evm, Spec, R: ReceiptBuilder> ArbScheduledTxDrain for ArbBlockExecutor<'a, Evm, Spec, R> {
84    fn drain_scheduled_txs(&mut self) -> Vec<Vec<u8>> {
85        self.arb_hooks
86            .as_mut()
87            .map(|hooks| std::mem::take(&mut hooks.tx_proc.scheduled_txs))
88            .unwrap_or_default()
89    }
90}
91
92/// Arbitrum block executor factory.
93///
94/// Wraps an `EthBlockExecutor` with ArbOS-specific hooks for gas charging,
95/// fee distribution, and L1 data pricing.
96#[derive(Debug, Clone)]
97pub struct ArbBlockExecutorFactory<R, Spec, EvmF> {
98    receipt_builder: R,
99    spec: Spec,
100    evm_factory: EvmF,
101    allow_debug_precompiles: bool,
102}
103
104impl<R, Spec, EvmF> ArbBlockExecutorFactory<R, Spec, EvmF> {
105    pub fn new(receipt_builder: R, spec: Spec, evm_factory: EvmF) -> Self {
106        Self {
107            receipt_builder,
108            spec,
109            evm_factory,
110            allow_debug_precompiles: false,
111        }
112    }
113
114    pub fn with_allow_debug_precompiles(mut self, allow: bool) -> Self {
115        self.allow_debug_precompiles = allow;
116        self
117    }
118
119    pub fn allow_debug_precompiles(&self) -> bool {
120        self.allow_debug_precompiles
121    }
122
123    pub fn arb_evm_factory(&self) -> &EvmF {
124        &self.evm_factory
125    }
126
127    /// Create an executor with the concrete `ArbBlockExecutor` return type.
128    ///
129    /// Unlike the trait method which returns an opaque type, this provides
130    /// access to Arbitrum-specific methods like `drain_scheduled_txs`.
131    pub fn create_arb_executor<'a, DB, I>(
132        &'a self,
133        evm: EvmF::Evm<&'a mut State<DB>, I>,
134        ctx: EthBlockExecutionCtx<'a>,
135        chain_id: u64,
136    ) -> ArbBlockExecutor<'a, EvmF::Evm<&'a mut State<DB>, I>, &'a Spec, &'a R>
137    where
138        DB: Database + 'a,
139        R: ReceiptBuilder,
140        Spec: EthExecutorSpec + Clone,
141        I: Inspector<EvmF::Context<&'a mut State<DB>>> + 'a,
142        EvmF: EvmFactory + crate::evm::ArbEvmFactoryStaged,
143    {
144        let extra_bytes = ctx.extra_data.as_ref();
145        let (delayed_messages_read, l2_block_number) = decode_extra_fields(extra_bytes);
146        let arb_ctx = ArbBlockExecutionCtx {
147            parent_hash: ctx.parent_hash,
148            parent_beacon_block_root: ctx.parent_beacon_block_root,
149            extra_data: extra_bytes[..core::cmp::min(extra_bytes.len(), 32)].to_vec(),
150            delayed_messages_read,
151            l2_block_number,
152            chain_id,
153            ..Default::default()
154        };
155        ArbBlockExecutor {
156            inner: EthBlockExecutor::new(evm, ctx, &self.spec, &self.receipt_builder),
157            arb_hooks: None,
158            arb_ctx,
159            // Reuse the per-block ctx the factory staged for the EVM so the
160            // EVM-side precompile handlers and the executor's per-tx writes go
161            // through the same `Arc<ArbPrecompileCtx>`.
162            precompile_ctx: self.evm_factory.staged_precompile_ctx().unwrap_or_default(),
163            pending_tx: None,
164            block_gas_left: 0,
165            user_txs_processed: 0,
166            gas_used_for_l1: Vec::new(),
167            multi_gas_used: Vec::new(),
168            expected_balance_delta: 0,
169            zombie_accounts: rustc_hash::FxHashSet::default(),
170            finalise_deleted: rustc_hash::FxHashSet::default(),
171            touched_accounts: rustc_hash::FxHashSet::default(),
172            multi_gas_current_fees: std::sync::OnceLock::new(),
173            state_overlay: StateOverlay::new(),
174            multi_gas_sink: crate::multi_gas::MultiGasSink::default(),
175        }
176    }
177}
178
179impl<R, Spec, EvmF> BlockExecutorFactory for ArbBlockExecutorFactory<R, Spec, EvmF>
180where
181    R: ReceiptBuilder<
182            Transaction: Transaction + Encodable2718 + ArbTransactionExt,
183            Receipt: TxReceipt<Log = Log> + arb_primitives::SetArbReceiptFields,
184        > + 'static,
185    Spec: EthExecutorSpec + Clone + 'static,
186    EvmF: EvmFactory<
187            Tx: FromRecoveredTx<R::Transaction>
188                    + FromTxWithEncoded<R::Transaction>
189                    + ArbTransactionEnv,
190        > + crate::evm::ArbEvmFactoryStaged,
191    Self: 'static,
192{
193    type EvmFactory = EvmF;
194    type ExecutionCtx<'a> = EthBlockExecutionCtx<'a>;
195    type Transaction = R::Transaction;
196    type Receipt = R::Receipt;
197
198    fn evm_factory(&self) -> &Self::EvmFactory {
199        &self.evm_factory
200    }
201
202    fn create_executor<'a, DB, I>(
203        &'a self,
204        _evm: EvmF::Evm<DB, I>,
205        _ctx: Self::ExecutionCtx<'a>,
206    ) -> impl BlockExecutorFor<'a, Self, DB, I>
207    where
208        DB: StateDB + 'a,
209        I: Inspector<EvmF::Context<DB>> + 'a,
210    {
211        unreachable!(
212            "BlockExecutorFactory::create_executor must not be called directly on \
213                 ArbBlockExecutorFactory; all execution goes through the \
214                 ConfigureEvm::create_executor override on ArbEvmConfig"
215        );
216        #[allow(unreachable_code)]
217        EthBlockExecutor::new(_evm, _ctx, &self.spec, &self.receipt_builder)
218    }
219}
220
221// ---------------------------------------------------------------------------
222// Per-transaction state carried between execute and commit
223// ---------------------------------------------------------------------------
224
225/// Captured per-transaction state for fee distribution in `commit_transaction`.
226struct PendingArbTx {
227    sender: Address,
228    tx_gas_limit: u64,
229    arb_tx_type: Option<ArbTxType>,
230    poster_gas: u64,
231    /// Gas reth's EVM charged for (0 for paths that bypass reth's EVM).
232    evm_gas_used: u64,
233    charged_multi_gas: MultiGas,
234    gas_price_positive: bool,
235    stylus_data_fee: U256,
236    retry_context: Option<PendingRetryContext>,
237    coinbase_tip_per_gas: u128,
238    /// True when tx_env.gas_price was capped to base_fee. Determines whether
239    /// commit_transaction must burn the tip (revm saw only base_fee) or
240    /// transfer it from coinbase to network (revm minted to coinbase).
241    capped_gas_price: bool,
242    /// Effective per-gas price the sender pays on posterGas (full when
243    /// CollectTips is true, else base fee). Used for posterGas rounding
244    /// and the sender-side burn on gas reth didn't charge.
245    actual_gas_price: U256,
246}
247
248/// Context for a retry tx that needs end-tx processing after EVM execution.
249struct PendingRetryContext {
250    ticket_id: alloy_primitives::B256,
251    refund_to: Address,
252    max_refund: U256,
253    submission_fee_refund: U256,
254    /// Call value transferred from escrow; returned to escrow on failure.
255    call_value: U256,
256}
257
258/// Arbitrum block executor wrapping `EthBlockExecutor`.
259///
260/// Adds ArbOS-specific pre/post execution logic:
261/// - Loads ArbOS state at block start (version, fee accounts)
262/// - Adjusts gas accounting for L1 poster costs
263/// - Distributes fees to network/infra/poster accounts after each tx
264/// - Tracks block gas consumption for rate limiting
265pub struct ArbBlockExecutor<'a, Evm, Spec, R: ReceiptBuilder> {
266    /// Inner Ethereum block executor.
267    pub inner: EthBlockExecutor<'a, Evm, Spec, R>,
268    /// ArbOS hooks for per-transaction processing.
269    pub arb_hooks: Option<DefaultArbOsHooks>,
270    /// Arbitrum-specific block context.
271    pub arb_ctx: ArbBlockExecutionCtx,
272    /// Per-block precompile context handle (per-tx scratch writes).
273    pub precompile_ctx: std::sync::Arc<arb_context::ArbPrecompileCtx>,
274    /// Per-tx state between execute and commit.
275    pending_tx: Option<PendingArbTx>,
276    /// Remaining block gas for rate limiting.
277    /// Starts at per_block_gas_limit and decreases with each tx's compute gas.
278    pub block_gas_left: u64,
279    /// Number of user transactions successfully committed.
280    /// Used for ArbOS < 50 block gas check (first user tx may exceed limit).
281    user_txs_processed: u64,
282    /// Per-receipt poster gas (L1 gas component), parallel to the receipts vector.
283    /// Used to populate `gasUsedForL1` in RPC receipt responses.
284    pub gas_used_for_l1: Vec<u64>,
285    /// Per-receipt multi-dimensional gas, parallel to the receipts vector.
286    pub multi_gas_used: Vec<MultiGas>,
287    /// Expected balance delta from deposits (positive) and L2→L1 withdrawals (negative).
288    /// Used for post-block safety verification.
289    expected_balance_delta: i128,
290    /// Zombie accounts: empty accounts preserved from EIP-161 deletion because
291    /// they were touched by a zero-value transfer on pre-Stylus ArbOS.
292    zombie_accounts: rustc_hash::FxHashSet<Address>,
293    /// Accounts removed by per-tx Finalise (EIP-161). Tracked so the producer
294    /// can mark them for trie deletion if they existed pre-block.
295    finalise_deleted: rustc_hash::FxHashSet<Address>,
296    /// Accounts modified in the current tx (bypass ops + EVM state).
297    /// Per-tx Finalise only processes these, matching Go's journal.dirties.
298    touched_accounts: rustc_hash::FxHashSet<Address>,
299    /// Cached per-resource current-block fees, populated lazily on first read
300    /// within a block. SingleDim slot is left zero; callers substitute the
301    /// live base_fee_wei for that slot and for any cached slot that is zero.
302    /// Safe to cache because current-block fees are only written by
303    /// `commit_next_to_current` during `apply_pre_execution_changes` — no user
304    /// tx or precompile path mutates them mid-block.
305    multi_gas_current_fees: std::sync::OnceLock<[U256; NUM_RESOURCE_KIND]>,
306    /// Per-transaction overlay of pre-mutation account snapshots. Reset at the
307    /// start of each tx and drained into the state's transition set when the
308    /// tx commits.
309    state_overlay: StateOverlay,
310    /// Shared slot the EVM's multi-gas inspector publishes each transaction's
311    /// per-dimension gas to. Empty unless a [`MultiGasInspector`] is installed,
312    /// in which case it drives the v60 multi-gas backlog.
313    multi_gas_sink: crate::multi_gas::MultiGasSink,
314}
315
316impl<'a, Evm, Spec, R: ReceiptBuilder> ArbBlockExecutor<'a, Evm, Spec, R> {
317    /// Set the ArbOS hooks for this block execution.
318    pub fn with_hooks(mut self, hooks: DefaultArbOsHooks) -> Self {
319        self.arb_hooks = Some(hooks);
320        self
321    }
322
323    /// Set the Arbitrum execution context.
324    pub fn with_arb_ctx(mut self, ctx: ArbBlockExecutionCtx) -> Self {
325        self.arb_ctx = ctx;
326        self
327    }
328
329    /// Install the shared slot the EVM's multi-gas inspector publishes to. Must
330    /// be the same slot held by the [`MultiGasInspector`] installed on `evm`.
331    pub fn set_multi_gas_sink(&mut self, sink: crate::multi_gas::MultiGasSink) {
332        self.multi_gas_sink = sink;
333    }
334
335    /// Returns the set of zombie account addresses.
336    ///
337    /// Zombie accounts are empty accounts that should be preserved in the
338    /// state trie (not deleted by EIP-161) because they were re-created by
339    /// a zero-value transfer on pre-Stylus ArbOS.
340    pub fn zombie_accounts(&self) -> rustc_hash::FxHashSet<Address> {
341        self.zombie_accounts.clone()
342    }
343
344    /// Returns accounts deleted by per-tx Finalise (EIP-161).
345    /// These may need trie deletion if they existed pre-block.
346    pub fn finalise_deleted(&self) -> &rustc_hash::FxHashSet<Address> {
347        &self.finalise_deleted
348    }
349
350    /// Deduct TX_GAS from block gas budget for a failed/invalid transaction.
351    /// Call this when a user transaction fails execution so the block budget
352    /// and user-tx counter stay in sync (TX_GAS is charged for invalid txs
353    /// and userTxsProcessed is incremented).
354    pub fn deduct_failed_tx_gas(&mut self, is_user_tx: bool) {
355        const TX_GAS: u64 = 21_000;
356        self.block_gas_left = self.block_gas_left.saturating_sub(TX_GAS);
357        if is_user_tx {
358            self.user_txs_processed += 1;
359        }
360    }
361
362    /// Drain any scheduled transactions (e.g. auto-redeem retry txs) produced
363    /// by the most recently committed transaction. The caller should decode and
364    /// re-inject these as new transactions in the same block.
365    pub fn drain_scheduled_txs(&mut self) -> Vec<Vec<u8>> {
366        self.arb_hooks
367            .as_mut()
368            .map(|hooks| std::mem::take(&mut hooks.tx_proc.scheduled_txs))
369            .unwrap_or_default()
370    }
371}
372
373/// Read state parameters from ArbOS state into the execution context
374/// and create/update the hooks. Pulled out of [`ArbBlockExecutor`] so it
375/// can borrow only the fields it mutates, leaving the rest of `self`
376/// (notably the executor's `inner.db_mut()`) free for concurrent reborrow.
377fn load_state_params<D: Database>(
378    arb_ctx: &mut ArbBlockExecutionCtx,
379    precompile_ctx: &mut std::sync::Arc<arb_context::ArbPrecompileCtx>,
380    arb_hooks: &mut Option<DefaultArbOsHooks>,
381    state: &mut revm::database::State<D>,
382    arb_state: &ArbosState<D, impl arbos::burn::Burner>,
383) {
384    let arbos_version = arb_state.arbos_version();
385    arb_ctx.arbos_version = arbos_version;
386
387    // Reset per-tx scratch on the existing precompile ctx Arc rather than
388    // allocating a new one. EVM-side precompile handler closures captured
389    // this Arc at registration time; swapping the Arc here would orphan
390    // their reads from the executor's per-tx writes (set_sender,
391    // set_stylus_call_value, etc.). Block-level fields are populated when
392    // the factory stages the per-block ctx (evm_env path).
393    precompile_ctx.reset_tx();
394    precompile_ctx.reset_caller_stack();
395    precompile_ctx
396        .block
397        .cache_l1_block_number(arb_ctx.l2_block_number, arb_ctx.l1_block_number);
398
399    if arbos_version >= arb_chainspec::arbos_version::ARBOS_VERSION_60 {
400        let cap = arb_state
401            .programs
402            .params(state)
403            .map(|p| p.block_cache_size as usize)
404            .unwrap_or(0);
405        precompile_ctx.block.reset_recent_wasms(cap);
406    } else {
407        precompile_ctx.block.reset_recent_wasms(0);
408    }
409
410    if let Ok(backlog) = arb_state.l2_pricing_state.gas_backlog(state) {
411        precompile_ctx.block.set_current_gas_backlog(backlog);
412    }
413
414    if let Ok(addr) = arb_state.network_fee_account(state) {
415        arb_ctx.network_fee_account = addr;
416    }
417    if let Ok(addr) = arb_state.infra_fee_account(state) {
418        arb_ctx.infra_fee_account = addr;
419    }
420    if let Ok(level) = arb_state.brotli_compression_level(state) {
421        arb_ctx.brotli_compression_level = level;
422    }
423    if let Ok(price) = arb_state.l1_pricing_state.price_per_unit(state) {
424        arb_ctx.l1_price_per_unit = price;
425    }
426    if let Ok(min_fee) = arb_state.l2_pricing_state.min_base_fee_wei(state) {
427        arb_ctx.min_base_fee = min_fee;
428    }
429
430    let per_block_gas_limit = arb_state
431        .l2_pricing_state
432        .per_block_gas_limit(state)
433        .unwrap_or(0);
434    let per_tx_gas_limit = arb_state
435        .l2_pricing_state
436        .per_tx_gas_limit(state)
437        .unwrap_or(0);
438
439    let calldata_pricing_increase_enabled = arbos_version
440        >= arb_chainspec::arbos_version::ARBOS_VERSION_40
441        && arb_state
442            .features
443            .is_increased_calldata_price_enabled(state)
444            .unwrap_or(false);
445
446    let collect_tips_enabled = arb_state.collect_tips(state).unwrap_or(false);
447
448    let hooks = DefaultArbOsHooks::new(
449        arb_ctx.coinbase,
450        arbos_version,
451        arb_ctx.network_fee_account,
452        arb_ctx.infra_fee_account,
453        arb_ctx.min_base_fee,
454        per_block_gas_limit,
455        per_tx_gas_limit,
456        false,
457        arb_ctx.l1_base_fee,
458        calldata_pricing_increase_enabled,
459        collect_tips_enabled,
460    );
461    *arb_hooks = Some(hooks);
462}
463
464/// Fill the `arbBlockHash` window `[current_l2-256, current_l2-1]`: parent from the
465/// header, deeper committed ancestors from `lookup`, stopping at the first gap.
466/// Entries already present (chunk-internal predecessors) are kept.
467fn populate_l2_block_hash_window(
468    block: &arb_context::BlockCtx,
469    current_l2: u64,
470    parent_hash: B256,
471    mut lookup: impl FnMut(u64) -> Option<B256>,
472) {
473    let Some(parent) = current_l2.checked_sub(1) else {
474        return;
475    };
476    block.cache_l2_block_hash(parent, parent_hash);
477    for offset in 2..=256u64 {
478        let Some(n) = current_l2.checked_sub(offset) else {
479            break;
480        };
481        if block.cached_l2_block_hash(n).is_some() {
482            continue;
483        }
484        match lookup(n) {
485            Some(hash) => block.cache_l2_block_hash(n, hash),
486            None => break,
487        }
488    }
489}
490
491impl<'db, DB, E, Spec, R> ArbBlockExecutor<'_, E, Spec, R>
492where
493    DB: Database + 'db,
494    E: Evm<
495            DB = &'db mut State<DB>,
496            Tx: FromRecoveredTx<R::Transaction>
497                    + FromTxWithEncoded<R::Transaction>
498                    + ArbTransactionEnv,
499        >,
500    Spec: EthExecutorSpec,
501    R: ReceiptBuilder<
502            Transaction: Transaction + Encodable2718 + ArbTransactionExt,
503            Receipt: TxReceipt<Log = Log>,
504        >,
505    R::Transaction: TransactionEnvelope,
506{
507    /// Re-read the per-tx ArbOS state parameters from committed state into the
508    /// cached context and hooks: the fee collectors, minimum base fee, brotli
509    /// compression level, and calldata-pricing feature.
510    #[cold]
511    #[inline(never)]
512    fn refresh_state_params(&mut self) {
513        let arbos_version = self.arb_ctx.arbos_version;
514        let mut calldata_pricing_increase_enabled = false;
515        let mut collect_tips_enabled = self
516            .arb_hooks
517            .as_ref()
518            .map(|h| h.collect_tips_enabled)
519            .unwrap_or(false);
520        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
521        if let Ok(arb_state) = ArbosState::open(db, SystemBurner::new(None, false)) {
522            // SAFETY: see `Storage::state_mut()` invariant.
523            let state_ref = unsafe { arb_state.backing_storage.state_mut() };
524            if let Ok(net) = arb_state.network_fee_account(state_ref) {
525                self.arb_ctx.network_fee_account = net;
526            }
527            if let Ok(infra) = arb_state.infra_fee_account(state_ref) {
528                self.arb_ctx.infra_fee_account = infra;
529            }
530            if let Ok(min_fee) = arb_state.l2_pricing_state.min_base_fee_wei(state_ref) {
531                self.arb_ctx.min_base_fee = min_fee;
532            }
533            if let Ok(level) = arb_state.brotli_compression_level(state_ref) {
534                self.arb_ctx.brotli_compression_level = level;
535            }
536            calldata_pricing_increase_enabled = arbos_version
537                >= arb_chainspec::arbos_version::ARBOS_VERSION_40
538                && arb_state
539                    .features
540                    .is_increased_calldata_price_enabled(state_ref)
541                    .unwrap_or(false);
542            collect_tips_enabled = arb_state
543                .collect_tips(state_ref)
544                .unwrap_or(collect_tips_enabled);
545        }
546        if let Some(hooks) = self.arb_hooks.as_mut() {
547            hooks.network_fee_account = self.arb_ctx.network_fee_account;
548            hooks.infra_fee_account = self.arb_ctx.infra_fee_account;
549            hooks.min_base_fee = self.arb_ctx.min_base_fee;
550            hooks.calldata_pricing_increase_enabled = calldata_pricing_increase_enabled;
551            hooks.collect_tips_enabled = collect_tips_enabled;
552        }
553    }
554
555    /// Handle SubmitRetryableTx: no EVM execution, all state changes done directly.
556    ///
557    /// Returns a synthetic execution result (endTxNow=true).
558    fn execute_submit_retryable(
559        &mut self,
560        ticket_id: alloy_primitives::B256,
561        tx_type: <R::Transaction as TransactionEnvelope>::TxType,
562        mut info: arb_primitives::SubmitRetryableInfo,
563    ) -> Result<
564        EthTxResult<E::HaltReason, <R::Transaction as TransactionEnvelope>::TxType>,
565        BlockExecutionError,
566    > {
567        let sender = info.from;
568
569        // Check if this submit retryable is in the on-chain filter.
570        // If filtered, redirect fee_refund_addr and beneficiary to the
571        // filtered funds recipient. The retryable is still created but
572        // auto-redeem scheduling is skipped.
573        let is_filtered = {
574            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
575            let arb_state = ArbosState::open(db, SystemBurner::new(None, false))
576                .map_err(BlockExecutionError::other)?;
577            if arb_state.filtered_transactions.is_filtered_free(ticket_id) {
578                // SAFETY: see `Storage::state_mut()` invariant. `arb_state` borrows
579                // the state for `'a`; `state_mut()` re-materialises that borrow
580                // for one accessor call.
581                let state_ref = unsafe { arb_state.backing_storage.state_mut() };
582                let recipient = arb_state
583                    .filtered_funds_recipient_or_default(state_ref)
584                    .map_err(BlockExecutionError::other)?;
585                info.fee_refund_addr = recipient;
586                info.beneficiary = recipient;
587                true
588            } else {
589                false
590            }
591        };
592
593        // Compute fees (read block info before mutably borrowing db).
594        let block = self.inner.evm().block();
595        let current_time = revm::context::Block::timestamp(block).to::<u64>();
596        let effective_base_fee = self.arb_ctx.basefee;
597
598        let overlay = &mut self.state_overlay;
599        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
600
601        // Mint deposit value to sender.
602        let _ = arb_util::mint_balance(&sender, info.deposit_value, |f, t, a| {
603            apply_balance_op(db, overlay, f, t, a)
604        });
605        self.touched_accounts.insert(sender);
606
607        // Track retryable deposit for balance delta verification.
608        let dep_i128: i128 = info.deposit_value.try_into().unwrap_or(i128::MAX);
609        self.expected_balance_delta = self.expected_balance_delta.saturating_add(dep_i128);
610
611        // Get sender balance after minting.
612        let _ = db.load_cache_account(sender);
613        let balance_after_mint = db
614            .cache
615            .accounts
616            .get(&sender)
617            .and_then(|a| a.account.as_ref())
618            .map(|a| a.info.balance)
619            .unwrap_or(U256::ZERO);
620
621        let params = SubmitRetryableParams {
622            ticket_id,
623            from: sender,
624            fee_refund_addr: info.fee_refund_addr,
625            deposit_value: info.deposit_value,
626            retry_value: info.retry_value,
627            gas_fee_cap: info.gas_fee_cap,
628            gas: info.gas,
629            max_submission_fee: info.max_submission_fee,
630            retry_data_len: info.retry_data.len(),
631            l1_base_fee: info.l1_base_fee,
632            effective_base_fee,
633            current_time,
634            balance_after_mint,
635            infra_fee_account: self.arb_ctx.infra_fee_account,
636            min_base_fee: self.arb_ctx.min_base_fee,
637            arbos_version: self.arb_ctx.arbos_version,
638        };
639
640        let fees = compute_submit_retryable_fees(&params);
641
642        let user_gas = info.gas;
643
644        // Fee validation errors end the transaction immediately with zero gas.
645        // The deposit was already minted (separate ArbitrumDepositTx), and no
646        // further transfers should occur.
647        if let Some(ref err) = fees.error {
648            tracing::warn!(
649                target: "arb::executor",
650                ticket_id = %ticket_id,
651                error = %err,
652                "submit retryable fee validation failed"
653            );
654
655            self.pending_tx = Some(PendingArbTx {
656                sender,
657                tx_gas_limit: user_gas,
658                arb_tx_type: Some(ArbTxType::ArbitrumSubmitRetryableTx),
659                poster_gas: 0,
660                evm_gas_used: 0,
661
662                charged_multi_gas: MultiGas::default(),
663                gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
664                stylus_data_fee: U256::ZERO,
665                retry_context: None,
666                coinbase_tip_per_gas: 0,
667                capped_gas_price: false,
668                actual_gas_price: self.arb_ctx.basefee,
669            });
670
671            return Ok(EthTxResult {
672                result: revm::context::result::ResultAndState {
673                    result: ExecutionResult::Revert {
674                        gas: synthetic_result_gas(0),
675                        logs: Vec::new(),
676                        output: alloy_primitives::Bytes::new(),
677                    },
678                    state: Default::default(),
679                },
680                blob_gas_used: 0,
681                tx_type,
682            });
683        }
684
685        let overlay = &mut self.state_overlay;
686        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
687
688        // 3. Transfer submission fee to network fee account.
689        if !fees.submission_fee.is_zero() {
690            // Sender balance was just topped up by the deposit mint above and the
691            // pre-submit checks ensure it covers all submit fees. A shortfall here
692            // would indicate a fee-validation bug, so silently tolerate it.
693            let _ = arb_util::transfer_balance(
694                Some(&sender),
695                Some(&self.arb_ctx.network_fee_account),
696                fees.submission_fee,
697                |f, t, a| apply_balance_op(db, overlay, f, t, a),
698            );
699            self.touched_accounts.insert(sender);
700            self.touched_accounts
701                .insert(self.arb_ctx.network_fee_account);
702        }
703
704        // 4. Refund excess submission fee.
705        let _ = arb_util::transfer_balance(
706            Some(&sender),
707            Some(&info.fee_refund_addr),
708            fees.submission_fee_refund,
709            |f, t, a| apply_balance_op(db, overlay, f, t, a),
710        );
711        self.touched_accounts.insert(sender);
712        self.touched_accounts.insert(info.fee_refund_addr);
713
714        // 5. Move call value into escrow. If sender has insufficient funds (e.g. deposit didn't
715        //    cover retry_value after fee deductions), refund the submission fee and end the
716        //    transaction.
717        let escrow_outcome = arb_util::transfer_balance(
718            Some(&sender),
719            Some(&fees.escrow),
720            info.retry_value,
721            |f, t, a| apply_balance_op(db, overlay, f, t, a),
722        );
723        if matches!(
724            escrow_outcome,
725            Err(BalanceError::InsufficientBalance { .. })
726        ) {
727            self.touched_accounts.insert(sender);
728            self.touched_accounts.insert(fees.escrow);
729            // Refund submission fee from network account back to sender.
730            let _ = arb_util::transfer_balance(
731                Some(&self.arb_ctx.network_fee_account),
732                Some(&sender),
733                fees.submission_fee,
734                |f, t, a| apply_balance_op(db, overlay, f, t, a),
735            );
736            self.touched_accounts
737                .insert(self.arb_ctx.network_fee_account);
738            // Refund withheld portion of submission fee to fee refund address.
739            let _ = arb_util::transfer_balance(
740                Some(&sender),
741                Some(&info.fee_refund_addr),
742                fees.withheld_submission_fee,
743                |f, t, a| apply_balance_op(db, overlay, f, t, a),
744            );
745            self.touched_accounts.insert(info.fee_refund_addr);
746
747            self.pending_tx = Some(PendingArbTx {
748                sender,
749                tx_gas_limit: user_gas,
750                arb_tx_type: Some(ArbTxType::ArbitrumSubmitRetryableTx),
751                poster_gas: 0,
752                evm_gas_used: 0,
753
754                charged_multi_gas: MultiGas::default(),
755                gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
756                stylus_data_fee: U256::ZERO,
757                retry_context: None,
758                coinbase_tip_per_gas: 0,
759                capped_gas_price: false,
760                actual_gas_price: self.arb_ctx.basefee,
761            });
762
763            return Ok(EthTxResult {
764                result: revm::context::result::ResultAndState {
765                    result: ExecutionResult::Revert {
766                        gas: synthetic_result_gas(0),
767                        logs: Vec::new(),
768                        output: alloy_primitives::Bytes::new(),
769                    },
770                    state: Default::default(),
771                },
772                blob_gas_used: 0,
773                tx_type,
774            });
775        }
776        self.touched_accounts.insert(sender);
777        self.touched_accounts.insert(fees.escrow);
778
779        // The escrow is touched even at zero call value so the per-tx Finalise
780        // destructs it; a same-block zero-value redeem then resurrects it as a
781        // present-empty account, reproducing the leaf the state trie keeps.
782        if info.retry_value.is_zero() {
783            materialise_empty(db, overlay, fees.escrow, &mut self.touched_accounts);
784        }
785
786        // 6. Create retryable ticket.
787        let arb_state = ArbosState::open(db, SystemBurner::new(None, false))
788            .map_err(BlockExecutionError::other)?;
789        // SAFETY: see `Storage::state_mut()` invariant.
790        let state_ref = unsafe { arb_state.backing_storage.state_mut() };
791        let _ = arb_state.retryable_state.create_retryable(
792            state_ref,
793            ticket_id,
794            fees.timeout,
795            sender,
796            info.retry_to,
797            info.retry_value,
798            info.beneficiary,
799            &info.retry_data,
800        );
801
802        // Emit TicketCreated event (always, after retryable creation).
803        let mut receipt_logs: Vec<Log> = Vec::new();
804        receipt_logs.push(Log {
805            address: arb_precompiles::ARBRETRYABLETX_ADDRESS,
806            data: alloy_primitives::LogData::new_unchecked(
807                vec![arb_precompiles::ticket_created_topic(), ticket_id],
808                alloy_primitives::Bytes::new(),
809            ),
810        });
811
812        let overlay = &mut self.state_overlay;
813        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
814
815        // 7. Handle gas fees if user can pay.
816        if fees.can_pay_for_gas {
817            // Pay infra fee (skip when infra_fee_account is zero, matching Go).
818            if self.arb_ctx.infra_fee_account != Address::ZERO {
819                let _ = arb_util::transfer_balance(
820                    Some(&sender),
821                    Some(&self.arb_ctx.infra_fee_account),
822                    fees.infra_cost,
823                    |f, t, a| apply_balance_op(db, overlay, f, t, a),
824                );
825                self.touched_accounts.insert(sender);
826                self.touched_accounts.insert(self.arb_ctx.infra_fee_account);
827            }
828            // Pay network fee.
829            if !fees.network_cost.is_zero() {
830                let _ = arb_util::transfer_balance(
831                    Some(&sender),
832                    Some(&self.arb_ctx.network_fee_account),
833                    fees.network_cost,
834                    |f, t, a| apply_balance_op(db, overlay, f, t, a),
835                );
836                self.touched_accounts.insert(sender);
837                self.touched_accounts
838                    .insert(self.arb_ctx.network_fee_account);
839            }
840            // Gas price refund.
841            let _ = arb_util::transfer_balance(
842                Some(&sender),
843                Some(&info.fee_refund_addr),
844                fees.gas_price_refund,
845                |f, t, a| apply_balance_op(db, overlay, f, t, a),
846            );
847            self.touched_accounts.insert(sender);
848            self.touched_accounts.insert(info.fee_refund_addr);
849
850            // Filtered retryables do not get an auto-redeem scheduled.
851            if !is_filtered {
852                // Schedule auto-redeem: reconstruct the retry tx from stored
853                // fields and bump num_tries.
854                let arb_state = ArbosState::open(db, SystemBurner::new(None, false))
855                    .map_err(BlockExecutionError::other)?;
856                // SAFETY: see `Storage::state_mut()` invariant.
857                let state_ref = unsafe { arb_state.backing_storage.state_mut() };
858                match arb_state
859                    .retryable_state
860                    .open_retryable(state_ref, ticket_id, 0)
861                {
862                    Ok(Some(retryable)) => {
863                        let _ = retryable.increment_num_tries(state_ref);
864
865                        match retryable.make_tx(
866                            state_ref,
867                            U256::from(self.arb_ctx.chain_id),
868                            0, // nonce = 0 for first auto-redeem
869                            effective_base_fee,
870                            user_gas,
871                            ticket_id,
872                            info.fee_refund_addr,
873                            fees.available_refund,
874                            fees.submission_fee,
875                        ) {
876                            Ok(retry_tx) => {
877                                // Compute retry tx hash for the event.
878                                let retry_tx_hash = {
879                                    let mut enc = Vec::new();
880                                    enc.push(ArbTxType::ArbitrumRetryTx.as_u8());
881                                    alloy_rlp::Encodable::encode(&retry_tx, &mut enc);
882                                    keccak256(&enc)
883                                };
884
885                                // Emit RedeemScheduled event.
886                                let mut event_data = Vec::with_capacity(128);
887                                event_data.extend_from_slice(
888                                    &B256::left_padding_from(&user_gas.to_be_bytes()).0,
889                                );
890                                event_data.extend_from_slice(
891                                    &B256::left_padding_from(info.fee_refund_addr.as_slice()).0,
892                                );
893                                event_data
894                                    .extend_from_slice(&fees.available_refund.to_be_bytes::<32>());
895                                event_data
896                                    .extend_from_slice(&fees.submission_fee.to_be_bytes::<32>());
897
898                                receipt_logs.push(Log {
899                                    address: arb_precompiles::ARBRETRYABLETX_ADDRESS,
900                                    data: alloy_primitives::LogData::new_unchecked(
901                                        vec![
902                                            arb_precompiles::redeem_scheduled_topic(),
903                                            ticket_id,
904                                            retry_tx_hash,
905                                            B256::left_padding_from(&0u64.to_be_bytes()),
906                                        ],
907                                        event_data.into(),
908                                    ),
909                                });
910
911                                if let Some(hooks) = self.arb_hooks.as_mut() {
912                                    let mut encoded = Vec::new();
913                                    encoded.push(ArbTxType::ArbitrumRetryTx.as_u8());
914                                    alloy_rlp::Encodable::encode(&retry_tx, &mut encoded);
915                                    hooks.tx_proc.scheduled_txs.push(encoded);
916                                } else {
917                                    tracing::warn!(
918                                        target: "arb::executor",
919                                        "Cannot schedule auto-redeem: arb_hooks is None"
920                                    );
921                                }
922                            }
923                            Err(_) => {
924                                tracing::warn!(
925                                    target: "arb::executor",
926                                    "Auto-redeem make_tx failed"
927                                );
928                            }
929                        }
930                    }
931                    Ok(None) => {
932                        tracing::warn!(
933                            target: "arb::executor",
934                            %ticket_id,
935                            "open_retryable returned None after create"
936                        );
937                    }
938                    Err(_) => {
939                        tracing::warn!(
940                            target: "arb::executor",
941                            "open_retryable failed"
942                        );
943                    }
944                }
945            }
946        } else if !fees.gas_cost_refund.is_zero() {
947            // Can't pay for gas: refund gas cost from deposit.
948            let _ = arb_util::transfer_balance(
949                Some(&sender),
950                Some(&info.fee_refund_addr),
951                fees.gas_cost_refund,
952                |f, t, a| apply_balance_op(db, overlay, f, t, a),
953            );
954            self.touched_accounts.insert(sender);
955            self.touched_accounts.insert(info.fee_refund_addr);
956        }
957
958        // Store pending state for commit_transaction.
959        // evm_gas_used must equal gas_used when can_pay_for_gas because the gas
960        // fees were already transferred inside execute_submit_retryable. Setting
961        // evm_gas_used = gas_used prevents the sender_extra_gas burn in
962        // commit_transaction from double-charging the sender.
963        let gas_used = if fees.can_pay_for_gas { user_gas } else { 0 };
964        self.pending_tx = Some(PendingArbTx {
965            sender,
966            tx_gas_limit: user_gas,
967            arb_tx_type: Some(ArbTxType::ArbitrumSubmitRetryableTx),
968            poster_gas: 0,
969            evm_gas_used: gas_used,
970            charged_multi_gas: if fees.can_pay_for_gas {
971                MultiGas::single_dim_gas(user_gas)
972            } else {
973                MultiGas::default()
974            },
975            gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
976            stylus_data_fee: U256::ZERO,
977            retry_context: None,
978            coinbase_tip_per_gas: 0,
979            capped_gas_price: false,
980            actual_gas_price: self.arb_ctx.basefee,
981        });
982
983        // Construct synthetic execution result. Filtered retryables always
984        // return a failure receipt (filteredErr). Non-filtered txs
985        // succeed even when can't pay for gas (retryable was created).
986        let ticket_bytes = alloy_primitives::Bytes::copy_from_slice(ticket_id.as_slice());
987
988        if is_filtered {
989            Ok(EthTxResult {
990                result: revm::context::result::ResultAndState {
991                    result: ExecutionResult::Revert {
992                        gas: synthetic_result_gas(gas_used),
993                        logs: Vec::new(),
994                        output: ticket_bytes,
995                    },
996                    state: Default::default(),
997                },
998                blob_gas_used: 0,
999                tx_type,
1000            })
1001        } else {
1002            Ok(EthTxResult {
1003                result: revm::context::result::ResultAndState {
1004                    result: ExecutionResult::Success {
1005                        reason: revm::context::result::SuccessReason::Return,
1006                        gas: synthetic_result_gas(gas_used),
1007                        output: revm::context::result::Output::Call(ticket_bytes),
1008                        logs: receipt_logs,
1009                    },
1010                    state: Default::default(),
1011                },
1012                blob_gas_used: 0,
1013                tx_type,
1014            })
1015        }
1016    }
1017}
1018
1019impl<'db, DB, E, Spec, R> BlockExecutor for ArbBlockExecutor<'_, E, Spec, R>
1020where
1021    DB: Database + 'db,
1022    E: Evm<
1023            DB = &'db mut State<DB>,
1024            Tx: FromRecoveredTx<R::Transaction>
1025                    + FromTxWithEncoded<R::Transaction>
1026                    + ArbTransactionEnv,
1027        >,
1028    Spec: EthExecutorSpec,
1029    R: ReceiptBuilder<
1030            Transaction: Transaction + Encodable2718 + ArbTransactionExt,
1031            Receipt: TxReceipt<Log = Log> + arb_primitives::SetArbReceiptFields,
1032        >,
1033    R::Transaction: TransactionEnvelope,
1034{
1035    type Transaction = R::Transaction;
1036    type Receipt = R::Receipt;
1037    type Evm = E;
1038    type Result = EthTxResult<E::HaltReason, <R::Transaction as TransactionEnvelope>::TxType>;
1039
1040    fn apply_pre_execution_changes(&mut self) -> Result<(), BlockExecutionError> {
1041        self.inner.apply_pre_execution_changes()?;
1042
1043        // Populate header-derived fields from the EVM block/cfg environment.
1044        {
1045            let block = self.inner.evm().block();
1046            let timestamp = revm::context::Block::timestamp(block).to::<u64>();
1047            if self.arb_ctx.block_timestamp == 0 {
1048                self.arb_ctx.block_timestamp = timestamp;
1049            }
1050            self.arb_ctx.coinbase = revm::context::Block::beneficiary(block);
1051            self.arb_ctx.basefee = U256::from(revm::context::Block::basefee(block));
1052            // The block env carries no chain id, so the generic execution path
1053            // (e.g. re-execute) leaves it defaulted; the producer sets it via
1054            // with_arb_ctx. Source it from the EVM cfg when unset so retryable
1055            // auto-redeem tx hashes are correct.
1056            if self.arb_ctx.chain_id == 0 {
1057                self.arb_ctx.chain_id = self.inner.evm().chain_id();
1058            }
1059            if let Some(prevrandao) = revm::context::Block::prevrandao(block)
1060                && self.arb_ctx.l1_block_number == 0
1061            {
1062                self.arb_ctx.l1_block_number =
1063                    crate::config::l1_block_number_from_mix_hash(&prevrandao);
1064            }
1065        }
1066
1067        // Ensure L2 block number is set for precompile access.
1068        // block_env.number holds L1 block number; L2 comes from the sealed header
1069        // (set via arb_context_for_block or with_arb_ctx). If still 0, we're in a
1070        // path where it wasn't explicitly set — this shouldn't happen in production.
1071        if self.arb_ctx.l2_block_number > 0 {
1072            self.precompile_ctx
1073                .block
1074                .cache_l1_block_number(self.arb_ctx.l2_block_number, self.arb_ctx.l1_block_number);
1075        }
1076
1077        // Load ArbOS state parameters from the EVM database.
1078        // Block-start operations (pricing model update, retryable reaping, etc.)
1079        // are triggered by the startBlock internal tx, NOT here.
1080        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1081        let arb_state = ArbosState::open(db, SystemBurner::new(None, false))
1082            .map_err(BlockExecutionError::other)?;
1083        // SAFETY: see `Storage::state_mut()` invariant. The returned reference
1084        // inherits the storage handle's `'a` lifetime, decoupled from `&arb_state`.
1085        let state_ref = unsafe { arb_state.backing_storage.state_mut() };
1086
1087        let _ = arb_state.l2_pricing_state.commit_multi_gas_fees(state_ref);
1088
1089        if let Ok(base_fee) = arb_state.l2_pricing_state.base_fee_wei(state_ref) {
1090            self.arb_ctx.basefee = base_fee;
1091        }
1092
1093        load_state_params(
1094            &mut self.arb_ctx,
1095            &mut self.precompile_ctx,
1096            &mut self.arb_hooks,
1097            state_ref,
1098            &arb_state,
1099        );
1100
1101        self.block_gas_left = arb_state
1102            .l2_pricing_state
1103            .per_block_gas_limit(state_ref)
1104            .unwrap_or(0);
1105
1106        if let Ok(l1_block_number) = arb_state.blockhashes.l1_block_number(state_ref) {
1107            let lower = l1_block_number.saturating_sub(256);
1108            for n in lower..l1_block_number {
1109                // Reborrow `state_ref` for the read; the borrow ends before
1110                // the subsequent `block_hashes.insert` writes to the cache.
1111                if let Ok(Some(hash)) = arb_state.blockhashes.block_hash(state_ref, n) {
1112                    state_ref.block_hashes.insert(n, hash);
1113                }
1114            }
1115        }
1116
1117        // L2 block hashes for arbBlockHash(): parent from the header, deeper
1118        // committed ancestors from the state provider. The producer additionally
1119        // surfaces unflushed in-memory ancestors via its own header-chain walk.
1120        {
1121            let parent_hash = self.arb_ctx.parent_hash;
1122            let current_l2 = self.arb_ctx.l2_block_number;
1123            let block = std::sync::Arc::clone(&self.precompile_ctx.block);
1124            populate_l2_block_hash_window(&block, current_l2, parent_hash, |n| {
1125                match state_ref.database.block_hash(n) {
1126                    Ok(hash) if hash != B256::ZERO => Some(hash),
1127                    _ => None,
1128                }
1129            });
1130        }
1131
1132        tracing::trace!(
1133            target: "arb::executor",
1134            l1_block = self.arb_ctx.l1_block_number,
1135            delayed_msgs = self.arb_ctx.delayed_messages_read,
1136            chain_id = self.arb_ctx.chain_id,
1137            basefee = %self.arb_ctx.basefee,
1138            arbos_version = self.arb_ctx.arbos_version,
1139            has_hooks = self.arb_hooks.is_some(),
1140            "starting block execution"
1141        );
1142
1143        Ok(())
1144    }
1145
1146    fn execute_transaction_without_commit(
1147        &mut self,
1148        tx: impl ExecutableTx<Self>,
1149    ) -> Result<Self::Result, BlockExecutionError> {
1150        // Decompose the transaction to extract sender, type, and gas limit.
1151        let (tx_env, recovered) = tx.into_parts();
1152        let sender = *recovered.signer();
1153        let tx_type_raw = recovered.tx().ty();
1154        let tx_gas_limit = recovered.tx().gas_limit();
1155        let tx_value = recovered.tx().value();
1156        let envelope_tx_type = recovered.tx().tx_type();
1157        let intrinsic_multi_gas = tx_intrinsic_multi_gas(
1158            recovered.tx(),
1159            arb_chainspec::spec_id_by_arbos_version(self.arb_ctx.arbos_version),
1160        );
1161        // EIP-7623 calldata floor, charged only when the increase is enabled
1162        // (the flag already encodes the version gate).
1163        let calldata_floor_gas = if self
1164            .arb_hooks
1165            .as_ref()
1166            .map(|h| h.is_calldata_pricing_increase_enabled())
1167            .unwrap_or(false)
1168        {
1169            tx_floor_data_gas(recovered.tx())
1170        } else {
1171            0
1172        };
1173
1174        // Classify the transaction type.
1175        let arb_tx_type = ArbTxType::from_u8(tx_type_raw).ok();
1176        let is_arb_internal = arb_tx_type == Some(ArbTxType::ArbitrumInternalTx);
1177        let is_arb_deposit = arb_tx_type == Some(ArbTxType::ArbitrumDepositTx);
1178        let is_submit_retryable = arb_tx_type == Some(ArbTxType::ArbitrumSubmitRetryableTx);
1179        let is_retry_tx = arb_tx_type == Some(ArbTxType::ArbitrumRetryTx);
1180        let is_contract_tx = arb_tx_type == Some(ArbTxType::ArbitrumContractTx);
1181        let has_poster_costs = tx_type_has_poster_costs(tx_type_raw);
1182
1183        // Block gas rate limit: reject user txs when block gas budget is
1184        // exhausted. Internal, deposit, and submit retryable txs always proceed
1185        // (they are block-critical or come from the delayed inbox).
1186        let is_user_tx =
1187            !is_arb_internal && !is_arb_deposit && !is_submit_retryable && !is_retry_tx;
1188        const TX_GAS_MIN: u64 = 21_000;
1189        if is_user_tx && self.block_gas_left < TX_GAS_MIN {
1190            return Err(BlockExecutionError::msg("block gas limit reached"));
1191        }
1192
1193        // Reset per-tx processor state.
1194        crate::evm::reset_stylus_pages(&self.precompile_ctx);
1195        crate::evm::clear_poster_balance_correction();
1196        self.precompile_ctx.reset_tx();
1197        self.precompile_ctx.reset_caller_stack();
1198        self.state_overlay.reset_tx();
1199        if let Some(hooks) = self.arb_hooks.as_mut() {
1200            hooks.tx_proc.poster_fee = U256::ZERO;
1201            hooks.tx_proc.poster_gas = 0;
1202            hooks.tx_proc.compute_hold_gas = 0;
1203            hooks.tx_proc.current_retryable = None;
1204            hooks.tx_proc.current_refund_to = None;
1205            hooks.tx_proc.scheduled_txs.clear();
1206        }
1207
1208        // Effective gas price the sender pays on posterGas — full when
1209        // CollectTips is on, else base fee. `Transaction::gas_price` returns
1210        // max_fee for EIP-1559, so compute effective manually to keep
1211        // `max_fee > basefee, priority = 0` priced at basefee.
1212        let actual_gas_price: U256 = {
1213            let base_fee = self.arb_ctx.basefee;
1214            let base_fee_u128: u128 = base_fee.try_into().unwrap_or(u128::MAX);
1215            let max_fee: u128 = revm::context_interface::Transaction::gas_price(&tx_env);
1216            let effective: u128 =
1217                match revm::context_interface::Transaction::max_priority_fee_per_gas(&tx_env) {
1218                    Some(max_priority) => {
1219                        std::cmp::min(max_fee, base_fee_u128.saturating_add(max_priority))
1220                    }
1221                    None => max_fee,
1222                };
1223            let drop = self
1224                .arb_hooks
1225                .as_ref()
1226                .map(|h| h.drop_tip())
1227                .unwrap_or(false);
1228            if drop || effective == 0 {
1229                base_fee
1230            } else {
1231                U256::from(effective)
1232            }
1233        };
1234
1235        // --- Pre-execution: apply special tx type state changes ---
1236
1237        // Internal txs: verify sender, apply state update, end immediately.
1238        if is_arb_internal {
1239            use arbos::tx_processor::ARBOS_ADDRESS;
1240
1241            if sender != ARBOS_ADDRESS {
1242                return Err(BlockExecutionError::msg(
1243                    "internal tx not from ArbOS address",
1244                ));
1245            }
1246
1247            let tx_data = recovered.tx().input().to_vec();
1248            let tx_type = recovered.tx().tx_type();
1249            let mut tx_err = None;
1250
1251            if let Some(selector) = tx_data.first_chunk::<4>() {
1252                let is_start_block = *selector == internal_tx::INTERNAL_TX_START_BLOCK_METHOD_ID;
1253
1254                if is_start_block
1255                    && let Ok(start_data) = internal_tx::decode_start_block_data(&tx_data)
1256                {
1257                    self.arb_ctx.l1_base_fee = start_data.l1_base_fee;
1258                    self.arb_ctx.time_passed = start_data.time_passed;
1259                }
1260
1261                let (block_number, current_time) = {
1262                    let block = self.inner.evm().block();
1263                    (
1264                        revm::context::Block::number(block).to::<u64>(),
1265                        revm::context::Block::timestamp(block).to::<u64>(),
1266                    )
1267                };
1268                let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1269                let mut arb_state = ArbosState::open(db, SystemBurner::new(None, false))
1270                    .map_err(BlockExecutionError::other)?;
1271                // SAFETY: see `Storage::state_mut()` invariant. A second handle
1272                // (`closure_storage`) is taken so the closures below can
1273                // re-materialise the state borrow on demand alongside the
1274                // outer `apply_internal_tx_update` call. The Storage type's
1275                // single-threaded sequential invariant is upheld because all
1276                // accessors run on the same thread without interleaving.
1277                let closure_storage = arb_state.backing_storage.clone();
1278                let ctx = InternalTxContext {
1279                    block_number,
1280                    current_time,
1281                    prev_hash: self.arb_ctx.parent_hash,
1282                };
1283
1284                // EIP-2935: Store parent block hash for ArbOS >= 40.
1285                if is_start_block
1286                    && arb_state.arbos_version() >= arb_chainspec::arbos_version::ARBOS_VERSION_40
1287                {
1288                    // SAFETY: see `Storage::state_mut()` invariant.
1289                    process_parent_block_hash(
1290                        unsafe { closure_storage.state_mut() },
1291                        self.arb_ctx.l2_block_number,
1292                        ctx.prev_hash,
1293                    );
1294                }
1295
1296                let touched_ptr = &mut self.touched_accounts as *mut rustc_hash::FxHashSet<Address>;
1297                let zombie_ptr = &mut self.zombie_accounts as *mut rustc_hash::FxHashSet<Address>;
1298                let finalise_ptr = &self.finalise_deleted as *const rustc_hash::FxHashSet<Address>;
1299                let overlay_ptr = &mut self.state_overlay as *mut StateOverlay;
1300                let arbos_ver = self.arb_ctx.arbos_version;
1301                let transfer_storage = closure_storage.clone();
1302                let balance_storage = closure_storage.clone();
1303                let mut do_transfer = move |from: Address, to: Address, amount: U256| {
1304                    // SAFETY: see `Storage::state_mut()` invariant.
1305                    unsafe {
1306                        let state = transfer_storage.state_mut();
1307                        if amount.is_zero()
1308                            && arbos_ver < arb_chainspec::arbos_version::ARBOS_VERSION_STYLUS
1309                        {
1310                            create_zombie_if_deleted(
1311                                state,
1312                                &mut *overlay_ptr,
1313                                from,
1314                                &*finalise_ptr,
1315                                &mut *zombie_ptr,
1316                                &mut *touched_ptr,
1317                            );
1318                        }
1319                        // Internal-tx transfers move funds between system accounts
1320                        // (L1 pricer pool, fee accounts, retryable escrow). Their
1321                        // bookkeeping keeps the source funded by construction; a
1322                        // shortfall here would indicate consensus-state drift and
1323                        // must not abort the internal tx — match the historic
1324                        // best-effort behavior by swallowing the typed error.
1325                        let _ = apply_balance_op(
1326                            state,
1327                            &mut *overlay_ptr,
1328                            Some(&from),
1329                            Some(&to),
1330                            amount,
1331                        );
1332                        if !amount.is_zero() {
1333                            (*zombie_ptr).remove(&from);
1334                        }
1335                        (*zombie_ptr).remove(&to);
1336                        (*touched_ptr).insert(from);
1337                        (*touched_ptr).insert(to);
1338                    }
1339                    Ok(())
1340                };
1341                let mut do_balance = move |addr: Address| -> U256 {
1342                    // SAFETY: see `Storage::state_mut()` invariant.
1343                    unsafe { get_balance(balance_storage.state_mut(), addr) }
1344                };
1345                // SAFETY: see `Storage::state_mut()` invariant. The state
1346                // handed to `apply_internal_tx_update` and the one materialised
1347                // inside `do_transfer`/`do_balance` alias at the type level but
1348                // do not overlap at runtime — `apply_internal_tx_update` runs
1349                // sequentially on a single thread.
1350                if let Err(e) = internal_tx::apply_internal_tx_update(
1351                    unsafe { closure_storage.state_mut() },
1352                    &tx_data,
1353                    &mut arb_state,
1354                    &ctx,
1355                    &mut do_transfer,
1356                    &mut do_balance,
1357                ) {
1358                    tracing::warn!(
1359                        target: "arb::executor",
1360                        error = %e,
1361                        "internal tx processing failed"
1362                    );
1363                    tx_err = Some(e);
1364                }
1365
1366                if is_start_block {
1367                    // SAFETY: see `Storage::state_mut()` invariant.
1368                    let state_ref = unsafe { arb_state.backing_storage.state_mut() };
1369                    if let Ok(l1_block_number) = arb_state.blockhashes.l1_block_number(state_ref) {
1370                        self.arb_ctx.l1_block_number = l1_block_number;
1371                    }
1372
1373                    load_state_params(
1374                        &mut self.arb_ctx,
1375                        &mut self.precompile_ctx,
1376                        &mut self.arb_hooks,
1377                        state_ref,
1378                        &arb_state,
1379                    );
1380
1381                    if let Ok(l1_block_number) = arb_state.blockhashes.l1_block_number(state_ref) {
1382                        let lower = l1_block_number.saturating_sub(256);
1383                        for n in lower..l1_block_number {
1384                            if let Ok(Some(hash)) = arb_state.blockhashes.block_hash(state_ref, n) {
1385                                state_ref.block_hashes.insert(n, hash);
1386                            }
1387                        }
1388                    }
1389                }
1390            }
1391
1392            // Internal txs end immediately — no EVM execution.
1393            self.pending_tx = Some(PendingArbTx {
1394                sender,
1395                tx_gas_limit: 0,
1396                arb_tx_type: Some(ArbTxType::ArbitrumInternalTx),
1397                poster_gas: 0,
1398                evm_gas_used: 0,
1399
1400                charged_multi_gas: MultiGas::default(),
1401                gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
1402                stylus_data_fee: U256::ZERO,
1403                retry_context: None,
1404                coinbase_tip_per_gas: 0,
1405                capped_gas_price: false,
1406                actual_gas_price: self.arb_ctx.basefee,
1407            });
1408
1409            // Internal tx errors are fatal — abort block production.
1410            if let Some(err) = tx_err {
1411                return Err(BlockExecutionError::other(err));
1412            }
1413
1414            return Ok(EthTxResult {
1415                result: revm::context::result::ResultAndState {
1416                    result: ExecutionResult::Success {
1417                        reason: revm::context::result::SuccessReason::Return,
1418                        gas: synthetic_result_gas(0),
1419                        output: revm::context::result::Output::Call(alloy_primitives::Bytes::new()),
1420                        logs: Vec::new(),
1421                    },
1422                    state: Default::default(),
1423                },
1424                blob_gas_used: 0,
1425                tx_type,
1426            });
1427        }
1428
1429        // Deposit txs: mint to sender, transfer to recipient, end immediately.
1430        // No EVM execution — the value transfer is the entire transaction.
1431        if is_arb_deposit {
1432            let value = recovered.tx().value();
1433            let mut to = match recovered.tx().kind() {
1434                TxKind::Call(addr) => addr,
1435                TxKind::Create => {
1436                    return Err(BlockExecutionError::msg("deposit tx has no To address"));
1437                }
1438            };
1439            let tx_type = recovered.tx().tx_type();
1440            let tx_hash = recovered.tx().trie_hash();
1441
1442            // Check if this deposit is in the on-chain filter.
1443            // Deposits return endTxNow=true so RevertedTxHook is never reached;
1444            // we must check here instead.
1445            let mut is_filtered = false;
1446            {
1447                let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1448                let arb_state = ArbosState::open(db, SystemBurner::new(None, false))
1449                    .map_err(BlockExecutionError::other)?;
1450                if arb_state.filtered_transactions.is_filtered_free(tx_hash) {
1451                    // SAFETY: see `Storage::state_mut()` invariant.
1452                    let state_ref = unsafe { arb_state.backing_storage.state_mut() };
1453                    to = arb_state
1454                        .filtered_funds_recipient_or_default(state_ref)
1455                        .map_err(BlockExecutionError::other)?;
1456                    is_filtered = true;
1457                }
1458            }
1459
1460            let overlay = &mut self.state_overlay;
1461            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1462            let _ = arb_util::mint_balance(&sender, value, |f, t, a| {
1463                apply_balance_op(db, overlay, f, t, a)
1464            });
1465            let _ = arb_util::transfer_balance(Some(&sender), Some(&to), value, |f, t, a| {
1466                apply_balance_op(db, overlay, f, t, a)
1467            });
1468            self.touched_accounts.insert(sender);
1469            self.touched_accounts.insert(to);
1470
1471            // Track deposit for balance delta verification.
1472            let value_i128: i128 = value.try_into().unwrap_or(i128::MAX);
1473            self.expected_balance_delta = self.expected_balance_delta.saturating_add(value_i128);
1474
1475            self.pending_tx = Some(PendingArbTx {
1476                sender,
1477                tx_gas_limit: 0,
1478                arb_tx_type: Some(ArbTxType::ArbitrumDepositTx),
1479                poster_gas: 0,
1480                evm_gas_used: 0,
1481
1482                charged_multi_gas: MultiGas::default(),
1483                gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
1484                stylus_data_fee: U256::ZERO,
1485                retry_context: None,
1486                coinbase_tip_per_gas: 0,
1487                capped_gas_price: false,
1488                actual_gas_price: self.arb_ctx.basefee,
1489            });
1490
1491            // Filtered deposits produce a failed receipt (status=0) via
1492            // ErrFilteredTx. The state changes (mint + redirected transfer)
1493            // are still committed.
1494            let result = if is_filtered {
1495                ExecutionResult::Revert {
1496                    gas: synthetic_result_gas(0),
1497                    logs: Vec::new(),
1498                    output: alloy_primitives::Bytes::from("filtered transaction"),
1499                }
1500            } else {
1501                ExecutionResult::Success {
1502                    reason: revm::context::result::SuccessReason::Return,
1503                    gas: synthetic_result_gas(0),
1504                    output: revm::context::result::Output::Call(alloy_primitives::Bytes::new()),
1505                    logs: Vec::new(),
1506                }
1507            };
1508
1509            return Ok(EthTxResult {
1510                result: revm::context::result::ResultAndState {
1511                    result,
1512                    state: Default::default(),
1513                },
1514                blob_gas_used: 0,
1515                tx_type,
1516            });
1517        }
1518
1519        // --- SubmitRetryable: skip EVM, handle fees/escrow/ticket creation ---
1520        if is_submit_retryable && let Some(info) = recovered.tx().submit_retryable_info() {
1521            let ticket_id = recovered.tx().trie_hash();
1522            let tx_type = recovered.tx().tx_type();
1523            return self.execute_submit_retryable(ticket_id, tx_type, info);
1524        }
1525
1526        // --- RetryTx pre-processing: escrow transfer and prepaid gas ---
1527        // Track retry pre-exec state so we can undo it if the inner execution
1528        // errors out before the outer state_transition can revert.
1529        let mut retry_pre_exec_undo: Option<(Address, U256, Address, U256)> = None;
1530        let mut retry_context = None;
1531        if is_retry_tx && let Some(info) = recovered.tx().retry_tx_info() {
1532            let current_time = {
1533                let block = self.inner.evm().block();
1534                revm::context::Block::timestamp(block).to::<u64>()
1535            };
1536            let overlay = &mut self.state_overlay;
1537            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1538
1539            // Open the retryable ticket. Scoped so `arb_state`'s borrow of
1540            // `db` is released before the balance-op closures below reborrow it.
1541            let retryable = {
1542                let arb_state = ArbosState::open(db, SystemBurner::new(None, false))
1543                    .map_err(BlockExecutionError::other)?;
1544                // SAFETY: see `Storage::state_mut()` invariant.
1545                let state_ref = unsafe { arb_state.backing_storage.state_mut() };
1546                arb_state
1547                    .retryable_state
1548                    .open_retryable(state_ref, info.ticket_id, current_time)
1549                    .map(|opt| opt.map(|_| ()))
1550            };
1551
1552            match retryable {
1553                Ok(Some(_)) => {
1554                    // Transfer call value from escrow to sender.
1555                    let escrow = retryables::retryable_escrow_address(info.ticket_id);
1556                    let value = recovered.tx().value();
1557
1558                    // Go's TransferBalance calls CreateZombieIfDeleted(from)
1559                    // when amount == 0 on pre-Stylus ArbOS.
1560                    if value.is_zero()
1561                        && self.arb_ctx.arbos_version
1562                            < arb_chainspec::arbos_version::ARBOS_VERSION_STYLUS
1563                    {
1564                        create_zombie_if_deleted(
1565                            db,
1566                            overlay,
1567                            escrow,
1568                            &self.finalise_deleted,
1569                            &mut self.zombie_accounts,
1570                            &mut self.touched_accounts,
1571                        );
1572                    }
1573
1574                    let escrow_outcome = arb_util::transfer_balance(
1575                        Some(&escrow),
1576                        Some(&sender),
1577                        value,
1578                        |f, t, a| apply_balance_op(db, overlay, f, t, a),
1579                    );
1580                    if matches!(
1581                        escrow_outcome,
1582                        Err(BalanceError::InsufficientBalance { .. })
1583                    ) {
1584                        // Escrow has insufficient funds — abort the retry tx.
1585                        let tx_type = recovered.tx().tx_type();
1586                        self.pending_tx = Some(PendingArbTx {
1587                            sender,
1588                            tx_gas_limit: 0,
1589                            arb_tx_type: Some(ArbTxType::ArbitrumRetryTx),
1590                            poster_gas: 0,
1591                            evm_gas_used: 0,
1592
1593                            charged_multi_gas: MultiGas::default(),
1594                            gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
1595                            stylus_data_fee: U256::ZERO,
1596                            retry_context: None,
1597                            coinbase_tip_per_gas: 0,
1598                            capped_gas_price: false,
1599                            actual_gas_price: self.arb_ctx.basefee,
1600                        });
1601                        return Ok(EthTxResult {
1602                            result: revm::context::result::ResultAndState {
1603                                result: ExecutionResult::Revert {
1604                                    gas: synthetic_result_gas(0),
1605                                    logs: Vec::new(),
1606                                    output: alloy_primitives::Bytes::new(),
1607                                },
1608                                state: Default::default(),
1609                            },
1610                            blob_gas_used: 0,
1611                            tx_type,
1612                        });
1613                    }
1614
1615                    // Track escrow transfer addresses.
1616                    if !value.is_zero() {
1617                        self.zombie_accounts.remove(&escrow);
1618                    }
1619                    self.zombie_accounts.remove(&sender);
1620                    self.touched_accounts.insert(escrow);
1621                    self.touched_accounts.insert(sender);
1622
1623                    // Mint prepaid gas to sender.
1624                    let prepaid = self
1625                        .arb_ctx
1626                        .basefee
1627                        .saturating_mul(U256::from(tx_gas_limit));
1628                    let _ = arb_util::mint_balance(&sender, prepaid, |f, t, a| {
1629                        apply_balance_op(db, overlay, f, t, a)
1630                    });
1631                    retry_pre_exec_undo = Some((sender, prepaid, escrow, value));
1632
1633                    // Record the pre-exec synthetic credits (escrow value +
1634                    // prepaid gas) as transitions now. The EVM's own commit
1635                    // would otherwise capture the transient prepaid mint as
1636                    // the revert baseline of a freshly-created redeemer,
1637                    // corrupting the account changeset and the stateRoot.
1638                    overlay.drain_and_apply(db, &self.zombie_accounts);
1639
1640                    // Set retry context for end-tx processing.
1641                    if let Some(hooks) = self.arb_hooks.as_mut() {
1642                        hooks
1643                            .tx_proc
1644                            .prepare_retry_tx(info.ticket_id, info.refund_to);
1645                    }
1646
1647                    retry_context = Some(PendingRetryContext {
1648                        ticket_id: info.ticket_id,
1649                        refund_to: info.refund_to,
1650                        max_refund: info.max_refund,
1651                        submission_fee_refund: info.submission_fee_refund,
1652                        call_value: recovered.tx().value(),
1653                    });
1654                }
1655                Ok(None) => {
1656                    // Retryable expired or not found — endTxNow=true.
1657                    let tx_type = recovered.tx().tx_type();
1658                    self.pending_tx = Some(PendingArbTx {
1659                        sender,
1660                        tx_gas_limit: 0,
1661                        arb_tx_type: Some(ArbTxType::ArbitrumRetryTx),
1662                        poster_gas: 0,
1663                        evm_gas_used: 0,
1664
1665                        charged_multi_gas: MultiGas::default(),
1666                        gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
1667                        stylus_data_fee: U256::ZERO,
1668                        retry_context: None,
1669                        coinbase_tip_per_gas: 0,
1670                        capped_gas_price: false,
1671                        actual_gas_price: self.arb_ctx.basefee,
1672                    });
1673                    let err_msg = format!("retryable ticket {} not found", info.ticket_id,);
1674                    return Ok(EthTxResult {
1675                        result: revm::context::result::ResultAndState {
1676                            result: ExecutionResult::Revert {
1677                                gas: synthetic_result_gas(0),
1678                                logs: Vec::new(),
1679                                output: alloy_primitives::Bytes::from(err_msg.into_bytes()),
1680                            },
1681                            state: Default::default(),
1682                        },
1683                        blob_gas_used: 0,
1684                        tx_type,
1685                    });
1686                }
1687                Err(_) => {
1688                    // State error opening retryable — endTxNow=true.
1689                    let tx_type = recovered.tx().tx_type();
1690                    self.pending_tx = Some(PendingArbTx {
1691                        sender,
1692                        tx_gas_limit: 0,
1693                        arb_tx_type: Some(ArbTxType::ArbitrumRetryTx),
1694                        poster_gas: 0,
1695                        evm_gas_used: 0,
1696
1697                        charged_multi_gas: MultiGas::default(),
1698                        gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
1699                        stylus_data_fee: U256::ZERO,
1700                        retry_context: None,
1701                        coinbase_tip_per_gas: 0,
1702                        capped_gas_price: false,
1703                        actual_gas_price: self.arb_ctx.basefee,
1704                    });
1705                    return Ok(EthTxResult {
1706                        result: revm::context::result::ResultAndState {
1707                            result: ExecutionResult::Revert {
1708                                gas: synthetic_result_gas(0),
1709                                logs: Vec::new(),
1710                                output: alloy_primitives::Bytes::from(
1711                                    format!("error opening retryable {}", info.ticket_id,)
1712                                        .into_bytes(),
1713                                ),
1714                            },
1715                            state: Default::default(),
1716                        },
1717                        blob_gas_used: 0,
1718                        tx_type,
1719                    });
1720                }
1721            }
1722        }
1723
1724        // --- Poster cost and gas limiting ---
1725
1726        let mut poster_gas = 0u64;
1727        let mut compute_hold_gas = 0u64;
1728        let calldata_units: u64 = if has_poster_costs {
1729            let level = self.arb_ctx.brotli_compression_level;
1730            let coinbase = self.arb_ctx.coinbase;
1731            let tx_ref = recovered.tx();
1732            let units = if coinbase == l1_pricing::BATCH_POSTER_ADDRESS {
1733                let tx_bytes_ref = tx_ref;
1734                tx_ref.poster_units_for(level, &mut || {
1735                    l1_pricing::poster_units_from_bytes(&tx_bytes_ref.encoded_2718(), level)
1736                })
1737            } else {
1738                0
1739            };
1740            let poster_cost = self
1741                .arb_ctx
1742                .l1_price_per_unit
1743                .saturating_mul(U256::from(units));
1744
1745            if let Some(hooks) = self.arb_hooks.as_mut() {
1746                hooks.tx_proc.poster_gas = compute_poster_gas(
1747                    poster_cost,
1748                    actual_gas_price,
1749                    false,
1750                    self.arb_ctx.min_base_fee,
1751                );
1752                hooks.tx_proc.poster_fee =
1753                    actual_gas_price.saturating_mul(U256::from(hooks.tx_proc.poster_gas));
1754                poster_gas = hooks.tx_proc.poster_gas;
1755            }
1756
1757            units
1758        } else {
1759            0
1760        };
1761
1762        // Compute hold gas: clamp gas available for EVM execution to the
1763        // per-block (< v50) or per-tx (>= v50) gas limit. Applies to ALL
1764        // non-endTxNow txs (including retry txs with poster_gas=0), as the
1765        // GasChargingHook runs for every tx that enters the EVM.
1766        if let Some(hooks) = self.arb_hooks.as_mut()
1767            && !hooks.is_eth_call
1768        {
1769            let spec = arb_chainspec::spec_id_by_arbos_version(self.arb_ctx.arbos_version);
1770            let intrinsic_estimate = estimate_intrinsic_gas(recovered.tx(), spec);
1771            let gas_after_intrinsic = tx_gas_limit.saturating_sub(intrinsic_estimate);
1772            let gas_after_poster = gas_after_intrinsic.saturating_sub(poster_gas);
1773
1774            let max_compute =
1775                if hooks.arbos_version < arb_chainspec::arbos_version::ARBOS_VERSION_50 {
1776                    hooks.per_block_gas_limit
1777                } else {
1778                    hooks.per_tx_gas_limit.saturating_sub(intrinsic_estimate)
1779                };
1780
1781            if max_compute > 0 && gas_after_poster > max_compute {
1782                compute_hold_gas = gas_after_poster - max_compute;
1783                hooks.tx_proc.compute_hold_gas = compute_hold_gas;
1784            }
1785        }
1786
1787        // ArbOS < 50: reject user txs whose compute gas exceeds block gas left,
1788        // but always allow the first user tx through (userTxsProcessed > 0).
1789        // ArbOS >= 50 uses per-tx gas limit clamping (compute_hold_gas) instead.
1790        // computeGas is clamped to at least TxGas before this check.
1791        if is_user_tx
1792            && self.arb_ctx.arbos_version < arb_chainspec::arbos_version::ARBOS_VERSION_50
1793            && self.user_txs_processed > 0
1794        {
1795            const TX_GAS: u64 = 21_000;
1796            let compute_gas = tx_gas_limit.saturating_sub(poster_gas).max(TX_GAS);
1797            if compute_gas > self.block_gas_left {
1798                return Err(BlockExecutionError::msg("block gas limit reached"));
1799            }
1800        }
1801
1802        // Add calldata units to L1 pricing state before EVM execution, and
1803        // read the filtered-tx status for the reverted_tx_hook via the same
1804        // ArbosState handle.
1805        let tx_hash_for_filter = recovered.tx().trie_hash();
1806        let is_filtered = {
1807            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1808            let arb_state = ArbosState::open(db, SystemBurner::new(None, false))
1809                .map_err(BlockExecutionError::other)?;
1810            if calldata_units > 0 {
1811                // SAFETY: see `Storage::state_mut()` invariant.
1812                let state_ref = unsafe { arb_state.backing_storage.state_mut() };
1813                let _ = arb_state
1814                    .l1_pricing_state
1815                    .add_to_units_since_update(state_ref, calldata_units);
1816            }
1817            arb_state
1818                .filtered_transactions
1819                .is_filtered_free(tx_hash_for_filter)
1820        };
1821
1822        // Reduce the gas the EVM sees by poster_gas and compute_hold_gas.
1823        // poster_gas is subtracted here so that BuyGas charges
1824        // (gas_limit - poster_gas - compute_hold_gas) * baseFee. The resulting
1825        // balance overshoots the protocol's "full gas_limit charge" BALANCE by
1826        // `poster_gas * baseFee`; the custom BALANCE opcode handler subtracts
1827        // this correction via a thread-local.
1828        let mut tx_env = tx_env;
1829        let gas_deduction = poster_gas.saturating_add(compute_hold_gas);
1830        if gas_deduction > 0 {
1831            let evm_gas_limit_before = revm::context_interface::Transaction::gas_limit(&tx_env);
1832            tx_env.set_gas_limit(evm_gas_limit_before.saturating_sub(gas_deduction));
1833        }
1834
1835        // BALANCE/SELFBALANCE correction: the reduced gas_limit above makes
1836        // BuyGas charge `(posterGas + computeHoldGas) * gasPrice` less than the
1837        // protocol requires, so the BALANCE handler subtracts this correction
1838        // whenever it queries the sender's balance.
1839        {
1840            let correction = actual_gas_price
1841                .saturating_mul(U256::from(poster_gas.saturating_add(compute_hold_gas)));
1842            let correction_u128 = correction.try_into().unwrap_or(u128::MAX);
1843            self.precompile_ctx
1844                .set_poster_balance_correction(correction_u128);
1845            // Publish the same value to the per-thread slot consulted by
1846            // `arb_balance` / `arb_selfbalance` opcode overrides — opcodes are
1847            // invoked through revm's `fn`-pointer table and cannot accept
1848            // ctx as an extra argument.
1849            crate::evm::set_poster_balance_correction(correction_u128);
1850            self.precompile_ctx.set_sender(sender);
1851        }
1852
1853        // --- RevertedTxHook: check for pre-recorded reverted or filtered txs ---
1854        // Called after gas charging but before EVM execution.
1855        {
1856            use arbos::tx_processor::RevertedTxAction;
1857
1858            let tx_hash = tx_hash_for_filter;
1859
1860            if let Some(hooks) = self.arb_hooks.as_ref() {
1861                let action = hooks.tx_proc.reverted_tx_hook(
1862                    Some(tx_hash),
1863                    None, // pre_recorded_gas: tx_proc looks up its hardcoded table
1864                    is_filtered,
1865                );
1866
1867                match action {
1868                    RevertedTxAction::PreRecordedRevert { gas_to_consume } => {
1869                        let overlay = &mut self.state_overlay;
1870                        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1871                        increment_nonce(db, overlay, sender);
1872                        self.touched_accounts.insert(sender);
1873                        // RevertedTxHook fires after intrinsic deduction; the EVM never
1874                        // runs on this path, so add intrinsic manually:
1875                        // gasUsed = intrinsic + adjustedGas + posterGas.
1876                        let spec =
1877                            arb_chainspec::spec_id_by_arbos_version(self.arb_ctx.arbos_version);
1878                        let intrinsic = estimate_intrinsic_gas(recovered.tx(), spec);
1879                        let gas_used = intrinsic
1880                            .saturating_add(gas_to_consume)
1881                            .saturating_add(poster_gas);
1882                        let charged_multi_gas = MultiGas::single_dim_gas(poster_gas)
1883                            .saturating_add(MultiGas::computation_gas(gas_to_consume));
1884                        self.pending_tx = Some(PendingArbTx {
1885                            sender,
1886                            tx_gas_limit,
1887                            arb_tx_type,
1888                            poster_gas,
1889                            evm_gas_used: 0,
1890                            charged_multi_gas,
1891                            gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
1892                            stylus_data_fee: U256::ZERO,
1893                            retry_context,
1894                            coinbase_tip_per_gas: 0,
1895                            capped_gas_price: false,
1896                            actual_gas_price: self.arb_ctx.basefee,
1897                        });
1898                        return Ok(EthTxResult {
1899                            result: revm::context::result::ResultAndState {
1900                                result: ExecutionResult::Revert {
1901                                    gas: synthetic_result_gas(gas_used),
1902                                    logs: Vec::new(),
1903                                    output: alloy_primitives::Bytes::new(),
1904                                },
1905                                state: Default::default(),
1906                            },
1907                            blob_gas_used: 0,
1908                            tx_type: envelope_tx_type,
1909                        });
1910                    }
1911                    RevertedTxAction::FilteredTx => {
1912                        let overlay = &mut self.state_overlay;
1913                        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1914                        increment_nonce(db, overlay, sender);
1915                        self.touched_accounts.insert(sender);
1916                        // Consume all remaining gas.
1917                        let gas_remaining = tx_gas_limit
1918                            .saturating_sub(poster_gas)
1919                            .saturating_sub(compute_hold_gas);
1920                        let gas_used = tx_gas_limit;
1921                        let charged_multi_gas = MultiGas::single_dim_gas(poster_gas)
1922                            .saturating_add(MultiGas::computation_gas(gas_remaining));
1923                        self.pending_tx = Some(PendingArbTx {
1924                            sender,
1925                            tx_gas_limit,
1926                            arb_tx_type,
1927                            poster_gas,
1928                            evm_gas_used: 0,
1929                            charged_multi_gas,
1930                            gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
1931                            stylus_data_fee: U256::ZERO,
1932                            retry_context,
1933                            coinbase_tip_per_gas: 0,
1934                            capped_gas_price: false,
1935                            actual_gas_price: self.arb_ctx.basefee,
1936                        });
1937                        return Ok(EthTxResult {
1938                            result: revm::context::result::ResultAndState {
1939                                result: ExecutionResult::Revert {
1940                                    gas: synthetic_result_gas(gas_used),
1941                                    logs: Vec::new(),
1942                                    output: alloy_primitives::Bytes::from(
1943                                        "filtered transaction".as_bytes(),
1944                                    ),
1945                                },
1946                                state: Default::default(),
1947                            },
1948                            blob_gas_used: 0,
1949                            tx_type: envelope_tx_type,
1950                        });
1951                    }
1952                    RevertedTxAction::None => {}
1953                }
1954            }
1955        }
1956
1957        // --- Execute via inner EVM executor ---
1958
1959        // Save the original gas price before tip drop for upfront balance check.
1960        // The balance check uses GasFeeCap (full gas price), not the
1961        // effective gas price after tip drop.
1962        let upfront_gas_price: u128 = revm::context_interface::Transaction::gas_price(&tx_env);
1963
1964        // Stash the pre-cap effective price for Stylus `tx.gasprice` before
1965        // the tip-drop cap below rewrites `tx_env.gas_price` to base_fee.
1966        {
1967            let base_fee_u128: u128 = self.arb_ctx.basefee.try_into().unwrap_or(u128::MAX);
1968            let effective: u128 =
1969                match revm::context_interface::Transaction::max_priority_fee_per_gas(&tx_env) {
1970                    Some(max_priority) => {
1971                        upfront_gas_price.min(base_fee_u128.saturating_add(max_priority))
1972                    }
1973                    None => upfront_gas_price,
1974                };
1975            self.precompile_ctx.set_effective_gas_price(effective);
1976        }
1977
1978        // Effective tip per gas (per EIP-1559): min(max_priority_fee, max_fee - base_fee).
1979        // This is what revm mints to coinbase. Used by commit_transaction to
1980        // redirect coinbase tip to network when CollectTips() is true.
1981        let effective_tip_per_gas: u128 = {
1982            let bf: u128 = self.arb_ctx.basefee.try_into().unwrap_or(u128::MAX);
1983            let max_fee: u128 = upfront_gas_price; // gas_price() returns max_fee_per_gas for EIP-1559
1984            let max_minus_bf = max_fee.saturating_sub(bf);
1985            match revm::context_interface::Transaction::max_priority_fee_per_gas(&tx_env) {
1986                Some(max_priority) => max_priority.min(max_minus_bf),
1987                None => max_minus_bf,
1988            }
1989        };
1990
1991        // Drop the priority fee tip: cap gas price to the base fee.
1992        // For ArbOS versions where CollectTips() = false (most pre-v60 + v60+
1993        // when tip-collection is disabled), capping makes GASPRICE return the
1994        // base fee. When CollectTips() = true (v9 or v60+ with the flag set),
1995        // we leave gas_price intact so GASPRICE returns the full price; revm
1996        // mints the tip to coinbase (= batch_poster) which is post-EVM
1997        // redirected to the network fee account by commit_transaction.
1998        let should_drop_tip = self
1999            .arb_hooks
2000            .as_ref()
2001            .map(|h| h.drop_tip())
2002            .unwrap_or(false);
2003        if should_drop_tip {
2004            let base_fee: u128 = self.arb_ctx.basefee.try_into().unwrap_or(u128::MAX);
2005            if upfront_gas_price > base_fee {
2006                tx_env.set_gas_price(base_fee);
2007                tx_env.set_gas_priority_fee(Some(0));
2008            }
2009        }
2010
2011        self.precompile_ctx
2012            .set_tx_is_aliased(arbos::util::does_tx_type_alias(tx_type_raw));
2013
2014        {
2015            let poster_fee_val = self
2016                .arb_hooks
2017                .as_ref()
2018                .map(|h| h.tx_proc.poster_fee)
2019                .unwrap_or(U256::ZERO);
2020            self.precompile_ctx
2021                .set_poster_fee(poster_fee_val.try_into().unwrap_or(u128::MAX));
2022            let retryable_id = retry_context
2023                .as_ref()
2024                .map(|ctx| ctx.ticket_id)
2025                .unwrap_or(B256::ZERO);
2026            self.precompile_ctx.set_retryable_id(retryable_id);
2027            let redeemer = retry_context
2028                .as_ref()
2029                .map(|ctx| ctx.refund_to)
2030                .unwrap_or(Address::ZERO);
2031            self.precompile_ctx.set_redeemer(redeemer);
2032        }
2033
2034        let retry_undo = retry_pre_exec_undo;
2035        let rollback_pre_exec_state =
2036            |this: &mut Self, units: u64| -> Result<(), BlockExecutionError> {
2037                this.precompile_ctx.reset_tx();
2038                let overlay = &mut this.state_overlay;
2039                let db: &mut State<DB> = this.inner.evm_mut().db_mut();
2040                if units > 0 {
2041                    let arb_state = ArbosState::open(db, SystemBurner::new(None, false))
2042                        .map_err(BlockExecutionError::other)?;
2043                    // SAFETY: see `Storage::state_mut()` invariant.
2044                    let state_ref = unsafe { arb_state.backing_storage.state_mut() };
2045                    let _ = arb_state
2046                        .l1_pricing_state
2047                        .subtract_from_units_since_update(state_ref, units);
2048                }
2049                if let Some((retry_sender, prepaid, escrow, escrow_value)) = retry_undo {
2050                    if !prepaid.is_zero() {
2051                        let _ = arb_util::burn_balance(&retry_sender, prepaid, |f, t, a| {
2052                            apply_balance_op(db, overlay, f, t, a)
2053                        });
2054                    }
2055                    if !escrow_value.is_zero() {
2056                        // Rollback path: best-effort return of the value we just
2057                        // transferred from escrow. If the retry tx fails its
2058                        // pre-checks, simply discarding the typed shortfall keeps
2059                        // the rollback idempotent with the historic behavior.
2060                        let _ = arb_util::transfer_balance(
2061                            Some(&retry_sender),
2062                            Some(&escrow),
2063                            escrow_value,
2064                            |f, t, a| apply_balance_op(db, overlay, f, t, a),
2065                        );
2066                    }
2067                }
2068                Ok(())
2069            };
2070
2071        // Manual balance and nonce validation for user txs. ContractTx
2072        // (0x66) and RetryTx (0x68) skip nonce checks.
2073        if is_user_tx {
2074            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2075            let account = db
2076                .load_cache_account(sender)
2077                .ok()
2078                .and_then(|a| a.account_info());
2079            let sender_balance = account.as_ref().map(|a| a.balance).unwrap_or(U256::ZERO);
2080            let sender_nonce = account.as_ref().map(|a| a.nonce).unwrap_or(0);
2081
2082            // Nonce check: ContractTx skips (skipNonceChecks=true).
2083            if !is_contract_tx {
2084                let tx_nonce = revm::context_interface::Transaction::nonce(&tx_env);
2085                if tx_nonce != sender_nonce {
2086                    rollback_pre_exec_state(self, calldata_units)?;
2087                    return Err(BlockExecutionError::msg(format!(
2088                        "nonce mismatch: address {sender} tx nonce {tx_nonce} != state nonce {sender_nonce}"
2089                    )));
2090                }
2091            }
2092
2093            // Base fee check. cfg.disable_base_fee is set chain-wide so revm
2094            // skips London's preCheck for every tx. The Go preCheck (state_transition.go
2095            // preCheck) only skips the basefee comparison when NoBaseFee is on AND
2096            // both GasFeeCap and GasTipCap are zero — i.e. tx types with no fee
2097            // intent (ArbitrumDepositTx, ArbitrumInternalTx). Every other user tx,
2098            // including ArbitrumUnsignedTx (0x65), ArbitrumContractTx (0x66),
2099            // ArbitrumRetryTx (0x68), and ArbitrumSubmitRetryableTx (0x69), has a
2100            // non-zero GasFeeCap and gets the check applied. is_user_tx already
2101            // excludes deposit and internal, and retry/submit-retryable are not
2102            // user txs in this branch.
2103            let base_fee = self.arb_ctx.basefee;
2104            if U256::from(upfront_gas_price) < base_fee {
2105                rollback_pre_exec_state(self, calldata_units)?;
2106                return Err(BlockExecutionError::msg(format!(
2107                    "max fee per gas less than block base fee: address {sender}, maxFeePerGas: {upfront_gas_price}, baseFee: {base_fee}"
2108                )));
2109            }
2110
2111            let gas_cost = U256::from(tx_gas_limit) * U256::from(upfront_gas_price);
2112            let tx_value = revm::context_interface::Transaction::value(&tx_env);
2113            let total_cost = gas_cost.saturating_add(tx_value);
2114            if sender_balance < total_cost {
2115                rollback_pre_exec_state(self, calldata_units)?;
2116                return Err(BlockExecutionError::msg(format!(
2117                    "insufficient funds: address {sender} have {sender_balance} want {total_cost}"
2118                )));
2119            }
2120
2121            if calldata_floor_gas > tx_gas_limit {
2122                rollback_pre_exec_state(self, calldata_units)?;
2123                return Err(BlockExecutionError::msg(format!(
2124                    "insufficient gas for floor data gas: address {sender} gas limit {tx_gas_limit} floor {calldata_floor_gas}"
2125                )));
2126            }
2127        }
2128
2129        // Fix nonce for retry and contract txs: skipNonceChecks() skips
2130        // the preCheck nonce validation but the nonce is still incremented in
2131        // TransitionDb for non-CREATE calls. Override the tx_env nonce to
2132        // match the sender's current state nonce so revm increments from the
2133        // right value.
2134        if is_retry_tx || is_contract_tx {
2135            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2136            let sender_nonce = db
2137                .load_cache_account(sender)
2138                .map(|a| a.account_info().map(|i| i.nonce).unwrap_or(0))
2139                .unwrap_or(0);
2140            tx_env.set_nonce(sender_nonce);
2141        }
2142
2143        {
2144            let to_addr = match recovered.tx().kind() {
2145                TxKind::Call(a) => Some(a),
2146                _ => None,
2147            };
2148            if to_addr == Some(arb_precompiles::ARBWASM_ADDRESS) {
2149                self.precompile_ctx.set_stylus_call_value(tx_value);
2150                if tx_value > U256::ZERO {
2151                    tx_env.set_value(U256::ZERO);
2152                }
2153            } else {
2154                self.precompile_ctx.set_stylus_call_value(U256::ZERO);
2155            }
2156        }
2157
2158        let mut output = match self
2159            .inner
2160            .execute_transaction_without_commit((tx_env, recovered))
2161        {
2162            Ok(o) => o,
2163            Err(e) => {
2164                rollback_pre_exec_state(self, calldata_units)?;
2165                return Err(e);
2166            }
2167        };
2168
2169        // Capture gas_used as reported by reth's EVM (before our adjustments).
2170        // This represents the gas cost reth already deducted from the sender.
2171        let evm_gas_used = output.result.result.gas_used();
2172        // The EIP-3529 gas refund reduces the single-dimensional gas the sender
2173        // pays, but the per-resource multi-gas tracks the raw (pre-refund) usage
2174        // — the refund applies only to the single-gas pool, not the resource
2175        // dimensions. Carry it so the multi-gas can be reconstituted raw for the
2176        // v60 refund and backlog, which reconcile against pre-refund usage.
2177        let gas_refunded = match &output.result.result {
2178            ExecutionResult::Success { gas, .. } => gas.inner_refunded(),
2179            _ => 0,
2180        };
2181
2182        // Adjust gas_used to include poster_gas only.
2183        // poster_gas was deducted from gas_limit before EVM execution so reth's
2184        // reported gas_used doesn't include it. Adding it back produces correct
2185        // receipt gas_used. compute_hold_gas is NOT added: it is returned via
2186        // calcHeldGasRefund() before computing final gasUsed, and
2187        // NonRefundableGas() excludes it from the refund denominator.
2188        if poster_gas > 0 {
2189            adjust_result_gas_used(&mut output.result.result, poster_gas);
2190        }
2191
2192        // Scan execution logs for RedeemScheduled events (manual redeem path).
2193        // The ArbRetryableTx.Redeem precompile emits this event; we discover it
2194        // here and schedule the retry tx via the ScheduledTxes() mechanism.
2195        //
2196        // The precompile emits a placeholder retry-tx hash (keccak256(ticket_id||nonce)).
2197        // Replace it with the real EIP-2718 encoded tx hash.
2198        let mut total_donated_gas = 0u64;
2199        // Collect (log_index, correct_hash) for patching logs before commit.
2200        let mut retry_tx_hash_fixes: Vec<(usize, B256)> = Vec::new();
2201        if let ExecutionResult::Success { ref logs, .. } = output.result.result {
2202            let redeem_topic = arb_precompiles::redeem_scheduled_topic();
2203            let precompile_addr = arb_precompiles::ARBRETRYABLETX_ADDRESS;
2204
2205            for (log_idx, log) in logs.iter().enumerate() {
2206                if log.address != precompile_addr {
2207                    continue;
2208                }
2209                if log.topics().is_empty() || log.topics()[0] != redeem_topic {
2210                    continue;
2211                }
2212                if log.topics().len() < 4 || log.data.data.len() < 128 {
2213                    continue;
2214                }
2215
2216                let ticket_id = log.topics()[1];
2217                let seq_num_bytes = log.topics()[3];
2218                let nonce =
2219                    u64::from_be_bytes(seq_num_bytes.0[24..32].try_into().unwrap_or([0u8; 8]));
2220                let data = &log.data.data;
2221                let donated_gas = U256::from_be_slice(&data[0..32]).to::<u64>();
2222                total_donated_gas = total_donated_gas.saturating_add(donated_gas);
2223                let gas_donor = Address::from_slice(&data[44..64]);
2224                let max_refund = U256::from_be_slice(&data[64..96]);
2225                let submission_fee_refund = U256::from_be_slice(&data[96..128]);
2226
2227                // Open the retryable and construct the retry tx. Scoped so
2228                // `arb_state` is dropped before we re-borrow `self` to push the
2229                // scheduled tx into `arb_hooks`.
2230                let (encoded_retry_tx, latest_backlog) = {
2231                    let current_time = {
2232                        let block = self.inner.evm().block();
2233                        revm::context::Block::timestamp(block).to::<u64>()
2234                    };
2235                    let chain_id = self.arb_ctx.chain_id;
2236                    let basefee = self.arb_ctx.basefee;
2237                    let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2238                    let arb_state = ArbosState::open(db, SystemBurner::new(None, false))
2239                        .map_err(BlockExecutionError::other)?;
2240                    // SAFETY: see `Storage::state_mut()` invariant.
2241                    let state_ref = unsafe { arb_state.backing_storage.state_mut() };
2242
2243                    let mut encoded_retry_tx = None;
2244                    if let Ok(Some(retryable)) =
2245                        arb_state
2246                            .retryable_state
2247                            .open_retryable(state_ref, ticket_id, current_time)
2248                    {
2249                        let _ = retryable.increment_num_tries(state_ref);
2250
2251                        if let Ok(retry_tx) = retryable.make_tx(
2252                            state_ref,
2253                            U256::from(chain_id),
2254                            nonce,
2255                            basefee,
2256                            donated_gas,
2257                            ticket_id,
2258                            gas_donor,
2259                            max_refund,
2260                            submission_fee_refund,
2261                        ) {
2262                            let mut encoded = Vec::new();
2263                            encoded.push(ArbTxType::ArbitrumRetryTx.as_u8());
2264                            alloy_rlp::Encodable::encode(&retry_tx, &mut encoded);
2265                            let correct_hash = keccak256(&encoded);
2266                            retry_tx_hash_fixes.push((log_idx, correct_hash));
2267                            encoded_retry_tx = Some(encoded);
2268                        }
2269                    }
2270
2271                    let _ = arb_state.l2_pricing_state.shrink_backlog(
2272                        state_ref,
2273                        donated_gas,
2274                        MultiGas::default(),
2275                    );
2276                    let backlog = arb_state.l2_pricing_state.gas_backlog(state_ref).ok();
2277                    (encoded_retry_tx, backlog)
2278                };
2279
2280                if let Some(encoded) = encoded_retry_tx
2281                    && let Some(hooks) = self.arb_hooks.as_mut()
2282                {
2283                    hooks.tx_proc.scheduled_txs.push(encoded);
2284                }
2285                if let Some(b) = latest_backlog {
2286                    self.precompile_ctx.block.set_current_gas_backlog(b);
2287                }
2288            }
2289        }
2290
2291        // Patch RedeemScheduled event logs with the correct retry tx hash.
2292        // The precompile emits a placeholder; we replace topic[2] with the
2293        // actual EIP-2718 encoded tx hash computed from the constructed retry tx.
2294        if !retry_tx_hash_fixes.is_empty()
2295            && let ExecutionResult::Success { ref mut logs, .. } = output.result.result
2296        {
2297            for (log_idx, correct_hash) in &retry_tx_hash_fixes {
2298                if let Some(log) = logs.get_mut(*log_idx)
2299                    && log.data.topics().len() > 2
2300                {
2301                    let topics = log.data.topics_mut_unchecked();
2302                    topics[2] = *correct_hash;
2303                }
2304            }
2305        }
2306
2307        // Handle Stylus activation/keepalive data fee payment post-commit.
2308        // We zero out tx_env.value before EVM execution (below) so revm
2309        // doesn't transfer value to the precompile. The data_fee transfer
2310        // from sender to network happens via the cache after commit.
2311        let stylus_data_fee = if self.precompile_ctx.take_stylus_activation_addr().is_some()
2312            || self.precompile_ctx.take_stylus_keepalive_hash().is_some()
2313        {
2314            self.precompile_ctx.take_stylus_activation_data_fee()
2315        } else {
2316            U256::ZERO
2317        };
2318
2319        // EVM opcode gas comes from the inspector, Stylus host gas and
2320        // dimensioned precompile gas from the per-tx accumulators; each carries
2321        // its own dimensions. The intrinsic is added here. Whatever the
2322        // dimensioned amounts don't cover is folded into computation as the
2323        // remainder, so the split totals evm_gas_used. Poster gas is separate.
2324        let stylus_multi_gas = self.precompile_ctx.stylus_multi_gas();
2325        let precompile_multi_gas = self.precompile_ctx.precompile_multi_gas();
2326        let dimensioned = stylus_multi_gas.saturating_add(precompile_multi_gas);
2327        // Target the raw (pre-EIP-3529-refund) gas. The reference tracks
2328        // per-resource usage before applying the single-gas refund, so the
2329        // multi-gas total reconciles to `evm_gas_used + gas_refunded`. The
2330        // inspector already reports raw per-opcode gas (the refund only adjusts
2331        // the single-gas pool), so the computation remainder fills the gap on
2332        // the no-inspector path without double-counting on the inspector path.
2333        let raw_gas_used = evm_gas_used.saturating_add(gas_refunded);
2334        // Gas burned by a Stylus frame that aborts at the upfront-cost gate is
2335        // spent but belongs to no resource dimension; exclude it from the total
2336        // the split must reach so it is not folded into computation.
2337        let stylus_upfront_oog_gas = self.precompile_ctx.stylus_upfront_oog_gas();
2338        let dimensionable_gas = raw_gas_used.saturating_sub(stylus_upfront_oog_gas);
2339        let execution_multi_gas = match self.multi_gas_sink.lock().take() {
2340            Some(opcode_gas) => {
2341                let observed = intrinsic_multi_gas
2342                    .saturating_add(opcode_gas)
2343                    .saturating_add(dimensioned);
2344                let remainder = dimensionable_gas.saturating_sub(observed.single_gas());
2345                observed.saturating_add(MultiGas::computation_gas(remainder))
2346            }
2347            None => {
2348                let remainder = dimensionable_gas.saturating_sub(dimensioned.single_gas());
2349                dimensioned.saturating_add(MultiGas::computation_gas(remainder))
2350            }
2351        };
2352        // The per-dimension split must total the raw pre-refund gas. Over-
2353        // attribution (more single-gas than the tx actually used) would inflate
2354        // the multi-dimensional cost and corrupt the v60 refund.
2355        debug_assert_eq!(
2356            execution_multi_gas.single_gas(),
2357            dimensionable_gas,
2358            "multi-gas split must total the dimensionable gas",
2359        );
2360        let mut charged_multi_gas =
2361            MultiGas::single_dim_gas(poster_gas).saturating_add(execution_multi_gas);
2362
2363        // EIP-7623: a data-heavy tx pays the calldata floor. The receipt gas is
2364        // raised to the floor and the top-up is priced as L2 calldata, keeping
2365        // charged_multi_gas.single_gas() == gas_used (so the v60 refund stays
2366        // exact). The sender pays the floor via the existing sender_extra_gas.
2367        let gas_before_floor = output.result.result.gas_used();
2368        if calldata_floor_gas > gas_before_floor {
2369            let receipt_top_up = calldata_floor_gas - gas_before_floor;
2370            adjust_result_gas_used(&mut output.result.result, receipt_top_up);
2371            let dim_single = charged_multi_gas.single_gas();
2372            if calldata_floor_gas > dim_single {
2373                let dim_top_up = calldata_floor_gas - dim_single;
2374                charged_multi_gas =
2375                    charged_multi_gas.saturating_add(MultiGas::l2_calldata_gas(dim_top_up));
2376            }
2377        }
2378
2379        // Capture effective tip per gas (gas_price - base_fee, clamped >= 0).
2380        // The effective tip per gas captured before EVM execution. Used by
2381        // commit_transaction to redirect coinbase's tip mint to network.
2382        let coinbase_tip_per_gas: u128 = effective_tip_per_gas;
2383        let capped_gas_price = should_drop_tip;
2384
2385        self.pending_tx = Some(PendingArbTx {
2386            sender,
2387            tx_gas_limit,
2388            arb_tx_type,
2389            poster_gas,
2390            evm_gas_used,
2391            charged_multi_gas,
2392            gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
2393            stylus_data_fee,
2394            retry_context,
2395            coinbase_tip_per_gas,
2396            capped_gas_price,
2397            actual_gas_price,
2398        });
2399
2400        Ok(output)
2401    }
2402
2403    fn commit_transaction(&mut self, output: Self::Result) -> Result<u64, BlockExecutionError> {
2404        // Extract info needed for fee distribution before the output is consumed.
2405        let pending = self.pending_tx.take();
2406        let gas_used_total = output.result.result.gas_used();
2407        let success = matches!(&output.result.result, ExecutionResult::Success { .. });
2408
2409        // Scan receipt logs for L2→L1 withdrawal events and burn value from ArbSys.
2410        // Value transferred to the ArbSys address during a withdrawEth call
2411        // is burned (subtracted from ArbSys balance) after the tx commits.
2412        let mut withdrawal_value = U256::ZERO;
2413        if let ExecutionResult::Success { ref logs, .. } = output.result.result {
2414            let arbsys_addr = arb_precompiles::ARBSYS_ADDRESS;
2415            let l2_to_l1_tx_topic = keccak256(
2416                b"L2ToL1Tx(address,address,uint256,uint256,uint256,uint256,uint256,uint256,bytes)",
2417            );
2418            for log in logs {
2419                if log.address == arbsys_addr
2420                    && !log.data.topics().is_empty()
2421                    && log.data.topics()[0] == l2_to_l1_tx_topic
2422                {
2423                    // L2ToL1Tx data layout: ABI-encoded [caller, arb_block, eth_block, timestamp,
2424                    // callvalue, data] callvalue is at offset 4*32 = 128 bytes.
2425                    if log.data.data.len() >= 160 {
2426                        let callvalue = U256::from_be_slice(&log.data.data[128..160]);
2427                        withdrawal_value = withdrawal_value.saturating_add(callvalue);
2428                        let val_i128: i128 = callvalue.try_into().unwrap_or(i128::MAX);
2429                        self.expected_balance_delta =
2430                            self.expected_balance_delta.saturating_sub(val_i128);
2431                    }
2432                }
2433            }
2434        }
2435
2436        for addr in output.result.state.keys() {
2437            self.touched_accounts.insert(*addr);
2438        }
2439
2440        let gas_used = self.inner.commit_transaction(output)?;
2441
2442        // An owner setter flags a per-tx state-parameter change; refresh the
2443        // cached values so it takes effect within the block, including the
2444        // setting transaction's own subsequent accounting.
2445        if self
2446            .precompile_ctx
2447            .block
2448            .state_params_dirty
2449            .swap(false, std::sync::atomic::Ordering::Relaxed)
2450        {
2451            self.refresh_state_params();
2452        }
2453
2454        // Redirect the coinbase tip to network_fee_account when
2455        // CollectTips is on. tx_env.gas_limit is shrunk by poster_gas before
2456        // revm, so revm only minted `tip * compute_gas` to coinbase — that's
2457        // the amount to transfer. tip × posterGas is burned implicitly.
2458        if let Some(ref p) = pending
2459            && !p.capped_gas_price
2460            && p.coinbase_tip_per_gas > 0
2461            && gas_used > 0
2462        {
2463            let coinbase = self.arb_ctx.coinbase;
2464            let net_acct = self.arb_ctx.network_fee_account;
2465            let compute_gas = gas_used.saturating_sub(p.poster_gas);
2466            let tip_to_network =
2467                U256::from(p.coinbase_tip_per_gas).saturating_mul(U256::from(compute_gas));
2468            if coinbase != net_acct && !tip_to_network.is_zero() {
2469                let overlay = &mut self.state_overlay;
2470                let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2471                if get_balance(db, coinbase) >= tip_to_network {
2472                    let _ = arb_util::transfer_balance(
2473                        Some(&coinbase),
2474                        Some(&net_acct),
2475                        tip_to_network,
2476                        |f, t, a| apply_balance_op(db, overlay, f, t, a),
2477                    );
2478                    self.touched_accounts.insert(coinbase);
2479                    self.touched_accounts.insert(net_acct);
2480                }
2481            }
2482        }
2483
2484        // Stylus activation data fee: sender → network (via cache, post-commit).
2485        // Value was zeroed in tx_env so sender still has the ETH.
2486        if let Some(ref p) = pending
2487            && !p.stylus_data_fee.is_zero()
2488        {
2489            let overlay = &mut self.state_overlay;
2490            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2491            let _ = arb_util::burn_balance(&p.sender, p.stylus_data_fee, |f, t, a| {
2492                apply_balance_op(db, overlay, f, t, a)
2493            });
2494            let _ = arb_util::mint_balance(
2495                &self.arb_ctx.network_fee_account,
2496                p.stylus_data_fee,
2497                |f, t, a| apply_balance_op(db, overlay, f, t, a),
2498            );
2499            self.touched_accounts.insert(p.sender);
2500            self.touched_accounts
2501                .insert(self.arb_ctx.network_fee_account);
2502        }
2503
2504        // Cancelled-retryable escrow sweep: move the ticket's escrow balance to
2505        // its beneficiary in the same block, through the cache and overlay so it
2506        // forms a single state transition.
2507        if let Some((escrow, beneficiary)) = self.precompile_ctx.take_cancel_escrow_sweep() {
2508            let arbos_ver = self.arb_ctx.arbos_version;
2509            let touched_ptr = &mut self.touched_accounts as *mut rustc_hash::FxHashSet<Address>;
2510            let zombie_ptr = &mut self.zombie_accounts as *mut rustc_hash::FxHashSet<Address>;
2511            let finalise_ptr = &self.finalise_deleted as *const rustc_hash::FxHashSet<Address>;
2512            let overlay_ptr = &mut self.state_overlay as *mut StateOverlay;
2513            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2514            let amount = get_balance(db, escrow);
2515
2516            // SAFETY: see `Storage::state_mut()` invariant. The pointers reborrow
2517            // disjoint fields of `self` (`touched_accounts`, `zombie_accounts`,
2518            // `finalise_deleted`, `state_overlay`); `db` borrows `self.inner`. No
2519            // two of these alias within this block.
2520            unsafe {
2521                if amount.is_zero()
2522                    && arbos_ver < arb_chainspec::arbos_version::ARBOS_VERSION_STYLUS
2523                {
2524                    create_zombie_if_deleted(
2525                        db,
2526                        &mut *overlay_ptr,
2527                        escrow,
2528                        &*finalise_ptr,
2529                        &mut *zombie_ptr,
2530                        &mut *touched_ptr,
2531                    );
2532                }
2533                let _ = apply_balance_op(
2534                    db,
2535                    &mut *overlay_ptr,
2536                    Some(&escrow),
2537                    Some(&beneficiary),
2538                    amount,
2539                );
2540                if !amount.is_zero() {
2541                    (*zombie_ptr).remove(&escrow);
2542                }
2543                (*zombie_ptr).remove(&beneficiary);
2544                (*touched_ptr).insert(escrow);
2545                (*touched_ptr).insert(beneficiary);
2546            }
2547        }
2548
2549        // Burn ETH from ArbSys address for L2→L1 withdrawals.
2550        if !withdrawal_value.is_zero() {
2551            let overlay = &mut self.state_overlay;
2552            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2553            let _ = arb_util::burn_balance(
2554                &arb_precompiles::ARBSYS_ADDRESS,
2555                withdrawal_value,
2556                |f, t, a| apply_balance_op(db, overlay, f, t, a),
2557            );
2558            self.touched_accounts
2559                .insert(arb_precompiles::ARBSYS_ADDRESS);
2560        }
2561
2562        // Track poster gas and multi-gas for this receipt (parallel to receipts vector).
2563        let poster_gas_for_receipt = pending.as_ref().map_or(0, |p| p.poster_gas);
2564        self.gas_used_for_l1.push(poster_gas_for_receipt);
2565        let multi_gas_for_receipt = pending
2566            .as_ref()
2567            .map_or(MultiGas::zero(), |p| p.charged_multi_gas);
2568        self.multi_gas_used.push(multi_gas_for_receipt);
2569
2570        // --- Post-execution: fee distribution ---
2571        if let Some(pending) = pending {
2572            let is_retry = pending.retry_context.is_some();
2573
2574            // Safety check: gas refund should never exceed gas limit.
2575            debug_assert!(
2576                gas_used_total <= pending.tx_gas_limit,
2577                "gas_used ({gas_used_total}) exceeds gas_limit ({})",
2578                pending.tx_gas_limit
2579            );
2580
2581            // Charge the sender for gas reth's buyGas didn't cover: poster_gas
2582            // on normal txs, full gas_used on early-return paths. Priced at
2583            // actual_gas_price so `tip * posterGas` gets burned here (revm
2584            // never minted it to coinbase, since we shrunk gas_limit first).
2585            let sender_extra_gas = gas_used_total.saturating_sub(pending.evm_gas_used);
2586            if sender_extra_gas > 0 {
2587                let extra_cost = pending
2588                    .actual_gas_price
2589                    .saturating_mul(U256::from(sender_extra_gas));
2590                let overlay = &mut self.state_overlay;
2591                let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2592                let _ = arb_util::burn_balance(&pending.sender, extra_cost, |f, t, a| {
2593                    apply_balance_op(db, overlay, f, t, a)
2594                });
2595                self.touched_accounts.insert(pending.sender);
2596            }
2597
2598            if let Some(retry_ctx) = pending.retry_context {
2599                // RetryTx end-of-tx: handle gas refunds, retryable cleanup.
2600                let gas_left = pending.tx_gas_limit.saturating_sub(gas_used_total);
2601
2602                let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2603                let touched_ptr = &mut self.touched_accounts as *mut rustc_hash::FxHashSet<Address>;
2604                let zombie_ptr = &mut self.zombie_accounts as *mut rustc_hash::FxHashSet<Address>;
2605                let finalise_ptr = &self.finalise_deleted as *const rustc_hash::FxHashSet<Address>;
2606                let overlay_ptr = &mut self.state_overlay as *mut StateOverlay;
2607                let arbos_ver = self.arb_ctx.arbos_version;
2608
2609                let arb_state_retry = ArbosState::open(db, SystemBurner::new(None, false))
2610                    .map_err(BlockExecutionError::other)?;
2611                // SAFETY: see `Storage::state_mut()` invariant. The cloned
2612                // storage handles below let the closures re-materialise the
2613                // state borrow on demand without holding a long-lived `&mut`.
2614                let burn_storage = arb_state_retry.backing_storage.clone();
2615                let transfer_storage = arb_state_retry.backing_storage.clone();
2616                let delete_transfer_storage = arb_state_retry.backing_storage.clone();
2617                let delete_balance_storage = arb_state_retry.backing_storage.clone();
2618                let escrow_storage = arb_state_retry.backing_storage.clone();
2619
2620                // Compute multi-dimensional cost for refund (ArbOS v60+).
2621                let multi_dimensional_cost = if self.arb_ctx.arbos_version
2622                    >= arb_chainspec::arbos_version::ARBOS_VERSION_MULTI_GAS_CONSTRAINTS
2623                {
2624                    let cached = self.multi_gas_current_fees.get_or_init(|| {
2625                        // SAFETY: see `Storage::state_mut()` invariant.
2626                        let state_ref = unsafe { arb_state_retry.backing_storage.state_mut() };
2627                        arb_state_retry
2628                            .l2_pricing_state
2629                            .get_current_multi_gas_fees(state_ref)
2630                            .unwrap_or([U256::ZERO; NUM_RESOURCE_KIND])
2631                    });
2632                    // SAFETY: see `Storage::state_mut()` invariant.
2633                    let state_ref = unsafe { arb_state_retry.backing_storage.state_mut() };
2634                    arb_state_retry
2635                        .l2_pricing_state
2636                        .multi_dimensional_price_for_refund_with_fees(
2637                            state_ref,
2638                            pending.charged_multi_gas,
2639                            cached,
2640                        )
2641                        .ok()
2642                } else {
2643                    None
2644                };
2645
2646                let result = self.arb_hooks.as_ref().map(|hooks| {
2647                    hooks.tx_proc.end_tx_retryable(
2648                        &EndTxRetryableParams {
2649                            gas_left,
2650                            gas_used: gas_used_total,
2651                            effective_base_fee: self.arb_ctx.basefee,
2652                            from: pending.sender,
2653                            refund_to: retry_ctx.refund_to,
2654                            max_refund: retry_ctx.max_refund,
2655                            submission_fee_refund: retry_ctx.submission_fee_refund,
2656                            ticket_id: retry_ctx.ticket_id,
2657                            value: U256::ZERO, // Already transferred in pre-exec
2658                            success,
2659                            network_fee_account: self.arb_ctx.network_fee_account,
2660                            infra_fee_account: self.arb_ctx.infra_fee_account,
2661                            min_base_fee: self.arb_ctx.min_base_fee,
2662                            arbos_version: self.arb_ctx.arbos_version,
2663                            multi_dimensional_cost,
2664                            block_base_fee: self.arb_ctx.basefee,
2665                        },
2666                        |addr, amount| {
2667                            // SAFETY: see `Storage::state_mut()` invariant.
2668                            unsafe {
2669                                apply_burn_to_state(
2670                                    burn_storage.state_mut(),
2671                                    &mut *overlay_ptr,
2672                                    addr,
2673                                    amount,
2674                                );
2675                                (*touched_ptr).insert(addr);
2676                            }
2677                        },
2678                        |from, to, amount| {
2679                            // SAFETY: see `Storage::state_mut()` invariant.
2680                            unsafe {
2681                                let state = transfer_storage.state_mut();
2682                                if amount.is_zero()
2683                                    && arbos_ver
2684                                        < arb_chainspec::arbos_version::ARBOS_VERSION_STYLUS
2685                                {
2686                                    create_zombie_if_deleted(
2687                                        state,
2688                                        &mut *overlay_ptr,
2689                                        from,
2690                                        &*finalise_ptr,
2691                                        &mut *zombie_ptr,
2692                                        &mut *touched_ptr,
2693                                    );
2694                                }
2695                                // end_tx_retryable distributes refunds via refund_with_pool,
2696                                // which already discards typed errors. Mirror that pattern
2697                                // here so a hypothetical shortfall does not surface as Err
2698                                // and short-circuit downstream bookkeeping.
2699                                let _ = apply_balance_op(
2700                                    state,
2701                                    &mut *overlay_ptr,
2702                                    Some(&from),
2703                                    Some(&to),
2704                                    amount,
2705                                );
2706                                // Go's SubBalance(from, nonzero) creates a non-zombie
2707                                // balanceChange entry, breaking zombie protection.
2708                                if !amount.is_zero() {
2709                                    (*zombie_ptr).remove(&from);
2710                                }
2711                                // Go's AddBalance(to, _) dirts `to`, breaking zombie.
2712                                (*zombie_ptr).remove(&to);
2713                                (*touched_ptr).insert(from);
2714                                (*touched_ptr).insert(to);
2715                            }
2716                            Ok(())
2717                        },
2718                    )
2719                });
2720
2721                if let Some(ref result) = result {
2722                    if result.should_delete_retryable {
2723                        // SAFETY: see `Storage::state_mut()` invariant.
2724                        let state_ref = unsafe { arb_state_retry.backing_storage.state_mut() };
2725                        let _ = arb_state_retry.retryable_state.delete_retryable(
2726                            state_ref,
2727                            retry_ctx.ticket_id,
2728                            |from, to, amount| {
2729                                // SAFETY: see `Storage::state_mut()` invariant.
2730                                unsafe {
2731                                    let state = delete_transfer_storage.state_mut();
2732                                    if amount.is_zero()
2733                                        && arbos_ver
2734                                            < arb_chainspec::arbos_version::ARBOS_VERSION_STYLUS
2735                                    {
2736                                        create_zombie_if_deleted(
2737                                            state,
2738                                            &mut *overlay_ptr,
2739                                            from,
2740                                            &*finalise_ptr,
2741                                            &mut *zombie_ptr,
2742                                            &mut *touched_ptr,
2743                                        );
2744                                    }
2745                                    // delete_retryable propagates this closure's error
2746                                    // via `?` and would skip clearing ticket fields on
2747                                    // shortfall. The escrow holds the retryable's full
2748                                    // callvalue by construction, so this never errors in
2749                                    // practice; swallow the typed error to preserve the
2750                                    // historic "always-clear" behavior.
2751                                    let _ = apply_balance_op(
2752                                        state,
2753                                        &mut *overlay_ptr,
2754                                        Some(&from),
2755                                        Some(&to),
2756                                        amount,
2757                                    );
2758                                    if !amount.is_zero() {
2759                                        (*zombie_ptr).remove(&from);
2760                                    }
2761                                    (*zombie_ptr).remove(&to);
2762                                    (*touched_ptr).insert(from);
2763                                    (*touched_ptr).insert(to);
2764                                }
2765                                Ok(())
2766                            },
2767                            |addr| {
2768                                // SAFETY: see `Storage::state_mut()` invariant.
2769                                unsafe { get_balance(delete_balance_storage.state_mut(), addr) }
2770                            },
2771                        );
2772                    } else if result.should_return_value_to_escrow {
2773                        // Failed retry: return call value to escrow.
2774                        // SAFETY: see `Storage::state_mut()` invariant.
2775                        unsafe {
2776                            let state = escrow_storage.state_mut();
2777                            if retry_ctx.call_value.is_zero()
2778                                && arbos_ver < arb_chainspec::arbos_version::ARBOS_VERSION_STYLUS
2779                            {
2780                                create_zombie_if_deleted(
2781                                    state,
2782                                    &mut *overlay_ptr,
2783                                    pending.sender,
2784                                    &*finalise_ptr,
2785                                    &mut *zombie_ptr,
2786                                    &mut *touched_ptr,
2787                                );
2788                            }
2789                            let _ = arb_util::transfer_balance(
2790                                Some(&pending.sender),
2791                                Some(&result.escrow_address),
2792                                retry_ctx.call_value,
2793                                |f, t, a| {
2794                                    apply_balance_op(
2795                                        escrow_storage.state_mut(),
2796                                        &mut *overlay_ptr,
2797                                        f,
2798                                        t,
2799                                        a,
2800                                    )
2801                                },
2802                            );
2803                            // Go's SubBalance(sender, nonzero) breaks zombie on sender.
2804                            if !retry_ctx.call_value.is_zero() {
2805                                (*zombie_ptr).remove(&pending.sender);
2806                            }
2807                            // Go's AddBalance(escrow, _) breaks zombie on escrow.
2808                            (*zombie_ptr).remove(&result.escrow_address);
2809                            (*touched_ptr).insert(pending.sender);
2810                            (*touched_ptr).insert(result.escrow_address);
2811                        }
2812                    }
2813
2814                    // SAFETY: see `Storage::state_mut()` invariant.
2815                    let state_ref = unsafe { arb_state_retry.backing_storage.state_mut() };
2816                    let _ = arb_state_retry.l2_pricing_state.grow_backlog(
2817                        state_ref,
2818                        result.compute_gas_for_backlog,
2819                        pending.charged_multi_gas,
2820                    );
2821                    if let Ok(b) = arb_state_retry.l2_pricing_state.gas_backlog(state_ref) {
2822                        self.precompile_ctx.block.set_current_gas_backlog(b);
2823                    }
2824                }
2825            } else if matches!(
2826                pending.arb_tx_type,
2827                None | Some(ArbTxType::ArbitrumLegacyTx)
2828                    | Some(ArbTxType::ArbitrumUnsignedTx)
2829                    | Some(ArbTxType::ArbitrumContractTx)
2830            ) {
2831                // Normal tx fee distribution: standard EOA-signed txs, plus
2832                // UnsignedTx/ContractTx (L1->L2 messages that pass through normal
2833                // EVM gas charging). Poster cost is zero for the latter two.
2834                let gas_left = pending.tx_gas_limit.saturating_sub(gas_used_total);
2835
2836                let fee_dist = self.arb_hooks.as_ref().map(|hooks| {
2837                    hooks.compute_end_tx_fees(&EndTxContext {
2838                        sender: pending.sender,
2839                        gas_left,
2840                        gas_used: gas_used_total,
2841                        gas_price: self.arb_ctx.basefee,
2842                        base_fee: self.arb_ctx.basefee,
2843                        tx_type: pending.arb_tx_type.unwrap_or(ArbTxType::ArbitrumLegacyTx),
2844                        success,
2845                        refund_to: pending.sender,
2846                    })
2847                });
2848
2849                if let Some(ref dist) = fee_dist {
2850                    {
2851                        let overlay = &mut self.state_overlay;
2852                        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2853                        apply_fee_distribution(db, overlay, dist, None);
2854                    }
2855                    // Skip the network-fee touch when compute cost is 0
2856                    // (avoids a no-op EIP-161 touch).
2857                    if !dist.network_fee_amount.is_zero() {
2858                        self.touched_accounts.insert(dist.network_fee_account);
2859                    }
2860                    self.touched_accounts.insert(dist.infra_fee_account);
2861                    self.touched_accounts.insert(dist.poster_fee_destination);
2862
2863                    let arbos_version_active = self.arb_ctx.arbos_version;
2864                    let basefee_active = self.arb_ctx.basefee;
2865                    let charged_multi_gas = pending.charged_multi_gas;
2866                    let poster_gas_active = pending.poster_gas;
2867                    let gas_price_positive_active = pending.gas_price_positive;
2868
2869                    let (refund_done, new_backlog) = {
2870                        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2871                        let arb_state_post = ArbosState::open(db, SystemBurner::new(None, false))
2872                            .map_err(BlockExecutionError::other)?;
2873                        // SAFETY: see `Storage::state_mut()` invariant. Cloned
2874                        // so the inner `transfer_balance` closure can
2875                        // re-materialise the state borrow alongside the outer
2876                        // accessor calls.
2877                        let refund_storage = arb_state_post.backing_storage.clone();
2878                        let overlay_ptr = &mut self.state_overlay as *mut StateOverlay;
2879
2880                        let mut refund_done = false;
2881                        if arbos_version_active
2882                            >= arb_chainspec::arbos_version::ARBOS_VERSION_MULTI_GAS_CONSTRAINTS
2883                        {
2884                            let total_cost =
2885                                basefee_active.saturating_mul(U256::from(gas_used_total));
2886                            let cached = self.multi_gas_current_fees.get_or_init(|| {
2887                                // SAFETY: see `Storage::state_mut()` invariant.
2888                                let state_ref =
2889                                    unsafe { arb_state_post.backing_storage.state_mut() };
2890                                arb_state_post
2891                                    .l2_pricing_state
2892                                    .get_current_multi_gas_fees(state_ref)
2893                                    .unwrap_or([U256::ZERO; NUM_RESOURCE_KIND])
2894                            });
2895                            // SAFETY: see `Storage::state_mut()` invariant.
2896                            let state_ref = unsafe { arb_state_post.backing_storage.state_mut() };
2897                            let multi_cost = arb_state_post
2898                                .l2_pricing_state
2899                                .multi_dimensional_price_for_refund_with_fees(
2900                                    state_ref,
2901                                    charged_multi_gas,
2902                                    cached,
2903                                )
2904                                .unwrap_or(total_cost);
2905                            if total_cost > multi_cost {
2906                                let refund_amount = total_cost.saturating_sub(multi_cost);
2907                                let _ = arb_util::transfer_balance(
2908                                    Some(&dist.network_fee_account),
2909                                    Some(&pending.sender),
2910                                    refund_amount,
2911                                    |f, t, a| {
2912                                        // SAFETY: see `Storage::state_mut()` invariant.
2913                                        unsafe {
2914                                            apply_balance_op(
2915                                                refund_storage.state_mut(),
2916                                                &mut *overlay_ptr,
2917                                                f,
2918                                                t,
2919                                                a,
2920                                            )
2921                                        }
2922                                    },
2923                                );
2924                                refund_done = true;
2925                            }
2926                        }
2927
2928                        // Remove poster gas from the L1Calldata dimension: the
2929                        // poster gas was added during gas charging, but for
2930                        // backlog growth we only want compute gas in the
2931                        // multi-gas.
2932                        let used_multi_gas = charged_multi_gas
2933                            .saturating_sub(MultiGas::single_dim_gas(poster_gas_active));
2934
2935                        let mut new_backlog: Option<u64> = None;
2936                        if gas_price_positive_active {
2937                            // SAFETY: see `Storage::state_mut()` invariant.
2938                            let state_ref = unsafe { arb_state_post.backing_storage.state_mut() };
2939                            let _ = arb_state_post.l2_pricing_state.grow_backlog(
2940                                state_ref,
2941                                dist.compute_gas_for_backlog,
2942                                used_multi_gas,
2943                            );
2944                            new_backlog =
2945                                arb_state_post.l2_pricing_state.gas_backlog(state_ref).ok();
2946                        }
2947                        if !dist.l1_fees_to_add.is_zero() {
2948                            // SAFETY: see `Storage::state_mut()` invariant.
2949                            let state_ref = unsafe { arb_state_post.backing_storage.state_mut() };
2950                            let _ = arb_state_post
2951                                .l1_pricing_state
2952                                .add_to_l1_fees_available(state_ref, dist.l1_fees_to_add);
2953                        }
2954
2955                        (refund_done, new_backlog)
2956                    };
2957
2958                    if refund_done {
2959                        self.touched_accounts.insert(dist.network_fee_account);
2960                        self.touched_accounts.insert(pending.sender);
2961                    }
2962                    if let Some(b) = new_backlog {
2963                        self.precompile_ctx.block.set_current_gas_backlog(b);
2964                    }
2965                }
2966            }
2967
2968            // FixRedeemGas (ArbOS >= 11): subtract gas allocated to scheduled
2969            // retry txs from this tx's gas_used for block rate limiting, since
2970            // that gas will be accounted for when the retry tx itself executes.
2971            let mut adjusted_gas_used = gas_used_total;
2972            if self.arb_ctx.arbos_version
2973                >= arb_chainspec::arbos_version::ARBOS_VERSION_FIX_REDEEM_GAS
2974                && let Some(hooks) = self.arb_hooks.as_ref()
2975            {
2976                for scheduled in &hooks.tx_proc.scheduled_txs {
2977                    if let Some(retry_gas) = decode_retry_tx_gas(scheduled) {
2978                        adjusted_gas_used = adjusted_gas_used.saturating_sub(retry_gas);
2979                    }
2980                }
2981            }
2982
2983            // Block gas rate limiting: deduct compute gas from block budget.
2984            const TX_GAS: u64 = 21_000;
2985            let data_gas = pending.poster_gas;
2986            let compute_used = if adjusted_gas_used < data_gas {
2987                TX_GAS
2988            } else {
2989                let compute = adjusted_gas_used - data_gas;
2990                if compute < TX_GAS { TX_GAS } else { compute }
2991            };
2992            self.block_gas_left = self.block_gas_left.saturating_sub(compute_used);
2993
2994            // Track user txs for the ArbOS < 50 first-tx bypass.
2995            let is_user_tx = !matches!(
2996                pending.arb_tx_type,
2997                Some(ArbTxType::ArbitrumInternalTx)
2998                    | Some(ArbTxType::ArbitrumDepositTx)
2999                    | Some(ArbTxType::ArbitrumSubmitRetryableTx)
3000                    | Some(ArbTxType::ArbitrumRetryTx)
3001            );
3002            if is_user_tx {
3003                self.user_txs_processed += 1;
3004            }
3005
3006            let _ = is_retry; // suppress unused warning
3007        }
3008
3009        self.precompile_ctx.reset_tx();
3010
3011        // Per-tx Finalise: delete empty accounts from cache.
3012        // Only iterates touched accounts (matching Go's journal.dirties).
3013        // Accounts merely loaded (e.g. balance check) are not considered.
3014        //
3015        // Go's Finalise protects zombie accounts: an account is zombie-protected
3016        // if ALL its journal dirty entries are createZombieChange entries.
3017        // Our zombie_accounts set approximates this — if a zombie is subsequently
3018        // dirtied by a non-zero transfer, it's removed from zombie_accounts
3019        // (matching Go's dirtyCount > zombieEntries check).
3020        {
3021            let keccak_empty = alloy_primitives::B256::from(alloy_primitives::keccak256([]));
3022            let overlay = &mut self.state_overlay;
3023            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
3024            let to_remove: Vec<Address> = self
3025                .touched_accounts
3026                .drain()
3027                .filter(|addr| {
3028                    // Zombie accounts must be preserved even if empty.
3029                    if self.zombie_accounts.contains(addr) {
3030                        return false;
3031                    }
3032                    if let Some(cached) = db.cache.accounts.get(addr)
3033                        && let Some(ref acct) = cached.account
3034                    {
3035                        let is_empty = acct.info.nonce == 0
3036                            && acct.info.balance.is_zero()
3037                            && acct.info.code_hash == keccak_empty;
3038                        return is_empty;
3039                    }
3040                    false
3041                })
3042                .collect();
3043
3044            // Mark deleted accounts non-existent in the cache instead of
3045            // removing them. Removing the entry would let the next same-block
3046            // access reload stale data from the database (the Entry::Vacant
3047            // path in load_cache_account). Keeping account=None with a
3048            // non-existent status leaves a self-consistent entry, so both
3049            // later accesses and any revert baseline captured from it see a
3050            // genuinely absent account.
3051            for addr in &to_remove {
3052                overlay.record_pre_touch(db, *addr);
3053                if let Some(cached) = db.cache.accounts.get_mut(addr) {
3054                    cached.account = None;
3055                    cached.status = revm_database::AccountStatus::LoadedNotExisting;
3056                }
3057            }
3058            self.finalise_deleted.extend(to_remove);
3059        }
3060
3061        {
3062            let overlay = &mut self.state_overlay;
3063            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
3064            overlay.drain_and_apply(db, &self.zombie_accounts);
3065        }
3066
3067        Ok(gas_used)
3068    }
3069
3070    fn finish(self) -> Result<(Self::Evm, BlockExecutionResult<R::Receipt>), BlockExecutionError> {
3071        // Log if expected balance delta is non-zero (deposits/withdrawals occurred).
3072        if self.expected_balance_delta != 0 {
3073            tracing::trace!(
3074                target: "arb::executor",
3075                delta = self.expected_balance_delta,
3076                "expected balance delta from deposits/withdrawals"
3077            );
3078        }
3079        // Skip inner.finish() to avoid Ethereum block rewards.
3080        // Arbitrum has no block rewards (no PoW/PoS mining).
3081        // Directly extract the EVM and receipts instead.
3082        let mut result = BlockExecutionResult {
3083            receipts: self.inner.receipts,
3084            requests: Default::default(),
3085            gas_used: self.inner.gas_used,
3086            blob_gas_used: self.inner.blob_gas_used,
3087        };
3088        // Set Arbitrum-specific fields on each receipt from tracking vectors.
3089        for (i, receipt) in result.receipts.iter_mut().enumerate() {
3090            if let Some(&l1_gas) = self.gas_used_for_l1.get(i) {
3091                arb_primitives::SetArbReceiptFields::set_gas_used_for_l1(receipt, l1_gas);
3092            }
3093            if let Some(&multi_gas) = self.multi_gas_used.get(i) {
3094                arb_primitives::SetArbReceiptFields::set_multi_gas_used(receipt, multi_gas);
3095            }
3096        }
3097        Ok((self.inner.evm, result))
3098    }
3099
3100    fn set_state_hook(&mut self, hook: Option<Box<dyn OnStateHook>>) {
3101        self.inner.set_state_hook(hook);
3102    }
3103
3104    fn evm_mut(&mut self) -> &mut Self::Evm {
3105        self.inner.evm_mut()
3106    }
3107
3108    fn evm(&self) -> &Self::Evm {
3109        self.inner.evm()
3110    }
3111
3112    fn receipts(&self) -> &[Self::Receipt] {
3113        self.inner.receipts()
3114    }
3115}
3116
3117// ---------------------------------------------------------------------------
3118// Helpers
3119// ---------------------------------------------------------------------------
3120
3121/// Builds the gas accounting for a synthetic (non-EVM) execution result.
3122fn synthetic_result_gas(gas_used: u64) -> revm::context::result::ResultGas {
3123    revm::context::result::ResultGas::new(gas_used, gas_used, 0, 0, 0)
3124}
3125
3126/// Adjust gas_used in an `ExecutionResult` by adding extra gas.
3127///
3128/// Used to account for poster gas (L1 data cost) which is deducted before
3129/// EVM execution but must be reflected in the receipt's gas_used.
3130fn adjust_result_gas_used<H>(result: &mut ExecutionResult<H>, extra_gas: u64) {
3131    match result {
3132        ExecutionResult::Success { gas, .. }
3133        | ExecutionResult::Revert { gas, .. }
3134        | ExecutionResult::Halt { gas, .. } => gas.set_spent(gas.spent().saturating_add(extra_gas)),
3135    }
3136}
3137
3138/// Apply an unconditional AddBalance to the EVM state.
3139fn apply_mint_to_state<DB: Database>(
3140    state: &mut State<DB>,
3141    overlay: &mut StateOverlay,
3142    address: Address,
3143    amount: U256,
3144) {
3145    if amount.is_zero() {
3146        return;
3147    }
3148    overlay.record_pre_touch(state, address);
3149    if let Some(cache_acct) = state.cache.accounts.get_mut(&address) {
3150        if let Some(ref mut acct) = cache_acct.account {
3151            acct.info.balance = acct.info.balance.saturating_add(amount);
3152        } else {
3153            cache_acct.account = Some(revm_database::states::plain_account::PlainAccount {
3154                info: revm_state::AccountInfo {
3155                    balance: amount,
3156                    ..Default::default()
3157                },
3158                storage: Default::default(),
3159            });
3160        }
3161    }
3162}
3163
3164/// Materialise an account as present-empty if it does not yet exist (an EIP-161
3165/// zero-value touch). The per-tx Finalise then destructs the empty result and
3166/// records it in `finalise_deleted`, so a later zero-value transfer can
3167/// resurrect it via `create_zombie_if_deleted`.
3168fn materialise_empty<DB: Database>(
3169    state: &mut State<DB>,
3170    overlay: &mut StateOverlay,
3171    addr: Address,
3172    touched: &mut rustc_hash::FxHashSet<Address>,
3173) {
3174    overlay.record_pre_touch(state, addr);
3175    let _ = state.load_cache_account(addr);
3176    if let Some(cached) = state.cache.accounts.get_mut(&addr)
3177        && cached.account.is_none()
3178    {
3179        cached.account = Some(revm_database::states::plain_account::PlainAccount {
3180            info: revm_state::AccountInfo::default(),
3181            storage: Default::default(),
3182        });
3183        cached.status = revm_database::AccountStatus::InMemoryChange;
3184    }
3185    touched.insert(addr);
3186}
3187
3188/// Apply an unconditional SubBalance to the EVM state.
3189fn apply_burn_to_state<DB: Database>(
3190    state: &mut State<DB>,
3191    overlay: &mut StateOverlay,
3192    address: Address,
3193    amount: U256,
3194) {
3195    if amount.is_zero() {
3196        return;
3197    }
3198    overlay.record_pre_touch(state, address);
3199    if let Some(cache_acct) = state.cache.accounts.get_mut(&address)
3200        && let Some(ref mut acct) = cache_acct.account
3201    {
3202        acct.info.balance = acct.info.balance.saturating_sub(amount);
3203    }
3204}
3205
3206/// Backing state mutation for the typed transfer callback.
3207///
3208/// Maps the `(from, to, amount)` triple to a concrete state mutation:
3209///   - `(Some(from), Some(to))` — transfer with balance check; returns
3210///     `BalanceError::InsufficientBalance` when `from` cannot cover `amount`.
3211///   - `(Some(from), None)` — unconditional burn (saturating, matches Go).
3212///   - `(None, Some(to))` — unconditional mint.
3213fn apply_balance_op<DB: Database>(
3214    state: &mut State<DB>,
3215    overlay: &mut StateOverlay,
3216    from: Option<&Address>,
3217    to: Option<&Address>,
3218    amount: U256,
3219) -> Result<(), BalanceError> {
3220    if amount.is_zero() {
3221        return Ok(());
3222    }
3223    match (from, to) {
3224        (Some(from_addr), Some(to_addr)) => {
3225            let available = get_balance(state, *from_addr);
3226            if available < amount {
3227                return Err(BalanceError::InsufficientBalance {
3228                    account: *from_addr,
3229                    available,
3230                    requested: amount,
3231                });
3232            }
3233            apply_burn_to_state(state, overlay, *from_addr, amount);
3234            apply_mint_to_state(state, overlay, *to_addr, amount);
3235        }
3236        (Some(from_addr), None) => {
3237            apply_burn_to_state(state, overlay, *from_addr, amount);
3238        }
3239        (None, Some(to_addr)) => {
3240            apply_mint_to_state(state, overlay, *to_addr, amount);
3241        }
3242        (None, None) => {}
3243    }
3244    Ok(())
3245}
3246
3247/// Increment the nonce of an account.
3248fn increment_nonce<DB: Database>(
3249    state: &mut State<DB>,
3250    overlay: &mut StateOverlay,
3251    address: Address,
3252) {
3253    overlay.record_pre_touch(state, address);
3254    if let Some(cache_acct) = state.cache.accounts.get_mut(&address)
3255        && let Some(ref mut acct) = cache_acct.account
3256    {
3257        acct.info.nonce += 1;
3258    }
3259}
3260
3261/// Read the balance of an account in the EVM state.
3262fn get_balance<DB: Database>(state: &mut State<DB>, address: Address) -> U256 {
3263    match revm::Database::basic(state, address) {
3264        Ok(Some(info)) => info.balance,
3265        _ => U256::ZERO,
3266    }
3267}
3268
3269/// Re-create an empty account that was deleted by per-tx Finalise.
3270/// Matches Go's `CreateZombieIfDeleted`: if `addr` was removed by Finalise
3271/// (present in `finalise_deleted`) and no longer in cache, create a zombie.
3272/// Go calls this for `from` in TransferBalance when amount == 0 and
3273/// ArbOS version < Stylus.
3274fn create_zombie_if_deleted<DB: Database>(
3275    state: &mut State<DB>,
3276    overlay: &mut StateOverlay,
3277    addr: Address,
3278    finalise_deleted: &rustc_hash::FxHashSet<Address>,
3279    zombie_accounts: &mut rustc_hash::FxHashSet<Address>,
3280    touched_accounts: &mut rustc_hash::FxHashSet<Address>,
3281) {
3282    overlay.record_pre_touch(state, addr);
3283    let account_missing = state
3284        .cache
3285        .accounts
3286        .get(&addr)
3287        .is_none_or(|c| c.account.is_none());
3288    if account_missing && finalise_deleted.contains(&addr) {
3289        if let Some(cached) = state.cache.accounts.get_mut(&addr) {
3290            cached.account = Some(revm_database::states::plain_account::PlainAccount {
3291                info: revm_state::AccountInfo::default(),
3292                storage: Default::default(),
3293            });
3294            cached.status = revm_database::AccountStatus::InMemoryChange;
3295        }
3296        zombie_accounts.insert(addr);
3297        touched_accounts.insert(addr);
3298    }
3299}
3300
3301/// Apply a computed fee distribution to the EVM state.
3302fn apply_fee_distribution<DB: Database>(
3303    state: &mut State<DB>,
3304    overlay: &mut StateOverlay,
3305    dist: &EndTxFeeDistribution,
3306    l1_pricing: Option<&l1_pricing::L1PricingState<DB>>,
3307) {
3308    // Skip the 0-value mint to avoid an EIP-161 touch on the network
3309    // fee account.
3310    if !dist.network_fee_amount.is_zero() {
3311        let _ = arb_util::mint_balance(
3312            &dist.network_fee_account,
3313            dist.network_fee_amount,
3314            |f, t, a| apply_balance_op(state, overlay, f, t, a),
3315        );
3316    }
3317    let _ = arb_util::mint_balance(&dist.infra_fee_account, dist.infra_fee_amount, |f, t, a| {
3318        apply_balance_op(state, overlay, f, t, a)
3319    });
3320    let _ = arb_util::mint_balance(
3321        &dist.poster_fee_destination,
3322        dist.poster_fee_amount,
3323        |f, t, a| apply_balance_op(state, overlay, f, t, a),
3324    );
3325
3326    if !dist.l1_fees_to_add.is_zero()
3327        && let Some(l1_state) = l1_pricing
3328    {
3329        let _ = l1_state.add_to_l1_fees_available(state, dist.l1_fees_to_add);
3330    }
3331
3332    tracing::trace!(
3333        target: "arb::executor",
3334        network_fee = %dist.network_fee_amount,
3335        infra_fee = %dist.infra_fee_amount,
3336        poster_fee = %dist.poster_fee_amount,
3337        poster_dest = %dist.poster_fee_destination,
3338        l1_fees_added = %dist.l1_fees_to_add,
3339        backlog_gas = dist.compute_gas_for_backlog,
3340        "applied fee distribution"
3341    );
3342}
3343
3344/// Estimate intrinsic gas for a transaction.
3345///
3346/// Matches geth's `IntrinsicGas()`: base 21000 + calldata cost + create cost +
3347/// access list cost + EIP-3860 initcode cost (Shanghai+).
3348/// Must be spec-aware to avoid charging initcode cost at pre-Shanghai specs.
3349fn estimate_intrinsic_gas(tx: &impl Transaction, spec: revm::primitives::hardfork::SpecId) -> u64 {
3350    const TX_GAS: u64 = 21_000;
3351    const TX_CREATE_GAS: u64 = 32_000;
3352    const TX_DATA_ZERO_GAS: u64 = 4;
3353    const TX_DATA_NON_ZERO_GAS: u64 = 16;
3354    const TX_ACCESS_LIST_ADDRESS_GAS: u64 = 2400;
3355    const TX_ACCESS_LIST_STORAGE_KEY_GAS: u64 = 1900;
3356    const INIT_CODE_WORD_GAS: u64 = 2;
3357
3358    let is_create = tx.to().is_none();
3359
3360    let mut gas = TX_GAS;
3361    if is_create {
3362        gas += TX_CREATE_GAS;
3363    }
3364
3365    let data = tx.input();
3366
3367    // Calldata cost.
3368    let data_gas: u64 = data
3369        .iter()
3370        .map(|&b| {
3371            if b == 0 {
3372                TX_DATA_ZERO_GAS
3373            } else {
3374                TX_DATA_NON_ZERO_GAS
3375            }
3376        })
3377        .sum();
3378    gas = gas.saturating_add(data_gas);
3379
3380    // EIP-2930: access list cost.
3381    if let Some(access_list) = tx.access_list() {
3382        for item in access_list.iter() {
3383            gas = gas.saturating_add(TX_ACCESS_LIST_ADDRESS_GAS);
3384            gas = gas.saturating_add(
3385                (item.storage_keys.len() as u64).saturating_mul(TX_ACCESS_LIST_STORAGE_KEY_GAS),
3386            );
3387        }
3388    }
3389
3390    // EIP-3860: initcode word cost for CREATE txs (Shanghai+).
3391    if spec.is_enabled_in(revm::primitives::hardfork::SpecId::SHANGHAI)
3392        && is_create
3393        && !data.is_empty()
3394    {
3395        let words = (data.len() as u64).div_ceil(32);
3396        gas = gas.saturating_add(words.saturating_mul(INIT_CODE_WORD_GAS));
3397    }
3398
3399    gas
3400}
3401
3402/// Per-resource intrinsic gas for a transaction. Its total matches
3403/// [`estimate_intrinsic_gas`] plus the EIP-7702 authorization cost; the
3404/// inspector never observes it because the intrinsic is charged before the
3405/// first opcode runs.
3406fn tx_intrinsic_multi_gas(
3407    tx: &impl Transaction,
3408    spec: revm::primitives::hardfork::SpecId,
3409) -> MultiGas {
3410    let is_create = tx.to().is_none();
3411    let data = tx.input();
3412    let zero_bytes = data.iter().filter(|&&b| b == 0).count() as u64;
3413    let nonzero_bytes = data.len() as u64 - zero_bytes;
3414    let (access_list_addresses, access_list_keys) = tx.access_list().map_or((0, 0), |al| {
3415        let mut addrs = 0u64;
3416        let mut keys = 0u64;
3417        for item in al.iter() {
3418            addrs += 1;
3419            keys += item.storage_keys.len() as u64;
3420        }
3421        (addrs, keys)
3422    });
3423    let init_code_words = if is_create
3424        && spec.is_enabled_in(revm::primitives::hardfork::SpecId::SHANGHAI)
3425        && !data.is_empty()
3426    {
3427        (data.len() as u64).div_ceil(32)
3428    } else {
3429        0
3430    };
3431    crate::multi_gas::intrinsic_multigas(crate::multi_gas::IntrinsicInput {
3432        is_create,
3433        zero_bytes,
3434        nonzero_bytes,
3435        init_code_words,
3436        access_list_addresses,
3437        access_list_keys,
3438        auth_list_len: tx.authorization_list().map_or(0, |l| l.len()) as u64,
3439    })
3440}
3441
3442/// EIP-7623 calldata floor: `TxGas + tokens * floor cost`, where each non-zero
3443/// data byte is four tokens and each zero byte one. Applied only when the
3444/// calldata-price increase feature is enabled.
3445fn tx_floor_data_gas(tx: &impl Transaction) -> u64 {
3446    const TX_GAS: u64 = 21_000;
3447    const TX_TOKEN_PER_NONZERO_BYTE: u64 = 4;
3448    const TX_COST_FLOOR_PER_TOKEN: u64 = 10;
3449    let data = tx.input();
3450    let zero = data.iter().filter(|&&b| b == 0).count() as u64;
3451    let nonzero = (data.len() as u64).saturating_sub(zero);
3452    let tokens = nonzero
3453        .saturating_mul(TX_TOKEN_PER_NONZERO_BYTE)
3454        .saturating_add(zero);
3455    TX_GAS.saturating_add(tokens.saturating_mul(TX_COST_FLOOR_PER_TOKEN))
3456}
3457
3458/// Decode delayed_messages_read (bytes 32-39) and L2 block number (bytes 40-47)
3459/// from the extra_data field passed through EthBlockExecutionCtx.
3460fn decode_extra_fields(extra_bytes: &[u8]) -> (u64, u64) {
3461    let delayed = if extra_bytes.len() >= 40 {
3462        let mut buf = [0u8; 8];
3463        buf.copy_from_slice(&extra_bytes[32..40]);
3464        u64::from_be_bytes(buf)
3465    } else {
3466        0
3467    };
3468    let l2_block = if extra_bytes.len() >= 48 {
3469        let mut buf = [0u8; 8];
3470        buf.copy_from_slice(&extra_bytes[40..48]);
3471        u64::from_be_bytes(buf)
3472    } else {
3473        0
3474    };
3475    (delayed, l2_block)
3476}
3477
3478/// EIP-2935: Store the parent block hash in the history storage contract.
3479///
3480/// For Arbitrum, uses L2 block numbers and a buffer size of 393168 blocks.
3481fn process_parent_block_hash<DB: Database>(
3482    state: &mut State<DB>,
3483    l2_block_number: u64,
3484    prev_hash: B256,
3485) {
3486    use arb_primitives::arbos_versions::HISTORY_STORAGE_ADDRESS;
3487
3488    /// Arbitrum EIP-2935 buffer size (matching the Arbitrum history storage contract).
3489    const HISTORY_SERVE_WINDOW: u64 = 393168;
3490
3491    if l2_block_number == 0 {
3492        return;
3493    }
3494
3495    let slot = U256::from((l2_block_number - 1) % HISTORY_SERVE_WINDOW);
3496    let value = U256::from_be_slice(prev_hash.as_slice());
3497
3498    arb_storage::write_storage_at(state, HISTORY_STORAGE_ADDRESS, slot, value)
3499        .expect("HISTORY_STORAGE write must succeed: in-memory state writes are infallible");
3500}
3501
3502/// Extract the gas field from a scheduled retry tx's encoded bytes.
3503///
3504/// The encoded format is `[type_byte][RLP(ArbRetryTx)]`.
3505fn decode_retry_tx_gas(encoded: &[u8]) -> Option<u64> {
3506    if encoded.is_empty() {
3507        return None;
3508    }
3509    if encoded[0] != ArbTxType::ArbitrumRetryTx.as_u8() {
3510        tracing::warn!(
3511            target: "arb::executor",
3512            tx_type = encoded[0],
3513            "unexpected scheduled tx type"
3514        );
3515        return None;
3516    }
3517    let rlp_data = &encoded[1..];
3518    let retry =
3519        <arb_alloy_consensus::tx::ArbRetryTx as alloy_rlp::Decodable>::decode(&mut &rlp_data[..])
3520            .ok()?;
3521    Some(retry.gas)
3522}
3523
3524#[cfg(test)]
3525mod tests {
3526    use std::collections::HashMap;
3527
3528    use alloy_primitives::{B256, U256};
3529    use arb_context::BlockCtx;
3530
3531    use super::populate_l2_block_hash_window;
3532
3533    #[test]
3534    fn fills_window_from_parent_and_lookup() {
3535        let block = BlockCtx::new(60, 0, 0, 1000, false);
3536        let parent_hash = B256::repeat_byte(0xfe);
3537        let mut db: HashMap<u64, B256> = (744..=998)
3538            .map(|n| (n, B256::from(U256::from(n))))
3539            .collect();
3540        populate_l2_block_hash_window(&block, 1000, parent_hash, |n| db.remove(&n));
3541
3542        assert_eq!(block.cached_l2_block_hash(999), Some(parent_hash));
3543        assert_eq!(
3544            block.cached_l2_block_hash(998),
3545            Some(B256::from(U256::from(998u64)))
3546        );
3547        assert_eq!(
3548            block.cached_l2_block_hash(744),
3549            Some(B256::from(U256::from(744u64)))
3550        );
3551        assert_eq!(block.cached_l2_block_hash(743), None);
3552    }
3553
3554    #[test]
3555    fn keeps_chunk_internal_and_stops_at_gap() {
3556        let block = BlockCtx::new(60, 0, 0, 1000, false);
3557        populate_l2_block_hash_window(&block, 1000, B256::repeat_byte(0xfe), |n| {
3558            (n == 997).then(|| B256::repeat_byte(0x97))
3559        });
3560        assert_eq!(
3561            block.cached_l2_block_hash(999),
3562            Some(B256::repeat_byte(0xfe))
3563        );
3564        assert_eq!(block.cached_l2_block_hash(998), None);
3565        assert_eq!(block.cached_l2_block_hash(997), None);
3566    }
3567}