Skip to main content

evm_fork_cache/cache/
read_set.rs

1//! Cache-owned execution read-set discovery and bulk warming.
2
3use std::collections::{BTreeMap, HashMap, HashSet};
4use std::sync::Arc;
5
6use alloy_eips::BlockId;
7use alloy_primitives::{Address, U256};
8use alloy_rpc_types_eth::TransactionRequest;
9
10use super::{EvmCache, PrewarmReport};
11use crate::access_set::StorageAccessList;
12use crate::errors::{AccessListError, StorageFetchError};
13
14/// One exact-hydration failure, with enough structure for callers to decide
15/// whether to retry, re-warm, or reject a candidate.
16#[derive(Clone, Debug, thiserror::Error)]
17#[non_exhaustive]
18pub enum ReadSetHydrationFailure {
19    /// The cache has no account-proof callback installed.
20    #[error("no account proof fetcher is installed for {address}")]
21    ProofFetcherUnavailable {
22        /// Account that could not be refreshed.
23        address: Address,
24    },
25    /// The callback returned no result for a requested account.
26    #[error("account proof fetcher omitted requested address {address}")]
27    ProofResultMissing {
28        /// Requested account omitted by the callback.
29        address: Address,
30    },
31    /// The callback returned more than one result for a requested account.
32    #[error("account proof fetcher returned duplicate results for {address}")]
33    ProofResultDuplicate {
34        /// Requested account with ambiguous results.
35        address: Address,
36    },
37    /// The callback returned a result for an account that was not requested.
38    #[error("account proof fetcher returned unexpected address {address}")]
39    ProofResultUnexpected {
40        /// Unrequested account returned by the callback.
41        address: Address,
42    },
43    /// A successful account proof returned one requested storage slot more
44    /// than once, making its value ambiguous.
45    #[error("account proof for {address} returned duplicate storage slot {slot}")]
46    StorageSlotDuplicate {
47        /// Account whose proof contained the duplicate slot.
48        address: Address,
49        /// Requested slot returned more than once.
50        slot: U256,
51    },
52    /// A successful account proof returned a storage slot that was not
53    /// requested.
54    #[error("account proof for {address} returned unexpected storage slot {slot}")]
55    StorageSlotUnexpected {
56        /// Account whose proof contained the unrequested slot.
57        address: Address,
58        /// Unrequested slot returned by the callback.
59        slot: U256,
60    },
61    /// The provider or custom callback failed for one requested account.
62    #[error("account proof fetch failed for {address}: {source}")]
63    ProofFetch {
64        /// Account whose proof failed.
65        address: Address,
66        /// Typed provider/callback failure.
67        #[source]
68        source: StorageFetchError,
69    },
70    /// A deployed account's runtime code is not resident, so its code identity
71    /// cannot be validated from a hash-only proof.
72    #[error("runtime code {code_hash} is not resident for deployed account {address}")]
73    RuntimeCodeUnavailable {
74        /// Deployed account requiring runtime code.
75        address: Address,
76        /// Code hash reported by the exact-block account proof.
77        code_hash: alloy_primitives::B256,
78    },
79    /// A successful account proof omitted one requested storage slot.
80    #[error("account proof for {address} omitted requested storage slot {slot}")]
81    StorageSlotMissing {
82        /// Account whose proof was incomplete.
83        address: Address,
84        /// Requested slot omitted from the proof result.
85        slot: U256,
86    },
87}
88
89/// Exact-block hydration result for one learned execution read set.
90#[derive(Clone, Debug)]
91pub struct ReadSetHydrationReport {
92    /// Block identity passed to every provider read.
93    pub block: BlockId,
94    /// Account headers refreshed from proofs.
95    pub accounts_refreshed: usize,
96    /// Storage slots refreshed from proofs.
97    pub slots_refreshed: usize,
98    /// Typed provider, callback, code-residency, or proof-shape failures.
99    pub failures: Vec<ReadSetHydrationFailure>,
100    /// Runtime-code identity changes that invalidate the learned layout.
101    pub code_changes: Vec<(Address, alloy_primitives::B256, alloy_primitives::B256)>,
102    /// Required reads still unavailable after hydration.
103    pub missing_after: StorageAccessList,
104}
105
106impl ReadSetHydrationReport {
107    /// Whether every requested dependency is resident and every code identity
108    /// still matches the learned layout.
109    pub fn is_complete(&self) -> bool {
110        self.failures.is_empty() && self.code_changes.is_empty() && self.missing_after.is_empty()
111    }
112}
113
114/// Callback for deriving calls' read sets via `eth_createAccessList`.
115///
116/// The returned vector must contain exactly one result for each request, in
117/// request order. [`EvmCache::prewarm_read_sets`] rejects the whole discovery
118/// batch when that cardinality contract is violated.
119pub type AccessListFetchFn = Arc<
120    dyn Fn(
121            Vec<TransactionRequest>,
122            BlockId,
123        ) -> Vec<std::result::Result<StorageAccessList, AccessListError>>
124        + Send
125        + Sync,
126>;
127
128/// A cache-owned read-set warmup could not honor its selected discovery policy.
129#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
130#[non_exhaustive]
131pub enum ReadSetWarmupError {
132    /// Access-list discovery was selected, but the cache has no discovery
133    /// callback installed.
134    #[error("access-list discovery was required for {calls} call(s), but no fetcher is installed")]
135    AccessListFetcherUnavailable {
136        /// Number of calls that could not be discovered.
137        calls: usize,
138    },
139    /// The callback violated the one-result-per-request contract.
140    #[error("access-list fetcher returned {actual} result(s) for {expected} request(s)")]
141    AccessListResultCountMismatch {
142        /// Number of access-list requests issued.
143        expected: usize,
144        /// Number of callback results returned.
145        actual: usize,
146    },
147}
148
149/// One call whose storage read set may be remotely discovered.
150#[derive(Clone, Debug, Default)]
151pub struct ReadSetWarmupCall {
152    /// RPC transaction passed to `eth_createAccessList`.
153    pub tx: TransactionRequest,
154    /// Approximate expected slot count used by the automatic strategy.
155    pub expected_slots: Option<usize>,
156    /// Optional account filter applied before hydration.
157    pub restrict_to: Option<Vec<Address>>,
158}
159
160/// Declared known slots plus calls with unknown read sets.
161#[derive(Clone, Debug, Default)]
162pub struct ReadSetWarmupBatch {
163    /// Slots to load directly.
164    pub known_slots: Vec<(Address, U256)>,
165    /// Calls eligible for remote read-set discovery.
166    pub calls: Vec<ReadSetWarmupCall>,
167}
168
169/// Read-set discovery policy.
170#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
171pub enum ReadSetWarmupStrategy {
172    /// Use access-list discovery only when the call hints justify its round trip.
173    #[default]
174    Auto,
175    /// Warm declared slots only; leave every call for later local simulation.
176    LocalOnly,
177    /// Attempt access-list discovery for every declared call.
178    AccessList,
179}
180
181/// Heuristic configuration for [`EvmCache::prewarm_read_sets`].
182#[derive(Clone, Debug, PartialEq, Eq)]
183pub struct ReadSetWarmupConfig {
184    /// Discovery policy.
185    pub strategy: ReadSetWarmupStrategy,
186    /// Total hinted slots that activates access-list discovery in automatic mode.
187    pub min_expected_slots_for_access_list: usize,
188    /// Number of unhinted calls that activates discovery in automatic mode.
189    pub min_unhinted_calls_for_access_list: usize,
190}
191
192impl Default for ReadSetWarmupConfig {
193    fn default() -> Self {
194        Self {
195            strategy: ReadSetWarmupStrategy::Auto,
196            min_expected_slots_for_access_list: 32,
197            min_unhinted_calls_for_access_list: 8,
198        }
199    }
200}
201
202impl ReadSetWarmupConfig {
203    fn should_use_access_lists(&self, calls: &[ReadSetWarmupCall]) -> bool {
204        match self.strategy {
205            ReadSetWarmupStrategy::LocalOnly => false,
206            ReadSetWarmupStrategy::AccessList => !calls.is_empty(),
207            ReadSetWarmupStrategy::Auto => {
208                let expected = calls
209                    .iter()
210                    .filter_map(|call| call.expected_slots)
211                    .fold(0usize, usize::saturating_add);
212                let unhinted = calls
213                    .iter()
214                    .filter(|call| call.expected_slots.is_none())
215                    .count();
216                expected >= self.min_expected_slots_for_access_list
217                    || unhinted >= self.min_unhinted_calls_for_access_list
218            }
219        }
220    }
221}
222
223/// Outcome of cache-owned read-set warming.
224#[derive(Debug, Default)]
225pub struct ReadSetWarmupReport {
226    /// Direct known-slot hydration.
227    pub known: PrewarmReport,
228    /// Whether remote access-list discovery was attempted.
229    pub used_access_lists: bool,
230    /// Calls skipped because the selected policy did not request discovery.
231    pub skipped_calls: usize,
232    /// Successful access-list probes.
233    pub access_list_successes: usize,
234    /// Failed probes keyed by call index.
235    pub access_list_failures: Vec<(usize, AccessListError)>,
236    /// Union of successful filtered read sets.
237    pub discovered_access: StorageAccessList,
238    /// Hydration result for discovered slots.
239    pub discovered: PrewarmReport,
240}
241
242impl EvmCache {
243    /// Installed access-list discovery callback, if any.
244    pub fn access_list_fetcher(&self) -> Option<&AccessListFetchFn> {
245        self.access_list_fetcher.as_ref()
246    }
247
248    /// Replace the access-list discovery callback.
249    pub fn set_access_list_fetcher(&mut self, fetcher: AccessListFetchFn) {
250        self.access_list_fetcher = Some(fetcher);
251    }
252
253    /// Warm known slots and, when selected by policy, discover and bulk-load
254    /// unknown call read sets through cache-owned provider plumbing.
255    ///
256    /// An access-list callback is mandatory when [`ReadSetWarmupStrategy::AccessList`]
257    /// is selected, or when [`ReadSetWarmupStrategy::Auto`] crosses its configured
258    /// threshold. A callback result remains a per-call success or failure, but
259    /// the callback itself must return exactly one result per request.
260    ///
261    /// # Errors
262    ///
263    /// Returns [`ReadSetWarmupError::AccessListFetcherUnavailable`] when remote
264    /// discovery was selected without an installed callback, or
265    /// [`ReadSetWarmupError::AccessListResultCountMismatch`] when the callback
266    /// violates the one-result-per-request contract.
267    pub fn prewarm_read_sets(
268        &mut self,
269        batch: ReadSetWarmupBatch,
270        config: ReadSetWarmupConfig,
271    ) -> Result<ReadSetWarmupReport, ReadSetWarmupError> {
272        let discovery_results =
273            if batch.calls.is_empty() || !config.should_use_access_lists(&batch.calls) {
274                None
275            } else {
276                let Some(fetcher) = self.access_list_fetcher.clone() else {
277                    return Err(ReadSetWarmupError::AccessListFetcherUnavailable {
278                        calls: batch.calls.len(),
279                    });
280                };
281                let requests = batch.calls.iter().map(|call| call.tx.clone()).collect();
282                let results = fetcher(requests, self.block);
283                if results.len() != batch.calls.len() {
284                    return Err(ReadSetWarmupError::AccessListResultCountMismatch {
285                        expected: batch.calls.len(),
286                        actual: results.len(),
287                    });
288                }
289                Some(results)
290            };
291
292        let known = if batch.known_slots.is_empty() {
293            PrewarmReport::default()
294        } else {
295            self.prewarm_slots(&batch.known_slots)
296        };
297        let mut report = ReadSetWarmupReport {
298            known,
299            ..Default::default()
300        };
301        if batch.calls.is_empty() {
302            return Ok(report);
303        }
304        let Some(results) = discovery_results else {
305            report.skipped_calls = batch.calls.len();
306            return Ok(report);
307        };
308
309        report.used_access_lists = true;
310        let mut results = results.into_iter();
311        let mut discovered = StorageAccessList::default();
312        for (index, call) in batch.calls.iter().enumerate() {
313            let result = results
314                .next()
315                .expect("access-list result count was checked above");
316            match result {
317                Ok(mut access) => {
318                    if let Some(restrict_to) = &call.restrict_to {
319                        let keep: HashSet<_> = restrict_to.iter().copied().collect();
320                        access.accounts.retain(|address| keep.contains(address));
321                        access.slots.retain(|(address, _)| keep.contains(address));
322                    }
323                    discovered.extend(&access);
324                    report.access_list_successes += 1;
325                }
326                Err(error) => report.access_list_failures.push((index, error)),
327            }
328        }
329
330        let mut slots: Vec<_> = discovered.slots.iter().copied().collect();
331        slots.sort_unstable();
332        report.discovered_access = discovered;
333        if !slots.is_empty() {
334            report.discovered = self.prewarm_slots(&slots);
335        }
336        Ok(report)
337    }
338
339    /// Refresh a learned execution read set at this cache's exact block pin.
340    ///
341    /// Account headers and requested storage values are fetched together with
342    /// `eth_getProof`, preventing values from different provider observations
343    /// from being combined. Existing runtime bytecode is retained only when the
344    /// proof reports the same code hash; a changed hash is surfaced explicitly
345    /// so an AMM manifest can be invalidated instead of simulating against a new
346    /// layout with stale slot identifiers.
347    ///
348    /// Hash-only proofs cannot supply runtime bytecode. Code required by the
349    /// read set must therefore already be resident, and its identity must match
350    /// the proof. Historical `BLOCKHASH` values are likewise never fetched by
351    /// this method: they must already be present in the canonical cache. Any
352    /// missing code, slot, account, or block hash keeps the returned report
353    /// incomplete.
354    pub fn hydrate_read_set(&mut self, required: &StorageAccessList) -> ReadSetHydrationReport {
355        let block = self.block;
356        let mut requests: BTreeMap<Address, Vec<U256>> = BTreeMap::new();
357        for address in &required.accounts {
358            requests.entry(*address).or_default();
359        }
360        for (address, slot) in &required.slots {
361            requests.entry(*address).or_default().push(*slot);
362        }
363        for slots in requests.values_mut() {
364            slots.sort_unstable();
365            slots.dedup();
366        }
367
368        let mut report = ReadSetHydrationReport {
369            block,
370            accounts_refreshed: 0,
371            slots_refreshed: 0,
372            failures: Vec::new(),
373            code_changes: Vec::new(),
374            missing_after: required.clone(),
375        };
376        if requests.is_empty() {
377            report.missing_after = self.snapshot().missing_read_set(required);
378            return report;
379        }
380        let Some(fetcher) = self.account_proof_fetcher.clone() else {
381            report.failures.extend(
382                requests
383                    .keys()
384                    .copied()
385                    .map(|address| ReadSetHydrationFailure::ProofFetcherUnavailable { address }),
386            );
387            return report;
388        };
389
390        let requested: Vec<_> = requests
391            .iter()
392            .map(|(address, slots)| (*address, slots.clone()))
393            .collect();
394        let mut fetched = HashMap::new();
395        let mut duplicate_addresses = HashSet::new();
396        for (address, result) in fetcher(requested, block) {
397            if !requests.contains_key(&address) {
398                report
399                    .failures
400                    .push(ReadSetHydrationFailure::ProofResultUnexpected { address });
401                continue;
402            }
403            if duplicate_addresses.contains(&address) || fetched.contains_key(&address) {
404                if duplicate_addresses.insert(address) {
405                    report
406                        .failures
407                        .push(ReadSetHydrationFailure::ProofResultDuplicate { address });
408                }
409                fetched.remove(&address);
410                continue;
411            }
412            fetched.insert(address, result);
413        }
414        let mut fresh_slots = Vec::new();
415
416        for (address, expected_slots) in requests {
417            if duplicate_addresses.contains(&address) {
418                continue;
419            }
420            let Some(result) = fetched.get(&address) else {
421                report
422                    .failures
423                    .push(ReadSetHydrationFailure::ProofResultMissing { address });
424                continue;
425            };
426            let proof = match result {
427                Ok(proof) => proof,
428                Err(error) => {
429                    report.failures.push(ReadSetHydrationFailure::ProofFetch {
430                        address,
431                        source: error.clone(),
432                    });
433                    continue;
434                }
435            };
436
437            let current = self.local_account_info(address);
438            if let Some(current) = current.as_ref()
439                && current.code_hash != proof.code_hash
440            {
441                report
442                    .code_changes
443                    .push((address, current.code_hash, proof.code_hash));
444                continue;
445            }
446            if current.is_none()
447                && proof.code_hash != alloy_primitives::B256::ZERO
448                && proof.code_hash != revm::primitives::KECCAK_EMPTY
449            {
450                report
451                    .failures
452                    .push(ReadSetHydrationFailure::RuntimeCodeUnavailable {
453                        address,
454                        code_hash: proof.code_hash,
455                    });
456                continue;
457            }
458
459            let mut info = current.unwrap_or_default();
460            info.balance = proof.balance;
461            info.nonce = proof.nonce;
462            info.code_hash = proof.code_hash;
463            self.write_account_info_through(address, info);
464            report.accounts_refreshed += 1;
465
466            let expected_slot_set: HashSet<_> = expected_slots.iter().copied().collect();
467            let mut by_slot = HashMap::new();
468            let mut duplicate_slots = HashSet::new();
469            for (slot, value) in proof.slots.iter().copied() {
470                if !expected_slot_set.contains(&slot) {
471                    report
472                        .failures
473                        .push(ReadSetHydrationFailure::StorageSlotUnexpected { address, slot });
474                    continue;
475                }
476                if duplicate_slots.contains(&slot) || by_slot.contains_key(&slot) {
477                    if duplicate_slots.insert(slot) {
478                        report
479                            .failures
480                            .push(ReadSetHydrationFailure::StorageSlotDuplicate { address, slot });
481                    }
482                    by_slot.remove(&slot);
483                    continue;
484                }
485                by_slot.insert(slot, value);
486            }
487            let mut complete_slots = true;
488            for slot in expected_slots {
489                if duplicate_slots.contains(&slot) {
490                    complete_slots = false;
491                    continue;
492                }
493                let Some(value) = by_slot.get(&slot).copied() else {
494                    report
495                        .failures
496                        .push(ReadSetHydrationFailure::StorageSlotMissing { address, slot });
497                    complete_slots = false;
498                    continue;
499                };
500                fresh_slots.push((address, slot, value));
501            }
502            if !complete_slots {
503                continue;
504            }
505        }
506
507        report.slots_refreshed = fresh_slots.len();
508        if !fresh_slots.is_empty() {
509            self.inject_storage_batch_fresh(&fresh_slots);
510        }
511        report.missing_after = self.snapshot().missing_read_set(required);
512        report
513    }
514}