ethrex_storage/layering.rs
1//! # Trie layering: in-memory diff-layers and deep-reorg overlay
2//!
3//! This module implements ethrex's two-tier in-memory trie cache that sits
4//! between block execution and RocksDB. It is the read/write path for all
5//! trie-node and flat-KV accesses during block execution and fork-choice
6//! updates.
7//!
8//! ## Architecture overview
9//!
10//! ```text
11//! ┌──────────────────────────────────────────────────────────┐
12//! │ Block N+2 ──► Block N+1 ──► Block N (cache edge D) │ TrieLayerCache
13//! │ (newest layer) (oldest cached layer) │ (forward diff-layers)
14//! └───────────────────────────┬──────────────────────────────┘
15//! │ miss
16//! ▼
17//! ┌──────────────────────────────────────────────────────────┐
18//! │ Overlay: reverse-diff [D..pivot+1] on the OLD chain │ (installed only during
19//! │ exposes the virtual state at `pivot` without touching │ deep reorgs; None in
20//! │ the on-disk trie. │ steady state)
21//! └───────────────────────────┬──────────────────────────────┘
22//! │ miss (or no overlay)
23//! ▼
24//! ┌──────────────────────────────────────────────────────────┐
25//! │ RocksDB on-disk state (account/storage trie+flat KV) │
26//! └──────────────────────────────────────────────────────────┘
27//! ```
28//!
29//! ## `TrieLayer` — one block's diff
30//!
31//! Each [`TrieLayer`] stores the trie-node writes produced by executing one
32//! block (in regular sync) or one batch of ~1024 blocks (full sync / batch
33//! mode). Layers are linked via a `parent` state-root field to form a
34//! singly-linked chain from newest to oldest.
35//!
36//! ## `TrieLayerCache` — the forward cache
37//!
38//! [`TrieLayerCache`] is a `HashMap<state_root, Arc<TrieLayer>>` with a
39//! bloom filter for fast miss detection. When the chain reaches
40//! `commit_threshold` layers the oldest eligible layer is flushed to
41//! RocksDB and removed from the map. Two thresholds are used:
42//! - **128** — regular block-by-block execution.
43//! - **4** — full sync / batch mode (one layer ≈ 1 GB of state diffs).
44//!
45//! ## `Overlay` — the deep-reorg bridge
46//!
47//! When a fork-choice update targets a head whose ancestor state was flushed
48//! past the layer-cache edge `D`, ethrex builds an [`Overlay`] by replaying
49//! the [`STATE_HISTORY`](crate::api::tables::STATE_HISTORY) journal entries
50//! for blocks `[D, D-1, ..., pivot+1]` in descending order. The overlay
51//! holds the accumulated reverse-diff, exposing the virtual state at `pivot`
52//! without mutating RocksDB.
53//!
54//! ## `TrieWrapper::get` — the read cascade
55//!
56//! [`TrieWrapper`] is the [`ethrex_trie::TrieDB`] implementation used during
57//! block execution. Its `get` method follows a strict priority order:
58//!
59//! 1. **Layer cache** — forward layers on the new chain (keyed by state-root
60//! chain from the executing block back to the oldest in-memory layer).
61//! 2. **Overlay** — if installed, the reverse-diff that reconstructs the
62//! pivot state. A layer hit pre-empts the overlay; an overlay hit pre-empts
63//! disk. `Some(None)` from the overlay means the key was absent at the
64//! pivot (caller must treat as missing, not fall through to disk, because
65//! disk still holds the old chain's value).
66//! 3. **Disk** — RocksDB, queried only when both cache and overlay miss.
67//!
68//! ## Cache swap on deep reorg
69//!
70//! [`Store::install_overlay_for_reorg`](crate::store::Store::install_overlay_for_reorg)
71//! atomically replaces the layer cache with a fresh empty cache that has the
72//! newly built overlay pre-installed. Side-chain blocks `[pivot+1 .. new_head]`
73//! are then executed via the normal `add_block` path; each block's reads
74//! cascade through the overlay and each commit adds a new forward layer.
75//! On the first commit the reconciliation step folds the overlay entries and
76//! the new layer together into a single atomic RocksDB write batch, then
77//! clears the overlay.
78
79use ethrex_common::{H256, types::BlockNumber};
80use fastbloom::AtomicBloomFilter;
81use rayon::prelude::*;
82use rustc_hash::{FxBuildHasher, FxHashMap};
83use std::{
84 fmt,
85 sync::{Arc, RwLock},
86};
87
88use ethrex_trie::{Nibbles, TrieDB, TrieError};
89
90use crate::{
91 api::{StorageBackend, tables::STATE_HISTORY},
92 error::StoreError,
93 journal::{JournalDecodeError, JournalEntry},
94 trie::classify_trie_key,
95};
96
97const BLOOM_SIZE: usize = 1_000_000;
98const FALSE_POSITIVE_RATE: f64 = 0.02;
99
100#[derive(Debug, Clone)]
101struct TrieLayer {
102 nodes: FxHashMap<Vec<u8>, Vec<u8>>,
103 parent: H256,
104 id: usize,
105 /// Number of the block whose post-state this layer represents. Used by the
106 /// journal write path so a commit can record the entry under the correct
107 /// block number (not the in-flight block whose insertion triggered the commit).
108 block_number: BlockNumber,
109 /// Hash of the block whose post-state this layer represents.
110 block_hash: H256,
111}
112
113/// In-memory cache of trie diff-layers, one per block (or batch in full sync), forming a
114/// newest->oldest chain via `parent` down to the on-disk state.
115///
116/// Disk commits are gated on a canonical safe-commit root (`safe_commit_root`): a layer is
117/// flushed only when that root is canonical and deep enough that it lands on the ancestor
118/// walk. `H256::zero()` means "no safe commit point yet", so nothing is flushed and the
119/// on-disk genesis state is never pruned. A global bloom filter short-circuits lookups for
120/// keys absent from every layer.
121#[derive(Clone)]
122pub struct TrieLayerCache {
123 /// Monotonically increasing ID for layers, starting at 1.
124 /// TODO: this implementation panics on overflow
125 last_id: usize,
126 /// Number of layers after which we should commit to the database.
127 pub(crate) commit_threshold: usize,
128 layers: FxHashMap<H256, Arc<TrieLayer>>,
129 /// Global bloom filter that tracks all keys across all layers.
130 ///
131 /// Used to avoid looking up all layers when the given path doesn't exist in any
132 /// layer, thus going directly to the database.
133 bloom: AtomicBloomFilter<FxBuildHasher>,
134 /// Optional in-memory overlay bridging on-disk state at the cache edge `D` to the
135 /// virtual state at a deep-reorg pivot. When installed, reads that miss the layer
136 /// chain consult the overlay before falling through to disk. `None` in steady state.
137 overlay: Option<Arc<Overlay>>,
138 /// The canonical safe-commit state root, computed by the Store after each forkchoice update.
139 ///
140 /// `H256::zero()` means "no safe commit point yet". Read by
141 /// [`get_commitable`](Self::get_commitable) to gate disk commits.
142 pub(crate) safe_commit_root: Arc<RwLock<H256>>,
143}
144
145impl fmt::Debug for TrieLayerCache {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 let safe_commit = match self.safe_commit_root.read() {
148 Ok(guard) => format!("{:?}", *guard),
149 Err(_) => "<poisoned>".to_string(),
150 };
151 f.debug_struct("TrieLayerCache")
152 .field("last_id", &self.last_id)
153 .field("commit_threshold", &self.commit_threshold)
154 .field("layers", &self.layers)
155 .field("bloom", &"AtomicBloomFilter")
156 .field("overlay", &self.overlay)
157 .field("safe_commit_root", &safe_commit)
158 .finish()
159 }
160}
161
162impl TrieLayerCache {
163 /// Creates a new cache with the given commit threshold and a shared safe-commit-root cell.
164 ///
165 /// The `safe_commit_root` Arc is shared with [`Store`](crate::Store) so that the Store
166 /// can update the cell without replacing the cache Arc.
167 /// `H256::zero()` in the cell means "no safe commit point yet".
168 pub fn new_with_safe_commit(
169 commit_threshold: usize,
170 safe_commit_root: Arc<RwLock<H256>>,
171 ) -> Self {
172 Self {
173 bloom: Self::create_filter(BLOOM_SIZE),
174 last_id: 0,
175 layers: Default::default(),
176 commit_threshold,
177 overlay: None,
178 safe_commit_root,
179 }
180 }
181
182 /// Installs an overlay on this cache. Subsequent reads that miss the layer chain
183 /// will consult the overlay before falling through to disk. Replaces any
184 /// previously-installed overlay.
185 pub fn set_overlay(&mut self, overlay: Arc<Overlay>) {
186 self.overlay = Some(overlay);
187 }
188
189 /// Removes any installed overlay. Idempotent.
190 pub fn clear_overlay(&mut self) {
191 self.overlay = None;
192 }
193
194 /// Returns a reference to the installed overlay, if any.
195 pub fn overlay(&self) -> Option<&Arc<Overlay>> {
196 self.overlay.as_ref()
197 }
198
199 /// Whether a reader at `state_root` should consult the installed overlay.
200 ///
201 /// The overlay reconstructs the pivot's state ([`Overlay::serves_root`]); the
202 /// new-chain layers built on top of it during replay live in `self.layers`. Only
203 /// those "consuming" roots may see overlay values ; every other reader (an
204 /// eth_call/getProof at the old cache-edge `D`, or any unrelated historical root)
205 /// must fall through to disk, which still holds that root's canonical state while
206 /// the overlay is alive. Returns `false` when no overlay is installed.
207 pub fn overlay_serves(&self, state_root: H256) -> bool {
208 self.overlay
209 .as_ref()
210 .is_some_and(|o| state_root == o.serves_root() || self.layers.contains_key(&state_root))
211 }
212
213 /// Looks up `key` in the installed overlay. Three-state return:
214 /// - `None` ; no overlay installed, or overlay does not contain the key. Caller
215 /// should fall through to disk.
216 /// - `Some(None)` ; overlay says the key did not exist at the pivot. Caller
217 /// should treat as missing without consulting disk (disk still holds the OLD
218 /// chain's value).
219 /// - `Some(Some(v))` ; overlay says the key had value `v` at the pivot. Caller
220 /// should return `v` without consulting disk.
221 ///
222 /// CF is determined by the key's length, matching `BackendTrieDB::table_for_key`.
223 pub fn lookup_overlay(&self, key: &[u8]) -> Option<Option<Vec<u8>>> {
224 let overlay = self.overlay.as_ref()?;
225 let cf = OverlayCf::classify_by_key_length(key.len());
226 overlay.lookup(cf, key)
227 }
228
229 /// Returns true if a layer with the given `state_root` is present in the cache.
230 /// Used by callers (engine API, deep-reorg orchestrator) to decide whether a
231 /// parent state is reachable through forward execution or requires overlay
232 /// construction.
233 pub fn contains(&self, state_root: H256) -> bool {
234 self.layers.contains_key(&state_root)
235 }
236
237 /// Returns this cache's commit threshold. Used by the deep-reorg path so a
238 /// freshly-constructed replacement cache inherits the same threshold.
239 pub fn commit_threshold(&self) -> usize {
240 self.commit_threshold
241 }
242
243 fn create_filter(expected_items: usize) -> AtomicBloomFilter<FxBuildHasher> {
244 AtomicBloomFilter::with_false_pos(FALSE_POSITIVE_RATE)
245 .hasher(FxBuildHasher)
246 .expected_items(expected_items.max(BLOOM_SIZE))
247 }
248
249 /// Looks up a trie node `key` starting from the layer identified by `state_root`,
250 /// walking the parent chain toward older layers.
251 ///
252 /// Returns `Some(value)` from the first (newest) layer that contains the key, or `None`
253 /// if no layer has it. A bloom filter is checked first to skip the walk entirely when the
254 /// key is guaranteed absent from all layers (callers then fall through to the on-disk trie).
255 pub fn get(&self, state_root: H256, key: &[u8]) -> Option<Vec<u8>> {
256 // Fast check to know if any layer may contain the given key.
257 // We can only be certain it doesn't exist, but if it returns true it may or may not exist (false positive).
258 if !self.bloom.contains(key) {
259 // TrieWrapper goes to db when returning None.
260 return None;
261 }
262
263 let mut current_state_root = state_root;
264
265 while let Some(layer) = self.layers.get(¤t_state_root) {
266 if let Some(value) = layer.nodes.get(key) {
267 return Some(value.clone());
268 }
269 current_state_root = layer.parent;
270 if current_state_root == state_root {
271 // TODO: check if this is possible in practice
272 // This can't happen in L1, due to system contracts irreversibly modifying state
273 // at each block.
274 // On L2, if no transactions are included in a block, the state root remains the same,
275 // but we handle that case in put_batch. It may happen, however, if someone modifies
276 // state with a privileged tx and later reverts it (since it doesn't update nonce).
277 panic!("State cycle found");
278 }
279 }
280 None
281 }
282
283 /// Determines whether a disk commit should happen by checking whether the safe-commit root
284 /// appears on the ancestor chain starting from `parent_state_root`.
285 ///
286 /// Returns `Some(safe_commit_root)` when the root is found on the ancestor walk; `None`
287 /// when the cell is zero, poisoned, or the root is not on the walk. The bounded-walk
288 /// cycle guard caps the walk at `layers.len()` steps to ensure termination.
289 pub fn get_commitable(&self, parent_state_root: H256) -> Option<H256> {
290 // (a) Read the safe-commit root; a poisoned lock is treated as "not ready".
291 let safe_root = *self.safe_commit_root.read().ok()?;
292 // (b) Zero means no safe commit point yet; commit nothing.
293 if safe_root.is_zero() {
294 return None;
295 }
296 // (c) The executed parent IS the safe-commit root. Still requires a layer: a
297 // canonical root need not have one (`put_batch` skips blocks whose state root equals
298 // their parent's, which on L2 is every empty block). Branch (d) gets this for free
299 // by walking `layers`.
300 if parent_state_root == safe_root {
301 return self.has_layer(safe_root).then_some(safe_root);
302 }
303 // (d) Walk the layer parent-chain from parent_state_root looking for safe_root.
304 let mut current = parent_state_root;
305 let mut steps = 0usize;
306 let max_steps = self.layers.len();
307 while let Some(layer) = self.layers.get(¤t) {
308 if current == safe_root {
309 return Some(safe_root);
310 }
311 let next = layer.parent;
312 // Cycle guard: if walking would return to the walk start, stop.
313 if next == parent_state_root {
314 return None;
315 }
316 steps += 1;
317 // Bounded-walk safeguard: a mid-chain cycle (e.g. B→C→B) would not be
318 // caught by the start-of-walk guard above. Capping at layers.len() steps
319 // ensures the loop always terminates.
320 if steps > max_steps {
321 return None;
322 }
323 current = next;
324 }
325 // (e) Reached chain bottom (root not in layers / already on disk) without matching safe_root.
326 None
327 }
328
329 /// Depth-only commit gate for single-canonical-chain re-execution (full sync, block
330 /// import, startup state regeneration).
331 ///
332 /// Walks the parent chain from `state_root`, counting layers, and returns the state root of
333 /// the layer that is `threshold` layers deep — committing purely by depth, ignoring the
334 /// canonical [`safe_commit_root`](Self::safe_commit_root) cell.
335 ///
336 /// Used only where the node extends a single canonical chain (these paths never execute
337 /// competing forks), so the non-canonical-commit hazard that the canonical gate guards
338 /// against cannot occur. The canonical gate keys on the `head - 128` safe-commit root, which
339 /// these paths never advance (nothing is canonicalized until a later forkchoice update), so
340 /// it would never flush during re-execution; this depth gate bounds memory instead.
341 pub(crate) fn get_commitable_by_depth(
342 &self,
343 mut state_root: H256,
344 threshold: usize,
345 ) -> Option<H256> {
346 let mut counter = 0;
347 while let Some(layer) = self.layers.get(&state_root) {
348 counter += 1;
349 if counter >= threshold {
350 return Some(state_root);
351 }
352 state_root = layer.parent;
353 }
354 None
355 }
356
357 /// Inserts a new diff-layer into the cache, keyed by `state_root` and pointing to `parent`.
358 ///
359 /// In regular sync each call adds one block's trie diffs. In full sync (batch mode), each
360 /// call adds diffs for an entire batch of ~1024 blocks.
361 ///
362 /// No-ops if `parent == state_root` (empty block with no state change), or if `state_root`
363 /// is already present (duplicate insertion guard).
364 pub fn put_batch(
365 &mut self,
366 parent: H256,
367 state_root: H256,
368 block_number: BlockNumber,
369 block_hash: H256,
370 key_values: Vec<(Nibbles, Vec<u8>)>,
371 ) {
372 if parent == state_root && key_values.is_empty() {
373 return;
374 } else if parent == state_root {
375 // L1 always changes the state root (system contracts run even on empty blocks), so
376 // this should not happen there. L2 can legitimately keep the same root on empty blocks
377 // because it has no system contract calls.
378 tracing::trace!("parent == state_root but key_values not empty");
379 return;
380 }
381 if self.layers.contains_key(&state_root) {
382 tracing::warn!("tried to insert a state_root that's already inserted");
383 return;
384 }
385
386 // Add keys to the global bloom filter
387 for (p, _) in &key_values {
388 self.bloom.insert(p.as_ref());
389 }
390
391 let nodes: FxHashMap<Vec<u8>, Vec<u8>> = key_values
392 .into_iter()
393 .map(|(path, value)| (path.into_vec(), value))
394 .collect();
395
396 self.last_id += 1;
397 let entry = TrieLayer {
398 nodes,
399 parent,
400 id: self.last_id,
401 block_number,
402 block_hash,
403 };
404 self.layers.insert(state_root, Arc::new(entry));
405 }
406
407 /// Rebuilds the global bloom filter from scratch using all keys across all remaining layers.
408 ///
409 /// Called after [`commit`](Self::commit) removes layers, since the old filter may contain
410 /// keys from the removed layers (producing unnecessary false positives).
411 pub fn rebuild_bloom(&mut self) {
412 // Pre-compute total keys for optimal filter sizing
413 let total_keys: usize = self.layers.values().map(|layer| layer.nodes.len()).sum();
414
415 let filter = Self::create_filter(total_keys.max(BLOOM_SIZE));
416
417 // Parallel insertion - AtomicBloomFilter allows concurrent insert via &self
418 self.layers.par_iter().for_each(|(_, layer)| {
419 for path in layer.nodes.keys() {
420 filter.insert(path);
421 }
422 });
423
424 self.bloom = filter;
425 }
426
427 /// Whether `state_root` has a diff layer. Not every canonical root does: [`Self::put_batch`]
428 /// skips blocks whose state root equals their parent's, and flushed roots are pruned. Callers
429 /// holding a root from outside the cache must check this before treating it as committable.
430 pub fn has_layer(&self, state_root: H256) -> bool {
431 self.layers.contains_key(&state_root)
432 }
433
434 /// Number of diff layers held in memory. Diagnostic only: distinguishes a pruned root from
435 /// an empty cache.
436 pub fn layer_count(&self) -> usize {
437 self.layers.len()
438 }
439
440 /// Returns the root of the oldest layer in the layer chain containing `root` — i.e.
441 /// walks parents until reaching a layer whose parent is not itself a layer. Returns
442 /// `root` unchanged if it has no layer.
443 ///
444 /// Used to force single-layer commits while a deep-reorg overlay is installed: the
445 /// The reconciliation in `commit_to_disk` is defined for exactly one layer at
446 /// the pivot tip `T`, and a multi-layer sweep would journal upper layers' pre-images
447 /// against the old-chain disk state instead of the new-chain/bridge state.
448 pub(crate) fn bottom_layer_root(&self, root: H256) -> H256 {
449 let mut current = root;
450 while let Some(layer) = self.layers.get(¤t) {
451 if !self.layers.contains_key(&layer.parent) {
452 break;
453 }
454 current = layer.parent;
455 }
456 current
457 }
458
459 /// Removes the layer at `state_root` and all its ancestors from the cache, returning
460 /// one [`CommittedLayer`] per removed layer in oldest-first order (suitable for
461 /// sequential disk write and per-block journaling).
462 ///
463 /// `state_root` must be a key in `self.layers` (as returned by
464 /// [`get_commitable`](Self::get_commitable) /
465 /// [`get_commitable_with_threshold`](Self::get_commitable_with_threshold)).
466 /// If it isn't, the walk exits immediately and returns `None`.
467 ///
468 /// After removal, any orphaned layers (older than the committed ones) are pruned, and
469 /// the bloom filter is rebuilt to remove stale entries.
470 ///
471 /// Normal block-by-block sync commits exactly one layer per call. Multi-layer commits
472 /// are legitimate for the forkchoice-driven flush of an accumulated backlog (e.g. block
473 /// import, which executes many blocks and then advances the safe-commit root once): each
474 /// committed layer keeps its own block identity and `parent_state_root`, so the caller can
475 /// write one journal entry per block rather than merging diffs across blocks.
476 pub fn commit(&mut self, state_root: H256) -> Option<Vec<CommittedLayer>> {
477 let mut layers_to_commit = vec![];
478 let mut current_state_root = state_root;
479 while let Some(layer) = self.layers.remove(¤t_state_root) {
480 let layer = Arc::unwrap_or_clone(layer);
481 current_state_root = layer.parent;
482 layers_to_commit.push(layer);
483 }
484 // `layers_to_commit` is built by walking parent links from `state_root`,
485 // so `.first()` is the newest layer (the one at `state_root` itself).
486 let top_layer_id = layers_to_commit.first()?.id;
487 // older layers are useless
488 self.layers.retain(|_, item| item.id > top_layer_id);
489 self.rebuild_bloom(); // layers removed, rebuild global bloom filter.
490 // Oldest-first: apply/journal in block order so per-block reverse diffs are
491 // correct and newer writes overwrite older ones on disk.
492 let committed = layers_to_commit
493 .into_iter()
494 .rev()
495 .map(|layer| CommittedLayer {
496 block_number: layer.block_number,
497 block_hash: layer.block_hash,
498 parent_state_root: layer.parent,
499 nodes: layer.nodes.into_iter().collect(),
500 })
501 .collect();
502 Some(committed)
503 }
504}
505
506/// One committed layer produced by [`TrieLayerCache::commit`]: a single block's identity plus
507/// the trie/flat-KV node diffs it wrote. Returned oldest-first so callers apply them in block
508/// order and journal each block separately.
509///
510/// `parent_state_root` is the state we'd return to on rollback (this block's pre-state).
511#[derive(Debug)]
512pub struct CommittedLayer {
513 /// Block number of the committed block.
514 pub block_number: BlockNumber,
515 /// Block hash of the committed block.
516 pub block_hash: H256,
517 /// Pre-state root of the committed block (the state to return to on rollback).
518 pub parent_state_root: H256,
519 /// Merged trie node updates in oldest-first order, ready for a sequential disk write.
520 pub nodes: Vec<(Vec<u8>, Vec<u8>)>,
521}
522
523/// [`TrieDB`] adapter that checks in-memory diff-layers ([`TrieLayerCache`]) first,
524/// falling back to the on-disk trie only for keys not found in any layer.
525///
526/// Used by the EVM during block execution: reads see the latest uncommitted state without
527/// waiting for a disk flush.
528pub struct TrieWrapper {
529 /// State root of the executing block; used as the starting point for the layer-chain walk.
530 pub state_root: H256,
531 /// Shared reference to the layer cache. Multiple `TrieWrapper` instances (per account/storage
532 /// trie) share the same cache within a single block execution context.
533 pub inner: Arc<TrieLayerCache>,
534 /// The underlying on-disk trie, consulted only when both the layer cache and the overlay miss.
535 pub db: Box<dyn TrieDB>,
536 /// Pre-computed prefix nibbles for storage tries.
537 /// For state tries this is None; for storage tries this is
538 /// `Nibbles::from_bytes(address.as_bytes()).append_new(17)`.
539 prefix_nibbles: Option<Nibbles>,
540}
541
542impl TrieWrapper {
543 /// Constructs a `TrieWrapper`. `prefix` is `Some(account_hash)` for storage tries;
544 /// pass `None` for the state trie.
545 pub fn new(
546 state_root: H256,
547 inner: Arc<TrieLayerCache>,
548 db: Box<dyn TrieDB>,
549 prefix: Option<H256>,
550 ) -> Self {
551 let prefix_nibbles = prefix.map(|p| Nibbles::from_bytes(p.as_bytes()).append_new(17));
552 Self {
553 state_root,
554 inner,
555 db,
556 prefix_nibbles,
557 }
558 }
559}
560
561/// Prepends an account address prefix (with an invalid nibble `17` as separator) to a
562/// trie path, distinguishing storage trie entries from state trie entries in the flat
563/// key-value namespace. Returns the path unchanged if `prefix` is `None` (state trie).
564pub fn apply_prefix(prefix: Option<H256>, path: Nibbles) -> Nibbles {
565 match prefix {
566 Some(prefix) => Nibbles::from_bytes(prefix.as_bytes())
567 .append_new(17)
568 .concat(&path),
569 None => path,
570 }
571}
572
573impl TrieDB for TrieWrapper {
574 fn flatkeyvalue_computed(&self, key: Nibbles) -> bool {
575 // While a deep-reorg overlay serves this root, flat-KV leaf reads must not
576 // trust disk: journal entries written while the FKV generator was running
577 // are permanently missing pre-images for keys past the generator frontier,
578 // and disk flat-KV may hold the generator's value for the chain
579 // being reorged away. Force the trie-node read path instead — trie nodes
580 // are always journaled, so the overlay reconstructs them completely.
581 if self.inner.overlay_serves(self.state_root) {
582 return false;
583 }
584 // NOTE: we apply the prefix here, since the underlying TrieDB should
585 // always be for the state trie.
586 let key = match &self.prefix_nibbles {
587 Some(prefix) => prefix.concat(&key),
588 None => key,
589 };
590 self.db.flatkeyvalue_computed(key)
591 }
592
593 fn get(&self, key: Nibbles) -> Result<Option<Vec<u8>>, TrieError> {
594 let key = match &self.prefix_nibbles {
595 Some(prefix) => prefix.concat(&key),
596 None => key,
597 };
598 // Read cascade: forward layer cache (new-chain layers above the pivot) ->
599 // overlay (reverse-diff bridge to disk during deep reorgs, if installed) ->
600 // on-disk state. A layer-cache hit pre-empts the overlay because a
601 // side-chain write at this key supersedes the pivot value the overlay holds.
602 // An overlay hit pre-empts disk because disk still reflects the OLD chain's
603 // edge `D`, not the pivot.
604 if let Some(value) = self.inner.get(self.state_root, key.as_ref()) {
605 return Ok(Some(value));
606 }
607 // Overlay gate: the overlay reconstructs the pivot's state and the new-chain
608 // layers built on top of it. Only a reader at a consuming root (the pivot or one
609 // of those layer roots) may see it; an eth_call/getProof at the old cache-edge
610 // `D` or an unrelated historical root must fall through to disk, which is
611 // unchanged during the overlay window. See [`TrieLayerCache::overlay_serves`].
612 if self.inner.overlay_serves(self.state_root)
613 && let Some(overlay_result) = self.inner.lookup_overlay(key.as_ref())
614 {
615 return Ok(overlay_result);
616 }
617 self.db.get(key)
618 }
619
620 fn multi_get(&self, keys: &[Nibbles]) -> Vec<Result<Option<Vec<u8>>, TrieError>> {
621 // Walk the same cascade `get` does, per key: layer cache -> overlay ->
622 // disk. Only the keys that miss both in-memory stages are batched into a
623 // single `db.multi_get` call. Skipping the overlay stage here would serve
624 // the OLD chain's disk nodes (and resurrect keys the overlay reports as
625 // absent at the pivot) for every batched reader.
626 let mut results: Vec<Option<Result<Option<Vec<u8>>, TrieError>>> =
627 (0..keys.len()).map(|_| None).collect();
628 let mut miss_indices = Vec::new();
629 let mut miss_keys = Vec::new();
630 let overlay_serves = self.inner.overlay_serves(self.state_root);
631
632 for (idx, key) in keys.iter().enumerate() {
633 let key = match &self.prefix_nibbles {
634 Some(prefix) => prefix.concat(key),
635 None => key.clone(),
636 };
637 if let Some(value) = self.inner.get(self.state_root, key.as_ref()) {
638 results[idx] = Some(Ok(Some(value)));
639 } else if overlay_serves
640 && let Some(overlay_result) = self.inner.lookup_overlay(key.as_ref())
641 {
642 // `Some(None)` means absent at the pivot: disk still holds the old
643 // chain's value for this key, so it must not be consulted.
644 results[idx] = Some(Ok(overlay_result));
645 } else {
646 miss_indices.push(idx);
647 miss_keys.push(key);
648 }
649 }
650
651 if !miss_keys.is_empty() {
652 let db_results = self.db.multi_get(&miss_keys);
653 for (idx, res) in miss_indices.into_iter().zip(db_results) {
654 results[idx] = Some(res);
655 }
656 }
657
658 results
659 .into_iter()
660 .map(|r| r.expect("every key is resolved in-memory or batched as a db miss"))
661 .collect()
662 }
663
664 fn put_batch(&self, _key_values: Vec<(Nibbles, Vec<u8>)>) -> Result<(), TrieError> {
665 // TODO: Get rid of this.
666 unimplemented!("This function should not be called");
667 }
668}
669
670// ===========================================================================
671// Overlay ; in-memory aggregated reverse-diff used during deep reorgs.
672// ===========================================================================
673
674/// Identifier of which on-disk column family an [`Overlay`] entry targets.
675#[derive(Debug, Clone, Copy, PartialEq, Eq)]
676pub enum OverlayCf {
677 /// Non-leaf nodes of the account/state trie (`ACCOUNT_TRIE_NODES` CF, key length < 65).
678 AccountTrie,
679 /// Non-leaf nodes of storage tries (`STORAGE_TRIE_NODES` CF, key length 66-130).
680 StorageTrie,
681 /// Leaf entries of the account flat-KV table (`ACCOUNT_FLATKEYVALUE` CF, key length 65).
682 AccountFlat,
683 /// Leaf entries of the storage flat-KV table (`STORAGE_FLATKEYVALUE` CF, key length 131).
684 StorageFlat,
685}
686
687impl OverlayCf {
688 /// Classifies an on-disk key into its CF based on length, matching the rule in
689 /// `BackendTrieDB::table_for_key` / `classify_trie_key`:
690 /// - `len == 65` -> `AccountFlat` (account leaf)
691 /// - `len == 131` -> `StorageFlat` (storage leaf, includes 32-byte account prefix)
692 /// - `len < 65` -> `AccountTrie` (non-leaf state-trie node)
693 /// - otherwise -> `StorageTrie` (non-leaf storage-trie node)
694 pub fn classify_by_key_length(len: usize) -> Self {
695 let (is_leaf, is_account) = classify_trie_key(len);
696 match (is_leaf, is_account) {
697 (true, true) => OverlayCf::AccountFlat,
698 (true, false) => OverlayCf::StorageFlat,
699 (false, true) => OverlayCf::AccountTrie,
700 (false, false) => OverlayCf::StorageTrie,
701 }
702 }
703}
704
705/// Errors produced while constructing an [`Overlay`] from the on-disk journal.
706#[derive(Debug, thiserror::Error)]
707pub enum OverlayError {
708 #[error("missing journal entry for block {0}")]
709 MissingEntry(BlockNumber),
710 #[error(
711 "journal block_hash mismatch at block {block_number}: expected {expected:?}, found {found:?}"
712 )]
713 HashMismatch {
714 block_number: BlockNumber,
715 expected: H256,
716 found: H256,
717 },
718 #[error("invalid overlay range: from_block ({from_block}) < to_block ({to_block})")]
719 InvalidRange {
720 from_block: BlockNumber,
721 to_block: BlockNumber,
722 },
723 #[error("journal decode error: {0}")]
724 Decode(#[from] JournalDecodeError),
725 #[error("storage error: {0}")]
726 Store(#[from] StoreError),
727}
728
729/// In-memory aggregated reverse-diff bridging the on-disk state at the cache edge `D`
730/// to the virtual state at a deep-reorg pivot `T-1`.
731///
732/// Built once per deep reorg by replaying [`STATE_HISTORY`] entries for blocks
733/// `D, D-1, ..., T` in descending order. Subsequent state reads during side-chain
734/// execution cascade as: new layer cache -> overlay -> on-disk state. On-disk state
735/// is NOT mutated while the overlay is alive; disk stays at `D` until the first
736/// new-chain commit folds the overlay and the new layer together into a single
737/// atomic write (the reconciliation step in `commit_to_disk`).
738pub struct Overlay {
739 account_trie: FxHashMap<Vec<u8>, Option<Vec<u8>>>,
740 storage_trie: FxHashMap<Vec<u8>, Option<Vec<u8>>>,
741 account_flat: FxHashMap<Vec<u8>, Option<Vec<u8>>>,
742 storage_flat: FxHashMap<Vec<u8>, Option<Vec<u8>>>,
743 /// Bloom filter shared across all four CFs. A miss here lets readers skip the
744 /// overlay lookup and fall through to disk without touching any map.
745 bloom: AtomicBloomFilter<FxBuildHasher>,
746 /// Highest block number covered by the overlay (= cache edge `D` at install time).
747 from_block: BlockNumber,
748 /// Lowest block number covered by the overlay (= `pivot + 1`).
749 to_block: BlockNumber,
750 /// State root the overlay reconstructs: the state as of `to_block - 1` (the pivot).
751 /// Captured from the `parent_state_root` of the journal entry at `to_block`. Used by
752 /// the read cascade to gate overlay consultation to the pivot root (and, transitively,
753 /// the new-chain layer roots built on top of it) so unrelated readers of the shared
754 /// cache do not get pivot values in place of the on-disk canonical state.
755 /// `H256::zero()` for a default/empty overlay (never a real reconstructed root).
756 serves_root: H256,
757}
758
759impl fmt::Debug for Overlay {
760 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
761 f.debug_struct("Overlay")
762 .field("account_trie_len", &self.account_trie.len())
763 .field("storage_trie_len", &self.storage_trie.len())
764 .field("account_flat_len", &self.account_flat.len())
765 .field("storage_flat_len", &self.storage_flat.len())
766 .field("from_block", &self.from_block)
767 .field("to_block", &self.to_block)
768 .finish()
769 }
770}
771
772impl Default for Overlay {
773 fn default() -> Self {
774 Self {
775 account_trie: FxHashMap::default(),
776 storage_trie: FxHashMap::default(),
777 account_flat: FxHashMap::default(),
778 storage_flat: FxHashMap::default(),
779 bloom: AtomicBloomFilter::with_false_pos(FALSE_POSITIVE_RATE)
780 .hasher(FxBuildHasher)
781 .expected_items(Self::BLOOM_INITIAL_CAPACITY),
782 from_block: 0,
783 to_block: 0,
784 serves_root: H256::zero(),
785 }
786 }
787}
788
789impl Overlay {
790 /// Expected-items hint used to size the bloom filter at construction time.
791 /// Sized for typical reorg depths (tens to low-hundreds of blocks); the filter
792 /// will still function past this count, just with a higher false-positive rate.
793 const BLOOM_INITIAL_CAPACITY: usize = 64 * 1024;
794
795 /// Builds an overlay by replaying journal entries for blocks `[to_block, from_block]`
796 /// (inclusive both ends) in descending order. Each loaded entry's `block_hash` is
797 /// verified against `expected_hash(n)`; a mismatch aborts with
798 /// [`OverlayError::HashMismatch`].
799 ///
800 /// `expected_hash` is a callback that maps a height to the hash of the canonical
801 /// block at that height on the chain being unwound. Returning `None` skips
802 /// verification at that height (useful for tests).
803 ///
804 /// Within a single key, the OLDEST recorded `prev` value wins ; later inserts
805 /// during the descending walk overwrite earlier ones, so the value at `to_block - 1`
806 /// (whatever the oldest in-range journal entry recorded as the pre-image) is what
807 /// remains after the walk.
808 pub fn from_journal(
809 backend: &dyn StorageBackend,
810 from_block: BlockNumber,
811 to_block: BlockNumber,
812 expected_hash: impl Fn(BlockNumber) -> Option<H256>,
813 ) -> Result<Self, OverlayError> {
814 // Hard guard (not debug-only): swapped arguments would underflow `n -= 1`
815 // below in release builds and loop indefinitely.
816 if from_block < to_block {
817 return Err(OverlayError::InvalidRange {
818 from_block,
819 to_block,
820 });
821 }
822 let mut overlay = Overlay {
823 from_block,
824 to_block,
825 ..Default::default()
826 };
827
828 // SAFETY: `StorageReadView` does not guarantee snapshot isolation on RocksDB.
829 // The only writer to STATE_HISTORY is `forkchoice_update_inner` (finality
830 // pruning); a concurrent FCU `delete_range` between two `.get()` calls below
831 // could cause a spurious `MissingEntry`. This is prevented by the
832 // reorg-in-progress guard: `Blockchain::enter_reorg` holds
833 // `Store::set_journal_pruning_paused(true)` for the whole apply pass, so
834 // pruning is deferred while the overlay is constructed (it catches up on
835 // the next finality advance after the pass).
836 let read = backend.begin_read()?;
837 let mut n = from_block;
838 loop {
839 let bytes = read
840 .get(STATE_HISTORY, &n.to_be_bytes())?
841 .ok_or(OverlayError::MissingEntry(n))?;
842 let entry = JournalEntry::decode(&bytes)?;
843 if let Some(expected) = expected_hash(n)
844 && entry.block_hash != expected
845 {
846 return Err(OverlayError::HashMismatch {
847 block_number: n,
848 expected,
849 found: entry.block_hash,
850 });
851 }
852 // The entry at `to_block` unwinds `to_block -> to_block - 1`, so its
853 // `parent_state_root` is the state root the overlay reconstructs (the pivot).
854 if n == to_block {
855 overlay.serves_root = entry.parent_state_root;
856 }
857 overlay.absorb(entry);
858 if n == to_block {
859 break;
860 }
861 n -= 1;
862 }
863 Ok(overlay)
864 }
865
866 /// Absorbs one journal entry into the overlay. Later inserts overwrite earlier
867 /// ones ; combined with a descending walk in [`Self::from_journal`], this makes
868 /// the OLDEST in-range entry's `prev` value win, which is the correct value at
869 /// the pivot.
870 fn absorb(&mut self, entry: JournalEntry) {
871 for (k, v) in entry.account_trie_diff {
872 self.bloom.insert(&k);
873 self.account_trie.insert(k, v);
874 }
875 for (k, v) in entry.storage_trie_diff {
876 self.bloom.insert(&k);
877 self.storage_trie.insert(k, v);
878 }
879 for (k, v) in entry.account_flat_diff {
880 self.bloom.insert(&k);
881 self.account_flat.insert(k, v);
882 }
883 for (k, v) in entry.storage_flat_diff {
884 self.bloom.insert(&k);
885 self.storage_flat.insert(k, v);
886 }
887 }
888
889 /// Looks up `key` in the overlay's `cf` slot. Three-state return:
890 /// - `None` ; key not in overlay (caller falls through to disk).
891 /// - `Some(None)` ; key was overwritten and previously didn't exist on disk
892 /// (caller treats as absent ; a rollback would delete it).
893 /// - `Some(Some(v))` ; key was overwritten and previously had value `v` on disk
894 /// (caller treats as `v` ; a rollback would restore it).
895 pub fn lookup(&self, cf: OverlayCf, key: &[u8]) -> Option<Option<Vec<u8>>> {
896 if !self.bloom.contains(key) {
897 return None;
898 }
899 let map = match cf {
900 OverlayCf::AccountTrie => &self.account_trie,
901 OverlayCf::StorageTrie => &self.storage_trie,
902 OverlayCf::AccountFlat => &self.account_flat,
903 OverlayCf::StorageFlat => &self.storage_flat,
904 };
905 map.get(key).cloned()
906 }
907
908 /// Total number of overlay entries across all four CFs.
909 pub fn len(&self) -> usize {
910 self.account_trie.len()
911 + self.storage_trie.len()
912 + self.account_flat.len()
913 + self.storage_flat.len()
914 }
915
916 /// Whether the overlay holds any entries.
917 #[allow(dead_code)]
918 pub fn is_empty(&self) -> bool {
919 self.len() == 0
920 }
921
922 /// Approximate byte size of the overlay's key+value data. O(N) over entries;
923 /// intended for one-shot install-time metric emission, NOT per-lookup.
924 pub fn byte_size(&self) -> usize {
925 [
926 &self.account_trie,
927 &self.storage_trie,
928 &self.account_flat,
929 &self.storage_flat,
930 ]
931 .iter()
932 .flat_map(|map| map.iter())
933 .map(|(k, v)| k.len() + v.as_ref().map_or(0, |v| v.len()))
934 .sum()
935 }
936
937 /// Highest block number covered by the overlay (= cache edge `D` at install time).
938 #[allow(
939 clippy::wrong_self_convention,
940 reason = "field accessor: name matches struct field"
941 )]
942 pub fn from_block(&self) -> BlockNumber {
943 self.from_block
944 }
945
946 /// State root the overlay reconstructs (the pivot's state, as of `to_block - 1`).
947 /// The read cascade consults the overlay only for this root and the new-chain
948 /// layer roots derived from it; see [`TrieWrapper::get`].
949 pub fn serves_root(&self) -> H256 {
950 self.serves_root
951 }
952
953 /// Lowest block number covered by the overlay (= `pivot + 1`).
954 pub fn to_block(&self) -> BlockNumber {
955 self.to_block
956 }
957
958 /// Iterates every overlay entry across the four CFs as `(cf, key, value)`. Used
959 /// by the reconciliation step in `commit_to_disk` to fold overlay-only entries
960 /// into the first new-chain commit.
961 pub fn iter_all_entries(
962 &self,
963 ) -> impl Iterator<Item = (OverlayCf, &Vec<u8>, &Option<Vec<u8>>)> {
964 self.account_trie
965 .iter()
966 .map(|(k, v)| (OverlayCf::AccountTrie, k, v))
967 .chain(
968 self.storage_trie
969 .iter()
970 .map(|(k, v)| (OverlayCf::StorageTrie, k, v)),
971 )
972 .chain(
973 self.account_flat
974 .iter()
975 .map(|(k, v)| (OverlayCf::AccountFlat, k, v)),
976 )
977 .chain(
978 self.storage_flat
979 .iter()
980 .map(|(k, v)| (OverlayCf::StorageFlat, k, v)),
981 )
982 }
983}
984
985#[cfg(test)]
986mod overlay_tests {
987 use super::*;
988 use crate::backend::in_memory::InMemoryBackend;
989 use crate::journal::FlatDiff;
990
991 fn h(b: u8) -> H256 {
992 H256::repeat_byte(b)
993 }
994
995 /// Seeds N journal entries directly into STATE_HISTORY so tests can drive overlay
996 /// construction without going through the full block-execution path.
997 fn seed(backend: &Arc<dyn StorageBackend>, per_block: &[(BlockNumber, H256, FlatDiff)]) {
998 let mut tx = backend.begin_write().unwrap();
999 for (n, block_hash, diff) in per_block {
1000 let entry = JournalEntry {
1001 block_hash: *block_hash,
1002 parent_state_root: H256::zero(),
1003 account_trie_diff: diff.clone(),
1004 storage_trie_diff: vec![],
1005 account_flat_diff: vec![],
1006 storage_flat_diff: vec![],
1007 };
1008 tx.put(STATE_HISTORY, &n.to_be_bytes(), &entry.encode())
1009 .unwrap();
1010 }
1011 tx.commit().unwrap();
1012 }
1013
1014 #[test]
1015 fn from_journal_loads_descending_range() {
1016 let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::open().unwrap());
1017 seed(
1018 &backend,
1019 &[
1020 (3, h(0x03), vec![(vec![0xa], Some(vec![0x33]))]),
1021 (4, h(0x04), vec![(vec![0xb], Some(vec![0x44]))]),
1022 (5, h(0x05), vec![(vec![0xc], Some(vec![0x55]))]),
1023 ],
1024 );
1025 let overlay =
1026 Overlay::from_journal(backend.as_ref(), 5, 3, |n| Some(H256::repeat_byte(n as u8)))
1027 .unwrap();
1028 assert_eq!(overlay.len(), 3);
1029 assert_eq!(overlay.from_block(), 5);
1030 assert_eq!(overlay.to_block(), 3);
1031 assert_eq!(
1032 overlay.lookup(OverlayCf::AccountTrie, &[0xa]),
1033 Some(Some(vec![0x33]))
1034 );
1035 assert_eq!(
1036 overlay.lookup(OverlayCf::AccountTrie, &[0xb]),
1037 Some(Some(vec![0x44]))
1038 );
1039 assert_eq!(
1040 overlay.lookup(OverlayCf::AccountTrie, &[0xc]),
1041 Some(Some(vec![0x55]))
1042 );
1043 }
1044
1045 /// Block 3 (oldest) recorded K=X. Block 5 (newest) recorded K=Y4. After
1046 /// descending walk, the overlay must expose K=X ; the value at the pivot
1047 /// (= to_block - 1 = 2).
1048 #[test]
1049 fn older_entry_wins_when_key_repeats() {
1050 let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::open().unwrap());
1051 seed(
1052 &backend,
1053 &[
1054 (3, h(0x03), vec![(vec![0xaa], Some(b"X".to_vec()))]),
1055 (4, h(0x04), vec![(vec![0xaa], Some(b"Y3".to_vec()))]),
1056 (5, h(0x05), vec![(vec![0xaa], Some(b"Y4".to_vec()))]),
1057 ],
1058 );
1059 let overlay =
1060 Overlay::from_journal(backend.as_ref(), 5, 3, |n| Some(H256::repeat_byte(n as u8)))
1061 .unwrap();
1062 assert_eq!(
1063 overlay.lookup(OverlayCf::AccountTrie, &[0xaa]),
1064 Some(Some(b"X".to_vec())),
1065 "oldest reverse-diff value should win after descending walk"
1066 );
1067 }
1068
1069 #[test]
1070 fn absent_key_passes_through_bloom() {
1071 let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::open().unwrap());
1072 seed(
1073 &backend,
1074 &[(3, h(0x03), vec![(vec![0xaa], Some(vec![0x11]))])],
1075 );
1076 let overlay =
1077 Overlay::from_journal(backend.as_ref(), 3, 3, |n| Some(H256::repeat_byte(n as u8)))
1078 .unwrap();
1079 assert_eq!(overlay.lookup(OverlayCf::AccountTrie, &[0xff]), None);
1080 }
1081
1082 #[test]
1083 fn hash_mismatch_aborts() {
1084 let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::open().unwrap());
1085 seed(&backend, &[(7, h(0x07), vec![(vec![0xaa], None)])]);
1086 // Caller supplies the WRONG expected hash for height 7.
1087 let err = Overlay::from_journal(backend.as_ref(), 7, 7, |_| Some(h(0xff))).unwrap_err();
1088 match err {
1089 OverlayError::HashMismatch { block_number, .. } => assert_eq!(block_number, 7),
1090 other => panic!("expected HashMismatch, got {other:?}"),
1091 }
1092 }
1093
1094 #[test]
1095 fn missing_entry_aborts() {
1096 let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::open().unwrap());
1097 // Seed only block 5; ask for [5, 3] ; blocks 4 and 3 are missing.
1098 seed(&backend, &[(5, h(0x05), vec![])]);
1099 let err = Overlay::from_journal(backend.as_ref(), 5, 3, |_| None).unwrap_err();
1100 match err {
1101 OverlayError::MissingEntry(n) => assert_eq!(n, 4),
1102 other => panic!("expected MissingEntry, got {other:?}"),
1103 }
1104 }
1105
1106 #[test]
1107 fn skip_verification_when_callback_returns_none() {
1108 let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::open().unwrap());
1109 seed(&backend, &[(7, h(0xab), vec![(vec![0x01], None)])]);
1110 let overlay = Overlay::from_journal(backend.as_ref(), 7, 7, |_| None).unwrap();
1111 assert_eq!(overlay.lookup(OverlayCf::AccountTrie, &[0x01]), Some(None));
1112 }
1113
1114 #[test]
1115 fn classify_by_key_length_matches_backend_table_routing() {
1116 // Spot-check the boundaries. These must agree with `classify_trie_key`
1117 // (account leaf at 65, storage leaf at 131, anything else routed by length).
1118 assert_eq!(OverlayCf::classify_by_key_length(0), OverlayCf::AccountTrie);
1119 assert_eq!(
1120 OverlayCf::classify_by_key_length(64),
1121 OverlayCf::AccountTrie
1122 );
1123 assert_eq!(
1124 OverlayCf::classify_by_key_length(65),
1125 OverlayCf::AccountFlat
1126 );
1127 assert_eq!(
1128 OverlayCf::classify_by_key_length(66),
1129 OverlayCf::StorageTrie
1130 );
1131 assert_eq!(
1132 OverlayCf::classify_by_key_length(130),
1133 OverlayCf::StorageTrie
1134 );
1135 assert_eq!(
1136 OverlayCf::classify_by_key_length(131),
1137 OverlayCf::StorageFlat
1138 );
1139 assert_eq!(
1140 OverlayCf::classify_by_key_length(132),
1141 OverlayCf::StorageTrie
1142 );
1143 }
1144
1145 /// `serves_root` is the reconstructed pivot root, taken from the `parent_state_root`
1146 /// of the entry at `to_block` (the deepest in-range entry) ; NOT the `from_block`
1147 /// entry. Proves the capture picks the right end of the descending walk.
1148 #[test]
1149 fn from_journal_captures_serves_root_from_to_block_entry() {
1150 let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::open().unwrap());
1151 let pivot = h(0x77);
1152 let mut tx = backend.begin_write().unwrap();
1153 for (n, psr) in [(3u64, pivot), (4, h(0x44)), (5, h(0x55))] {
1154 let entry = JournalEntry {
1155 block_hash: h(n as u8),
1156 parent_state_root: psr,
1157 account_trie_diff: vec![(vec![n as u8], Some(vec![n as u8]))],
1158 storage_trie_diff: vec![],
1159 account_flat_diff: vec![],
1160 storage_flat_diff: vec![],
1161 };
1162 tx.put(STATE_HISTORY, &n.to_be_bytes(), &entry.encode())
1163 .unwrap();
1164 }
1165 tx.commit().unwrap();
1166 // Range [to_block=3, from_block=5]; serves_root must be entry-3's parent root.
1167 let overlay = Overlay::from_journal(backend.as_ref(), 5, 3, |_| None).unwrap();
1168 assert_eq!(overlay.serves_root(), pivot);
1169 }
1170
1171 /// The overlay must only be consulted by readers at a "consuming" root ; the pivot
1172 /// (`serves_root`) or a new-chain layer root present in the cache. Unrelated roots
1173 /// (old-chain edge `D`, historical/RPC reads) must fall through to disk. Regression
1174 /// for the review finding "Overlay Applies Across Roots".
1175 #[test]
1176 fn overlay_serves_only_consuming_roots() {
1177 let pivot = h(0xaa);
1178 let new_chain = h(0xbb);
1179 let unrelated = h(0xcc);
1180 let parent = h(0xa9);
1181
1182 let mut cache =
1183 TrieLayerCache::new_with_safe_commit(128, Arc::new(RwLock::new(H256::zero())));
1184
1185 // No overlay installed -> never serves.
1186 assert!(!cache.overlay_serves(pivot));
1187
1188 // Register a new-chain layer at `new_chain` (a replay commit).
1189 cache.put_batch(
1190 parent,
1191 new_chain,
1192 1,
1193 h(0xb1),
1194 vec![(Nibbles::from_bytes(&[0x01]), vec![0x02])],
1195 );
1196
1197 // Install an overlay reconstructing the pivot state.
1198 let overlay = Overlay {
1199 serves_root: pivot,
1200 from_block: 5,
1201 to_block: 3,
1202 ..Default::default()
1203 };
1204 cache.set_overlay(Arc::new(overlay));
1205
1206 assert!(
1207 cache.overlay_serves(pivot),
1208 "pivot root must consume the overlay"
1209 );
1210 assert!(
1211 cache.overlay_serves(new_chain),
1212 "new-chain layer root must consume the overlay"
1213 );
1214 assert!(
1215 !cache.overlay_serves(unrelated),
1216 "unrelated root must NOT see the overlay (would leak pivot state over disk)"
1217 );
1218
1219 // Clearing the overlay disables consumption for every root.
1220 cache.clear_overlay();
1221 assert!(!cache.overlay_serves(pivot));
1222 assert!(!cache.overlay_serves(new_chain));
1223 }
1224
1225 /// While an overlay serves the read's state root, `flatkeyvalue_computed`
1226 /// must return false so `Trie::get` walks the (always journaled) trie nodes
1227 /// instead of trusting disk flat-KV, which may hold the generator's stale,
1228 /// unjournaled values. Roots the overlay does not serve must be unaffected.
1229 #[test]
1230 fn flatkeyvalue_computed_is_disabled_while_overlay_serves() {
1231 struct AlwaysComputedDb;
1232 impl TrieDB for AlwaysComputedDb {
1233 fn flatkeyvalue_computed(&self, _key: Nibbles) -> bool {
1234 true
1235 }
1236 fn get(&self, _key: Nibbles) -> Result<Option<Vec<u8>>, TrieError> {
1237 Ok(None)
1238 }
1239 fn put_batch(&self, _key_values: Vec<(Nibbles, Vec<u8>)>) -> Result<(), TrieError> {
1240 unimplemented!()
1241 }
1242 }
1243
1244 let pivot = h(0xaa);
1245 let unrelated = h(0xcc);
1246 let key = Nibbles::from_bytes(&[0x01; 32]);
1247
1248 let mut cache =
1249 TrieLayerCache::new_with_safe_commit(128, Arc::new(RwLock::new(H256::zero())));
1250 cache.set_overlay(Arc::new(Overlay {
1251 serves_root: pivot,
1252 ..Default::default()
1253 }));
1254 let cache = Arc::new(cache);
1255
1256 let served = TrieWrapper::new(pivot, cache.clone(), Box::new(AlwaysComputedDb), None);
1257 assert!(
1258 !served.flatkeyvalue_computed(key.clone()),
1259 "served root must not trust disk flat-KV"
1260 );
1261
1262 let unserved = TrieWrapper::new(unrelated, cache.clone(), Box::new(AlwaysComputedDb), None);
1263 assert!(
1264 unserved.flatkeyvalue_computed(key.clone()),
1265 "unserved root must keep the flat-KV fast path"
1266 );
1267
1268 // Same for storage tries (prefixed wrapper).
1269 let served_storage =
1270 TrieWrapper::new(pivot, cache, Box::new(AlwaysComputedDb), Some(h(0x01)));
1271 assert!(
1272 !served_storage.flatkeyvalue_computed(key),
1273 "served storage root must not trust disk flat-KV"
1274 );
1275 }
1276
1277 /// `multi_get` must resolve every key through the same cascade as `get`: layer
1278 /// cache -> overlay -> disk.
1279 ///
1280 /// While an overlay serves the read's state root, disk still holds the old chain's
1281 /// edge `D`, so a batched read that skips the overlay answers with nodes from the
1282 /// chain that was reorged away, and reports keys the overlay knows were absent at
1283 /// the pivot as present. `Trie::prefetch_sorted` installs whatever it reads into
1284 /// the trie arena under the hash the reference already carried, so those nodes are
1285 /// merkleized without complaint and the block's state root comes out wrong.
1286 #[test]
1287 fn multi_get_walks_the_same_cascade_as_get() {
1288 struct DiskDb;
1289 impl TrieDB for DiskDb {
1290 fn get(&self, key: Nibbles) -> Result<Option<Vec<u8>>, TrieError> {
1291 Ok(Some([b"disk-".as_slice(), key.as_ref()].concat()))
1292 }
1293 fn put_batch(&self, _key_values: Vec<(Nibbles, Vec<u8>)>) -> Result<(), TrieError> {
1294 unimplemented!()
1295 }
1296 }
1297
1298 let pivot = h(0xaa);
1299 let unrelated = h(0xcc);
1300 // 4-nibble paths land in the account-trie CF (see `classify_by_key_length`).
1301 let overlay_hit = Nibbles::from_raw(&[0xab, 0xcd], false);
1302 let absent_at_pivot = Nibbles::from_raw(&[0x12, 0x34], false);
1303 let overlay_miss = Nibbles::from_raw(&[0x56, 0x78], false);
1304
1305 let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::open().unwrap());
1306 seed(
1307 &backend,
1308 &[(
1309 1,
1310 h(0x01),
1311 vec![
1312 (overlay_hit.as_ref().to_vec(), Some(b"pivot-value".to_vec())),
1313 (absent_at_pivot.as_ref().to_vec(), None),
1314 ],
1315 )],
1316 );
1317 let mut overlay = Overlay::from_journal(backend.as_ref(), 1, 1, |_| None).unwrap();
1318 overlay.serves_root = pivot;
1319
1320 let mut cache =
1321 TrieLayerCache::new_with_safe_commit(128, Arc::new(RwLock::new(H256::zero())));
1322 cache.set_overlay(Arc::new(overlay));
1323 let cache = Arc::new(cache);
1324
1325 let keys = [overlay_hit, absent_at_pivot, overlay_miss.clone()];
1326
1327 let served = TrieWrapper::new(pivot, cache.clone(), Box::new(DiskDb), None);
1328 let batched: Vec<_> = served
1329 .multi_get(&keys)
1330 .into_iter()
1331 .map(|r| r.unwrap())
1332 .collect();
1333 let serial: Vec<_> = keys
1334 .iter()
1335 .map(|k| served.get(k.clone()).unwrap())
1336 .collect();
1337 assert_eq!(batched, serial, "batched reads must match serial reads");
1338 assert_eq!(batched[0], Some(b"pivot-value".to_vec()));
1339 assert_eq!(
1340 batched[1], None,
1341 "absent at pivot must not fall through to the old chain's disk node"
1342 );
1343 assert_eq!(
1344 batched[2],
1345 Some([b"disk-".as_slice(), overlay_miss.as_ref()].concat()),
1346 "overlay miss falls through to disk"
1347 );
1348
1349 // A root the overlay does not serve reads disk for every key, batched or not.
1350 let unserved = TrieWrapper::new(unrelated, cache, Box::new(DiskDb), None);
1351 let batched: Vec<_> = unserved
1352 .multi_get(&keys)
1353 .into_iter()
1354 .map(|r| r.unwrap())
1355 .collect();
1356 let serial: Vec<_> = keys
1357 .iter()
1358 .map(|k| unserved.get(k.clone()).unwrap())
1359 .collect();
1360 assert_eq!(batched, serial);
1361 assert!(
1362 batched
1363 .iter()
1364 .all(|v| v.as_ref().is_some_and(|b| b.starts_with(b"disk-"))),
1365 "unserved root must not see overlay values"
1366 );
1367 }
1368
1369 /// `lookup_overlay` is the entry point from the read cascade. It must short-circuit
1370 /// to `None` when no overlay is installed, regardless of key length.
1371 #[test]
1372 fn overlay_lookup_returns_none_when_no_overlay_installed() {
1373 let cache = TrieLayerCache::new_with_safe_commit(1, Arc::new(RwLock::new(H256::zero())));
1374 for key_len in [4usize, 65, 67, 131] {
1375 let key = vec![0xab; key_len];
1376 assert_eq!(
1377 cache.lookup_overlay(&key),
1378 None,
1379 "no overlay installed -> outer None at length {key_len}"
1380 );
1381 }
1382 }
1383
1384 /// Installs an overlay with one entry per CF (each at the canonical length) and
1385 /// confirms `lookup_overlay` routes to the right map.
1386 #[test]
1387 fn overlay_lookup_classifies_cf_by_key_length() {
1388 let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::open().unwrap());
1389 let entry = JournalEntry {
1390 block_hash: h(0x01),
1391 parent_state_root: H256::zero(),
1392 account_trie_diff: vec![(vec![0x10; 4], Some(b"acct-trie".to_vec()))],
1393 storage_trie_diff: vec![(vec![0x20; 67], Some(b"stor-trie".to_vec()))],
1394 account_flat_diff: vec![(vec![0x30; 65], Some(b"acct-flat".to_vec()))],
1395 storage_flat_diff: vec![(vec![0x40; 131], None)],
1396 };
1397 let mut tx = backend.begin_write().unwrap();
1398 tx.put(STATE_HISTORY, &1u64.to_be_bytes(), &entry.encode())
1399 .unwrap();
1400 tx.commit().unwrap();
1401 let overlay = Overlay::from_journal(backend.as_ref(), 1, 1, |_| None).unwrap();
1402
1403 let mut cache =
1404 TrieLayerCache::new_with_safe_commit(1, Arc::new(RwLock::new(H256::zero())));
1405 cache.set_overlay(Arc::new(overlay));
1406
1407 assert_eq!(
1408 cache.lookup_overlay(&[0x10; 4]),
1409 Some(Some(b"acct-trie".to_vec()))
1410 );
1411 assert_eq!(
1412 cache.lookup_overlay(&[0x20; 67]),
1413 Some(Some(b"stor-trie".to_vec()))
1414 );
1415 assert_eq!(
1416 cache.lookup_overlay(&[0x30; 65]),
1417 Some(Some(b"acct-flat".to_vec()))
1418 );
1419 assert_eq!(
1420 cache.lookup_overlay(&[0x40; 131]),
1421 Some(None),
1422 "overlay with None means key was absent at pivot"
1423 );
1424 // Same length but different bytes ; bloom miss.
1425 assert_eq!(cache.lookup_overlay(&[0xee; 4]), None);
1426 }
1427
1428 #[test]
1429 fn set_and_clear_overlay_round_trips() {
1430 let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::open().unwrap());
1431 seed(&backend, &[(1, h(0x01), vec![(vec![0xaa], None)])]);
1432 let overlay = Overlay::from_journal(backend.as_ref(), 1, 1, |_| None).unwrap();
1433
1434 let mut cache =
1435 TrieLayerCache::new_with_safe_commit(1, Arc::new(RwLock::new(H256::zero())));
1436 assert!(cache.overlay().is_none());
1437 cache.set_overlay(Arc::new(overlay));
1438 assert!(cache.overlay().is_some());
1439 cache.clear_overlay();
1440 assert!(cache.overlay().is_none());
1441 // Idempotent.
1442 cache.clear_overlay();
1443 assert!(cache.overlay().is_none());
1444 }
1445
1446 /// `from_block == to_block == 0` is a legitimate single-block-at-genesis case
1447 /// and must not underflow the descending loop.
1448 #[test]
1449 fn single_entry_at_genesis() {
1450 let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::open().unwrap());
1451 seed(
1452 &backend,
1453 &[(0, h(0x00), vec![(vec![0xaa], Some(vec![0x11]))])],
1454 );
1455 let overlay = Overlay::from_journal(backend.as_ref(), 0, 0, |_| None).unwrap();
1456 assert_eq!(overlay.len(), 1);
1457 assert_eq!(overlay.from_block(), 0);
1458 assert_eq!(overlay.to_block(), 0);
1459 assert_eq!(
1460 overlay.lookup(OverlayCf::AccountTrie, &[0xaa]),
1461 Some(Some(vec![0x11]))
1462 );
1463 }
1464
1465 /// Swapped `from_block < to_block` must be a hard error (not a debug-only
1466 /// assert) so a caller mistake fires in release too. Pin the variant to guard
1467 /// against future error-text changes.
1468 #[test]
1469 fn swapped_args_returns_error() {
1470 let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::open().unwrap());
1471 let err = Overlay::from_journal(backend.as_ref(), 3, 5, |_| None).unwrap_err();
1472 match err {
1473 OverlayError::InvalidRange {
1474 from_block,
1475 to_block,
1476 } => {
1477 assert_eq!(from_block, 3);
1478 assert_eq!(to_block, 5);
1479 }
1480 other => panic!("expected InvalidRange, got {other:?}"),
1481 }
1482 }
1483
1484 /// `Some(Some(vec![]))` (an empty-but-present pre-image) must round-trip
1485 /// through `absorb`/`lookup` without being confused with `Some(None)`
1486 /// (absent at pivot). The journal codec handles this correctly; this test
1487 /// guards a future codec change.
1488 #[test]
1489 fn empty_but_present_round_trips() {
1490 let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::open().unwrap());
1491 seed(&backend, &[(1, h(0x01), vec![(vec![0xaa], Some(vec![]))])]);
1492 let overlay = Overlay::from_journal(backend.as_ref(), 1, 1, |_| None).unwrap();
1493 assert_eq!(
1494 overlay.lookup(OverlayCf::AccountTrie, &[0xaa]),
1495 Some(Some(vec![])),
1496 "empty-but-present value must NOT degrade to Some(None)"
1497 );
1498 }
1499
1500 #[test]
1501 fn iter_all_entries_visits_each_cf() {
1502 // Sanity check for the reconciliation path: every CF an entry was inserted
1503 // into must show up in iter_all_entries, with the right CF tag.
1504 let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::open().unwrap());
1505 let entry = JournalEntry {
1506 block_hash: h(0x01),
1507 parent_state_root: H256::zero(),
1508 account_trie_diff: vec![(vec![0x10; 4], Some(b"at".to_vec()))],
1509 storage_trie_diff: vec![(vec![0x20; 67], Some(b"st".to_vec()))],
1510 account_flat_diff: vec![(vec![0x30; 65], None)],
1511 storage_flat_diff: vec![(vec![0x40; 131], Some(b"sf".to_vec()))],
1512 };
1513 let mut tx = backend.begin_write().unwrap();
1514 tx.put(STATE_HISTORY, &1u64.to_be_bytes(), &entry.encode())
1515 .unwrap();
1516 tx.commit().unwrap();
1517 let overlay = Overlay::from_journal(backend.as_ref(), 1, 1, |_| None).unwrap();
1518
1519 let mut cfs: Vec<OverlayCf> = overlay.iter_all_entries().map(|(cf, _, _)| cf).collect();
1520 cfs.sort_by_key(|cf| match cf {
1521 OverlayCf::AccountTrie => 0,
1522 OverlayCf::StorageTrie => 1,
1523 OverlayCf::AccountFlat => 2,
1524 OverlayCf::StorageFlat => 3,
1525 });
1526 assert_eq!(
1527 cfs,
1528 vec![
1529 OverlayCf::AccountTrie,
1530 OverlayCf::StorageTrie,
1531 OverlayCf::AccountFlat,
1532 OverlayCf::StorageFlat,
1533 ]
1534 );
1535 assert_eq!(overlay.len(), 4);
1536 assert!(!overlay.is_empty());
1537 }
1538}
1539
1540#[cfg(test)]
1541mod tests {
1542 use std::collections::HashSet;
1543 use std::sync::{Arc, RwLock};
1544
1545 use ethrex_common::H256;
1546 use ethrex_trie::Nibbles;
1547
1548 use super::TrieLayerCache;
1549
1550 /// Build an `H256` from a single byte, all other bytes zero.
1551 fn h256(b: u8) -> H256 {
1552 let mut bytes = [0u8; 32];
1553 bytes[31] = b;
1554 H256(bytes)
1555 }
1556
1557 /// A dummy trie key, distinct per layer so `put_batch`'s empty-block guard does not skip it.
1558 fn key(b: u8) -> Nibbles {
1559 Nibbles::from_bytes(&[b; 32])
1560 }
1561
1562 /// Build a linear chain of N layers on top of an on-disk floor (`H256::zero()`),
1563 /// returning the roots in order `[root_1, ..., root_n]` with `root_{i+1}.parent == root_i`.
1564 fn build_chain(cache: &mut TrieLayerCache, n: u8) -> Vec<H256> {
1565 let mut roots = Vec::with_capacity(n as usize);
1566 let mut parent = H256::zero();
1567 for i in 1..=n {
1568 let root = h256(i);
1569 cache.put_batch(parent, root, i as u64, root, vec![(key(i), vec![i])]);
1570 roots.push(root);
1571 parent = root;
1572 }
1573 roots
1574 }
1575
1576 fn cache_with_cell(threshold: usize, root: H256) -> (TrieLayerCache, Arc<RwLock<H256>>) {
1577 let cell = Arc::new(RwLock::new(root));
1578 let cache = TrieLayerCache::new_with_safe_commit(threshold, Arc::clone(&cell));
1579 (cache, cell)
1580 }
1581
1582 /// (a) Zero cell means "no safe commit point yet" -> get_commitable returns None.
1583 #[test]
1584 fn zero_cell_yields_none() {
1585 let (mut cache, _cell) = cache_with_cell(4, H256::zero());
1586 let roots = build_chain(&mut cache, 5);
1587 assert_eq!(cache.get_commitable(*roots.last().unwrap()), None);
1588 }
1589
1590 /// (b) Safe root on the parent walk -> get_commitable returns Some(safe_root).
1591 #[test]
1592 fn safe_root_on_walk_yields_some() {
1593 let safe = h256(2);
1594 let (mut cache, _cell) = cache_with_cell(4, safe);
1595 let roots = build_chain(&mut cache, 4);
1596 // Walk from L4: L4 -> L3 -> L2 (== safe) -> Some(L2).
1597 assert_eq!(cache.get_commitable(roots[3]), Some(safe));
1598 }
1599
1600 /// (c) parent_state_root == safe root -> immediate Some, no walk needed.
1601 #[test]
1602 fn parent_equals_safe_root_yields_some() {
1603 let roots = {
1604 let (mut cache, _cell) = cache_with_cell(4, H256::zero());
1605 build_chain(&mut cache, 3)
1606 };
1607 let l3 = roots[2];
1608 let (mut cache, _cell) = cache_with_cell(4, l3);
1609 build_chain(&mut cache, 3);
1610 assert_eq!(cache.get_commitable(l3), Some(l3));
1611 }
1612
1613 /// (c2) Regression: `parent_state_root == safe_root` but that root has no layer, the steady
1614 /// state on L2 where empty blocks keep their parent's root. Branch (c) used to match on the
1615 /// root value alone and hand `commit_to_disk` something it could not commit.
1616 #[test]
1617 fn parent_equals_safe_root_without_layer_yields_none() {
1618 let orphan = h256(7);
1619 let (mut cache, _cell) = cache_with_cell(4, orphan);
1620 // An empty block: parent == state_root, so `put_batch` inserts nothing.
1621 cache.put_batch(orphan, orphan, 7, orphan, vec![]);
1622 assert!(
1623 !cache.has_layer(orphan),
1624 "empty-block root must not create a layer"
1625 );
1626 assert_eq!(
1627 cache.get_commitable(orphan),
1628 None,
1629 "a safe root with no layer is not committable, even when it equals the parent"
1630 );
1631 }
1632
1633 /// (c3) A flushed root is pruned by `commit`, so it must not be offered again.
1634 #[test]
1635 fn already_committed_safe_root_yields_none() {
1636 let safe = h256(2);
1637 let (mut cache, _cell) = cache_with_cell(4, safe);
1638 build_chain(&mut cache, 3);
1639 assert_eq!(cache.get_commitable(safe), Some(safe));
1640 cache.commit(safe).expect("first commit flushes the layer");
1641 assert!(!cache.has_layer(safe), "commit prunes the flushed layer");
1642 assert_eq!(
1643 cache.get_commitable(safe),
1644 None,
1645 "re-committing an already-flushed root must not be offered again"
1646 );
1647 }
1648
1649 /// (d) Safe root not an ancestor (never inserted as a layer) -> None, regardless of depth.
1650 #[test]
1651 fn safe_root_not_ancestor_yields_none() {
1652 let (mut cache, _cell) = cache_with_cell(4, h256(99));
1653 // 6 layers (> threshold) so an old depth-only path would have fired.
1654 let roots = build_chain(&mut cache, 6);
1655 assert_eq!(cache.get_commitable(*roots.last().unwrap()), None);
1656 }
1657
1658 /// (e) Cycle guard: a B -> C -> B link must terminate and return None.
1659 #[test]
1660 fn cycle_guard_terminates() {
1661 let b = h256(20);
1662 let c = h256(21);
1663 let (mut cache, _cell) = cache_with_cell(4, h256(99));
1664 // Insert C (parent B) then B (parent C); neither key pre-exists, so both insert,
1665 // forming the cycle B <-> C. The safe root (h256(99)) is absent on purpose.
1666 cache.put_batch(b, c, 21, c, vec![(key(21), vec![21])]);
1667 cache.put_batch(c, b, 20, b, vec![(key(20), vec![20])]);
1668 // Walking from C must terminate (start-of-walk + bounded-walk guards) and yield None.
1669 assert_eq!(cache.get_commitable(c), None);
1670 }
1671
1672 /// (f) commit(safe_root) removes the safe layer and all older ones, retaining layers above it.
1673 #[test]
1674 fn commit_retains_layers_above_safe_root() {
1675 let safe = h256(2);
1676 let (mut cache, _cell) = cache_with_cell(4, safe);
1677 let roots = build_chain(&mut cache, 4);
1678 let (l3, l4) = (roots[2], roots[3]);
1679 assert_eq!(cache.get_commitable(roots[3]), Some(safe));
1680
1681 cache.commit(safe);
1682 let remaining: HashSet<H256> = cache.layers.keys().copied().collect();
1683 let expected: HashSet<H256> = [l3, l4].into_iter().collect();
1684 assert_eq!(
1685 remaining, expected,
1686 "commit(safe) must retain only the layers above it"
1687 );
1688 }
1689
1690 /// (g) Memory bound: after building > threshold layers and committing at a safe root that
1691 /// keeps the chain bounded, the retained layer count stays <= commit_threshold + 1.
1692 #[test]
1693 fn memory_bound_after_commit() {
1694 let threshold = 4usize;
1695 // Build threshold + 3 = 7 layers; set the safe root `threshold` below the tip.
1696 let n = (threshold + 3) as u8;
1697 let safe = h256(n - threshold as u8); // h256(3): leaves layers 4..=7 above it
1698 let (mut cache, _cell) = cache_with_cell(threshold, safe);
1699 let roots = build_chain(&mut cache, n);
1700 let tip = *roots.last().unwrap();
1701
1702 let commitable = cache.get_commitable(tip).expect("safe root on the walk");
1703 cache.commit(commitable);
1704 assert!(
1705 cache.layers.len() <= threshold + 1,
1706 "retained layers ({}) must stay within commit_threshold + 1 ({})",
1707 cache.layers.len(),
1708 threshold + 1
1709 );
1710 }
1711
1712 /// Why live block-by-block execution must NOT use the depth gate: with nothing canonicalized
1713 /// (safe_commit cell ZERO), the depth gate would flush a non-canonical layer and prune genesis
1714 /// -> the "post-state for block 0 absent" wedge. The canonical gate commits nothing instead.
1715 ///
1716 /// Wedge simulation: non-canonical newPayload layers pile up but nothing is canonicalized,
1717 /// so the safe_commit cell stays ZERO and never advances.
1718 #[test]
1719 fn live_canonical_gate_holds_while_depth_gate_would_commit() {
1720 // threshold = 4, safe_commit cell = ZERO (nothing canonicalized).
1721 let (mut cache, _cell) = cache_with_cell(4, H256::zero());
1722 // Linear chain L1 <- L2 <- L3 <- L4 <- L5 (distinct keys so guards pass).
1723 let roots = build_chain(&mut cache, 5);
1724 let l5 = *roots.last().unwrap();
1725
1726 // Depth gate: commits a layer at depth 4 -> would prune genesis on the path-keyed disk
1727 // root if used in live mode. This is why live mode uses the canonical gate below.
1728 assert!(
1729 cache.get_commitable_by_depth(l5, 4).is_some(),
1730 "depth-only gate commits at depth 4 regardless of canonicality"
1731 );
1732
1733 // Canonical gate (live mode): safe_commit cell is zero, so nothing is committed and
1734 // genesis is preserved.
1735 assert_eq!(
1736 cache.get_commitable(l5),
1737 None,
1738 "canonical gate must commit nothing while safe_commit is zero (the wedge fix)"
1739 );
1740 }
1741
1742 /// Batch execution (full sync / import) must still flush even when no FCU has advanced the
1743 /// safe-commit cell. The canonical gate stays parked at zero (import does not FCU until the
1744 /// end; full sync's `head - 128` root never lands on a ~1024-block batch boundary), so batch
1745 /// mode commits by depth instead -> memory stays bounded and state is durable across restart.
1746 #[test]
1747 fn batch_depth_gate_flushes_without_safe_commit() {
1748 // safe_commit cell = ZERO, as during bulk import before the terminal FCU.
1749 let (mut cache, _cell) = cache_with_cell(4, H256::zero());
1750 // Five batch layers stacked (each stands in for ~1024 blocks in real batch mode).
1751 let roots = build_chain(&mut cache, 5);
1752 let tip = *roots.last().unwrap();
1753
1754 // Canonical gate would never flush here -> unbounded memory (the regression iovoid flagged).
1755 assert_eq!(
1756 cache.get_commitable(tip),
1757 None,
1758 "canonical gate never flushes batch layers while safe_commit is zero"
1759 );
1760
1761 // Depth gate (batch mode) flushes the layer BATCH_COMMIT_THRESHOLD deep: root_2 sits 4
1762 // layers below the tip (tip=root_5).
1763 assert_eq!(
1764 cache.get_commitable_by_depth(tip, 4),
1765 Some(roots[1]),
1766 "batch depth gate must flush the layer 4 deep, bounding memory"
1767 );
1768 }
1769}