diff --git a/compact-proof-oversized.scale.tar.xz b/compact-proof-oversized.scale.tar.xz new file mode 100644 index 00000000..68f7ab2a Binary files /dev/null and b/compact-proof-oversized.scale.tar.xz differ diff --git a/compact-proof-worst-dedup-child.scale.tar.xz b/compact-proof-worst-dedup-child.scale.tar.xz new file mode 100644 index 00000000..bd53cd6a Binary files /dev/null and b/compact-proof-worst-dedup-child.scale.tar.xz differ diff --git a/test-support/reference-trie/src/lib.rs b/test-support/reference-trie/src/lib.rs index 4eaf5c04..e9892dc4 100644 --- a/test-support/reference-trie/src/lib.rs +++ b/test-support/reference-trie/src/lib.rs @@ -30,6 +30,7 @@ use trie_root::{Hasher, Value as TrieStreamValue}; mod substrate; mod substrate_like; +pub mod trie_db_0_31_decoder; pub mod node { pub use trie_db::node::Node; } diff --git a/test-support/reference-trie/src/trie_db_0_31_decoder.rs b/test-support/reference-trie/src/trie_db_0_31_decoder.rs new file mode 100644 index 00000000..5e7dcbf0 --- /dev/null +++ b/test-support/reference-trie/src/trie_db_0_31_decoder.rs @@ -0,0 +1,208 @@ +// Copyright 2019, 2021 Parity Technologies +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Verbatim compact-proof decoder of the released `trie-db` 0.31.0 (only imports adjusted). +//! +//! A frozen snapshot of deployed decoder behavior — **do not update**. It exists so tests and +//! fuzzers can assert that deduplicated proofs (see `encode_compact_skip_duplicates`) stay +//! decodable by decoders already in the field. Reconstructs into a hash-keyed database only; the +//! 0.31.0 decoder inserts attached values under an incomplete prefix in a position-keyed database +//! (fixed by #227), so prefixed databases are out of scope. + +use hash_db::HashDB; +use std::{convert::TryInto, marker::PhantomData}; +use trie_db::{ + nibble_ops::NIBBLE_LENGTH, + node::{Node, NodeHandle, Value}, + CError, ChildReference, DBValue, NibbleVec, NodeCodec, TrieError, TrieHash, TrieLayout, +}; + +struct DecoderStackEntry<'a, C: NodeCodec> { + node: Node<'a>, + /// The next entry in the stack is a child of the preceding entry at this index. For branch + /// nodes, the index is in [0, NIBBLE_LENGTH] and for extension nodes, the index is in + /// [0, 1]. + child_index: usize, + /// The reconstructed child references. + children: Vec>>, + /// A value attached as a node. The node will need to use its hash as value. + attached_value: Option<&'a [u8]>, + _marker: PhantomData, +} + +impl<'a, C: NodeCodec> DecoderStackEntry<'a, C> { + fn advance_child_index(&mut self) -> trie_db::Result { + match self.node { + Node::Extension(_, child) if self.child_index == 0 => { + match child { + NodeHandle::Inline(data) if data.is_empty() => return Ok(false), + _ => { + let child_ref = child.try_into().map_err(|hash| { + Box::new(TrieError::InvalidHash(C::HashOut::default(), hash)) + })?; + self.children[self.child_index] = Some(child_ref); + }, + } + self.child_index += 1; + }, + Node::Branch(children, _) | Node::NibbledBranch(_, children, _) => { + while self.child_index < NIBBLE_LENGTH { + match children[self.child_index] { + Some(NodeHandle::Inline(data)) if data.is_empty() => return Ok(false), + Some(child) => { + let child_ref = child.try_into().map_err(|hash| { + Box::new(TrieError::InvalidHash(C::HashOut::default(), hash)) + })?; + self.children[self.child_index] = Some(child_ref); + }, + None => {}, + } + self.child_index += 1; + } + }, + _ => {}, + } + Ok(true) + } + + fn push_to_prefix(&self, prefix: &mut NibbleVec) { + match self.node { + Node::Empty => {}, + Node::Leaf(partial, _) | Node::Extension(partial, _) => { + prefix.append_partial(partial.right()); + }, + Node::Branch(_, _) => { + prefix.push(self.child_index as u8); + }, + Node::NibbledBranch(partial, _, _) => { + prefix.append_partial(partial.right()); + prefix.push(self.child_index as u8); + }, + } + } + + fn pop_from_prefix(&self, prefix: &mut NibbleVec) { + match self.node { + Node::Empty => {}, + Node::Leaf(partial, _) | Node::Extension(partial, _) => { + prefix.drop_lasts(partial.len()); + }, + Node::Branch(_, _) => { + prefix.pop(); + }, + Node::NibbledBranch(partial, _, _) => { + prefix.pop(); + prefix.drop_lasts(partial.len()); + }, + } + } + + fn encode_node(self, attached_hash: Option<&[u8]>) -> Vec { + let attached_hash = attached_hash.map(|h| Value::Node(h)); + match self.node { + Node::Empty => C::empty_node().to_vec(), + Node::Leaf(partial, value) => + C::leaf_node(partial.right_iter(), partial.len(), attached_hash.unwrap_or(value)), + Node::Extension(partial, _) => C::extension_node( + partial.right_iter(), + partial.len(), + self.children[0].expect("required by method precondition; qed"), + ), + Node::Branch(_, value) => C::branch_node( + self.children.into_iter(), + if attached_hash.is_some() { attached_hash } else { value }, + ), + Node::NibbledBranch(partial, _, value) => C::branch_node_nibbled( + partial.right_iter(), + partial.len(), + self.children.iter(), + if attached_hash.is_some() { attached_hash } else { value }, + ), + } + } +} + +pub fn decode_compact_from_iter<'a, L, DB, I>( + db: &mut DB, + encoded: I, +) -> trie_db::Result<(TrieHash, usize), TrieHash, CError> +where + L: TrieLayout, + DB: HashDB, + I: IntoIterator, +{ + let mut stack: Vec> = Vec::new(); + + let mut prefix = NibbleVec::new(); + + let mut iter = encoded.into_iter().enumerate(); + while let Some((i, encoded_node)) = iter.next() { + let mut attached_node = 0; + if let Some(header) = L::Codec::ESCAPE_HEADER { + if encoded_node.starts_with(&[header]) { + attached_node = 1; + } + } + let node = L::Codec::decode(&encoded_node[attached_node..]) + .map_err(|err| Box::new(TrieError::DecoderError(>::default(), err)))?; + + let children_len = match node { + Node::Empty | Node::Leaf(..) => 0, + Node::Extension(..) => 1, + Node::Branch(..) | Node::NibbledBranch(..) => NIBBLE_LENGTH, + }; + let mut last_entry = DecoderStackEntry { + node, + child_index: 0, + children: vec![None; children_len], + attached_value: None, + _marker: PhantomData::default(), + }; + + if attached_node > 0 { + // Read value + if let Some((_, fetched_value)) = iter.next() { + last_entry.attached_value = Some(fetched_value); + } else { + return Err(Box::new(TrieError::IncompleteDatabase(>::default()))) + } + } + + loop { + if !last_entry.advance_child_index()? { + last_entry.push_to_prefix(&mut prefix); + stack.push(last_entry); + break + } + + let hash = last_entry + .attached_value + .as_ref() + .map(|value| db.insert(prefix.as_prefix(), value)); + let node_data = last_entry.encode_node(hash.as_ref().map(|h| h.as_ref())); + let node_hash = db.insert(prefix.as_prefix(), node_data.as_ref()); + + if let Some(entry) = stack.pop() { + last_entry = entry; + last_entry.pop_from_prefix(&mut prefix); + last_entry.children[last_entry.child_index] = Some(ChildReference::Hash(node_hash)); + last_entry.child_index += 1; + } else { + return Ok((node_hash, i + 1)) + } + } + } + + Err(Box::new(TrieError::IncompleteDatabase(>::default()))) +} diff --git a/trie-db/CHANGELOG.md b/trie-db/CHANGELOG.md index 07ecc2e8..87fa58d7 100644 --- a/trie-db/CHANGELOG.md +++ b/trie-db/CHANGELOG.md @@ -4,6 +4,19 @@ The format is based on [Keep a Changelog]. [Keep a Changelog]: http://keepachangelog.com/en/1.0.0/ +## [Unreleased] +- Fix the item count returned by `decode_compact`/`decode_compact_from_iter` when the last + decoded node carries an attached (detached-at-encoding) value: the value item was not counted, + so continuing to decode concatenated encodings at the returned offset re-read the value as a + node. +- Add `encode_compact_skip_duplicates`, emitting each distinct item in a compact proof — trie + node or detached value node — only once instead of once per referencing position. Deduplicated + encodings reconstruct a readable hash-keyed database with any decoder, including already + released ones; decoding them into position-keyed (prefixed) databases, or relying on + reconstructed reference counts, is unsupported — a proof is a record of state accesses, not a + database to mutate + ([polkadot-sdk#12565](https://github.com/paritytech/polkadot-sdk/issues/12565)). + ## [0.30.0] - 2025-03-06 - Improve `TrieCache` size by reducing size_of `NodeOwned` [#216](https://github.com/paritytech/trie/pull/216) diff --git a/trie-db/fuzz/Cargo.toml b/trie-db/fuzz/Cargo.toml index 0d92c2a9..d0e223e4 100644 --- a/trie-db/fuzz/Cargo.toml +++ b/trie-db/fuzz/Cargo.toml @@ -10,7 +10,7 @@ cargo-fuzz = true [dependencies] hash-db = { path = "../../hash-db", version = "0.16.0" } -memory-db = { path = "../../memory-db", version = "0.33.0" } +memory-db = { path = "../../memory-db", version = "0.34.0" } reference-trie = { path = "../../test-support/reference-trie", version = "0.29.1" } arbitrary = { version = "1.3.0", features = ["derive"] } array-bytes = "6.0.0" @@ -61,6 +61,14 @@ path = "fuzz_targets/trie_proof_valid.rs" name = "trie_codec_proof" path = "fuzz_targets/trie_codec_proof.rs" +[[bin]] +name = "trie_codec_proof_dedup" +path = "fuzz_targets/trie_codec_proof_dedup.rs" + +[[bin]] +name = "trie_codec_proof_dedup_guided" +path = "fuzz_targets/trie_codec_proof_dedup_guided.rs" + [[bin]] name = "trie_proof_invalid" path = "fuzz_targets/trie_proof_invalid.rs" diff --git a/trie-db/fuzz/fuzz_targets/trie_codec_proof_dedup.rs b/trie-db/fuzz/fuzz_targets/trie_codec_proof_dedup.rs new file mode 100644 index 00000000..2ce0e813 --- /dev/null +++ b/trie-db/fuzz/fuzz_targets/trie_codec_proof_dedup.rs @@ -0,0 +1,18 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use trie_db_fuzz::{ + fuzz_that_trie_codec_proofs, fuzz_that_trie_codec_proofs_with_shared_subtrees, + fuzz_that_trie_codec_proofs_with_shared_values, +}; + +fuzz_target!(|data: &[u8]| { + // Hashed-value layout, so value detachment and deduplication are exercised. + fuzz_that_trie_codec_proofs::>(data); + fuzz_that_trie_codec_proofs_with_shared_values::>( + data, + ); + fuzz_that_trie_codec_proofs_with_shared_subtrees::>( + data, + ); +}); diff --git a/trie-db/fuzz/fuzz_targets/trie_codec_proof_dedup_guided.rs b/trie-db/fuzz/fuzz_targets/trie_codec_proof_dedup_guided.rs new file mode 100644 index 00000000..2ea73ce6 --- /dev/null +++ b/trie-db/fuzz/fuzz_targets/trie_codec_proof_dedup_guided.rs @@ -0,0 +1,13 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use trie_db_fuzz::{fuzz_dedup_scenario, DedupScenario}; + +// Structure-aware, differential harness for deduplicated compact proofs. `DedupScenario` is built +// via `arbitrary`, so libFuzzer mutates the trie/value structure directly instead of a raw byte +// stream fed through `fuzz_to_data`. +fuzz_target!(|scenario: DedupScenario| { + // Hashed-value layout: values are detachable value nodes, so value- and subtree-level + // deduplication are both exercised. + fuzz_dedup_scenario::>(scenario); +}); diff --git a/trie-db/fuzz/src/lib.rs b/trie-db/fuzz/src/lib.rs index d8beb97e..a5ccaab8 100644 --- a/trie-db/fuzz/src/lib.rs +++ b/trie-db/fuzz/src/lib.rs @@ -218,7 +218,7 @@ pub fn fuzz_prefix_iter(input: &[u8]) { assert_eq!(error, 0); } -#[derive(Debug, Arbitrary)] +#[derive(Debug, Clone, Arbitrary)] pub struct PrefixSeekTestInput { keys: Vec>, prefix_key: Vec, @@ -310,6 +310,61 @@ pub fn fuzz_that_trie_codec_proofs(input: &[u8]) { test_trie_codec_proof::(data, keys); } +pub fn fuzz_that_trie_codec_proofs_with_shared_values(input: &[u8]) { + let mut data = fuzz_to_data(input); + // Draw values from a tiny alphabet of large values, so many keys share a value node. + for (_, value) in data.iter_mut() { + let selector = value.first().copied().unwrap_or(0) % 4; + *value = vec![selector; 64]; + } + // Split data into 3 parts: + // - the first 1/3 is added to the trie and not included in the proof + // - the second 1/3 is added to the trie and included in the proof + // - the last 1/3 is not added to the trie and the proof proves non-inclusion of them + let mut keys = data[(data.len() / 3)..].iter().map(|(key, _)| key.clone()).collect::>(); + data.truncate(data.len() * 2 / 3); + + let data = data_sorted_unique(data); + keys.sort(); + keys.dedup(); + + test_trie_codec_proof::(data, keys); +} + +pub fn fuzz_that_trie_codec_proofs_with_shared_subtrees(input: &[u8]) { + let data = fuzz_to_data(input); + // Mirror every key under two first-nibble prefixes with identical suffixes, drawing values + // from a tiny alphabet of large values, so the trie contains identically encoded sibling + // subtrees (exercising node-level deduplication) referencing shared value nodes (for layouts + // detaching values). + let mut mirrored = Vec::with_capacity(data.len() * 2); + for (key, value) in data { + let value = vec![value.first().copied().unwrap_or(0) % 4; 64]; + for prefix in [0x00u8, 0x10] { + let mut mirrored_key = Vec::with_capacity(key.len() + 1); + mirrored_key.push(prefix); + mirrored_key.extend_from_slice(&key); + mirrored.push((mirrored_key, value.clone())); + } + } + // Split data into 3 parts: + // - the first 1/3 is added to the trie and not included in the proof + // - the second 1/3 is added to the trie and included in the proof + // - the last 1/3 is not added to the trie and the proof proves non-inclusion of them + // Mirrored pairs are adjacent, so the split mostly keeps both occurrences of a subtree. + let mut keys = mirrored[(mirrored.len() / 3)..] + .iter() + .map(|(key, _)| key.clone()) + .collect::>(); + mirrored.truncate(mirrored.len() * 2 / 3); + + let mirrored = data_sorted_unique(mirrored); + keys.sort(); + keys.dedup(); + + test_trie_codec_proof::(mirrored, keys); +} + pub fn fuzz_that_verify_rejects_invalid_proofs(input: &[u8]) { if input.len() < 4 { return @@ -376,9 +431,336 @@ fn test_generate_proof( (root, proof, items) } +/// Structure-aware input for the deduplicating compact-proof harness. +/// +/// Unlike the byte-stream `fuzz_to_data` harnesses (which produce mostly disjoint keys and shallow +/// tries), this describes several tries drawn from a shared value pool, with keys squeezed into a +/// small nibble alphabet and optional (possibly nested) subtree mirroring. That deliberately +/// manufactures colliding keys, deep branches, extensions, nibbled branches carrying a value *and* +/// children, and recursively duplicated subtrees — the structures the deduplication code paths +/// need. Sharing one value pool across tries also exercises cross-encoding deduplication +/// (a threaded `seen_hashes` set). +#[derive(Debug, Clone, Arbitrary)] +pub struct DedupScenario { + /// The tries in the scenario; capped to [`DedupScenario::MAX_TRIES`] at build time. + tries: Vec, + /// Pool of candidate values, shared by every trie so values (and whole subtrees) collide. + value_pool: Vec, +} + +impl DedupScenario { + const MAX_TRIES: usize = 4; + const MAX_BASE_KEYS: usize = 12; + const MAX_KEY_BYTES: usize = 6; + /// Squeeze raw bytes into a small alphabet so keys collide and branches form. + const KEY_ALPHABET: usize = 4; +} + +#[derive(Debug, Clone, Arbitrary)] +struct TrieSpec { + /// Raw key material; squeezed to a small nibble alphabet at build time. + keys: Vec, + /// Number of identical sibling subtrees to mirror the key set into (`1 + n % 4`, i.e. 1..=4). + /// A value of 1 disables mirroring. + mirror_copies: u8, + /// When set, mirror the key set one extra level down first, so a duplicated subtree itself + /// contains a duplicated sub-subtree (exercises nested subtree deduplication). + nested_mirror: bool, + /// Indices (modulo entry count) of the keys proven into the partial trie. Empty means "all". + queried: Vec, +} + +#[derive(Debug, Clone, Arbitrary)] +struct KeySpec { + /// Raw key bytes; squeezed to the small alphabet and truncated at build time. + bytes: Vec, + /// Index (modulo pool length) of this key's value in the shared value pool. + value: u16, +} + +#[derive(Debug, Clone, Arbitrary)] +struct ValueSpec { + /// Byte the value is filled with (small alphabet keeps distinct values few and sharing + /// likely). + selector: u8, + /// Large values become separate (detachable) value nodes; small ones stay inline. + large: bool, +} + +/// Map a raw byte to the small key alphabet. Each symbol has equal nibbles so branches diverge at +/// byte boundaries: {0x00, 0x11, 0x22, 0x33}. +fn alphabet_byte(raw: u8) -> u8 { + let symbol = (raw as usize % DedupScenario::KEY_ALPHABET) as u8; + symbol * 0x11 +} + +/// Prepend `prefix` to every key in `entries`, cloning the values. +fn mirror_entries(entries: &[(Vec, Vec)], prefix: u8) -> Vec<(Vec, Vec)> { + entries + .iter() + .map(|(key, value)| { + let mut mirrored = Vec::with_capacity(key.len() + 1); + mirrored.push(prefix); + mirrored.extend_from_slice(key); + (mirrored, value.clone()) + }) + .collect() +} + +/// Materialize the shared value pool. Each spec is either a small inline value or a large value +/// node; an empty pool falls back to one of each. +fn build_value_pool(specs: &[ValueSpec]) -> Vec> { + if specs.is_empty() { + return vec![vec![0u8], vec![1u8; 32]] + } + specs + .iter() + .map(|spec| if spec.large { vec![spec.selector; 32] } else { vec![spec.selector] }) + .collect() +} + +/// Build the sorted, unique `(key, value)` entries for one trie from its spec. +fn build_entries(spec: &TrieSpec, value_pool: &[Vec]) -> Vec<(Vec, Vec)> { + // Base keys, squeezed into the small alphabet, truncated, and non-empty. + let mut base: Vec<(Vec, Vec)> = Vec::new(); + for key_spec in spec.keys.iter().take(DedupScenario::MAX_BASE_KEYS) { + let key: Vec = key_spec + .bytes + .iter() + .take(DedupScenario::MAX_KEY_BYTES) + .map(|raw| alphabet_byte(*raw)) + .collect(); + if key.is_empty() { + continue + } + let value = value_pool[key_spec.value as usize % value_pool.len()].clone(); + base.push((key, value)); + } + + // Optionally mirror one extra level down first, so mirrored subtrees nest. + if spec.nested_mirror { + let mut nested = mirror_entries(&base, 0x01); + nested.extend(mirror_entries(&base, 0x02)); + base = nested; + } + + // Mirror into `copies` identical sibling subtrees below the root (distinct first nibbles). + let copies = 1 + spec.mirror_copies as usize % 4; + let entries = if copies >= 2 { + const OUTER_PREFIXES: [u8; 4] = [0x00, 0x10, 0x20, 0x30]; + let mut mirrored = Vec::new(); + for &prefix in OUTER_PREFIXES.iter().take(copies) { + mirrored.extend(mirror_entries(&base, prefix)); + } + mirrored + } else { + base + }; + + data_sorted_unique(entries) +} + +/// Select the keys proven into the partial trie: the listed indices (modulo entry count), or all +/// keys when none are listed. +fn select_queried(spec: &TrieSpec, entries: &[(Vec, Vec)]) -> Vec> { + if spec.queried.is_empty() { + return entries.iter().map(|(key, _)| key.clone()).collect() + } + let mut keys: Vec> = spec + .queried + .iter() + .map(|index| entries[*index as usize % entries.len()].0.clone()) + .collect(); + keys.sort(); + keys.dedup(); + keys +} + +/// Structure-aware, differential fuzz harness for deduplicated compact proofs. +/// +/// Builds up to [`DedupScenario::MAX_TRIES`] tries, records the queried keys of each, and +/// consolidates all recorded nodes into one frozen union set — the fixed-backing-set +/// precondition of [`encode_compact_skip_duplicates`](trie_db::encode_compact_skip_duplicates) +/// (in Substrate, the single proof recorder shared by a whole block). Every trie is then encoded +/// from that set: plain ([`encode_compact`](trie_db::encode_compact)), self-contained +/// deduplicated (fresh seen-set) and deduplicated with one `seen_hashes` set threaded across all +/// tries. Plain encodings decode independently; threaded ones accumulate into one shared +/// hash-keyed database. +/// +/// Oracles: +/// - every deduplicated encoding reconstructs its root and all proven keys; +/// - deduplication never grows the encoding (`Σ dedup <= Σ plain`); +/// - re-encoding a fully-seen trie emits only its root ("always emit root"); +/// - a self-contained encoding reconstructs the same node set as plain; +/// - a self-contained encoding stays decodable by the released 0.31.0 decoder (hash-keyed); +/// - the threaded reconstruction yields exactly the node set of the plain reconstructions: +/// deduplication drops re-emissions, never nodes. Reference counts and positions are not +/// compared — a proof is a hash-keyed node set, not a mutable database, and deduplicated +/// encodings deliberately do not reconstruct per-position bookkeeping. +/// +/// Threading `seen_hashes` across per-trie recorded sets instead violates the precondition and +/// drops nodes; `divergent_coverage_drops_nodes` in the smoke tests pins that failure mode. +pub fn fuzz_dedup_scenario(scenario: DedupScenario) { + use hash_db::{HashDB, EMPTY_PREFIX}; + use trie_db::{ + decode_compact, decode_compact_from_iter, encode_compact, encode_compact_skip_duplicates, + Recorder, SeenHashes, + }; + + let value_pool = build_value_pool(&scenario.value_pool); + + // Databases accumulated across all tries for the differential oracle. `expected_hashed` is + // the plain reconstruction, `actual_hashed` the threaded deduplicated one. + let mut expected_hashed = MemoryDB::, DBValue>::default(); + let mut actual_hashed = MemoryDB::, DBValue>::default(); + + // Deduplication state threaded across every trie in the scenario. + let mut seen_hashes = SeenHashes::default(); + + let mut total_plain = 0usize; + let mut total_dedup = 0usize; + + // Phase 1: build every trie and record its queried keys, consolidating all recorded nodes + // into one frozen union set — so every encoding sees the same coverage below any shared hash + // (the precondition of `encode_compact_skip_duplicates`). + let mut union_partial = MemoryDB::, DBValue>::default(); + let mut recorded = Vec::new(); + for trie_spec in scenario.tries.iter().take(DedupScenario::MAX_TRIES) { + let entries = build_entries(trie_spec, &value_pool); + if entries.is_empty() { + continue + } + + // Build the full trie in a hash-keyed database. + let mut db = MemoryDB::, DBValue>::default(); + let mut root = Default::default(); + { + let mut trie = TrieDBMutBuilder::::new(&mut db, &mut root).build(); + for (key, value) in &entries { + trie.insert(key, value).unwrap(); + } + } + + // Record the partial trie for the proven keys. + let queried = select_queried(trie_spec, &entries); + let mut recorder = Recorder::::new(); + let mut items = Vec::with_capacity(queried.len()); + { + let trie = TrieDBBuilder::::new(&db, &root).with_recorder(&mut recorder).build(); + for key in &queried { + let value = trie.get(key).unwrap(); + items.push((key.clone(), value)); + } + } + for record in recorder.drain() { + union_partial.emplace(record.hash, EMPTY_PREFIX, record.data); + } + recorded.push((root, items)); + } + if recorded.is_empty() { + return + } + + // Phase 2: encode every trie from the frozen union set, threading `seen_hashes` in order. + for (root, items) in &recorded { + // Encode the partial trie three ways: plain, deduplicated with the threaded `seen_hashes`, + // and deduplicated standalone (a fresh seen-set, i.e. a self-contained proof). + let (plain, dedup, standalone) = { + let trie = TrieDBBuilder::::new(&union_partial, root).build(); + let plain = encode_compact::(&trie).unwrap(); + let standalone = + encode_compact_skip_duplicates::(&trie, &mut SeenHashes::default()).unwrap(); + let dedup = encode_compact_skip_duplicates::(&trie, &mut seen_hashes).unwrap(); + (plain, dedup, standalone) + }; + assert!(dedup.len() <= plain.len(), "deduplication must not grow the encoding"); + total_plain += plain.len(); + total_dedup += dedup.len(); + + // Idempotency: with every item already seen, re-encoding emits only the root. + { + let trie = TrieDBBuilder::::new(&union_partial, root).build(); + let reencoded = + encode_compact_skip_duplicates::(&trie, &mut seen_hashes.clone()).unwrap(); + assert_eq!(reencoded.len(), 1, "re-encoding a fully-seen trie must emit only the root"); + } + + // A single self-contained encoding reconstructs the same node set as plain: within one + // proof only references to already-emitted items are collapsed, so nothing goes missing. + // Reference counts differ by design and are not compared. + { + let mut plain_hashed = MemoryDB::, DBValue>::default(); + let mut standalone_hashed = MemoryDB::, DBValue>::default(); + decode_compact::(&mut plain_hashed, &plain).unwrap(); + decode_compact::(&mut standalone_hashed, &standalone).unwrap(); + assert!( + db_key_set(&plain_hashed) == db_key_set(&standalone_hashed), + "single-encoding node-set mismatch" + ); + } + + // Backward compatibility: the released 0.31.0 decoder must still reconstruct a readable + // hash-keyed database from a self-contained encoding (an old node verifying a new proof). + // It under-counts references, so we check readability only, not equality. Hash-keyed + // only: 0.31.0 mis-prefixes attached values in a position-keyed database (fixed by #227). + { + let mut old_db = MemoryDB::, DBValue>::default(); + let (old_root, _) = reference_trie::trie_db_0_31_decoder::decode_compact_from_iter::< + L, + _, + _, + >(&mut old_db, standalone.iter().map(Vec::as_slice)) + .unwrap(); + assert_eq!(&old_root, root, "released 0.31.0 decoder must reconstruct the same root"); + let old_trie = TrieDBBuilder::::new(&old_db, &old_root).build(); + for (key, expected_value) in items { + assert_eq!( + &old_trie.get(key).unwrap(), + expected_value, + "released 0.31.0 decoder must recover every proven key (refcounts aside)" + ); + } + } + + // Decode the plain encoding independently into the expected database. + let (decoded_root, _) = decode_compact::(&mut expected_hashed, &plain).unwrap(); + assert_eq!(&decoded_root, root); + + // Decode the deduplicated encoding into the shared actual database: items deduplicated + // across encodings are present from the encoding that emitted them. + let (decoded_root, _) = + decode_compact_from_iter::(&mut actual_hashed, dedup.iter().map(Vec::as_slice)) + .unwrap(); + assert_eq!(&decoded_root, root); + + // Every proven key resolves to its expected value in the reconstructed trie. + let trie = TrieDBBuilder::::new(&actual_hashed, root).build(); + for (key, expected_value) in items { + assert_eq!(&trie.get(key).unwrap(), expected_value); + } + } + + // The threaded, cross-encoding differential: deduplication drops re-emissions, never nodes, + // so the threaded reconstruction holds exactly the node set of the plain reconstructions. + // Reference counts and positions are not compared — deduplicated encodings deliberately do + // not reconstruct per-position bookkeeping. + assert!( + db_key_set(&actual_hashed) == db_key_set(&expected_hashed), + "threaded reconstruction differs from the plain node set" + ); + assert!(total_dedup <= total_plain); +} + +/// The stored keys of a hash-keyed database, ignoring reference counts. +fn db_key_set( + db: &MemoryDB, DBValue>, +) -> std::collections::BTreeSet> { + db.keys().into_iter().map(|(key, _rc)| key.as_ref().to_vec()).collect() +} + fn test_trie_codec_proof(entries: Vec<(Vec, Vec)>, keys: Vec>) { use hash_db::{HashDB, EMPTY_PREFIX}; - use trie_db::{decode_compact, encode_compact, Recorder}; + use trie_db::{decode_compact, encode_compact, encode_compact_skip_duplicates, Recorder}; // Populate DB with full trie from entries. let (db, root) = { @@ -426,7 +808,26 @@ fn test_trie_codec_proof(entries: Vec<(Vec, Vec)>, keys: // Check that lookups for all items succeed. let trie = TrieDBBuilder::::new(&db, &root).build(); - for (key, expected_value) in items { - assert_eq!(trie.get(key.as_slice()).unwrap(), expected_value); + for (key, expected_value) in &items { + assert_eq!(&trie.get(key.as_slice()).unwrap(), expected_value); + } + + // Round-trip the deduplicating encoding into a hash-keyed database. + let deduplicated = { + let trie = TrieDBBuilder::::new(&partial_db, &expected_root).build(); + encode_compact_skip_duplicates::(&trie, &mut Default::default()).unwrap() + }; + assert!(deduplicated.len() <= expected_used); + + let mut hash_keyed_db = , _>>::default(); + let (root, used) = decode_compact::(&mut hash_keyed_db, &deduplicated).unwrap(); + assert_eq!(root, expected_root); + assert_eq!(used, deduplicated.len()); + // Deduplication must not change the reconstructed node set (`db` holds the decoded + // unmodified encoding); reference counts differ by design and are not compared. + assert!(db_key_set(&hash_keyed_db) == db_key_set(&db)); + let trie = TrieDBBuilder::::new(&hash_keyed_db, &root).build(); + for (key, expected_value) in &items { + assert_eq!(&trie.get(key.as_slice()).unwrap(), expected_value); } } diff --git a/trie-db/fuzz/tests/smoke.rs b/trie-db/fuzz/tests/smoke.rs new file mode 100644 index 00000000..07a41296 --- /dev/null +++ b/trie-db/fuzz/tests/smoke.rs @@ -0,0 +1,142 @@ +// Copyright 2026 Parity Technologies +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Deterministic smoke runs of the fuzz harnesses, so their assertions are exercised in CI +//! without a libFuzzer setup. + +use arbitrary::{Arbitrary, Unstructured}; +use reference_trie::HashedValueNoExtThreshold; +use trie_db_fuzz::{ + fuzz_dedup_scenario, fuzz_that_trie_codec_proofs, + fuzz_that_trie_codec_proofs_with_shared_subtrees, + fuzz_that_trie_codec_proofs_with_shared_values, DedupScenario, +}; + +fn xorshift(state: &mut u64) -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state +} + +#[test] +fn trie_codec_proof_dedup_smoke() { + let mut state = 0x0123_4567_89ab_cdefu64; + for len in (0..2048usize).step_by(37) { + let input: Vec = (0..len).map(|_| xorshift(&mut state) as u8).collect(); + // Hashed-value layout, so value detachment and deduplication are exercised. + fuzz_that_trie_codec_proofs::>(&input); + fuzz_that_trie_codec_proofs_with_shared_values::>(&input); + fuzz_that_trie_codec_proofs_with_shared_subtrees::>(&input); + } +} + +#[test] +fn trie_codec_proof_dedup_guided_smoke() { + let mut state = 0xdead_beef_cafe_f00du64; + for len in (0..4096usize).step_by(29) { + let input: Vec = (0..len).map(|_| xorshift(&mut state) as u8).collect(); + // Structure-aware scenario built the same way libFuzzer builds it, via `arbitrary`. + let mut unstructured = Unstructured::new(&input); + if let Ok(scenario) = DedupScenario::arbitrary(&mut unstructured) { + fuzz_dedup_scenario::>(scenario); + } + } +} + +/// Fuzzer-found inputs, replayed byte-for-byte the way libFuzzer feeds them. +#[test] +fn fuzzer_found_scenarios() { + const INPUTS: &[&str] = &[ + // crash-bf782723…: shared subtrees deduplicated across threaded encodings; historically + // pinned the reference-count semantics of the (since removed) re-inserting decoder, kept + // as a regression scenario for the node-set invariant of `fuzz_dedup_scenario`. + "3fff413f20b5b5b5b54185018601ff00858b01028681ff96fffffffaffff41fa96ffffa1a1ff96ffffe2ffb1b524fafafbfa962c01", + // crash-ef90b66d…: proofs covering a shared subtree to different depths; dropped a node + // when the harness encoded from per-trie recorded sets instead of their union (the + // fixed-backing-set precondition of `encode_compact_skip_duplicates`). + "c901cd01002b2b0b0100000081402b2b2b2b0b0011ab3105d2ffffff01b000d2f90660cf2b", + ]; + for input in INPUTS { + let bytes = array_bytes::hex2bytes(*input).unwrap(); + let scenario = DedupScenario::arbitrary_take_rest(Unstructured::new(&bytes)).unwrap(); + fuzz_dedup_scenario::>(scenario); + } +} + +/// Violating the precondition of `encode_compact_skip_duplicates` — threading `seen_hashes` +/// across encodings from per-proof recorded sets whose coverage of a shared node diverges — +/// silently drops nodes. This pins the failure mode; if the encoder is ever hardened against +/// it, update this test (and the harness doc) accordingly. +#[test] +fn divergent_coverage_drops_nodes() { + use hash_db::{HashDB, EMPTY_PREFIX}; + use memory_db::{HashKey, MemoryDB}; + use trie_db::{ + decode_compact_from_iter, encode_compact_skip_duplicates, DBValue, Recorder, SeenHashes, + Trie, TrieDBBuilder, TrieDBMutBuilder, TrieError, TrieLayout, TrieMut, + }; + type L = HashedValueNoExtThreshold<1>; + type H = ::Hash; + + let entries: Vec<(Vec, Vec)> = vec![ + (vec![0x11, 0x00], vec![0]), + (vec![0x11, 0x11], vec![1; 32]), + (vec![0x22, 0x22], vec![0]), + ]; + let mut db = MemoryDB::, DBValue>::default(); + let mut root = Default::default(); + { + let mut trie = TrieDBMutBuilder::::new(&mut db, &mut root).build(); + for (key, value) in &entries { + trie.insert(key, value).unwrap(); + } + } + + // Two proofs of the same trie, each recorded independently, so the branch above the two + // 0x11… leaves is a boundary node of proof 0 and covered deeper by proof 1. + let mut seen = SeenHashes::default(); + let mut reconstructed = MemoryDB::, DBValue>::default(); + let queried: [&[u8]; 2] = [&[0x11, 0x00], &[0x11, 0x11]]; + for (proof, key) in queried.iter().enumerate() { + let mut recorder = Recorder::::new(); + { + let trie = TrieDBBuilder::::new(&db, &root).with_recorder(&mut recorder).build(); + trie.get(key).unwrap().unwrap(); + } + let mut partial = MemoryDB::, DBValue>::default(); + for record in recorder.drain() { + partial.emplace(record.hash, EMPTY_PREFIX, record.data); + } + let encoded = { + let trie = TrieDBBuilder::::new(&partial, &root).build(); + encode_compact_skip_duplicates::(&trie, &mut seen).unwrap() + }; + decode_compact_from_iter::(&mut reconstructed, encoded.iter().map(Vec::as_slice)) + .unwrap(); + + let trie = TrieDBBuilder::::new(&reconstructed, &root).build(); + let result = trie.get(key); + if proof == 0 { + assert!(result.unwrap().is_some(), "proof 0 must be readable"); + } else { + // Proof 1's encoding skipped the seen boundary branch, dropping the leaf that only + // proof 1 covers — the node exists in no encoding at all. + assert!( + matches!(*result.unwrap_err(), TrieError::IncompleteDatabase(_)), + "expected the documented node drop under divergent coverage" + ); + } + } +} diff --git a/trie-db/src/iterator.rs b/trie-db/src/iterator.rs index f8b9381b..dd9c7457 100644 --- a/trie-db/src/iterator.rs +++ b/trie-db/src/iterator.rs @@ -125,6 +125,16 @@ impl TrieDBRawIterator { .push(Crumb { hash: node_hash, status: Status::Entering, node: Arc::new(node) }); } + /// Skip the descendants of the node most recently yielded by `next_raw_item`: iteration + /// continues with the node's next sibling (or an ancestor's). + /// + /// Must only be called directly after `next_raw_item(_, true)` yielded a node. + pub(crate) fn skip_current_subtree(&mut self) { + if let Some(crumb) = self.trail.last_mut() { + crumb.status = Status::AftExiting; + } + } + /// Fetch value by hash at a current node height pub(crate) fn fetch_value( db: &TrieDB, diff --git a/trie-db/src/lib.rs b/trie-db/src/lib.rs index 2c091eaa..043a76d4 100644 --- a/trie-db/src/lib.rs +++ b/trie-db/src/lib.rs @@ -22,7 +22,7 @@ extern crate alloc; mod rstd { pub use std::{ borrow, boxed, cmp, - collections::{BTreeMap, VecDeque}, + collections::{BTreeMap, BTreeSet, VecDeque}, convert, error::Error, fmt, hash, iter, marker, mem, ops, result, sync, vec, @@ -33,7 +33,7 @@ mod rstd { mod rstd { pub use alloc::{ borrow, boxed, - collections::{btree_map::BTreeMap, VecDeque}, + collections::{btree_map::BTreeMap, btree_set::BTreeSet, VecDeque}, rc, sync, vec, }; pub use core::{cmp, convert, fmt, hash, iter, marker, mem, ops, result}; @@ -81,7 +81,10 @@ pub use crate::{ iter_build::{trie_visit, ProcessEncodedNode, TrieBuilder, TrieRoot, TrieRootUnhashed}, iterator::{TrieDBNodeIterator, TrieDBRawIterator}, node_codec::{NodeCodec, Partial}, - trie_codec::{decode_compact, decode_compact_from_iter, encode_compact}, + trie_codec::{ + decode_compact, decode_compact_from_iter, encode_compact, encode_compact_skip_duplicates, + SeenHashes, + }, }; pub use hash_db::{HashDB, HashDBRef, Hasher}; diff --git a/trie-db/src/trie_codec.rs b/trie-db/src/trie_codec.rs index da7af4ed..73cfe8fe 100644 --- a/trie-db/src/trie_codec.rs +++ b/trie-db/src/trie_codec.rs @@ -24,11 +24,25 @@ //! hash references to nodes not in the partial trie are left intact. The compact encoding can be //! expected to save roughly (n - 1) hashes in size where n is the number of nodes in the partial //! trie. +//! +//! A value node contained in the partial trie (see [`TrieLayout::MAX_INLINE_VALUE`]) is +//! "detached": the referencing node is emitted with an escape header (see +//! `NodeCodec::ESCAPE_HEADER`) and an empty inline value, directly followed by the value bytes +//! as a standalone item. A node whose value node is *not* part of the partial trie is emitted +//! unmodified, still referencing its value by hash. +//! +//! `encode_compact` re-emits a shared item once per position (a detached value per referencing +//! node, a duplicated subtree per occurrence). [`encode_compact_skip_duplicates`] instead emits +//! each distinct item only once; later occurrences keep a plain hash reference, like any +//! reference to an item outside the partial trie. use crate::{ nibble_ops::NIBBLE_LENGTH, - node::{Node, NodeHandle, NodeHandlePlan, NodePlan, OwnedNode, ValuePlan}, - rstd::{boxed::Box, convert::TryInto, marker::PhantomData, result, sync::Arc, vec, vec::Vec}, + node::{decode_hash, Node, NodeHandle, NodeHandlePlan, NodePlan, OwnedNode, ValuePlan}, + rstd::{ + boxed::Box, convert::TryInto, marker::PhantomData, result, sync::Arc, vec, vec::Vec, + BTreeSet, + }, CError, ChildReference, DBValue, NibbleVec, NodeCodec, Result, TrieDB, TrieDBRawIterator, TrieError, TrieHash, TrieLayout, }; @@ -184,27 +198,59 @@ impl EncoderStackEntry { } } +/// Hashes of items already emitted by [`encode_compact_skip_duplicates`], so later occurrences +/// keep a plain hash reference instead of being emitted again. +/// +/// Node and value hashes are kept in separate namespaces: skipping a node drops its whole subtree +/// and is only sound once that subtree was emitted, which a value can never guarantee. A shared +/// set would let a value equal to a node's encoding trigger the skip and drop the subtree. +pub struct SeenHashes { + nodes: BTreeSet>, + values: BTreeSet>, +} + +// Hand-written impls: deriving would add a spurious `L: Default`/`L: Clone` bound. +impl Default for SeenHashes { + fn default() -> Self { + SeenHashes { nodes: BTreeSet::new(), values: BTreeSet::new() } + } +} + +impl Clone for SeenHashes { + fn clone(&self) -> Self { + SeenHashes { nodes: self.nodes.clone(), values: self.values.clone() } + } +} + /// Detached value if included does write a reserved header, /// followed by node encoded with 0 length value and the value /// as a standalone vec. +/// +/// When `seen` is given, a value whose hash is already in the value namespace is not detached +/// again. Only hashes of actually emitted values are added to the set. fn detached_value( db: &TrieDB, value: &ValuePlan, node_data: &[u8], node_prefix: Prefix, + seen: Option<&mut SeenHashes>, ) -> Option> { - let fetched; - match value { - ValuePlan::Node(hash_plan) => { - if let Ok(value) = - TrieDBRawIterator::fetch_value(db, &node_data[hash_plan.clone()], node_prefix) - { - fetched = value; - } else { - return None - } - }, + let hash_plan = match value { + ValuePlan::Node(hash_plan) => hash_plan, _ => return None, + }; + let value_hash = &node_data[hash_plan.clone()]; + + let dedup_key = seen.as_ref().and_then(|_| decode_hash::(value_hash)); + if let (Some(seen), Some(key)) = (&seen, &dedup_key) { + // Already emitted once: keep the plain hash reference instead of detaching again. + if seen.values.contains(key) { + return None + } + } + let fetched = TrieDBRawIterator::fetch_value(db, value_hash, node_prefix).ok()?; + if let (Some(seen), Some(key)) = (seen, dedup_key) { + seen.values.insert(key); } Some(fetched) } @@ -214,9 +260,52 @@ fn detached_value( /// are listed in pre-order traversal order so that the full nodes can be efficiently /// reconstructed recursively. /// +/// A shared detached value node is emitted once per referencing node and a duplicated subtree +/// once per occurrence (see [`encode_compact_skip_duplicates`]). +/// /// This function makes the assumption that all child references in an inline trie node are inline /// references. pub fn encode_compact(db: &TrieDB) -> Result>, TrieHash, CError> +where + L: TrieLayout, +{ + encode_compact_inner(db, None) +} + +/// Variant of [`encode_compact`] that emits each distinct item — trie node or detached value +/// node — only once. Later occurrences keep a plain hash reference to the emitted item, exactly +/// like references to items outside the partial trie. Only items at least as large as a hash are +/// referenced this way, so deduplication never grows the encoding. +/// +/// `seen` collects the emitted hashes, keeping trie-node and detached-value hashes in disjoint +/// namespaces (see [`SeenHashes`]). +/// +/// All encodings sharing one `seen` set must be generated from a single, fixed backing +/// node set: a skipped subtree is reconstructable only if everything below it was emitted when +/// its root was first seen. Encoding from per-proof recorded sets whose coverage of a shared +/// node diverges silently drops the divergent nodes and produces unverifiable proofs. +/// +/// A deduplicated occurrence is indistinguishable from a reference to an item outside the +/// partial trie, so any decoder reconstructs a readable hash-keyed +/// node set: every item is present under its hash from its first occurrence. Per-position +/// bookkeeping is deliberately not reconstructable: decoding into a position-keyed (prefixed) +/// database, or relying on the reconstruction's reference counts, is unsupported. +/// +/// Assumes occurrences of an item are interchangeable, as they are when `db` is hash-keyed. +pub fn encode_compact_skip_duplicates( + db: &TrieDB, + seen: &mut SeenHashes, +) -> Result>, TrieHash, CError> +where + L: TrieLayout, +{ + encode_compact_inner(db, Some(seen)) +} + +fn encode_compact_inner( + db: &TrieDB, + mut seen: Option<&mut SeenHashes>, +) -> Result>, TrieHash, CError> where L: TrieLayout, { @@ -241,8 +330,21 @@ where Ok((prefix, node_hash, node)) => { // Skip inline nodes, as they cannot contain hash references to other nodes by // assumption. - if node_hash.is_none() { - continue + let Some(node_hash) = node_hash else { continue }; + + if let Some(seen) = seen.as_deref_mut() { + let is_root = stack.is_empty(); + // A subtree whose root was already emitted is skipped entirely; the parent's + // `omit_children` bit stays unset, keeping a plain hash reference. The root is + // never skipped, so each encoding stays individually decodable when + // `seen` is threaded across successive encodings. Sound only under the + // fixed-backing-set precondition (see `encode_compact_skip_duplicates`): the + // subtree below a seen hash must not have grown since it was emitted. + if !is_root && seen.nodes.contains(node_hash) { + iter.skip_current_subtree(); + continue + } + seen.nodes.insert(*node_hash); } // Unwind the stack until the new entry is a child of the last entry on the stack. @@ -268,12 +370,28 @@ where let (children_len, detached_value) = match node.node_plan() { NodePlan::Empty => (0, None), - NodePlan::Leaf { value, .. } => - (0, detached_value(db, value, node.data(), prefix.as_prefix())), + NodePlan::Leaf { value, .. } => ( + 0, + detached_value( + db, + value, + node.data(), + prefix.as_prefix(), + seen.as_deref_mut(), + ), + ), NodePlan::Extension { .. } => (1, None), NodePlan::NibbledBranch { value: Some(value), .. } | - NodePlan::Branch { value: Some(value), .. } => - (NIBBLE_LENGTH, detached_value(db, value, node.data(), prefix.as_prefix())), + NodePlan::Branch { value: Some(value), .. } => ( + NIBBLE_LENGTH, + detached_value( + db, + value, + node.data(), + prefix.as_prefix(), + seen.as_deref_mut(), + ), + ), NodePlan::NibbledBranch { value: None, .. } | NodePlan::Branch { value: None, .. } => (NIBBLE_LENGTH, None), }; @@ -435,12 +553,13 @@ impl<'a, C: NodeCodec> DecoderStackEntry<'a, C> { /// mutated trie nodes with those child references omitted. The decode function reads them in order /// from the given slice, reconstructing the full nodes and inserting them into the given `HashDB`. /// It stops after fully constructing one partial trie and returns the root hash and the number of -/// nodes read. If an error occurs during decoding, there are no guarantees about which entries -/// were or were not added to the DB. +/// items read — trie nodes plus any detached value items consumed. If an error occurs during +/// decoding, there are no guarantees about which entries were or were not added to the DB. +/// +/// This count may be fewer than the total number of items in `encoded`. This allows one to +/// concatenate multiple compact encodings together and still reconstruct them all: decode the +/// next encoding starting at the returned offset. /// -/// The number of nodes read may be fewer than the total number of items in `encoded`. This allows -/// one to concatenate multiple compact encodings together and still reconstruct them all. -// /// This function makes the assumption that all child references in an inline trie node are inline /// references. pub fn decode_compact( @@ -534,7 +653,7 @@ where last_entry.children[last_entry.child_index] = Some(ChildReference::Hash(node_hash)); last_entry.child_index += 1; } else { - return Ok((node_hash, i + 1)) + return Ok((node_hash, i + 1 + attached_node)) } } } diff --git a/trie-db/test/src/trie_codec.rs b/trie-db/test/src/trie_codec.rs index cd77032e..3875263b 100644 --- a/trie-db/test/src/trie_codec.rs +++ b/trie-db/test/src/trie_codec.rs @@ -14,9 +14,11 @@ use hash_db::{HashDB, HashDBRef, Hasher, EMPTY_PREFIX}; use reference_trie::{test_layouts, ExtensionLayout}; +use std::collections::BTreeSet; use trie_db::{ - decode_compact, encode_compact, DBValue, NodeCodec, Recorder, Trie, TrieDBBuilder, - TrieDBMutBuilder, TrieError, TrieLayout, TrieMut, + decode_compact, decode_compact_from_iter, encode_compact, encode_compact_skip_duplicates, + DBValue, NodeCodec, Recorder, SeenHashes, Trie, TrieDBBuilder, TrieDBMutBuilder, TrieError, + TrieLayout, TrieMut, }; type MemoryDB = memory_db::MemoryDB< @@ -152,6 +154,457 @@ fn trie_decoding_fails_with_incomplete_database_internal() { } } +/// A value above every tested layout threshold, stored as a shared, hash-addressed value node. +const SHARED_VALUE: &[u8] = &[4; 33]; + +/// The stored keys of a hash-keyed database, ignoring reference counts. +fn db_key_set(db: &MemoryDB) -> BTreeSet> { + db.keys().into_iter().map(|(key, _rc)| key.as_ref().to_vec()).collect() +} + +/// Whether the layout stores [`SHARED_VALUE`] as a separate value node. +fn has_value_nodes() -> bool { + T::MAX_INLINE_VALUE.map_or(false, |threshold| threshold as usize <= SHARED_VALUE.len()) +} + +/// Count the encoded items that consist of exactly the shared value, i.e. its detached copies. +fn count_shared_value_items(encoded: &[Vec]) -> usize { + encoded.iter().filter(|item| item.as_slice() == SHARED_VALUE).count() +} + +/// Entries where five nodes (one branch with value, four leaves) reference the shared value. +fn shared_value_entries() -> Vec<(&'static [u8], &'static [u8])> { + vec![ + // "key" is at a branch node carrying the shared value. + (b"key", SHARED_VALUE), + (b"key1", SHARED_VALUE), + (b"key2", SHARED_VALUE), + (b"key3", SHARED_VALUE), + // A distinct value node, must not be affected by deduplication. + (b"key4", &[5; 32]), + // A leaf in an unrelated part of the trie, referencing the shared value. + (b"other", SHARED_VALUE), + ] +} + +fn build_trie( + entries: &[(&'static [u8], &'static [u8])], +) -> (MemoryDB, ::Out) { + let mut db = >::default(); + let mut root = Default::default(); + { + let mut trie = >::new(&mut db, &mut root).build(); + for (key, value) in entries.iter() { + trie.insert(key, value).unwrap(); + } + } + (db, root) +} + +fn assert_entries_match( + db: &impl HashDBRef, + root: ::Out, + entries: &[(&'static [u8], &'static [u8])], +) { + let trie = >::new(db, &root).build(); + for (key, value) in entries.iter() { + assert_eq!(trie.get(key).unwrap().as_deref(), Some(*value)); + } +} + +test_layouts!( + skip_duplicate_values_emits_shared_values_once, + skip_duplicate_values_emits_shared_values_once_internal +); +fn skip_duplicate_values_emits_shared_values_once_internal() { + let entries = shared_value_entries(); + let (db, root) = build_trie::(&entries); + + let trie = >::new(&db, &root).build(); + let encoded = encode_compact::(&trie).unwrap(); + let deduplicated = encode_compact_skip_duplicates::(&trie, &mut Default::default()).unwrap(); + + if has_value_nodes::() { + // One detached copy per referencing node without deduplication... + assert_eq!(count_shared_value_items(&encoded), 5); + // ...exactly one with it. + assert_eq!(count_shared_value_items(&deduplicated), 1); + // Four duplicate value copies are skipped, and the leaves of "key2" and "key3" — encoded + // identically to the "key1" leaf (same partial, same value hash) — are deduplicated at + // the node level. + assert_eq!(deduplicated.len(), encoded.len() - 4 - 2); + } else { + // Without value nodes there are no detached values to deduplicate, but the identical + // leaves of "key2" and "key3" still are. + assert_eq!(count_shared_value_items(&encoded), 0); + assert_eq!(deduplicated.len(), encoded.len() - 2); + } + + // The deduplicated encoding reconstructs a fully readable trie in a hash-keyed database: + // every deduplicated item is present from its first occurrence. + let mut hash_keyed_db = MemoryDB::::default(); + let (decoded_root, used) = decode_compact::(&mut hash_keyed_db, &deduplicated).unwrap(); + assert_eq!(decoded_root, root); + assert_eq!(used, deduplicated.len()); + assert_entries_match::(&hash_keyed_db, root, &entries); + + // Both encodings reconstruct the same node set; only reference counts differ (a deduplicated + // item is inserted once instead of once per occurrence), which reads never depend on. + let mut expected_hash_keyed_db = MemoryDB::::default(); + decode_compact::(&mut expected_hash_keyed_db, &encoded).unwrap(); + assert_eq!(db_key_set::(&hash_keyed_db), db_key_set::(&expected_hash_keyed_db)); +} + +/// Deduplicated encodings are unsupported in position-keyed (prefixed) databases: a deduplicated +/// item is present only below the position of its first occurrence, so decoding succeeds (the +/// root is exact) but a lookup through a later referencing position misses. This pins the +/// documented limitation of [`encode_compact_skip_duplicates`]. +#[test] +fn skip_duplicates_is_unsupported_in_prefixed_databases() { + // Layout storing every non-empty value as a separate value node. + type L = reference_trie::HashedValueNoExtThreshold<1>; + + let entries = shared_value_entries(); + let (db, root) = build_trie::(&entries); + let trie = >::new(&db, &root).build(); + let deduplicated = encode_compact_skip_duplicates::(&trie, &mut Default::default()).unwrap(); + + let mut prefixed_db = PrefixedMemoryDB::::default(); + let (decoded_root, _) = decode_compact::(&mut prefixed_db, &deduplicated).unwrap(); + assert_eq!(decoded_root, root); + + let trie = >::new(&prefixed_db, &root).build(); + // The shared value sits under the prefix of its first referencing node... + assert_eq!(trie.get(b"key").unwrap().as_deref(), Some(SHARED_VALUE)); + // ...so the lookup under any other prefix misses it. + match trie.get(b"other") { + Err(err) => match *err { + TrieError::IncompleteDatabase(_) => {}, + _ => panic!("got unexpected TrieError"), + }, + _ => panic!("lookup was unexpectedly successful"), + } +} + +test_layouts!( + skip_duplicate_values_handles_missing_value_nodes, + skip_duplicate_values_handles_missing_value_nodes_internal +); +fn skip_duplicate_values_handles_missing_value_nodes_internal() { + let entries = shared_value_entries(); + let (db, root) = build_trie::(&entries); + + // Record all keys, but leave the shared value node out of the partial trie. + let mut recorder = Recorder::::new(); + { + let trie = >::new(&db, &root).with_recorder(&mut recorder).build(); + for (key, _) in entries.iter() { + trie.get(key).unwrap(); + } + } + let mut partial_db = MemoryDB::::default(); + for record in recorder.drain() { + if record.data != SHARED_VALUE { + partial_db.insert(EMPTY_PREFIX, &record.data); + } + } + + let trie = >::new(&partial_db, &root).build(); + let encoded = encode_compact::(&trie).unwrap(); + let deduplicated = encode_compact_skip_duplicates::(&trie, &mut Default::default()).unwrap(); + + // With no fetchable shared value there is nothing to detach: no encoded item is a standalone + // value and the referencing nodes are emitted unmodified. The leaves of "key2" and "key3" — + // encoded identically to the "key1" leaf — are still deduplicated at the node level. + assert_eq!(count_shared_value_items(&encoded), 0); + assert_eq!(count_shared_value_items(&deduplicated), 0); + assert_eq!(deduplicated.len(), encoded.len() - 2); + + let mut decoded_db = MemoryDB::::default(); + let (decoded_root, _) = decode_compact::(&mut decoded_db, &deduplicated).unwrap(); + assert_eq!(decoded_root, root); + + // Node-level deduplication must not change the reconstructed node set. + let mut expected_db = MemoryDB::::default(); + decode_compact::(&mut expected_db, &encoded).unwrap(); + assert_eq!(db_key_set::(&decoded_db), db_key_set::(&expected_db)); + + let trie = >::new(&decoded_db, &root).build(); + // The distinct value is present either way. + assert_eq!(trie.get(b"key4").unwrap().as_deref(), Some(&[5u8; 32][..])); + if has_value_nodes::() { + // The shared value node is missing, so lookups must fail with an incomplete + // database error. + match trie.get(b"key1") { + Err(err) => match *err { + TrieError::IncompleteDatabase(_) => {}, + _ => panic!("got unexpected TrieError"), + }, + _ => panic!("lookup was unexpectedly successful"), + } + } else { + // Values are inline; filtering the standalone value node removed nothing. + assert_entries_match::(&decoded_db, root, &entries); + } +} + +#[test] +fn decode_compact_counts_attached_value_items() { + // Layout storing every non-empty value as a separate value node. + type L = reference_trie::HashedValueNoExtThreshold<1>; + + // A single entry: the encoding is exactly the escaped root leaf followed by its detached + // value. The returned item count must include the value item. + let (db, root) = build_trie::(&[(b"key", SHARED_VALUE)]); + let encoded = { + let trie = >::new(&db, &root).build(); + encode_compact::(&trie).unwrap() + }; + assert_eq!(encoded.len(), 2); + assert_eq!(encoded[1].as_slice(), SHARED_VALUE); + + let mut decoded_db = MemoryDB::::default(); + let (decoded_root, used) = decode_compact::(&mut decoded_db, &encoded).unwrap(); + assert_eq!(decoded_root, root); + assert_eq!(used, encoded.len()); +} + +#[test] +fn skip_duplicate_values_across_concatenated_encodings() { + // Layout storing every non-empty value as a separate value node. + type L = reference_trie::HashedValueNoExtThreshold<1>; + + // Two independent tries (think: a top trie and a child trie) sharing a value. + let entries_a: Vec<(&'static [u8], &'static [u8])> = + vec![(b"alpha1", SHARED_VALUE), (b"alpha2", SHARED_VALUE), (b"alpha3", &[7; 32])]; + let entries_b: Vec<(&'static [u8], &'static [u8])> = + vec![(b"beta1", SHARED_VALUE), (b"beta2", SHARED_VALUE), (b"beta9", &[9; 32])]; + let (db_a, root_a) = build_trie::(&entries_a); + let (db_b, root_b) = build_trie::(&entries_b); + + // Thread one seen-set through both encodings: the second references the shared value + // without re-emitting it. + let mut seen_hashes = SeenHashes::default(); + let encoded_a = { + let trie = >::new(&db_a, &root_a).build(); + encode_compact_skip_duplicates::(&trie, &mut seen_hashes).unwrap() + }; + let encoded_b = { + let trie = >::new(&db_b, &root_b).build(); + encode_compact_skip_duplicates::(&trie, &mut seen_hashes).unwrap() + }; + assert_eq!(count_shared_value_items(&encoded_a), 1); + assert_eq!(count_shared_value_items(&encoded_b), 0); + + // Decode both into one shared hash-keyed database: the second encoding's reference to the + // shared value resolves against the item decoded from the first. + let mut shared_db = MemoryDB::::default(); + let (decoded_root_a, _) = + decode_compact_from_iter::(&mut shared_db, encoded_a.iter().map(Vec::as_slice)) + .unwrap(); + let (decoded_root_b, _) = + decode_compact_from_iter::(&mut shared_db, encoded_b.iter().map(Vec::as_slice)) + .unwrap(); + assert_eq!(decoded_root_a, root_a); + assert_eq!(decoded_root_b, root_b); + assert_entries_match::(&shared_db, root_a, &entries_a); + assert_entries_match::(&shared_db, root_b, &entries_b); +} + +/// Entries forming two identical subtrees below the root. +/// +/// The mirrored keys diverge at their first nibble and share all following nibbles and values, so +/// the two subtrees below the root branch consist of identically encoded nodes: one branch +/// carrying two leaves (plus value nodes, for layouts detaching values; plus an extension node, +/// for layouts using them). +fn shared_subtree_entries() -> Vec<(&'static [u8], &'static [u8])> { + vec![ + (b"\x00AAAA", SHARED_VALUE), + (b"\x00AAAB", &[6; 32]), + (b"\x10AAAA", SHARED_VALUE), + (b"\x10AAAB", &[6; 32]), + // An unrelated entry so the root is a branch with a third, distinct child. + (b"\xf0ZZZZ", &[7; 32]), + ] +} + +test_layouts!( + skip_duplicates_emits_shared_subtrees_once, + skip_duplicates_emits_shared_subtrees_once_internal +); +fn skip_duplicates_emits_shared_subtrees_once_internal() { + let entries = shared_subtree_entries(); + let (db, root) = build_trie::(&entries); + + let trie = >::new(&db, &root).build(); + let encoded = encode_compact::(&trie).unwrap(); + let deduplicated = encode_compact_skip_duplicates::(&trie, &mut Default::default()).unwrap(); + + // The mirrored subtree (and everything below it) is emitted only once; its second occurrence + // stays a plain hash reference in the root node. This holds for every layout: node-level + // deduplication does not depend on values being stored in separate value nodes. + assert!(deduplicated.len() < encoded.len()); + + // The deduplicated encoding reconstructs a fully readable trie in a hash-keyed database: + // the skipped subtree is present from its first occurrence. + let mut hash_keyed_db = MemoryDB::::default(); + let (decoded_root, used) = decode_compact::(&mut hash_keyed_db, &deduplicated).unwrap(); + assert_eq!(decoded_root, root); + assert_eq!(used, deduplicated.len()); + assert_entries_match::(&hash_keyed_db, root, &entries); + + // Both encodings reconstruct the same node set; only reference counts differ. + let mut expected_hash_keyed_db = MemoryDB::::default(); + decode_compact::(&mut expected_hash_keyed_db, &encoded).unwrap(); + assert_eq!(db_key_set::(&hash_keyed_db), db_key_set::(&expected_hash_keyed_db)); +} + +#[test] +fn skip_duplicates_shares_subtrees_across_concatenated_encodings() { + // Layout storing every non-empty value as a separate value node. + type L = reference_trie::HashedValueNoExtThreshold<1>; + + // Two independent tries containing an identically encoded subtree: in both tries the + // mirrored keys share all nibbles below the diverging first nibble. + let entries_a: Vec<(&'static [u8], &'static [u8])> = + vec![(b"\x00AAAA", SHARED_VALUE), (b"\x00AAAB", &[6; 32]), (b"\xf0ZZZZ", &[7; 32])]; + let entries_b: Vec<(&'static [u8], &'static [u8])> = + vec![(b"\x10AAAA", SHARED_VALUE), (b"\x10AAAB", &[6; 32]), (b"\xe0YYYY", &[8; 32])]; + let (db_a, root_a) = build_trie::(&entries_a); + let (db_b, root_b) = build_trie::(&entries_b); + let trie_a = >::new(&db_a, &root_a).build(); + let trie_b = >::new(&db_b, &root_b).build(); + + // Thread one seen-set through both encodings: the second references the shared subtree + // without re-emitting it. + let mut seen_hashes = SeenHashes::default(); + let encoded_a = encode_compact_skip_duplicates::(&trie_a, &mut seen_hashes).unwrap(); + let encoded_b = encode_compact_skip_duplicates::(&trie_b, &mut seen_hashes).unwrap(); + let standalone_b = + encode_compact_skip_duplicates::(&trie_b, &mut Default::default()).unwrap(); + assert!(encoded_b.len() < standalone_b.len()); + + // Decode both into one shared hash-keyed database: the second encoding's reference to the + // shared subtree resolves against the items decoded from the first. + let mut shared_db = MemoryDB::::default(); + let (decoded_root_a, _) = + decode_compact_from_iter::(&mut shared_db, encoded_a.iter().map(Vec::as_slice)) + .unwrap(); + let (decoded_root_b, _) = + decode_compact_from_iter::(&mut shared_db, encoded_b.iter().map(Vec::as_slice)) + .unwrap(); + assert_eq!(decoded_root_a, root_a); + assert_eq!(decoded_root_b, root_b); + assert_entries_match::(&shared_db, root_a, &entries_a); + assert_entries_match::(&shared_db, root_b, &entries_b); + + // The reconstructed node set matches the plain encodings' exactly. + let mut expected_db = MemoryDB::::default(); + let plain_a = encode_compact::(&trie_a).unwrap(); + let plain_b = encode_compact::(&trie_b).unwrap(); + decode_compact::(&mut expected_db, &plain_a).unwrap(); + decode_compact::(&mut expected_db, &plain_b).unwrap(); + assert_eq!(db_key_set::(&shared_db), db_key_set::(&expected_db)); +} + +#[test] +fn skip_duplicates_always_emits_the_root() { + // Layout storing every non-empty value as a separate value node. + type L = reference_trie::HashedValueNoExtThreshold<1>; + + let entries = shared_subtree_entries(); + let (db, root) = build_trie::(&entries); + let trie = >::new(&db, &root).build(); + + // Encode the same trie twice with one threaded seen-set. Everything is known when the second + // encoding starts, but the root must still be emitted so the encoding stays individually + // decodable; all its children collapse to plain hash references. + let mut seen_hashes = SeenHashes::default(); + let first = encode_compact_skip_duplicates::(&trie, &mut seen_hashes).unwrap(); + let second = encode_compact_skip_duplicates::(&trie, &mut seen_hashes).unwrap(); + assert!(first.len() > 1); + assert_eq!(second.len(), 1); + + // Decoded into one shared hash-keyed database, the second encoding verifies against the items + // decoded from the first: the root is reconstructed and every entry stays readable. + let mut shared_db = MemoryDB::::default(); + let (decoded_root, _) = + decode_compact_from_iter::(&mut shared_db, first.iter().map(Vec::as_slice)) + .unwrap(); + assert_eq!(decoded_root, root); + let (decoded_root, used) = + decode_compact_from_iter::(&mut shared_db, second.iter().map(Vec::as_slice)) + .unwrap(); + assert_eq!(decoded_root, root); + assert_eq!(used, 1); + assert_entries_match::(&shared_db, root, &entries); +} + +/// A value byte-identical to a node's encoding hashes to that node's hash. With one shared set, +/// emitting it early would fire the node-subtree skip on the real node and drop its subtree while +/// the proof still matched the root. [`SeenHashes`] keeps the namespaces disjoint, so the subtree +/// must survive. Every node encoding is tried in turn as the poison value. +#[test] +fn value_equal_to_node_encoding_does_not_drop_subtree() { + // Layout storing every non-empty value as a separate value node. + type L = reference_trie::HashedValueNoExtThreshold<1>; + + // Root branch with children under nibbles 0xA and 0xF; the 0xA child is a non-root branch with + // two leaves — a subtree worth dropping. + let base: [(&[u8], &[u8]); 3] = + [(&[0xA1], &[1u8; 40]), (&[0xA2], &[2u8; 40]), (&[0xF9], &[3u8; 40])]; + let (base_db, _base_root) = { + let mut db = MemoryDB::::default(); + let mut root = Default::default(); + { + let mut trie = >::new(&mut db, &mut root).build(); + for (key, value) in base.iter() { + trie.insert(key, value).unwrap(); + } + } + (db, root) + }; + + // Every node encoding in the trie, each a candidate poison value. + let node_encodings: Vec = base_db + .keys() + .into_iter() + .filter_map(|(hash, _rc)| { + HashDBRef::<::Hash, DBValue>::get(&base_db, &hash, EMPTY_PREFIX) + }) + .collect(); + assert!(node_encodings.len() > 1); + + for poison in node_encodings { + // Add a leaf under nibble 0x0 (so it sorts first) whose value equals a node encoding. The + // 0xA/0xF subtrees are untouched, so their node hashes still match `poison`. + let mut db = MemoryDB::::default(); + let mut root = Default::default(); + { + let mut trie = >::new(&mut db, &mut root).build(); + trie.insert(&[0x01], &poison).unwrap(); + for (key, value) in base.iter() { + trie.insert(key, value).unwrap(); + } + } + + let trie = >::new(&db, &root).build(); + let deduplicated = + encode_compact_skip_duplicates::(&trie, &mut SeenHashes::default()).unwrap(); + + let mut decoded_db = MemoryDB::::default(); + let (decoded_root, _) = decode_compact::(&mut decoded_db, &deduplicated).unwrap(); + assert_eq!(decoded_root, root); + + // The whole trie must still be readable; before the fix the 0xA subtree was dropped. + let decoded = >::new(&decoded_db, &root).build(); + assert_eq!(decoded.get(&[0x01]).unwrap().as_deref(), Some(poison.as_slice())); + for (key, value) in base.iter() { + assert_eq!(decoded.get(key).unwrap().as_deref(), Some(*value)); + } + } +} + #[test] fn encoding_node_owned_and_decoding_node_works() { let entries: Vec<(&[u8], &[u8])> = vec![ @@ -200,3 +653,66 @@ fn encoding_node_owned_and_decoding_node_works() { assert_eq!(record.data, node_owned.to_encoded::<::Codec>()); } } + +#[test] +fn deduplicated_encoding_decodes_with_released_0_31_decoder() { + // Layout storing every non-empty value as a separate value node. + type L = reference_trie::HashedValueNoExtThreshold<1>; + + let entries = shared_value_entries(); + let (db, root) = build_trie::(&entries); + let trie = >::new(&db, &root).build(); + let encoded = encode_compact::(&trie).unwrap(); + let deduplicated = encode_compact_skip_duplicates::(&trie, &mut Default::default()).unwrap(); + assert_eq!(count_shared_value_items(&deduplicated), 1); + + // Baseline: the released decoder handles the unmodified encoding. + let mut baseline_db = MemoryDB::::default(); + let (decoded_root, _) = + reference_trie::trie_db_0_31_decoder::decode_compact_from_iter::( + &mut baseline_db, + encoded.iter().map(Vec::as_slice), + ) + .unwrap(); + assert_eq!(decoded_root, root); + + // The deduplicated encoding decodes with the released decoder into a readable hash-keyed + // database: the value node is present from its first, still-attached occurrence. + // (Prefixed databases are out of scope: 0.31.0 inserts attached values under an incomplete + // prefix, fixed by #227.) + let mut hash_keyed_db = MemoryDB::::default(); + let (decoded_root, _) = + reference_trie::trie_db_0_31_decoder::decode_compact_from_iter::( + &mut hash_keyed_db, + deduplicated.iter().map(Vec::as_slice), + ) + .unwrap(); + assert_eq!(decoded_root, root); + assert_entries_match::(&hash_keyed_db, root, &entries); +} + +#[test] +fn subtree_deduplicated_encoding_decodes_with_released_0_31_decoder() { + // Layout storing every non-empty value as a separate value node. + type L = reference_trie::HashedValueNoExtThreshold<1>; + + let entries = shared_subtree_entries(); + let (db, root) = build_trie::(&entries); + let trie = >::new(&db, &root).build(); + let encoded = encode_compact::(&trie).unwrap(); + let deduplicated = encode_compact_skip_duplicates::(&trie, &mut Default::default()).unwrap(); + assert!(deduplicated.len() < encoded.len()); + + // A deduplicated subtree occurrence is indistinguishable from a node outside the partial + // trie, so the released decoder handles it: the subtree is present in a hash-keyed database + // from its first, still-emitted occurrence. + let mut hash_keyed_db = MemoryDB::::default(); + let (decoded_root, _) = + reference_trie::trie_db_0_31_decoder::decode_compact_from_iter::( + &mut hash_keyed_db, + deduplicated.iter().map(Vec::as_slice), + ) + .unwrap(); + assert_eq!(decoded_root, root); + assert_entries_match::(&hash_keyed_db, root, &entries); +}