arbos/
tx_processor.rs

1use std::collections::HashMap;
2
3use alloy_primitives::{Address, B256, U256};
4use arb_chainspec::arbos_version as arb_ver;
5
6use crate::{l1_pricing, retryables, util::BalanceError};
7
8/// ArbOS system address (0x00000000000000000000000000000000000a4b05).
9pub const ARBOS_ADDRESS: Address = {
10    let mut bytes = [0u8; 20];
11    bytes[17] = 0x0a;
12    bytes[18] = 0x4b;
13    bytes[19] = 0x05;
14    Address::new(bytes)
15};
16
17/// Padding applied to L1 gas price estimates for safety margin (110% = 11000 bips).
18pub const GAS_ESTIMATION_L1_PRICE_PADDING_BIPS: u64 = 11000;
19
20/// Per-transaction state for processing Arbitrum transactions.
21///
22/// Created and freed for every L2 transaction. Tracks ArbOS state
23/// that influences transaction processing. In reth, this is used by
24/// the block executor's per-transaction logic.
25#[derive(Debug)]
26pub struct TxProcessor {
27    /// The poster's fee contribution (L1 calldata cost expressed in ETH).
28    pub poster_fee: U256,
29    /// Gas reserved for L1 posting costs.
30    pub poster_gas: u64,
31    /// Gas temporarily held to prevent compute from exceeding the gas limit.
32    pub compute_hold_gas: u64,
33    /// Whether this tx was submitted through the delayed inbox.
34    pub delayed_inbox: bool,
35    /// The top-level tx type byte, set in StartTxHook.
36    pub top_tx_type: Option<u8>,
37    /// The current retryable ticket being redeemed (if any).
38    pub current_retryable: Option<B256>,
39    /// The refund-to address for retryable redeems.
40    pub current_refund_to: Option<Address>,
41    /// Scheduled transactions (e.g., retryable auto-redeems).
42    pub scheduled_txs: Vec<Vec<u8>>,
43    /// Count of open Stylus program contexts per contract address.
44    /// Used to detect reentrance.
45    pub programs_depth: HashMap<Address, usize>,
46}
47
48impl Default for TxProcessor {
49    fn default() -> Self {
50        Self {
51            poster_fee: U256::ZERO,
52            poster_gas: 0,
53            compute_hold_gas: 0,
54            delayed_inbox: false,
55            top_tx_type: None,
56            current_retryable: None,
57            current_refund_to: None,
58            scheduled_txs: Vec::new(),
59            programs_depth: HashMap::new(),
60        }
61    }
62}
63
64impl TxProcessor {
65    /// Create a new TxProcessor. The `delayed_inbox` flag indicates whether the
66    /// coinbase differs from the batch poster address.
67    pub fn new(coinbase: Address) -> Self {
68        Self {
69            delayed_inbox: coinbase != l1_pricing::BATCH_POSTER_ADDRESS,
70            ..Self::default()
71        }
72    }
73
74    /// Gas that should not be refundable (the poster's L1 cost component).
75    pub fn nonrefundable_gas(&self) -> u64 {
76        self.poster_gas
77    }
78
79    /// Gas held back to limit computation; must be refunded after computation completes.
80    pub fn held_gas(&self) -> u64 {
81        self.compute_hold_gas
82    }
83
84    /// Whether the tip should be dropped (version-gated behavior).
85    pub fn drop_tip(&self, arbos_version: u64) -> bool {
86        self.drop_tip_with_collect(arbos_version, false)
87    }
88
89    /// Drop-tip decision:
90    /// - delayed inbox: always drop
91    /// - v9: never drop (collect)
92    /// - v10..v59: always drop
93    /// - v60+: drop iff collect_tips_enabled is false
94    pub fn drop_tip_with_collect(&self, arbos_version: u64, collect_tips_enabled: bool) -> bool {
95        if self.delayed_inbox {
96            return true;
97        }
98        if arbos_version == 9 {
99            return false;
100        }
101        if arbos_version < 60 {
102            return true;
103        }
104        !collect_tips_enabled
105    }
106
107    /// Get the effective gas price paid.
108    pub fn get_paid_gas_price(&self, arbos_version: u64, base_fee: U256, gas_price: U256) -> U256 {
109        self.get_paid_gas_price_with_collect(arbos_version, base_fee, gas_price, false)
110    }
111
112    /// Effective paid gas price, accounting for v60+ collect-tips behavior.
113    pub fn get_paid_gas_price_with_collect(
114        &self,
115        arbos_version: u64,
116        base_fee: U256,
117        gas_price: U256,
118        collect_tips_enabled: bool,
119    ) -> U256 {
120        // Pay full gas price when tip collection is active, else basefee.
121        if !self.drop_tip_with_collect(arbos_version, collect_tips_enabled) {
122            gas_price
123        } else {
124            base_fee
125        }
126    }
127
128    /// The GASPRICE opcode return value.
129    pub fn gas_price_op(&self, arbos_version: u64, base_fee: U256, gas_price: U256) -> U256 {
130        self.gas_price_op_with_collect(arbos_version, base_fee, gas_price, false)
131    }
132
133    /// GASPRICE opcode value, accounting for v60+ collect-tips behavior.
134    pub fn gas_price_op_with_collect(
135        &self,
136        arbos_version: u64,
137        base_fee: U256,
138        gas_price: U256,
139        collect_tips_enabled: bool,
140    ) -> U256 {
141        if arbos_version >= 3 {
142            self.get_paid_gas_price_with_collect(
143                arbos_version,
144                base_fee,
145                gas_price,
146                collect_tips_enabled,
147            )
148        } else {
149            gas_price
150        }
151    }
152
153    /// Fill receipt info with the poster gas used for L1.
154    pub fn fill_receipt_gas_used_for_l1(&self) -> u64 {
155        self.poster_gas
156    }
157
158    // -----------------------------------------------------------------
159    // Stylus / WASM Execution
160    // -----------------------------------------------------------------
161
162    /// Record entering a Stylus program context for a contract address.
163    pub fn push_program(&mut self, addr: Address) {
164        *self.programs_depth.entry(addr).or_insert(0) += 1;
165    }
166
167    /// Record leaving a Stylus program context for a contract address.
168    pub fn pop_program(&mut self, addr: Address) {
169        if let Some(count) = self.programs_depth.get_mut(&addr) {
170            *count = count.saturating_sub(1);
171            if *count == 0 {
172                self.programs_depth.remove(&addr);
173            }
174        }
175    }
176
177    /// Whether the given address has a reentrant Stylus call.
178    pub fn is_reentrant(&self, addr: &Address) -> bool {
179        self.programs_depth.get(addr).copied().unwrap_or(0) > 1
180    }
181
182    // -----------------------------------------------------------------
183    // Reverted Tx Hook
184    // -----------------------------------------------------------------
185
186    /// Check for pre-recorded reverted or filtered transactions.
187    ///
188    /// Returns an action describing how the caller should handle this tx
189    /// before normal execution. The caller should:
190    /// - `None`: proceed with normal execution
191    /// - `PreRecordedRevert`: increment sender nonce, deduct `gas_to_consume` from gas remaining,
192    ///   and return execution-reverted error
193    /// - `FilteredTx`: increment sender nonce, consume ALL remaining gas, and return filtered-tx
194    ///   error
195    pub fn reverted_tx_hook(
196        &self,
197        tx_hash: Option<B256>,
198        pre_recorded_gas: Option<u64>,
199        is_filtered: bool,
200    ) -> RevertedTxAction {
201        let Some(hash) = tx_hash else {
202            return RevertedTxAction::None;
203        };
204
205        let l2_gas_used = pre_recorded_gas.or_else(|| crate::reverted_tx_gas::lookup(hash));
206        if let Some(g) = l2_gas_used {
207            let adjusted_gas = g.saturating_sub(TX_GAS);
208            return RevertedTxAction::PreRecordedRevert {
209                gas_to_consume: adjusted_gas,
210            };
211        }
212
213        if is_filtered {
214            return RevertedTxAction::FilteredTx;
215        }
216
217        RevertedTxAction::None
218    }
219
220    // -----------------------------------------------------------------
221    // Start Tx Hook helpers
222    // -----------------------------------------------------------------
223
224    /// Set the top-level transaction type for this tx.
225    pub fn set_tx_type(&mut self, tx_type: u8) {
226        self.top_tx_type = Some(tx_type);
227    }
228
229    /// Set up state for processing a retry transaction.
230    ///
231    /// The caller should:
232    /// 1. Verify the retryable exists (via `RetryableState::open_retryable`)
233    /// 2. Transfer call value from escrow to `from`
234    /// 3. Mint prepaid gas (`base_fee * gas`) to `from`
235    /// 4. Continue to gas charging and EVM execution
236    pub fn prepare_retry_tx(&mut self, ticket_id: B256, refund_to: Address) {
237        self.current_retryable = Some(ticket_id);
238        self.current_refund_to = Some(refund_to);
239    }
240
241    // -----------------------------------------------------------------
242    // Gas Charging Hook
243    // -----------------------------------------------------------------
244
245    /// Compute poster gas and held compute gas.
246    ///
247    /// Charges poster data cost from the remaining gas and holds excess
248    /// compute gas to enforce per-block/per-tx limits. After calling,
249    /// `poster_gas`, `poster_fee`, and `compute_hold_gas` are set.
250    pub fn gas_charging_hook(
251        &mut self,
252        gas_remaining: &mut u64,
253        intrinsic_gas: u64,
254        params: &GasChargingParams,
255    ) -> Result<(), GasChargingError> {
256        let mut gas_needed = 0u64;
257
258        if !params.base_fee.is_zero() && !params.skip_l1_charging {
259            self.poster_gas = compute_poster_gas(
260                params.poster_cost,
261                params.base_fee,
262                params.is_gas_estimation,
263                params.min_base_fee,
264            );
265            self.poster_fee = params.base_fee.saturating_mul(U256::from(self.poster_gas));
266            gas_needed = self.poster_gas;
267        }
268
269        if *gas_remaining < gas_needed {
270            return Err(GasChargingError::IntrinsicGasTooLow);
271        }
272        *gas_remaining -= gas_needed;
273
274        // Hold excess compute gas to enforce per-block/per-tx limits.
275        if !params.is_eth_call {
276            let max = if params.arbos_version < arb_ver::ARBOS_VERSION_50 {
277                params.per_block_gas_limit
278            } else {
279                // ArbOS 50+ uses per-tx limit, reduced by already-charged intrinsic gas.
280                params.per_tx_gas_limit.saturating_sub(intrinsic_gas)
281            };
282
283            if *gas_remaining > max {
284                self.compute_hold_gas = *gas_remaining - max;
285                *gas_remaining = max;
286            }
287        }
288
289        Ok(())
290    }
291
292    // -----------------------------------------------------------------
293    // End Tx Hook (normal transactions)
294    // -----------------------------------------------------------------
295
296    /// Compute fee distribution for a normal (non-retryable) transaction.
297    ///
298    /// Returns the amounts to mint to each fee account and the gas to
299    /// add to the backlog. The caller executes the balance operations.
300    pub fn compute_end_tx_fee_distribution(
301        &self,
302        params: &EndTxNormalParams,
303    ) -> EndTxFeeDistribution {
304        let gas_used = params.gas_used;
305        let base_fee = params.base_fee;
306
307        // `compute_cost = basefee × compute_gas` directly. A `total_cost -
308        // poster_fee` formulation leaks `tip × posterGas` out of the network
309        // mint whenever poster_fee is priced at `actualGasPrice` (CollectTips
310        // true) while total_cost uses basefee.
311        let compute_gas = gas_used.saturating_sub(self.poster_gas);
312        let mut compute_cost = base_fee.saturating_mul(U256::from(compute_gas));
313        let poster_fee = self.poster_fee;
314
315        let mut infra_fee_amount = U256::ZERO;
316
317        if params.arbos_version > 4 && params.infra_fee_account != Address::ZERO {
318            let infra_fee = params.min_base_fee.min(base_fee);
319            infra_fee_amount = infra_fee.saturating_mul(U256::from(compute_gas));
320            compute_cost = compute_cost.saturating_sub(infra_fee_amount);
321        }
322
323        let poster_fee_destination = if params.arbos_version < 2 {
324            params.coinbase
325        } else {
326            l1_pricing::L1_PRICER_FUNDS_POOL_ADDRESS
327        };
328
329        let l1_fees_to_add = if params.arbos_version >= arb_ver::ARBOS_VERSION_10 {
330            poster_fee
331        } else {
332            U256::ZERO
333        };
334
335        let compute_gas_for_backlog = if !params.gas_price.is_zero() {
336            if gas_used > self.poster_gas {
337                gas_used - self.poster_gas
338            } else {
339                tracing::error!(
340                    gas_used,
341                    poster_gas = self.poster_gas,
342                    "gas used < poster gas"
343                );
344                gas_used
345            }
346        } else {
347            0
348        };
349
350        EndTxFeeDistribution {
351            infra_fee_account: params.infra_fee_account,
352            infra_fee_amount,
353            network_fee_account: params.network_fee_account,
354            network_fee_amount: compute_cost,
355            poster_fee_destination,
356            poster_fee_amount: poster_fee,
357            l1_fees_to_add,
358            compute_gas_for_backlog,
359        }
360    }
361
362    // -----------------------------------------------------------------
363    // End Tx Hook (retryable transactions)
364    // -----------------------------------------------------------------
365
366    /// Process end-of-tx for a retryable redemption.
367    ///
368    /// Handles undoing geth's gas refund, distributing refunds between
369    /// the refund-to address and the sender, and determining whether
370    /// to delete the retryable or return value to escrow.
371    pub fn end_tx_retryable<F>(
372        &self,
373        params: &EndTxRetryableParams,
374        mut burn_fn: impl FnMut(Address, U256),
375        mut transfer_fn: F,
376    ) -> EndTxRetryableResult
377    where
378        F: FnMut(Address, Address, U256) -> Result<(), BalanceError>,
379    {
380        let effective_base_fee = params.effective_base_fee;
381        let gas_left = params.gas_left;
382        let gas_used = params.gas_used;
383
384        let gas_refund_amount = effective_base_fee.saturating_mul(U256::from(gas_left));
385        burn_fn(params.from, gas_refund_amount);
386
387        let single_gas_cost = effective_base_fee.saturating_mul(U256::from(gas_used));
388
389        let mut max_refund = params.max_refund;
390
391        if params.success {
392            refund_with_pool(
393                params.network_fee_account,
394                params.submission_fee_refund,
395                &mut max_refund,
396                params.refund_to,
397                params.from,
398                &mut transfer_fn,
399            );
400        } else {
401            take_funds(&mut max_refund, params.submission_fee_refund);
402        }
403
404        take_funds(&mut max_refund, single_gas_cost);
405
406        let mut network_refund = gas_refund_amount;
407
408        if params.arbos_version >= arb_ver::ARBOS_VERSION_11
409            && params.infra_fee_account != Address::ZERO
410        {
411            let infra_fee = params.min_base_fee.min(effective_base_fee);
412            let infra_refund_amount = infra_fee.saturating_mul(U256::from(gas_left));
413            let infra_refund = take_funds(&mut network_refund, infra_refund_amount);
414            refund_with_pool(
415                params.infra_fee_account,
416                infra_refund,
417                &mut max_refund,
418                params.refund_to,
419                params.from,
420                &mut transfer_fn,
421            );
422        }
423
424        refund_with_pool(
425            params.network_fee_account,
426            network_refund,
427            &mut max_refund,
428            params.refund_to,
429            params.from,
430            &mut transfer_fn,
431        );
432
433        // Multi-dimensional gas refund: if multi-gas cost < single-gas cost,
434        // refund the difference. Only when effective_base_fee == block_base_fee
435        // (skip during retryable gas estimation).
436        if let Some(multi_cost) = params.multi_dimensional_cost {
437            let should_refund =
438                single_gas_cost > multi_cost && effective_base_fee == params.block_base_fee;
439            if should_refund {
440                let refund_amount = single_gas_cost.saturating_sub(multi_cost);
441                refund_with_pool(
442                    params.network_fee_account,
443                    refund_amount,
444                    &mut max_refund,
445                    params.refund_to,
446                    params.from,
447                    &mut transfer_fn,
448                );
449            }
450        }
451
452        let escrow = retryables::retryable_escrow_address(params.ticket_id);
453
454        EndTxRetryableResult {
455            compute_gas_for_backlog: gas_used,
456            should_delete_retryable: params.success,
457            should_return_value_to_escrow: !params.success,
458            escrow_address: escrow,
459        }
460    }
461}
462
463// =====================================================================
464// Parameter and result types
465// =====================================================================
466
467/// Parameters for the gas charging hook.
468#[derive(Debug, Clone)]
469pub struct GasChargingParams {
470    /// The current block base fee.
471    pub base_fee: U256,
472    /// The computed poster data cost for this tx.
473    pub poster_cost: U256,
474    /// Whether this is gas estimation (eth_estimateGas).
475    pub is_gas_estimation: bool,
476    /// Whether this is an eth_call (non-mutating).
477    pub is_eth_call: bool,
478    /// Whether to skip L1 charging.
479    pub skip_l1_charging: bool,
480    /// The minimum L2 base fee.
481    pub min_base_fee: U256,
482    /// The per-block gas limit from L2 pricing.
483    pub per_block_gas_limit: u64,
484    /// The per-tx gas limit from L2 pricing (ArbOS v50+).
485    pub per_tx_gas_limit: u64,
486    /// Current ArbOS version.
487    pub arbos_version: u64,
488}
489
490/// Error from gas charging.
491#[derive(Debug, Clone, thiserror::Error)]
492pub enum GasChargingError {
493    #[error("intrinsic gas too low")]
494    IntrinsicGasTooLow,
495}
496
497/// Parameters for end-tx fee distribution (normal transactions).
498#[derive(Debug, Clone)]
499pub struct EndTxNormalParams {
500    pub gas_used: u64,
501    pub gas_price: U256,
502    pub base_fee: U256,
503    pub coinbase: Address,
504    pub network_fee_account: Address,
505    pub infra_fee_account: Address,
506    pub min_base_fee: U256,
507    pub arbos_version: u64,
508}
509
510/// Fee distribution result from end-tx hook (normal transactions).
511///
512/// The caller mints `infra_fee_amount` to `infra_fee_account`,
513/// `network_fee_amount` to `network_fee_account`, and
514/// `poster_fee_amount` to `poster_fee_destination`. Then adds
515/// `l1_fees_to_add` to L1 fees available and grows the gas backlog
516/// by `compute_gas_for_backlog`.
517#[derive(Debug, Clone, Default)]
518pub struct EndTxFeeDistribution {
519    pub infra_fee_account: Address,
520    pub infra_fee_amount: U256,
521    pub network_fee_account: Address,
522    pub network_fee_amount: U256,
523    pub poster_fee_destination: Address,
524    pub poster_fee_amount: U256,
525    pub l1_fees_to_add: U256,
526    pub compute_gas_for_backlog: u64,
527}
528
529/// Parameters for end-tx hook (retryable transactions).
530#[derive(Debug, Clone)]
531pub struct EndTxRetryableParams {
532    pub gas_left: u64,
533    pub gas_used: u64,
534    pub effective_base_fee: U256,
535    pub from: Address,
536    pub refund_to: Address,
537    pub max_refund: U256,
538    pub submission_fee_refund: U256,
539    pub ticket_id: B256,
540    pub value: U256,
541    pub success: bool,
542    pub network_fee_account: Address,
543    pub infra_fee_account: Address,
544    pub min_base_fee: U256,
545    pub arbos_version: u64,
546    /// Multi-dimensional cost if ArbOS >= v60 (None otherwise).
547    /// When set and less than single-gas cost, the difference is refunded.
548    pub multi_dimensional_cost: Option<U256>,
549    /// Block base fee for comparing with effective_base_fee.
550    /// Multi-gas refund is skipped if effective_base_fee != block_base_fee
551    /// (retryable estimation case).
552    pub block_base_fee: U256,
553}
554
555/// Result from end-tx retryable hook.
556///
557/// The caller should:
558/// - Grow gas backlog by `compute_gas_for_backlog`
559/// - If `should_delete_retryable`: delete the retryable ticket
560/// - If `should_return_value_to_escrow`: transfer value from `from` back to `escrow_address`
561#[derive(Debug, Clone)]
562pub struct EndTxRetryableResult {
563    pub compute_gas_for_backlog: u64,
564    pub should_delete_retryable: bool,
565    pub should_return_value_to_escrow: bool,
566    pub escrow_address: Address,
567}
568
569/// Action to take for a reverted/filtered transaction.
570#[derive(Debug, Clone, PartialEq, Eq)]
571pub enum RevertedTxAction {
572    /// No special handling; proceed with normal execution.
573    None,
574    /// Pre-recorded revert: increment nonce, consume specific gas, return revert.
575    PreRecordedRevert { gas_to_consume: u64 },
576    /// Filtered transaction: increment nonce, consume all remaining gas.
577    FilteredTx,
578}
579
580/// Parameters for computing submit retryable fees.
581#[derive(Debug, Clone)]
582pub struct SubmitRetryableParams {
583    pub ticket_id: B256,
584    pub from: Address,
585    pub fee_refund_addr: Address,
586    pub deposit_value: U256,
587    pub retry_value: U256,
588    pub gas_fee_cap: U256,
589    pub gas: u64,
590    pub max_submission_fee: U256,
591    pub retry_data_len: usize,
592    pub l1_base_fee: U256,
593    pub effective_base_fee: U256,
594    pub current_time: u64,
595    /// From address balance after deposit minting.
596    pub balance_after_mint: U256,
597    pub infra_fee_account: Address,
598    pub min_base_fee: U256,
599    pub arbos_version: u64,
600}
601
602/// Computed fee distribution for a submit retryable transaction.
603///
604/// The caller should execute the following operations in order:
605/// 1. Mint `deposit_value` to `from`
606/// 2. Transfer `submission_fee` from `from` to network fee account
607/// 3. Transfer `submission_fee_refund` from `from` to fee refund address
608/// 4. Transfer `retry_value` from `from` to `escrow`
609/// 5. Create retryable ticket with `timeout`
610/// 6. If `can_pay_for_gas`:
611///    - Transfer `infra_cost` from `from` to infra fee account
612///    - Transfer `network_cost` from `from` to network fee account
613///    - Transfer `gas_price_refund` from `from` to fee refund address
614///    - Schedule auto-redeem with `available_refund` as max refund
615/// 7. If not `can_pay_for_gas`: refund `gas_cost_refund` to fee refund address
616#[derive(Debug, Clone, Default)]
617pub struct SubmitRetryableFees {
618    /// The actual submission fee.
619    pub submission_fee: U256,
620    /// Excess submission fee to refund.
621    pub submission_fee_refund: U256,
622    /// Escrow address for the retryable's call value.
623    pub escrow: Address,
624    /// Retryable ticket timeout.
625    pub timeout: u64,
626    /// Whether the user can pay for gas.
627    pub can_pay_for_gas: bool,
628    /// Total gas cost (effective_base_fee * gas).
629    pub gas_cost: U256,
630    /// Infra fee portion of gas cost (ArbOS v11+).
631    pub infra_cost: U256,
632    /// Network fee portion (gas_cost - infra_cost).
633    pub network_cost: U256,
634    /// Gas price refund ((gas_fee_cap - effective_base_fee) * gas).
635    pub gas_price_refund: U256,
636    /// If user can't pay for gas, this amount should be refunded.
637    pub gas_cost_refund: U256,
638    /// Remaining L1 deposit available for auto-redeem max refund.
639    pub available_refund: U256,
640    /// Withheld submission fee (for error path refunds).
641    pub withheld_submission_fee: U256,
642    /// Error if validation fails.
643    pub error: Option<String>,
644}
645
646/// Standard Ethereum base transaction gas.
647pub const TX_GAS: u64 = 21_000;
648
649// =====================================================================
650// Helper functions
651// =====================================================================
652
653/// Attempts to subtract up to `take` from `pool` without going negative.
654/// Returns the amount actually subtracted.
655pub fn take_funds(pool: &mut U256, take: U256) -> U256 {
656    if *pool < take {
657        let old = *pool;
658        *pool = U256::ZERO;
659        old
660    } else {
661        *pool -= take;
662        take
663    }
664}
665
666/// Compute poster gas given a poster cost and base fee,
667/// with optional gas estimation padding.
668pub fn compute_poster_gas(
669    poster_cost: U256,
670    base_fee: U256,
671    is_gas_estimation: bool,
672    min_gas_price: U256,
673) -> u64 {
674    if base_fee.is_zero() {
675        return 0;
676    }
677
678    let adjusted_base_fee = if is_gas_estimation {
679        // Assume congestion: use 7/8 of base fee
680        let adjusted = base_fee * U256::from(7) / U256::from(8);
681        if adjusted < min_gas_price {
682            min_gas_price
683        } else {
684            adjusted
685        }
686    } else {
687        base_fee
688    };
689
690    let padded_cost = if is_gas_estimation {
691        poster_cost * U256::from(GAS_ESTIMATION_L1_PRICE_PADDING_BIPS) / U256::from(10000)
692    } else {
693        poster_cost
694    };
695
696    if adjusted_base_fee.is_zero() {
697        return 0;
698    }
699
700    let gas = padded_cost / adjusted_base_fee;
701    gas.try_into().unwrap_or(u64::MAX)
702}
703
704/// Calculates the poster gas cost for a transaction's calldata.
705///
706/// Returns (poster_gas, calldata_units) where:
707/// - poster_gas: Gas that should be reserved for L1 posting costs
708/// - calldata_units: The raw calldata units before price conversion
709pub fn get_poster_gas(
710    tx_data: &[u8],
711    l1_base_fee: U256,
712    l2_base_fee: U256,
713    _arbos_version: u64,
714) -> (u64, u64) {
715    if l2_base_fee.is_zero() || l1_base_fee.is_zero() {
716        return (0, 0);
717    }
718
719    let calldata_units = tx_data_non_zero_count(tx_data) * 16 + tx_data_zero_count(tx_data) * 4;
720
721    let l1_cost = U256::from(calldata_units) * l1_base_fee;
722    let poster_gas = l1_cost / l2_base_fee;
723    let poster_gas_u64: u64 = poster_gas.try_into().unwrap_or(u64::MAX);
724
725    (poster_gas_u64, calldata_units as u64)
726}
727
728/// Refund with L1 deposit pool cap.
729///
730/// Takes up to `amount` from `max_refund` and transfers that to `refund_to`.
731/// Any excess (amount beyond the L1 deposit) goes to `from`.
732fn refund_with_pool<F>(
733    refund_from: Address,
734    amount: U256,
735    max_refund: &mut U256,
736    refund_to: Address,
737    from: Address,
738    transfer_fn: &mut F,
739) where
740    F: FnMut(Address, Address, U256) -> Result<(), BalanceError>,
741{
742    let to_refund_addr = take_funds(max_refund, amount);
743    // Refunds run inside end-tx bookkeeping where the network/infra fee
744    // accounts always hold what we just collected from the same tx; a
745    // typed shortfall here would only signal an accounting bug and must
746    // not abort the rest of the refund.
747    let _ = transfer_fn(refund_from, refund_to, to_refund_addr);
748    let remainder = amount.saturating_sub(to_refund_addr);
749    let _ = transfer_fn(refund_from, from, remainder);
750}
751
752/// Compute the gas payment split between infra and network fee accounts.
753///
754/// Returns (infra_cost, network_cost) where gas_cost = infra_cost + network_cost.
755pub fn compute_retryable_gas_split(
756    gas: u64,
757    effective_base_fee: U256,
758    infra_fee_account: Address,
759    min_base_fee: U256,
760    arbos_version: u64,
761) -> (U256, U256) {
762    let gas_cost = effective_base_fee.saturating_mul(U256::from(gas));
763    let mut network_cost = gas_cost;
764    let mut infra_cost = U256::ZERO;
765
766    if arbos_version >= arb_ver::ARBOS_VERSION_11 && infra_fee_account != Address::ZERO {
767        let infra_fee = min_base_fee.min(effective_base_fee);
768        infra_cost = infra_fee.saturating_mul(U256::from(gas));
769        infra_cost = take_funds(&mut network_cost, infra_cost);
770    }
771
772    (infra_cost, network_cost)
773}
774
775/// Compute fees for a submit retryable transaction.
776///
777/// This performs the pure fee computation without executing any balance
778/// operations. The caller should execute the operations described in
779/// the `SubmitRetryableFees` documentation.
780pub fn compute_submit_retryable_fees(params: &SubmitRetryableParams) -> SubmitRetryableFees {
781    let submission_fee =
782        retryables::retryable_submission_fee(params.retry_data_len, params.l1_base_fee);
783
784    let escrow = retryables::retryable_escrow_address(params.ticket_id);
785    let timeout = params.current_time + retryables::RETRYABLE_LIFETIME_SECONDS;
786
787    // Check balance covers max submission fee.
788    if params.balance_after_mint < params.max_submission_fee {
789        return SubmitRetryableFees {
790            submission_fee,
791            escrow,
792            timeout,
793            error: Some(format!(
794                "insufficient funds for max submission fee: have {} want {}",
795                params.balance_after_mint, params.max_submission_fee,
796            )),
797            ..Default::default()
798        };
799    }
800
801    // Check max submission fee covers actual fee.
802    if params.max_submission_fee < submission_fee {
803        return SubmitRetryableFees {
804            submission_fee,
805            escrow,
806            timeout,
807            error: Some(format!(
808                "max submission fee {} is less than actual {}",
809                params.max_submission_fee, submission_fee,
810            )),
811            ..Default::default()
812        };
813    }
814
815    // Track available refund from L1 deposit.
816    let mut available_refund = params.deposit_value;
817    take_funds(&mut available_refund, params.retry_value);
818    let withheld_submission_fee = take_funds(&mut available_refund, submission_fee);
819    // Refund excess submission fee, capped by available refund pool.
820    let submission_fee_refund = take_funds(
821        &mut available_refund,
822        params.max_submission_fee.saturating_sub(submission_fee),
823    );
824
825    // Check if user can pay for gas.
826    let max_gas_cost = params.gas_fee_cap.saturating_mul(U256::from(params.gas));
827    let fee_cap_too_low = params.gas_fee_cap < params.effective_base_fee;
828
829    // Balance after all deductions so far.
830    // Go reads statedb.GetBalance(tx.From) after executing the transfers, so
831    // self-transfers (fee_refund_addr == from) don't reduce the balance.
832    let mut balance_after_deductions = params
833        .balance_after_mint
834        .saturating_sub(submission_fee)
835        .saturating_sub(params.retry_value);
836    if params.fee_refund_addr != params.from {
837        balance_after_deductions = balance_after_deductions.saturating_sub(submission_fee_refund);
838    }
839
840    let can_pay_for_gas =
841        !fee_cap_too_low && params.gas >= TX_GAS && balance_after_deductions >= max_gas_cost;
842
843    // Compute gas cost split.
844    let (infra_cost, network_cost) = compute_retryable_gas_split(
845        params.gas,
846        params.effective_base_fee,
847        params.infra_fee_account,
848        params.min_base_fee,
849        params.arbos_version,
850    );
851    let gas_cost = params
852        .effective_base_fee
853        .saturating_mul(U256::from(params.gas));
854
855    // Gas cost refund if user can't pay.
856    let gas_cost_refund = if !can_pay_for_gas {
857        take_funds(&mut available_refund, max_gas_cost)
858    } else {
859        U256::ZERO
860    };
861
862    // Gas price refund (difference between fee cap and effective base fee).
863    let gas_price_refund = if params.gas_fee_cap > params.effective_base_fee {
864        (params.gas_fee_cap - params.effective_base_fee).saturating_mul(U256::from(params.gas))
865    } else {
866        U256::ZERO
867    };
868
869    // The actual gas price refund is capped by the available pool.
870    // Go reassigns gasPriceRefund = takeFunds(availableRefund, gasPriceRefund).
871    let mut gas_price_refund_actual = U256::ZERO;
872
873    if can_pay_for_gas {
874        // Track gas cost and gas price refund through available_refund.
875        let withheld_gas_funds = take_funds(&mut available_refund, gas_cost);
876        gas_price_refund_actual = take_funds(&mut available_refund, gas_price_refund);
877        // Add back withheld amounts for the auto-redeem's max refund.
878        available_refund = available_refund
879            .saturating_add(withheld_gas_funds)
880            .saturating_add(withheld_submission_fee);
881    }
882
883    SubmitRetryableFees {
884        submission_fee,
885        submission_fee_refund,
886        escrow,
887        timeout,
888        can_pay_for_gas,
889        gas_cost,
890        infra_cost,
891        network_cost,
892        gas_price_refund: gas_price_refund_actual,
893        gas_cost_refund,
894        available_refund,
895        withheld_submission_fee,
896        error: None,
897    }
898}
899
900fn tx_data_non_zero_count(data: &[u8]) -> usize {
901    data.iter().filter(|&&b| b != 0).count()
902}
903
904fn tx_data_zero_count(data: &[u8]) -> usize {
905    data.iter().filter(|&&b| b == 0).count()
906}
907
908#[cfg(test)]
909mod block1_retryable_repro {
910    use alloy_primitives::{Address, Bytes, U256, address, b256, keccak256};
911    use arb_alloy_consensus::tx::{ArbRetryTx, ArbTxType};
912
913    use super::{SubmitRetryableParams, compute_submit_retryable_fees};
914
915    // Arbitrum Sepolia block 1 (ArbOS v10) SubmitRetryable inputs from a real
916    // node; drives our fee computation to the canonical auto-redeem tx hash.
917    #[test]
918    fn canonical_block1_auto_redeem_hash() {
919        let params = SubmitRetryableParams {
920            ticket_id: b256!("13cb79b086a427f3db7ebe6ec2bb90a806a3b0368ecee6020144f352e37dbdf6"),
921            from: address!("b8787d8f23e176a5d32135d746b69886e03313be"),
922            fee_refund_addr: address!("11155ca9bbf7be58e27f3309e629c847996b43c8"),
923            deposit_value: U256::from(0x23e3dbb7b88ab8u64),
924            retry_value: U256::from(0x2386f26fc10000u64),
925            gas_fee_cap: U256::from(0x3b9aca00u64),
926            gas: 100_000,
927            max_submission_fee: U256::from(0x1f6377d4ab8u64),
928            retry_data_len: 0,
929            l1_base_fee: U256::from(0x5bd57bd9u64),
930            effective_base_fee: U256::from(0x5f5e100u64),
931            current_time: 0,
932            balance_after_mint: U256::from(1_000_000_000_000_000_000u64),
933            infra_fee_account: Address::ZERO,
934            min_base_fee: U256::ZERO,
935            arbos_version: 10,
936        };
937        let fees = compute_submit_retryable_fees(&params);
938        assert!(fees.can_pay_for_gas, "expected auto-redeem path");
939        let retry = ArbRetryTx {
940            chain_id: U256::from(421614u64),
941            nonce: 0,
942            from: params.from,
943            gas_fee_cap: params.effective_base_fee,
944            gas: params.gas,
945            to: Some(address!("3fab184622dc19b6109349b94811493bf2a45362")),
946            value: params.retry_value,
947            data: Bytes::new(),
948            ticket_id: params.ticket_id,
949            refund_to: params.fee_refund_addr,
950            max_refund: fees.available_refund,
951            submission_fee_refund: fees.submission_fee,
952        };
953        let mut enc = Vec::new();
954        enc.push(ArbTxType::ArbitrumRetryTx.as_u8());
955        alloy_rlp::Encodable::encode(&retry, &mut enc);
956        assert_eq!(
957            keccak256(&enc),
958            b256!("873c5ee3092c40336006808e249293bf5f4cb3235077a74cac9cafa7cf73cb8b"),
959            "mismatch: available_refund=0x{:x} submission_fee=0x{:x}",
960            fees.available_refund,
961            fees.submission_fee
962        );
963    }
964}