use alloy_eips::{
BlockNumberOrTag,
eip2930::{AccessList, AccessListItem},
};
use alloy_network::Network;
use alloy_primitives::{Address, B256, Bytes, U256, address};
use alloy_provider::Provider;
use alloy_rlp::Encodable;
use alloy_rpc_types_eth::{TransactionInput, TransactionRequest};
use alloy_sol_types::{SolCall, sol};
use revm::context::result::ExecutionResult;
use tracing::{debug, info};
use crate::cache::EvmCache;
use crate::errors::{AccessListError, AccessListResult as Result};
const ARB_GAS_INFO: Address = address!("000000000000000000000000000000000000006C");
pub const OP_GAS_PRICE_ORACLE: Address = address!("420000000000000000000000000000000000000F");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChainType {
L1,
Arbitrum,
OpStack,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccessListPricing {
L1,
Arbitrum,
OpStack {
tx_without_access_list: Bytes,
tx_with_access_list: Bytes,
},
}
sol! {
#[sol(rpc)]
interface ArbGasInfo {
function getPricesInWei() external view returns (
uint256 perL2Tx,
uint256 perL1CalldataUnit,
uint256 perStorageUnit,
uint256 perArbGas,
uint256 perL1Surplus,
uint256 baseFee
);
function getL1BaseFeeEstimate() external view returns (uint256);
}
#[sol(rpc)]
interface OpGasPriceOracle {
function l1BaseFee() external view returns (uint256);
function getL1Fee(bytes _data) external view returns (uint256);
}
}
pub struct SmartAccessList {
items: Vec<AccessListItem>,
}
impl SmartAccessList {
pub fn new() -> Self {
Self { items: Vec::new() }
}
pub fn from_items(items: Vec<AccessListItem>) -> Self {
Self { items }
}
pub fn add_address(&mut self, address: Address) {
if !self.items.iter().any(|item| item.address == address) {
self.items.push(AccessListItem {
address,
storage_keys: Vec::new(),
});
}
}
pub fn add_storage_key(&mut self, address: Address, storage_key: B256) {
if let Some(item) = self.items.iter_mut().find(|item| item.address == address) {
push_unique(&mut item.storage_keys, storage_key);
} else {
self.items.push(AccessListItem {
address,
storage_keys: vec![storage_key],
});
}
}
pub fn into_access_list_always(self) -> Option<AccessList> {
if self.items.is_empty() {
return None;
}
info!(
items = self.items.len(),
"Using access list unconditionally (L1 mode)"
);
Some(AccessList(self.items))
}
pub async fn into_access_list_if_profitable<P: Provider>(
self,
provider: &P,
) -> Result<Option<AccessList>> {
if self.items.is_empty() {
return Ok(None);
}
let arb = ArbGasInfo::new(ARB_GAS_INFO, provider);
let prices_call = arb.getPricesInWei();
let prices = prices_call
.call()
.await
.map_err(|e| AccessListError::query("ArbGasInfo prices", e))?;
let l1_fee_call = arb.getL1BaseFeeEstimate();
let l1_base_fee = l1_fee_call
.call()
.await
.map_err(|e| AccessListError::query("ArbGasInfo L1 base fee", e))?;
let l2_gas_price = prices.perArbGas;
if l2_gas_price.is_zero() || l1_base_fee.is_zero() {
debug!("L1 or L2 gas price is zero, skipping access list");
return Ok(None);
}
let access_list = AccessList(self.items);
if log_access_list_profitability(
&access_list,
l2_gas_price,
l1_base_fee,
"Access list profitability check",
) {
Ok(Some(access_list))
} else {
Ok(None)
}
}
}
pub async fn access_list_if_profitable<P: Provider>(
access_list: AccessList,
provider: &P,
) -> Result<Option<AccessList>> {
if access_list.0.is_empty() {
return Ok(None);
}
let arb = ArbGasInfo::new(ARB_GAS_INFO, provider);
let prices = arb
.getPricesInWei()
.call()
.await
.map_err(|e| AccessListError::query("ArbGasInfo prices", e))?;
let l1_base_fee = arb
.getL1BaseFeeEstimate()
.call()
.await
.map_err(|e| AccessListError::query("ArbGasInfo L1 base fee", e))?;
let l2_gas_price = prices.perArbGas;
if l2_gas_price.is_zero() || l1_base_fee.is_zero() {
debug!("L1 or L2 gas price is zero, skipping access list");
return Ok(None);
}
if log_access_list_profitability(
&access_list,
l2_gas_price,
l1_base_fee,
"Simulation access list profitability check",
) {
Ok(Some(access_list))
} else {
Ok(None)
}
}
pub async fn resolve_access_list<P: Provider>(
sim_access_list: AccessList,
provider: &P,
pricing: AccessListPricing,
) -> Result<Option<AccessList>> {
if sim_access_list.0.is_empty() {
return Ok(None);
}
match pricing {
AccessListPricing::L1 => Ok(Some(sim_access_list)),
AccessListPricing::Arbitrum => access_list_if_profitable(sim_access_list, provider).await,
AccessListPricing::OpStack {
tx_without_access_list,
tx_with_access_list,
} => {
access_list_if_profitable_op_stack(
sim_access_list,
provider,
tx_without_access_list,
tx_with_access_list,
)
.await
}
}
}
async fn access_list_if_profitable_op_stack<P: Provider>(
access_list: AccessList,
provider: &P,
tx_without_access_list: Bytes,
tx_with_access_list: Bytes,
) -> Result<Option<AccessList>> {
let l2_gas_price = U256::from(
provider
.get_gas_price()
.await
.map_err(|e| AccessListError::query("OP Stack provider gas price", e))?,
);
let l1_fee_without = query_op_l1_fee(provider, tx_without_access_list)
.await
.map_err(|e| AccessListError::Query {
operation: "OP Stack GasPriceOracle L1 fee without access list",
details: e.to_string(),
})?;
let l1_fee_with = query_op_l1_fee(provider, tx_with_access_list)
.await
.map_err(|e| AccessListError::Query {
operation: "OP Stack GasPriceOracle L1 fee with access list",
details: e.to_string(),
})?;
let incremental_l1_fee = l1_fee_with.saturating_sub(l1_fee_without);
let total_entries = access_list_entry_count(&access_list);
let l2_savings_wei = U256::from(total_entries) * U256::from(100) * l2_gas_price;
let profitable = l2_savings_wei > incremental_l1_fee;
info!(
entries = total_entries,
items = access_list.0.len(),
l2_savings_wei = %l2_savings_wei,
l1_fee_without_wei = %l1_fee_without,
l1_fee_with_wei = %l1_fee_with,
incremental_l1_fee_wei = %incremental_l1_fee,
l2_gas_price_gwei = %format_gwei(l2_gas_price),
profitable,
"OP Stack access list profitability check"
);
if profitable {
Ok(Some(access_list))
} else {
Ok(None)
}
}
async fn query_op_l1_fee<P: Provider>(provider: &P, tx_data: Bytes) -> Result<U256> {
let calldata = OpGasPriceOracle::getL1FeeCall { _data: tx_data }.abi_encode();
let tx = TransactionRequest::default()
.to(OP_GAS_PRICE_ORACLE)
.input(TransactionInput::from(calldata));
provider
.client()
.request("eth_call", (tx, BlockNumberOrTag::Latest))
.await
.map_err(|e| AccessListError::query("OP Stack GasPriceOracle.getL1Fee eth_call", e))
}
pub async fn query_l1_base_fee_for_chain<P, N>(provider: &P, chain_type: ChainType) -> U256
where
P: Provider<N>,
N: Network,
{
match chain_type {
ChainType::Arbitrum => {
let arb = ArbGasInfo::new(ARB_GAS_INFO, provider);
match arb.getL1BaseFeeEstimate().call().await {
Ok(fee) => fee,
Err(e) => {
debug!(error = %e, "Failed to query L1 base fee from ArbGasInfo");
U256::ZERO
}
}
}
ChainType::OpStack => {
let oracle = OpGasPriceOracle::new(OP_GAS_PRICE_ORACLE, provider);
match oracle.l1BaseFee().call().await {
Ok(fee) => fee,
Err(e) => {
debug!(error = %e, "Failed to query L1 base fee from OP GasPriceOracle");
U256::ZERO
}
}
}
ChainType::L1 => U256::ZERO,
}
}
pub fn compute_op_l1_fee(cache: &mut EvmCache, calldata: &[u8]) -> U256 {
let encoded = OpGasPriceOracle::getL1FeeCall {
_data: calldata.to_vec().into(),
}
.abi_encode();
match cache.call_raw(Address::ZERO, OP_GAS_PRICE_ORACLE, encoded.into(), false) {
Ok(ExecutionResult::Success { output, .. }) => {
let out = output.into_data();
OpGasPriceOracle::getL1FeeCall::abi_decode_returns(&out).unwrap_or(U256::ZERO)
}
Ok(_) => {
debug!("GasPriceOracle.getL1Fee() reverted");
U256::ZERO
}
Err(e) => {
debug!(error = %e, "Failed to call GasPriceOracle.getL1Fee()");
U256::ZERO
}
}
}
impl Default for SmartAccessList {
fn default() -> Self {
Self::new()
}
}
fn push_unique(vec: &mut Vec<B256>, val: B256) {
if !vec.contains(&val) {
vec.push(val);
}
}
pub fn l1_data_gas_for_bytes(data: &[u8]) -> u64 {
data.iter()
.map(|&b| if b == 0 { 4u64 } else { 16u64 })
.sum()
}
pub fn access_list_rlp_data_gas(access_list: &AccessList) -> u64 {
let mut encoded = Vec::with_capacity(access_list.length());
access_list.encode(&mut encoded);
l1_data_gas_for_bytes(&encoded)
}
fn access_list_entry_count(access_list: &AccessList) -> u64 {
access_list
.0
.iter()
.map(|item| 1 + item.storage_keys.len() as u64)
.sum()
}
fn log_access_list_profitability(
access_list: &AccessList,
l2_gas_price: U256,
l1_base_fee: U256,
message: &'static str,
) -> bool {
let total_entries = access_list_entry_count(access_list);
let total_l1_data_gas = access_list_rlp_data_gas(access_list);
let l2_savings_wei = U256::from(total_entries) * U256::from(100) * l2_gas_price;
let l1_cost_wei = U256::from(total_l1_data_gas) * l1_base_fee;
let profitable = l2_savings_wei > l1_cost_wei;
info!(
entries = total_entries,
items = access_list.0.len(),
l1_data_gas = total_l1_data_gas,
l2_savings_wei = %l2_savings_wei,
l1_cost_wei = %l1_cost_wei,
l2_gas_price_gwei = %format_gwei(l2_gas_price),
l1_base_fee_gwei = %format_gwei(l1_base_fee),
profitable,
check = message,
"Access list profitability check"
);
profitable
}
pub fn apply_access_list(
tx: &mut alloy_rpc_types_eth::TransactionRequest,
access_list: &mut AccessList,
sender: Address,
exclude: &[Address],
) {
let tx_to = tx.to.as_ref().and_then(|t| t.to().copied());
access_list.0.retain(|item| {
if Some(item.address) == tx_to || item.address == sender {
return false;
}
if exclude.contains(&item.address) {
return false;
}
true
});
if !access_list.0.is_empty() {
*tx = std::mem::take(tx).access_list(access_list.clone());
}
}
fn format_gwei(wei: U256) -> String {
let gwei = wei / U256::from(1_000_000_000u64);
let remainder = (wei % U256::from(1_000_000_000u64))
.try_into()
.unwrap_or(0u64);
format!("{}.{:03}", gwei, remainder / 1_000_000)
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_primitives::Bytes;
#[test]
fn add_address_deduplicates_address_only_entries() {
let address = Address::repeat_byte(0xAA);
let mut al = SmartAccessList::new();
al.add_address(address);
al.add_address(address);
let access_list = al.into_access_list_always().expect("non-empty");
assert_eq!(access_list.0.len(), 1);
assert_eq!(access_list.0[0].address, address);
assert!(access_list.0[0].storage_keys.is_empty());
}
#[test]
fn add_storage_key_deduplicates_keys() {
let address = Address::repeat_byte(0xBB);
let key = B256::from(U256::from(4));
let mut al = SmartAccessList::new();
al.add_storage_key(address, key);
al.add_storage_key(address, key);
let access_list = al.into_access_list_always().expect("should return Some");
assert_eq!(access_list.0.len(), 1);
assert_eq!(access_list.0[0].address, address);
assert_eq!(access_list.0[0].storage_keys, vec![key]);
}
#[test]
fn into_access_list_always_returns_none_when_empty() {
let al = SmartAccessList::new();
assert!(al.into_access_list_always().is_none());
}
#[test]
fn l1_gas_for_zero_bytes_is_cheap() {
let key = [0u8; 32];
assert_eq!(l1_data_gas_for_bytes(&key), 128);
}
#[test]
fn l1_gas_for_nonzero_address_bytes_is_expensive() {
let addr = Address::repeat_byte(0xFF);
assert_eq!(l1_data_gas_for_bytes(addr.as_slice()), 320);
}
#[test]
fn access_list_rlp_data_gas_uses_exact_eip2930_encoding() {
let access_list = AccessList(vec![AccessListItem {
address: Address::ZERO,
storage_keys: Vec::new(),
}]);
assert_eq!(access_list_rlp_data_gas(&access_list), 144);
}
#[tokio::test]
async fn access_list_profitability_provider_error_returns_err() {
use alloy_network::Ethereum;
use alloy_provider::RootProvider;
use alloy_rpc_client::RpcClient;
use alloy_transport::mock::Asserter;
let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(Asserter::new()));
let access_list = AccessList(vec![AccessListItem {
address: Address::repeat_byte(0xAA),
storage_keys: Vec::new(),
}]);
let err = access_list_if_profitable(access_list, &provider)
.await
.expect_err("provider failures must be distinguishable from unprofitable lists");
assert!(
err.to_string().contains("ArbGasInfo") || err.to_string().contains("provider"),
"unexpected error: {err:#}"
);
}
#[tokio::test]
async fn access_list_profitability_empty_list_still_returns_none() {
use alloy_network::Ethereum;
use alloy_provider::RootProvider;
use alloy_rpc_client::RpcClient;
use alloy_transport::mock::Asserter;
let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(Asserter::new()));
let result = access_list_if_profitable(AccessList::default(), &provider)
.await
.expect("empty list must not query provider");
assert!(result.is_none());
}
#[tokio::test]
async fn resolve_access_list_l1_returns_non_empty_without_provider_calls() {
use alloy_network::Ethereum;
use alloy_provider::RootProvider;
use alloy_rpc_client::RpcClient;
use alloy_transport::mock::Asserter;
let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(Asserter::new()));
let access_list = AccessList(vec![AccessListItem {
address: Address::repeat_byte(0xAA),
storage_keys: Vec::new(),
}]);
let result = resolve_access_list(access_list.clone(), &provider, AccessListPricing::L1)
.await
.expect("L1 must not query provider");
assert_eq!(result, Some(access_list));
let empty = resolve_access_list(AccessList::default(), &provider, AccessListPricing::L1)
.await
.expect("empty L1 list must not query provider");
assert!(empty.is_none());
}
#[tokio::test]
async fn resolve_access_list_op_stack_uses_oracle_incremental_fee() {
use alloy_network::Ethereum;
use alloy_provider::RootProvider;
use alloy_rpc_client::RpcClient;
use alloy_transport::mock::Asserter;
let asserter = Asserter::new();
asserter.push_success(&100u128); asserter.push_success(&U256::from(1_000u64)); asserter.push_success(&U256::from(1_010u64)); let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(asserter));
let access_list = AccessList(vec![AccessListItem {
address: Address::repeat_byte(0xAA),
storage_keys: Vec::new(),
}]);
let result = resolve_access_list(
access_list.clone(),
&provider,
AccessListPricing::OpStack {
tx_without_access_list: Bytes::from_static(b"without"),
tx_with_access_list: Bytes::from_static(b"with"),
},
)
.await
.expect("OP Stack pricing succeeds");
assert_eq!(result, Some(access_list));
}
#[tokio::test]
async fn resolve_access_list_op_stack_unprofitable_returns_none() {
use alloy_network::Ethereum;
use alloy_provider::RootProvider;
use alloy_rpc_client::RpcClient;
use alloy_transport::mock::Asserter;
let asserter = Asserter::new();
asserter.push_success(&100u128); asserter.push_success(&U256::from(1_000u64)); asserter.push_success(&U256::from(20_000u64)); let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(asserter));
let access_list = AccessList(vec![AccessListItem {
address: Address::repeat_byte(0xAA),
storage_keys: Vec::new(),
}]);
let result = resolve_access_list(
access_list,
&provider,
AccessListPricing::OpStack {
tx_without_access_list: Bytes::from_static(b"without"),
tx_with_access_list: Bytes::from_static(b"with"),
},
)
.await
.expect("OP Stack pricing succeeds");
assert!(result.is_none());
}
#[tokio::test]
async fn resolve_access_list_op_stack_provider_failure_returns_err() {
use alloy_network::Ethereum;
use alloy_provider::RootProvider;
use alloy_rpc_client::RpcClient;
use alloy_transport::mock::Asserter;
let asserter = Asserter::new();
asserter.push_failure_msg("gas oracle unavailable");
let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(asserter));
let access_list = AccessList(vec![AccessListItem {
address: Address::repeat_byte(0xAA),
storage_keys: Vec::new(),
}]);
let err = resolve_access_list(
access_list,
&provider,
AccessListPricing::OpStack {
tx_without_access_list: Bytes::from_static(b"without"),
tx_with_access_list: Bytes::from_static(b"with"),
},
)
.await
.expect_err("provider failures must be distinguishable from unprofitable lists");
assert!(
err.to_string().contains("gas")
|| err.to_string().contains("oracle")
|| err.to_string().contains("provider"),
"unexpected error: {err:#}"
);
}
}