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 anyhow::{Context as _, Result};
33use revm::context::result::ExecutionResult;
34use tracing::{debug, info};
35
36use crate::cache::EvmCache;
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 .context("failed to query ArbGasInfo prices for access-list profitability")?;
204 let l1_fee_call = arb.getL1BaseFeeEstimate();
205 let l1_base_fee = l1_fee_call
206 .call()
207 .await
208 .context("failed to query ArbGasInfo L1 base fee for access-list profitability")?;
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 .context("failed to query ArbGasInfo prices for access-list profitability")?;
265 let l1_base_fee = arb
266 .getL1BaseFeeEstimate()
267 .call()
268 .await
269 .context("failed to query ArbGasInfo L1 base fee for access-list profitability")?;
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 =
333 U256::from(provider.get_gas_price().await.context(
334 "failed to query OP Stack provider gas price for access-list profitability",
335 )?);
336
337 let l1_fee_without = query_op_l1_fee(provider, tx_without_access_list)
338 .await
339 .context("failed to query OP Stack GasPriceOracle L1 fee without access list")?;
340 let l1_fee_with = query_op_l1_fee(provider, tx_with_access_list)
341 .await
342 .context("failed to query OP Stack GasPriceOracle L1 fee with access list")?;
343
344 let incremental_l1_fee = l1_fee_with.saturating_sub(l1_fee_without);
345 let total_entries = access_list_entry_count(&access_list);
346 let l2_savings_wei = U256::from(total_entries) * U256::from(100) * l2_gas_price;
347 let profitable = l2_savings_wei > incremental_l1_fee;
348
349 info!(
350 entries = total_entries,
351 items = access_list.0.len(),
352 l2_savings_wei = %l2_savings_wei,
353 l1_fee_without_wei = %l1_fee_without,
354 l1_fee_with_wei = %l1_fee_with,
355 incremental_l1_fee_wei = %incremental_l1_fee,
356 l2_gas_price_gwei = %format_gwei(l2_gas_price),
357 profitable,
358 "OP Stack access list profitability check"
359 );
360
361 if profitable {
362 Ok(Some(access_list))
363 } else {
364 Ok(None)
365 }
366}
367
368async fn query_op_l1_fee<P: Provider>(provider: &P, tx_data: Bytes) -> Result<U256> {
369 let calldata = OpGasPriceOracle::getL1FeeCall { _data: tx_data }.abi_encode();
370 let tx = TransactionRequest::default()
371 .to(OP_GAS_PRICE_ORACLE)
372 .input(TransactionInput::from(calldata));
373
374 provider
375 .client()
376 .request("eth_call", (tx, BlockNumberOrTag::Latest))
377 .await
378 .context("OP Stack GasPriceOracle.getL1Fee eth_call failed")
379}
380
381pub async fn query_l1_base_fee_for_chain<P, N>(provider: &P, chain_type: ChainType) -> U256
384where
385 P: Provider<N>,
386 N: Network,
387{
388 match chain_type {
389 ChainType::Arbitrum => {
390 let arb = ArbGasInfo::new(ARB_GAS_INFO, provider);
391 match arb.getL1BaseFeeEstimate().call().await {
392 Ok(fee) => fee,
393 Err(e) => {
394 debug!(error = %e, "Failed to query L1 base fee from ArbGasInfo");
395 U256::ZERO
396 }
397 }
398 }
399 ChainType::OpStack => {
400 let oracle = OpGasPriceOracle::new(OP_GAS_PRICE_ORACLE, provider);
401 match oracle.l1BaseFee().call().await {
402 Ok(fee) => fee,
403 Err(e) => {
404 debug!(error = %e, "Failed to query L1 base fee from OP GasPriceOracle");
405 U256::ZERO
406 }
407 }
408 }
409 ChainType::L1 => U256::ZERO,
410 }
411}
412
413pub fn compute_op_l1_fee(cache: &mut EvmCache, calldata: &[u8]) -> U256 {
422 let encoded = OpGasPriceOracle::getL1FeeCall {
423 _data: calldata.to_vec().into(),
424 }
425 .abi_encode();
426
427 match cache.call_raw(Address::ZERO, OP_GAS_PRICE_ORACLE, encoded.into(), false) {
428 Ok(ExecutionResult::Success { output, .. }) => {
429 let out = output.into_data();
430 OpGasPriceOracle::getL1FeeCall::abi_decode_returns(&out).unwrap_or(U256::ZERO)
431 }
432 Ok(_) => {
433 debug!("GasPriceOracle.getL1Fee() reverted");
434 U256::ZERO
435 }
436 Err(e) => {
437 debug!(error = %e, "Failed to call GasPriceOracle.getL1Fee()");
438 U256::ZERO
439 }
440 }
441}
442
443impl Default for SmartAccessList {
444 fn default() -> Self {
445 Self::new()
446 }
447}
448
449fn push_unique(vec: &mut Vec<B256>, val: B256) {
450 if !vec.contains(&val) {
451 vec.push(val);
452 }
453}
454
455pub fn l1_data_gas_for_bytes(data: &[u8]) -> u64 {
474 data.iter()
475 .map(|&b| if b == 0 { 4u64 } else { 16u64 })
476 .sum()
477}
478
479pub fn access_list_rlp_data_gas(access_list: &AccessList) -> u64 {
481 let mut encoded = Vec::with_capacity(access_list.length());
482 access_list.encode(&mut encoded);
483 l1_data_gas_for_bytes(&encoded)
484}
485
486fn access_list_entry_count(access_list: &AccessList) -> u64 {
487 access_list
488 .0
489 .iter()
490 .map(|item| 1 + item.storage_keys.len() as u64)
491 .sum()
492}
493
494fn log_access_list_profitability(
495 access_list: &AccessList,
496 l2_gas_price: U256,
497 l1_base_fee: U256,
498 message: &'static str,
499) -> bool {
500 let total_entries = access_list_entry_count(access_list);
501 let total_l1_data_gas = access_list_rlp_data_gas(access_list);
502 let l2_savings_wei = U256::from(total_entries) * U256::from(100) * l2_gas_price;
503 let l1_cost_wei = U256::from(total_l1_data_gas) * l1_base_fee;
504 let profitable = l2_savings_wei > l1_cost_wei;
505
506 info!(
507 entries = total_entries,
508 items = access_list.0.len(),
509 l1_data_gas = total_l1_data_gas,
510 l2_savings_wei = %l2_savings_wei,
511 l1_cost_wei = %l1_cost_wei,
512 l2_gas_price_gwei = %format_gwei(l2_gas_price),
513 l1_base_fee_gwei = %format_gwei(l1_base_fee),
514 profitable,
515 check = message,
516 "Access list profitability check"
517 );
518
519 profitable
520}
521
522pub fn apply_access_list(
532 tx: &mut alloy_rpc_types_eth::TransactionRequest,
533 access_list: &mut AccessList,
534 sender: Address,
535 exclude: &[Address],
536) {
537 let tx_to = tx.to.as_ref().and_then(|t| t.to().copied());
538 access_list.0.retain(|item| {
539 if Some(item.address) == tx_to || item.address == sender {
540 return false;
541 }
542 if exclude.contains(&item.address) {
543 return false;
544 }
545 true
546 });
547 if !access_list.0.is_empty() {
548 *tx = std::mem::take(tx).access_list(access_list.clone());
549 }
550}
551
552fn format_gwei(wei: U256) -> String {
553 let gwei = wei / U256::from(1_000_000_000u64);
554 let remainder = (wei % U256::from(1_000_000_000u64))
555 .try_into()
556 .unwrap_or(0u64);
557 format!("{}.{:03}", gwei, remainder / 1_000_000)
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563 use alloy_primitives::Bytes;
564
565 #[test]
566 fn add_address_deduplicates_address_only_entries() {
567 let address = Address::repeat_byte(0xAA);
568 let mut al = SmartAccessList::new();
569
570 al.add_address(address);
571 al.add_address(address);
572
573 let access_list = al.into_access_list_always().expect("non-empty");
574 assert_eq!(access_list.0.len(), 1);
575 assert_eq!(access_list.0[0].address, address);
576 assert!(access_list.0[0].storage_keys.is_empty());
577 }
578
579 #[test]
580 fn add_storage_key_deduplicates_keys() {
581 let address = Address::repeat_byte(0xBB);
582 let key = B256::from(U256::from(4));
583 let mut al = SmartAccessList::new();
584
585 al.add_storage_key(address, key);
586 al.add_storage_key(address, key);
587
588 let access_list = al.into_access_list_always().expect("should return Some");
589 assert_eq!(access_list.0.len(), 1);
590 assert_eq!(access_list.0[0].address, address);
591 assert_eq!(access_list.0[0].storage_keys, vec![key]);
592 }
593
594 #[test]
595 fn into_access_list_always_returns_none_when_empty() {
596 let al = SmartAccessList::new();
597 assert!(al.into_access_list_always().is_none());
598 }
599
600 #[test]
601 fn l1_gas_for_zero_bytes_is_cheap() {
602 let key = [0u8; 32];
603 assert_eq!(l1_data_gas_for_bytes(&key), 128);
604 }
605
606 #[test]
607 fn l1_gas_for_nonzero_address_bytes_is_expensive() {
608 let addr = Address::repeat_byte(0xFF);
609 assert_eq!(l1_data_gas_for_bytes(addr.as_slice()), 320);
610 }
611
612 #[test]
613 fn access_list_rlp_data_gas_uses_exact_eip2930_encoding() {
614 let access_list = AccessList(vec![AccessListItem {
615 address: Address::ZERO,
616 storage_keys: Vec::new(),
617 }]);
618
619 assert_eq!(access_list_rlp_data_gas(&access_list), 144);
623 }
624
625 #[tokio::test]
626 async fn access_list_profitability_provider_error_returns_err() {
627 use alloy_network::Ethereum;
628 use alloy_provider::RootProvider;
629 use alloy_rpc_client::RpcClient;
630 use alloy_transport::mock::Asserter;
631
632 let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(Asserter::new()));
633 let access_list = AccessList(vec![AccessListItem {
634 address: Address::repeat_byte(0xAA),
635 storage_keys: Vec::new(),
636 }]);
637
638 let err = access_list_if_profitable(access_list, &provider)
639 .await
640 .expect_err("provider failures must be distinguishable from unprofitable lists");
641 assert!(
642 err.to_string().contains("ArbGasInfo") || err.to_string().contains("provider"),
643 "unexpected error: {err:#}"
644 );
645 }
646
647 #[tokio::test]
648 async fn access_list_profitability_empty_list_still_returns_none() {
649 use alloy_network::Ethereum;
650 use alloy_provider::RootProvider;
651 use alloy_rpc_client::RpcClient;
652 use alloy_transport::mock::Asserter;
653
654 let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(Asserter::new()));
655 let result = access_list_if_profitable(AccessList::default(), &provider)
656 .await
657 .expect("empty list must not query provider");
658 assert!(result.is_none());
659 }
660
661 #[tokio::test]
662 async fn resolve_access_list_l1_returns_non_empty_without_provider_calls() {
663 use alloy_network::Ethereum;
664 use alloy_provider::RootProvider;
665 use alloy_rpc_client::RpcClient;
666 use alloy_transport::mock::Asserter;
667
668 let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(Asserter::new()));
669 let access_list = AccessList(vec![AccessListItem {
670 address: Address::repeat_byte(0xAA),
671 storage_keys: Vec::new(),
672 }]);
673
674 let result = resolve_access_list(access_list.clone(), &provider, AccessListPricing::L1)
675 .await
676 .expect("L1 must not query provider");
677 assert_eq!(result, Some(access_list));
678
679 let empty = resolve_access_list(AccessList::default(), &provider, AccessListPricing::L1)
680 .await
681 .expect("empty L1 list must not query provider");
682 assert!(empty.is_none());
683 }
684
685 #[tokio::test]
686 async fn resolve_access_list_op_stack_uses_oracle_incremental_fee() {
687 use alloy_network::Ethereum;
688 use alloy_provider::RootProvider;
689 use alloy_rpc_client::RpcClient;
690 use alloy_transport::mock::Asserter;
691
692 let asserter = Asserter::new();
693 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));
697 let access_list = AccessList(vec![AccessListItem {
698 address: Address::repeat_byte(0xAA),
699 storage_keys: Vec::new(),
700 }]);
701
702 let result = resolve_access_list(
703 access_list.clone(),
704 &provider,
705 AccessListPricing::OpStack {
706 tx_without_access_list: Bytes::from_static(b"without"),
707 tx_with_access_list: Bytes::from_static(b"with"),
708 },
709 )
710 .await
711 .expect("OP Stack pricing succeeds");
712
713 assert_eq!(result, Some(access_list));
714 }
715
716 #[tokio::test]
717 async fn resolve_access_list_op_stack_unprofitable_returns_none() {
718 use alloy_network::Ethereum;
719 use alloy_provider::RootProvider;
720 use alloy_rpc_client::RpcClient;
721 use alloy_transport::mock::Asserter;
722
723 let asserter = Asserter::new();
724 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));
728 let access_list = AccessList(vec![AccessListItem {
729 address: Address::repeat_byte(0xAA),
730 storage_keys: Vec::new(),
731 }]);
732
733 let result = resolve_access_list(
734 access_list,
735 &provider,
736 AccessListPricing::OpStack {
737 tx_without_access_list: Bytes::from_static(b"without"),
738 tx_with_access_list: Bytes::from_static(b"with"),
739 },
740 )
741 .await
742 .expect("OP Stack pricing succeeds");
743
744 assert!(result.is_none());
745 }
746
747 #[tokio::test]
748 async fn resolve_access_list_op_stack_provider_failure_returns_err() {
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 asserter = Asserter::new();
755 asserter.push_failure_msg("gas oracle unavailable");
756 let provider = RootProvider::<Ethereum>::new(RpcClient::mocked(asserter));
757 let access_list = AccessList(vec![AccessListItem {
758 address: Address::repeat_byte(0xAA),
759 storage_keys: Vec::new(),
760 }]);
761
762 let err = resolve_access_list(
763 access_list,
764 &provider,
765 AccessListPricing::OpStack {
766 tx_without_access_list: Bytes::from_static(b"without"),
767 tx_with_access_list: Bytes::from_static(b"with"),
768 },
769 )
770 .await
771 .expect_err("provider failures must be distinguishable from unprofitable lists");
772
773 assert!(
774 err.to_string().contains("gas")
775 || err.to_string().contains("oracle")
776 || err.to_string().contains("provider"),
777 "unexpected error: {err:#}"
778 );
779 }
780}