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