1use 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
38const ARB_GAS_INFO: Address = address!("000000000000000000000000000000000000006C");
40
41pub const OP_GAS_PRICE_ORACLE: Address = address!("420000000000000000000000000000000000000F");
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ChainType {
52 L1,
54 Arbitrum,
56 OpStack,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum AccessListPricing {
63 L1,
65 Arbitrum,
67 OpStack {
69 tx_without_access_list: Bytes,
71 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
97pub struct SmartAccessList {
105 items: Vec<AccessListItem>,
106}
107
108impl SmartAccessList {
109 pub fn new() -> Self {
115 Self { items: Vec::new() }
116 }
117
118 pub fn from_items(items: Vec<AccessListItem>) -> Self {
125 Self { items }
126 }
127
128 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 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 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 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 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
231pub 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 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
290pub 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
389pub 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
421pub 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
463pub 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
487pub 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
530pub 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 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); asserter.push_success(&U256::from(1_000u64)); asserter.push_success(&U256::from(1_010u64)); 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); asserter.push_success(&U256::from(1_000u64)); asserter.push_success(&U256::from(20_000u64)); 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}