Skip to main content

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