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