evm_fork_cache/freshness.rs
1//! Freshness control plane and the optimistic verify-and-rerun execution loop.
2//!
3//! This module is the generic core of the engine's "honest freshness" model: it
4//! knows which cached state it can trust, for how long, and how to keep the rest
5//! correct without blocking simulations on RPC. It is built from four layers:
6//!
7//! 1. **Classification** — [`Validity`] (`Pinned` / `Volatile` / `ValidThrough`)
8//! and the [`FreshnessRegistry`] that resolves a validity per `(address, slot)`
9//! with the precedence **slot ▸ account ▸ default**.
10//! 2. **Observation** — [`SlotObservationTracker`] records per-slot change
11//! frequency (clock-agnostic) to drive adaptive re-verification, tuned by
12//! [`FreshnessParams`].
13//! 3. **Policy** — the [`FreshnessPolicy`] trait decides *which* volatile slots to
14//! verify this cycle; built-ins are [`AlwaysVerify`], [`NeverVerify`] and
15//! [`ObservationDriven`].
16//! 4. **Mechanism** — `EvmCache::verify_slots` / `EvmCache::purge_account`, and
17//! the freshness controller that runs the optimistic loop.
18//!
19//! The clock is configurable via [`FreshnessClock`]: [`BlockClock`] (the default,
20//! block-number based) or [`WallClock`] (unix seconds). The controller threads
21//! `clock.now()` as `now: u64` through the tracker, the policy, and
22//! [`FreshnessRegistry::is_volatile`].
23//!
24//! # Reconciliation scope
25//!
26//! The optimistic loop verifies only **volatile storage slots** in each sim's
27//! read set. Account-level state — native balance, nonce, and bytecode — is
28//! **not** re-fetched or diffed today, so [`Validation::ConfirmedStorage`] means
29//! *"no volatile storage slot the sims read had changed"*, not *"no account state
30//! changed"*. A sim whose result depends on a `BALANCE`/`SELFBALANCE` (or
31//! nonce/code) that moved on-chain without a co-changing storage slot in its read
32//! set can still be reported `ConfirmedStorage`. If account-level state matters to
33//! a sim, mark the account [`Validity::Pinned`] and keep it fresh via event-driven
34//! writes, or reconcile it out of band.
35//!
36//! The verdict taxonomy is deliberately split so this over-promise is visible in
37//! the type: [`ConfirmedStorage`](Validation::ConfirmedStorage) (storage only,
38//! account fields unverified) is distinct from
39//! [`ConfirmedFull`](Validation::ConfirmedFull) (storage *and* verified
40//! account-level fields both unchanged). `ConfirmedFull` is defined but not yet
41//! emitted — a follow-up wave wires validator-side account verification that will
42//! populate it and the [`Corrected`](Validation::Corrected) verdict's
43//! `changed_accounts`. See [`Validation`] for the per-verdict note.
44//!
45//! # Example
46//!
47//! Classification + policy selection, no network required:
48//!
49//! ```
50//! use alloy_primitives::{Address, U256};
51//! use evm_fork_cache::freshness::{
52//! AlwaysVerify, FreshnessPolicy, FreshnessRegistry, NeverVerify,
53//! };
54//! use evm_fork_cache::cache::SlotObservationTracker;
55//!
56//! let contract = Address::repeat_byte(0x01);
57//! let volatile_slot = U256::from(0);
58//! let immutable_slot = U256::from(6); // e.g. a constructor-set config value
59//!
60//! let mut registry = FreshnessRegistry::new(); // default: Volatile
61//! registry.pin_slot(contract, immutable_slot); // never re-verified
62//!
63//! // `now` is in clock units (block number for the default BlockClock).
64//! let now = 100;
65//! assert!(registry.is_volatile(contract, volatile_slot, now));
66//! assert!(!registry.is_volatile(contract, immutable_slot, now));
67//!
68//! // Policies pick which volatile candidates to verify this cycle.
69//! let obs = SlotObservationTracker::new();
70//! let candidates = [(contract, volatile_slot)];
71//! assert_eq!(
72//! AlwaysVerify.select(&candidates, &obs, now),
73//! vec![(contract, volatile_slot)]
74//! );
75//! assert!(NeverVerify.select(&candidates, &obs, now).is_empty());
76//! ```
77
78use std::collections::{HashMap, HashSet};
79use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
80use std::sync::{Arc, Mutex};
81use std::time::{SystemTime, UNIX_EPOCH};
82
83use alloy_eips::BlockId;
84use alloy_eips::eip2930::AccessList;
85use alloy_primitives::{Address, Bytes, U256};
86use revm::context::result::ExecutionResult;
87use tokio::task::JoinHandle;
88
89use crate::cache::{
90 CallSimulationResult, EvmCache, EvmOverlay, EvmSnapshot, SimStatus, SlotObservationTracker,
91 StorageBatchFetchFn, TxConfig,
92};
93use crate::errors::{FreshnessError, FreshnessResult as Result, StorageFetchResult};
94use crate::state_update::{AccountChange, StateUpdate};
95
96/// Default minimum observations before the change-frequency data is trusted.
97pub const DEFAULT_MIN_OBSERVATIONS: u32 = 10;
98
99/// Default maximum reuse window, in clock units, before a slot is rechecked.
100///
101/// Block-based default (≈300 blocks). Wall-clock users typically set this to
102/// `7 * 86400` (one week) to reproduce the original behavior.
103pub const DEFAULT_MAX_REUSE: u64 = 300;
104
105/// Default refetch threshold on expected probability of change.
106pub const DEFAULT_STALENESS_THRESHOLD: f64 = 0.05;
107
108/// Default change-rate above which a slot is always refetched.
109pub const DEFAULT_ALWAYS_REFETCH_RATE: f64 = 0.9;
110
111/// Default clock units per "cycle" used by the probabilistic model.
112pub const DEFAULT_CYCLE_INTERVAL: u64 = 1;
113
114/// Tunable thresholds for the adaptive freshness model.
115///
116/// All time-like fields are expressed in **clock units** (`FreshnessClock`):
117/// block numbers for a block clock, unix seconds for a wall clock. The defaults
118/// are block-oriented; wall-clock users should raise [`max_reuse`](Self::max_reuse)
119/// and [`cycle_interval`](Self::cycle_interval) accordingly.
120#[derive(Clone, Debug, PartialEq)]
121pub struct FreshnessParams {
122 /// Minimum observations before the change frequency is trusted (else refetch).
123 pub min_observations: u32,
124 /// Maximum reuse window (clock units) before a slot is force-rechecked.
125 pub max_reuse: u64,
126 /// Refetch when the expected probability of change exceeds this threshold.
127 pub staleness_threshold: f64,
128 /// Slots changing more often than this rate are always refetched.
129 pub always_refetch_rate: f64,
130 /// Clock units per "cycle" for the probabilistic expected-change estimate.
131 /// Must be non-zero; a zero is treated as one to avoid division by zero.
132 pub cycle_interval: u64,
133}
134
135impl Default for FreshnessParams {
136 fn default() -> Self {
137 Self {
138 min_observations: DEFAULT_MIN_OBSERVATIONS,
139 max_reuse: DEFAULT_MAX_REUSE,
140 staleness_threshold: DEFAULT_STALENESS_THRESHOLD,
141 always_refetch_rate: DEFAULT_ALWAYS_REFETCH_RATE,
142 cycle_interval: DEFAULT_CYCLE_INTERVAL,
143 }
144 }
145}
146
147impl FreshnessParams {
148 /// Block-oriented defaults (`max_reuse ≈ 300` blocks, one cycle per block).
149 pub fn for_block_clock() -> Self {
150 Self::default()
151 }
152
153 /// Wall-clock defaults: reuse up to one week, ~60s cycles, matching the
154 /// original (pre-Phase-2) hardcoded behavior of the observation tracker.
155 pub fn for_wall_clock() -> Self {
156 Self {
157 max_reuse: 7 * 86400,
158 cycle_interval: 60,
159 ..Self::default()
160 }
161 }
162}
163
164// ---------------------------------------------------------------------------
165// 1. Classification
166// ---------------------------------------------------------------------------
167
168/// How long a cached account or storage slot can be trusted.
169///
170/// Resolution precedence is **slot ▸ account ▸ default** (see
171/// [`FreshnessRegistry::validity`]).
172#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
173pub enum Validity {
174 /// Caller-owned: immutable, or kept fresh out-of-band (e.g. via event
175 /// writes). The freshness system never re-verifies or purges it.
176 Pinned,
177 /// Governed by the active [`FreshnessPolicy`]; may be re-verified each cycle.
178 Volatile,
179 /// Pinned until clock value `N` (inclusive), then treated as [`Volatile`].
180 ///
181 /// [`Volatile`]: Validity::Volatile
182 ValidThrough(u64),
183}
184
185/// Per-address / per-slot validity classification.
186///
187/// A slot's validity is resolved with the precedence **slot ▸ account ▸
188/// default**: an explicit `(address, slot)` entry wins, else the account-level
189/// entry for `address`, else the registry default ([`Validity::Volatile`] unless
190/// changed via [`with_default`](Self::with_default)).
191///
192/// The setters are builder-style (`&mut Self`) so they can be chained.
193#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
194pub struct FreshnessRegistry {
195 default: Validity,
196 accounts: HashMap<Address, Validity>,
197 slots: HashMap<(Address, U256), Validity>,
198}
199
200impl Default for FreshnessRegistry {
201 fn default() -> Self {
202 Self::new()
203 }
204}
205
206impl FreshnessRegistry {
207 /// A registry whose default validity is [`Validity::Volatile`].
208 pub fn new() -> Self {
209 Self {
210 default: Validity::Volatile,
211 accounts: HashMap::new(),
212 slots: HashMap::new(),
213 }
214 }
215
216 /// A registry with a custom default validity for unclassified state.
217 pub fn with_default(default: Validity) -> Self {
218 Self {
219 default,
220 accounts: HashMap::new(),
221 slots: HashMap::new(),
222 }
223 }
224
225 /// The default validity applied when neither the slot nor the account is set.
226 pub fn default_validity(&self) -> Validity {
227 self.default
228 }
229
230 /// Pin an account ([`Validity::Pinned`]).
231 pub fn pin(&mut self, addr: Address) -> &mut Self {
232 self.set_account(addr, Validity::Pinned)
233 }
234
235 /// Pin a single slot ([`Validity::Pinned`]).
236 pub fn pin_slot(&mut self, addr: Address, slot: U256) -> &mut Self {
237 self.set_slot(addr, slot, Validity::Pinned)
238 }
239
240 /// Mark an account [`Validity::Volatile`].
241 pub fn mark_volatile(&mut self, addr: Address) -> &mut Self {
242 self.set_account(addr, Validity::Volatile)
243 }
244
245 /// Mark a single slot [`Validity::Volatile`].
246 pub fn mark_volatile_slot(&mut self, addr: Address, slot: U256) -> &mut Self {
247 self.set_slot(addr, slot, Validity::Volatile)
248 }
249
250 /// Mark an account [`Validity::ValidThrough`] block/clock `n`.
251 pub fn valid_through(&mut self, addr: Address, n: u64) -> &mut Self {
252 self.set_account(addr, Validity::ValidThrough(n))
253 }
254
255 /// Mark a single slot [`Validity::ValidThrough`] block/clock `n`.
256 pub fn valid_through_slot(&mut self, addr: Address, slot: U256, n: u64) -> &mut Self {
257 self.set_slot(addr, slot, Validity::ValidThrough(n))
258 }
259
260 /// Invalidate event-derived validity horizons from a dropped canonical
261 /// block onward while preserving caller-owned pinning and older horizons.
262 #[cfg(feature = "reactive")]
263 pub(crate) fn invalidate_valid_through_from(&mut self, first_dropped_block: u64) {
264 let invalidate = |validity: &mut Validity| {
265 if matches!(validity, Validity::ValidThrough(block) if *block >= first_dropped_block) {
266 *validity = Validity::Volatile;
267 }
268 };
269 invalidate(&mut self.default);
270 for validity in self.accounts.values_mut() {
271 invalidate(validity);
272 }
273 for validity in self.slots.values_mut() {
274 invalidate(validity);
275 }
276 }
277
278 /// Set the account-level validity for `addr`.
279 pub fn set_account(&mut self, addr: Address, validity: Validity) -> &mut Self {
280 self.accounts.insert(addr, validity);
281 self
282 }
283
284 /// Set the slot-level validity for `(addr, slot)`.
285 pub fn set_slot(&mut self, addr: Address, slot: U256, validity: Validity) -> &mut Self {
286 self.slots.insert((addr, slot), validity);
287 self
288 }
289
290 /// Resolve the validity of `(addr, slot)` with **slot ▸ account ▸ default**.
291 pub fn validity(&self, addr: Address, slot: U256) -> Validity {
292 if let Some(v) = self.slots.get(&(addr, slot)) {
293 return *v;
294 }
295 if let Some(v) = self.accounts.get(&addr) {
296 return *v;
297 }
298 self.default
299 }
300
301 /// Whether `(addr, slot)` is currently volatile (subject to verification).
302 ///
303 /// `true` for [`Validity::Volatile`], and for [`Validity::ValidThrough`]`(m)`
304 /// once `now > m`. `false` for [`Validity::Pinned`] and a still-valid
305 /// `ValidThrough` (`now <= m`).
306 pub fn is_volatile(&self, addr: Address, slot: U256, now: u64) -> bool {
307 match self.validity(addr, slot) {
308 Validity::Pinned => false,
309 Validity::Volatile => true,
310 Validity::ValidThrough(m) => now > m,
311 }
312 }
313}
314
315// ---------------------------------------------------------------------------
316// 2. Clock
317// ---------------------------------------------------------------------------
318
319/// Source of the current clock value used throughout the freshness model.
320///
321/// Implementations return a monotone-ish `u64` in their own units. The two
322/// built-ins are [`BlockClock`] (block number, the default) and [`WallClock`]
323/// (unix seconds).
324pub trait FreshnessClock: Send + Sync {
325 /// The current clock value (block number or unix seconds).
326 fn now(&self) -> u64;
327
328 /// Advance the clock to `now`.
329 ///
330 /// Called by [`FreshnessController::on_new_block`] so the natural API drives
331 /// the clock forward. The default is a no-op (for clocks like [`WallClock`]
332 /// that advance on their own); [`BlockClock`] overrides it to set the block.
333 fn advance(&self, _now: u64) {}
334}
335
336/// Block-number clock (the default). Cloning shares the underlying counter, so a
337/// clone observed by a background task sees [`set_block`](Self::set_block)
338/// updates made on the main thread.
339#[derive(Clone, Debug, Default)]
340pub struct BlockClock(Arc<AtomicU64>);
341
342impl BlockClock {
343 /// A block clock starting at block 0.
344 pub fn new() -> Self {
345 Self(Arc::new(AtomicU64::new(0)))
346 }
347
348 /// A block clock starting at `block`.
349 pub fn at(block: u64) -> Self {
350 Self(Arc::new(AtomicU64::new(block)))
351 }
352
353 /// Set the current block number. Shared across clones.
354 pub fn set_block(&self, block: u64) {
355 self.0.store(block, Ordering::Relaxed);
356 }
357}
358
359impl FreshnessClock for BlockClock {
360 fn now(&self) -> u64 {
361 self.0.load(Ordering::Relaxed)
362 }
363
364 /// Set the current block to `now` (shared across clones).
365 fn advance(&self, now: u64) {
366 self.set_block(now);
367 }
368}
369
370/// Wall-clock clock: [`now`](FreshnessClock::now) returns unix seconds.
371///
372/// A zero-sized unit struct: unlike [`BlockClock`] it holds no `Arc`/`AtomicU64`,
373/// since the value is read straight from the system clock on each call. It
374/// advances on its own, so [`advance`](FreshnessClock::advance) is the trait
375/// default no-op and has no effect.
376#[derive(Clone, Copy, Debug, Default)]
377pub struct WallClock;
378
379impl FreshnessClock for WallClock {
380 fn now(&self) -> u64 {
381 SystemTime::now()
382 .duration_since(UNIX_EPOCH)
383 .map(|d| d.as_secs())
384 .unwrap_or(0)
385 }
386}
387
388// ---------------------------------------------------------------------------
389// 3. Policy
390// ---------------------------------------------------------------------------
391
392/// Decides which volatile candidate slots must be verified this cycle.
393///
394/// The controller passes the volatile candidates (predicted read set) plus the
395/// current observation stats and `now`; the policy returns the subset to
396/// re-fetch. Correctness does not depend on the policy being complete — the
397/// background validator always re-checks each sim's *actual* volatile read set
398/// before trusting results — so a policy only trades RPC cost against how often a
399/// `Corrected` verdict is needed.
400pub trait FreshnessPolicy: Send {
401 /// Of these volatile candidate slots, which must be verified this cycle?
402 fn select(
403 &mut self,
404 candidates: &[(Address, U256)],
405 obs: &SlotObservationTracker,
406 now: u64,
407 ) -> Vec<(Address, U256)>;
408
409 /// Hook called when the controller advances to a new block.
410 fn on_new_block(&mut self, _block: u64) {}
411}
412
413/// Verifies every volatile candidate (safe / eager). Always correct, most RPC.
414#[derive(Clone, Copy, Debug, Default)]
415pub struct AlwaysVerify;
416
417impl FreshnessPolicy for AlwaysVerify {
418 fn select(
419 &mut self,
420 candidates: &[(Address, U256)],
421 _obs: &SlotObservationTracker,
422 _now: u64,
423 ) -> Vec<(Address, U256)> {
424 candidates.to_vec()
425 }
426}
427
428/// Verifies nothing (trust-all). Selects no slots from the predicted set, though
429/// the actual-read-set reconcile in the background validator can still surface
430/// changes.
431#[derive(Clone, Copy, Debug, Default)]
432pub struct NeverVerify;
433
434impl FreshnessPolicy for NeverVerify {
435 fn select(
436 &mut self,
437 _candidates: &[(Address, U256)],
438 _obs: &SlotObservationTracker,
439 _now: u64,
440 ) -> Vec<(Address, U256)> {
441 Vec::new()
442 }
443}
444
445/// Adaptive policy: verifies candidates the observation tracker flags via
446/// [`SlotObservationTracker::should_refetch`](crate::cache::SlotObservationTracker::should_refetch),
447/// driven by the thresholds in [`FreshnessParams`].
448#[derive(Clone, Debug, Default)]
449pub struct ObservationDriven {
450 /// Thresholds for the underlying [`SlotObservationTracker::should_refetch`](crate::cache::SlotObservationTracker::should_refetch)
451 /// heuristic.
452 pub params: FreshnessParams,
453}
454
455impl ObservationDriven {
456 /// An observation-driven policy with the given parameters.
457 pub fn new(params: FreshnessParams) -> Self {
458 Self { params }
459 }
460}
461
462impl FreshnessPolicy for ObservationDriven {
463 fn select(
464 &mut self,
465 candidates: &[(Address, U256)],
466 obs: &SlotObservationTracker,
467 now: u64,
468 ) -> Vec<(Address, U256)> {
469 candidates
470 .iter()
471 .copied()
472 .filter(|(addr, slot)| obs.should_refetch(*addr, *slot, now, &self.params))
473 .collect()
474 }
475}
476
477// ---------------------------------------------------------------------------
478// 4. Results
479// ---------------------------------------------------------------------------
480
481/// A storage slot whose value changed: `old` is the prior cached/snapshot value
482/// (`ZERO` if previously uncached), `new` is the resulting value.
483///
484/// Produced by two paths: the freshness verifier
485/// ([`EvmCache::verify_slots`](crate::cache::EvmCache::verify_slots) and the
486/// background validator), where `new` is a freshly-fetched value that differed
487/// from the cache; and the state-update writer
488/// ([`EvmCache::apply_update`](crate::cache::EvmCache::apply_update) /
489/// [`apply_updates`](crate::cache::EvmCache::apply_updates)), where `new` is the
490/// value just written.
491#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
492pub struct SlotChange {
493 /// Contract whose storage changed.
494 pub address: Address,
495 /// Storage slot key.
496 pub slot: U256,
497 /// Value previously held in the cache/snapshot.
498 pub old: U256,
499 /// Freshly-fetched value.
500 pub new: U256,
501}
502
503/// The classified outcome of fetching a single storage slot.
504///
505/// Where a [`SlotChange`] records only slots whose value *differed* from the
506/// cache, a [`SlotOutcome`] is produced for **every** requested slot — including
507/// ones that did not change and ones the fetcher could not return. This closes
508/// the "archive-miss" gap: a transient fetch failure is surfaced explicitly as
509/// [`SlotFetch::FetchFailed`] rather than collapsing into the same "no value"
510/// signal as a genuine on-chain zero ([`SlotFetch::Zero`]).
511///
512/// The fetch classification ([`SlotFetch`]) and change detection ([`SlotChange`])
513/// are independent reads of the same fetched value: a genuine `Ok(0)` on a slot
514/// whose cached value was also `0` yields [`SlotFetch::Zero`] **and** no
515/// `SlotChange`.
516#[derive(Clone, Debug, PartialEq, Eq)]
517pub struct SlotOutcome {
518 /// Contract whose storage slot was fetched.
519 pub address: Address,
520 /// Storage slot key.
521 pub slot: U256,
522 /// The classified result of fetching this slot.
523 pub fetch: SlotFetch,
524}
525
526/// The classified result of an individual slot fetch.
527///
528/// A fetcher `Ok(value)` is classified into [`Value`](SlotFetch::Value) (non-zero)
529/// or [`Zero`](SlotFetch::Zero) (a genuine on-chain zero); a fetcher `Err`
530/// becomes [`FetchFailed`](SlotFetch::FetchFailed), carrying the error string.
531/// [`NotAttempted`](SlotFetch::NotAttempted) marks a declared slot that a
532/// short-circuited round never reached (produced only by the accounts/discover
533/// phases of a cold-start round, never by verify).
534#[derive(Clone, Debug, PartialEq, Eq)]
535pub enum SlotFetch {
536 /// The slot was fetched and holds a non-zero value.
537 Value(U256),
538 /// The slot was fetched and holds a genuine on-chain zero.
539 Zero,
540 /// The fetcher returned an error for this slot; `reason` is its description.
541 FetchFailed {
542 /// Human-readable description of why the fetch failed.
543 reason: String,
544 },
545 /// The slot was declared but never reached because the round
546 /// short-circuited on an earlier-phase hard error.
547 NotAttempted,
548}
549
550/// The deferred verdict on a [`SpeculativeSim`]'s optimistic results.
551///
552/// # Verdict taxonomy
553///
554/// The verdict distinguishes *what* was reconciled:
555///
556/// - [`ConfirmedStorage`](Validation::ConfirmedStorage): no volatile storage slot
557/// the sims read changed. Account-level fields (balance/nonce/code) were **not**
558/// verified — this is what today's validator emits on a storage-only success.
559/// - [`ConfirmedFull`](Validation::ConfirmedFull): no volatile storage slot **and**
560/// no verified account-level field changed. Defined but not yet emitted (a
561/// follow-up wave wires validator-side account verification).
562/// - [`Corrected`](Validation::Corrected): at least one read slot (and, once
563/// account verification lands, account field) changed; carries `changed_slots`
564/// and `changed_accounts`.
565/// - [`Unverified`](Validation::Unverified): the fetcher failed; results are not
566/// trusted.
567///
568/// # Reconciliation scope
569///
570/// Today the verdict reflects only **volatile storage slots** in each sim's read
571/// set. Account-level state — native balance, nonce, and bytecode — is **not**
572/// re-verified, so a sim whose result depends on a `BALANCE`/`SELFBALANCE` (or
573/// nonce/code) that changed on-chain *without* a co-changing storage slot in its
574/// read set can still be reported
575/// [`ConfirmedStorage`](Validation::ConfirmedStorage). Classify such accounts as
576/// [`Validity::Pinned`] and keep them fresh via event-driven writes if their
577/// account-level state matters to your sims. A follow-up wave wires validator-side
578/// account verification that will populate
579/// [`ConfirmedFull`](Validation::ConfirmedFull) and the `changed_accounts` field
580/// of [`Corrected`](Validation::Corrected). See the module-level docs for the full
581/// freshness contract.
582pub enum Validation {
583 /// No **volatile storage slot** the sims read changed; account-level
584 /// balance/nonce/code was **NOT** verified. This is the storage-only success
585 /// verdict today's validator emits — it does *not* cover account-level state
586 /// (see the [type-level scope](Validation)).
587 ConfirmedStorage,
588 /// No volatile storage slot **AND** no verified account-level field
589 /// (balance/nonce/code) changed. Not emitted yet: a follow-up wave wires the
590 /// validator-side account verification that populates it (see the
591 /// [type-level scope](Validation)).
592 ConfirmedFull,
593 /// At least one read storage slot changed. `results` is the optimistic set
594 /// with the affected sims re-run against the fresh values; `changed_slots`
595 /// lists the slots that differed (also queued for flow-back into the cache) and
596 /// `changed_accounts` lists any account-level fields that differed. Only
597 /// storage slots are reconciled today — account-level verification is wired by
598 /// a follow-up wave, so `changed_accounts` is currently always empty (see the
599 /// [type-level scope](Validation)).
600 Corrected {
601 /// Optimistic results with the affected sims replaced by re-runs.
602 results: Vec<CallSimulationResult>,
603 /// Slots whose fresh value differed from the snapshot.
604 changed_slots: Vec<SlotChange>,
605 /// Accounts whose native fields differed from the snapshot. Empty until a
606 /// follow-up wave wires validator-side account verification.
607 changed_accounts: Vec<AccountChange>,
608 },
609 /// Validation could not complete — the fetcher failed or was missing, a
610 /// corrected re-run could not execute, the fixed-point round cap was hit,
611 /// or a sim read `BLOCKHASH` (which validator overlays resolve to ZERO and
612 /// therefore cannot vouch for). The optimistic results are *not* trusted.
613 Unverified {
614 /// Human-readable description of why validation could not complete.
615 reason: String,
616 },
617}
618
619impl std::fmt::Debug for Validation {
620 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621 match self {
622 Validation::ConfirmedStorage => write!(f, "ConfirmedStorage"),
623 Validation::ConfirmedFull => write!(f, "ConfirmedFull"),
624 Validation::Corrected {
625 changed_slots,
626 changed_accounts,
627 ..
628 } => f
629 .debug_struct("Corrected")
630 .field("changed_slots", changed_slots)
631 .field("changed_accounts", changed_accounts)
632 .finish_non_exhaustive(),
633 Validation::Unverified { reason } => f
634 .debug_struct("Unverified")
635 .field("reason", reason)
636 .finish(),
637 }
638 }
639}
640
641/// A single non-committing simulation request for the optimistic loop.
642///
643/// `tx.access_list` is the *predicted* read set (a performance hint that seeds
644/// the verify candidates); correctness does not depend on it because the
645/// background validator re-checks each sim's actual volatile read set.
646#[derive(Clone, Debug)]
647pub struct SimRequest {
648 /// Transaction sender.
649 pub from: Address,
650 /// Call target.
651 pub to: Address,
652 /// Calldata.
653 pub calldata: Bytes,
654 /// Per-call tx environment; `tx.access_list` is the predicted read set.
655 pub tx: TxConfig,
656}
657
658impl SimRequest {
659 /// A zero-value request with default tx environment.
660 pub fn new(from: Address, to: Address, calldata: Bytes) -> Self {
661 Self {
662 from,
663 to,
664 calldata,
665 tx: TxConfig::default(),
666 }
667 }
668
669 /// Set the predicted read set (EIP-2930 access list hint).
670 pub fn with_access_list(mut self, access_list: AccessList) -> Self {
671 self.tx.access_list = Some(access_list);
672 self
673 }
674
675 /// Set the native value (wei) sent with the call (e.g. for a payable call).
676 pub fn with_value(mut self, value: U256) -> Self {
677 self.tx.value = value;
678 self
679 }
680
681 /// Set the gas limit for the call (e.g. to model out-of-gas behavior).
682 pub fn with_gas_limit(mut self, gas_limit: u64) -> Self {
683 self.tx.gas_limit = Some(gas_limit);
684 self
685 }
686
687 /// Set the gas price (wei) for the call.
688 pub fn with_gas_price(mut self, gas_price: u128) -> Self {
689 self.tx.gas_price = Some(gas_price);
690 self
691 }
692}
693
694/// Optimistic simulation results plus a handle to their deferred validation.
695///
696/// Returned by [`FreshnessController::run`] as soon as the optimistic sims
697/// finish (without awaiting RPC). Read [`optimistic`](Self::optimistic)
698/// immediately, then `await` [`validate`](Self::validate) for the verdict.
699///
700/// # Cancellation (best-effort)
701/// Dropping this — or calling [`into_optimistic`](Self::into_optimistic) — sets a
702/// cancel flag and aborts the background task. Cancellation is **cooperative and
703/// best-effort, not instantaneous**: `run_validator` is synchronous, so an abort
704/// cannot preempt it once it is running. Instead the validator checks the flag at
705/// several checkpoints — before each fetch, and (on the first pass) after a fetch
706/// returns but before it records observations or queues corrections — so a cancel
707/// observed at a checkpoint skips the side effects downstream of it. A validator
708/// already executing a synchronous step (e.g. mid-fetch) completes that step
709/// before reaching the next checkpoint, and corrections accumulated up to a fetch
710/// that completed in the *final* loop iteration may still be queued for flow-back
711/// (the post-loop queue is not guarded by an immediately-preceding checkpoint).
712/// The intent is that a dropped speculation stops doing further work and, in the
713/// common case, does not flow corrections back into the cache; it does not
714/// guarantee that an in-flight or just-completed fetch's correction is withheld.
715pub struct SpeculativeSim {
716 optimistic: Vec<CallSimulationResult>,
717 /// `Option` so `validate`/`into_optimistic` can take the handle and skip the
718 /// abort-on-drop; `Drop` only aborts a handle still left in place.
719 validation: Option<JoinHandle<Validation>>,
720 /// Set when the caller drops or [`into_optimistic`](Self::into_optimistic)s
721 /// this handle; the validator polls it at its checkpoints to bail out before
722 /// causing side effects (fetching, observing, queuing corrections).
723 cancelled: Arc<AtomicBool>,
724}
725
726impl SpeculativeSim {
727 /// The optimistic results, readable before validation completes.
728 ///
729 /// These (and the re-run results in [`Validation::Corrected`]) carry an
730 /// **empty `token_deltas`** map: the optimistic loop does not run transfer
731 /// tracking, so the signed per-token balance deltas populated by the
732 /// committing `simulate_call_with_balance_deltas` path are not available here.
733 pub fn optimistic(&self) -> &[CallSimulationResult] {
734 &self.optimistic
735 }
736
737 /// Consume the handle and return the optimistic results, aborting the
738 /// background validation task.
739 ///
740 /// Because this takes `self` by value, it and [`validate`](Self::validate)
741 /// are mutually exclusive: only one of them can ever run for a given
742 /// `SpeculativeSim`, and each takes the handle.
743 pub fn into_optimistic(mut self) -> Vec<CallSimulationResult> {
744 self.cancelled.store(true, Ordering::Relaxed);
745 if let Some(handle) = self.validation.take() {
746 handle.abort();
747 }
748 std::mem::take(&mut self.optimistic)
749 }
750
751 /// Await the deferred validation verdict.
752 ///
753 /// If the background task failed to complete (e.g. it panicked), returns an
754 /// error. This consumes `self`, so it is mutually
755 /// exclusive with the cancel paths ([`into_optimistic`](Self::into_optimistic)
756 /// / drop) — a handle that is awaited here is never cancelled.
757 pub async fn validate(mut self) -> Result<Validation> {
758 let Some(handle) = self.validation.take() else {
759 return Err(FreshnessError::ValidationHandleConsumed);
760 };
761 handle
762 .await
763 .map_err(|source| FreshnessError::ValidationTaskFailed { source })
764 }
765}
766
767impl Drop for SpeculativeSim {
768 fn drop(&mut self) {
769 self.cancelled.store(true, Ordering::Relaxed);
770 if let Some(handle) = self.validation.take() {
771 handle.abort();
772 }
773 }
774}
775
776// ---------------------------------------------------------------------------
777// Controller
778// ---------------------------------------------------------------------------
779
780/// Drives the optimistic verify-and-rerun loop over an [`EvmCache`].
781///
782/// Holds the freshness [`FreshnessRegistry`], the shared
783/// [`SlotObservationTracker`], a [`FreshnessPolicy`], a [`FreshnessClock`], and
784/// the pending-corrections queue. The tracker and the pending queue are
785/// `Arc<Mutex<…>>` so the background validator can update them without touching
786/// the `!Send` cache. Adaptive thresholds ([`FreshnessParams`]) live on the
787/// policy that uses them ([`ObservationDriven`]), not on the controller.
788///
789/// # Runtime requirement
790/// [`run`](Self::run) spawns a background task and the (synchronous) fetcher uses
791/// `block_in_place` internally, so a **multi-thread** tokio runtime is required
792/// (`#[tokio::main(flavor = "multi_thread")]` or
793/// `Builder::new_multi_thread()`), mirroring the [`EvmCache`] constructor note.
794pub struct FreshnessController<P: FreshnessPolicy, C: FreshnessClock> {
795 registry: FreshnessRegistry,
796 tracker: Arc<Mutex<SlotObservationTracker>>,
797 policy: P,
798 clock: C,
799 pending: Arc<Mutex<Vec<SlotChange>>>,
800 /// Cumulative count of background re-runs performed by the validator across
801 /// all `run` calls. Shared with the spawned task; incremented once per
802 /// re-executed sim. Lets callers observe that selective re-run actually
803 /// skipped the unaffected sims rather than re-running every one.
804 rerun_count: Arc<AtomicUsize>,
805}
806
807impl<P: FreshnessPolicy> FreshnessController<P, BlockClock> {
808 /// Build a controller with the default [`BlockClock`] (starting at block 0).
809 ///
810 /// Starts with a fresh, empty [`SlotObservationTracker`] and an empty
811 /// pending-corrections queue. Use [`with_tracker`](Self::with_tracker) to share
812 /// a persisted tracker, or [`with_clock`](Self::with_clock) for a non-default
813 /// clock such as [`WallClock`].
814 pub fn new(registry: FreshnessRegistry, policy: P) -> Self {
815 Self::with_clock(registry, policy, BlockClock::new())
816 }
817}
818
819impl<P: FreshnessPolicy, C: FreshnessClock> FreshnessController<P, C> {
820 /// Build a controller with an explicit clock.
821 ///
822 /// Starts with a fresh, empty [`SlotObservationTracker`] and an empty
823 /// pending-corrections queue. The clock's units must match those the
824 /// `policy`'s [`FreshnessParams`] were tuned for (block numbers for
825 /// [`BlockClock`], unix seconds for [`WallClock`]).
826 pub fn with_clock(registry: FreshnessRegistry, policy: P, clock: C) -> Self {
827 Self {
828 registry,
829 tracker: Arc::new(Mutex::new(SlotObservationTracker::new())),
830 policy,
831 clock,
832 pending: Arc::new(Mutex::new(Vec::new())),
833 rerun_count: Arc::new(AtomicUsize::new(0)),
834 }
835 }
836
837 /// Use an existing shared observation tracker (e.g. a persisted one).
838 ///
839 /// Builder-style override that replaces the fresh tracker installed by
840 /// [`new`](Self::new) / [`with_clock`](Self::with_clock) with the given shared
841 /// handle, so change-frequency history survives across runs or is shared with
842 /// other components. The background validator updates this same tracker under
843 /// its `Mutex`.
844 pub fn with_tracker(mut self, tracker: Arc<Mutex<SlotObservationTracker>>) -> Self {
845 self.tracker = tracker;
846 self
847 }
848
849 /// The shared observation tracker.
850 pub fn tracker(&self) -> &Arc<Mutex<SlotObservationTracker>> {
851 &self.tracker
852 }
853
854 /// The freshness registry.
855 pub fn registry(&self) -> &FreshnessRegistry {
856 &self.registry
857 }
858
859 /// Mutable access to the freshness registry.
860 pub fn registry_mut(&mut self) -> &mut FreshnessRegistry {
861 &mut self.registry
862 }
863
864 /// Number of corrections waiting to be drained into the cache on the next
865 /// [`run`](Self::run).
866 pub fn pending_len(&self) -> usize {
867 self.pending.lock().unwrap_or_else(|e| e.into_inner()).len()
868 }
869
870 /// Cumulative number of background re-runs performed by the validator across
871 /// all [`run`](Self::run) calls so far.
872 ///
873 /// Incremented once per sim that the reconcile step actually re-executes
874 /// (i.e. whose read set intersected a changed slot). A `Corrected` verdict
875 /// over `n` requests where only one slot changed advances this by the number
876 /// of *affected* sims, not by `n` — making the selective-re-run behavior
877 /// directly observable.
878 pub fn rerun_count(&self) -> usize {
879 self.rerun_count.load(Ordering::Relaxed)
880 }
881
882 /// Advance to a new block.
883 ///
884 /// Advances the clock to `block` (a no-op for [`WallClock`], a `set_block`
885 /// for [`BlockClock`]) and then notifies the policy. Advancing the clock is
886 /// what ages [`Validity::ValidThrough`] slots into [`Validity::Volatile`] and
887 /// progresses the observation-tracker reuse window through the natural API.
888 pub fn on_new_block(&mut self, block: u64) {
889 self.clock.advance(block);
890 self.policy.on_new_block(block);
891 }
892
893 /// Run the optimistic loop for a batch of requests.
894 ///
895 /// 1. Drain queued corrections from prior cycles into the cache.
896 /// 2. Snapshot the cache and grab the batch fetcher.
897 /// 3. Run each request optimistically against the snapshot, capturing its
898 /// actual volatile read set.
899 /// 4. Compute the predicted volatile candidates and ask the policy which to
900 /// verify.
901 /// 5. Spawn the background validator (Send data only) and return a
902 /// [`SpeculativeSim`] immediately.
903 ///
904 /// # Panics
905 /// Must be called from within a tokio runtime: it calls `tokio::spawn` to
906 /// launch the background validator, which panics (`there is no reactor
907 /// running`) if no runtime is active. The spawned (synchronous) fetcher
908 /// additionally uses `tokio::task::block_in_place` internally, so the runtime
909 /// must be **multi-thread** (`#[tokio::main(flavor = "multi_thread")]` or
910 /// `Builder::new_multi_thread()`); on a current-thread runtime `block_in_place`
911 /// panics, mirroring the [`EvmCache`] constructor note.
912 ///
913 /// # Errors
914 /// Returns an error if any optimistic simulation fails to execute against the
915 /// freshly-created snapshot (propagated from
916 /// `EvmOverlay::call_raw_with_access_list`).
917 pub fn run(
918 &mut self,
919 cache: &mut EvmCache,
920 requests: Vec<SimRequest>,
921 ) -> Result<SpeculativeSim> {
922 let now = self.clock.now();
923
924 // 1. Drain pending corrections into the cache before snapshotting.
925 // Routed through the unified write primitive (`apply_updates` of
926 // write-through `Slot`s); behavior-identical to the old
927 // `inject_storage_batch_fresh`, demonstrating the one write path.
928 {
929 let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
930 if !pending.is_empty() {
931 let injects: Vec<StateUpdate> = pending
932 .iter()
933 .map(|c| StateUpdate::slot(c.address, c.slot, c.new))
934 .collect();
935 cache.apply_updates(&injects);
936 pending.clear();
937 }
938 }
939
940 // 2. Snapshot + fetcher (Arc clones, both Send). Capture the cache's
941 // pinned block now, so the deferred validator fetches at the block the
942 // snapshot was built from even if the cache is re-pinned meanwhile.
943 let snapshot = cache.snapshot();
944 let fetcher = cache.storage_batch_fetcher().cloned();
945 let validation_block = cache.block();
946
947 // 3. Optimistic sims + per-sim actual volatile read sets. Sims whose
948 // execution read `BLOCKHASH` through the ext-db-less overlay (which
949 // resolves it to ZERO) are recorded so the validator can fail
950 // closed instead of confirming a result the replay cannot verify.
951 let mut optimistic = Vec::with_capacity(requests.len());
952 let mut read_sets: Vec<Vec<(Address, U256)>> = Vec::with_capacity(requests.len());
953 let mut blockhash_readers: Vec<usize> = Vec::new();
954 for (index, req) in requests.iter().enumerate() {
955 let mut overlay = EvmOverlay::new(Arc::clone(&snapshot), None);
956 let (result, access) = overlay.call_raw_with_access_list_with(
957 req.from,
958 req.to,
959 req.calldata.clone(),
960 &req.tx,
961 )?;
962 if overlay.blockhash_zero_fallback() {
963 blockhash_readers.push(index);
964 }
965 optimistic.push(result_to_sim(result, &access.to_eip2930()));
966
967 let volatile: Vec<(Address, U256)> = access
968 .slots
969 .iter()
970 .copied()
971 .filter(|(addr, slot)| self.registry.is_volatile(*addr, *slot, now))
972 .collect();
973 read_sets.push(volatile);
974 }
975
976 // 4. Predicted candidates (union of request access lists, volatile only).
977 let mut candidate_set: HashSet<(Address, U256)> = HashSet::new();
978 for req in &requests {
979 if let Some(al) = &req.tx.access_list {
980 for item in &al.0 {
981 for key in &item.storage_keys {
982 let slot = U256::from_be_bytes(key.0);
983 if self.registry.is_volatile(item.address, slot, now) {
984 candidate_set.insert((item.address, slot));
985 }
986 }
987 }
988 }
989 }
990 let candidates: Vec<(Address, U256)> = candidate_set.into_iter().collect();
991 let verify_set = {
992 let tracker = self.tracker.lock().unwrap_or_else(|e| e.into_inner());
993 self.policy.select(&candidates, &tracker, now)
994 };
995
996 // 5. Spawn the validator with Send-only data.
997 let registry = self.registry.clone();
998 let tracker = Arc::clone(&self.tracker);
999 let pending = Arc::clone(&self.pending);
1000 let rerun_count = Arc::clone(&self.rerun_count);
1001 let optimistic_for_task = optimistic.clone();
1002 let cancelled = Arc::new(AtomicBool::new(false));
1003 let cancelled_for_task = Arc::clone(&cancelled);
1004 let validation = tokio::spawn(async move {
1005 // Yield once before doing any work, so a prompt drop/into_optimistic
1006 // can cancel before the validator is first polled. `run_validator` is
1007 // otherwise synchronous, so cancellation past this point is
1008 // cooperative: it observes the cancel flag at checkpoints.
1009 tokio::task::yield_now().await;
1010 run_validator(ValidatorInput {
1011 snapshot,
1012 fetcher,
1013 requests,
1014 read_sets,
1015 registry,
1016 tracker,
1017 pending,
1018 rerun_count,
1019 now,
1020 verify_set,
1021 optimistic: optimistic_for_task,
1022 cancelled: cancelled_for_task,
1023 validation_block,
1024 blockhash_readers,
1025 })
1026 });
1027
1028 Ok(SpeculativeSim {
1029 optimistic,
1030 validation: Some(validation),
1031 cancelled,
1032 })
1033 }
1034}
1035
1036/// Owned inputs handed to the background validator (all `Send`).
1037struct ValidatorInput {
1038 snapshot: Arc<EvmSnapshot>,
1039 fetcher: Option<StorageBatchFetchFn>,
1040 requests: Vec<SimRequest>,
1041 read_sets: Vec<Vec<(Address, U256)>>,
1042 registry: FreshnessRegistry,
1043 tracker: Arc<Mutex<SlotObservationTracker>>,
1044 pending: Arc<Mutex<Vec<SlotChange>>>,
1045 rerun_count: Arc<AtomicUsize>,
1046 now: u64,
1047 verify_set: Vec<(Address, U256)>,
1048 optimistic: Vec<CallSimulationResult>,
1049 cancelled: Arc<AtomicBool>,
1050 /// Block the snapshot was built from; passed to the fetcher so the deferred
1051 /// fetch reads the same block the snapshot represents.
1052 validation_block: BlockId,
1053 /// Indices of requests whose optimistic run read `BLOCKHASH` through the
1054 /// ZERO fallback. Non-empty ⇒ the validator fails closed (`Unverified`):
1055 /// storage verification cannot vouch for a result whose control flow may
1056 /// depend on a hash these overlays cannot resolve.
1057 blockhash_readers: Vec<usize>,
1058}
1059
1060/// Maximum fixed-point iterations the background validator performs while a
1061/// correction keeps expanding a sim's volatile read set. A backstop against
1062/// pathological contracts that read an unbounded chain of new volatile slots;
1063/// reaching it yields [`Validation::Unverified`] (the results have not reached a
1064/// verified fixed point, so they must not be trusted), logged via `tracing::warn!`.
1065const MAX_VALIDATION_ROUNDS: u32 = 8;
1066
1067/// Collect batch-fetcher results into a lookup map, requiring **every** requested
1068/// `(address, slot)` to be present and `Ok`.
1069///
1070/// The validator must never silently trust a gap: a fetch error *or* a slot the
1071/// fetcher omitted from its response yields `Err(reason)` (mapped to
1072/// [`Validation::Unverified`] by the caller) rather than defaulting the missing
1073/// value to zero — a custom fetcher that drops a slot would otherwise produce a
1074/// false confirmation or correction.
1075fn collect_fetch_results(
1076 requested: &[(Address, U256)],
1077 results: Vec<(Address, U256, StorageFetchResult<U256>)>,
1078) -> std::result::Result<HashMap<(Address, U256), U256>, String> {
1079 let mut map: HashMap<(Address, U256), U256> = HashMap::new();
1080 for (addr, slot, value) in results {
1081 match value {
1082 Ok(v) => {
1083 map.insert((addr, slot), v);
1084 }
1085 Err(e) => return Err(format!("fetch failed for {addr}:{slot}: {e}")),
1086 }
1087 }
1088 for &key in requested {
1089 if !map.contains_key(&key) {
1090 return Err(format!(
1091 "fetcher omitted requested slot {}:{}",
1092 key.0, key.1
1093 ));
1094 }
1095 }
1096 Ok(map)
1097}
1098
1099/// The background validation routine. Touches only `Send` data — never the cache.
1100fn run_validator(input: ValidatorInput) -> Validation {
1101 let ValidatorInput {
1102 snapshot,
1103 fetcher,
1104 requests,
1105 read_sets,
1106 registry,
1107 tracker,
1108 pending,
1109 rerun_count,
1110 now,
1111 verify_set,
1112 optimistic,
1113 cancelled,
1114 validation_block,
1115 blockhash_readers,
1116 } = input;
1117
1118 // Checkpoint: cancelled before we even begin (the caller dropped or
1119 // `into_optimistic`d the handle while we were parked at the initial yield).
1120 if cancelled.load(Ordering::Relaxed) {
1121 return Validation::ConfirmedStorage;
1122 }
1123
1124 // Fail closed on unverifiable `BLOCKHASH` reads (G5). The overlays these
1125 // sims ran on carry no block hashes, so the opcode resolved to ZERO;
1126 // re-checking storage slots cannot vouch for a result whose control flow
1127 // may depend on the real hash. This must precede the empty-verify-set
1128 // early confirm below — a hash-reading sim that touches no volatile slots
1129 // would otherwise silently confirm.
1130 if let Some(first) = blockhash_readers.first() {
1131 return Validation::Unverified {
1132 reason: format!(
1133 "request {first} read BLOCKHASH, which resolves to ZERO in \
1134 validator overlays (block hashes are not tracked); the result \
1135 cannot be verified"
1136 ),
1137 };
1138 }
1139
1140 let Some(fetcher) = fetcher else {
1141 return Validation::Unverified {
1142 reason: "no storage batch fetcher available".to_string(),
1143 };
1144 };
1145
1146 // verify = policy-selected set ∪ each sim's actual volatile read set,
1147 // re-filtered through the registry clone so only currently-volatile slots
1148 // are checked (defensive: read sets and the policy selection are already
1149 // volatile-filtered on the main thread).
1150 let mut verify: HashSet<(Address, U256)> = verify_set.into_iter().collect();
1151 for set in &read_sets {
1152 verify.extend(set.iter().copied());
1153 }
1154 verify.retain(|(addr, slot)| registry.is_volatile(*addr, *slot, now));
1155 if verify.is_empty() {
1156 return Validation::ConfirmedStorage;
1157 }
1158 let verify: Vec<(Address, U256)> = verify.into_iter().collect();
1159
1160 // Checkpoint: cancelled before issuing the (costly, side-effecting) fetch.
1161 // This is what makes the "dropped before fetching" guarantee hold.
1162 if cancelled.load(Ordering::Relaxed) {
1163 return Validation::ConfirmedStorage;
1164 }
1165
1166 // Fetch fresh values. Any error OR any omitted slot → Unverified (never trust
1167 // silently: a missing result must not default to zero).
1168 let results = (fetcher)(verify.clone(), validation_block);
1169 let fresh = match collect_fetch_results(&verify, results) {
1170 Ok(map) => map,
1171 Err(reason) => return Validation::Unverified { reason },
1172 };
1173
1174 // Checkpoint: cancelled after the fetch returned but before we record any
1175 // observations or queue a correction. A cancel seen here discards the
1176 // verdict's side effects entirely.
1177 if cancelled.load(Ordering::Relaxed) {
1178 return Validation::ConfirmedStorage;
1179 }
1180
1181 // Compare the initial verify set against the snapshot, observe each checked
1182 // slot, and seed the changed set (deduped by `(address, slot)`).
1183 let mut changed_map: HashMap<(Address, U256), SlotChange> = HashMap::new();
1184 let mut verified: HashSet<(Address, U256)> = verify.iter().copied().collect();
1185 {
1186 let mut tracker = tracker.lock().unwrap_or_else(|e| e.into_inner());
1187 for &(addr, slot) in &verify {
1188 // `collect_fetch_results` guarantees every requested slot is present.
1189 let new = fresh[&(addr, slot)];
1190 let old = snapshot.storage_value(addr, slot).unwrap_or(U256::ZERO);
1191 tracker.observe(addr, slot, new, now);
1192 if new != old {
1193 changed_map.insert(
1194 (addr, slot),
1195 SlotChange {
1196 address: addr,
1197 slot,
1198 old,
1199 new,
1200 },
1201 );
1202 }
1203 }
1204 }
1205
1206 if changed_map.is_empty() {
1207 return Validation::ConfirmedStorage;
1208 }
1209
1210 // Re-run affected sims to a fixed point. A correction can flip control flow
1211 // so a re-run reads a *new* volatile slot the optimistic read set never
1212 // touched; that slot must itself be verified, or the "corrected" result
1213 // would still rest on stale snapshot state. Each round re-runs every sim
1214 // whose (possibly expanded) read set intersects a changed slot — applying
1215 // the full accumulated override set — collects newly-read volatile slots,
1216 // fetches and diffs them, and repeats until no new volatile slot appears,
1217 // none of the newly fetched slots differ, or the iteration cap is reached.
1218 let mut results = optimistic;
1219 // Per-sim current volatile read set; starts at the optimistic read set and
1220 // expands as corrections open new branches.
1221 let mut sim_reads = read_sets;
1222 let mut rerun_indices: HashSet<usize> = HashSet::new();
1223 let mut round: u32 = 0;
1224 loop {
1225 let changed_keys: HashSet<(Address, U256)> = changed_map.keys().copied().collect();
1226 let overrides: Vec<(Address, U256, U256)> = changed_map
1227 .values()
1228 .map(|c| (c.address, c.slot, c.new))
1229 .collect();
1230
1231 // Re-run sims whose current read set intersects a changed slot, applying
1232 // every accumulated override, and gather newly-read volatile candidates.
1233 let mut any_rerun = false;
1234 let mut new_candidates: HashSet<(Address, U256)> = HashSet::new();
1235 for (i, req) in requests.iter().enumerate() {
1236 if !sim_reads[i].iter().any(|k| changed_keys.contains(k)) {
1237 continue;
1238 }
1239 any_rerun = true;
1240 rerun_indices.insert(i);
1241 let mut overlay = EvmOverlay::new(Arc::clone(&snapshot), None);
1242 for &(addr, slot, value) in &overrides {
1243 overlay.override_slot(addr, slot, value);
1244 }
1245 // A host/transact error means the corrected re-run could not execute;
1246 // we must not keep the stale optimistic result and call it "Corrected".
1247 // (A revert/halt is `Ok(..)`, not an `Err`.) → Unverified.
1248 let (result, access) = match overlay.call_raw_with_access_list_with(
1249 req.from,
1250 req.to,
1251 req.calldata.clone(),
1252 &req.tx,
1253 ) {
1254 Ok(v) => v,
1255 Err(e) => {
1256 return Validation::Unverified {
1257 reason: format!("corrected re-run failed for request {i}: {e}"),
1258 };
1259 }
1260 };
1261 // A correction can flip control flow onto a `BLOCKHASH` read the
1262 // optimistic run never made; the re-run saw ZERO for it, so the
1263 // "corrected" result cannot be trusted either (G5, fail closed).
1264 if overlay.blockhash_zero_fallback() {
1265 return Validation::Unverified {
1266 reason: format!(
1267 "corrected re-run for request {i} read BLOCKHASH, which \
1268 resolves to ZERO in validator overlays; the corrected \
1269 result cannot be verified"
1270 ),
1271 };
1272 }
1273 results[i] = result_to_sim(result, &access.to_eip2930());
1274 let new_volatile: Vec<(Address, U256)> = access
1275 .slots
1276 .iter()
1277 .copied()
1278 .filter(|(a, s)| registry.is_volatile(*a, *s, now))
1279 .collect();
1280 for &key in &new_volatile {
1281 if !verified.contains(&key) {
1282 new_candidates.insert(key);
1283 }
1284 }
1285 sim_reads[i] = new_volatile;
1286 }
1287
1288 // No sim read a changed slot (the change came from the predicted
1289 // candidate set, not an actual read), or no new volatile slot surfaced:
1290 // the current results already reflect every override, so we are done.
1291 if !any_rerun || new_candidates.is_empty() {
1292 break;
1293 }
1294 // The fixed point was not reached within the cap: corrections kept opening
1295 // new volatile slots. The results still rest on un-verified state, so we
1296 // must NOT return a trusted `Corrected`. Return `Unverified` without
1297 // queuing any pending corrections (matching the fetch-error paths); the
1298 // still-volatile slots are re-discovered and re-fetched on the next run.
1299 if round >= MAX_VALIDATION_ROUNDS {
1300 tracing::warn!(
1301 rounds = round,
1302 "freshness validator exceeded fixed-point round cap; returning Unverified"
1303 );
1304 return Validation::Unverified {
1305 reason: format!(
1306 "freshness validation exceeded fixed-point round cap ({MAX_VALIDATION_ROUNDS})"
1307 ),
1308 };
1309 }
1310
1311 // Checkpoint: cancelled mid-loop. Results so far reflect the applied
1312 // overrides; do not fetch further or queue corrections.
1313 if cancelled.load(Ordering::Relaxed) {
1314 return Validation::ConfirmedStorage;
1315 }
1316
1317 // Fetch the newly-discovered candidates; any error OR omitted slot →
1318 // Unverified (a missing result must not default to zero).
1319 let new_vec: Vec<(Address, U256)> = new_candidates.into_iter().collect();
1320 let fetched = (fetcher)(new_vec.clone(), validation_block);
1321 let new_fresh = match collect_fetch_results(&new_vec, fetched) {
1322 Ok(map) => map,
1323 Err(reason) => return Validation::Unverified { reason },
1324 };
1325
1326 // Diff + observe the newly fetched slots, growing the changed set.
1327 let mut grew = false;
1328 {
1329 let mut tracker = tracker.lock().unwrap_or_else(|e| e.into_inner());
1330 for &(addr, slot) in &new_vec {
1331 verified.insert((addr, slot));
1332 // `collect_fetch_results` guarantees every requested slot is present.
1333 let new = new_fresh[&(addr, slot)];
1334 let old = snapshot.storage_value(addr, slot).unwrap_or(U256::ZERO);
1335 tracker.observe(addr, slot, new, now);
1336 if new != old {
1337 changed_map.insert(
1338 (addr, slot),
1339 SlotChange {
1340 address: addr,
1341 slot,
1342 old,
1343 new,
1344 },
1345 );
1346 grew = true;
1347 }
1348 }
1349 }
1350
1351 // The newly fetched slots were all unchanged → another round would not
1352 // alter any result; current results are final.
1353 if !grew {
1354 break;
1355 }
1356 round += 1;
1357 }
1358
1359 // Count distinct affected sims once: a sim re-run across multiple rounds is
1360 // still one affected sim, preserving the "once per re-executed sim" contract.
1361 rerun_count.fetch_add(rerun_indices.len(), Ordering::Relaxed);
1362
1363 // Queue every accumulated correction for flow-back into the cache next run.
1364 let changed_slots: Vec<SlotChange> = changed_map.into_values().collect();
1365 {
1366 let mut pending = pending.lock().unwrap_or_else(|e| e.into_inner());
1367 pending.extend(changed_slots.iter().cloned());
1368 }
1369
1370 // Account-level changes are populated by a follow-up wave that wires
1371 // validator-side account verification; empty for now.
1372 Validation::Corrected {
1373 results,
1374 changed_slots,
1375 changed_accounts: Vec::new(),
1376 }
1377}
1378
1379/// Build a [`CallSimulationResult`] from a non-committing execution result and
1380/// its captured access list. `token_deltas` is empty (the optimistic path does
1381/// not run transfer tracking); gas, logs, and return data come from the
1382/// execution result. `status` records whether the call succeeded, reverted, or
1383/// halted; `output` carries the `Success`/`Revert` payload (empty on `Halt`),
1384/// so a corrected view-call's new return value is observable here.
1385fn result_to_sim(result: ExecutionResult, access_list: &AccessList) -> CallSimulationResult {
1386 let (status, gas_used, logs, output) = match result {
1387 ExecutionResult::Success {
1388 gas_used,
1389 logs,
1390 output,
1391 ..
1392 } => (SimStatus::Success, gas_used, logs, output.into_data()),
1393 ExecutionResult::Revert { gas_used, output } => {
1394 (SimStatus::Revert, gas_used, Vec::new(), output)
1395 }
1396 ExecutionResult::Halt { gas_used, reason } => (
1397 SimStatus::Halt {
1398 reason: format!("{reason:?}"),
1399 },
1400 gas_used,
1401 Vec::new(),
1402 Bytes::new(),
1403 ),
1404 };
1405 CallSimulationResult {
1406 status,
1407 gas_used,
1408 token_deltas: HashMap::new(),
1409 logs,
1410 access_list: access_list.clone(),
1411 output,
1412 }
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417 use super::*;
1418
1419 fn addr(n: u8) -> Address {
1420 Address::repeat_byte(n)
1421 }
1422
1423 // --- Classification ----------------------------------------------------
1424
1425 #[test]
1426 fn registry_default_is_volatile() {
1427 let reg = FreshnessRegistry::new();
1428 assert_eq!(reg.default_validity(), Validity::Volatile);
1429 assert_eq!(reg.validity(addr(1), U256::from(0)), Validity::Volatile);
1430 }
1431
1432 #[test]
1433 fn registry_with_default_overrides_unclassified() {
1434 let reg = FreshnessRegistry::with_default(Validity::Pinned);
1435 assert_eq!(reg.validity(addr(1), U256::from(0)), Validity::Pinned);
1436 assert!(!reg.is_volatile(addr(1), U256::from(0), 100));
1437 }
1438
1439 #[test]
1440 fn registry_resolution_order_slot_account_default() {
1441 let a = addr(1);
1442 let mut reg = FreshnessRegistry::new(); // default Volatile
1443 reg.pin(a); // account-level Pinned
1444 reg.mark_volatile_slot(a, U256::from(7)); // slot-level Volatile
1445
1446 // slot-level wins over account-level
1447 assert_eq!(reg.validity(a, U256::from(7)), Validity::Volatile);
1448 // account-level wins over default for a non-overridden slot
1449 assert_eq!(reg.validity(a, U256::from(8)), Validity::Pinned);
1450 // default for an unrelated account
1451 assert_eq!(reg.validity(addr(2), U256::from(7)), Validity::Volatile);
1452 }
1453
1454 #[test]
1455 fn is_volatile_per_variant() {
1456 let a = addr(1);
1457 let mut reg = FreshnessRegistry::new();
1458 reg.pin_slot(a, U256::from(1));
1459 reg.mark_volatile_slot(a, U256::from(2));
1460 reg.valid_through_slot(a, U256::from(3), 50);
1461
1462 assert!(!reg.is_volatile(a, U256::from(1), 100)); // Pinned
1463 assert!(reg.is_volatile(a, U256::from(2), 100)); // Volatile
1464 }
1465
1466 #[test]
1467 fn valid_through_boundary() {
1468 let a = addr(1);
1469 let slot = U256::from(3);
1470 let mut reg = FreshnessRegistry::new();
1471 reg.valid_through_slot(a, slot, 50);
1472
1473 assert!(!reg.is_volatile(a, slot, 49)); // before
1474 assert!(!reg.is_volatile(a, slot, 50)); // at boundary: still valid (now == m)
1475 assert!(reg.is_volatile(a, slot, 51)); // after: now > m
1476 }
1477
1478 #[test]
1479 fn registry_is_clone() {
1480 let mut reg = FreshnessRegistry::new();
1481 reg.pin(addr(1));
1482 let clone = reg.clone();
1483 assert_eq!(clone.validity(addr(1), U256::from(0)), Validity::Pinned);
1484 }
1485
1486 // --- Clock -------------------------------------------------------------
1487
1488 #[test]
1489 fn block_clock_default_and_set() {
1490 let clock = BlockClock::new();
1491 assert_eq!(clock.now(), 0);
1492 clock.set_block(123);
1493 assert_eq!(clock.now(), 123);
1494 }
1495
1496 #[test]
1497 fn block_clock_clone_shares_counter() {
1498 let clock = BlockClock::at(10);
1499 let clone = clock.clone();
1500 clock.set_block(42);
1501 // The clone observes the update through the shared Arc.
1502 assert_eq!(clone.now(), 42);
1503 }
1504
1505 #[test]
1506 fn wall_clock_is_unix_seconds() {
1507 let now = WallClock.now();
1508 // Sanity: after 2021-01-01.
1509 assert!(now > 1_600_000_000);
1510 }
1511
1512 // --- Policy ------------------------------------------------------------
1513
1514 #[test]
1515 fn always_verify_selects_all() {
1516 let obs = SlotObservationTracker::new();
1517 let candidates = [(addr(1), U256::from(0)), (addr(2), U256::from(1))];
1518 let mut policy = AlwaysVerify;
1519 assert_eq!(policy.select(&candidates, &obs, 0), candidates.to_vec());
1520 }
1521
1522 #[test]
1523 fn never_verify_selects_none() {
1524 let obs = SlotObservationTracker::new();
1525 let candidates = [(addr(1), U256::from(0))];
1526 let mut policy = NeverVerify;
1527 assert!(policy.select(&candidates, &obs, 0).is_empty());
1528 }
1529
1530 #[test]
1531 fn observation_driven_selects_only_should_refetch() {
1532 let mut obs = SlotObservationTracker::new();
1533 let params = FreshnessParams::default();
1534 let stable = (addr(1), U256::from(0));
1535 let unknown = (addr(2), U256::from(0));
1536
1537 // Build a stable (never-changed) slot with enough observations so
1538 // `should_refetch` returns false for it.
1539 for now in 0..params.min_observations {
1540 obs.observe(stable.0, stable.1, U256::from(42), now as u64);
1541 }
1542 let now = params.min_observations as u64 - 1;
1543 assert!(!obs.should_refetch(stable.0, stable.1, now, ¶ms));
1544 assert!(obs.should_refetch(unknown.0, unknown.1, now, ¶ms));
1545
1546 let mut policy = ObservationDriven::new(params);
1547 let selected = policy.select(&[stable, unknown], &obs, now);
1548 assert_eq!(selected, vec![unknown]);
1549 }
1550}