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