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}