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