arb_rpc/
api.rs

1//! Arbitrum EthApi wrapper with L1 gas estimation.
2//!
3//! Wraps reth's [`EthApiInner`] to override gas estimation
4//! with L1 posting cost awareness.
5
6use std::{sync::Arc, time::Duration};
7
8use alloy_primitives::{Address, StorageKey, B256, U256};
9use alloy_rpc_types_eth::{state::StateOverride, BlockId};
10use reth_primitives_traits::{Recovered, WithEncoded};
11use reth_rpc::eth::core::EthApiInner;
12use reth_rpc_convert::{RpcConvert, RpcTxReq};
13use reth_rpc_eth_api::{
14    helpers::{
15        estimate::EstimateCall, pending_block::PendingEnvBuilder, Call, EthApiSpec, EthBlocks,
16        EthCall, EthFees, EthSigner, EthState, EthTransactions, LoadBlock, LoadFee,
17        LoadPendingBlock, LoadReceipt, LoadState, LoadTransaction, SpawnBlocking, Trace,
18    },
19    EthApiTypes, FromEvmError, RpcNodeCore, RpcNodeCoreExt,
20};
21use reth_rpc_eth_types::{
22    builder::config::PendingBlockKind, EthApiError, EthStateCache, FeeHistoryCache, GasPriceOracle,
23    PendingBlock,
24};
25use reth_storage_api::{ProviderHeader, StateProviderFactory, TransactionsProvider};
26use reth_tasks::{
27    pool::{BlockingTaskGuard, BlockingTaskPool},
28    Runtime,
29};
30use reth_transaction_pool::{
31    AddedTransactionOutcome, PoolPooledTx, PoolTransaction, TransactionOrigin, TransactionPool,
32};
33use tracing::trace;
34
35use arb_precompiles::storage_slot::{
36    root_slot, subspace_slot, ARBOS_STATE_ADDRESS, BROTLI_COMPRESSION_LEVEL_OFFSET,
37    CHAIN_ID_OFFSET, L1_PRICING_SUBSPACE, L2_PRICING_SUBSPACE,
38};
39
40/// Type alias matching reth's `SignersForRpc`.
41type SignersForRpc<Provider, Rpc> = parking_lot::RwLock<
42    Vec<Box<dyn EthSigner<<Provider as TransactionsProvider>::Transaction, RpcTxReq<Rpc>>>>,
43>;
44
45/// L1 pricing field offset for price per unit.
46const L1_PRICE_PER_UNIT: u64 = 7;
47
48/// L2 pricing field offset for base fee.
49const L2_BASE_FEE: u64 = 2;
50
51/// L2 pricing field offset for minimum base fee.
52const L2_MIN_BASE_FEE: u64 = 3;
53
54/// Non-zero calldata gas cost per byte (EIP-2028).
55const TX_DATA_NON_ZERO_GAS: u64 = 16;
56
57/// Padding applied to L1 fee estimates (110% = 11000 bips).
58const GAS_ESTIMATION_L1_PRICE_PADDING: u64 = 11000;
59
60/// Selector for `ArbGasInfo.getCurrentTxL1GasFees()`.
61const SEL_GET_CURRENT_TX_L1_FEES: &[u8] = &[0xc6, 0xf7, 0xde, 0x0e];
62
63/// Apply Arbitrum's L1→L2 address aliasing (offset by
64/// 0x1111000000000000000000000000000000001111).
65fn apply_l1_to_l2_alias(addr: Address) -> Address {
66    let mut offset = [0u8; 32];
67    offset[12] = 0x11;
68    offset[13] = 0x11;
69    offset[30] = 0x11;
70    offset[31] = 0x11;
71    let lhs = U256::from_be_slice(addr.as_slice());
72    let rhs = U256::from_be_bytes(offset);
73    let sum = lhs.wrapping_add(rhs);
74    let bytes = sum.to_be_bytes::<32>();
75    Address::from_slice(&bytes[12..32])
76}
77
78/// Selector for `ArbGasInfo.getL1PricingUnitsSinceUpdate()`.
79const SEL_GET_L1_PRICING_UNITS_SINCE_UPDATE: &[u8] = &[0xef, 0xf0, 0x13, 0x06];
80
81/// L1 pricing field offset for units-since-update.
82const L1_UNITS_SINCE_UPDATE: u64 = 6;
83
84/// Arbitrum Eth API wrapping the standard reth EthApiInner.
85///
86/// This wrapper overrides gas estimation to add L1 posting costs.
87pub struct ArbEthApi<N: RpcNodeCore, Rpc: RpcConvert> {
88    inner: Arc<EthApiInner<N, Rpc>>,
89}
90
91impl<N: RpcNodeCore, Rpc: RpcConvert> Clone for ArbEthApi<N, Rpc> {
92    fn clone(&self) -> Self {
93        Self {
94            inner: self.inner.clone(),
95        }
96    }
97}
98
99impl<N: RpcNodeCore, Rpc: RpcConvert> std::fmt::Debug for ArbEthApi<N, Rpc> {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("ArbEthApi").finish_non_exhaustive()
102    }
103}
104
105impl<N: RpcNodeCore, Rpc: RpcConvert> ArbEthApi<N, Rpc> {
106    /// Create a new `ArbEthApi` wrapping the given inner.
107    pub fn new(inner: EthApiInner<N, Rpc>) -> Self {
108        Self {
109            inner: Arc::new(inner),
110        }
111    }
112}
113
114impl<N, Rpc> ArbEthApi<N, Rpc>
115where
116    N: RpcNodeCore<Provider: StateProviderFactory>,
117    Rpc: RpcConvert,
118{
119    /// Compute L1 posting gas for gas estimation.
120    ///
121    /// Reads L1 pricing state from ArbOS to estimate the gas needed to cover
122    /// L1 data posting costs for the given calldata length.
123    fn l1_posting_gas(&self, calldata_len: usize, at: BlockId) -> Result<u64, EthApiError> {
124        if calldata_len == 0 {
125            return Ok(0);
126        }
127
128        let state = self
129            .inner
130            .provider()
131            .state_by_block_id(at)
132            .map_err(|e| EthApiError::Internal(e.into()))?;
133
134        let l1_price_slot = subspace_slot(L1_PRICING_SUBSPACE, L1_PRICE_PER_UNIT);
135        let l1_price = state
136            .storage(
137                ARBOS_STATE_ADDRESS,
138                StorageKey::from(B256::from(l1_price_slot.to_be_bytes::<32>())),
139            )
140            .map_err(|e| EthApiError::Internal(e.into()))?
141            .unwrap_or_default();
142
143        let basefee_slot = subspace_slot(L2_PRICING_SUBSPACE, L2_BASE_FEE);
144        let basefee = state
145            .storage(
146                ARBOS_STATE_ADDRESS,
147                StorageKey::from(B256::from(basefee_slot.to_be_bytes::<32>())),
148            )
149            .map_err(|e| EthApiError::Internal(e.into()))?
150            .unwrap_or_default();
151
152        if l1_price.is_zero() || basefee.is_zero() {
153            return Ok(0);
154        }
155
156        // L1 fee = l1_price * calldata_bytes * TX_DATA_NON_ZERO_GAS
157        let l1_fee = l1_price
158            .saturating_mul(U256::from(TX_DATA_NON_ZERO_GAS))
159            .saturating_mul(U256::from(calldata_len));
160
161        // Apply 110% padding for L1 price volatility.
162        let padded = l1_fee.saturating_mul(U256::from(GAS_ESTIMATION_L1_PRICE_PADDING))
163            / U256::from(10000u64);
164
165        // Use 7/8 of basefee as congestion discount for estimation.
166        let adjusted_basefee = basefee.saturating_mul(U256::from(7)) / U256::from(8);
167        let adjusted_basefee = if adjusted_basefee.is_zero() {
168            U256::from(1)
169        } else {
170            adjusted_basefee
171        };
172
173        // Convert to gas units: posting_gas = padded_fee / adjusted_basefee
174        let gas = padded / adjusted_basefee;
175        Ok(gas.try_into().unwrap_or(u64::MAX))
176    }
177}
178
179impl<N, Rpc> ArbEthApi<N, Rpc>
180where
181    N: RpcNodeCore<Provider: StateProviderFactory>,
182    EthApiError: FromEvmError<N::Evm>,
183    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError, Evm = N::Evm>,
184    RpcTxReq<<Rpc as RpcConvert>::Network>: AsRef<alloy_rpc_types_eth::TransactionRequest>
185        + AsMut<alloy_rpc_types_eth::TransactionRequest>
186        + Clone
187        + Default,
188{
189    fn compute_eth_call_units_since_update(
190        &self,
191        request: RpcTxReq<<Rpc as RpcConvert>::Network>,
192        at: BlockId,
193    ) -> Result<alloy_primitives::Bytes, EthApiError> {
194        let inner = request.as_ref();
195        let (to, contract_creation) = match inner.to {
196            Some(alloy_primitives::TxKind::Call(addr)) => (addr, false),
197            Some(alloy_primitives::TxKind::Create) => (Address::ZERO, true),
198            None => (Address::ZERO, false),
199        };
200        let value = inner.value.unwrap_or(U256::ZERO);
201        let data: alloy_primitives::Bytes = inner.input.input().cloned().unwrap_or_default();
202
203        let state = self
204            .inner
205            .provider()
206            .state_by_block_id(at)
207            .map_err(|e| EthApiError::Internal(e.into()))?;
208        let read = |slot: U256| -> Result<U256, EthApiError> {
209            Ok(state
210                .storage(
211                    ARBOS_STATE_ADDRESS,
212                    StorageKey::from(B256::from(slot.to_be_bytes::<32>())),
213                )
214                .map_err(|e| EthApiError::Internal(e.into()))?
215                .unwrap_or_default())
216        };
217        let stored = read(subspace_slot(L1_PRICING_SUBSPACE, L1_UNITS_SINCE_UPDATE))?;
218        let chain_id_u: u64 = read(root_slot(CHAIN_ID_OFFSET))?.try_into().unwrap_or(0);
219        let brotli_level: u64 = read(root_slot(BROTLI_COMPRESSION_LEVEL_OFFSET))?
220            .try_into()
221            .unwrap_or(0);
222
223        let tx_bytes =
224            arb_precompiles::build_fake_tx_bytes(chain_id_u, to, contract_creation, value, data);
225        let raw_units = arbos::l1_pricing::poster_units_from_bytes(&tx_bytes, brotli_level);
226        let padded_units = raw_units
227            .saturating_add(arbos::l1_pricing::ESTIMATION_PADDING_UNITS)
228            .saturating_mul(10_000 + arbos::l1_pricing::ESTIMATION_PADDING_BASIS_POINTS)
229            / 10_000;
230        let total = stored.saturating_add(U256::from(padded_units));
231
232        Ok(alloy_primitives::Bytes::from(
233            total.to_be_bytes::<32>().to_vec(),
234        ))
235    }
236
237    fn compute_eth_call_current_tx_l1_fees(
238        &self,
239        request: RpcTxReq<<Rpc as RpcConvert>::Network>,
240        at: BlockId,
241    ) -> Result<alloy_primitives::Bytes, EthApiError> {
242        let inner = request.as_ref();
243        let (to, contract_creation) = match inner.to {
244            Some(alloy_primitives::TxKind::Call(addr)) => (addr, false),
245            Some(alloy_primitives::TxKind::Create) => (Address::ZERO, true),
246            None => (Address::ZERO, false),
247        };
248        let value = inner.value.unwrap_or(U256::ZERO);
249        let data: alloy_primitives::Bytes = inner.input.input().cloned().unwrap_or_default();
250
251        let state = self
252            .inner
253            .provider()
254            .state_by_block_id(at)
255            .map_err(|e| EthApiError::Internal(e.into()))?;
256        let read = |slot: U256| -> Result<U256, EthApiError> {
257            Ok(state
258                .storage(
259                    ARBOS_STATE_ADDRESS,
260                    StorageKey::from(B256::from(slot.to_be_bytes::<32>())),
261                )
262                .map_err(|e| EthApiError::Internal(e.into()))?
263                .unwrap_or_default())
264        };
265        let l1_price = read(subspace_slot(L1_PRICING_SUBSPACE, L1_PRICE_PER_UNIT))?;
266        if l1_price.is_zero() {
267            return Ok(alloy_primitives::Bytes::from(vec![0u8; 32]));
268        }
269        let chain_id_u: u64 = read(root_slot(CHAIN_ID_OFFSET))?.try_into().unwrap_or(0);
270        let brotli_level: u64 = read(root_slot(BROTLI_COMPRESSION_LEVEL_OFFSET))?
271            .try_into()
272            .unwrap_or(0);
273
274        let tx_bytes =
275            arb_precompiles::build_fake_tx_bytes(chain_id_u, to, contract_creation, value, data);
276        let raw_units = arbos::l1_pricing::poster_units_from_bytes(&tx_bytes, brotli_level);
277        let padded_units = raw_units
278            .saturating_add(arbos::l1_pricing::ESTIMATION_PADDING_UNITS)
279            .saturating_mul(10_000 + arbos::l1_pricing::ESTIMATION_PADDING_BASIS_POINTS)
280            / 10_000;
281        let poster_fee = l1_price.saturating_mul(U256::from(padded_units));
282
283        Ok(alloy_primitives::Bytes::from(
284            poster_fee.to_be_bytes::<32>().to_vec(),
285        ))
286    }
287
288    /// Combined gas estimator matching Arbitrum's `DoEstimateGas`: the
289    /// binary search operates on total gas (L2 compute + L1 poster) so the
290    /// 64/63 optimistic multiplier and 0.015 error ratio apply to the
291    /// combined figure. Each simulation passes `total − l1_gas` as the L2
292    /// gas limit so the poster-gas deduction is accounted for.
293    async fn estimate_arb_combined_gas(
294        &self,
295        inner_req: RpcTxReq<<Rpc as RpcConvert>::Network>,
296        gas_for_l1: u64,
297        at: BlockId,
298        state_override: Option<StateOverride>,
299    ) -> Result<u64, EthApiError>
300    where
301        RpcTxReq<<Rpc as RpcConvert>::Network>: From<alloy_rpc_types_eth::TransactionRequest>,
302    {
303        use alloy_rpc_types_eth::state::EvmOverrides;
304
305        const CALL_STIPEND: u64 = 2300;
306        const ERROR_RATIO: f64 = 0.015;
307
308        let rpc_gas_cap = self.call_gas_limit();
309
310        let simulate = async |req_gas: u64| -> Result<(u64, bool), EthApiError> {
311            let mut req = inner_req.clone();
312            req.as_mut().gas = Some(req_gas);
313            let _permit = self.acquire_owned_blocking_io().await;
314            let res = self
315                .transact_call_at(req, at, EvmOverrides::state(state_override.clone()))
316                .await?;
317            Ok((res.result.gas_used(), res.result.is_success()))
318        };
319
320        let compute_cap = rpc_gas_cap.saturating_sub(gas_for_l1).max(1);
321        let (used_compute, success_first) = simulate(compute_cap).await?;
322        if !success_first {
323            return Ok(rpc_gas_cap);
324        }
325
326        let used_total = used_compute.saturating_add(gas_for_l1);
327        let mut lo = used_total.saturating_sub(1);
328        let mut hi = rpc_gas_cap.max(used_total);
329
330        let optimistic = used_total.saturating_add(CALL_STIPEND).saturating_mul(64) / 63;
331        if optimistic < hi {
332            let compute_limit = optimistic.saturating_sub(gas_for_l1);
333            let (_, ok) = simulate(compute_limit).await?;
334            if ok {
335                hi = optimistic;
336            } else {
337                lo = optimistic;
338            }
339        }
340
341        while lo + 1 < hi {
342            let ratio = (hi - lo) as f64 / hi as f64;
343            if ratio < ERROR_RATIO {
344                break;
345            }
346            let mut mid = lo + (hi - lo) / 2;
347            let two_lo = lo.saturating_mul(2);
348            if mid > two_lo {
349                mid = two_lo;
350            }
351            let compute_limit = mid.saturating_sub(gas_for_l1);
352            let (_, ok) = simulate(compute_limit).await?;
353            if ok {
354                hi = mid;
355            } else {
356                lo = mid;
357            }
358        }
359
360        Ok(hi)
361    }
362
363    /// Gas estimate for `NodeInterface.estimateRetryableTicket` —
364    /// `submit_intrinsic + auto_redeem_gas`.
365    async fn estimate_retryable_ticket_gas(
366        &self,
367        input: &alloy_primitives::Bytes,
368        at: BlockId,
369        state_override: Option<StateOverride>,
370    ) -> Result<U256, EthApiError>
371    where
372        RpcTxReq<<Rpc as RpcConvert>::Network>: From<alloy_rpc_types_eth::TransactionRequest>,
373    {
374        use alloy_primitives::{Bytes, TxKind};
375        use alloy_rpc_types_eth::TransactionRequest;
376
377        // ABI decode: selector(4) + 7 heads(32 each) + bytes tail.
378        // sender, deposit, to, l2CallValue, excessFeeRefundAddr,
379        // callValueRefundAddr, <bytes data offset>.
380        const HEAD_LEN: usize = 4 + 32 * 7;
381        if input.len() < HEAD_LEN {
382            return Err(EthApiError::InvalidParams(
383                "estimateRetryableTicket: calldata too short".into(),
384            ));
385        }
386        let sender = Address::from_slice(&input[4 + 12..4 + 32]);
387        let _deposit = U256::from_be_slice(&input[36..68]);
388        let to_word = &input[68..100];
389        let to = Address::from_slice(&to_word[12..32]);
390        let l2_call_value = U256::from_be_slice(&input[100..132]);
391        let _excess_fee_refund = Address::from_slice(&input[132 + 12..132 + 32]);
392        let _call_value_refund = Address::from_slice(&input[164 + 12..164 + 32]);
393        let data_offset: usize =
394            U256::from_be_slice(&input[196..228])
395                .try_into()
396                .map_err(|_| {
397                    EthApiError::InvalidParams(
398                        "estimateRetryableTicket: invalid data offset".into(),
399                    )
400                })?;
401        let abi_body = &input[4..];
402        let data: Bytes = if data_offset + 32 <= abi_body.len() {
403            let len: usize = U256::from_be_slice(&abi_body[data_offset..data_offset + 32])
404                .try_into()
405                .map_err(|_| {
406                    EthApiError::InvalidParams(
407                        "estimateRetryableTicket: data length too large".into(),
408                    )
409                })?;
410            if data_offset + 32 + len > abi_body.len() {
411                return Err(EthApiError::InvalidParams(
412                    "estimateRetryableTicket: data out of bounds".into(),
413                ));
414            }
415            Bytes::copy_from_slice(&abi_body[data_offset + 32..data_offset + 32 + len])
416        } else {
417            Bytes::new()
418        };
419
420        // `to == zero` means the retryable is a contract-create — map
421        // that to TxKind::Create for the estimate request.
422        let kind = if to == Address::ZERO {
423            TxKind::Create
424        } else {
425            TxKind::Call(to)
426        };
427        let data_ref = data.clone();
428        let equivalent = TransactionRequest {
429            from: Some(sender),
430            to: Some(kind),
431            value: Some(l2_call_value),
432            input: data.into(),
433            ..Default::default()
434        };
435        let equivalent_req: RpcTxReq<<Rpc as RpcConvert>::Network> = equivalent.into();
436
437        // Binary-search the auto-redeem gas via the standard eth
438        // estimation machinery. The equivalent call has the exact
439        // same state transitions as what the auto-redeem runs, so
440        // its gas result is the auto-redeem's gas 1:1.
441        let redeem_gas =
442            EstimateCall::estimate_gas_at(self, equivalent_req, at, state_override).await?;
443
444        // Submit-retryable intrinsic gas matches the default
445        // IntrinsicGas for ArbitrumSubmitRetryableTx: 21,000 tx base +
446        // EIP-2028 calldata (16 × non-zero + 4 × zero). ArbOS's
447        // state-transition overhead for retryable creation, escrow,
448        // and auto-redeem scheduling is charged inside the auto-redeem
449        // itself and is therefore already captured by `redeem_gas`.
450        let (zeros, non_zeros) =
451            data_ref.iter().fold(
452                (0u64, 0u64),
453                |(z, nz), &b| if b == 0 { (z + 1, nz) } else { (z, nz + 1) },
454            );
455        let calldata_gas = zeros
456            .saturating_mul(4)
457            .saturating_add(non_zeros.saturating_mul(16));
458        let submit_intrinsic = 21_000u64.saturating_add(calldata_gas);
459
460        Ok(redeem_gas.saturating_add(U256::from(submit_intrinsic)))
461    }
462
463    /// `eth_call` of `NodeInterface.estimateRetryableTicket(...)`:
464    /// synthesize the ArbitrumSubmitRetryableTx that corresponds to this
465    /// call and return its EIP-2718 envelope hash (the ticket ID).
466    async fn simulate_retryable_ticket_call(
467        &self,
468        input: &alloy_primitives::Bytes,
469        at: BlockId,
470        _overrides: alloy_rpc_types_eth::state::EvmOverrides,
471    ) -> Result<alloy_primitives::Bytes, EthApiError>
472    where
473        RpcTxReq<<Rpc as RpcConvert>::Network>: From<alloy_rpc_types_eth::TransactionRequest>,
474    {
475        use alloy_primitives::{keccak256, Bytes};
476        use arb_alloy_consensus::{tx::ArbSubmitRetryableTx, ArbTxEnvelope};
477
478        const HEAD_LEN: usize = 4 + 32 * 7;
479        if input.len() < HEAD_LEN {
480            return Err(EthApiError::InvalidParams(
481                "estimateRetryableTicket: calldata too short".into(),
482            ));
483        }
484        let sender = Address::from_slice(&input[4 + 12..4 + 32]);
485        let deposit = U256::from_be_slice(&input[36..68]);
486        let to = Address::from_slice(&input[68 + 12..100]);
487        let l2_call_value = U256::from_be_slice(&input[100..132]);
488        let excess_fee_refund = Address::from_slice(&input[132 + 12..164]);
489        let call_value_refund = Address::from_slice(&input[164 + 12..196]);
490        let data_offset: usize =
491            U256::from_be_slice(&input[196..228])
492                .try_into()
493                .map_err(|_| {
494                    EthApiError::InvalidParams(
495                        "estimateRetryableTicket: invalid data offset".into(),
496                    )
497                })?;
498        let abi_body = &input[4..];
499        let data: Bytes = if data_offset + 32 <= abi_body.len() {
500            let len: usize = U256::from_be_slice(&abi_body[data_offset..data_offset + 32])
501                .try_into()
502                .map_err(|_| {
503                    EthApiError::InvalidParams(
504                        "estimateRetryableTicket: data length too large".into(),
505                    )
506                })?;
507            if data_offset + 32 + len > abi_body.len() {
508                return Err(EthApiError::InvalidParams(
509                    "estimateRetryableTicket: data out of bounds".into(),
510                ));
511            }
512            Bytes::copy_from_slice(&abi_body[data_offset + 32..data_offset + 32 + len])
513        } else {
514            Bytes::new()
515        };
516
517        let l1_base_fee = {
518            let state = self
519                .inner
520                .provider()
521                .state_by_block_id(at)
522                .map_err(|e| EthApiError::Internal(e.into()))?;
523            let slot = subspace_slot(L1_PRICING_SUBSPACE, L1_PRICE_PER_UNIT);
524            state
525                .storage(
526                    ARBOS_STATE_ADDRESS,
527                    StorageKey::from(B256::from(slot.to_be_bytes::<32>())),
528                )
529                .map_err(|e| EthApiError::Internal(e.into()))?
530                .unwrap_or_default()
531        };
532
533        let aliased_from = apply_l1_to_l2_alias(sender);
534        let max_submission_fee =
535            arbos::retryables::retryable_submission_fee(data.len(), l1_base_fee);
536        let retry_to = if to == Address::ZERO { None } else { Some(to) };
537
538        let gas_cap = self.inner.gas_cap();
539        let gas = gas_cap;
540
541        let tx = ArbSubmitRetryableTx {
542            chain_id: U256::ZERO,
543            request_id: B256::ZERO,
544            from: aliased_from,
545            l1_base_fee,
546            deposit_value: deposit,
547            gas_fee_cap: U256::ZERO,
548            gas,
549            retry_to,
550            retry_value: l2_call_value,
551            beneficiary: call_value_refund,
552            max_submission_fee,
553            fee_refund_addr: excess_fee_refund,
554            retry_data: data,
555        };
556        let envelope = ArbTxEnvelope::SubmitRetryable(tx);
557        let encoded = envelope.encode_typed();
558        let hash = keccak256(&encoded);
559        Ok(alloy_primitives::Bytes::from(hash.0.to_vec()))
560    }
561
562    /// Handle `eth_call` dispatch of
563    /// `NodeInterfaceDebug.getRetryable(bytes32 ticketId)` — reads the
564    /// retryable record from storage and returns the 7-tuple
565    /// `(timeout, from, to, value, beneficiary, tries, data)`.
566    async fn get_retryable_abi(
567        &self,
568        input: &alloy_primitives::Bytes,
569        at: BlockId,
570    ) -> Result<alloy_primitives::Bytes, EthApiError> {
571        use arb_precompiles::storage_slot::{derive_subspace_key, map_slot, ROOT_STORAGE_KEY};
572        use arbos::retryables::{
573            BENEFICIARY_OFFSET, CALLDATA_KEY, CALLVALUE_OFFSET, FROM_OFFSET, NUM_TRIES_OFFSET,
574            TIMEOUT_OFFSET, TO_OFFSET,
575        };
576
577        if input.len() < 4 + 32 {
578            return Err(EthApiError::InvalidParams(
579                "getRetryable: expected bytes32 ticket".into(),
580            ));
581        }
582        let ticket = B256::from_slice(&input[4..36]);
583
584        let state = self
585            .inner
586            .provider()
587            .state_by_block_id(at)
588            .map_err(|e| EthApiError::Internal(e.into()))?;
589        let load = |slot: U256| -> Result<U256, EthApiError> {
590            let k = StorageKey::from(B256::from(slot.to_be_bytes::<32>()));
591            Ok(state
592                .storage(ARBOS_STATE_ADDRESS, k)
593                .map_err(|e| EthApiError::Internal(e.into()))?
594                .unwrap_or(U256::ZERO))
595        };
596
597        let retryables_key = derive_subspace_key(
598            ROOT_STORAGE_KEY,
599            arb_precompiles::storage_slot::RETRYABLES_SUBSPACE,
600        );
601        let r_key = derive_subspace_key(retryables_key.as_slice(), ticket.as_slice());
602
603        let timeout: u64 = load(map_slot(r_key.as_slice(), TIMEOUT_OFFSET))?
604            .try_into()
605            .unwrap_or(0);
606        if timeout == 0 {
607            return Err(EthApiError::InvalidParams(format!(
608                "no retryable with id 0x{ticket:x}"
609            )));
610        }
611        let from_word = load(map_slot(r_key.as_slice(), FROM_OFFSET))?;
612        let from = Address::from_slice(&from_word.to_be_bytes::<32>()[12..]);
613        let to_word = load(map_slot(r_key.as_slice(), TO_OFFSET))?;
614        let to_bytes: [u8; 32] = to_word.to_be_bytes();
615        // StorageBackedAddressOrNil uses all-ones in the high 12 bytes
616        // to encode Nil; actual encoding varies, so just take low 20
617        // bytes and let callers treat zero as nil.
618        let to = Address::from_slice(&to_bytes[12..]);
619        let value = load(map_slot(r_key.as_slice(), CALLVALUE_OFFSET))?;
620        let beneficiary_word = load(map_slot(r_key.as_slice(), BENEFICIARY_OFFSET))?;
621        let beneficiary = Address::from_slice(&beneficiary_word.to_be_bytes::<32>()[12..]);
622        let tries: u64 = load(map_slot(r_key.as_slice(), NUM_TRIES_OFFSET))?
623            .try_into()
624            .unwrap_or(0);
625
626        // Calldata lives under its own subspace with StorageBackedBytes
627        // layout: slot 0 = size, slot 1+ = chunks. We read the size,
628        // then each 32-byte chunk, and truncate.
629        let cd_key = derive_subspace_key(r_key.as_slice(), CALLDATA_KEY);
630        let size: usize = load(map_slot(cd_key.as_slice(), 0))?
631            .try_into()
632            .unwrap_or(0);
633        let chunks = size.div_ceil(32);
634        let mut data = Vec::with_capacity(size);
635        for i in 0..chunks {
636            let chunk = load(map_slot(cd_key.as_slice(), 1 + i as u64))?;
637            data.extend_from_slice(&chunk.to_be_bytes::<32>());
638        }
639        data.truncate(size);
640
641        // ABI-encode the 7-tuple:
642        //   head (7 × 32):
643        //     timeout, from, to, value, beneficiary, tries, data_offset
644        //   tail: data_len, data_bytes (padded to 32)
645        let mut out = vec![0u8; 7 * 32];
646        U256::from(timeout)
647            .to_be_bytes::<32>()
648            .iter()
649            .enumerate()
650            .for_each(|(i, b)| out[i] = *b);
651        out[32 + 12..32 + 32].copy_from_slice(from.as_slice());
652        out[64 + 12..64 + 32].copy_from_slice(to.as_slice());
653        out[96..128].copy_from_slice(&value.to_be_bytes::<32>());
654        out[128 + 12..128 + 32].copy_from_slice(beneficiary.as_slice());
655        U256::from(tries)
656            .to_be_bytes::<32>()
657            .iter()
658            .enumerate()
659            .for_each(|(i, b)| out[160 + i] = *b);
660        // data offset = 0xe0 (7 × 32).
661        U256::from(7u64 * 32)
662            .to_be_bytes::<32>()
663            .iter()
664            .enumerate()
665            .for_each(|(i, b)| out[192 + i] = *b);
666        // Tail.
667        let padded_len = size.div_ceil(32) * 32;
668        let mut tail = vec![0u8; 32 + padded_len];
669        U256::from(size as u64)
670            .to_be_bytes::<32>()
671            .iter()
672            .enumerate()
673            .for_each(|(i, b)| tail[i] = *b);
674        tail[32..32 + size].copy_from_slice(&data);
675        out.extend_from_slice(&tail);
676        Ok(alloy_primitives::Bytes::from(out))
677    }
678
679    /// Handle `eth_call` dispatch of
680    /// `NodeInterface.constructOutboxProof(size, leaf)`. Scans ArbSys
681    /// (0x64) L2ToL1Tx / SendMerkleUpdate event logs over the chain up
682    /// to `at` to resolve every node hash the proof walk needs, then
683    /// feeds the map to `outbox_proof::finalize_proof`.
684    async fn construct_outbox_proof(
685        &self,
686        input: &alloy_primitives::Bytes,
687        at: BlockId,
688    ) -> Result<alloy_primitives::Bytes, EthApiError>
689    where
690        N: RpcNodeCore<
691            Provider: reth_provider::BlockReaderIdExt + reth_storage_api::ReceiptProvider,
692        >,
693    {
694        use std::collections::HashMap;
695
696        use alloy_consensus::TxReceipt;
697        use arb_precompiles::arbsys::{
698            l2_to_l1_tx_topic, send_merkle_update_topic, ARBSYS_ADDRESS,
699        };
700        use reth_provider::{BlockNumReader, ReceiptProvider};
701
702        use crate::outbox_proof::{encode_outbox_proof, finalize_proof, plan_proof, LevelAndLeaf};
703
704        if input.len() < 4 + 64 {
705            return Err(EthApiError::InvalidParams(
706                "constructOutboxProof: expected (uint64 size, uint64 leaf)".into(),
707            ));
708        }
709        let size: u64 = U256::from_be_slice(&input[4..36])
710            .try_into()
711            .unwrap_or(u64::MAX);
712        let leaf: u64 = U256::from_be_slice(&input[36..68])
713            .try_into()
714            .unwrap_or(u64::MAX);
715
716        let plan = plan_proof(size, leaf).ok_or_else(|| {
717            EthApiError::InvalidParams(format!("constructOutboxProof: leaf {leaf} ≥ size {size}"))
718        })?;
719
720        // Resolve `at` to a concrete block number upper-bound. If
721        // `latest` or missing, use the chain tip.
722        let provider = self.inner.provider();
723        let tip = provider
724            .best_block_number()
725            .map_err(|e| EthApiError::Internal(e.into()))?;
726        let upper = match at {
727            BlockId::Number(alloy_rpc_types_eth::BlockNumberOrTag::Number(n)) => n.min(tip),
728            _ => tip,
729        };
730
731        // Scan receipts over [0..=upper] for ArbSys merkle + L2ToL1Tx
732        // logs. Topic layout for both events: topic[3] = position (a
733        // LevelAndLeaf packed as uint256), topic[1..3] carry the hash
734        // depending on which event variant.
735        let merkle_topic = send_merkle_update_topic();
736        let l2tol1_topic = l2_to_l1_tx_topic();
737
738        // Position → hash map. Keyed by the 32-byte position bytes.
739        let mut positions: HashMap<[u8; 32], B256> = HashMap::new();
740
741        let receipts_per_block = provider
742            .receipts_by_block_range(0..=upper)
743            .map_err(|e| EthApiError::Internal(e.into()))?;
744
745        for block_receipts in receipts_per_block {
746            for receipt in block_receipts {
747                for log in receipt.logs() {
748                    if log.address != ARBSYS_ADDRESS {
749                        continue;
750                    }
751                    let topics = log.data.topics();
752                    if topics.len() < 4 {
753                        continue;
754                    }
755                    let kind = topics[0];
756                    let is_merkle = kind == merkle_topic;
757                    let is_l2tol1 = kind == l2tol1_topic;
758                    if !is_merkle && !is_l2tol1 {
759                        continue;
760                    }
761                    // position encoded in topic[3]; hash in topic[2]
762                    // for both events (hash is an indexed arg).
763                    let pos: [u8; 32] = topics[3].0;
764                    let hash: B256 = topics[2];
765                    positions.insert(pos, hash);
766                }
767            }
768        }
769
770        let lookup = |p: LevelAndLeaf| -> Option<B256> {
771            let topic = p.as_topic();
772            positions.get(&topic.0).copied()
773        };
774
775        let (send, root, proof) = finalize_proof(&plan, leaf, lookup)
776            .map_err(|e| EthApiError::InvalidParams(format!("constructOutboxProof: {e}")))?;
777
778        Ok(encode_outbox_proof(send, root, &proof))
779    }
780}
781
782// ---- Trait delegations (matching reth's EthApi bounds exactly) ----
783
784impl<N, Rpc> EthApiTypes for ArbEthApi<N, Rpc>
785where
786    N: RpcNodeCore,
787    Rpc: RpcConvert<Error = EthApiError>,
788{
789    type Error = EthApiError;
790    type NetworkTypes = Rpc::Network;
791    type RpcConvert = Rpc;
792
793    fn converter(&self) -> &Self::RpcConvert {
794        self.inner.converter()
795    }
796}
797
798impl<N, Rpc> RpcNodeCore for ArbEthApi<N, Rpc>
799where
800    N: RpcNodeCore,
801    Rpc: RpcConvert,
802{
803    type Primitives = N::Primitives;
804    type Provider = N::Provider;
805    type Pool = N::Pool;
806    type Evm = N::Evm;
807    type Network = N::Network;
808
809    #[inline]
810    fn pool(&self) -> &Self::Pool {
811        self.inner.pool()
812    }
813
814    #[inline]
815    fn evm_config(&self) -> &Self::Evm {
816        self.inner.evm_config()
817    }
818
819    #[inline]
820    fn network(&self) -> &Self::Network {
821        self.inner.network()
822    }
823
824    #[inline]
825    fn provider(&self) -> &Self::Provider {
826        self.inner.provider()
827    }
828}
829
830impl<N, Rpc> RpcNodeCoreExt for ArbEthApi<N, Rpc>
831where
832    N: RpcNodeCore,
833    Rpc: RpcConvert,
834{
835    #[inline]
836    fn cache(&self) -> &EthStateCache<N::Primitives> {
837        self.inner.cache()
838    }
839}
840
841impl<N, Rpc> EthApiSpec for ArbEthApi<N, Rpc>
842where
843    N: RpcNodeCore,
844    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError>,
845{
846    fn starting_block(&self) -> U256 {
847        self.inner.starting_block()
848    }
849}
850
851impl<N, Rpc> SpawnBlocking for ArbEthApi<N, Rpc>
852where
853    N: RpcNodeCore,
854    Rpc: RpcConvert<Error = EthApiError>,
855{
856    #[inline]
857    fn io_task_spawner(&self) -> &Runtime {
858        self.inner.task_spawner()
859    }
860
861    #[inline]
862    fn tracing_task_pool(&self) -> &BlockingTaskPool {
863        self.inner.blocking_task_pool()
864    }
865
866    #[inline]
867    fn tracing_task_guard(&self) -> &BlockingTaskGuard {
868        self.inner.blocking_task_guard()
869    }
870
871    #[inline]
872    fn blocking_io_task_guard(&self) -> &Arc<tokio::sync::Semaphore> {
873        self.inner.blocking_io_request_semaphore()
874    }
875}
876
877impl<N, Rpc> LoadFee for ArbEthApi<N, Rpc>
878where
879    N: RpcNodeCore,
880    EthApiError: FromEvmError<N::Evm>,
881    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError>,
882{
883    fn gas_oracle(&self) -> &GasPriceOracle<Self::Provider> {
884        self.inner.gas_oracle()
885    }
886
887    fn fee_history_cache(&self) -> &FeeHistoryCache<ProviderHeader<N::Provider>> {
888        self.inner.fee_history_cache()
889    }
890}
891
892impl<N, Rpc> LoadState for ArbEthApi<N, Rpc>
893where
894    N: RpcNodeCore,
895    Rpc: RpcConvert<Primitives = N::Primitives>,
896    Self: LoadPendingBlock,
897{
898}
899
900impl<N, Rpc> EthState for ArbEthApi<N, Rpc>
901where
902    N: RpcNodeCore,
903    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError>,
904    Self: LoadPendingBlock,
905{
906    fn max_proof_window(&self) -> u64 {
907        self.inner.eth_proof_window()
908    }
909}
910
911impl<N, Rpc> EthFees for ArbEthApi<N, Rpc>
912where
913    N: RpcNodeCore,
914    EthApiError: FromEvmError<N::Evm>,
915    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError>,
916{
917    /// `eth_gasPrice` returns just the latest base fee — there is no
918    /// priority-fee market on this chain.
919    fn gas_price(&self) -> impl std::future::Future<Output = Result<U256, Self::Error>> + Send
920    where
921        Self: reth_rpc_eth_api::helpers::LoadBlock,
922    {
923        use alloy_consensus::BlockHeader;
924        use reth_storage_api::{BlockNumReader, HeaderProvider};
925        async move {
926            let best = self
927                .provider()
928                .best_block_number()
929                .map_err(|e| EthApiError::Internal(e.into()))?;
930            let header_opt = HeaderProvider::sealed_header(self.provider(), best)
931                .map_err(|e| EthApiError::Internal(e.into()))?;
932            let base_fee = match header_opt {
933                Some(sealed) => sealed.header().base_fee_per_gas().unwrap_or_default(),
934                None => 0,
935            };
936            Ok(U256::from(base_fee))
937        }
938    }
939
940    #[allow(clippy::manual_async_fn)]
941    fn suggested_priority_fee(
942        &self,
943    ) -> impl std::future::Future<Output = Result<U256, Self::Error>> + Send
944    where
945        Self: 'static,
946    {
947        async move { Ok(U256::ZERO) }
948    }
949}
950
951impl<N, Rpc> Trace for ArbEthApi<N, Rpc>
952where
953    N: RpcNodeCore,
954    EthApiError: FromEvmError<N::Evm>,
955    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError, Evm = N::Evm>,
956{
957}
958
959impl<N, Rpc> LoadPendingBlock for ArbEthApi<N, Rpc>
960where
961    N: RpcNodeCore,
962    EthApiError: FromEvmError<N::Evm>,
963    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError>,
964{
965    fn pending_block(&self) -> &tokio::sync::Mutex<Option<PendingBlock<N::Primitives>>> {
966        self.inner.pending_block()
967    }
968
969    fn pending_env_builder(&self) -> &dyn PendingEnvBuilder<N::Evm> {
970        self.inner.pending_env_builder()
971    }
972
973    fn pending_block_kind(&self) -> PendingBlockKind {
974        self.inner.pending_block_kind()
975    }
976}
977
978impl<N, Rpc> LoadBlock for ArbEthApi<N, Rpc>
979where
980    Self: LoadPendingBlock,
981    N: RpcNodeCore,
982    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError>,
983{
984}
985
986impl<N, Rpc> LoadTransaction for ArbEthApi<N, Rpc>
987where
988    N: RpcNodeCore,
989    EthApiError: FromEvmError<N::Evm>,
990    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError>,
991{
992}
993
994impl<N, Rpc> EthBlocks for ArbEthApi<N, Rpc>
995where
996    N: RpcNodeCore,
997    EthApiError: FromEvmError<N::Evm>,
998    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError>,
999{
1000}
1001
1002impl<N, Rpc> EthTransactions for ArbEthApi<N, Rpc>
1003where
1004    N: RpcNodeCore,
1005    EthApiError: FromEvmError<N::Evm>,
1006    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError>,
1007{
1008    fn signers(&self) -> &SignersForRpc<Self::Provider, Self::NetworkTypes> {
1009        self.inner.signers()
1010    }
1011
1012    fn send_raw_transaction_sync_timeout(&self) -> Duration {
1013        self.inner.send_raw_transaction_sync_timeout()
1014    }
1015
1016    async fn send_transaction(
1017        &self,
1018        origin: TransactionOrigin,
1019        tx: WithEncoded<Recovered<PoolPooledTx<Self::Pool>>>,
1020    ) -> Result<B256, Self::Error> {
1021        let (_tx_bytes, recovered) = tx.split();
1022        let pool_transaction = <Self::Pool as TransactionPool>::Transaction::from_pooled(recovered);
1023
1024        let AddedTransactionOutcome { hash, .. } = self
1025            .inner
1026            .add_pool_transaction(origin, pool_transaction)
1027            .await?;
1028
1029        Ok(hash)
1030    }
1031}
1032
1033impl<N, Rpc> LoadReceipt for ArbEthApi<N, Rpc>
1034where
1035    N: RpcNodeCore<Primitives = arb_primitives::ArbPrimitives>,
1036    EthApiError: FromEvmError<N::Evm>,
1037    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError>,
1038    Self::Error: reth_rpc_eth_types::error::FromEthApiError,
1039{
1040    /// Override to use `convert_receipts_with_block` so every single-tx
1041    /// receipt fetch (e.g. `eth_getTransactionReceipt`) includes the
1042    /// Arbitrum `l1BlockNumber` field sourced from the block's mix_hash.
1043    ///
1044    /// Reth's default impl uses `convert_receipts` (no-block path), which
1045    /// our `ArbReceiptConverter` populates with `l1_block_number = None`.
1046    /// That breaks Arbitrum spec (bridges, indexers, explorers all expect
1047    /// `l1BlockNumber` on every receipt).
1048    fn build_transaction_receipt(
1049        &self,
1050        tx: reth_storage_api::ProviderTx<Self::Provider>,
1051        meta: alloy_consensus::transaction::TransactionMeta,
1052        receipt: reth_storage_api::ProviderReceipt<Self::Provider>,
1053    ) -> impl std::future::Future<
1054        Output = Result<reth_rpc_eth_api::RpcReceipt<Self::NetworkTypes>, Self::Error>,
1055    > + Send {
1056        use alloy_consensus::TxReceipt;
1057        use reth_primitives_traits::SignerRecoverable;
1058        use reth_rpc_convert::transaction::ConvertReceiptInput;
1059        use reth_rpc_eth_api::RpcNodeCoreExt;
1060        use reth_rpc_eth_types::{
1061            error::FromEthApiError, utils::calculate_gas_used_and_next_log_index, EthApiError,
1062        };
1063        async move {
1064            let hash = meta.block_hash;
1065            let all_receipts = self
1066                .cache()
1067                .get_receipts(hash)
1068                .await
1069                .map_err(<Self::Error as FromEthApiError>::from_eth_err)?
1070                .ok_or_else(|| {
1071                    <Self::Error as FromEthApiError>::from_eth_err(EthApiError::HeaderNotFound(
1072                        hash.into(),
1073                    ))
1074                })?;
1075
1076            let (gas_used, next_log_index) =
1077                calculate_gas_used_and_next_log_index(meta.index, &all_receipts);
1078
1079            let block = self
1080                .cache()
1081                .get_recovered_block(hash)
1082                .await
1083                .map_err(<Self::Error as FromEthApiError>::from_eth_err)?;
1084
1085            let tx_recovered = tx
1086                .try_into_recovered_unchecked()
1087                .map_err(<Self::Error as FromEthApiError>::from_eth_err)?;
1088
1089            let input = ConvertReceiptInput {
1090                tx: tx_recovered.as_recovered_ref(),
1091                gas_used: receipt.cumulative_gas_used() - gas_used,
1092                receipt,
1093                next_log_index,
1094                meta,
1095            };
1096
1097            let result = match block {
1098                Some(sealed_block_with_senders) => self.converter().convert_receipts_with_block(
1099                    vec![input],
1100                    sealed_block_with_senders.sealed_block(),
1101                )?,
1102                None => self.converter().convert_receipts(vec![input])?,
1103            };
1104            Ok(result.into_iter().next().expect("one receipt in, one out"))
1105        }
1106    }
1107}
1108
1109// ---- Gas estimation override ----
1110
1111impl<N, Rpc> Call for ArbEthApi<N, Rpc>
1112where
1113    N: RpcNodeCore,
1114    EthApiError: FromEvmError<N::Evm>,
1115    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError, Evm = N::Evm>,
1116{
1117    #[inline]
1118    fn call_gas_limit(&self) -> u64 {
1119        self.inner.gas_cap()
1120    }
1121
1122    #[inline]
1123    fn max_simulate_blocks(&self) -> u64 {
1124        self.inner.max_simulate_blocks()
1125    }
1126
1127    #[inline]
1128    fn evm_memory_limit(&self) -> u64 {
1129        self.inner.evm_memory_limit()
1130    }
1131}
1132
1133impl<N, Rpc> EstimateCall for ArbEthApi<N, Rpc>
1134where
1135    N: RpcNodeCore,
1136    EthApiError: FromEvmError<N::Evm>,
1137    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError, Evm = N::Evm>,
1138{
1139    // Uses default binary search. L1 posting gas is added in EthCall below.
1140}
1141
1142impl<N, Rpc> EthCall for ArbEthApi<N, Rpc>
1143where
1144    N: RpcNodeCore<
1145        Provider: StateProviderFactory + reth_provider::BlockReaderIdExt + Clone,
1146        Primitives = arb_primitives::ArbPrimitives,
1147    >,
1148    EthApiError: FromEvmError<N::Evm>,
1149    Rpc: RpcConvert<Primitives = N::Primitives, Error = EthApiError, Evm = N::Evm>,
1150    RpcTxReq<<Rpc as RpcConvert>::Network>: AsRef<alloy_rpc_types_eth::TransactionRequest>
1151        + AsMut<alloy_rpc_types_eth::TransactionRequest>
1152        + Clone
1153        + Default
1154        + From<alloy_rpc_types_eth::TransactionRequest>,
1155{
1156    /// Override gas estimation to add L1 posting costs.
1157    ///
1158    /// Also intercepts `estimateRetryableTicket` calls to the
1159    /// NodeInterface (0xc8): client calls
1160    /// `eth_estimateGas({to:0xc8, data: estimateRetryableTicket(...)})`
1161    /// and expects back the gas for the retryable submission. We parse
1162    /// the ABI args, build an equivalent transaction request targeting
1163    /// the retry_to with retry_value + retry_data, run the standard
1164    /// estimation on that, and add the submit-retryable overhead.
1165    #[allow(clippy::manual_async_fn)]
1166    fn estimate_gas_at(
1167        &self,
1168        request: RpcTxReq<<Self::RpcConvert as RpcConvert>::Network>,
1169        at: BlockId,
1170        state_override: Option<StateOverride>,
1171    ) -> impl std::future::Future<Output = Result<U256, Self::Error>> + Send {
1172        async move {
1173            use crate::nodeinterface_rpc::NODE_INTERFACE_ADDRESS;
1174            use alloy_primitives::TxKind;
1175
1176            let inner = request.as_ref();
1177            let target: Option<Address> = match inner.to {
1178                Some(TxKind::Call(addr)) => Some(addr),
1179                _ => None,
1180            };
1181            let input_bytes: Option<alloy_primitives::Bytes> = inner.input.input().cloned();
1182
1183            // Intercept estimateRetryableTicket on NodeInterface (0xc8).
1184            //
1185            // ABI: estimateRetryableTicket(
1186            //   address sender, uint256 deposit, address to,
1187            //   uint256 l2CallValue, address excessFeeRefundAddress,
1188            //   address callValueRefundAddress, bytes data)
1189            //
1190            // selector: 0xc3dc5879
1191            if target == Some(NODE_INTERFACE_ADDRESS) {
1192                if let Some(ref buf) = input_bytes {
1193                    if buf.len() >= 4 && buf[..4] == [0xc3, 0xdc, 0x58, 0x79] {
1194                        return self
1195                            .estimate_retryable_ticket_gas(buf, at, state_override)
1196                            .await;
1197                    }
1198                }
1199            }
1200
1201            // Extract calldata length before request is consumed by the binary search.
1202            let calldata_len = input_bytes.as_ref().map(|b| b.len()).unwrap_or(0);
1203
1204            // Run the standard binary search to find compute gas.
1205            let compute_gas =
1206                EstimateCall::estimate_gas_at(self, request, at, state_override).await?;
1207
1208            // Add L1 posting gas.
1209            let l1_gas = self.l1_posting_gas(calldata_len, at)?;
1210
1211            if l1_gas > 0 {
1212                trace!(target: "rpc::eth::estimate", %compute_gas, l1_gas, "Adding L1 posting gas to estimate");
1213            }
1214
1215            Ok(compute_gas.saturating_add(U256::from(l1_gas)))
1216        }
1217    }
1218
1219    /// Intercept `eth_call` to the NodeInterface (0xc8) virtual contract
1220    /// for methods that need chain history or nested EVM calls. Methods
1221    /// that can be resolved at the precompile layer (with zero / empty
1222    /// fallbacks) are delegated to the default EVM path.
1223    #[allow(clippy::manual_async_fn)]
1224    fn call(
1225        &self,
1226        request: RpcTxReq<<Self::RpcConvert as RpcConvert>::Network>,
1227        block_number: Option<BlockId>,
1228        overrides: alloy_rpc_types_eth::state::EvmOverrides,
1229    ) -> impl std::future::Future<Output = Result<alloy_primitives::Bytes, Self::Error>> + Send
1230    {
1231        async move {
1232            use crate::nodeinterface_rpc::{
1233                encode_gas_estimate_components, encode_l2_block_range, NODE_INTERFACE_ADDRESS,
1234                SEL_GAS_ESTIMATE_COMPONENTS, SEL_GAS_ESTIMATE_L1_COMPONENT,
1235                SEL_L2_BLOCK_RANGE_FOR_L1,
1236            };
1237            use alloy_primitives::{Address, TxKind};
1238
1239            // Only intercept calls targeting the NodeInterface or
1240            // NodeInterfaceDebug addresses.
1241            let target: Option<Address> = match request.as_ref().to {
1242                Some(TxKind::Call(addr)) => Some(addr),
1243                _ => None,
1244            };
1245            let is_ni = target == Some(NODE_INTERFACE_ADDRESS);
1246            let is_ni_debug = target == Some(arb_precompiles::NODE_INTERFACE_DEBUG_ADDRESS);
1247
1248            // ArbGasInfo.getCurrentTxL1GasFees needs the poster fee for
1249            // this eth_call. The precompile can't see the outer message,
1250            // so compute the poster fee from the request envelope using
1251            // the same fake-tx + brotli + (units+256)*1.01 formula.
1252            if target == Some(arb_precompiles::ARBGASINFO_ADDRESS) {
1253                let input_bytes = request.as_ref().input.input().cloned().unwrap_or_default();
1254                if input_bytes.len() == 4 && input_bytes.as_ref() == SEL_GET_CURRENT_TX_L1_FEES {
1255                    return self.compute_eth_call_current_tx_l1_fees(
1256                        request,
1257                        block_number.unwrap_or_default(),
1258                    );
1259                }
1260                if input_bytes.len() == 4
1261                    && input_bytes.as_ref() == SEL_GET_L1_PRICING_UNITS_SINCE_UPDATE
1262                {
1263                    return self.compute_eth_call_units_since_update(
1264                        request,
1265                        block_number.unwrap_or_default(),
1266                    );
1267                }
1268            }
1269
1270            if !is_ni && !is_ni_debug {
1271                let _permit = self.acquire_owned_blocking_io().await;
1272                let res = self
1273                    .transact_call_at(request, block_number.unwrap_or_default(), overrides)
1274                    .await?;
1275                return <Self::Error as reth_rpc_eth_types::error::api::FromEvmError<N::Evm>>::ensure_success(res.result);
1276            }
1277
1278            // NodeInterfaceDebug (0xc9) has one method: getRetryable(bytes32).
1279            if is_ni_debug {
1280                let at = block_number.unwrap_or_default();
1281                let data: alloy_primitives::Bytes =
1282                    request.as_ref().input.input().cloned().unwrap_or_default();
1283                return self.get_retryable_abi(&data, at).await;
1284            }
1285
1286            // Parse selector.
1287            let input_bytes = request.as_ref().input.input().cloned().unwrap_or_default();
1288            if input_bytes.len() < 4 {
1289                // Fall back to EVM (which will revert with our precompile).
1290                let _permit = self.acquire_owned_blocking_io().await;
1291                let res = self
1292                    .transact_call_at(request, block_number.unwrap_or_default(), overrides)
1293                    .await?;
1294                return <Self::Error as reth_rpc_eth_types::error::api::FromEvmError<N::Evm>>::ensure_success(res.result);
1295            }
1296            let selector: [u8; 4] = [
1297                input_bytes[0],
1298                input_bytes[1],
1299                input_bytes[2],
1300                input_bytes[3],
1301            ];
1302            let at = block_number.unwrap_or_default();
1303
1304            match selector {
1305                SEL_GAS_ESTIMATE_COMPONENTS | SEL_GAS_ESTIMATE_L1_COMPONENT => {
1306                    use alloy_rpc_types_eth::TransactionRequest;
1307
1308                    let (inner_to, inner_creation, inner_data) =
1309                        arb_precompiles::decode_estimate_args(&input_bytes).ok_or_else(|| {
1310                            EthApiError::InvalidParams(
1311                                "gasEstimateComponents: malformed calldata".into(),
1312                            )
1313                        })?;
1314
1315                    let (l1_price, basefee, min_basefee, chain_id_u, brotli_level) = {
1316                        let state = self
1317                            .inner
1318                            .provider()
1319                            .state_by_block_id(at)
1320                            .map_err(|e| EthApiError::Internal(e.into()))?;
1321                        let read = |slot: U256| -> Result<U256, EthApiError> {
1322                            Ok(state
1323                                .storage(
1324                                    ARBOS_STATE_ADDRESS,
1325                                    StorageKey::from(B256::from(slot.to_be_bytes::<32>())),
1326                                )
1327                                .map_err(|e| EthApiError::Internal(e.into()))?
1328                                .unwrap_or_default())
1329                        };
1330                        let l1_price = read(subspace_slot(L1_PRICING_SUBSPACE, L1_PRICE_PER_UNIT))?;
1331                        let basefee = read(subspace_slot(L2_PRICING_SUBSPACE, L2_BASE_FEE))?;
1332                        let min_basefee =
1333                            read(subspace_slot(L2_PRICING_SUBSPACE, L2_MIN_BASE_FEE))?;
1334                        let chain_id_u: u64 =
1335                            read(root_slot(CHAIN_ID_OFFSET))?.try_into().unwrap_or(0);
1336                        let brotli_level: u64 = read(root_slot(BROTLI_COMPRESSION_LEVEL_OFFSET))?
1337                            .try_into()
1338                            .unwrap_or(0);
1339                        (l1_price, basefee, min_basefee, chain_id_u, brotli_level)
1340                    };
1341
1342                    let gas_for_l1 = arb_precompiles::compute_l1_gas_for_estimate(
1343                        chain_id_u,
1344                        inner_to,
1345                        inner_creation,
1346                        U256::ZERO,
1347                        inner_data.clone(),
1348                        l1_price,
1349                        basefee,
1350                        min_basefee,
1351                        brotli_level,
1352                    );
1353
1354                    if selector == SEL_GAS_ESTIMATE_L1_COMPONENT {
1355                        let mut out = vec![0u8; 96];
1356                        out[24..32].copy_from_slice(&gas_for_l1.to_be_bytes());
1357                        out[32..64].copy_from_slice(&basefee.to_be_bytes::<32>());
1358                        out[64..96].copy_from_slice(&l1_price.to_be_bytes::<32>());
1359                        return Ok(alloy_primitives::Bytes::from(out));
1360                    }
1361
1362                    let kind = if inner_creation {
1363                        TxKind::Create
1364                    } else {
1365                        TxKind::Call(inner_to)
1366                    };
1367                    let from = request.as_ref().from.unwrap_or(Address::ZERO);
1368                    let inner_request = TransactionRequest {
1369                        from: Some(from),
1370                        to: Some(kind),
1371                        value: Some(U256::ZERO),
1372                        input: inner_data.into(),
1373                        ..Default::default()
1374                    };
1375                    let inner_req: RpcTxReq<<Rpc as RpcConvert>::Network> = inner_request.into();
1376
1377                    let total = self
1378                        .estimate_arb_combined_gas(inner_req, gas_for_l1, at, overrides.state)
1379                        .await?;
1380
1381                    Ok(encode_gas_estimate_components(
1382                        total, gas_for_l1, basefee, l1_price,
1383                    ))
1384                }
1385
1386                SEL_L2_BLOCK_RANGE_FOR_L1 => {
1387                    use reth_provider::{BlockNumReader, BlockReaderIdExt};
1388
1389                    if input_bytes.len() < 4 + 32 {
1390                        return Err(EthApiError::InvalidParams(
1391                            "l2BlockRangeForL1: missing uint64 arg".into(),
1392                        ));
1393                    }
1394                    let target_l1: u64 = U256::from_be_slice(&input_bytes[4..36])
1395                        .try_into()
1396                        .unwrap_or(u64::MAX);
1397
1398                    let provider = self.inner.provider().clone();
1399                    let best = provider
1400                        .best_block_number()
1401                        .map_err(|e| EthApiError::Internal(e.into()))?;
1402
1403                    let mix_hash_of = move |n: u64| -> Option<B256> {
1404                        use alloy_consensus::BlockHeader;
1405                        provider
1406                            .sealed_header_by_number_or_tag(
1407                                alloy_rpc_types_eth::BlockNumberOrTag::Number(n),
1408                            )
1409                            .ok()
1410                            .flatten()
1411                            .and_then(|h| h.header().mix_hash())
1412                    };
1413
1414                    match crate::nodeinterface_rpc::find_l2_block_range(
1415                        target_l1,
1416                        best,
1417                        mix_hash_of,
1418                    ) {
1419                        Some((first, last)) => Ok(encode_l2_block_range(first, last)),
1420                        None => Err(EthApiError::InvalidParams(format!(
1421                            "l2BlockRangeForL1: no L2 blocks found for L1 block {target_l1}"
1422                        ))),
1423                    }
1424                }
1425
1426                // estimateRetryableTicket via eth_call.
1427                [0xc3, 0xdc, 0x58, 0x79] => {
1428                    self.simulate_retryable_ticket_call(&input_bytes, at, overrides)
1429                        .await
1430                }
1431
1432                // constructOutboxProof(uint64 size, uint64 leaf): scan
1433                // ArbSys SendMerkleUpdate / L2ToL1Tx events, build a
1434                // position → hash map, run the outbox-proof algorithm.
1435                [0x42, 0x69, 0x63, 0x50] => self.construct_outbox_proof(&input_bytes, at).await,
1436
1437                _ => {
1438                    // Delegate to EVM (precompile returns zero / reverts).
1439                    let _permit = self.acquire_owned_blocking_io().await;
1440                    let res = self.transact_call_at(request, at, overrides).await?;
1441                    <Self::Error as reth_rpc_eth_types::error::api::FromEvmError<N::Evm>>::ensure_success(res.result)
1442                }
1443            }
1444        }
1445    }
1446}