Skip to main content

evm_fork_cache/cold_start/
driver.rs

1//! The cold-start driver: inherent [`EvmCache`] methods that execute rounds and
2//! run the bounded multi-round loop.
3//!
4//! The driver performs every fetch and call; planners are pure and IO-free. The
5//! whole module is gated behind the `reactive` feature.
6
7use std::collections::{HashMap, HashSet};
8
9use alloy_eips::BlockId;
10use alloy_primitives::{Address, B256};
11
12use crate::cache::{EvmCache, block_in_place_handle};
13use crate::cold_start::config::{ColdStartConfig, ColdStartPin, ColdStartRunReport};
14use crate::cold_start::error::ColdStartError;
15use crate::cold_start::plan::ColdStartPlan;
16use crate::cold_start::planner::{ColdStartPlanner, ColdStartStep};
17use crate::cold_start::results::{ColdStartCallResult, ColdStartResults, RoundOutcome};
18use crate::cold_start::roots::RootProbeOutcome;
19use crate::errors::CacheResult;
20use crate::events::StateView;
21use crate::freshness::{SlotFetch, SlotOutcome};
22
23impl EvmCache {
24    /// View `self` as a [`StateView`] for handing to a planner.
25    ///
26    /// The returned borrow must be **inlined** into each planner call rather than
27    /// held across [`execute_cold_start_round`](Self::execute_cold_start_round),
28    /// which needs `&mut self` (holding the shared borrow across it is a borrowck
29    /// error).
30    fn state_view(&self) -> &dyn StateView {
31        self
32    }
33
34    /// Pre-seed an account synchronously, bridging [`ensure_account`] across the
35    /// async boundary.
36    ///
37    /// [`ensure_account`](EvmCache::ensure_account) early-returns for an
38    /// already-present account; for a missing one it issues a backend fetch that
39    /// can return `Err`. This is the only producer of a cold-start round hard error
40    /// in the accounts phase. Requires a **multi-thread** tokio runtime (the
41    /// cold-start runtime precondition); on a current-thread runtime or with no
42    /// runtime present it returns a typed error via
43    /// [`block_in_place_handle`](crate::cache::block_in_place_handle).
44    pub(crate) fn ensure_account_blocking(&mut self, address: Address) -> CacheResult<()> {
45        let handle = block_in_place_handle()?;
46        tokio::task::block_in_place(|| handle.block_on(self.ensure_account(address)))
47    }
48
49    /// Execute a single cold-start round and return its (possibly partial) outcome.
50    ///
51    /// Fixed phase order: **verify_code → accounts → verify → probe →
52    /// probe_roots → discover**.
53    ///
54    /// Per-round fetcher guards: if the plan declares any verify or probe slots
55    /// and the cache has no storage batch fetcher, the round short-circuits with
56    /// [`ColdStartError::NoBatchFetcher`] before issuing any read; likewise a
57    /// probe_roots-bearing round with no account proof fetcher short-circuits
58    /// with [`ColdStartError::NoAccountProofFetcher`], and a round with pending
59    /// code seeds but no account-fields fetcher short-circuits with
60    /// [`ColdStartError::NoAccountFieldsFetcher`]. A round declaring only
61    /// accounts/discover (and holding no pending seeds) runs without any
62    /// fetcher.
63    ///
64    /// - **verify_code (first):** every [`CodeSeedState`](crate::cache::CodeSeedState)
65    ///   `Pending` claim is settled via
66    ///   [`verify_code_seeds`](EvmCache::verify_code_seeds); the work set is
67    ///   the cache's own pending marks, not a plan field. Matches are marked
68    ///   `Verified`; contradicted claims are purged, so an address also listed
69    ///   in `plan.accounts` is refetched by the very next phase. A transport
70    ///   failure is **not** a round hard error — it surfaces in the report's
71    ///   `unverifiable` bucket (matching probe_roots' per-address stance).
72    ///   Running first means no discover sim ever executes over an unverified
73    ///   claim, and `results.code_verifications` survives any later phase's
74    ///   hard error. With no pending seeds the phase is a no-op
75    ///   (`code_verifications: None`).
76    /// - **accounts:** each `plan.accounts` address is pre-seeded via
77    ///   `ensure_account_blocking`. A failure here is a hard error before the
78    ///   slot phases ran: every declared verify/probe slot is marked
79    ///   [`SlotFetch::NotAttempted`], every declared probe_roots address is
80    ///   synthesized as `root: None`, and the round returns with `error: Some(..)`
81    ///   (the already-computed `code_verifications` is preserved). This is the
82    ///   only producer of `NotAttempted`.
83    /// - **verify:** each verify slot is re-fetched, classified into
84    ///   `results.fetched`, and (when changed) injected and recorded in
85    ///   `results.verified`.
86    /// - **probe:** each probe slot is re-fetched at the pinned block and
87    ///   classified into `results.probed` via the same shared `Result<U256>`
88    ///   classification verify uses. Unlike verify, a probe injects nothing and
89    ///   records no [`SlotChange`](crate::freshness::SlotChange): it is the
90    ///   archive-miss classification for slots a consumer does not want to warm.
91    /// - **probe_roots:** each `plan.probe_roots` address is root-only probed
92    ///   (`(addr, vec![])`) through the account proof fetcher at the pinned
93    ///   block and recorded into `results.probed_roots` as a
94    ///   [`RootProbeOutcome`]. Nothing is injected; a per-address failure (or an
95    ///   address the fetcher omitted) is `root: None`, never a round hard error.
96    /// - **discover (last):** each [`ColdStartCall`](crate::cold_start::ColdStartCall)
97    ///   is executed via
98    ///   [`call_raw_with_access_list`](EvmCache::call_raw_with_access_list), its
99    ///   access list filtered by `restrict_to`, and the result pushed to
100    ///   `results.discovered`. A discover failure preserves the verify/probe
101    ///   outcomes already computed this round (they ran earlier, so they are *not*
102    ///   `NotAttempted`); the failing call and all subsequent discover calls are
103    ///   dropped, and the round returns with `error: Some(..)`.
104    pub fn execute_cold_start_round(&mut self, plan: &ColdStartPlan) -> RoundOutcome {
105        let mut results = ColdStartResults::default();
106
107        // Per-round fetcher guard: only fires for verify/probe-bearing rounds.
108        if (!plan.verify.is_empty() || !plan.probe.is_empty())
109            && self.storage_batch_fetcher().is_none()
110        {
111            return RoundOutcome {
112                results,
113                error: Some(ColdStartError::NoBatchFetcher),
114            };
115        }
116
117        // Per-round proof-fetcher guard: only fires for probe_roots-bearing rounds.
118        if !plan.probe_roots.is_empty() && self.account_proof_fetcher().is_none() {
119            return RoundOutcome {
120                results,
121                error: Some(ColdStartError::NoAccountProofFetcher),
122            };
123        }
124
125        // Per-round fields-fetcher guard: only fires when the cache actually
126        // holds pending code seeds (the work set is cache state, not a plan
127        // declaration).
128        let pending_seeds = self.pending_code_seeds();
129        if !pending_seeds.is_empty() && self.account_fields_fetcher().is_none() {
130            return RoundOutcome {
131                results,
132                error: Some(ColdStartError::NoAccountFieldsFetcher),
133            };
134        }
135
136        // verify_code phase (first): settle every Pending canonical code claim
137        // before anything simulates over it. Purged mismatches are refetched
138        // by the accounts phase right below when listed there. The guard above
139        // front-ran the only error surface of verify_code_seeds; a residual
140        // error is synthesized exactly like an accounts-phase hard error.
141        if !pending_seeds.is_empty() {
142            match self.verify_code_seeds() {
143                Ok(report) => results.code_verifications = Some(report),
144                Err(e) => {
145                    results.fetched = not_attempted_outcomes(&plan.verify);
146                    results.probed = not_attempted_outcomes(&plan.probe);
147                    results.probed_roots = not_attempted_root_outcomes(&plan.probe_roots);
148                    return RoundOutcome {
149                        results,
150                        error: Some(ColdStartError::Fetch(e)),
151                    };
152                }
153            }
154        }
155
156        // Accounts phase: pre-seed each declared account. A failure here
157        // short-circuits the round before verify/probe/discover run, so every
158        // declared verify/probe slot is synthesized as NotAttempted.
159        for &address in &plan.accounts {
160            if let Err(e) = self.ensure_account_blocking(address) {
161                results.fetched = not_attempted_outcomes(&plan.verify);
162                results.probed = not_attempted_outcomes(&plan.probe);
163                results.probed_roots = not_attempted_root_outcomes(&plan.probe_roots);
164                return RoundOutcome {
165                    results,
166                    error: Some(ColdStartError::Fetch(e)),
167                };
168            }
169        }
170
171        // Verify phase: classify every slot, inject and record the changed ones.
172        if !plan.verify.is_empty() {
173            match self.verify_slots_with_outcomes(&plan.verify) {
174                Ok((changed, outcomes)) => {
175                    results.verified = changed;
176                    results.fetched = outcomes;
177                }
178                Err(e) => {
179                    // The only error surface of verify_slots_with_outcomes is the
180                    // missing-fetcher guard, already front-run above; surface any
181                    // residual error explicitly rather than panicking.
182                    return RoundOutcome {
183                        results,
184                        error: Some(ColdStartError::Fetch(e)),
185                    };
186                }
187            }
188        }
189
190        // Probe phase: classify each declared slot at the pinned block WITHOUT
191        // injecting (the archive-miss classification for slots a consumer does
192        // not want to warm). It records into `results.probed` only — never
193        // `results.verified`, and never writes the cache.
194        if !plan.probe.is_empty() {
195            // The per-round guard already ensured a fetcher is present for a
196            // probe-bearing round. Read pinned to self.block (NOT read_storage_slot,
197            // which is unpinned), classify, and inject NOTHING.
198            let fetcher = self
199                .storage_batch_fetcher()
200                .cloned()
201                .expect("probe-bearing round guarded a fetcher above");
202            let probed = (fetcher)(plan.probe.clone(), self.block());
203            results.probed = probed
204                .into_iter()
205                .map(|(address, slot, fetched)| SlotOutcome {
206                    address,
207                    slot,
208                    fetch: EvmCache::classify(fetched),
209                })
210                .collect();
211        }
212
213        // Probe-roots phase: root-only probe each declared account through the
214        // account proof fetcher at the pinned block, WITHOUT injecting anything.
215        // A per-address failure (or an address the fetcher omitted) records
216        // `root: None` — never a round hard error.
217        if !plan.probe_roots.is_empty() {
218            // The per-round guard already ensured a proof fetcher is present
219            // for a probe_roots-bearing round.
220            let fetcher = self
221                .account_proof_fetcher()
222                .cloned()
223                .expect("probe_roots-bearing round guarded a proof fetcher above");
224            let responses = (fetcher)(
225                plan.probe_roots.iter().map(|&a| (a, vec![])).collect(),
226                self.block(),
227            );
228            // Per the AccountProofFetchFn contract, at most one result comes
229            // back per requested address; Ok carries the root, Err / omitted
230            // means the root could not be observed.
231            let observed: HashMap<Address, Option<B256>> = responses
232                .into_iter()
233                .map(|(address, result)| (address, result.ok().map(|proof| proof.storage_hash)))
234                .collect();
235            results.probed_roots = plan
236                .probe_roots
237                .iter()
238                .map(|&address| RootProbeOutcome {
239                    address,
240                    root: observed.get(&address).copied().flatten(),
241                })
242                .collect();
243        }
244
245        // Discover phase (last): run each view-call, filter by restrict_to. A
246        // failure drops this and every subsequent call but preserves the
247        // verify/probe outcomes already computed above.
248        for call in &plan.discover {
249            match self.call_raw_with_access_list(call.from, call.to, call.calldata.clone()) {
250                Ok((result, mut access)) => {
251                    if let Some(list) = &call.restrict_to {
252                        let keep: HashSet<Address> = list.iter().copied().collect();
253                        access.slots.retain(|(a, _)| keep.contains(a));
254                        access.accounts.retain(|a| keep.contains(a));
255                    }
256                    results
257                        .discovered
258                        .push(ColdStartCallResult { result, access });
259                }
260                Err(e) => {
261                    return RoundOutcome {
262                        results,
263                        error: Some(ColdStartError::Fetch(e)),
264                    };
265                }
266            }
267        }
268
269        RoundOutcome {
270            results,
271            error: None,
272        }
273    }
274
275    /// Run a bounded multi-round cold start driven by `planner`.
276    ///
277    /// Pin handling per `config.pin`: [`ColdStartPin::CachePinned`] is a no-op;
278    /// [`ColdStartPin::Hash`] pins every round to
279    /// `BlockId::from((hash, Some(require_canonical)))`, capturing the prior block
280    /// and restoring it on **every** exit path (success, budget-exceeded, and
281    /// mid-round error).
282    ///
283    /// The loop checks the round budget at the top: with `max_rounds = N`, rounds
284    /// `0..N` execute and a planner still returning `Continue` after round `N`
285    /// yields [`RoundBudgetExceeded`](ColdStartError::RoundBudgetExceeded). Each
286    /// round's results are absorbed into the report **before** its `error` is
287    /// checked; a round error propagates after restoring the pin and **without**
288    /// calling `on_results`. Note the absorbed report is returned only on the
289    /// `Ok` path — on error it is dropped and the `Err` carries only the cause;
290    /// use [`execute_cold_start_round`](Self::execute_cold_start_round) directly
291    /// to observe a failed round's partial outcomes.
292    pub fn run_cold_start(
293        &mut self,
294        planner: &mut dyn ColdStartPlanner,
295        config: ColdStartConfig,
296    ) -> Result<ColdStartRunReport, ColdStartError> {
297        // Pin handling: capture the block to restore (None == no restore needed).
298        let restore: Option<BlockId> = match config.pin {
299            ColdStartPin::CachePinned => None,
300            ColdStartPin::Hash {
301                hash,
302                require_canonical,
303                ..
304            } => {
305                let prev = self.block();
306                self.set_block(BlockId::from((hash, Some(require_canonical))));
307                Some(prev)
308            }
309        };
310
311        let mut report = ColdStartRunReport::default();
312        // Borrow inlined, not hoisted across the &mut self round call.
313        let mut plan = planner.initial_plan(self.state_view());
314
315        loop {
316            if report.rounds >= config.max_rounds {
317                if let Some(prev) = restore {
318                    self.set_block(prev);
319                }
320                return Err(ColdStartError::RoundBudgetExceeded {
321                    max_rounds: config.max_rounds,
322                });
323            }
324
325            let outcome = self.execute_cold_start_round(&plan);
326            report.absorb_round(&plan, &outcome.results);
327
328            if let Some(err) = outcome.error {
329                if let Some(prev) = restore {
330                    self.set_block(prev);
331                }
332                return Err(err);
333            }
334
335            match planner.on_results(&outcome.results, self.state_view()) {
336                ColdStartStep::Done => break,
337                ColdStartStep::Continue(next) => plan = next,
338            }
339        }
340
341        if let Some(prev) = restore {
342            self.set_block(prev);
343        }
344        Ok(report)
345    }
346}
347
348/// Synthesize one [`SlotFetch::NotAttempted`] outcome per declared slot.
349///
350/// Used when an accounts-phase hard error short-circuits a round before the
351/// verify/probe phases run: every declared slot is reported as `NotAttempted`
352/// rather than silently dropped.
353fn not_attempted_outcomes(slots: &[(Address, alloy_primitives::U256)]) -> Vec<SlotOutcome> {
354    slots
355        .iter()
356        .map(|&(address, slot)| SlotOutcome {
357            address,
358            slot,
359            fetch: SlotFetch::NotAttempted,
360        })
361        .collect()
362}
363
364/// Synthesize one `root: None` [`RootProbeOutcome`] per declared probe-roots
365/// address.
366///
367/// The probe_roots mirror of [`not_attempted_outcomes`]: when an accounts-phase
368/// hard error short-circuits a round before the probe_roots phase runs, every
369/// declared address is reported with an unobserved root rather than silently
370/// dropped.
371fn not_attempted_root_outcomes(addresses: &[Address]) -> Vec<RootProbeOutcome> {
372    addresses
373        .iter()
374        .map(|&address| RootProbeOutcome {
375            address,
376            root: None,
377        })
378        .collect()
379}