Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 139 additions & 3 deletions src/access_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,19 @@
//! 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};
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};

Expand All @@ -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<u128>,
}

impl AccessListCall {
/// Build a probe request using [`DEFAULT_CREATE_ACCESS_LIST_GAS_CAP`].
pub fn new(from: Address, to: Address, input: impl Into<Bytes>) -> 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<CreateAccessListProbeItem>,
#[serde(default)]
error: Option<String>,
}

#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateAccessListProbeItem {
address: Address,
#[serde(default)]
storage_keys: Option<Vec<B256>>,
}

/// Chain fee model used by helpers that only need to identify the chain's L1
/// base-fee oracle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -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<P>(
provider: &P,
block: BlockId,
call: &AccessListCall,
) -> Result<StorageAccessList>
where
P: Provider<AnyNetwork>,
{
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<P>(provider: &P, block: BlockId) -> Option<u128>
where
P: Provider<AnyNetwork>,
{
let block = provider.get_block(block).await.ok().flatten()?;
block.header.base_fee_per_gas.map(u128::from)
}

sol! {
#[sol(rpc)]
interface ArbGasInfo {
Expand Down
135 changes: 133 additions & 2 deletions src/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<StorageAccessList, AccessListError>
+ 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).
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -1411,6 +1432,9 @@ pub struct EvmCache {
/// (`inject_storage_batch`) does not bump it.
snapshot_generation: u64,
storage_batch_fetcher: Option<StorageBatchFetchFn>,
/// Optional fetcher for deriving a call's read set with
/// `eth_createAccessList`.
access_list_fetcher: Option<AccessListFetchFn>,
/// 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`.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<AccessListPrewarmReport, AccessListError> {
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.
///
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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::<AnyNetwork>::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!([
Expand Down
Loading
Loading