Skip to main content

ethrex_storage/
journal.rs

1//! # State-history journal
2//!
3//! Per-block reverse-diff entries persisted to disk so reorgs deeper than the
4//! in-memory `TrieLayerCache` become possible up to the finalized boundary.
5//!
6//! Each entry captures the previous on-disk values (or absence markers) for every
7//! account-trie node, storage-trie node, account flat-key-value, and storage
8//! flat-key-value path that a single layer commit overwrites. Codes are
9//! content-addressed and not journaled.
10//!
11//! Entries are keyed by `block_number.to_be_bytes()` in the
12//! [`STATE_HISTORY`](crate::api::tables::STATE_HISTORY) column family. Big-endian
13//! ensures lexicographic order matches numeric order, which lets finality
14//! pruning use a single `delete_range`.
15//!
16//! ## Pruning model
17//!
18//! When a `forkchoice_update` advances the finalized block, `forkchoice_update_inner`
19//! calls `delete_range(STATE_HISTORY, lowest, min(finalized_number, highest) + 1)`,
20//! where `lowest` and `highest` are the lowest and highest entries present, removing
21//! all journal entries at or below the new finality boundary. No range is written when
22//! `lowest` is above finality. Ending at the highest existing entry rather than at
23//! finality matters because entries are written `DB_COMMIT_THRESHOLD` blocks behind
24//! head and finality can run ahead of them: a range delete only hides keys written
25//! before it, so a range reaching past the newest entry would leave later-written
26//! entries inside it alive and the next range would overlap it. The ranges therefore
27//! never overlap, which keeps RocksDB's unflushed range tombstones cheap to fragment
28//! (overlapping ones grow as N²). The surviving entries cover
29//! `[finalized_number+1, cache_edge_D]`, which is exactly the window a future
30//! deep reorg could need. After pruning, `Store::lowest_state_history_block_number`
31//! reflects the new floor.
32//!
33//! ## Final layers are not journaled
34//!
35//! Layers commit ~`DB_COMMIT_THRESHOLD` blocks behind the head. On a finalizing chain
36//! that block is usually final already: an L2 finalizes its head every block, and L1
37//! finality trails the head by two or three epochs. Its entry would be pruned on the
38//! next finality advance, and until then it could only serve a reorg across finality,
39//! which ethrex does allow (finality is not a reorg limit, see `compute_reorg_ceiling`
40//! in the blockchain crate) but rejects as too deep when the journal has no entry for
41//! it. The commit path therefore skips the entry, giving up only that window: up to an
42//! epoch on L1, one block on L2. It does so only while the journal is empty and the
43//! block is at or below the head as well, which leaves the journal in a state pruning
44//! already produces: the newest entry, when there is one, still names the block whose
45//! state is on disk. In steady state the journal stays empty and pruning writes
46//! nothing; entries appear when finality falls behind the commit edge, which is when a
47//! deep reorg can need them. See `journal_skip_ceiling` in `store.rs`.
48//!
49//! ## Batch mode (full sync)
50//!
51//! When `batch_mode == true` (full sync), the commit path skips journaling
52//! entirely. A full-sync import writes one layer per ~1024 blocks and does
53//! not produce the per-block reverse-diffs needed for deep reorgs. Reorg
54//! support is only active after the node transitions to normal block-by-block
55//! execution.
56//!
57//! ## Codec
58//!
59//! Entries use a hand-rolled compact format: a version byte at offset 0, then
60//! `block_hash` (32 bytes), `parent_state_root` (32 bytes), then four
61//! varint-prefixed reverse-diff sections in order: account-trie, storage-trie,
62//! account flat-KV, storage flat-KV. RLP/bincode/postcard are skipped — the
63//! access pattern (write-once, read-on-reorg, large volume) makes encode/decode
64//! cost matter.
65//!
66//! ## Version strategy
67//!
68//! [`JOURNAL_VERSION`] is a single byte at offset 0 of every entry. The decoder
69//! rejects any version other than the current one with
70//! [`JournalDecodeError::VersionMismatch`]. On a codec bump, the journal should
71//! be drained at a finality boundary so the new binary starts with an empty
72//! journal and never encounters old-format entries. A future bump that needs to
73//! keep history across the upgrade should introduce per-version `decode_vN` arms
74//! rather than re-encoding existing entries.
75
76use ethrex_common::H256;
77
78/// Current version of the journal entry codec.
79///
80/// Bumping this constant changes the wire format. The decoder rejects any
81/// other version with [`JournalDecodeError::VersionMismatch`]: a v(N) binary
82/// will refuse to interpret v(N+1) entries (forward safety) and will also
83/// refuse to read v(N-1) entries written by a previous binary (no implicit
84/// fallback). The plan for the rollback consumer (the deep-reorg overlay) is to drain
85/// the journal at a finality boundary on upgrade, so the v(N) journal
86/// starts empty after the bump; a future bump that needs to keep history
87/// across the upgrade should introduce per-version `decode_vN` arms here
88/// rather than re-encoding existing entries.
89pub const JOURNAL_VERSION: u8 = 1;
90
91/// A single reverse-diff entry: `(on_disk_key, previous_value_or_none)`.
92///
93/// `on_disk_key` is the exact key written to its column family — for storage
94/// CFs this includes the nibble-encoded account-hash prefix. `Some(prev)`
95/// means the key existed on disk with `prev` before the commit; `None` means
96/// the key did not exist on disk (i.e., the commit added it, and a rollback
97/// should remove it).
98pub type ReverseDiffEntry = (Vec<u8>, Option<Vec<u8>>);
99
100/// A flat list of reverse-diff entries.
101pub type FlatDiff = Vec<ReverseDiffEntry>;
102
103/// A single reverse-diff entry covering one block's commit.
104///
105/// All four diff sections are flat lists of `(on_disk_key, prev_value)` tuples.
106/// On rollback, each entry can be applied directly to its column family
107/// without further interpretation: `Some(prev)` becomes a `put`, `None`
108/// becomes a `delete`.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct JournalEntry {
111    /// Hash of the block whose commit this entry reverses.
112    pub block_hash: H256,
113    /// Post-state root of the parent block (the state we'd return to on rollback).
114    pub parent_state_root: H256,
115    /// Reverse diff for `ACCOUNT_TRIE_NODES`.
116    pub account_trie_diff: FlatDiff,
117    /// Reverse diff for `STORAGE_TRIE_NODES`. Keys carry the nibble-encoded
118    /// account-hash prefix as written on disk; no separate grouping is needed.
119    pub storage_trie_diff: FlatDiff,
120    /// Reverse diff for `ACCOUNT_FLATKEYVALUE`.
121    pub account_flat_diff: FlatDiff,
122    /// Reverse diff for `STORAGE_FLATKEYVALUE`. Keys carry the nibble-encoded
123    /// account-hash prefix as written on disk.
124    pub storage_flat_diff: FlatDiff,
125}
126
127/// Errors that can occur when decoding a journal entry from disk.
128#[derive(Debug, thiserror::Error, PartialEq, Eq)]
129pub enum JournalDecodeError {
130    #[error("journal entry truncated: expected {expected} more bytes at offset {offset}")]
131    Truncated { offset: usize, expected: usize },
132    #[error("journal entry version mismatch: expected {expected}, found {found}")]
133    VersionMismatch { expected: u8, found: u8 },
134    #[error("journal entry varint overflow at offset {offset}")]
135    VarintOverflow { offset: usize },
136    #[error(
137        "journal entry presence byte invalid: expected 0 or 1, found {found} at offset {offset}"
138    )]
139    InvalidPresenceByte { offset: usize, found: u8 },
140    #[error("journal entry has {trailing} trailing bytes after offset {offset}")]
141    TrailingBytes { offset: usize, trailing: usize },
142    #[error(
143        "journal entry length prefix {claimed} at offset {offset} exceeds remaining bytes {remaining}"
144    )]
145    LengthExceedsRemaining {
146        offset: usize,
147        claimed: u64,
148        remaining: usize,
149    },
150}
151
152impl JournalEntry {
153    /// Encode this entry into its on-disk byte representation.
154    pub fn encode(&self) -> Vec<u8> {
155        let approx = 1
156            + 32
157            + 32
158            + diff_byte_estimate(&self.account_trie_diff)
159            + diff_byte_estimate(&self.storage_trie_diff)
160            + diff_byte_estimate(&self.account_flat_diff)
161            + diff_byte_estimate(&self.storage_flat_diff);
162        let mut out = Vec::with_capacity(approx);
163
164        out.push(JOURNAL_VERSION);
165        out.extend_from_slice(self.block_hash.as_bytes());
166        out.extend_from_slice(self.parent_state_root.as_bytes());
167
168        encode_flat_diff(&mut out, &self.account_trie_diff);
169        encode_flat_diff(&mut out, &self.storage_trie_diff);
170        encode_flat_diff(&mut out, &self.account_flat_diff);
171        encode_flat_diff(&mut out, &self.storage_flat_diff);
172
173        out
174    }
175
176    /// Read only the block hash of an entry: the version byte followed by the
177    /// 32-byte hash at offset 1. Skips the reverse diffs, which can run to
178    /// megabytes for a state-heavy block, and does not depend on their
179    /// integrity. Rejects a foreign version byte like [`Self::decode`].
180    pub fn decode_block_hash(bytes: &[u8]) -> Result<H256, JournalDecodeError> {
181        let Some(&version) = bytes.first() else {
182            return Err(JournalDecodeError::Truncated {
183                offset: 0,
184                expected: 1,
185            });
186        };
187        if version != JOURNAL_VERSION {
188            return Err(JournalDecodeError::VersionMismatch {
189                expected: JOURNAL_VERSION,
190                found: version,
191            });
192        }
193        let Some(hash) = bytes.get(1..33) else {
194            return Err(JournalDecodeError::Truncated {
195                offset: 1,
196                expected: 32,
197            });
198        };
199        Ok(H256::from_slice(hash))
200    }
201
202    /// Decode an entry from its on-disk byte representation.
203    ///
204    /// Returns [`JournalDecodeError::VersionMismatch`] if the version byte is
205    /// not [`JOURNAL_VERSION`]. The current binary deliberately refuses to
206    /// interpret entries written by a future codec version rather than silently
207    /// producing a malformed reverse-diff.
208    pub fn decode(bytes: &[u8]) -> Result<Self, JournalDecodeError> {
209        let mut cur = Cursor::new(bytes);
210
211        let version = cur.read_byte()?;
212        if version != JOURNAL_VERSION {
213            return Err(JournalDecodeError::VersionMismatch {
214                expected: JOURNAL_VERSION,
215                found: version,
216            });
217        }
218
219        let block_hash = cur.read_h256()?;
220        let parent_state_root = cur.read_h256()?;
221
222        let account_trie_diff = decode_flat_diff(&mut cur)?;
223        let storage_trie_diff = decode_flat_diff(&mut cur)?;
224        let account_flat_diff = decode_flat_diff(&mut cur)?;
225        let storage_flat_diff = decode_flat_diff(&mut cur)?;
226
227        // Reject trailing bytes: a corrupt or mixed-version record that happens to
228        // have a valid prefix must not be silently treated as valid.
229        if cur.offset != bytes.len() {
230            return Err(JournalDecodeError::TrailingBytes {
231                offset: cur.offset,
232                trailing: bytes.len() - cur.offset,
233            });
234        }
235
236        Ok(Self {
237            block_hash,
238            parent_state_root,
239            account_trie_diff,
240            storage_trie_diff,
241            account_flat_diff,
242            storage_flat_diff,
243        })
244    }
245}
246
247fn encode_varint(out: &mut Vec<u8>, mut value: u64) {
248    while value >= 0x80 {
249        out.push((value as u8 & 0x7f) | 0x80);
250        value >>= 7;
251    }
252    out.push(value as u8);
253}
254
255fn encode_flat_diff(out: &mut Vec<u8>, diff: &[ReverseDiffEntry]) {
256    encode_varint(out, diff.len() as u64);
257    for (path, value) in diff {
258        encode_varint(out, path.len() as u64);
259        out.extend_from_slice(path);
260        match value {
261            None => out.push(0),
262            Some(v) => {
263                out.push(1);
264                encode_varint(out, v.len() as u64);
265                out.extend_from_slice(v);
266            }
267        }
268    }
269}
270
271/// Returns the encoded LEB128 length of `value`. 1 byte per 7 bits, with bytes
272/// 1-9 having the continuation bit set.
273fn varint_len(value: u64) -> usize {
274    let bits = 64 - value.leading_zeros() as usize;
275    bits.div_ceil(7).max(1)
276}
277
278fn diff_byte_estimate(diff: &[ReverseDiffEntry]) -> usize {
279    // varint(path_len) + path + presence_byte + (value_section if Some).
280    // value_section = varint(value_len) + value.
281    diff.iter()
282        .map(|(p, v)| {
283            varint_len(p.len() as u64)
284                + p.len()
285                + 1
286                + v.as_ref()
287                    .map_or(0, |v| varint_len(v.len() as u64) + v.len())
288        })
289        .sum::<usize>()
290        + varint_len(diff.len() as u64)
291}
292
293struct Cursor<'a> {
294    bytes: &'a [u8],
295    offset: usize,
296}
297
298impl<'a> Cursor<'a> {
299    fn new(bytes: &'a [u8]) -> Self {
300        Self { bytes, offset: 0 }
301    }
302
303    fn read_byte(&mut self) -> Result<u8, JournalDecodeError> {
304        if self.offset >= self.bytes.len() {
305            return Err(JournalDecodeError::Truncated {
306                offset: self.offset,
307                expected: 1,
308            });
309        }
310        let b = self.bytes[self.offset];
311        self.offset += 1;
312        Ok(b)
313    }
314
315    fn read_slice(&mut self, n: usize) -> Result<&'a [u8], JournalDecodeError> {
316        // Saturating form: explicit even though `offset <= bytes.len()` is an
317        // invariant maintained by `read_byte` / `read_slice` themselves.
318        let remaining = self.bytes.len().saturating_sub(self.offset);
319        if remaining < n {
320            return Err(JournalDecodeError::Truncated {
321                offset: self.offset,
322                expected: n,
323            });
324        }
325        let s = &self.bytes[self.offset..self.offset + n];
326        self.offset += n;
327        Ok(s)
328    }
329
330    /// Returns the number of unread bytes in the cursor.
331    fn remaining(&self) -> usize {
332        self.bytes.len().saturating_sub(self.offset)
333    }
334
335    fn read_h256(&mut self) -> Result<H256, JournalDecodeError> {
336        let s = self.read_slice(32)?;
337        Ok(H256::from_slice(s))
338    }
339
340    fn read_varint(&mut self) -> Result<u64, JournalDecodeError> {
341        let mut result: u64 = 0;
342        let mut shift: u32 = 0;
343        loop {
344            let b = self.read_byte()?;
345            // Maximum 10 bytes for u64 LEB128 (10 * 7 = 70 > 64). Reject the
346            // 11th byte unconditionally.
347            if shift >= 64 {
348                return Err(JournalDecodeError::VarintOverflow {
349                    offset: self.offset - 1,
350                });
351            }
352            // At shift==63 only bit 0 of the final byte fits into a u64; bits 1-6
353            // would shift past position 63 and be silently dropped. A continuation
354            // bit at this point is also invalid: a u64 LEB128 is at most 10 bytes.
355            if shift == 63 && (b & 0x7e != 0 || b & 0x80 != 0) {
356                return Err(JournalDecodeError::VarintOverflow {
357                    offset: self.offset - 1,
358                });
359            }
360            result |= ((b & 0x7f) as u64) << shift;
361            if b & 0x80 == 0 {
362                return Ok(result);
363            }
364            shift += 7;
365        }
366    }
367}
368
369/// Smallest possible per-entry overhead in bytes: `varint(path_len=0)` + 0 path
370/// bytes + 1 presence byte. Used to bound `Vec::with_capacity(count)` against the
371/// actual payload size so a corrupt count prefix can't trigger OOM.
372const MIN_ENTRY_BYTES: usize = 2;
373
374fn decode_flat_diff(cur: &mut Cursor<'_>) -> Result<FlatDiff, JournalDecodeError> {
375    let count_offset = cur.offset;
376    let count_u64 = cur.read_varint()?;
377    let remaining = cur.remaining();
378    // Each entry needs at least MIN_ENTRY_BYTES of payload. Reject a count that
379    // can't possibly fit in the remaining buffer ; otherwise `Vec::with_capacity`
380    // could request near-`usize::MAX` and panic with OOM.
381    let max_possible = remaining / MIN_ENTRY_BYTES;
382    if count_u64 as usize > max_possible {
383        return Err(JournalDecodeError::LengthExceedsRemaining {
384            offset: count_offset,
385            claimed: count_u64,
386            remaining,
387        });
388    }
389    let count = count_u64 as usize;
390    let mut out = Vec::with_capacity(count);
391    for _ in 0..count {
392        let path_len_offset = cur.offset;
393        let path_len_u64 = cur.read_varint()?;
394        // Guard the path-length allocation against malformed input the same way.
395        let remaining = cur.remaining();
396        if path_len_u64 > remaining as u64 {
397            return Err(JournalDecodeError::LengthExceedsRemaining {
398                offset: path_len_offset,
399                claimed: path_len_u64,
400                remaining,
401            });
402        }
403        let path_len = path_len_u64 as usize;
404        let path = cur.read_slice(path_len)?.to_vec();
405        let presence_offset = cur.offset;
406        let presence = cur.read_byte()?;
407        let value = match presence {
408            0 => None,
409            1 => {
410                let value_len_offset = cur.offset;
411                let value_len_u64 = cur.read_varint()?;
412                let remaining = cur.remaining();
413                if value_len_u64 > remaining as u64 {
414                    return Err(JournalDecodeError::LengthExceedsRemaining {
415                        offset: value_len_offset,
416                        claimed: value_len_u64,
417                        remaining,
418                    });
419                }
420                let value_len = value_len_u64 as usize;
421                Some(cur.read_slice(value_len)?.to_vec())
422            }
423            other => {
424                return Err(JournalDecodeError::InvalidPresenceByte {
425                    offset: presence_offset,
426                    found: other,
427                });
428            }
429        };
430        out.push((path, value));
431    }
432    Ok(out)
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    fn h(b: u8) -> H256 {
440        H256::repeat_byte(b)
441    }
442
443    fn round_trip(entry: &JournalEntry) {
444        let bytes = entry.encode();
445        let decoded = JournalEntry::decode(&bytes).unwrap();
446        assert_eq!(&decoded, entry);
447    }
448
449    #[test]
450    fn empty_entry_round_trips() {
451        let entry = JournalEntry {
452            block_hash: h(0xaa),
453            parent_state_root: h(0xbb),
454            account_trie_diff: vec![],
455            storage_trie_diff: vec![],
456            account_flat_diff: vec![],
457            storage_flat_diff: vec![],
458        };
459        round_trip(&entry);
460        // 1 (version) + 32 + 32 + 1 (count=0) * 4 = 69 bytes.
461        assert_eq!(entry.encode().len(), 69);
462    }
463
464    #[test]
465    fn typical_entry_round_trips() {
466        let entry = JournalEntry {
467            block_hash: h(0x11),
468            parent_state_root: h(0x22),
469            account_trie_diff: vec![
470                (vec![0x00, 0x01], Some(vec![0xde, 0xad, 0xbe, 0xef])),
471                (vec![0x02], None),
472            ],
473            storage_trie_diff: vec![(vec![0x0a; 67], Some(vec![0xff])), (vec![0x0b; 68], None)],
474            account_flat_diff: vec![(vec![0xaa; 65], Some(vec![0x01, 0x02, 0x03]))],
475            storage_flat_diff: vec![(vec![0xbb; 131], None)],
476        };
477        round_trip(&entry);
478    }
479
480    #[test]
481    fn entry_with_only_absences_round_trips() {
482        let entry = JournalEntry {
483            block_hash: h(0x55),
484            parent_state_root: h(0x66),
485            account_trie_diff: vec![(vec![0x00], None), (vec![0x01], None), (vec![0x02], None)],
486            storage_trie_diff: vec![],
487            account_flat_diff: vec![(vec![0xaa; 32], None)],
488            storage_flat_diff: vec![],
489        };
490        round_trip(&entry);
491    }
492
493    #[test]
494    fn large_entry_round_trips() {
495        let mut account_trie_diff = Vec::with_capacity(10_000);
496        for i in 0u32..10_000 {
497            let path = i.to_be_bytes().to_vec();
498            let value = if i % 7 == 0 {
499                None
500            } else {
501                Some(vec![(i & 0xff) as u8; (i % 200) as usize])
502            };
503            account_trie_diff.push((path, value));
504        }
505        let entry = JournalEntry {
506            block_hash: h(0xee),
507            parent_state_root: h(0xff),
508            account_trie_diff,
509            storage_trie_diff: vec![],
510            account_flat_diff: vec![],
511            storage_flat_diff: vec![],
512        };
513        round_trip(&entry);
514    }
515
516    #[test]
517    fn rejects_unknown_version() {
518        let mut bytes = vec![0xff];
519        bytes.extend_from_slice(&[0; 32]);
520        bytes.extend_from_slice(&[0; 32]);
521        bytes.extend_from_slice(&[0, 0, 0, 0]);
522        let err = JournalEntry::decode(&bytes).unwrap_err();
523        assert_eq!(
524            err,
525            JournalDecodeError::VersionMismatch {
526                expected: JOURNAL_VERSION,
527                found: 0xff,
528            }
529        );
530    }
531
532    #[test]
533    fn rejects_truncated_input() {
534        let entry = JournalEntry {
535            block_hash: h(0x77),
536            parent_state_root: h(0x88),
537            account_trie_diff: vec![(vec![0x00], Some(vec![0xff]))],
538            storage_trie_diff: vec![],
539            account_flat_diff: vec![],
540            storage_flat_diff: vec![],
541        };
542        let bytes = entry.encode();
543        let err = JournalEntry::decode(&bytes[..bytes.len() - 1]).unwrap_err();
544        assert!(matches!(err, JournalDecodeError::Truncated { .. }));
545    }
546
547    #[test]
548    fn rejects_invalid_presence_byte() {
549        let mut bytes = Vec::new();
550        bytes.push(JOURNAL_VERSION);
551        bytes.extend_from_slice(&[0; 32]);
552        bytes.extend_from_slice(&[0; 32]);
553        bytes.push(1); // account_trie_diff count = 1
554        bytes.push(1); // path_len = 1
555        bytes.push(0xab); // path
556        bytes.push(2); // presence = 2 (invalid)
557        let err = JournalEntry::decode(&bytes).unwrap_err();
558        assert!(matches!(
559            err,
560            JournalDecodeError::InvalidPresenceByte { found: 2, .. }
561        ));
562    }
563
564    #[test]
565    fn varint_round_trip() {
566        for &v in &[0u64, 1, 127, 128, 16_383, 16_384, u32::MAX as u64, u64::MAX] {
567            let mut buf = Vec::new();
568            encode_varint(&mut buf, v);
569            let mut cur = Cursor::new(&buf);
570            assert_eq!(cur.read_varint().unwrap(), v);
571        }
572    }
573
574    /// At shift==63 only bit 0 of the 10th byte fits in a u64. A 10-byte LEB128
575    /// where the 10th byte has bits 1-6 set encodes a value > u64::MAX; the
576    /// decoder must reject it rather than silently truncate the extra bits.
577    #[test]
578    fn rejects_varint_with_truncating_10th_byte() {
579        // 9 continuation bytes carrying zero data + 10th byte 0x7e (bits 1-5 set,
580        // no continuation). Without the guard, this decodes the same as 0x00 at
581        // shift==63 because the shifted bits fall outside the u64.
582        let mut buf = vec![0x80; 9];
583        buf.push(0x7e);
584        let mut cur = Cursor::new(&buf);
585        let err = cur.read_varint().unwrap_err();
586        assert!(matches!(err, JournalDecodeError::VarintOverflow { .. }));
587    }
588
589    /// An 11th byte must be rejected regardless of its bits.
590    #[test]
591    fn rejects_varint_with_11th_byte() {
592        let mut buf = vec![0x80; 10];
593        buf.push(0x00);
594        let mut cur = Cursor::new(&buf);
595        let err = cur.read_varint().unwrap_err();
596        assert!(matches!(err, JournalDecodeError::VarintOverflow { .. }));
597    }
598
599    /// A 10th byte with the continuation bit set claims an 11th byte and must
600    /// be rejected even if its data bits are valid.
601    #[test]
602    fn rejects_varint_with_continuation_at_byte_10() {
603        let mut buf = vec![0x80; 9];
604        buf.push(0x81); // bit 0 set + continuation
605        let mut cur = Cursor::new(&buf);
606        let err = cur.read_varint().unwrap_err();
607        assert!(matches!(err, JournalDecodeError::VarintOverflow { .. }));
608    }
609
610    /// u64::MAX must still round-trip after the tightened decoder (its 10-byte
611    /// LEB128 has 10th byte 0x01, which has zero in bits 1-6 and no continuation).
612    #[test]
613    fn u64_max_round_trips_after_tighter_decoder() {
614        let mut buf = Vec::new();
615        encode_varint(&mut buf, u64::MAX);
616        assert_eq!(buf.len(), 10);
617        let mut cur = Cursor::new(&buf);
618        assert_eq!(cur.read_varint().unwrap(), u64::MAX);
619    }
620
621    /// A corrupt count prefix (e.g. u64::MAX) must NOT cause a near-`usize::MAX`
622    /// allocation. We expect `LengthExceedsRemaining` before any vec is allocated.
623    #[test]
624    fn rejects_oom_via_malformed_count() {
625        // Manually craft a payload: version + 32B block_hash + 32B parent_state_root
626        // + account_trie_diff count = u64::MAX (10-byte LEB128). The remaining
627        // payload is too small to hold that many entries.
628        let mut bytes = vec![JOURNAL_VERSION];
629        bytes.extend_from_slice(&[0; 32]);
630        bytes.extend_from_slice(&[0; 32]);
631        encode_varint(&mut bytes, u64::MAX);
632        let err = JournalEntry::decode(&bytes).unwrap_err();
633        assert!(
634            matches!(err, JournalDecodeError::LengthExceedsRemaining { .. }),
635            "expected LengthExceedsRemaining, got {err:?}"
636        );
637    }
638
639    /// A corrupt path-length must NOT cause a near-`usize::MAX` allocation.
640    #[test]
641    fn rejects_oom_via_malformed_path_len() {
642        let mut bytes = vec![JOURNAL_VERSION];
643        bytes.extend_from_slice(&[0; 32]);
644        bytes.extend_from_slice(&[0; 32]);
645        encode_varint(&mut bytes, 1); // count = 1
646        encode_varint(&mut bytes, u64::MAX); // path_len = u64::MAX
647        let err = JournalEntry::decode(&bytes).unwrap_err();
648        assert!(
649            matches!(err, JournalDecodeError::LengthExceedsRemaining { .. }),
650            "expected LengthExceedsRemaining, got {err:?}"
651        );
652    }
653
654    /// A corrupt value-length must NOT cause a near-`usize::MAX` allocation.
655    #[test]
656    fn rejects_oom_via_malformed_value_len() {
657        let mut bytes = vec![JOURNAL_VERSION];
658        bytes.extend_from_slice(&[0; 32]);
659        bytes.extend_from_slice(&[0; 32]);
660        encode_varint(&mut bytes, 1); // count = 1
661        encode_varint(&mut bytes, 1); // path_len = 1
662        bytes.push(0xaa); // path
663        bytes.push(1); // presence = 1
664        encode_varint(&mut bytes, u64::MAX); // value_len = u64::MAX
665        let err = JournalEntry::decode(&bytes).unwrap_err();
666        assert!(
667            matches!(err, JournalDecodeError::LengthExceedsRemaining { .. }),
668            "expected LengthExceedsRemaining, got {err:?}"
669        );
670    }
671
672    /// Trailing bytes after a valid prefix must be rejected. A corrupt or
673    /// mixed-version record could otherwise be silently treated as valid.
674    #[test]
675    fn rejects_trailing_bytes() {
676        let entry = JournalEntry {
677            block_hash: h(0xaa),
678            parent_state_root: h(0xbb),
679            account_trie_diff: vec![],
680            storage_trie_diff: vec![],
681            account_flat_diff: vec![],
682            storage_flat_diff: vec![],
683        };
684        let mut bytes = entry.encode();
685        bytes.push(0xff); // unexpected trailing byte
686        let err = JournalEntry::decode(&bytes).unwrap_err();
687        match err {
688            JournalDecodeError::TrailingBytes { trailing, .. } => assert_eq!(trailing, 1),
689            other => panic!("expected TrailingBytes, got {other:?}"),
690        }
691    }
692
693    /// Round-trip property: any structurally-valid `JournalEntry` must decode
694    /// back to itself after `encode`. Complements the hand-written cases above
695    /// by covering shapes the author did not think to enumerate (empty paths,
696    /// large counts, mixed presence patterns, paths/values that straddle the
697    /// varint width boundary, etc.).
698    #[test]
699    fn proptest_encode_decode_round_trip() {
700        use proptest::collection::vec;
701        use proptest::prelude::*;
702        let entry_strategy = (
703            any::<[u8; 32]>(),
704            any::<[u8; 32]>(),
705            vec(flat_diff_entry(), 0..16),
706            vec(flat_diff_entry(), 0..16),
707            vec(flat_diff_entry(), 0..16),
708            vec(flat_diff_entry(), 0..16),
709        )
710            .prop_map(|(bh, psr, a, b, c, d)| JournalEntry {
711                block_hash: H256::from(bh),
712                parent_state_root: H256::from(psr),
713                account_trie_diff: a,
714                storage_trie_diff: b,
715                account_flat_diff: c,
716                storage_flat_diff: d,
717            });
718        proptest!(|(entry in entry_strategy)| {
719            let bytes = entry.encode();
720            let decoded = JournalEntry::decode(&bytes).expect("encoded entry must decode");
721            prop_assert_eq!(decoded, entry);
722        });
723    }
724
725    /// Safety property: the decoder must never panic, abort, or hang on
726    /// arbitrary input. Only `Ok` or `Err` outcomes are permitted. This is the
727    /// minimum bar for code that reads possibly-corrupted bytes off disk and
728    /// motivated the OOM / varint-truncation / trailing-bytes hardening above.
729    #[test]
730    fn proptest_decoder_never_panics_on_arbitrary_bytes() {
731        use proptest::collection::vec;
732        use proptest::prelude::*;
733        proptest!(|(bytes in vec(any::<u8>(), 0..1024))| {
734            let _ = JournalEntry::decode(&bytes);
735        });
736    }
737
738    /// Mutation property: flipping a byte in a valid encoding must either
739    /// produce `Err` or decode to a *different* entry. A silent
740    /// "corrupted-but-still-valid" result would be a hole in the decoder's
741    /// validation.
742    #[test]
743    fn proptest_single_byte_mutation_never_silently_accepted() {
744        use proptest::prelude::*;
745        let entry = JournalEntry {
746            block_hash: h(0x42),
747            parent_state_root: h(0x43),
748            account_trie_diff: vec![(vec![0x01, 0x02], Some(vec![0xaa, 0xbb]))],
749            storage_trie_diff: vec![(vec![0x0a; 67], None)],
750            account_flat_diff: vec![(vec![0xcc; 65], Some(vec![0xdd]))],
751            storage_flat_diff: vec![],
752        };
753        let baseline = entry.encode();
754        proptest!(|(idx in 0..baseline.len(), bit in 0u8..8)| {
755            let mut mutated = baseline.clone();
756            mutated[idx] ^= 1 << bit;
757            match JournalEntry::decode(&mutated) {
758                Err(_) => {}
759                Ok(decoded) => prop_assert_ne!(decoded, entry.clone()),
760            }
761        });
762    }
763
764    fn flat_diff_entry() -> impl proptest::strategy::Strategy<Value = ReverseDiffEntry> {
765        use proptest::collection::vec;
766        use proptest::prelude::*;
767        (
768            vec(any::<u8>(), 0..200),
769            proptest::option::of(vec(any::<u8>(), 0..200)),
770        )
771    }
772
773    /// `diff_byte_estimate` must be a lower-bound that matches the actual encoded
774    /// length when paths/values cross varint width boundaries (>= 128 bytes).
775    #[test]
776    fn diff_byte_estimate_handles_large_lengths() {
777        let diff: Vec<ReverseDiffEntry> = vec![
778            (vec![0xaa; 200], Some(vec![0xbb; 300])), // both > 128, 2-byte varints
779            (vec![0xcc; 50], Some(vec![0xdd; 50])),   // < 128, 1-byte varints
780        ];
781        // The estimate is consumed via diff_byte_estimate() in encode()'s
782        // Vec::with_capacity hint. Verify it matches the actual encoded length
783        // for one diff section so reallocations don't fire on the hot path.
784        let mut buf = Vec::new();
785        encode_flat_diff(&mut buf, &diff);
786        assert_eq!(
787            diff_byte_estimate(&diff),
788            buf.len(),
789            "estimate must equal actual encoded length so encode() avoids realloc"
790        );
791    }
792    #[test]
793    fn decode_block_hash_reads_the_hash_without_the_diffs() {
794        let entry = JournalEntry {
795            block_hash: H256::repeat_byte(0xab),
796            parent_state_root: H256::repeat_byte(0xcd),
797            account_trie_diff: vec![(vec![1, 2, 3], Some(vec![4]))],
798            storage_trie_diff: vec![],
799            account_flat_diff: vec![(vec![9], None)],
800            storage_flat_diff: vec![],
801        };
802        let bytes = entry.encode();
803        assert_eq!(
804            JournalEntry::decode_block_hash(&bytes).unwrap(),
805            H256::repeat_byte(0xab)
806        );
807        // Diffs are not looked at: a truncated tail still yields the hash ...
808        assert_eq!(
809            JournalEntry::decode_block_hash(&bytes[..40]).unwrap(),
810            H256::repeat_byte(0xab)
811        );
812        // ... but a foreign version byte or a record shorter than the hash is rejected.
813        let mut foreign = bytes.clone();
814        foreign[0] = JOURNAL_VERSION + 1;
815        assert!(matches!(
816            JournalEntry::decode_block_hash(&foreign),
817            Err(JournalDecodeError::VersionMismatch { .. })
818        ));
819        assert!(matches!(
820            JournalEntry::decode_block_hash(&bytes[..20]),
821            Err(JournalDecodeError::Truncated { .. })
822        ));
823    }
824}