evm_fork_cache/cold_start/roots.rs
1//! Cold-start root baseline (`roots.bin`): persist observed storage roots so a
2//! restarting process can cheaply detect which tracked accounts changed while
3//! it was down.
4//!
5//! An account's storage-trie root (`storageHash`) is a collision-resistant
6//! commitment over *all* of that account's storage, so `root_unchanged ⟹
7//! nothing under the account changed`. The baseline compares the on-chain root
8//! **across time** — never a locally-reconstructed root against the chain: on
9//! restart, probe each tracked account's root now and, where it equals the
10//! persisted baseline, the cached tracked slots are provably current and are
11//! **not** re-read. Where it diverges (or no baseline exists, or the probe
12//! fails), the tracked slots are re-read and the new root adopted.
13//!
14//! This is a **currency** gate, not a **completeness** gate (spec §6): an
15//! unchanged root proves the tracked subset did not change, but it cannot tell
16//! you that a slot you *should* have been tracking was missing all along.
17
18use std::collections::{BTreeMap, HashMap};
19use std::path::Path;
20
21use alloy_primitives::{Address, B256, U256};
22use serde::{Deserialize, Serialize};
23use tracing::{debug, warn};
24
25use crate::cache::versioned;
26use crate::cold_start::plan::ColdStartPlan;
27use crate::cold_start::planner::{ColdStartPlanner, ColdStartStep};
28use crate::cold_start::results::ColdStartResults;
29use crate::errors::PersistenceError;
30use crate::events::StateView;
31
32const ROOT_BASELINE_MAGIC: &[u8; 8] = b"EFCROOT\0";
33const ROOT_BASELINE_VERSION: u32 = 1;
34
35/// Serialized payload of a [`RootBaseline`]: sorted `(address, root)` pairs.
36///
37/// A `Vec` of pairs sorted by address (the [`BTreeMap`] iteration order), so the
38/// on-disk bytes are deterministic for a given set of entries.
39#[derive(Serialize, Deserialize)]
40struct RootBaselineFile {
41 roots: Vec<(Address, B256)>,
42}
43
44/// A persisted map of `Address -> B256` storage roots — each tracked account's
45/// last **observed** on-chain storage root (`storageHash` from `eth_getProof`).
46///
47/// Persisted via [`save`](Self::save) / [`load`](Self::load) using the same
48/// versioned envelope as the binary state cache (magic bytes + version + bincode
49/// payload); an unknown magic, unknown version, or corrupt payload is a cache
50/// miss (`None`), never an error. The persistence location is the caller's
51/// choice — conventionally `roots.bin` alongside the binary state file
52/// (`evm_state.bin`).
53///
54/// # Currency, not completeness
55///
56/// The baseline is compared **across time** (the root observed now vs. the root
57/// observed at the last run), never local-vs-chain. `root_unchanged` proves the
58/// tracked subset did not change since the baseline block — it cannot detect
59/// that a slot you should have tracked was missing (spec §6).
60#[derive(Clone, Debug, Default, PartialEq, Eq)]
61pub struct RootBaseline {
62 roots: BTreeMap<Address, B256>,
63}
64
65impl RootBaseline {
66 /// Record `root` as the observed storage root of `address`, returning the
67 /// previously recorded root (if any).
68 pub fn insert(&mut self, address: Address, root: B256) -> Option<B256> {
69 self.roots.insert(address, root)
70 }
71
72 /// The recorded storage root of `address`, if one was observed.
73 pub fn get(&self, address: &Address) -> Option<B256> {
74 self.roots.get(address).copied()
75 }
76
77 /// Number of recorded `(address, root)` entries.
78 pub fn len(&self) -> usize {
79 self.roots.len()
80 }
81
82 /// `true` when no roots are recorded.
83 pub fn is_empty(&self) -> bool {
84 self.roots.is_empty()
85 }
86
87 /// Persist the baseline to `path` (conventionally `roots.bin` next to
88 /// `evm_state.bin`).
89 ///
90 /// The on-disk format carries magic bytes and a version number before the
91 /// bincode payload, matching the binary state cache envelope. Entries are
92 /// written in address order, so the bytes are deterministic. Returns an
93 /// error if serialization, parent-directory creation, or writing fails.
94 pub fn save(&self, path: &Path) -> Result<(), PersistenceError> {
95 let file = RootBaselineFile {
96 roots: self.roots.iter().map(|(a, r)| (*a, *r)).collect(),
97 };
98 let data = versioned::encode(
99 ROOT_BASELINE_MAGIC,
100 ROOT_BASELINE_VERSION,
101 &file,
102 "root baseline",
103 )?;
104 if let Some(parent) = path.parent() {
105 std::fs::create_dir_all(parent)
106 .map_err(|err| PersistenceError::create_dir(parent, err))?;
107 }
108 std::fs::write(path, &data).map_err(|err| PersistenceError::write(path, err))?;
109 debug!(
110 entries = self.roots.len(),
111 bytes = data.len(),
112 "Saved root baseline"
113 );
114 Ok(())
115 }
116
117 /// Load a baseline from `path`.
118 ///
119 /// Returns `None` (rather than erroring) for a missing file, an unreadable
120 /// file, an unknown magic header, an unknown version, or a corrupt payload —
121 /// all are cache misses, matching the binary state cache's handling. A
122 /// missing file (the normal first-run case) is logged at `debug`; a read
123 /// error and any magic/version/decode failure are logged at `warn`.
124 pub fn load(path: &Path) -> Option<Self> {
125 let data = match std::fs::read(path) {
126 Ok(d) => d,
127 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
128 debug!("No root baseline file found, starting fresh");
129 return None;
130 }
131 Err(e) => {
132 warn!(error = %e, "Failed to read root baseline, starting fresh");
133 return None;
134 }
135 };
136
137 let file = versioned::decode::<RootBaselineFile>(
138 &data,
139 ROOT_BASELINE_MAGIC,
140 ROOT_BASELINE_VERSION,
141 "root baseline",
142 )?;
143
144 let baseline = Self {
145 roots: file.roots.into_iter().collect(),
146 };
147 debug!(
148 entries = baseline.roots.len(),
149 bytes = data.len(),
150 "Loaded root baseline"
151 );
152 Some(baseline)
153 }
154}
155
156/// The outcome of one declared probe-roots address: the storage root the
157/// account-proof fetcher observed at the pinned block, or `None` when it
158/// could not be observed.
159#[derive(Clone, Debug)]
160pub struct RootProbeOutcome {
161 /// The probed account.
162 pub address: Address,
163 /// The observed storage root; `None` when the probe failed or the fetcher
164 /// omitted the address (treat as unknown -> conservative re-read).
165 pub root: Option<B256>,
166}
167
168/// Which round the [`RootBaselinePlanner`] is in.
169#[derive(Clone, Copy, Debug)]
170enum RootBaselinePhase {
171 /// Round 1: probe every tracked account's root.
172 Probe,
173 /// Round 2 (if needed): re-read the tracked slots of diverged/unknown accounts.
174 Verify,
175}
176
177/// A restart planner that root-gates the tracked working set (Phase-8 §5.5).
178///
179/// Round 1 probes every tracked account's storage root via the account-proof
180/// fetcher (no reads are injected). For each tracked account:
181///
182/// - observed root **equal** to the baseline ⇒ the cached tracked slots are
183/// provably current — the root is retained in the updated baseline and the
184/// slots are **not** re-read;
185/// - observed root **diverged** (or no baseline entry) ⇒ the new root is
186/// adopted into the updated baseline and the account's tracked slots are
187/// re-read in a second verify round;
188/// - probe **failed** (`root: None`) ⇒ conservative: the tracked slots are
189/// re-read, and no root is adopted — an unobserved root never clobbers a
190/// previously persisted one.
191///
192/// When nothing needs re-reading the run finishes after the probe round.
193/// [`updated_baseline`](Self::updated_baseline) returns the baseline with this
194/// run's adoptions applied — persist it as the next `roots.bin`.
195///
196/// Like [`RootBaseline`], this is a **currency** gate, not a completeness gate
197/// (spec §6): it detects change in the tracked subset, not slots that were
198/// never tracked.
199pub struct RootBaselinePlanner {
200 /// Tracked accounts and the storage slots kept live for each.
201 tracked: Vec<(Address, Vec<U256>)>,
202 /// The persisted baseline this run compares against (never mutated).
203 baseline: RootBaseline,
204 /// Baseline-with-adoptions: starts as a copy of `baseline`, updated with
205 /// every root observed by this run.
206 updated: RootBaseline,
207 /// Which round the planner is in.
208 phase: RootBaselinePhase,
209}
210
211impl RootBaselinePlanner {
212 /// Build a planner over `tracked` (`account -> its tracked slots`) and
213 /// `baseline` loaded from `roots.bin` (empty when nothing was persisted).
214 pub fn new(tracked: Vec<(Address, Vec<U256>)>, baseline: RootBaseline) -> Self {
215 let updated = baseline.clone();
216 Self {
217 tracked,
218 baseline,
219 updated,
220 phase: RootBaselinePhase::Probe,
221 }
222 }
223
224 /// The observed roots adopted by this run, layered over the loaded baseline
225 /// (persist as the next `roots.bin`).
226 ///
227 /// Accounts whose probe failed keep their previous baseline entry (if any);
228 /// accounts whose root was observed carry the observed root.
229 pub fn updated_baseline(&self) -> RootBaseline {
230 self.updated.clone()
231 }
232}
233
234impl ColdStartPlanner for RootBaselinePlanner {
235 fn initial_plan(&mut self, _state: &dyn StateView) -> ColdStartPlan {
236 ColdStartPlan {
237 probe_roots: self.tracked.iter().map(|&(address, _)| address).collect(),
238 ..Default::default()
239 }
240 }
241
242 fn on_results(&mut self, results: &ColdStartResults, _state: &dyn StateView) -> ColdStartStep {
243 match self.phase {
244 RootBaselinePhase::Probe => {
245 // Index the probe outcomes; an address absent from the results
246 // entirely is treated the same as a failed probe (unknown).
247 let observed: HashMap<Address, Option<B256>> = results
248 .probed_roots
249 .iter()
250 .map(|o| (o.address, o.root))
251 .collect();
252
253 let mut verify: Vec<(Address, U256)> = Vec::new();
254 for (address, slots) in &self.tracked {
255 match observed.get(address).copied().flatten() {
256 Some(root) => {
257 let current = self.baseline.get(address) == Some(root);
258 // Adopt the observed root either way; skip the
259 // re-read only when it matches the baseline.
260 self.updated.insert(*address, root);
261 if !current {
262 verify.extend(slots.iter().map(|&slot| (*address, slot)));
263 }
264 }
265 // Probe failed / omitted: conservative re-read, and do
266 // NOT adopt a root — `updated` keeps the old baseline
267 // entry (if any) rather than clobbering it.
268 None => verify.extend(slots.iter().map(|&slot| (*address, slot))),
269 }
270 }
271
272 if verify.is_empty() {
273 return ColdStartStep::Done;
274 }
275 self.phase = RootBaselinePhase::Verify;
276 ColdStartStep::Continue(ColdStartPlan {
277 verify,
278 ..Default::default()
279 })
280 }
281 RootBaselinePhase::Verify => ColdStartStep::Done,
282 }
283 }
284}