arb_evm/
build.rs

1use alloy_consensus::{Transaction, TransactionEnvelope, TxReceipt};
2use alloy_eips::eip2718::{Encodable2718, Typed2718};
3use alloy_evm::{
4    block::{
5        BlockExecutionError, BlockExecutionResult, BlockExecutor, BlockExecutorFactory,
6        BlockExecutorFor, ExecutableTx, OnStateHook,
7    },
8    eth::{
9        receipt_builder::ReceiptBuilder, spec::EthExecutorSpec, EthBlockExecutionCtx,
10        EthBlockExecutor, EthTxResult,
11    },
12    tx::{FromRecoveredTx, FromTxWithEncoded},
13    Database, Evm, EvmFactory, RecoveredTx,
14};
15use alloy_primitives::{keccak256, Address, Log, TxKind, B256, U256};
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        compute_poster_gas, compute_submit_retryable_fees, EndTxFeeDistribution,
29        EndTxRetryableParams, SubmitRetryableParams,
30    },
31    util::tx_type_has_poster_costs,
32};
33use reth_evm::TransactionEnv;
34use revm::{
35    context::{result::ExecutionResult, TxEnv},
36    database::State,
37    inspector::Inspector,
38};
39
40use crate::{
41    context::ArbBlockExecutionCtx,
42    executor::DefaultArbOsHooks,
43    hooks::{ArbOsHooks, EndTxContext},
44};
45
46/// Extension trait for transaction environments that support gas price mutation.
47///
48/// Arbitrum needs to cap the gas price to the base fee when dropping tips,
49/// which requires mutating fields not exposed by the standard `TransactionEnv` trait.
50pub trait ArbTransactionEnv: TransactionEnv {
51    /// Set the effective gas price (max_fee_per_gas for EIP-1559, gas_price for legacy).
52    fn set_gas_price(&mut self, gas_price: u128);
53    /// Set the max priority fee per gas (tip cap).
54    fn set_gas_priority_fee(&mut self, fee: Option<u128>);
55    /// Set the transaction value.
56    fn set_value(&mut self, value: U256);
57}
58
59impl ArbTransactionEnv for TxEnv {
60    fn set_gas_price(&mut self, gas_price: u128) {
61        self.gas_price = gas_price;
62    }
63    fn set_gas_priority_fee(&mut self, fee: Option<u128>) {
64        self.gas_priority_fee = fee;
65    }
66    fn set_value(&mut self, value: U256) {
67        self.value = value;
68    }
69}
70
71/// Extension trait for draining scheduled transactions from the executor.
72///
73/// After executing a SubmitRetryable or a manual Redeem precompile call,
74/// auto-redeem retry transactions may be queued. The block producer must
75/// drain and re-inject them in the same block.
76pub trait ArbScheduledTxDrain {
77    /// Drain any scheduled transactions (e.g. auto-redeem retry txs) produced
78    /// by the most recently committed transaction.
79    fn drain_scheduled_txs(&mut self) -> Vec<Vec<u8>>;
80}
81
82impl<'a, Evm, Spec, R: ReceiptBuilder> ArbScheduledTxDrain for ArbBlockExecutor<'a, Evm, Spec, R> {
83    fn drain_scheduled_txs(&mut self) -> Vec<Vec<u8>> {
84        self.arb_hooks
85            .as_mut()
86            .map(|hooks| std::mem::take(&mut hooks.tx_proc.scheduled_txs))
87            .unwrap_or_default()
88    }
89}
90
91/// Arbitrum block executor factory.
92///
93/// Wraps an `EthBlockExecutor` with ArbOS-specific hooks for gas charging,
94/// fee distribution, and L1 data pricing.
95#[derive(Debug, Clone)]
96pub struct ArbBlockExecutorFactory<R, Spec, EvmF> {
97    receipt_builder: R,
98    spec: Spec,
99    evm_factory: EvmF,
100}
101
102impl<R, Spec, EvmF> ArbBlockExecutorFactory<R, Spec, EvmF> {
103    pub fn new(receipt_builder: R, spec: Spec, evm_factory: EvmF) -> Self {
104        Self {
105            receipt_builder,
106            spec,
107            evm_factory,
108        }
109    }
110
111    /// Create an executor with the concrete `ArbBlockExecutor` return type.
112    ///
113    /// Unlike the trait method which returns an opaque type, this provides
114    /// access to Arbitrum-specific methods like `drain_scheduled_txs`.
115    pub fn create_arb_executor<'a, DB, I>(
116        &'a self,
117        evm: EvmF::Evm<&'a mut State<DB>, I>,
118        ctx: EthBlockExecutionCtx<'a>,
119        chain_id: u64,
120    ) -> ArbBlockExecutor<'a, EvmF::Evm<&'a mut State<DB>, I>, &'a Spec, &'a R>
121    where
122        DB: Database + 'a,
123        R: ReceiptBuilder,
124        Spec: EthExecutorSpec + Clone,
125        I: Inspector<EvmF::Context<&'a mut State<DB>>> + 'a,
126        EvmF: EvmFactory,
127    {
128        let extra_bytes = ctx.extra_data.as_ref();
129        let (delayed_messages_read, l2_block_number) = decode_extra_fields(extra_bytes);
130        let arb_ctx = ArbBlockExecutionCtx {
131            parent_hash: ctx.parent_hash,
132            parent_beacon_block_root: ctx.parent_beacon_block_root,
133            extra_data: extra_bytes[..core::cmp::min(extra_bytes.len(), 32)].to_vec(),
134            delayed_messages_read,
135            l2_block_number,
136            chain_id,
137            ..Default::default()
138        };
139        ArbBlockExecutor {
140            inner: EthBlockExecutor::new(evm, ctx, &self.spec, &self.receipt_builder),
141            arb_hooks: None,
142            arb_ctx,
143            pending_tx: None,
144            block_gas_left: 0,
145            user_txs_processed: 0,
146            gas_used_for_l1: Vec::new(),
147            multi_gas_used: Vec::new(),
148            expected_balance_delta: 0,
149            zombie_accounts: rustc_hash::FxHashSet::default(),
150            finalise_deleted: rustc_hash::FxHashSet::default(),
151            touched_accounts: rustc_hash::FxHashSet::default(),
152            multi_gas_current_fees: std::sync::OnceLock::new(),
153        }
154    }
155}
156
157impl<R, Spec, EvmF> BlockExecutorFactory for ArbBlockExecutorFactory<R, Spec, EvmF>
158where
159    R: ReceiptBuilder<
160            Transaction: Transaction + Encodable2718 + ArbTransactionExt,
161            Receipt: TxReceipt<Log = Log> + arb_primitives::SetArbReceiptFields,
162        > + 'static,
163    Spec: EthExecutorSpec + Clone + 'static,
164    EvmF: EvmFactory<
165        Tx: FromRecoveredTx<R::Transaction> + FromTxWithEncoded<R::Transaction> + ArbTransactionEnv,
166    >,
167    Self: 'static,
168{
169    type EvmFactory = EvmF;
170    type ExecutionCtx<'a> = EthBlockExecutionCtx<'a>;
171    type Transaction = R::Transaction;
172    type Receipt = R::Receipt;
173
174    fn evm_factory(&self) -> &Self::EvmFactory {
175        &self.evm_factory
176    }
177
178    fn create_executor<'a, DB, I>(
179        &'a self,
180        evm: EvmF::Evm<&'a mut State<DB>, I>,
181        ctx: Self::ExecutionCtx<'a>,
182    ) -> impl BlockExecutorFor<'a, Self, DB, I>
183    where
184        DB: Database + 'a,
185        I: Inspector<EvmF::Context<&'a mut State<DB>>> + 'a,
186    {
187        let extra_bytes = ctx.extra_data.as_ref();
188        let (delayed_messages_read, l2_block_number) = decode_extra_fields(extra_bytes);
189        let arb_ctx = ArbBlockExecutionCtx {
190            parent_hash: ctx.parent_hash,
191            parent_beacon_block_root: ctx.parent_beacon_block_root,
192            extra_data: extra_bytes[..core::cmp::min(extra_bytes.len(), 32)].to_vec(),
193            delayed_messages_read,
194            l2_block_number,
195            ..Default::default()
196        };
197        ArbBlockExecutor {
198            inner: EthBlockExecutor::new(evm, ctx, &self.spec, &self.receipt_builder),
199            arb_hooks: None,
200            arb_ctx,
201            pending_tx: None,
202            block_gas_left: 0, // Set from state in apply_pre_execution_changes
203            user_txs_processed: 0,
204            gas_used_for_l1: Vec::new(),
205            multi_gas_used: Vec::new(),
206            expected_balance_delta: 0,
207            zombie_accounts: rustc_hash::FxHashSet::default(),
208            finalise_deleted: rustc_hash::FxHashSet::default(),
209            touched_accounts: rustc_hash::FxHashSet::default(),
210            multi_gas_current_fees: std::sync::OnceLock::new(),
211        }
212    }
213}
214
215// ---------------------------------------------------------------------------
216// Per-transaction state carried between execute and commit
217// ---------------------------------------------------------------------------
218
219/// Captured per-transaction state for fee distribution in `commit_transaction`.
220struct PendingArbTx {
221    sender: Address,
222    tx_gas_limit: u64,
223    arb_tx_type: Option<ArbTxType>,
224    poster_gas: u64,
225    /// Gas reth's EVM charged for (0 for paths that bypass reth's EVM).
226    evm_gas_used: u64,
227    charged_multi_gas: MultiGas,
228    gas_price_positive: bool,
229    stylus_data_fee: U256,
230    retry_context: Option<PendingRetryContext>,
231    coinbase_tip_per_gas: u128,
232    /// True when tx_env.gas_price was capped to base_fee. Determines whether
233    /// commit_transaction must burn the tip (revm saw only base_fee) or
234    /// transfer it from coinbase to network (revm minted to coinbase).
235    capped_gas_price: bool,
236    /// Effective per-gas price the sender pays on posterGas (full when
237    /// CollectTips is true, else base fee). Used for posterGas rounding
238    /// and the sender-side burn on gas reth didn't charge.
239    actual_gas_price: U256,
240}
241
242/// Context for a retry tx that needs end-tx processing after EVM execution.
243struct PendingRetryContext {
244    ticket_id: alloy_primitives::B256,
245    refund_to: Address,
246    #[allow(dead_code)]
247    gas_fee_cap: U256,
248    max_refund: U256,
249    submission_fee_refund: U256,
250    /// Call value transferred from escrow; returned to escrow on failure.
251    call_value: U256,
252}
253
254/// Arbitrum block executor wrapping `EthBlockExecutor`.
255///
256/// Adds ArbOS-specific pre/post execution logic:
257/// - Loads ArbOS state at block start (version, fee accounts)
258/// - Adjusts gas accounting for L1 poster costs
259/// - Distributes fees to network/infra/poster accounts after each tx
260/// - Tracks block gas consumption for rate limiting
261pub struct ArbBlockExecutor<'a, Evm, Spec, R: ReceiptBuilder> {
262    /// Inner Ethereum block executor.
263    pub inner: EthBlockExecutor<'a, Evm, Spec, R>,
264    /// ArbOS hooks for per-transaction processing.
265    pub arb_hooks: Option<DefaultArbOsHooks>,
266    /// Arbitrum-specific block context.
267    pub arb_ctx: ArbBlockExecutionCtx,
268    /// Per-tx state between execute and commit.
269    pending_tx: Option<PendingArbTx>,
270    /// Remaining block gas for rate limiting.
271    /// Starts at per_block_gas_limit and decreases with each tx's compute gas.
272    pub block_gas_left: u64,
273    /// Number of user transactions successfully committed.
274    /// Used for ArbOS < 50 block gas check (first user tx may exceed limit).
275    user_txs_processed: u64,
276    /// Per-receipt poster gas (L1 gas component), parallel to the receipts vector.
277    /// Used to populate `gasUsedForL1` in RPC receipt responses.
278    pub gas_used_for_l1: Vec<u64>,
279    /// Per-receipt multi-dimensional gas, parallel to the receipts vector.
280    pub multi_gas_used: Vec<MultiGas>,
281    /// Expected balance delta from deposits (positive) and L2→L1 withdrawals (negative).
282    /// Used for post-block safety verification.
283    expected_balance_delta: i128,
284    /// Zombie accounts: empty accounts preserved from EIP-161 deletion because
285    /// they were touched by a zero-value transfer on pre-Stylus ArbOS.
286    zombie_accounts: rustc_hash::FxHashSet<Address>,
287    /// Accounts removed by per-tx Finalise (EIP-161). Tracked so the producer
288    /// can mark them for trie deletion if they existed pre-block.
289    finalise_deleted: rustc_hash::FxHashSet<Address>,
290    /// Accounts modified in the current tx (bypass ops + EVM state).
291    /// Per-tx Finalise only processes these, matching Go's journal.dirties.
292    touched_accounts: rustc_hash::FxHashSet<Address>,
293    /// Cached per-resource current-block fees, populated lazily on first read
294    /// within a block. SingleDim slot is left zero; callers substitute the
295    /// live base_fee_wei for that slot and for any cached slot that is zero.
296    /// Safe to cache because current-block fees are only written by
297    /// `commit_next_to_current` during `apply_pre_execution_changes` — no user
298    /// tx or precompile path mutates them mid-block.
299    multi_gas_current_fees: std::sync::OnceLock<[U256; NUM_RESOURCE_KIND]>,
300}
301
302impl<'a, Evm, Spec, R: ReceiptBuilder> ArbBlockExecutor<'a, Evm, Spec, R> {
303    /// Set the ArbOS hooks for this block execution.
304    pub fn with_hooks(mut self, hooks: DefaultArbOsHooks) -> Self {
305        self.arb_hooks = Some(hooks);
306        self
307    }
308
309    /// Set the Arbitrum execution context.
310    pub fn with_arb_ctx(mut self, ctx: ArbBlockExecutionCtx) -> Self {
311        self.arb_ctx = ctx;
312        self
313    }
314
315    /// Returns the set of zombie account addresses.
316    ///
317    /// Zombie accounts are empty accounts that should be preserved in the
318    /// state trie (not deleted by EIP-161) because they were re-created by
319    /// a zero-value transfer on pre-Stylus ArbOS.
320    pub fn zombie_accounts(&self) -> rustc_hash::FxHashSet<Address> {
321        self.zombie_accounts.clone()
322    }
323
324    /// Returns accounts deleted by per-tx Finalise (EIP-161).
325    /// These may need trie deletion if they existed pre-block.
326    pub fn finalise_deleted(&self) -> &rustc_hash::FxHashSet<Address> {
327        &self.finalise_deleted
328    }
329
330    /// Deduct TX_GAS from block gas budget for a failed/invalid transaction.
331    /// Call this when a user transaction fails execution so the block budget
332    /// and user-tx counter stay in sync (TX_GAS is charged for invalid txs
333    /// and userTxsProcessed is incremented).
334    pub fn deduct_failed_tx_gas(&mut self, is_user_tx: bool) {
335        const TX_GAS: u64 = 21_000;
336        self.block_gas_left = self.block_gas_left.saturating_sub(TX_GAS);
337        if is_user_tx {
338            self.user_txs_processed += 1;
339        }
340    }
341
342    /// Drain any scheduled transactions (e.g. auto-redeem retry txs) produced
343    /// by the most recently committed transaction. The caller should decode and
344    /// re-inject these as new transactions in the same block.
345    pub fn drain_scheduled_txs(&mut self) -> Vec<Vec<u8>> {
346        self.arb_hooks
347            .as_mut()
348            .map(|hooks| std::mem::take(&mut hooks.tx_proc.scheduled_txs))
349            .unwrap_or_default()
350    }
351
352    /// Read state parameters from ArbOS state into the execution context
353    /// and create/update the hooks.
354    fn load_state_params<D: Database>(
355        &mut self,
356        arb_state: &ArbosState<D, impl arbos::burn::Burner>,
357    ) {
358        let arbos_version = arb_state.arbos_version();
359        self.arb_ctx.arbos_version = arbos_version;
360        // Set thread-locals for precompile access.
361        arb_precompiles::set_arbos_version(arbos_version);
362        arb_precompiles::set_block_timestamp(self.arb_ctx.block_timestamp);
363        arb_precompiles::set_current_l2_block(self.arb_ctx.l2_block_number);
364        arb_precompiles::set_l1_block_number_for_evm(self.arb_ctx.l1_block_number);
365        arb_precompiles::set_cached_l1_block_number(
366            self.arb_ctx.l2_block_number,
367            self.arb_ctx.l1_block_number,
368        );
369
370        // Set gas backlog for Redeem precompile's ShrinkBacklog cost computation.
371        if let Ok(backlog) = arb_state.l2_pricing_state.gas_backlog() {
372            arb_precompiles::set_current_gas_backlog(backlog);
373        }
374
375        if let Ok(addr) = arb_state.network_fee_account() {
376            self.arb_ctx.network_fee_account = addr;
377        }
378        if let Ok(addr) = arb_state.infra_fee_account() {
379            self.arb_ctx.infra_fee_account = addr;
380        }
381        if let Ok(level) = arb_state.brotli_compression_level() {
382            self.arb_ctx.brotli_compression_level = level;
383        }
384        if let Ok(price) = arb_state.l1_pricing_state.price_per_unit() {
385            self.arb_ctx.l1_price_per_unit = price;
386        }
387        if let Ok(min_fee) = arb_state.l2_pricing_state.min_base_fee_wei() {
388            self.arb_ctx.min_base_fee = min_fee;
389        }
390
391        let per_block_gas_limit = arb_state
392            .l2_pricing_state
393            .per_block_gas_limit()
394            .unwrap_or(0);
395        let per_tx_gas_limit = arb_state.l2_pricing_state.per_tx_gas_limit().unwrap_or(0);
396
397        // Read calldata pricing increase feature flag (ArbOS >= 40).
398        let calldata_pricing_increase_enabled = arbos_version
399            >= arb_chainspec::arbos_version::ARBOS_VERSION_40
400            && arb_state
401                .features
402                .is_increased_calldata_price_enabled()
403                .unwrap_or(false);
404
405        // Tip-collection flag (ArbOS >= 60).
406        let collect_tips_enabled = arb_state.collect_tips().unwrap_or(false);
407
408        let hooks = DefaultArbOsHooks::new(
409            self.arb_ctx.coinbase,
410            arbos_version,
411            self.arb_ctx.network_fee_account,
412            self.arb_ctx.infra_fee_account,
413            self.arb_ctx.min_base_fee,
414            per_block_gas_limit,
415            per_tx_gas_limit,
416            false,
417            self.arb_ctx.l1_base_fee,
418            calldata_pricing_increase_enabled,
419            collect_tips_enabled,
420        );
421        self.arb_hooks = Some(hooks);
422    }
423}
424
425impl<'db, DB, E, Spec, R> ArbBlockExecutor<'_, E, Spec, R>
426where
427    DB: Database + 'db,
428    E: Evm<
429        DB = &'db mut State<DB>,
430        Tx: FromRecoveredTx<R::Transaction> + FromTxWithEncoded<R::Transaction> + ArbTransactionEnv,
431    >,
432    Spec: EthExecutorSpec,
433    R: ReceiptBuilder<
434        Transaction: Transaction + Encodable2718 + ArbTransactionExt,
435        Receipt: TxReceipt<Log = Log>,
436    >,
437    R::Transaction: TransactionEnvelope,
438{
439    /// Handle SubmitRetryableTx: no EVM execution, all state changes done directly.
440    ///
441    /// Returns a synthetic execution result (endTxNow=true).
442    fn execute_submit_retryable(
443        &mut self,
444        ticket_id: alloy_primitives::B256,
445        tx_type: <R::Transaction as TransactionEnvelope>::TxType,
446        mut info: arb_primitives::SubmitRetryableInfo,
447    ) -> Result<
448        EthTxResult<E::HaltReason, <R::Transaction as TransactionEnvelope>::TxType>,
449        BlockExecutionError,
450    > {
451        let sender = info.from;
452
453        // Check if this submit retryable is in the on-chain filter.
454        // If filtered, redirect fee_refund_addr and beneficiary to the
455        // filtered funds recipient. The retryable is still created but
456        // auto-redeem scheduling is skipped.
457        let is_filtered = {
458            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
459            let state_ptr: *mut State<DB> = db as *mut State<DB>;
460            if let Ok(arb_state) = ArbosState::open(state_ptr, SystemBurner::new(None, false)) {
461                if arb_state.filtered_transactions.is_filtered_free(ticket_id) {
462                    if let Ok(recipient) = arb_state.filtered_funds_recipient_or_default() {
463                        info.fee_refund_addr = recipient;
464                        info.beneficiary = recipient;
465                    }
466                    true
467                } else {
468                    false
469                }
470            } else {
471                false
472            }
473        };
474
475        // Compute fees (read block info before mutably borrowing db).
476        let block = self.inner.evm().block();
477        let current_time = revm::context::Block::timestamp(block).to::<u64>();
478        let effective_base_fee = self.arb_ctx.basefee;
479
480        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
481
482        // Mint deposit value to sender.
483        mint_balance(db, sender, info.deposit_value);
484        self.touched_accounts.insert(sender);
485
486        // Track retryable deposit for balance delta verification.
487        let dep_i128: i128 = info.deposit_value.try_into().unwrap_or(i128::MAX);
488        self.expected_balance_delta = self.expected_balance_delta.saturating_add(dep_i128);
489
490        // Get sender balance after minting.
491        let _ = db.load_cache_account(sender);
492        let balance_after_mint = db
493            .cache
494            .accounts
495            .get(&sender)
496            .and_then(|a| a.account.as_ref())
497            .map(|a| a.info.balance)
498            .unwrap_or(U256::ZERO);
499
500        let params = SubmitRetryableParams {
501            ticket_id,
502            from: sender,
503            fee_refund_addr: info.fee_refund_addr,
504            deposit_value: info.deposit_value,
505            retry_value: info.retry_value,
506            gas_fee_cap: info.gas_fee_cap,
507            gas: info.gas,
508            max_submission_fee: info.max_submission_fee,
509            retry_data_len: info.retry_data.len(),
510            l1_base_fee: info.l1_base_fee,
511            effective_base_fee,
512            current_time,
513            balance_after_mint,
514            infra_fee_account: self.arb_ctx.infra_fee_account,
515            min_base_fee: self.arb_ctx.min_base_fee,
516            arbos_version: self.arb_ctx.arbos_version,
517        };
518
519        let fees = compute_submit_retryable_fees(&params);
520
521        let user_gas = info.gas;
522
523        // Fee validation errors end the transaction immediately with zero gas.
524        // The deposit was already minted (separate ArbitrumDepositTx), and no
525        // further transfers should occur.
526        if let Some(ref err) = fees.error {
527            tracing::warn!(
528                target: "arb::executor",
529                ticket_id = %ticket_id,
530                error = %err,
531                "submit retryable fee validation failed"
532            );
533
534            self.pending_tx = Some(PendingArbTx {
535                sender,
536                tx_gas_limit: user_gas,
537                arb_tx_type: Some(ArbTxType::ArbitrumSubmitRetryableTx),
538                poster_gas: 0,
539                evm_gas_used: 0,
540
541                charged_multi_gas: MultiGas::default(),
542                gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
543                stylus_data_fee: U256::ZERO,
544                retry_context: None,
545                coinbase_tip_per_gas: 0,
546                capped_gas_price: false,
547                actual_gas_price: self.arb_ctx.basefee,
548            });
549
550            return Ok(EthTxResult {
551                result: revm::context::result::ResultAndState {
552                    result: ExecutionResult::Revert {
553                        gas_used: 0,
554                        output: alloy_primitives::Bytes::new(),
555                    },
556                    state: Default::default(),
557                },
558                blob_gas_used: 0,
559                tx_type,
560            });
561        }
562
563        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
564
565        // 3. Transfer submission fee to network fee account.
566        if !fees.submission_fee.is_zero() {
567            transfer_balance(
568                db,
569                sender,
570                self.arb_ctx.network_fee_account,
571                fees.submission_fee,
572            );
573            self.touched_accounts.insert(sender);
574            self.touched_accounts
575                .insert(self.arb_ctx.network_fee_account);
576        }
577
578        // 4. Refund excess submission fee.
579        transfer_balance(db, sender, info.fee_refund_addr, fees.submission_fee_refund);
580        self.touched_accounts.insert(sender);
581        self.touched_accounts.insert(info.fee_refund_addr);
582
583        // 5. Move call value into escrow. If sender has insufficient funds (e.g. deposit didn't
584        //    cover retry_value after fee deductions), refund the submission fee and end the
585        //    transaction.
586        if !try_transfer_balance(db, sender, fees.escrow, info.retry_value) {
587            self.touched_accounts.insert(sender);
588            self.touched_accounts.insert(fees.escrow);
589            // Refund submission fee from network account back to sender.
590            transfer_balance(
591                db,
592                self.arb_ctx.network_fee_account,
593                sender,
594                fees.submission_fee,
595            );
596            self.touched_accounts
597                .insert(self.arb_ctx.network_fee_account);
598            // Refund withheld portion of submission fee to fee refund address.
599            transfer_balance(
600                db,
601                sender,
602                info.fee_refund_addr,
603                fees.withheld_submission_fee,
604            );
605            self.touched_accounts.insert(info.fee_refund_addr);
606
607            self.pending_tx = Some(PendingArbTx {
608                sender,
609                tx_gas_limit: user_gas,
610                arb_tx_type: Some(ArbTxType::ArbitrumSubmitRetryableTx),
611                poster_gas: 0,
612                evm_gas_used: 0,
613
614                charged_multi_gas: MultiGas::default(),
615                gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
616                stylus_data_fee: U256::ZERO,
617                retry_context: None,
618                coinbase_tip_per_gas: 0,
619                capped_gas_price: false,
620                actual_gas_price: self.arb_ctx.basefee,
621            });
622
623            return Ok(EthTxResult {
624                result: revm::context::result::ResultAndState {
625                    result: ExecutionResult::Revert {
626                        gas_used: 0,
627                        output: alloy_primitives::Bytes::new(),
628                    },
629                    state: Default::default(),
630                },
631                blob_gas_used: 0,
632                tx_type,
633            });
634        }
635        self.touched_accounts.insert(sender);
636        self.touched_accounts.insert(fees.escrow);
637
638        // 6. Create retryable ticket.
639        let state_ptr: *mut State<DB> = db as *mut State<DB>;
640        if let Ok(arb_state) = ArbosState::open(state_ptr, SystemBurner::new(None, false)) {
641            let _ = arb_state.retryable_state.create_retryable(
642                ticket_id,
643                fees.timeout,
644                sender,
645                info.retry_to,
646                info.retry_value,
647                info.beneficiary,
648                &info.retry_data,
649            );
650        }
651
652        // Emit TicketCreated event (always, after retryable creation).
653        let mut receipt_logs: Vec<Log> = Vec::new();
654        receipt_logs.push(Log {
655            address: arb_precompiles::ARBRETRYABLETX_ADDRESS,
656            data: alloy_primitives::LogData::new_unchecked(
657                vec![arb_precompiles::ticket_created_topic(), ticket_id],
658                alloy_primitives::Bytes::new(),
659            ),
660        });
661
662        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
663
664        // 7. Handle gas fees if user can pay.
665        if fees.can_pay_for_gas {
666            // Pay infra fee (skip when infra_fee_account is zero, matching Go).
667            if self.arb_ctx.infra_fee_account != Address::ZERO {
668                transfer_balance(db, sender, self.arb_ctx.infra_fee_account, fees.infra_cost);
669                self.touched_accounts.insert(sender);
670                self.touched_accounts.insert(self.arb_ctx.infra_fee_account);
671            }
672            // Pay network fee.
673            if !fees.network_cost.is_zero() {
674                transfer_balance(
675                    db,
676                    sender,
677                    self.arb_ctx.network_fee_account,
678                    fees.network_cost,
679                );
680                self.touched_accounts.insert(sender);
681                self.touched_accounts
682                    .insert(self.arb_ctx.network_fee_account);
683            }
684            // Gas price refund.
685            transfer_balance(db, sender, info.fee_refund_addr, fees.gas_price_refund);
686            self.touched_accounts.insert(sender);
687            self.touched_accounts.insert(info.fee_refund_addr);
688
689            // Filtered retryables do not get an auto-redeem scheduled.
690            if !is_filtered {
691                // Schedule auto-redeem: reconstruct the retry tx from stored
692                // fields and bump num_tries.
693                let state_ptr2: *mut State<DB> = db as *mut State<DB>;
694                match ArbosState::open(state_ptr2, SystemBurner::new(None, false)) {
695                    Ok(arb_state) => {
696                        match arb_state.retryable_state.open_retryable(
697                            ticket_id, 0, // pass 0 so any non-zero timeout is valid
698                        ) {
699                            Ok(Some(retryable)) => {
700                                let _ = retryable.increment_num_tries();
701
702                                match retryable.make_tx(
703                                    U256::from(self.arb_ctx.chain_id),
704                                    0, // nonce = 0 for first auto-redeem
705                                    effective_base_fee,
706                                    user_gas,
707                                    ticket_id,
708                                    info.fee_refund_addr,
709                                    fees.available_refund,
710                                    fees.submission_fee,
711                                ) {
712                                    Ok(retry_tx) => {
713                                        // Compute retry tx hash for the event.
714                                        let retry_tx_hash = {
715                                            let mut enc = Vec::new();
716                                            enc.push(ArbTxType::ArbitrumRetryTx.as_u8());
717                                            alloy_rlp::Encodable::encode(&retry_tx, &mut enc);
718                                            keccak256(&enc)
719                                        };
720
721                                        // Emit RedeemScheduled event.
722                                        let mut event_data = Vec::with_capacity(128);
723                                        event_data.extend_from_slice(
724                                            &B256::left_padding_from(&user_gas.to_be_bytes()).0,
725                                        );
726                                        event_data.extend_from_slice(
727                                            &B256::left_padding_from(
728                                                info.fee_refund_addr.as_slice(),
729                                            )
730                                            .0,
731                                        );
732                                        event_data.extend_from_slice(
733                                            &fees.available_refund.to_be_bytes::<32>(),
734                                        );
735                                        event_data.extend_from_slice(
736                                            &fees.submission_fee.to_be_bytes::<32>(),
737                                        );
738
739                                        receipt_logs.push(Log {
740                                            address: arb_precompiles::ARBRETRYABLETX_ADDRESS,
741                                            data: alloy_primitives::LogData::new_unchecked(
742                                                vec![
743                                                    arb_precompiles::redeem_scheduled_topic(),
744                                                    ticket_id,
745                                                    retry_tx_hash,
746                                                    B256::left_padding_from(&0u64.to_be_bytes()),
747                                                ],
748                                                event_data.into(),
749                                            ),
750                                        });
751
752                                        if let Some(hooks) = self.arb_hooks.as_mut() {
753                                            let mut encoded = Vec::new();
754                                            encoded.push(ArbTxType::ArbitrumRetryTx.as_u8());
755                                            alloy_rlp::Encodable::encode(&retry_tx, &mut encoded);
756                                            hooks.tx_proc.scheduled_txs.push(encoded);
757                                        } else {
758                                            tracing::warn!(
759                                                target: "arb::executor",
760                                                "Cannot schedule auto-redeem: arb_hooks is None"
761                                            );
762                                        }
763                                    }
764                                    Err(_) => {
765                                        tracing::warn!(
766                                            target: "arb::executor",
767                                            "Auto-redeem make_tx failed"
768                                        );
769                                    }
770                                }
771                            }
772                            Ok(None) => {
773                                tracing::warn!(
774                                    target: "arb::executor",
775                                    %ticket_id,
776                                    "open_retryable returned None after create"
777                                );
778                            }
779                            Err(_) => {
780                                tracing::warn!(
781                                    target: "arb::executor",
782                                    "open_retryable failed"
783                                );
784                            }
785                        }
786                    }
787                    Err(_) => {
788                        tracing::warn!(
789                            target: "arb::executor",
790                            "ArbosState::open failed for auto-redeem"
791                        );
792                    }
793                }
794            }
795        } else if !fees.gas_cost_refund.is_zero() {
796            // Can't pay for gas: refund gas cost from deposit.
797            transfer_balance(db, sender, info.fee_refund_addr, fees.gas_cost_refund);
798            self.touched_accounts.insert(sender);
799            self.touched_accounts.insert(info.fee_refund_addr);
800        }
801
802        // Store pending state for commit_transaction.
803        // evm_gas_used must equal gas_used when can_pay_for_gas because the gas
804        // fees were already transferred inside execute_submit_retryable. Setting
805        // evm_gas_used = gas_used prevents the sender_extra_gas burn in
806        // commit_transaction from double-charging the sender.
807        let gas_used = if fees.can_pay_for_gas { user_gas } else { 0 };
808        self.pending_tx = Some(PendingArbTx {
809            sender,
810            tx_gas_limit: user_gas,
811            arb_tx_type: Some(ArbTxType::ArbitrumSubmitRetryableTx),
812            poster_gas: 0,
813            evm_gas_used: gas_used,
814            charged_multi_gas: if fees.can_pay_for_gas {
815                MultiGas::l2_calldata_gas(user_gas)
816            } else {
817                MultiGas::default()
818            },
819            gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
820            stylus_data_fee: U256::ZERO,
821            retry_context: None,
822            coinbase_tip_per_gas: 0,
823            capped_gas_price: false,
824            actual_gas_price: self.arb_ctx.basefee,
825        });
826
827        // Construct synthetic execution result. Filtered retryables always
828        // return a failure receipt (filteredErr). Non-filtered txs
829        // succeed even when can't pay for gas (retryable was created).
830        let ticket_bytes = alloy_primitives::Bytes::copy_from_slice(ticket_id.as_slice());
831
832        if is_filtered {
833            Ok(EthTxResult {
834                result: revm::context::result::ResultAndState {
835                    result: ExecutionResult::Revert {
836                        gas_used,
837                        output: ticket_bytes,
838                    },
839                    state: Default::default(),
840                },
841                blob_gas_used: 0,
842                tx_type,
843            })
844        } else {
845            Ok(EthTxResult {
846                result: revm::context::result::ResultAndState {
847                    result: ExecutionResult::Success {
848                        reason: revm::context::result::SuccessReason::Return,
849                        gas_used,
850                        gas_refunded: 0,
851                        output: revm::context::result::Output::Call(ticket_bytes),
852                        logs: receipt_logs,
853                    },
854                    state: Default::default(),
855                },
856                blob_gas_used: 0,
857                tx_type,
858            })
859        }
860    }
861}
862
863impl<'db, DB, E, Spec, R> BlockExecutor for ArbBlockExecutor<'_, E, Spec, R>
864where
865    DB: Database + 'db,
866    E: Evm<
867        DB = &'db mut State<DB>,
868        Tx: FromRecoveredTx<R::Transaction> + FromTxWithEncoded<R::Transaction> + ArbTransactionEnv,
869    >,
870    Spec: EthExecutorSpec,
871    R: ReceiptBuilder<
872        Transaction: Transaction + Encodable2718 + ArbTransactionExt,
873        Receipt: TxReceipt<Log = Log> + arb_primitives::SetArbReceiptFields,
874    >,
875    R::Transaction: TransactionEnvelope,
876{
877    type Transaction = R::Transaction;
878    type Receipt = R::Receipt;
879    type Evm = E;
880    type Result = EthTxResult<E::HaltReason, <R::Transaction as TransactionEnvelope>::TxType>;
881
882    fn apply_pre_execution_changes(&mut self) -> Result<(), BlockExecutionError> {
883        self.inner.apply_pre_execution_changes()?;
884
885        // Populate header-derived fields from the EVM block/cfg environment.
886        {
887            let block = self.inner.evm().block();
888            let timestamp = revm::context::Block::timestamp(block).to::<u64>();
889            if self.arb_ctx.block_timestamp == 0 {
890                self.arb_ctx.block_timestamp = timestamp;
891            }
892            self.arb_ctx.coinbase = revm::context::Block::beneficiary(block);
893            self.arb_ctx.basefee = U256::from(revm::context::Block::basefee(block));
894            if let Some(prevrandao) = revm::context::Block::prevrandao(block) {
895                if self.arb_ctx.l1_block_number == 0 {
896                    self.arb_ctx.l1_block_number =
897                        crate::config::l1_block_number_from_mix_hash(&prevrandao);
898                }
899            }
900        }
901
902        // Ensure L2 block number is set for precompile access.
903        // block_env.number holds L1 block number; L2 comes from the sealed header
904        // (set via arb_context_for_block or with_arb_ctx). If still 0, we're in a
905        // path where it wasn't explicitly set — this shouldn't happen in production.
906        if self.arb_ctx.l2_block_number > 0 {
907            arb_precompiles::set_current_l2_block(self.arb_ctx.l2_block_number);
908            arb_precompiles::set_cached_l1_block_number(
909                self.arb_ctx.l2_block_number,
910                self.arb_ctx.l1_block_number,
911            );
912        }
913
914        // Load ArbOS state parameters from the EVM database.
915        // Block-start operations (pricing model update, retryable reaping, etc.)
916        // are triggered by the startBlock internal tx, NOT here.
917        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
918        let state_ptr: *mut State<DB> = db as *mut State<DB>;
919
920        if let Ok(arb_state) = ArbosState::open(state_ptr, SystemBurner::new(None, false)) {
921            // Rotate multi-gas fees: copy next-block fees to current-block.
922            let _ = arb_state.l2_pricing_state.commit_multi_gas_fees();
923
924            // Read baseFee from L2PricingState BEFORE StartBlock runs.
925            // This value was written by the previous block's StartBlock.
926            if let Ok(base_fee) = arb_state.l2_pricing_state.base_fee_wei() {
927                self.arb_ctx.basefee = base_fee;
928            }
929
930            // Read state parameters for the execution context and hooks.
931            self.load_state_params(&arb_state);
932
933            // Initialize block gas rate limiting.
934            self.block_gas_left = arb_state
935                .l2_pricing_state
936                .per_block_gas_limit()
937                .unwrap_or(0);
938
939            // Pre-populate the State's block_hashes cache with L1 block hashes
940            // from ArbOS state. Arbitrum overrides the BLOCKHASH opcode to return
941            // L1 block hashes (not L2). Since block_env.number is already set to
942            // the L1 block number, revm's range check uses L1 numbers — we just
943            // need to ensure the hash lookup returns L1 hashes.
944            if let Ok(l1_block_number) = arb_state.blockhashes.l1_block_number() {
945                let lower = l1_block_number.saturating_sub(256);
946                // SAFETY: state_ptr is valid for the lifetime of this block.
947                let state_ref = unsafe { &mut *state_ptr };
948                for n in lower..l1_block_number {
949                    if let Ok(Some(hash)) = arb_state.blockhashes.block_hash(n) {
950                        state_ref.block_hashes.insert(n, hash);
951                    }
952                }
953            }
954        }
955
956        // L2 block hash cache for arbBlockHash() is populated by the producer
957        // (which has access to the state provider's header chain).
958
959        tracing::trace!(
960            target: "arb::executor",
961            l1_block = self.arb_ctx.l1_block_number,
962            delayed_msgs = self.arb_ctx.delayed_messages_read,
963            chain_id = self.arb_ctx.chain_id,
964            basefee = %self.arb_ctx.basefee,
965            arbos_version = self.arb_ctx.arbos_version,
966            has_hooks = self.arb_hooks.is_some(),
967            "starting block execution"
968        );
969
970        Ok(())
971    }
972
973    fn execute_transaction_without_commit(
974        &mut self,
975        tx: impl ExecutableTx<Self>,
976    ) -> Result<Self::Result, BlockExecutionError> {
977        // Decompose the transaction to extract sender, type, and gas limit.
978        let (tx_env, recovered) = tx.into_parts();
979        let sender = *recovered.signer();
980        let tx_type_raw = recovered.tx().ty();
981        let tx_gas_limit = recovered.tx().gas_limit();
982        let tx_value = recovered.tx().value();
983        let envelope_tx_type = recovered.tx().tx_type();
984
985        // Classify the transaction type.
986        let arb_tx_type = ArbTxType::from_u8(tx_type_raw).ok();
987        let is_arb_internal = arb_tx_type == Some(ArbTxType::ArbitrumInternalTx);
988        let is_arb_deposit = arb_tx_type == Some(ArbTxType::ArbitrumDepositTx);
989        let is_submit_retryable = arb_tx_type == Some(ArbTxType::ArbitrumSubmitRetryableTx);
990        let is_retry_tx = arb_tx_type == Some(ArbTxType::ArbitrumRetryTx);
991        let is_contract_tx = arb_tx_type == Some(ArbTxType::ArbitrumContractTx);
992        let has_poster_costs = tx_type_has_poster_costs(tx_type_raw);
993
994        // Block gas rate limit: reject user txs when block gas budget is
995        // exhausted. Internal, deposit, and submit retryable txs always proceed
996        // (they are block-critical or come from the delayed inbox).
997        let is_user_tx =
998            !is_arb_internal && !is_arb_deposit && !is_submit_retryable && !is_retry_tx;
999        const TX_GAS_MIN: u64 = 21_000;
1000        if is_user_tx && self.block_gas_left < TX_GAS_MIN {
1001            return Err(BlockExecutionError::msg("block gas limit reached"));
1002        }
1003
1004        // Reset per-tx processor state.
1005        crate::evm::reset_stylus_pages();
1006        arb_precompiles::set_poster_balance_correction(U256::ZERO);
1007        arb_precompiles::set_current_tx_sender(Address::ZERO);
1008        arb_precompiles::reset_caller_stack();
1009        crate::state_overlay::reset_tx();
1010        if let Some(hooks) = self.arb_hooks.as_mut() {
1011            hooks.tx_proc.poster_fee = U256::ZERO;
1012            hooks.tx_proc.poster_gas = 0;
1013            hooks.tx_proc.compute_hold_gas = 0;
1014            hooks.tx_proc.current_retryable = None;
1015            hooks.tx_proc.current_refund_to = None;
1016            hooks.tx_proc.scheduled_txs.clear();
1017        }
1018
1019        // Effective gas price the sender pays on posterGas — full when
1020        // CollectTips is on, else base fee. `Transaction::gas_price` returns
1021        // max_fee for EIP-1559, so compute effective manually to keep
1022        // `max_fee > basefee, priority = 0` priced at basefee.
1023        let actual_gas_price: U256 = {
1024            let base_fee = self.arb_ctx.basefee;
1025            let base_fee_u128: u128 = base_fee.try_into().unwrap_or(u128::MAX);
1026            let max_fee: u128 = revm::context_interface::Transaction::gas_price(&tx_env);
1027            let max_priority: u128 =
1028                revm::context_interface::Transaction::max_priority_fee_per_gas(&tx_env)
1029                    .unwrap_or(0);
1030            let effective: u128 =
1031                std::cmp::min(max_fee, base_fee_u128.saturating_add(max_priority));
1032            let drop = self
1033                .arb_hooks
1034                .as_ref()
1035                .map(|h| h.drop_tip())
1036                .unwrap_or(false);
1037            if drop || effective == 0 {
1038                base_fee
1039            } else {
1040                U256::from(effective)
1041            }
1042        };
1043
1044        // --- Pre-execution: apply special tx type state changes ---
1045
1046        // Internal txs: verify sender, apply state update, end immediately.
1047        if is_arb_internal {
1048            use arbos::tx_processor::ARBOS_ADDRESS;
1049
1050            if sender != ARBOS_ADDRESS {
1051                return Err(BlockExecutionError::msg(
1052                    "internal tx not from ArbOS address",
1053                ));
1054            }
1055
1056            let tx_data = recovered.tx().input().to_vec();
1057            let tx_type = recovered.tx().tx_type();
1058            let mut tx_err = None;
1059
1060            if tx_data.len() >= 4 {
1061                let selector: [u8; 4] = tx_data[0..4].try_into().unwrap();
1062                let is_start_block = selector == internal_tx::INTERNAL_TX_START_BLOCK_METHOD_ID;
1063
1064                if is_start_block {
1065                    if let Ok(start_data) = internal_tx::decode_start_block_data(&tx_data) {
1066                        self.arb_ctx.l1_base_fee = start_data.l1_base_fee;
1067                        self.arb_ctx.time_passed = start_data.time_passed;
1068                    }
1069                }
1070
1071                let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1072                let state_ptr: *mut State<DB> = db as *mut State<DB>;
1073                if let Ok(mut arb_state) =
1074                    ArbosState::open(state_ptr, SystemBurner::new(None, false))
1075                {
1076                    let block = self.inner.evm().block();
1077                    let current_time = revm::context::Block::timestamp(block).to::<u64>();
1078                    let ctx = InternalTxContext {
1079                        block_number: revm::context::Block::number(block).to::<u64>(),
1080                        current_time,
1081                        prev_hash: self.arb_ctx.parent_hash,
1082                    };
1083
1084                    // EIP-2935: Store parent block hash for ArbOS >= 40.
1085                    // The slot is computed from the L2 block number (matching the
1086                    // history-storage contract, which calls ArbSys.arbBlockNumber).
1087                    if is_start_block
1088                        && arb_state.arbos_version()
1089                            >= arb_chainspec::arbos_version::ARBOS_VERSION_40
1090                    {
1091                        // SAFETY: state_ptr is valid for the lifetime of this block.
1092                        process_parent_block_hash(
1093                            unsafe { &mut *state_ptr },
1094                            self.arb_ctx.l2_block_number,
1095                            ctx.prev_hash,
1096                        );
1097                    }
1098
1099                    let touched_ptr =
1100                        &mut self.touched_accounts as *mut rustc_hash::FxHashSet<Address>;
1101                    let zombie_ptr =
1102                        &mut self.zombie_accounts as *mut rustc_hash::FxHashSet<Address>;
1103                    let finalise_ptr =
1104                        &self.finalise_deleted as *const rustc_hash::FxHashSet<Address>;
1105                    let arbos_ver = self.arb_ctx.arbos_version;
1106                    let mut do_transfer = |from: Address, to: Address, amount: U256| {
1107                        // SAFETY: state_ptr is valid for the lifetime of this block.
1108                        unsafe {
1109                            if amount.is_zero()
1110                                && arbos_ver < arb_chainspec::arbos_version::ARBOS_VERSION_STYLUS
1111                            {
1112                                create_zombie_if_deleted(
1113                                    &mut *state_ptr,
1114                                    from,
1115                                    &*finalise_ptr,
1116                                    &mut *zombie_ptr,
1117                                    &mut *touched_ptr,
1118                                );
1119                            }
1120                            transfer_balance(&mut *state_ptr, from, to, amount);
1121                            if !amount.is_zero() {
1122                                (*zombie_ptr).remove(&from);
1123                            }
1124                            (*zombie_ptr).remove(&to);
1125                            (*touched_ptr).insert(from);
1126                            (*touched_ptr).insert(to);
1127                        }
1128                        Ok(())
1129                    };
1130                    let mut do_balance = |addr: Address| -> U256 {
1131                        // SAFETY: state_ptr is valid for the lifetime of this block.
1132                        unsafe { get_balance(&mut *state_ptr, addr) }
1133                    };
1134                    if let Err(e) = internal_tx::apply_internal_tx_update(
1135                        &tx_data,
1136                        &mut arb_state,
1137                        &ctx,
1138                        &mut do_transfer,
1139                        &mut do_balance,
1140                    ) {
1141                        tracing::warn!(
1142                            target: "arb::executor",
1143                            error = %e,
1144                            "internal tx processing failed"
1145                        );
1146                        tx_err = Some(e);
1147                    }
1148
1149                    if is_start_block {
1150                        self.load_state_params(&arb_state);
1151
1152                        // Update L1 block number from ArbOS state (canonical
1153                        // source for the NUMBER opcode). mixHash value can
1154                        // differ from the StartBlock data's value.
1155                        if let Ok(l1_block_number) = arb_state.blockhashes.l1_block_number() {
1156                            self.arb_ctx.l1_block_number = l1_block_number;
1157                            arb_precompiles::set_l1_block_number_for_evm(l1_block_number);
1158                            arb_precompiles::set_cached_l1_block_number(
1159                                self.arb_ctx.l2_block_number,
1160                                l1_block_number,
1161                            );
1162
1163                            // Refresh L1 block hashes cache after StartBlock.
1164                            let lower = l1_block_number.saturating_sub(256);
1165                            let state_ref = unsafe { &mut *state_ptr };
1166                            for n in lower..l1_block_number {
1167                                if let Ok(Some(hash)) = arb_state.blockhashes.block_hash(n) {
1168                                    state_ref.block_hashes.insert(n, hash);
1169                                }
1170                            }
1171                        }
1172                    }
1173                }
1174            }
1175
1176            // Internal txs end immediately — no EVM execution.
1177            self.pending_tx = Some(PendingArbTx {
1178                sender,
1179                tx_gas_limit: 0,
1180                arb_tx_type: Some(ArbTxType::ArbitrumInternalTx),
1181                poster_gas: 0,
1182                evm_gas_used: 0,
1183
1184                charged_multi_gas: MultiGas::default(),
1185                gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
1186                stylus_data_fee: U256::ZERO,
1187                retry_context: None,
1188                coinbase_tip_per_gas: 0,
1189                capped_gas_price: false,
1190                actual_gas_price: self.arb_ctx.basefee,
1191            });
1192
1193            // Internal tx errors are fatal — abort block production.
1194            if let Some(err) = tx_err {
1195                return Err(BlockExecutionError::msg(format!(
1196                    "failed to apply internal transaction: {err}"
1197                )));
1198            }
1199
1200            return Ok(EthTxResult {
1201                result: revm::context::result::ResultAndState {
1202                    result: ExecutionResult::Success {
1203                        reason: revm::context::result::SuccessReason::Return,
1204                        gas_used: 0,
1205                        gas_refunded: 0,
1206                        output: revm::context::result::Output::Call(alloy_primitives::Bytes::new()),
1207                        logs: Vec::new(),
1208                    },
1209                    state: Default::default(),
1210                },
1211                blob_gas_used: 0,
1212                tx_type,
1213            });
1214        }
1215
1216        // Deposit txs: mint to sender, transfer to recipient, end immediately.
1217        // No EVM execution — the value transfer is the entire transaction.
1218        if is_arb_deposit {
1219            let value = recovered.tx().value();
1220            let mut to = match recovered.tx().kind() {
1221                TxKind::Call(addr) => addr,
1222                TxKind::Create => {
1223                    return Err(BlockExecutionError::msg("deposit tx has no To address"));
1224                }
1225            };
1226            let tx_type = recovered.tx().tx_type();
1227            let tx_hash = recovered.tx().trie_hash();
1228
1229            // Check if this deposit is in the on-chain filter.
1230            // Deposits return endTxNow=true so RevertedTxHook is never reached;
1231            // we must check here instead.
1232            let mut is_filtered = false;
1233            {
1234                let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1235                let state_ptr: *mut State<DB> = db as *mut State<DB>;
1236                if let Ok(arb_state) = ArbosState::open(state_ptr, SystemBurner::new(None, false)) {
1237                    if arb_state.filtered_transactions.is_filtered_free(tx_hash) {
1238                        if let Ok(recipient) = arb_state.filtered_funds_recipient_or_default() {
1239                            to = recipient;
1240                        }
1241                        is_filtered = true;
1242                    }
1243                }
1244            }
1245
1246            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1247            // Mint deposit value to sender, then transfer to recipient.
1248            mint_balance(db, sender, value);
1249            transfer_balance(db, sender, to, value);
1250            self.touched_accounts.insert(sender);
1251            self.touched_accounts.insert(to);
1252
1253            // Track deposit for balance delta verification.
1254            let value_i128: i128 = value.try_into().unwrap_or(i128::MAX);
1255            self.expected_balance_delta = self.expected_balance_delta.saturating_add(value_i128);
1256
1257            self.pending_tx = Some(PendingArbTx {
1258                sender,
1259                tx_gas_limit: 0,
1260                arb_tx_type: Some(ArbTxType::ArbitrumDepositTx),
1261                poster_gas: 0,
1262                evm_gas_used: 0,
1263
1264                charged_multi_gas: MultiGas::default(),
1265                gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
1266                stylus_data_fee: U256::ZERO,
1267                retry_context: None,
1268                coinbase_tip_per_gas: 0,
1269                capped_gas_price: false,
1270                actual_gas_price: self.arb_ctx.basefee,
1271            });
1272
1273            // Filtered deposits produce a failed receipt (status=0) via
1274            // ErrFilteredTx. The state changes (mint + redirected transfer)
1275            // are still committed.
1276            let result = if is_filtered {
1277                ExecutionResult::Revert {
1278                    gas_used: 0,
1279                    output: alloy_primitives::Bytes::from("filtered transaction"),
1280                }
1281            } else {
1282                ExecutionResult::Success {
1283                    reason: revm::context::result::SuccessReason::Return,
1284                    gas_used: 0,
1285                    gas_refunded: 0,
1286                    output: revm::context::result::Output::Call(alloy_primitives::Bytes::new()),
1287                    logs: Vec::new(),
1288                }
1289            };
1290
1291            return Ok(EthTxResult {
1292                result: revm::context::result::ResultAndState {
1293                    result,
1294                    state: Default::default(),
1295                },
1296                blob_gas_used: 0,
1297                tx_type,
1298            });
1299        }
1300
1301        // --- SubmitRetryable: skip EVM, handle fees/escrow/ticket creation ---
1302        if is_submit_retryable {
1303            if let Some(info) = recovered.tx().submit_retryable_info() {
1304                let ticket_id = recovered.tx().trie_hash();
1305                let tx_type = recovered.tx().tx_type();
1306                return self.execute_submit_retryable(ticket_id, tx_type, info);
1307            }
1308        }
1309
1310        // --- RetryTx pre-processing: escrow transfer and prepaid gas ---
1311        // Track retry pre-exec state so we can undo it if the inner execution
1312        // errors out before the outer state_transition can revert.
1313        let mut retry_pre_exec_undo: Option<(Address, U256, Address, U256)> = None;
1314        let mut retry_context = None;
1315        if is_retry_tx {
1316            if let Some(info) = recovered.tx().retry_tx_info() {
1317                let block = self.inner.evm().block();
1318                let current_time = revm::context::Block::timestamp(block).to::<u64>();
1319                let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1320                let state_ptr: *mut State<DB> = db as *mut State<DB>;
1321
1322                // Open the retryable ticket.
1323                if let Ok(arb_state) = ArbosState::open(state_ptr, SystemBurner::new(None, false)) {
1324                    let retryable = arb_state
1325                        .retryable_state
1326                        .open_retryable(info.ticket_id, current_time);
1327
1328                    match retryable {
1329                        Ok(Some(_)) => {
1330                            // Transfer call value from escrow to sender.
1331                            let escrow = retryables::retryable_escrow_address(info.ticket_id);
1332                            let value = recovered.tx().value();
1333
1334                            // Go's TransferBalance calls CreateZombieIfDeleted(from)
1335                            // when amount == 0 on pre-Stylus ArbOS.
1336                            if value.is_zero()
1337                                && self.arb_ctx.arbos_version
1338                                    < arb_chainspec::arbos_version::ARBOS_VERSION_STYLUS
1339                            {
1340                                create_zombie_if_deleted(
1341                                    db,
1342                                    escrow,
1343                                    &self.finalise_deleted,
1344                                    &mut self.zombie_accounts,
1345                                    &mut self.touched_accounts,
1346                                );
1347                            }
1348
1349                            if !try_transfer_balance(db, escrow, sender, value) {
1350                                // Escrow has insufficient funds — abort the retry tx.
1351                                let tx_type = recovered.tx().tx_type();
1352                                self.pending_tx = Some(PendingArbTx {
1353                                    sender,
1354                                    tx_gas_limit: 0,
1355                                    arb_tx_type: Some(ArbTxType::ArbitrumRetryTx),
1356                                    poster_gas: 0,
1357                                    evm_gas_used: 0,
1358
1359                                    charged_multi_gas: MultiGas::default(),
1360                                    gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
1361                                    stylus_data_fee: U256::ZERO,
1362                                    retry_context: None,
1363                                    coinbase_tip_per_gas: 0,
1364                                    capped_gas_price: false,
1365                                    actual_gas_price: self.arb_ctx.basefee,
1366                                });
1367                                return Ok(EthTxResult {
1368                                    result: revm::context::result::ResultAndState {
1369                                        result: ExecutionResult::Revert {
1370                                            gas_used: 0,
1371                                            output: alloy_primitives::Bytes::new(),
1372                                        },
1373                                        state: Default::default(),
1374                                    },
1375                                    blob_gas_used: 0,
1376                                    tx_type,
1377                                });
1378                            }
1379
1380                            // Track escrow transfer addresses.
1381                            if !value.is_zero() {
1382                                self.zombie_accounts.remove(&escrow);
1383                            }
1384                            self.zombie_accounts.remove(&sender);
1385                            self.touched_accounts.insert(escrow);
1386                            self.touched_accounts.insert(sender);
1387
1388                            // Mint prepaid gas to sender.
1389                            let prepaid = self
1390                                .arb_ctx
1391                                .basefee
1392                                .saturating_mul(U256::from(tx_gas_limit));
1393                            mint_balance(db, sender, prepaid);
1394                            retry_pre_exec_undo = Some((sender, prepaid, escrow, value));
1395
1396                            // Set retry context for end-tx processing.
1397                            if let Some(hooks) = self.arb_hooks.as_mut() {
1398                                hooks
1399                                    .tx_proc
1400                                    .prepare_retry_tx(info.ticket_id, info.refund_to);
1401                            }
1402
1403                            retry_context = Some(PendingRetryContext {
1404                                ticket_id: info.ticket_id,
1405                                refund_to: info.refund_to,
1406                                gas_fee_cap: info.gas_fee_cap,
1407                                max_refund: info.max_refund,
1408                                submission_fee_refund: info.submission_fee_refund,
1409                                call_value: recovered.tx().value(),
1410                            });
1411                        }
1412                        Ok(None) => {
1413                            // Retryable expired or not found — endTxNow=true.
1414                            let tx_type = recovered.tx().tx_type();
1415                            self.pending_tx = Some(PendingArbTx {
1416                                sender,
1417                                tx_gas_limit: 0,
1418                                arb_tx_type: Some(ArbTxType::ArbitrumRetryTx),
1419                                poster_gas: 0,
1420                                evm_gas_used: 0,
1421
1422                                charged_multi_gas: MultiGas::default(),
1423                                gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
1424                                stylus_data_fee: U256::ZERO,
1425                                retry_context: None,
1426                                coinbase_tip_per_gas: 0,
1427                                capped_gas_price: false,
1428                                actual_gas_price: self.arb_ctx.basefee,
1429                            });
1430                            let err_msg = format!("retryable ticket {} not found", info.ticket_id,);
1431                            return Ok(EthTxResult {
1432                                result: revm::context::result::ResultAndState {
1433                                    result: ExecutionResult::Revert {
1434                                        gas_used: 0,
1435                                        output: alloy_primitives::Bytes::from(err_msg.into_bytes()),
1436                                    },
1437                                    state: Default::default(),
1438                                },
1439                                blob_gas_used: 0,
1440                                tx_type,
1441                            });
1442                        }
1443                        Err(_) => {
1444                            // State error opening retryable — endTxNow=true.
1445                            let tx_type = recovered.tx().tx_type();
1446                            self.pending_tx = Some(PendingArbTx {
1447                                sender,
1448                                tx_gas_limit: 0,
1449                                arb_tx_type: Some(ArbTxType::ArbitrumRetryTx),
1450                                poster_gas: 0,
1451                                evm_gas_used: 0,
1452
1453                                charged_multi_gas: MultiGas::default(),
1454                                gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
1455                                stylus_data_fee: U256::ZERO,
1456                                retry_context: None,
1457                                coinbase_tip_per_gas: 0,
1458                                capped_gas_price: false,
1459                                actual_gas_price: self.arb_ctx.basefee,
1460                            });
1461                            return Ok(EthTxResult {
1462                                result: revm::context::result::ResultAndState {
1463                                    result: ExecutionResult::Revert {
1464                                        gas_used: 0,
1465                                        output: alloy_primitives::Bytes::from(
1466                                            format!("error opening retryable {}", info.ticket_id,)
1467                                                .into_bytes(),
1468                                        ),
1469                                    },
1470                                    state: Default::default(),
1471                                },
1472                                blob_gas_used: 0,
1473                                tx_type,
1474                            });
1475                        }
1476                    }
1477                }
1478            }
1479        }
1480
1481        // --- Poster cost and gas limiting ---
1482
1483        let mut poster_gas = 0u64;
1484        let mut compute_hold_gas = 0u64;
1485        let calldata_units: u64 = if has_poster_costs {
1486            let level = self.arb_ctx.brotli_compression_level;
1487            let coinbase = self.arb_ctx.coinbase;
1488            let tx_ref = recovered.tx();
1489            let units = if coinbase == l1_pricing::BATCH_POSTER_ADDRESS {
1490                let tx_bytes_ref = tx_ref;
1491                tx_ref.poster_units_for(level, &mut || {
1492                    l1_pricing::poster_units_from_bytes(&tx_bytes_ref.encoded_2718(), level)
1493                })
1494            } else {
1495                0
1496            };
1497            let poster_cost = self
1498                .arb_ctx
1499                .l1_price_per_unit
1500                .saturating_mul(U256::from(units));
1501
1502            if let Some(hooks) = self.arb_hooks.as_mut() {
1503                hooks.tx_proc.poster_gas = compute_poster_gas(
1504                    poster_cost,
1505                    actual_gas_price,
1506                    false,
1507                    self.arb_ctx.min_base_fee,
1508                );
1509                hooks.tx_proc.poster_fee =
1510                    actual_gas_price.saturating_mul(U256::from(hooks.tx_proc.poster_gas));
1511                poster_gas = hooks.tx_proc.poster_gas;
1512            }
1513
1514            units
1515        } else {
1516            0
1517        };
1518
1519        // Compute hold gas: clamp gas available for EVM execution to the
1520        // per-block (< v50) or per-tx (>= v50) gas limit. Applies to ALL
1521        // non-endTxNow txs (including retry txs with poster_gas=0), as the
1522        // GasChargingHook runs for every tx that enters the EVM.
1523        if let Some(hooks) = self.arb_hooks.as_mut() {
1524            if !hooks.is_eth_call {
1525                let spec = arb_chainspec::spec_id_by_arbos_version(self.arb_ctx.arbos_version);
1526                let intrinsic_estimate = estimate_intrinsic_gas(recovered.tx(), spec);
1527                let gas_after_intrinsic = tx_gas_limit.saturating_sub(intrinsic_estimate);
1528                let gas_after_poster = gas_after_intrinsic.saturating_sub(poster_gas);
1529
1530                let max_compute =
1531                    if hooks.arbos_version < arb_chainspec::arbos_version::ARBOS_VERSION_50 {
1532                        hooks.per_block_gas_limit
1533                    } else {
1534                        hooks.per_tx_gas_limit.saturating_sub(intrinsic_estimate)
1535                    };
1536
1537                if max_compute > 0 && gas_after_poster > max_compute {
1538                    compute_hold_gas = gas_after_poster - max_compute;
1539                    hooks.tx_proc.compute_hold_gas = compute_hold_gas;
1540                }
1541            }
1542        }
1543
1544        // ArbOS < 50: reject user txs whose compute gas exceeds block gas left,
1545        // but always allow the first user tx through (userTxsProcessed > 0).
1546        // ArbOS >= 50 uses per-tx gas limit clamping (compute_hold_gas) instead.
1547        // computeGas is clamped to at least TxGas before this check.
1548        if is_user_tx
1549            && self.arb_ctx.arbos_version < arb_chainspec::arbos_version::ARBOS_VERSION_50
1550            && self.user_txs_processed > 0
1551        {
1552            const TX_GAS: u64 = 21_000;
1553            let compute_gas = tx_gas_limit.saturating_sub(poster_gas).max(TX_GAS);
1554            if compute_gas > self.block_gas_left {
1555                return Err(BlockExecutionError::msg("block gas limit reached"));
1556            }
1557        }
1558
1559        // Add calldata units to L1 pricing state before EVM execution, and
1560        // read the filtered-tx status for the reverted_tx_hook via the same
1561        // ArbosState handle.
1562        let tx_hash_for_filter = recovered.tx().trie_hash();
1563        let is_filtered = {
1564            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1565            let state_ptr: *mut State<DB> = db as *mut State<DB>;
1566            match ArbosState::open(state_ptr, SystemBurner::new(None, false)) {
1567                Ok(arb_state) => {
1568                    if calldata_units > 0 {
1569                        let _ = arb_state
1570                            .l1_pricing_state
1571                            .add_to_units_since_update(calldata_units);
1572                    }
1573                    arb_state
1574                        .filtered_transactions
1575                        .is_filtered_free(tx_hash_for_filter)
1576                }
1577                Err(_) => false,
1578            }
1579        };
1580
1581        // Reduce the gas the EVM sees by poster_gas and compute_hold_gas.
1582        // poster_gas is subtracted here so that BuyGas charges
1583        // (gas_limit - poster_gas - compute_hold_gas) * baseFee. The resulting
1584        // balance overshoots the protocol's "full gas_limit charge" BALANCE by
1585        // `poster_gas * baseFee`; the custom BALANCE opcode handler subtracts
1586        // this correction via a thread-local.
1587        let mut tx_env = tx_env;
1588        let gas_deduction = poster_gas.saturating_add(compute_hold_gas);
1589        if gas_deduction > 0 {
1590            let evm_gas_limit_before = revm::context_interface::Transaction::gas_limit(&tx_env);
1591            tx_env.set_gas_limit(evm_gas_limit_before.saturating_sub(gas_deduction));
1592        }
1593
1594        // BALANCE/SELFBALANCE correction: the reduced gas_limit above makes
1595        // BuyGas charge `(posterGas + computeHoldGas) * baseFee` less than the
1596        // protocol requires, so the BALANCE handler subtracts this correction
1597        // whenever it queries the sender's balance.
1598        {
1599            let correction = self
1600                .arb_ctx
1601                .basefee
1602                .saturating_mul(U256::from(poster_gas.saturating_add(compute_hold_gas)));
1603            arb_precompiles::set_poster_balance_correction(correction);
1604            arb_precompiles::set_current_tx_sender(sender);
1605        }
1606
1607        // --- RevertedTxHook: check for pre-recorded reverted or filtered txs ---
1608        // Called after gas charging but before EVM execution.
1609        {
1610            use arbos::tx_processor::RevertedTxAction;
1611
1612            let tx_hash = tx_hash_for_filter;
1613
1614            if let Some(hooks) = self.arb_hooks.as_ref() {
1615                let action = hooks.tx_proc.reverted_tx_hook(
1616                    Some(tx_hash),
1617                    None, // pre_recorded_gas: sequencer-specific, not used in state machine
1618                    is_filtered,
1619                );
1620
1621                match action {
1622                    RevertedTxAction::PreRecordedRevert { gas_to_consume } => {
1623                        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1624                        increment_nonce(db, sender);
1625                        self.touched_accounts.insert(sender);
1626                        let gas_used = poster_gas + gas_to_consume;
1627                        let charged_multi_gas = MultiGas::single_dim_gas(poster_gas)
1628                            .saturating_add(MultiGas::computation_gas(gas_to_consume));
1629                        self.pending_tx = Some(PendingArbTx {
1630                            sender,
1631                            tx_gas_limit,
1632                            arb_tx_type,
1633                            poster_gas,
1634                            evm_gas_used: 0,
1635                            charged_multi_gas,
1636                            gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
1637                            stylus_data_fee: U256::ZERO,
1638                            retry_context,
1639                            coinbase_tip_per_gas: 0,
1640                            capped_gas_price: false,
1641                            actual_gas_price: self.arb_ctx.basefee,
1642                        });
1643                        return Ok(EthTxResult {
1644                            result: revm::context::result::ResultAndState {
1645                                result: ExecutionResult::Revert {
1646                                    gas_used,
1647                                    output: alloy_primitives::Bytes::new(),
1648                                },
1649                                state: Default::default(),
1650                            },
1651                            blob_gas_used: 0,
1652                            tx_type: envelope_tx_type,
1653                        });
1654                    }
1655                    RevertedTxAction::FilteredTx => {
1656                        let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1657                        increment_nonce(db, sender);
1658                        self.touched_accounts.insert(sender);
1659                        // Consume all remaining gas.
1660                        let gas_remaining = tx_gas_limit
1661                            .saturating_sub(poster_gas)
1662                            .saturating_sub(compute_hold_gas);
1663                        let gas_used = tx_gas_limit;
1664                        let charged_multi_gas = MultiGas::single_dim_gas(poster_gas)
1665                            .saturating_add(MultiGas::computation_gas(gas_remaining));
1666                        self.pending_tx = Some(PendingArbTx {
1667                            sender,
1668                            tx_gas_limit,
1669                            arb_tx_type,
1670                            poster_gas,
1671                            evm_gas_used: 0,
1672                            charged_multi_gas,
1673                            gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
1674                            stylus_data_fee: U256::ZERO,
1675                            retry_context,
1676                            coinbase_tip_per_gas: 0,
1677                            capped_gas_price: false,
1678                            actual_gas_price: self.arb_ctx.basefee,
1679                        });
1680                        return Ok(EthTxResult {
1681                            result: revm::context::result::ResultAndState {
1682                                result: ExecutionResult::Revert {
1683                                    gas_used,
1684                                    output: alloy_primitives::Bytes::from(
1685                                        "filtered transaction".as_bytes(),
1686                                    ),
1687                                },
1688                                state: Default::default(),
1689                            },
1690                            blob_gas_used: 0,
1691                            tx_type: envelope_tx_type,
1692                        });
1693                    }
1694                    RevertedTxAction::None => {}
1695                }
1696            }
1697        }
1698
1699        // --- Execute via inner EVM executor ---
1700
1701        // Save the original gas price before tip drop for upfront balance check.
1702        // The balance check uses GasFeeCap (full gas price), not the
1703        // effective gas price after tip drop.
1704        let upfront_gas_price: u128 = revm::context_interface::Transaction::gas_price(&tx_env);
1705
1706        // Effective tip per gas (per EIP-1559): min(max_priority_fee, max_fee - base_fee).
1707        // This is what revm mints to coinbase. Used by commit_transaction to
1708        // redirect coinbase tip to network when CollectTips() is true.
1709        let effective_tip_per_gas: u128 = {
1710            let bf: u128 = self.arb_ctx.basefee.try_into().unwrap_or(u128::MAX);
1711            let max_fee: u128 = upfront_gas_price; // gas_price() returns max_fee_per_gas for EIP-1559
1712            let max_priority: u128 =
1713                revm::context_interface::Transaction::max_priority_fee_per_gas(&tx_env)
1714                    .unwrap_or(0);
1715            let max_minus_bf = max_fee.saturating_sub(bf);
1716            max_priority.min(max_minus_bf)
1717        };
1718
1719        // Drop the priority fee tip: cap gas price to the base fee.
1720        // For ArbOS versions where CollectTips() = false (most pre-v60 + v60+
1721        // when tip-collection is disabled), capping makes GASPRICE return the
1722        // base fee. When CollectTips() = true (v9 or v60+ with the flag set),
1723        // we leave gas_price intact so GASPRICE returns the full price; revm
1724        // mints the tip to coinbase (= batch_poster) which is post-EVM
1725        // redirected to the network fee account by commit_transaction.
1726        let should_drop_tip = self
1727            .arb_hooks
1728            .as_ref()
1729            .map(|h| h.drop_tip())
1730            .unwrap_or(false);
1731        if should_drop_tip {
1732            let base_fee: u128 = self.arb_ctx.basefee.try_into().unwrap_or(u128::MAX);
1733            if upfront_gas_price > base_fee {
1734                tx_env.set_gas_price(base_fee);
1735                tx_env.set_gas_priority_fee(Some(0));
1736            }
1737        }
1738
1739        // Set the address aliasing flag for L1→L2 message types so that
1740        // ArbSys.wasMyCallersAddressAliased() and myCallersAddressWithoutAliasing()
1741        // observe it during this tx.
1742        arb_precompiles::set_tx_is_aliased(arbos::util::does_tx_type_alias(tx_type_raw));
1743
1744        {
1745            let poster_fee_val = self
1746                .arb_hooks
1747                .as_ref()
1748                .map(|h| h.tx_proc.poster_fee)
1749                .unwrap_or(U256::ZERO);
1750            arb_precompiles::set_current_tx_poster_fee(
1751                poster_fee_val.try_into().unwrap_or(u128::MAX),
1752            );
1753            let retryable_id = retry_context
1754                .as_ref()
1755                .map(|ctx| ctx.ticket_id)
1756                .unwrap_or(B256::ZERO);
1757            arb_precompiles::set_current_retryable_id(retryable_id);
1758            let redeemer = retry_context
1759                .as_ref()
1760                .map(|ctx| ctx.refund_to)
1761                .unwrap_or(Address::ZERO);
1762            arb_precompiles::set_current_redeemer(redeemer);
1763        }
1764
1765        let retry_undo = retry_pre_exec_undo;
1766        let rollback_pre_exec_state = |this: &mut Self, units: u64| {
1767            arb_precompiles::clear_tx_scratch();
1768            let db: &mut State<DB> = this.inner.evm_mut().db_mut();
1769            if units > 0 {
1770                let state_ptr: *mut State<DB> = db as *mut State<DB>;
1771                if let Ok(arb_state) = ArbosState::open(state_ptr, SystemBurner::new(None, false)) {
1772                    let _ = arb_state
1773                        .l1_pricing_state
1774                        .subtract_from_units_since_update(units);
1775                }
1776            }
1777            if let Some((retry_sender, prepaid, escrow, escrow_value)) = retry_undo {
1778                if !prepaid.is_zero() {
1779                    burn_balance(db, retry_sender, prepaid);
1780                }
1781                if !escrow_value.is_zero() {
1782                    let _ = try_transfer_balance(db, retry_sender, escrow, escrow_value);
1783                }
1784            }
1785        };
1786
1787        // Manual balance and nonce validation for user txs. ContractTx
1788        // (0x66) and RetryTx (0x68) skip nonce checks.
1789        if is_user_tx {
1790            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1791            let account = db
1792                .load_cache_account(sender)
1793                .ok()
1794                .and_then(|a| a.account_info());
1795            let sender_balance = account.as_ref().map(|a| a.balance).unwrap_or(U256::ZERO);
1796            let sender_nonce = account.as_ref().map(|a| a.nonce).unwrap_or(0);
1797
1798            // Nonce check: ContractTx skips (skipNonceChecks=true).
1799            if !is_contract_tx {
1800                let tx_nonce = revm::context_interface::Transaction::nonce(&tx_env);
1801                if tx_nonce != sender_nonce {
1802                    rollback_pre_exec_state(self, calldata_units);
1803                    return Err(BlockExecutionError::msg(format!(
1804                        "nonce mismatch: address {sender} tx nonce {tx_nonce} != state nonce {sender_nonce}"
1805                    )));
1806                }
1807            }
1808
1809            let gas_cost = U256::from(tx_gas_limit) * U256::from(upfront_gas_price);
1810            let tx_value = revm::context_interface::Transaction::value(&tx_env);
1811            let total_cost = gas_cost.saturating_add(tx_value);
1812            if sender_balance < total_cost {
1813                rollback_pre_exec_state(self, calldata_units);
1814                return Err(BlockExecutionError::msg(format!(
1815                    "insufficient funds: address {sender} have {sender_balance} want {total_cost}"
1816                )));
1817            }
1818        }
1819
1820        // Fix nonce for retry and contract txs: skipNonceChecks() skips
1821        // the preCheck nonce validation but the nonce is still incremented in
1822        // TransitionDb for non-CREATE calls. Override the tx_env nonce to
1823        // match the sender's current state nonce so revm increments from the
1824        // right value.
1825        if is_retry_tx || is_contract_tx {
1826            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1827            let sender_nonce = db
1828                .load_cache_account(sender)
1829                .map(|a| a.account_info().map(|i| i.nonce).unwrap_or(0))
1830                .unwrap_or(0);
1831            tx_env.set_nonce(sender_nonce);
1832        }
1833
1834        // For payable ArbWasm calls (ActivateProgram, CodehashKeepalive),
1835        // zero out value so revm doesn't transfer ETH to the precompile.
1836        // We handle the data fee transfer from sender to network post-commit.
1837        {
1838            let to_addr = match recovered.tx().kind() {
1839                TxKind::Call(a) => Some(a),
1840                _ => None,
1841            };
1842            if to_addr == Some(arb_precompiles::ARBWASM_ADDRESS) && tx_value > U256::ZERO {
1843                tx_env.set_value(U256::ZERO);
1844            }
1845        }
1846
1847        let mut output = match self
1848            .inner
1849            .execute_transaction_without_commit((tx_env, recovered))
1850        {
1851            Ok(o) => o,
1852            Err(e) => {
1853                rollback_pre_exec_state(self, calldata_units);
1854                return Err(e);
1855            }
1856        };
1857
1858        // Capture gas_used as reported by reth's EVM (before our adjustments).
1859        // This represents the gas cost reth already deducted from the sender.
1860        let evm_gas_used = output.result.result.gas_used();
1861
1862        // Adjust gas_used to include poster_gas only.
1863        // poster_gas was deducted from gas_limit before EVM execution so reth's
1864        // reported gas_used doesn't include it. Adding it back produces correct
1865        // receipt gas_used. compute_hold_gas is NOT added: it is returned via
1866        // calcHeldGasRefund() before computing final gasUsed, and
1867        // NonRefundableGas() excludes it from the refund denominator.
1868        if poster_gas > 0 {
1869            adjust_result_gas_used(&mut output.result.result, poster_gas);
1870        }
1871
1872        // Scan execution logs for RedeemScheduled events (manual redeem path).
1873        // The ArbRetryableTx.Redeem precompile emits this event; we discover it
1874        // here and schedule the retry tx via the ScheduledTxes() mechanism.
1875        //
1876        // The precompile emits a placeholder retry-tx hash (keccak256(ticket_id||nonce)).
1877        // Replace it with the real EIP-2718 encoded tx hash.
1878        let mut total_donated_gas = 0u64;
1879        // Collect (log_index, correct_hash) for patching logs before commit.
1880        let mut retry_tx_hash_fixes: Vec<(usize, B256)> = Vec::new();
1881        if let ExecutionResult::Success { ref logs, .. } = output.result.result {
1882            let redeem_topic = arb_precompiles::redeem_scheduled_topic();
1883            let precompile_addr = arb_precompiles::ARBRETRYABLETX_ADDRESS;
1884
1885            for (log_idx, log) in logs.iter().enumerate() {
1886                if log.address != precompile_addr {
1887                    continue;
1888                }
1889                if log.topics().is_empty() || log.topics()[0] != redeem_topic {
1890                    continue;
1891                }
1892                if log.topics().len() < 4 || log.data.data.len() < 128 {
1893                    continue;
1894                }
1895
1896                let ticket_id = log.topics()[1];
1897                let seq_num_bytes = log.topics()[3];
1898                let nonce =
1899                    u64::from_be_bytes(seq_num_bytes.0[24..32].try_into().unwrap_or([0u8; 8]));
1900                let data = &log.data.data;
1901                let donated_gas = U256::from_be_slice(&data[0..32]).to::<u64>();
1902                total_donated_gas = total_donated_gas.saturating_add(donated_gas);
1903                let gas_donor = Address::from_slice(&data[44..64]);
1904                let max_refund = U256::from_be_slice(&data[64..96]);
1905                let submission_fee_refund = U256::from_be_slice(&data[96..128]);
1906
1907                // Open the retryable and construct the retry tx.
1908                let db: &mut State<DB> = self.inner.evm_mut().db_mut();
1909                let state_ptr: *mut State<DB> = db as *mut State<DB>;
1910                let current_time = {
1911                    let block = self.inner.evm().block();
1912                    revm::context::Block::timestamp(block).to::<u64>()
1913                };
1914                if let Ok(arb_state) = ArbosState::open(state_ptr, SystemBurner::new(None, false)) {
1915                    if let Ok(Some(retryable)) = arb_state
1916                        .retryable_state
1917                        .open_retryable(ticket_id, current_time)
1918                    {
1919                        let _ = retryable.increment_num_tries();
1920
1921                        if let Ok(retry_tx) = retryable.make_tx(
1922                            U256::from(self.arb_ctx.chain_id),
1923                            nonce,
1924                            self.arb_ctx.basefee,
1925                            donated_gas,
1926                            ticket_id,
1927                            gas_donor,
1928                            max_refund,
1929                            submission_fee_refund,
1930                        ) {
1931                            // Compute the actual EIP-2718 retry tx hash.
1932                            let mut encoded = Vec::new();
1933                            encoded.push(ArbTxType::ArbitrumRetryTx.as_u8());
1934                            alloy_rlp::Encodable::encode(&retry_tx, &mut encoded);
1935                            let correct_hash = keccak256(&encoded);
1936                            retry_tx_hash_fixes.push((log_idx, correct_hash));
1937
1938                            if let Some(hooks) = self.arb_hooks.as_mut() {
1939                                hooks.tx_proc.scheduled_txs.push(encoded);
1940                            }
1941                        }
1942                    }
1943
1944                    // Shrink the backlog by the donated gas amount.
1945                    let _ = arb_state
1946                        .l2_pricing_state
1947                        .shrink_backlog(donated_gas, MultiGas::default());
1948                    if let Ok(b) = arb_state.l2_pricing_state.gas_backlog() {
1949                        arb_precompiles::set_current_gas_backlog(b);
1950                    }
1951                }
1952            }
1953        }
1954
1955        // Patch RedeemScheduled event logs with the correct retry tx hash.
1956        // The precompile emits a placeholder; we replace topic[2] with the
1957        // actual EIP-2718 encoded tx hash computed from the constructed retry tx.
1958        if !retry_tx_hash_fixes.is_empty() {
1959            if let ExecutionResult::Success { ref mut logs, .. } = output.result.result {
1960                for (log_idx, correct_hash) in &retry_tx_hash_fixes {
1961                    if let Some(log) = logs.get_mut(*log_idx) {
1962                        if log.data.topics().len() > 2 {
1963                            let topics = log.data.topics_mut_unchecked();
1964                            topics[2] = *correct_hash;
1965                        }
1966                    }
1967                }
1968            }
1969        }
1970
1971        // Handle Stylus activation/keepalive data fee payment post-commit.
1972        // We zero out tx_env.value before EVM execution (below) so revm
1973        // doesn't transfer value to the precompile. The data_fee transfer
1974        // from sender to network happens via the cache after commit.
1975        let stylus_data_fee = if arb_precompiles::take_stylus_activation_request().is_some()
1976            || arb_precompiles::take_stylus_keepalive_request().is_some()
1977        {
1978            arb_precompiles::take_stylus_activation_data_fee()
1979        } else {
1980            U256::ZERO
1981        };
1982
1983        // Inject pending precompile logs into the execution result.
1984        let pending_logs = arb_precompiles::take_pending_precompile_logs();
1985        if !pending_logs.is_empty() {
1986            if let ExecutionResult::Success { ref mut logs, .. } = output.result.result {
1987                for (address, topics, data) in pending_logs {
1988                    logs.push(Log {
1989                        address,
1990                        data: alloy_primitives::LogData::new(topics, data.into())
1991                            .unwrap_or_default(),
1992                    });
1993                }
1994            }
1995        }
1996
1997        let charged_multi_gas = MultiGas::single_dim_gas(poster_gas)
1998            .saturating_add(MultiGas::computation_gas(evm_gas_used));
1999
2000        // Capture effective tip per gas (gas_price - base_fee, clamped >= 0).
2001        // The effective tip per gas captured before EVM execution. Used by
2002        // commit_transaction to redirect coinbase's tip mint to network.
2003        let coinbase_tip_per_gas: u128 = effective_tip_per_gas;
2004        let capped_gas_price = should_drop_tip;
2005
2006        self.pending_tx = Some(PendingArbTx {
2007            sender,
2008            tx_gas_limit,
2009            arb_tx_type,
2010            poster_gas,
2011            evm_gas_used,
2012            charged_multi_gas,
2013            gas_price_positive: self.arb_ctx.basefee > U256::ZERO,
2014            stylus_data_fee,
2015            retry_context,
2016            coinbase_tip_per_gas,
2017            capped_gas_price,
2018            actual_gas_price,
2019        });
2020
2021        Ok(output)
2022    }
2023
2024    fn commit_transaction(&mut self, output: Self::Result) -> Result<u64, BlockExecutionError> {
2025        // Extract info needed for fee distribution before the output is consumed.
2026        let pending = self.pending_tx.take();
2027        let gas_used_total = output.result.result.gas_used();
2028        let success = matches!(&output.result.result, ExecutionResult::Success { .. });
2029
2030        // Scan receipt logs for L2→L1 withdrawal events and burn value from ArbSys.
2031        // Value transferred to the ArbSys address during a withdrawEth call
2032        // is burned (subtracted from ArbSys balance) after the tx commits.
2033        let mut withdrawal_value = U256::ZERO;
2034        if let ExecutionResult::Success { ref logs, .. } = output.result.result {
2035            let arbsys_addr = arb_precompiles::ARBSYS_ADDRESS;
2036            let l2_to_l1_tx_topic = keccak256(
2037                b"L2ToL1Tx(address,address,uint256,uint256,uint256,uint256,uint256,uint256,bytes)",
2038            );
2039            for log in logs {
2040                if log.address == arbsys_addr
2041                    && !log.data.topics().is_empty()
2042                    && log.data.topics()[0] == l2_to_l1_tx_topic
2043                {
2044                    // L2ToL1Tx data layout: ABI-encoded [caller, arb_block, eth_block, timestamp,
2045                    // callvalue, data] callvalue is at offset 4*32 = 128 bytes.
2046                    if log.data.data.len() >= 160 {
2047                        let callvalue = U256::from_be_slice(&log.data.data[128..160]);
2048                        withdrawal_value = withdrawal_value.saturating_add(callvalue);
2049                        let val_i128: i128 = callvalue.try_into().unwrap_or(i128::MAX);
2050                        self.expected_balance_delta =
2051                            self.expected_balance_delta.saturating_sub(val_i128);
2052                    }
2053                }
2054            }
2055        }
2056
2057        // Capture EVM-modified addresses for dirty tracking before commit consumes output.
2058        for addr in output.result.state.keys() {
2059            self.touched_accounts.insert(*addr);
2060        }
2061
2062        // Inner executor builds receipt with the adjusted gas_used and commits state.
2063        let gas_used = self.inner.commit_transaction(output)?;
2064
2065        // Redirect the coinbase tip to network_fee_account when
2066        // CollectTips is on. tx_env.gas_limit is shrunk by poster_gas before
2067        // revm, so revm only minted `tip * compute_gas` to coinbase — that's
2068        // the amount to transfer. tip × posterGas is burned implicitly.
2069        if let Some(ref p) = pending {
2070            if !p.capped_gas_price && p.coinbase_tip_per_gas > 0 && gas_used > 0 {
2071                let coinbase = self.arb_ctx.coinbase;
2072                let net_acct = self.arb_ctx.network_fee_account;
2073                let compute_gas = gas_used.saturating_sub(p.poster_gas);
2074                let tip_to_network =
2075                    U256::from(p.coinbase_tip_per_gas).saturating_mul(U256::from(compute_gas));
2076                if coinbase != net_acct && !tip_to_network.is_zero() {
2077                    let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2078                    if get_balance(db, coinbase) >= tip_to_network {
2079                        transfer_balance(db, coinbase, net_acct, tip_to_network);
2080                        self.touched_accounts.insert(coinbase);
2081                        self.touched_accounts.insert(net_acct);
2082                    }
2083                }
2084            }
2085        }
2086
2087        // Stylus activation data fee: sender → network (via cache, post-commit).
2088        // Value was zeroed in tx_env so sender still has the ETH.
2089        if let Some(ref p) = pending {
2090            if !p.stylus_data_fee.is_zero() {
2091                let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2092                burn_balance(db, p.sender, p.stylus_data_fee);
2093                mint_balance(db, self.arb_ctx.network_fee_account, p.stylus_data_fee);
2094                self.touched_accounts.insert(p.sender);
2095                self.touched_accounts
2096                    .insert(self.arb_ctx.network_fee_account);
2097            }
2098        }
2099
2100        // Burn ETH from ArbSys address for L2→L1 withdrawals.
2101        if !withdrawal_value.is_zero() {
2102            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2103            burn_balance(db, arb_precompiles::ARBSYS_ADDRESS, withdrawal_value);
2104            self.touched_accounts
2105                .insert(arb_precompiles::ARBSYS_ADDRESS);
2106        }
2107
2108        // Track poster gas and multi-gas for this receipt (parallel to receipts vector).
2109        let poster_gas_for_receipt = pending.as_ref().map_or(0, |p| p.poster_gas);
2110        self.gas_used_for_l1.push(poster_gas_for_receipt);
2111        let multi_gas_for_receipt = pending
2112            .as_ref()
2113            .map_or(MultiGas::zero(), |p| p.charged_multi_gas);
2114        self.multi_gas_used.push(multi_gas_for_receipt);
2115
2116        // --- Post-execution: fee distribution ---
2117        if let Some(pending) = pending {
2118            let is_retry = pending.retry_context.is_some();
2119
2120            // Safety check: gas refund should never exceed gas limit.
2121            debug_assert!(
2122                gas_used_total <= pending.tx_gas_limit,
2123                "gas_used ({gas_used_total}) exceeds gas_limit ({})",
2124                pending.tx_gas_limit
2125            );
2126
2127            // Charge the sender for gas reth's buyGas didn't cover: poster_gas
2128            // on normal txs, full gas_used on early-return paths. Priced at
2129            // actual_gas_price so `tip * posterGas` gets burned here (revm
2130            // never minted it to coinbase, since we shrunk gas_limit first).
2131            let sender_extra_gas = gas_used_total.saturating_sub(pending.evm_gas_used);
2132            if sender_extra_gas > 0 {
2133                let extra_cost = pending
2134                    .actual_gas_price
2135                    .saturating_mul(U256::from(sender_extra_gas));
2136                let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2137                burn_balance(db, pending.sender, extra_cost);
2138                self.touched_accounts.insert(pending.sender);
2139            }
2140
2141            if let Some(retry_ctx) = pending.retry_context {
2142                // RetryTx end-of-tx: handle gas refunds, retryable cleanup.
2143                let gas_left = pending.tx_gas_limit.saturating_sub(gas_used_total);
2144
2145                let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2146                let state_ptr: *mut State<DB> = db as *mut State<DB>;
2147                let touched_ptr = &mut self.touched_accounts as *mut rustc_hash::FxHashSet<Address>;
2148                let zombie_ptr = &mut self.zombie_accounts as *mut rustc_hash::FxHashSet<Address>;
2149                let finalise_ptr = &self.finalise_deleted as *const rustc_hash::FxHashSet<Address>;
2150                let arbos_ver = self.arb_ctx.arbos_version;
2151
2152                let arb_state_retry =
2153                    ArbosState::open(state_ptr, SystemBurner::new(None, false)).ok();
2154
2155                // Compute multi-dimensional cost for refund (ArbOS v60+).
2156                let multi_dimensional_cost = if self.arb_ctx.arbos_version
2157                    >= arb_chainspec::arbos_version::ARBOS_VERSION_MULTI_GAS_CONSTRAINTS
2158                {
2159                    arb_state_retry.as_ref().and_then(|s| {
2160                        let cached = self.multi_gas_current_fees.get_or_init(|| {
2161                            s.l2_pricing_state
2162                                .get_current_multi_gas_fees()
2163                                .unwrap_or([U256::ZERO; NUM_RESOURCE_KIND])
2164                        });
2165                        s.l2_pricing_state
2166                            .multi_dimensional_price_for_refund_with_fees(
2167                                pending.charged_multi_gas,
2168                                cached,
2169                            )
2170                            .ok()
2171                    })
2172                } else {
2173                    None
2174                };
2175
2176                let result = self.arb_hooks.as_ref().map(|hooks| {
2177                    hooks.tx_proc.end_tx_retryable(
2178                        &EndTxRetryableParams {
2179                            gas_left,
2180                            gas_used: gas_used_total,
2181                            effective_base_fee: self.arb_ctx.basefee,
2182                            from: pending.sender,
2183                            refund_to: retry_ctx.refund_to,
2184                            max_refund: retry_ctx.max_refund,
2185                            submission_fee_refund: retry_ctx.submission_fee_refund,
2186                            ticket_id: retry_ctx.ticket_id,
2187                            value: U256::ZERO, // Already transferred in pre-exec
2188                            success,
2189                            network_fee_account: self.arb_ctx.network_fee_account,
2190                            infra_fee_account: self.arb_ctx.infra_fee_account,
2191                            min_base_fee: self.arb_ctx.min_base_fee,
2192                            arbos_version: self.arb_ctx.arbos_version,
2193                            multi_dimensional_cost,
2194                            block_base_fee: self.arb_ctx.basefee,
2195                        },
2196                        |addr, amount| unsafe {
2197                            burn_balance(&mut *state_ptr, addr, amount);
2198                            (*touched_ptr).insert(addr);
2199                        },
2200                        |from, to, amount| {
2201                            unsafe {
2202                                if amount.is_zero()
2203                                    && arbos_ver
2204                                        < arb_chainspec::arbos_version::ARBOS_VERSION_STYLUS
2205                                {
2206                                    create_zombie_if_deleted(
2207                                        &mut *state_ptr,
2208                                        from,
2209                                        &*finalise_ptr,
2210                                        &mut *zombie_ptr,
2211                                        &mut *touched_ptr,
2212                                    );
2213                                }
2214                                transfer_balance(&mut *state_ptr, from, to, amount);
2215                                // Go's SubBalance(from, nonzero) creates a non-zombie
2216                                // balanceChange entry, breaking zombie protection.
2217                                if !amount.is_zero() {
2218                                    (*zombie_ptr).remove(&from);
2219                                }
2220                                // Go's AddBalance(to, _) dirts `to`, breaking zombie.
2221                                (*zombie_ptr).remove(&to);
2222                                (*touched_ptr).insert(from);
2223                                (*touched_ptr).insert(to);
2224                            }
2225                            Ok(())
2226                        },
2227                    )
2228                });
2229
2230                if let Some(ref result) = result {
2231                    if result.should_delete_retryable {
2232                        if let Some(arb_state) = arb_state_retry.as_ref() {
2233                            let _ = arb_state.retryable_state.delete_retryable(
2234                                retry_ctx.ticket_id,
2235                                |from, to, amount| {
2236                                    unsafe {
2237                                        if amount.is_zero()
2238                                            && arbos_ver
2239                                                < arb_chainspec::arbos_version::ARBOS_VERSION_STYLUS
2240                                        {
2241                                            create_zombie_if_deleted(
2242                                                &mut *state_ptr,
2243                                                from,
2244                                                &*finalise_ptr,
2245                                                &mut *zombie_ptr,
2246                                                &mut *touched_ptr,
2247                                            );
2248                                        }
2249                                        transfer_balance(&mut *state_ptr, from, to, amount);
2250                                        if !amount.is_zero() {
2251                                            (*zombie_ptr).remove(&from);
2252                                        }
2253                                        (*zombie_ptr).remove(&to);
2254                                        (*touched_ptr).insert(from);
2255                                        (*touched_ptr).insert(to);
2256                                    }
2257                                    Ok(())
2258                                },
2259                                |addr| unsafe { get_balance(&mut *state_ptr, addr) },
2260                            );
2261                        }
2262                    } else if result.should_return_value_to_escrow {
2263                        // Failed retry: return call value to escrow.
2264                        unsafe {
2265                            if retry_ctx.call_value.is_zero()
2266                                && arbos_ver < arb_chainspec::arbos_version::ARBOS_VERSION_STYLUS
2267                            {
2268                                create_zombie_if_deleted(
2269                                    &mut *state_ptr,
2270                                    pending.sender,
2271                                    &*finalise_ptr,
2272                                    &mut *zombie_ptr,
2273                                    &mut *touched_ptr,
2274                                );
2275                            }
2276                            transfer_balance(
2277                                &mut *state_ptr,
2278                                pending.sender,
2279                                result.escrow_address,
2280                                retry_ctx.call_value,
2281                            );
2282                            // Go's SubBalance(sender, nonzero) breaks zombie on sender.
2283                            if !retry_ctx.call_value.is_zero() {
2284                                (*zombie_ptr).remove(&pending.sender);
2285                            }
2286                            // Go's AddBalance(escrow, _) breaks zombie on escrow.
2287                            (*zombie_ptr).remove(&result.escrow_address);
2288                            (*touched_ptr).insert(pending.sender);
2289                            (*touched_ptr).insert(result.escrow_address);
2290                        }
2291                    }
2292
2293                    // Grow gas backlog unconditionally for retryable txs.
2294                    // Unlike normal txs, backlog growth is unconditional here.
2295                    if let Some(arb_state) = arb_state_retry.as_ref() {
2296                        let _ = arb_state.l2_pricing_state.grow_backlog(
2297                            result.compute_gas_for_backlog,
2298                            pending.charged_multi_gas,
2299                        );
2300                        if let Ok(b) = arb_state.l2_pricing_state.gas_backlog() {
2301                            arb_precompiles::set_current_gas_backlog(b);
2302                        }
2303                    }
2304                }
2305            } else if matches!(
2306                pending.arb_tx_type,
2307                None | Some(ArbTxType::ArbitrumLegacyTx)
2308                    | Some(ArbTxType::ArbitrumUnsignedTx)
2309                    | Some(ArbTxType::ArbitrumContractTx)
2310            ) {
2311                // Normal tx fee distribution: standard EOA-signed txs, plus
2312                // UnsignedTx/ContractTx (L1->L2 messages that pass through normal
2313                // EVM gas charging). Poster cost is zero for the latter two.
2314                let gas_left = pending.tx_gas_limit.saturating_sub(gas_used_total);
2315
2316                let fee_dist = self.arb_hooks.as_ref().map(|hooks| {
2317                    hooks.compute_end_tx_fees(&EndTxContext {
2318                        sender: pending.sender,
2319                        gas_left,
2320                        gas_used: gas_used_total,
2321                        gas_price: self.arb_ctx.basefee,
2322                        base_fee: self.arb_ctx.basefee,
2323                        tx_type: pending.arb_tx_type.unwrap_or(ArbTxType::ArbitrumLegacyTx),
2324                        success,
2325                        refund_to: pending.sender,
2326                    })
2327                });
2328
2329                if let Some(ref dist) = fee_dist {
2330                    let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2331                    apply_fee_distribution(db, dist, None);
2332                    // Skip the network-fee touch when compute cost is 0
2333                    // (avoids a no-op EIP-161 touch).
2334                    if !dist.network_fee_amount.is_zero() {
2335                        self.touched_accounts.insert(dist.network_fee_account);
2336                    }
2337                    self.touched_accounts.insert(dist.infra_fee_account);
2338                    self.touched_accounts.insert(dist.poster_fee_destination);
2339
2340                    let state_ptr: *mut State<DB> = db as *mut State<DB>;
2341                    let arb_state_post =
2342                        ArbosState::open(state_ptr, SystemBurner::new(None, false)).ok();
2343
2344                    // Multi-dimensional gas refund: if the multi-gas cost is less
2345                    // than the single-gas cost, refund the difference to the sender.
2346                    if self.arb_ctx.arbos_version
2347                        >= arb_chainspec::arbos_version::ARBOS_VERSION_MULTI_GAS_CONSTRAINTS
2348                    {
2349                        if let Some(arb_state) = arb_state_post.as_ref() {
2350                            let total_cost = self
2351                                .arb_ctx
2352                                .basefee
2353                                .saturating_mul(U256::from(gas_used_total));
2354                            let cached = self.multi_gas_current_fees.get_or_init(|| {
2355                                arb_state
2356                                    .l2_pricing_state
2357                                    .get_current_multi_gas_fees()
2358                                    .unwrap_or([U256::ZERO; NUM_RESOURCE_KIND])
2359                            });
2360                            if let Ok(multi_cost) = arb_state
2361                                .l2_pricing_state
2362                                .multi_dimensional_price_for_refund_with_fees(
2363                                    pending.charged_multi_gas,
2364                                    cached,
2365                                )
2366                            {
2367                                if total_cost > multi_cost {
2368                                    let refund_amount = total_cost.saturating_sub(multi_cost);
2369                                    transfer_balance(
2370                                        db,
2371                                        dist.network_fee_account,
2372                                        pending.sender,
2373                                        refund_amount,
2374                                    );
2375                                    self.touched_accounts.insert(dist.network_fee_account);
2376                                    self.touched_accounts.insert(pending.sender);
2377                                }
2378                            }
2379                        }
2380                    }
2381
2382                    // Remove poster gas from the L1Calldata dimension: the
2383                    // poster gas was added during gas charging, but for backlog
2384                    // growth we only want compute gas in the multi-gas.
2385                    let used_multi_gas = pending
2386                        .charged_multi_gas
2387                        .saturating_sub(MultiGas::single_dim_gas(pending.poster_gas));
2388
2389                    if let Some(arb_state) = arb_state_post.as_ref() {
2390                        // Backlog update is skipped when gas price is zero.
2391                        if pending.gas_price_positive {
2392                            let _ = arb_state
2393                                .l2_pricing_state
2394                                .grow_backlog(dist.compute_gas_for_backlog, used_multi_gas);
2395                            if let Ok(b) = arb_state.l2_pricing_state.gas_backlog() {
2396                                arb_precompiles::set_current_gas_backlog(b);
2397                            }
2398                        }
2399                        if !dist.l1_fees_to_add.is_zero() {
2400                            let _ = arb_state
2401                                .l1_pricing_state
2402                                .add_to_l1_fees_available(dist.l1_fees_to_add);
2403                        }
2404                    } else {
2405                        tracing::error!(
2406                            target: "arb::backlog",
2407                            "NormalTx: ArbosState::open FAILED for grow_backlog"
2408                        );
2409                    }
2410                }
2411            }
2412
2413            // FixRedeemGas (ArbOS >= 11): subtract gas allocated to scheduled
2414            // retry txs from this tx's gas_used for block rate limiting, since
2415            // that gas will be accounted for when the retry tx itself executes.
2416            let mut adjusted_gas_used = gas_used_total;
2417            if self.arb_ctx.arbos_version
2418                >= arb_chainspec::arbos_version::ARBOS_VERSION_FIX_REDEEM_GAS
2419            {
2420                if let Some(hooks) = self.arb_hooks.as_ref() {
2421                    for scheduled in &hooks.tx_proc.scheduled_txs {
2422                        if let Some(retry_gas) = decode_retry_tx_gas(scheduled) {
2423                            adjusted_gas_used = adjusted_gas_used.saturating_sub(retry_gas);
2424                        }
2425                    }
2426                }
2427            }
2428
2429            // Block gas rate limiting: deduct compute gas from block budget.
2430            const TX_GAS: u64 = 21_000;
2431            let data_gas = pending.poster_gas;
2432            let compute_used = if adjusted_gas_used < data_gas {
2433                TX_GAS
2434            } else {
2435                let compute = adjusted_gas_used - data_gas;
2436                if compute < TX_GAS {
2437                    TX_GAS
2438                } else {
2439                    compute
2440                }
2441            };
2442            self.block_gas_left = self.block_gas_left.saturating_sub(compute_used);
2443
2444            // Track user txs for the ArbOS < 50 first-tx bypass.
2445            let is_user_tx = !matches!(
2446                pending.arb_tx_type,
2447                Some(ArbTxType::ArbitrumInternalTx)
2448                    | Some(ArbTxType::ArbitrumDepositTx)
2449                    | Some(ArbTxType::ArbitrumSubmitRetryableTx)
2450                    | Some(ArbTxType::ArbitrumRetryTx)
2451            );
2452            if is_user_tx {
2453                self.user_txs_processed += 1;
2454            }
2455
2456            let _ = is_retry; // suppress unused warning
2457        }
2458
2459        arb_precompiles::clear_tx_scratch();
2460
2461        // Per-tx Finalise: delete empty accounts from cache.
2462        // Only iterates touched accounts (matching Go's journal.dirties).
2463        // Accounts merely loaded (e.g. balance check) are not considered.
2464        //
2465        // Go's Finalise protects zombie accounts: an account is zombie-protected
2466        // if ALL its journal dirty entries are createZombieChange entries.
2467        // Our zombie_accounts set approximates this — if a zombie is subsequently
2468        // dirtied by a non-zero transfer, it's removed from zombie_accounts
2469        // (matching Go's dirtyCount > zombieEntries check).
2470        {
2471            let keccak_empty = alloy_primitives::B256::from(alloy_primitives::keccak256([]));
2472            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2473            let to_remove: Vec<Address> = self
2474                .touched_accounts
2475                .drain()
2476                .filter(|addr| {
2477                    // Zombie accounts must be preserved even if empty.
2478                    if self.zombie_accounts.contains(addr) {
2479                        return false;
2480                    }
2481                    if let Some(cached) = db.cache.accounts.get(addr) {
2482                        if let Some(ref acct) = cached.account {
2483                            let is_empty = acct.info.nonce == 0
2484                                && acct.info.balance.is_zero()
2485                                && acct.info.code_hash == keccak_empty;
2486                            return is_empty;
2487                        }
2488                    }
2489                    false
2490                })
2491                .collect();
2492
2493            // Mark deleted accounts as destroyed in the cache instead of
2494            // removing them. Removing from cache causes the NEXT transaction
2495            // in the same block to reload stale data from the database when
2496            // it accesses the address (Entry::Vacant path in
2497            // load_cache_account). Keeping the entry with account=None
2498            // ensures subsequent accesses see a non-existent account —
2499            // matching Go's stateObject.deleted=true behaviour in Finalise.
2500            for addr in &to_remove {
2501                crate::state_overlay::record_pre_touch(db, *addr);
2502                if let Some(cached) = db.cache.accounts.get_mut(addr) {
2503                    cached.account = None;
2504                }
2505            }
2506            self.finalise_deleted.extend(to_remove);
2507        }
2508
2509        {
2510            let db: &mut State<DB> = self.inner.evm_mut().db_mut();
2511            crate::state_overlay::drain_and_apply(db);
2512        }
2513
2514        Ok(gas_used)
2515    }
2516
2517    fn finish(self) -> Result<(Self::Evm, BlockExecutionResult<R::Receipt>), BlockExecutionError> {
2518        // Log if expected balance delta is non-zero (deposits/withdrawals occurred).
2519        if self.expected_balance_delta != 0 {
2520            tracing::trace!(
2521                target: "arb::executor",
2522                delta = self.expected_balance_delta,
2523                "expected balance delta from deposits/withdrawals"
2524            );
2525        }
2526        // Skip inner.finish() to avoid Ethereum block rewards.
2527        // Arbitrum has no block rewards (no PoW/PoS mining).
2528        // Directly extract the EVM and receipts instead.
2529        let mut result = BlockExecutionResult {
2530            receipts: self.inner.receipts,
2531            requests: Default::default(),
2532            gas_used: self.inner.gas_used,
2533            blob_gas_used: self.inner.blob_gas_used,
2534        };
2535        // Set Arbitrum-specific fields on each receipt from tracking vectors.
2536        for (i, receipt) in result.receipts.iter_mut().enumerate() {
2537            if let Some(&l1_gas) = self.gas_used_for_l1.get(i) {
2538                arb_primitives::SetArbReceiptFields::set_gas_used_for_l1(receipt, l1_gas);
2539            }
2540            if let Some(&multi_gas) = self.multi_gas_used.get(i) {
2541                arb_primitives::SetArbReceiptFields::set_multi_gas_used(receipt, multi_gas);
2542            }
2543        }
2544        Ok((self.inner.evm, result))
2545    }
2546
2547    fn set_state_hook(&mut self, hook: Option<Box<dyn OnStateHook>>) {
2548        self.inner.set_state_hook(hook);
2549    }
2550
2551    fn evm_mut(&mut self) -> &mut Self::Evm {
2552        self.inner.evm_mut()
2553    }
2554
2555    fn evm(&self) -> &Self::Evm {
2556        self.inner.evm()
2557    }
2558
2559    fn receipts(&self) -> &[Self::Receipt] {
2560        self.inner.receipts()
2561    }
2562}
2563
2564// ---------------------------------------------------------------------------
2565// Helpers
2566// ---------------------------------------------------------------------------
2567
2568/// Adjust gas_used in an `ExecutionResult` by adding extra gas.
2569///
2570/// Used to account for poster gas (L1 data cost) which is deducted before
2571/// EVM execution but must be reflected in the receipt's gas_used.
2572fn adjust_result_gas_used<H>(result: &mut ExecutionResult<H>, extra_gas: u64) {
2573    match result {
2574        ExecutionResult::Success { gas_used, .. } => *gas_used = gas_used.saturating_add(extra_gas),
2575        ExecutionResult::Revert { gas_used, .. } => *gas_used = gas_used.saturating_add(extra_gas),
2576        ExecutionResult::Halt { gas_used, .. } => *gas_used = gas_used.saturating_add(extra_gas),
2577    }
2578}
2579
2580/// Mint balance to an address. Zero-amount is a no-op (matches AddBalance).
2581fn mint_balance<DB: Database>(state: &mut State<DB>, address: Address, amount: U256) {
2582    if amount.is_zero() {
2583        return;
2584    }
2585    crate::state_overlay::record_pre_touch(state, address);
2586    if let Some(cache_acct) = state.cache.accounts.get_mut(&address) {
2587        if let Some(ref mut acct) = cache_acct.account {
2588            acct.info.balance = acct.info.balance.saturating_add(amount);
2589        } else {
2590            cache_acct.account = Some(revm_database::states::plain_account::PlainAccount {
2591                info: revm_state::AccountInfo {
2592                    balance: amount,
2593                    ..Default::default()
2594                },
2595                storage: Default::default(),
2596            });
2597        }
2598    }
2599}
2600
2601/// Burn balance from an address. Zero-amount is a no-op (matches SubBalance).
2602fn burn_balance<DB: Database>(state: &mut State<DB>, address: Address, amount: U256) {
2603    if amount.is_zero() {
2604        return;
2605    }
2606    crate::state_overlay::record_pre_touch(state, address);
2607    if let Some(cache_acct) = state.cache.accounts.get_mut(&address) {
2608        if let Some(ref mut acct) = cache_acct.account {
2609            acct.info.balance = acct.info.balance.saturating_sub(amount);
2610        }
2611    }
2612}
2613
2614/// Increment the nonce of an account.
2615fn increment_nonce<DB: Database>(state: &mut State<DB>, address: Address) {
2616    crate::state_overlay::record_pre_touch(state, address);
2617    if let Some(cache_acct) = state.cache.accounts.get_mut(&address) {
2618        if let Some(ref mut acct) = cache_acct.account {
2619            acct.info.nonce += 1;
2620        }
2621    }
2622}
2623
2624/// Read the balance of an account in the EVM state.
2625fn get_balance<DB: Database>(state: &mut State<DB>, address: Address) -> U256 {
2626    match revm::Database::basic(state, address) {
2627        Ok(Some(info)) => info.balance,
2628        _ => U256::ZERO,
2629    }
2630}
2631
2632/// Transfer balance between two addresses. Skipped on insufficient funds.
2633///
2634/// At ArbOS >= Stylus a zero-amount call is a complete no-op. Pre-Stylus
2635/// callers that need the zombie-deleted semantics must use a dedicated
2636/// helper or call `create_zombie_if_deleted` directly.
2637fn transfer_balance<DB: Database>(state: &mut State<DB>, from: Address, to: Address, amount: U256) {
2638    if amount.is_zero() {
2639        return;
2640    }
2641    // No from == to early return — Go always does SubBalance + AddBalance
2642    // independently even when from == to. This ensures the account gets
2643    // dirtied in the state trie consistently.
2644    let balance = get_balance(state, from);
2645    if balance < amount {
2646        tracing::warn!(
2647            target: "arb::executor",
2648            %from, %to, %amount, %balance,
2649            "transfer_balance: insufficient funds, skipping"
2650        );
2651        return;
2652    }
2653    burn_balance(state, from, amount);
2654    mint_balance(state, to, amount);
2655}
2656
2657/// Re-create an empty account that was deleted by per-tx Finalise.
2658/// Matches Go's `CreateZombieIfDeleted`: if `addr` was removed by Finalise
2659/// (present in `finalise_deleted`) and no longer in cache, create a zombie.
2660/// Go calls this for `from` in TransferBalance when amount == 0 and
2661/// ArbOS version < Stylus.
2662fn create_zombie_if_deleted<DB: Database>(
2663    state: &mut State<DB>,
2664    addr: Address,
2665    finalise_deleted: &rustc_hash::FxHashSet<Address>,
2666    zombie_accounts: &mut rustc_hash::FxHashSet<Address>,
2667    touched_accounts: &mut rustc_hash::FxHashSet<Address>,
2668) {
2669    crate::state_overlay::record_pre_touch(state, addr);
2670    let account_missing = state
2671        .cache
2672        .accounts
2673        .get(&addr)
2674        .is_none_or(|c| c.account.is_none());
2675    if account_missing && finalise_deleted.contains(&addr) {
2676        if let Some(cached) = state.cache.accounts.get_mut(&addr) {
2677            cached.account = Some(revm_database::states::plain_account::PlainAccount {
2678                info: revm_state::AccountInfo::default(),
2679                storage: Default::default(),
2680            });
2681            cached.status = revm_database::AccountStatus::InMemoryChange;
2682        }
2683        zombie_accounts.insert(addr);
2684        touched_accounts.insert(addr);
2685    }
2686}
2687
2688/// Transfer balance with balance check. Returns false if sender has
2689/// insufficient funds (no state changes in that case). Zero-amount is a
2690/// no-op at ArbOS >= Stylus.
2691fn try_transfer_balance<DB: Database>(
2692    state: &mut State<DB>,
2693    from: Address,
2694    to: Address,
2695    amount: U256,
2696) -> bool {
2697    if amount.is_zero() {
2698        return true;
2699    }
2700    if get_balance(state, from) < amount {
2701        return false;
2702    }
2703    burn_balance(state, from, amount);
2704    mint_balance(state, to, amount);
2705    true
2706}
2707
2708/// Apply a computed fee distribution to the EVM state.
2709fn apply_fee_distribution<DB: Database>(
2710    state: &mut State<DB>,
2711    dist: &EndTxFeeDistribution,
2712    l1_pricing: Option<&l1_pricing::L1PricingState<DB>>,
2713) {
2714    // Skip the 0-value mint to avoid an EIP-161 touch on the network
2715    // fee account.
2716    if !dist.network_fee_amount.is_zero() {
2717        mint_balance(state, dist.network_fee_account, dist.network_fee_amount);
2718    }
2719    mint_balance(state, dist.infra_fee_account, dist.infra_fee_amount);
2720    mint_balance(state, dist.poster_fee_destination, dist.poster_fee_amount);
2721
2722    if !dist.l1_fees_to_add.is_zero() {
2723        if let Some(l1_state) = l1_pricing {
2724            let _ = l1_state.add_to_l1_fees_available(dist.l1_fees_to_add);
2725        }
2726    }
2727
2728    tracing::trace!(
2729        target: "arb::executor",
2730        network_fee = %dist.network_fee_amount,
2731        infra_fee = %dist.infra_fee_amount,
2732        poster_fee = %dist.poster_fee_amount,
2733        poster_dest = %dist.poster_fee_destination,
2734        l1_fees_added = %dist.l1_fees_to_add,
2735        backlog_gas = dist.compute_gas_for_backlog,
2736        "applied fee distribution"
2737    );
2738}
2739
2740/// Estimate intrinsic gas for a transaction.
2741///
2742/// Matches geth's `IntrinsicGas()`: base 21000 + calldata cost + create cost +
2743/// access list cost + EIP-3860 initcode cost (Shanghai+).
2744/// Must be spec-aware to avoid charging initcode cost at pre-Shanghai specs.
2745fn estimate_intrinsic_gas(tx: &impl Transaction, spec: revm::primitives::hardfork::SpecId) -> u64 {
2746    const TX_GAS: u64 = 21_000;
2747    const TX_CREATE_GAS: u64 = 32_000;
2748    const TX_DATA_ZERO_GAS: u64 = 4;
2749    const TX_DATA_NON_ZERO_GAS: u64 = 16;
2750    const TX_ACCESS_LIST_ADDRESS_GAS: u64 = 2400;
2751    const TX_ACCESS_LIST_STORAGE_KEY_GAS: u64 = 1900;
2752    const INIT_CODE_WORD_GAS: u64 = 2;
2753
2754    let is_create = tx.to().is_none();
2755
2756    let mut gas = TX_GAS;
2757    if is_create {
2758        gas += TX_CREATE_GAS;
2759    }
2760
2761    let data = tx.input();
2762
2763    // Calldata cost.
2764    let data_gas: u64 = data
2765        .iter()
2766        .map(|&b| {
2767            if b == 0 {
2768                TX_DATA_ZERO_GAS
2769            } else {
2770                TX_DATA_NON_ZERO_GAS
2771            }
2772        })
2773        .sum();
2774    gas = gas.saturating_add(data_gas);
2775
2776    // EIP-2930: access list cost.
2777    if let Some(access_list) = tx.access_list() {
2778        for item in access_list.iter() {
2779            gas = gas.saturating_add(TX_ACCESS_LIST_ADDRESS_GAS);
2780            gas = gas.saturating_add(
2781                (item.storage_keys.len() as u64).saturating_mul(TX_ACCESS_LIST_STORAGE_KEY_GAS),
2782            );
2783        }
2784    }
2785
2786    // EIP-3860: initcode word cost for CREATE txs (Shanghai+).
2787    if spec.is_enabled_in(revm::primitives::hardfork::SpecId::SHANGHAI)
2788        && is_create
2789        && !data.is_empty()
2790    {
2791        let words = (data.len() as u64).div_ceil(32);
2792        gas = gas.saturating_add(words.saturating_mul(INIT_CODE_WORD_GAS));
2793    }
2794
2795    gas
2796}
2797
2798/// Decode delayed_messages_read (bytes 32-39) and L2 block number (bytes 40-47)
2799/// from the extra_data field passed through EthBlockExecutionCtx.
2800fn decode_extra_fields(extra_bytes: &[u8]) -> (u64, u64) {
2801    let delayed = if extra_bytes.len() >= 40 {
2802        let mut buf = [0u8; 8];
2803        buf.copy_from_slice(&extra_bytes[32..40]);
2804        u64::from_be_bytes(buf)
2805    } else {
2806        0
2807    };
2808    let l2_block = if extra_bytes.len() >= 48 {
2809        let mut buf = [0u8; 8];
2810        buf.copy_from_slice(&extra_bytes[40..48]);
2811        u64::from_be_bytes(buf)
2812    } else {
2813        0
2814    };
2815    (delayed, l2_block)
2816}
2817
2818/// EIP-2935: Store the parent block hash in the history storage contract.
2819///
2820/// For Arbitrum, uses L2 block numbers and a buffer size of 393168 blocks.
2821fn process_parent_block_hash<DB: Database>(
2822    state: &mut State<DB>,
2823    l2_block_number: u64,
2824    prev_hash: B256,
2825) {
2826    use arb_primitives::arbos_versions::HISTORY_STORAGE_ADDRESS;
2827
2828    /// Arbitrum EIP-2935 buffer size (matching the Arbitrum history storage contract).
2829    const HISTORY_SERVE_WINDOW: u64 = 393168;
2830
2831    if l2_block_number == 0 {
2832        return;
2833    }
2834
2835    let slot = U256::from((l2_block_number - 1) % HISTORY_SERVE_WINDOW);
2836    let value = U256::from_be_slice(prev_hash.as_slice());
2837
2838    arb_storage::write_storage_at(state, HISTORY_STORAGE_ADDRESS, slot, value);
2839}
2840
2841/// Extract the gas field from a scheduled retry tx's encoded bytes.
2842///
2843/// The encoded format is `[type_byte][RLP(ArbRetryTx)]`.
2844fn decode_retry_tx_gas(encoded: &[u8]) -> Option<u64> {
2845    if encoded.is_empty() {
2846        return None;
2847    }
2848    if encoded[0] != ArbTxType::ArbitrumRetryTx.as_u8() {
2849        tracing::warn!(
2850            target: "arb::executor",
2851            tx_type = encoded[0],
2852            "unexpected scheduled tx type"
2853        );
2854        return None;
2855    }
2856    let rlp_data = &encoded[1..];
2857    let retry =
2858        <arb_alloy_consensus::tx::ArbRetryTx as alloy_rlp::Decodable>::decode(&mut &rlp_data[..])
2859            .ok()?;
2860    Some(retry.gas)
2861}