1use 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
39const ARB_GAS_INFO: Address = address!("000000000000000000000000000000000000006C");
41
42pub const OP_GAS_PRICE_ORACLE: Address = address!("420000000000000000000000000000000000000F");
48
49pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum ChainType {
73 L1,
75 Arbitrum,
77 OpStack,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum AccessListPricing {
84 L1,
86 Arbitrum,
88 OpStack {
90 tx_without_access_list: Bytes,
92 tx_with_access_list: Bytes,
94 },
95}
96
97pub 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
189pub struct SmartAccessList {
197 items: Vec<AccessListItem>,
198}
199
200impl SmartAccessList {
201 pub fn new() -> Self {
207 Self { items: Vec::new() }
208 }
209
210 pub fn from_items(items: Vec<AccessListItem>) -> Self {
217 Self { items }
218 }
219
220 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 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 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 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 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
323pub 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 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
382pub 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
481pub 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
513pub 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
555pub 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
579pub 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
622pub 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 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); asserter.push_success(&U256::from(1_000u64)); asserter.push_success(&U256::from(1_010u64)); 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); asserter.push_success(&U256::from(1_000u64)); asserter.push_success(&U256::from(20_000u64)); 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}