From 0c9af358d90c87e6452d5b3f1e252822043269f2 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 16:26:44 +0100 Subject: [PATCH] feat: add access-list read-set prewarm primitive --- src/access_list.rs | 142 ++++++++++++++++++++++++++++++++++++++++++++- src/cache/mod.rs | 135 +++++++++++++++++++++++++++++++++++++++++- src/lib.rs | 12 ++-- 3 files changed, 279 insertions(+), 10 deletions(-) diff --git a/src/access_list.rs b/src/access_list.rs index babcdad..9620490 100644 --- a/src/access_list.rs +++ b/src/access_list.rs @@ -20,11 +20,11 @@ //! so use `into_access_list_always()` to skip the profitability check. use alloy_eips::{ - BlockNumberOrTag, + BlockId, BlockNumberOrTag, eip2930::{AccessList, AccessListItem}, }; -use alloy_network::Network; -use alloy_primitives::{Address, B256, Bytes, U256, address}; +use alloy_network::{AnyNetwork, Network}; +use alloy_primitives::{Address, B256, Bytes, TxKind, U256, address}; use alloy_provider::Provider; use alloy_rlp::Encodable; use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; @@ -32,6 +32,7 @@ use alloy_sol_types::{SolCall, sol}; use revm::context::result::ExecutionResult; use tracing::{debug, info}; +use crate::access_set::StorageAccessList; use crate::cache::EvmCache; use crate::errors::{AccessListError, AccessListResult as Result}; @@ -45,6 +46,77 @@ const ARB_GAS_INFO: Address = address!("000000000000000000000000000000000000006C /// ([`compute_op_l1_fee`]). pub const OP_GAS_PRICE_ORACLE: Address = address!("420000000000000000000000000000000000000F"); +/// Default gas cap for an `eth_createAccessList` probe. +/// +/// The probe is a read-only call, but clients still run normal transaction +/// validation. This cap is intentionally generous for storage-heavy view calls +/// while keeping `gas * gasPrice` sender-funding checks bounded. +pub const DEFAULT_CREATE_ACCESS_LIST_GAS_CAP: u64 = 30_000_000; + +/// Read-only call whose storage read set should be discovered with +/// `eth_createAccessList`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AccessListCall { + /// Simulated sender. + pub from: Address, + /// Call target. + pub to: Address, + /// ABI-encoded call data. + pub input: Bytes, + /// Gas cap sent with the access-list probe. + pub gas: u64, + /// Optional gas price override. When unset, the probe uses the pinned + /// block's `baseFeePerGas`, falling back to 1 gwei if it cannot be read. + pub gas_price: Option, +} + +impl AccessListCall { + /// Build a probe request using [`DEFAULT_CREATE_ACCESS_LIST_GAS_CAP`]. + pub fn new(from: Address, to: Address, input: impl Into) -> Self { + Self { + from, + to, + input: input.into(), + gas: DEFAULT_CREATE_ACCESS_LIST_GAS_CAP, + gas_price: None, + } + } + + /// Override the gas cap used by the probe. + pub fn with_gas(mut self, gas: u64) -> Self { + self.gas = gas; + self + } + + /// Override the gas price used by the probe. + pub fn with_gas_price(mut self, gas_price: u128) -> Self { + self.gas_price = Some(gas_price); + self + } +} + +/// Null-tolerant view of the `eth_createAccessList` response. +/// +/// Some clients return `"storageKeys": null` for accounts a call touches +/// without reading storage. Alloy's EIP-2930 `AccessListItem` requires a vector, +/// so this RPC mirror accepts null and preserves the account-only touch. +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateAccessListProbe { + #[serde(default)] + access_list: Vec, + #[serde(default)] + error: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateAccessListProbeItem { + address: Address, + #[serde(default)] + storage_keys: Option>, +} + /// Chain fee model used by helpers that only need to identify the chain's L1 /// base-fee oracle. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -73,6 +145,70 @@ pub enum AccessListPricing { }, } +/// Ask a provider to derive the account/storage touch set for `call` at `block`. +/// +/// This is a generic wrapper over `eth_createAccessList`. It returns the +/// execution touch set as [`StorageAccessList`] so callers can prefetch the +/// storage pairs or convert it back to EIP-2930 form. Client execution errors +/// (for example a reverted view call) are surfaced as [`AccessListError`] rather +/// than being confused with an empty touch set. +pub async fn create_access_list_read_set

