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