Skip to main content

evm_fork_cache/
access_list.rs

1//! EIP-2930 access list builder with L2 profitability accounting.
2//!
3//! The caller supplies the addresses and storage slots to include; this module
4//! decides *whether attaching the list is profitable*, not *which slots are
5//! interesting* (it carries no protocol-specific slot knowledge). The trade-off
6//! is purely economic: pre-declaring an account/slot warms it (cheaper EIP-2929
7//! execution) but costs L1 data to post the list. Slots whose serialized form is
8//! mostly zero bytes (e.g. small, low-entropy values) are cheap to post; dense,
9//! high-entropy 32-byte keys are expensive on L1 — so the value of a given entry
10//! depends on its bytes, which is why the include/exclude decision is left to the
11//! caller and the profitability check below.
12//!
13//! On L2, automatically disables itself when L1 fees rise high enough that the
14//! L1 data cost exceeds the L2 execution savings. Arbitrum uses `ArbGasInfo`
15//! pricing with exact EIP-2930 RLP data gas; OP Stack chains use
16//! `GasPriceOracle.getL1Fee(bytes)` to compare whole transactions with and
17//! without the access list.
18//!
19//! On L1 (Ethereum): Access lists always save gas (no L1 data posting overhead),
20//! so use `into_access_list_always()` to skip the profitability check.
21
22use alloy_eips::{
23    BlockNumberOrTag,
24    eip2930::{AccessList, AccessListItem},
25};
26use alloy_network::Network;
27use alloy_primitives::{Address, B256, Bytes, U256, address};
28use alloy_provider::Provider;
29use alloy_rlp::Encodable;
30use alloy_rpc_types_eth::{TransactionInput, TransactionRequest};
31use alloy_sol_types::{SolCall, sol};
32use revm::context::result::ExecutionResult;
33use tracing::{debug, info};
34
35use crate::cache::EvmCache;
36use crate::errors::{AccessListError, AccessListResult as Result};
37
38/// Arbitrum ArbGasInfo precompile.
39const ARB_GAS_INFO: Address = address!("000000000000000000000000000000000000006C");
40
41/// Optimism GasPriceOracle predeploy (Bedrock+).
42///
43/// Fixed predeploy address on every OP Stack chain. Queried for the L1 base fee
44/// ([`query_l1_base_fee_for_chain`]) and the full Ecotone L1 data fee
45/// ([`compute_op_l1_fee`]).
46pub const OP_GAS_PRICE_ORACLE: Address = address!("420000000000000000000000000000000000000F");
47
48/// Chain fee model used by helpers that only need to identify the chain's L1
49/// base-fee oracle.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ChainType {
52    /// Ethereum L1-like chains where access lists do not incur rollup data fees.
53    L1,
54    /// Arbitrum-style rollups with ArbGasInfo pricing.
55    Arbitrum,
56    /// OP Stack rollups with GasPriceOracle pricing.
57    OpStack,
58}
59
60/// Pricing inputs used when deciding whether to include a simulation access list.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum AccessListPricing {
63    /// Ethereum L1-like chains where access lists do not incur rollup data fees.
64    L1,
65    /// Arbitrum-style rollups priced through the `ArbGasInfo` precompile.
66    Arbitrum,
67    /// OP Stack rollups priced by comparing oracle L1 fees for full tx bytes.
68    OpStack {
69        /// Serialized unsigned transaction bytes without an access list.
70        tx_without_access_list: Bytes,
71        /// Serialized unsigned transaction bytes with the candidate access list.
72        tx_with_access_list: Bytes,
73    },
74}
75
76sol! {
77    #[sol(rpc)]
78    interface ArbGasInfo {
79        function getPricesInWei() external view returns (
80            uint256 perL2Tx,
81            uint256 perL1CalldataUnit,
82            uint256 perStorageUnit,
83            uint256 perArbGas,
84            uint256 perL1Surplus,
85            uint256 baseFee
86        );
87        function getL1BaseFeeEstimate() external view returns (uint256);
88    }
89
90    #[sol(rpc)]
91    interface OpGasPriceOracle {
92        function l1BaseFee() external view returns (uint256);
93        function getL1Fee(bytes _data) external view returns (uint256);
94    }
95}
96
97/// An EIP-2930 access list builder with L2 profitability accounting.
98///
99/// The caller decides which addresses/slots to add (via
100/// [`add_address`](Self::add_address) / [`add_storage_key`](Self::add_storage_key));
101/// the builder itself applies no per-entry selection. The `into_access_list_*`
102/// finalizers decide whether attaching the *whole* list is profitable, comparing
103/// its L1 data-posting cost against the L2 warm-access savings.
104pub struct SmartAccessList {
105    items: Vec<AccessListItem>,
106}
107
108impl SmartAccessList {
109    /// Create an empty smart access-list builder.
110    ///
111    /// Populate it with [`SmartAccessList::add_address`] and
112    /// [`SmartAccessList::add_storage_key`], then finalize with one of the
113    /// `into_access_list_*` methods.
114    pub fn new() -> Self {
115        Self { items: Vec::new() }
116    }
117
118    /// Create a builder from precomputed EIP-2930 items.
119    ///
120    /// The items are taken as-is; this constructor does not deduplicate
121    /// addresses or storage keys (unlike [`SmartAccessList::add_address`] and
122    /// [`SmartAccessList::add_storage_key`]). Pass items that are already
123    /// distinct, or rely on downstream encoders to fold duplicates.
124    pub fn from_items(items: Vec<AccessListItem>) -> Self {
125        Self { items }
126    }
127
128    /// Add an address to the access list (address-only, no specific storage keys).
129    /// Useful for contracts that are accessed on every call.
130    pub fn add_address(&mut self, address: Address) {
131        if !self.items.iter().any(|item| item.address == address) {
132            self.items.push(AccessListItem {
133                address,
134                storage_keys: Vec::new(),
135            });
136        }
137    }
138
139    /// Add one storage key for an address, deduplicating both address and key.
140    pub fn add_storage_key(&mut self, address: Address, storage_key: B256) {
141        if let Some(item) = self.items.iter_mut().find(|item| item.address == address) {
142            push_unique(&mut item.storage_keys, storage_key);
143        } else {
144            self.items.push(AccessListItem {
145                address,
146                storage_keys: vec![storage_key],
147            });
148        }
149    }
150
151    /// Return the access list unconditionally.
152    ///
153    /// On L1 chains there is no L1 data posting overhead, so access lists
154    /// always save gas (100 gas per warm-vs-cold SLOAD). Returns `None`
155    /// only when the list is empty.
156    pub fn into_access_list_always(self) -> Option<AccessList> {
157        if self.items.is_empty() {
158            return None;
159        }
160        info!(
161            items = self.items.len(),
162            "Using access list unconditionally (L1 mode)"
163        );
164        Some(AccessList(self.items))
165    }
166
167    /// Evaluate Arbitrum profitability against current L1/L2 gas prices and
168    /// return the access list only if it saves money.
169    ///
170    /// Queries the Arbitrum `ArbGasInfo` precompile for pricing, then compares
171    /// the L2 execution savings against the estimated L1 data cost of posting
172    /// the serialized list:
173    ///
174    /// - **L2 savings**: `100 gas * entry_count * perArbGas`, where each address
175    ///   and each storage key counts as one entry (the EIP-2929 warm-vs-cold
176    ///   access discount).
177    /// - **L1 cost**: `l1_data_gas * l1_base_fee`, where `l1_data_gas` is the
178    ///   exact per-byte calldata gas ([`l1_data_gas_for_bytes`]) of the EIP-2930
179    ///   RLP-encoded access list.
180    ///
181    /// # Errors
182    ///
183    /// Returns `Err` if the provider/pricing queries fail.
184    ///
185    /// Returns `Ok(None)` when:
186    /// - the list is empty,
187    /// - either the L2 or L1 gas price reads as zero, or
188    /// - the estimated L1 cost meets or exceeds the L2 savings (not profitable).
189    pub async fn into_access_list_if_profitable<P: Provider>(
190        self,
191        provider: &P,
192    ) -> Result<Option<AccessList>> {
193        if self.items.is_empty() {
194            return Ok(None);
195        }
196
197        // Query ArbGasInfo for current pricing
198        let arb = ArbGasInfo::new(ARB_GAS_INFO, provider);
199        let prices_call = arb.getPricesInWei();
200        let prices = prices_call
201            .call()
202            .await
203            .map_err(|e| AccessListError::query("ArbGasInfo prices", e))?;
204        let l1_fee_call = arb.getL1BaseFeeEstimate();
205        let l1_base_fee = l1_fee_call
206            .call()
207            .await
208            .map_err(|e| AccessListError::query("ArbGasInfo L1 base fee", e))?;
209
210        let l2_gas_price = prices.perArbGas;
211
212        if l2_gas_price.is_zero() || l1_base_fee.is_zero() {
213            debug!("L1 or L2 gas price is zero, skipping access list");
214            return Ok(None);
215        }
216
217        let access_list = AccessList(self.items);
218        if log_access_list_profitability(
219            &access_list,
220            l2_gas_price,
221            l1_base_fee,
222            "Access list profitability check",
223        ) {
224            Ok(Some(access_list))
225        } else {
226            Ok(None)
227        }
228    }
229}
230
231/// Evaluate whether an existing access list is profitable on Arbitrum.
232///
233/// Each access list entry saves L2 execution gas (warm vs cold access) but
234/// costs L1 data posting gas for its serialized bytes. This function queries
235/// `ArbGasInfo`, computes the exact EIP-2930 RLP data gas, and returns the
236/// access list only if profitable.
237///
238/// This is the free-function counterpart to
239/// [`SmartAccessList::into_access_list_if_profitable`] for a pre-built
240/// [`AccessList`]; the two share the same cost model and break-even comparison.
241///
242/// # Errors
243///
244/// Returns `Err` if the provider/pricing queries fail.
245///
246/// Returns `Ok(None)` when:
247/// - the list is empty,
248/// - either the L2 or L1 gas price reads as zero, or
249/// - the estimated L1 cost meets or exceeds the L2 savings (not profitable).
250pub async fn access_list_if_profitable<P: Provider>(
251    access_list: AccessList,
252    provider: &P,
253) -> Result<Option<AccessList>> {
254    if access_list.0.is_empty() {
255        return Ok(None);
256    }
257
258    // Query ArbGasInfo for current pricing
259    let arb = ArbGasInfo::new(ARB_GAS_INFO, provider);
260    let prices = arb
261        .getPricesInWei()
262        .call()
263        .await
264        .map_err(|e| AccessListError::query("ArbGasInfo prices", e))?;
265    let l1_base_fee = arb
266        .getL1BaseFeeEstimate()
267        .call()
268        .await
269        .map_err(|e| AccessListError::query("ArbGasInfo L1 base fee", e))?;
270
271    let l2_gas_price = prices.perArbGas;
272
273    if l2_gas_price.is_zero() || l1_base_fee.is_zero() {
274        debug!("L1 or L2 gas price is zero, skipping access list");
275        return Ok(None);
276    }
277
278    if log_access_list_profitability(
279        &access_list,
280        l2_gas_price,
281        l1_base_fee,
282        "Simulation access list profitability check",
283    ) {
284        Ok(Some(access_list))
285    } else {
286        Ok(None)
287    }
288}
289
290/// Select the appropriate access list strategy based on pricing inputs.
291///
292/// - **L1**: Always include the simulation access list (no L1 data cost penalty).
293///   Returns `None` only if the list is empty.
294/// - **Arbitrum**: Include only when warm-access savings exceed the exact
295///   EIP-2930 RLP data cost priced through `ArbGasInfo`.
296/// - **OP Stack**: Include only when warm-access savings exceed the incremental
297///   `GasPriceOracle.getL1Fee(bytes)` fee between the transaction without and
298///   with the access list.
299pub async fn resolve_access_list<P: Provider>(
300    sim_access_list: AccessList,
301    provider: &P,
302    pricing: AccessListPricing,
303) -> Result<Option<AccessList>> {
304    if sim_access_list.0.is_empty() {
305        return Ok(None);
306    }
307
308    match pricing {
309        AccessListPricing::L1 => Ok(Some(sim_access_list)),
310        AccessListPricing::Arbitrum => access_list_if_profitable(sim_access_list, provider).await,
311        AccessListPricing::OpStack {
312            tx_without_access_list,
313            tx_with_access_list,
314        } => {
315            access_list_if_profitable_op_stack(
316                sim_access_list,
317                provider,
318                tx_without_access_list,
319                tx_with_access_list,
320            )
321            .await
322        }
323    }
324}
325
326async fn access_list_if_profitable_op_stack<P: Provider>(
327    access_list: AccessList,
328    provider: &P,
329    tx_without_access_list: Bytes,
330    tx_with_access_list: Bytes,
331) -> Result<Option<AccessList>> {
332    let l2_gas_price = U256::from(
333        provider
334            .get_gas_price()
335            .await
336            .map_err(|e| AccessListError::query("OP Stack provider gas price", e))?,
337    );
338
339    let l1_fee_without = query_op_l1_fee(provider, tx_without_access_list)
340        .await
341        .map_err(|e| AccessListError::Query {
342            operation: "OP Stack GasPriceOracle L1 fee without access list",
343            details: e.to_string(),
344        })?;
345    let l1_fee_with = query_op_l1_fee(provider, tx_with_access_list)
346        .await
347        .map_err(|e| AccessListError::Query {
348            operation: "OP Stack GasPriceOracle L1 fee with access list",
349            details: e.to_string(),
350        })?;
351
352    let incremental_l1_fee = l1_fee_with.saturating_sub(l1_fee_without);
353    let total_entries = access_list_entry_count(&access_list);
354    let l2_savings_wei = U256::from(total_entries) * U256::from(100) * l2_gas_price;
355    let profitable = l2_savings_wei > incremental_l1_fee;
356
357    info!(
358        entries = total_entries,
359        items = access_list.0.len(),
360        l2_savings_wei = %l2_savings_wei,
361        l1_fee_without_wei = %l1_fee_without,
362        l1_fee_with_wei = %l1_fee_with,
363        incremental_l1_fee_wei = %incremental_l1_fee,
364        l2_gas_price_gwei = %format_gwei(l2_gas_price),
365        profitable,
366        "OP Stack access list profitability check"
367    );
368
369    if profitable {
370        Ok(Some(access_list))
371    } else {
372        Ok(None)
373    }
374}
375
376async fn query_op_l1_fee<P: Provider>(provider: &P, tx_data: Bytes) -> Result<U256> {
377    let calldata = OpGasPriceOracle::getL1FeeCall { _data: tx_data }.abi_encode();
378    let tx = TransactionRequest::default()
379        .to(OP_GAS_PRICE_ORACLE)
380        .input(TransactionInput::from(calldata));
381
382    provider
383        .client()
384        .request("eth_call", (tx, BlockNumberOrTag::Latest))
385        .await
386        .map_err(|e| AccessListError::query("OP Stack GasPriceOracle.getL1Fee eth_call", e))
387}
388
389/// Query the current L1 base fee estimate, dispatching to the correct predeploy
390/// based on chain type. Returns `U256::ZERO` for L1 chains or on failure.
391pub async fn query_l1_base_fee_for_chain<P, N>(provider: &P, chain_type: ChainType) -> U256
392where
393    P: Provider<N>,
394    N: Network,
395{
396    match chain_type {
397        ChainType::Arbitrum => {
398            let arb = ArbGasInfo::new(ARB_GAS_INFO, provider);
399            match arb.getL1BaseFeeEstimate().call().await {
400                Ok(fee) => fee,
401                Err(e) => {
402                    debug!(error = %e, "Failed to query L1 base fee from ArbGasInfo");
403                    U256::ZERO
404                }
405            }
406        }
407        ChainType::OpStack => {
408            let oracle = OpGasPriceOracle::new(OP_GAS_PRICE_ORACLE, provider);
409            match oracle.l1BaseFee().call().await {
410                Ok(fee) => fee,
411                Err(e) => {
412                    debug!(error = %e, "Failed to query L1 base fee from OP GasPriceOracle");
413                    U256::ZERO
414                }
415            }
416        }
417        ChainType::L1 => U256::ZERO,
418    }
419}
420
421/// Compute the OP stack L1 data fee for a given transaction calldata.
422///
423/// Calls `GasPriceOracle.getL1Fee(bytes)` which handles the full Ecotone fee
424/// model internally (base fee scalars, blob base fee, compression). This gives
425/// the actual L1 data posting cost in wei, unlike the Arbitrum formula which
426/// simply multiplies `calldata_gas * l1_base_fee`.
427///
428/// Returns `U256::ZERO` on any failure (e.g. predeploy not available).
429pub fn compute_op_l1_fee(cache: &mut EvmCache, calldata: &[u8]) -> U256 {
430    let encoded = OpGasPriceOracle::getL1FeeCall {
431        _data: calldata.to_vec().into(),
432    }
433    .abi_encode();
434
435    match cache.call_raw(Address::ZERO, OP_GAS_PRICE_ORACLE, encoded.into(), false) {
436        Ok(ExecutionResult::Success { output, .. }) => {
437            let out = output.into_data();
438            OpGasPriceOracle::getL1FeeCall::abi_decode_returns(&out).unwrap_or(U256::ZERO)
439        }
440        Ok(_) => {
441            debug!("GasPriceOracle.getL1Fee() reverted");
442            U256::ZERO
443        }
444        Err(e) => {
445            debug!(error = %e, "Failed to call GasPriceOracle.getL1Fee()");
446            U256::ZERO
447        }
448    }
449}
450
451impl Default for SmartAccessList {
452    fn default() -> Self {
453        Self::new()
454    }
455}
456
457fn push_unique(vec: &mut Vec<B256>, val: B256) {
458    if !vec.contains(&val) {
459        vec.push(val);
460    }
461}
462
463/// L1 calldata gas for a byte slice: zero bytes = 4 gas, non-zero = 16 gas.
464///
465/// This is the post-EIP-2028 calldata pricing used to approximate the L1 data
466/// cost of serialized access-list entries. It counts the raw bytes only and
467/// does not add any RLP framing overhead.
468///
469/// # Examples
470///
471/// ```
472/// use evm_fork_cache::access_list::l1_data_gas_for_bytes;
473///
474/// // All-zero 32-byte slot: 32 * 4 = 128 gas.
475/// assert_eq!(l1_data_gas_for_bytes(&[0u8; 32]), 128);
476/// // All-non-zero 20-byte address: 20 * 16 = 320 gas.
477/// assert_eq!(l1_data_gas_for_bytes(&[0xFFu8; 20]), 320);
478/// // Empty slice costs nothing.
479/// assert_eq!(l1_data_gas_for_bytes(&[]), 0);
480/// ```
481pub fn l1_data_gas_for_bytes(data: &[u8]) -> u64 {
482    data.iter()
483        .map(|&b| if b == 0 { 4u64 } else { 16u64 })
484        .sum()
485}
486
487/// Exact L1 calldata gas for the EIP-2930 RLP encoding of an access list.
488pub fn access_list_rlp_data_gas(access_list: &AccessList) -> u64 {
489    let mut encoded = Vec::with_capacity(access_list.length());
490    access_list.encode(&mut encoded);
491    l1_data_gas_for_bytes(&encoded)
492}
493
494fn access_list_entry_count(access_list: &AccessList) -> u64 {
495    access_list
496        .0
497        .iter()
498        .map(|item| 1 + item.storage_keys.len() as u64)
499        .sum()
500}
501
502fn log_access_list_profitability(
503    access_list: &AccessList,
504    l2_gas_price: U256,
505    l1_base_fee: U256,
506    message: &'static str,
507) -> bool {
508    let total_entries = access_list_entry_count(access_list);
509    let total_l1_data_gas = access_list_rlp_data_gas(access_list);
510    let l2_savings_wei = U256::from(total_entries) * U256::from(100) * l2_gas_price;
511    let l1_cost_wei = U256::from(total_l1_data_gas) * l1_base_fee;
512    let profitable = l2_savings_wei > l1_cost_wei;
513
514    info!(
515        entries = total_entries,
516        items = access_list.0.len(),
517        l1_data_gas = total_l1_data_gas,
518        l2_savings_wei = %l2_savings_wei,
519        l1_cost_wei = %l1_cost_wei,
520        l2_gas_price_gwei = %format_gwei(l2_gas_price),
521        l1_base_fee_gwei = %format_gwei(l1_base_fee),
522        profitable,
523        check = message,
524        "Access list profitability check"
525    );
526
527    profitable
528}
529
530/// Filter already-warm and excluded addresses from an access list, then apply
531/// it to the transaction request.
532///
533/// Removes entries for:
534/// - `sender` — always warm as tx origin per EIP-2929
535/// - `tx.to` — always warm as the destination per EIP-2929
536/// - Any addresses in `exclude` — caller-excluded addresses
537///
538/// After filtering, sets the access list on `tx` (skipped if the list is empty).
539pub fn apply_access_list(
540    tx: &mut alloy_rpc_types_eth::TransactionRequest,
541    access_list: &mut AccessList,
542    sender: Address,
543    exclude: &[Address],
544) {
545    let tx_to = tx.to.as_ref().and_then(|t| t.to().copied());
546    access_list.0.retain(|item| {
547        if Some(item.address) == tx_to || item.address == sender {
548            return false;
549        }
550        if exclude.contains(&item.address) {
551            return false;
552        }
553        true
554    });
555    if !access_list.0.is_empty() {
556        *tx = std::mem::take(tx).access_list(access_list.clone());
557    }
558}
559
560fn format_gwei(wei: U256) -> String {
561    let gwei = wei / U256::from(1_000_000_000u64);
562    let remainder = (wei % U256::from(1_000_000_000u64))
563        .try_into()
564        .unwrap_or(0u64);
565    format!("{}.{:03}", gwei, remainder / 1_000_000)
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use alloy_primitives::Bytes;
572
573    #[test]
574    fn add_address_deduplicates_address_only_entries() {
575        let address = Address::repeat_byte(0xAA);
576        let mut al = SmartAccessList::new();
577
578        al.add_address(address);
579        al.add_address(address);
580
581        let access_list = al.into_access_list_always().expect("non-empty");
582        assert_eq!(access_list.0.len(), 1);
583        assert_eq!(access_list.0[0].address, address);
584        assert!(access_list.0[0].storage_keys.is_empty());
585    }
586
587    #[test]
588    fn add_storage_key_deduplicates_keys() {
589        let address = Address::repeat_byte(0xBB);
590        let key = B256::from(U256::from(4));
591        let mut al = SmartAccessList::new();
592
593        al.add_storage_key(address, key);
594        al.add_storage_key(address, key);
595
596        let access_list = al.into_access_list_always().expect("should return Some");
597        assert_eq!(access_list.0.len(), 1);
598        assert_eq!(access_list.0[0].address, address);
599        assert_eq!(access_list.0[0].storage_keys, vec![key]);
600    }
601
602    #[test]
603    fn into_access_list_always_returns_none_when_empty() {
604        let al = SmartAccessList::new();
605        assert!(al.into_access_list_always().is_none());
606    }
607
608    #[test]
609    fn l1_gas_for_zero_bytes_is_cheap() {
610        let key = [0u8; 32];
611        assert_eq!(l1_data_gas_for_bytes(&key), 128);
612    }
613
614    #[test]
615    fn l1_gas_for_nonzero_address_bytes_is_expensive() {
616        let addr = Address::repeat_byte(0xFF);
617        assert_eq!(l1_data_gas_for_bytes(addr.as_slice()), 320);
618    }
619
620    #[test]
621    fn access_list_rlp_data_gas_uses_exact_eip2930_encoding() {
622        let access_list = AccessList(vec![AccessListItem {
623            address: Address::ZERO,
624            storage_keys: Vec::new(),
625        }]);
626
627        // RLP([[zero_address, []]]) = d7 d6 94 <20 zero bytes> c0.
628        // Four non-zero framing bytes cost 64 gas; twenty zero address bytes cost
629        // 80 gas. The old fixed-overhead approximation returned 192.
630        assert_eq!(access_list_rlp_data_gas(&access_list), 144);
631    }
632
633    #[tokio::test]
634    async fn access_list_profitability_provider_error_returns_err() {
635        use alloy_network::Ethereum;
636        use alloy_provider::RootProvider;
637        use alloy_rpc_client::RpcClient;
638        use alloy_transport::mock::Asserter;
639
640        let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(Asserter::new()));
641        let access_list = AccessList(vec![AccessListItem {
642            address: Address::repeat_byte(0xAA),
643            storage_keys: Vec::new(),
644        }]);
645
646        let err = access_list_if_profitable(access_list, &provider)
647            .await
648            .expect_err("provider failures must be distinguishable from unprofitable lists");
649        assert!(
650            err.to_string().contains("ArbGasInfo") || err.to_string().contains("provider"),
651            "unexpected error: {err:#}"
652        );
653    }
654
655    #[tokio::test]
656    async fn access_list_profitability_empty_list_still_returns_none() {
657        use alloy_network::Ethereum;
658        use alloy_provider::RootProvider;
659        use alloy_rpc_client::RpcClient;
660        use alloy_transport::mock::Asserter;
661
662        let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(Asserter::new()));
663        let result = access_list_if_profitable(AccessList::default(), &provider)
664            .await
665            .expect("empty list must not query provider");
666        assert!(result.is_none());
667    }
668
669    #[tokio::test]
670    async fn resolve_access_list_l1_returns_non_empty_without_provider_calls() {
671        use alloy_network::Ethereum;
672        use alloy_provider::RootProvider;
673        use alloy_rpc_client::RpcClient;
674        use alloy_transport::mock::Asserter;
675
676        let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(Asserter::new()));
677        let access_list = AccessList(vec![AccessListItem {
678            address: Address::repeat_byte(0xAA),
679            storage_keys: Vec::new(),
680        }]);
681
682        let result = resolve_access_list(access_list.clone(), &provider, AccessListPricing::L1)
683            .await
684            .expect("L1 must not query provider");
685        assert_eq!(result, Some(access_list));
686
687        let empty = resolve_access_list(AccessList::default(), &provider, AccessListPricing::L1)
688            .await
689            .expect("empty L1 list must not query provider");
690        assert!(empty.is_none());
691    }
692
693    #[tokio::test]
694    async fn resolve_access_list_op_stack_uses_oracle_incremental_fee() {
695        use alloy_network::Ethereum;
696        use alloy_provider::RootProvider;
697        use alloy_rpc_client::RpcClient;
698        use alloy_transport::mock::Asserter;
699
700        let asserter = Asserter::new();
701        asserter.push_success(&100u128); // eth_gasPrice
702        asserter.push_success(&U256::from(1_000u64)); // getL1Fee(tx_without)
703        asserter.push_success(&U256::from(1_010u64)); // getL1Fee(tx_with)
704        let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(asserter));
705        let access_list = AccessList(vec![AccessListItem {
706            address: Address::repeat_byte(0xAA),
707            storage_keys: Vec::new(),
708        }]);
709
710        let result = resolve_access_list(
711            access_list.clone(),
712            &provider,
713            AccessListPricing::OpStack {
714                tx_without_access_list: Bytes::from_static(b"without"),
715                tx_with_access_list: Bytes::from_static(b"with"),
716            },
717        )
718        .await
719        .expect("OP Stack pricing succeeds");
720
721        assert_eq!(result, Some(access_list));
722    }
723
724    #[tokio::test]
725    async fn resolve_access_list_op_stack_unprofitable_returns_none() {
726        use alloy_network::Ethereum;
727        use alloy_provider::RootProvider;
728        use alloy_rpc_client::RpcClient;
729        use alloy_transport::mock::Asserter;
730
731        let asserter = Asserter::new();
732        asserter.push_success(&100u128); // eth_gasPrice
733        asserter.push_success(&U256::from(1_000u64)); // getL1Fee(tx_without)
734        asserter.push_success(&U256::from(20_000u64)); // getL1Fee(tx_with)
735        let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(asserter));
736        let access_list = AccessList(vec![AccessListItem {
737            address: Address::repeat_byte(0xAA),
738            storage_keys: Vec::new(),
739        }]);
740
741        let result = resolve_access_list(
742            access_list,
743            &provider,
744            AccessListPricing::OpStack {
745                tx_without_access_list: Bytes::from_static(b"without"),
746                tx_with_access_list: Bytes::from_static(b"with"),
747            },
748        )
749        .await
750        .expect("OP Stack pricing succeeds");
751
752        assert!(result.is_none());
753    }
754
755    #[tokio::test]
756    async fn resolve_access_list_op_stack_provider_failure_returns_err() {
757        use alloy_network::Ethereum;
758        use alloy_provider::RootProvider;
759        use alloy_rpc_client::RpcClient;
760        use alloy_transport::mock::Asserter;
761
762        let asserter = Asserter::new();
763        asserter.push_failure_msg("gas oracle unavailable");
764        let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(asserter));
765        let access_list = AccessList(vec![AccessListItem {
766            address: Address::repeat_byte(0xAA),
767            storage_keys: Vec::new(),
768        }]);
769
770        let err = resolve_access_list(
771            access_list,
772            &provider,
773            AccessListPricing::OpStack {
774                tx_without_access_list: Bytes::from_static(b"without"),
775                tx_with_access_list: Bytes::from_static(b"with"),
776            },
777        )
778        .await
779        .expect_err("provider failures must be distinguishable from unprofitable lists");
780
781        assert!(
782            err.to_string().contains("gas")
783                || err.to_string().contains("oracle")
784                || err.to_string().contains("provider"),
785            "unexpected error: {err:#}"
786        );
787    }
788}