( + provider: &P, + block: BlockId, + call: &AccessListCall, +) -> Result +where + P: Provider, +{ + let gas_price = match call.gas_price { + Some(gas_price) => gas_price, + None => pinned_base_fee(provider, block) + .await + .unwrap_or(1_000_000_000), + }; + let request = TransactionRequest { + from: Some(call.from), + to: Some(TxKind::Call(call.to)), + input: TransactionInput::new(call.input.clone()), + gas: Some(call.gas), + gas_price: Some(gas_price), + ..Default::default() + }; + + let result: CreateAccessListProbe = provider + .client() + .request("eth_createAccessList", (request, block)) + .await + .map_err(|e| AccessListError::query("eth_createAccessList", e))?; + if let Some(error) = result.error { + return Err(AccessListError::query( + "eth_createAccessList execution", + error, + )); + } + + let mut access = StorageAccessList::default(); + for item in result.access_list { + access.accounts.insert(item.address); + if let Some(storage_keys) = item.storage_keys { + access.slots.extend( + storage_keys + .into_iter() + .map(|key| (item.address, U256::from_be_slice(key.as_slice()))), + ); + } + } + Ok(access) +} + +async fn pinned_base_fee

(provider: &P, block: BlockId) -> Option +where + P: Provider, +{ + let block = provider.get_block(block).await.ok().flatten()?; + block.header.base_fee_per_gas.map(u128::from) +} + sol! { #[sol(rpc)] interface ArbGasInfo { diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 810f140..4880a6f 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -57,11 +57,13 @@ use revm::{ }; use tracing::{debug, instrument, trace, warn}; +use crate::access_list::AccessListCall; use crate::access_set::StorageAccessList; use crate::bulk_storage::AccountFieldsSample; use crate::errors::{ - BlockContextError, CacheError, CacheResult as Result, RpcError, RuntimeError, SimError, - SimHostError, SimulationError, SimulationResult, StorageFetchError, StorageFetchResult, + AccessListError, BlockContextError, CacheError, CacheResult as Result, RpcError, RuntimeError, + SimError, SimHostError, SimulationError, SimulationResult, StorageFetchError, + StorageFetchResult, }; use crate::freshness::{SlotChange, SlotFetch, SlotOutcome}; use crate::inspector::TransferInspector; @@ -117,6 +119,16 @@ pub type StorageBatchFetchFn = Arc< + Sync, >; +/// Callback for deriving a call's storage read set via `eth_createAccessList`. +/// +/// Provider-backed caches install one by default. Tests or custom transports can +/// replace it via [`EvmCache::set_access_list_fetcher`]. +pub type AccessListFetchFn = Arc< + dyn Fn(AccessListCall, BlockId) -> std::result::Result + + Send + + Sync, +>; + /// Account header + optional storage-proof slots from `eth_getProof`. /// `slots` is populated only for requested storage keys; an empty key list is a /// root-only probe (account fields + `storage_hash`, no slot payload). @@ -779,6 +791,15 @@ pub struct PrewarmReport { pub failed: Vec<(Address, U256, StorageFetchError)>, } +/// Outcome of [`EvmCache::prewarm_access_list_call`]. +#[derive(Debug)] +pub struct AccessListPrewarmReport { + /// Account/storage touch set returned by `eth_createAccessList`. + pub access_list: StorageAccessList, + /// Result of bulk-loading the access list's storage pairs. + pub prewarm: PrewarmReport, +} + /// Outcome of [`EvmCache::verify_code_seeds`]: how each `Pending` canonical /// code claim resolved against the chain at the pinned block. /// @@ -1411,6 +1432,9 @@ pub struct EvmCache { /// (`inject_storage_batch`) does not bump it. snapshot_generation: u64, storage_batch_fetcher: Option, + /// Optional fetcher for deriving a call's read set with + /// `eth_createAccessList`. + access_list_fetcher: Option, /// Optional account/root fetcher that bypasses SharedBackend. /// Captures a provider clone and fires `eth_getProof` calls directly to fetch /// authoritative account fields (balance/nonce/code hash) and `storageHash`. @@ -1863,6 +1887,23 @@ impl EvmCache { point_read_storage_fetcher(provider.clone(), storage_batch_config), ); + // Access-list read-set discovery: derive the storage touched by one + // read-only call with `eth_createAccessList`. Higher-level crates can + // then warm the returned slots through this cache's own batch fetcher. + let provider_for_access_list = provider.clone(); + let access_list_fetcher: AccessListFetchFn = + Arc::new(move |call: AccessListCall, block: BlockId| { + let handle = + block_in_place_handle().map_err(|e| AccessListError::query("runtime", e))?; + tokio::task::block_in_place(|| { + handle.block_on(crate::access_list::create_access_list_read_set( + provider_for_access_list.as_ref(), + block, + &call, + )) + }) + }); + // Create an account/root fetcher that bypasses SharedBackend, firing // `eth_getProof` calls directly for authoritative account fields plus the // account's `storageHash`. `eth_getProof` is single-address at the RPC @@ -2047,6 +2088,7 @@ impl EvmCache { snapshot_generation: 0, rpc_caller: Some(rpc_caller), storage_batch_fetcher: Some(storage_batch_fetcher), + access_list_fetcher: Some(access_list_fetcher), account_proof_fetcher: Some(account_proof_fetcher), block_state_diff_fetcher: Some(block_state_diff_fetcher), account_fields_fetcher: Some(account_fields_fetcher), @@ -2137,6 +2179,7 @@ impl EvmCache { ))), rpc_caller: None, storage_batch_fetcher: None, + access_list_fetcher: None, account_proof_fetcher: None, block_state_diff_fetcher: None, account_fields_fetcher: None, @@ -2331,6 +2374,15 @@ impl EvmCache { self.storage_batch_fetcher.as_ref() } + /// Get the access-list read-set fetcher, if available. + /// + /// Returns `None` when constructed via [`from_backend`](Self::from_backend) + /// unless a fetcher was injected via + /// [`set_access_list_fetcher`](Self::set_access_list_fetcher). + pub fn access_list_fetcher(&self) -> Option<&AccessListFetchFn> { + self.access_list_fetcher.as_ref() + } + /// Get the account/root proof fetcher, if available. /// /// Returns `None` when constructed via `from_backend` (no provider @@ -2456,6 +2508,35 @@ impl EvmCache { } } + /// Derive a call's read set with `eth_createAccessList`, then bulk-load it. + /// + /// The first shot runs the supplied call on the provider backing this cache + /// and captures the accounts/storage slots it touches. The second shot warms + /// all returned storage pairs through [`prewarm_slots`](Self::prewarm_slots), + /// so it respects the cache's configured storage fetch strategy (bulk + /// extraction by default, point-read fallback when needed). + /// + /// This is best-effort warming: a successful access-list probe can still + /// return storage pairs whose fetches fail. Those failures are reported in + /// [`AccessListPrewarmReport::prewarm`] and leave the cache unchanged for + /// those slots, so later EVM execution can still lazy-load them normally. + pub fn prewarm_access_list_call( + &mut self, + call: AccessListCall, + ) -> std::result::Result { + let fetcher = self.access_list_fetcher.clone().ok_or_else(|| { + AccessListError::query("eth_createAccessList", "no access-list fetcher installed") + })?; + let access_list = fetcher(call, self.block)?; + let mut requests: Vec<(Address, U256)> = access_list.slots.iter().copied().collect(); + requests.sort_unstable(); + let prewarm = self.prewarm_slots(&requests); + Ok(AccessListPrewarmReport { + access_list, + prewarm, + }) + } + /// Apply a single targeted [`StateUpdate`], returning a [`StateDiff`] of what /// actually changed. /// @@ -3092,6 +3173,15 @@ impl EvmCache { self.storage_batch_fetcher = Some(f); } + /// Set (or replace) the access-list read-set fetcher. + /// + /// This is the seam [`prewarm_access_list_call`](Self::prewarm_access_list_call) + /// uses to derive a call's storage read set without exposing the provider + /// itself. A mocked fetcher can be injected for offline tests. + pub fn set_access_list_fetcher(&mut self, f: AccessListFetchFn) { + self.access_list_fetcher = Some(f); + } + /// Set (or replace) the account/root proof fetcher. /// /// This is the seam account-target resyncs and account-level freshness use to @@ -6483,6 +6573,47 @@ mod shared_memory_capacity_tests { mod core_tests { use super::*; + #[test] + fn prewarm_access_list_call_uses_cache_owned_fetchers() { + use alloy_provider::RootProvider; + use alloy_rpc_client::RpcClient; + use alloy_transport::mock::Asserter; + + let provider = RootProvider::::new(RpcClient::mocked(Asserter::new())); + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + let mut cache = rt.block_on(EvmCache::new(Arc::new(provider))); + + let target = Address::repeat_byte(0x22); + let slot = U256::from(7); + let value = U256::from(99); + cache.set_access_list_fetcher(Arc::new(move |_call, _block| { + let mut access = StorageAccessList::default(); + access.accounts.insert(target); + access.slots.insert((target, slot)); + Ok(access) + })); + cache.set_storage_batch_fetcher(Arc::new(move |requests, _block| { + assert_eq!(requests, vec![(target, slot)]); + requests + .into_iter() + .map(|(addr, slot)| (addr, slot, Ok(value))) + .collect() + })); + + let report = cache + .prewarm_access_list_call(AccessListCall::new(Address::ZERO, target, Bytes::new())) + .expect("access-list prewarm should use injected cache fetchers"); + + assert_eq!(report.access_list.account_count(), 1); + assert_eq!(report.access_list.slot_count(), 1); + assert_eq!(report.prewarm.loaded, 1); + assert!(report.prewarm.failed.is_empty()); + assert_eq!(cache.cached_storage_value(target, slot), Some(value)); + } + #[test] fn parses_prestate_diff_trace_values_and_cleared_slots() { let trace = serde_json::json!([ diff --git a/src/lib.rs b/src/lib.rs index 43a44f7..fb84cdc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -159,6 +159,7 @@ pub mod reactive; pub mod state_update; pub mod tracing; +pub use access_list::{AccessListCall, DEFAULT_CREATE_ACCESS_LIST_GAS_CAP}; pub use access_set::StorageAccessList; // Bulk storage extraction over eth_call state overrides — the default batch // storage fetcher since 0.2.0 (see docs/bulk-storage-extraction.md). @@ -174,11 +175,12 @@ pub use bundle::{BundleOptions, BundleResult, BundleTx, RevertPolicy, TxOutcome} // fully-qualified module paths (`cache::EvmCache`, `reactive::ReactiveRuntime`, // …) remain valid, so this is purely additive. pub use cache::{ - AccountFieldsFetchFn, AccountProof, AccountProofFetchFn, BlockContextRequirements, - BlockStateAccountDiff, BlockStateDiff, BlockStateDiffFetchFn, BlockStateStorageDiff, - CacheSpeedMode, CallSimulationResult, CodeMismatch, CodeSeedState, CodeVerifyReport, EvmCache, - EvmCacheBuilder, EvmOverlay, EvmSnapshot, PrewarmReport, StorageBatchConfig, - StorageFetchStrategy, TxConfig, point_read_storage_fetcher, + AccessListFetchFn, AccessListPrewarmReport, AccountFieldsFetchFn, AccountProof, + AccountProofFetchFn, BlockContextRequirements, BlockStateAccountDiff, BlockStateDiff, + BlockStateDiffFetchFn, BlockStateStorageDiff, CacheSpeedMode, CallSimulationResult, + CodeMismatch, CodeSeedState, CodeVerifyReport, EvmCache, EvmCacheBuilder, EvmOverlay, + EvmSnapshot, PrewarmReport, StorageBatchConfig, StorageFetchStrategy, TxConfig, + point_read_storage_fetcher, }; #[cfg(feature = "reactive")] pub use cold_start::{