arbos/
tx_processor.rs

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