Skip to content

Commit 64df1e6

Browse files
committed
feat: Add range proof verification
1 parent 540c2cc commit 64df1e6

File tree

4 files changed

+464
-21
lines changed

4 files changed

+464
-21
lines changed

firewood/Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ fastrace.workspace = true
2929
firewood-macros.workspace = true
3030
hex.workspace = true
3131
metrics.workspace = true
32+
smallvec = { workspace = true, features = ["write", "union"] }
3233
sha2.workspace = true
3334
test-case.workspace = true
3435
thiserror.workspace = true
@@ -43,8 +44,8 @@ default = []
4344
nightly = []
4445
io-uring = ["firewood-storage/io-uring"]
4546
logger = ["firewood-storage/logger"]
46-
branch_factor_256 = [ "firewood-storage/branch_factor_256" ]
47-
ethhash = [ "firewood-storage/ethhash" ]
47+
branch_factor_256 = ["firewood-storage/branch_factor_256"]
48+
ethhash = ["firewood-storage/ethhash"]
4849

4950
[dev-dependencies]
5051
# Workspace dependencies

firewood/src/merkle.rs

Lines changed: 283 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,18 @@
44
#[cfg(test)]
55
mod tests;
66

7-
use crate::proof::{Proof, ProofCollection, ProofError, ProofNode};
7+
use crate::proof::{Proof, ProofCollection, ProofError, ProofNode, verify_opt_value_digest};
88
use crate::range_proof::RangeProof;
99
use crate::stream::{MerkleKeyValueStream, PathIterator};
1010
use crate::v2::api::{self, FrozenProof, FrozenRangeProof, KeyType, ValueType};
1111
use firewood_storage::{
12-
BranchNode, Child, FileIoError, HashType, HashedNodeReader, ImmutableProposal, IntoHashType,
13-
LeafNode, MaybePersistedNode, MutableProposal, NibblesIterator, Node, NodeStore, Parentable,
14-
Path, ReadableStorage, SharedNode, TrieHash, TrieReader, ValueDigest,
12+
BranchNode, Child, FileIoError, HashType, Hashable, HashedNodeReader, ImmutableProposal,
13+
IntoHashType, LeafNode, MaybePersistedNode, MemStore, MutableProposal, NibblesIterator, Node,
14+
NodeStore, Parentable, Path, ReadableStorage, SharedNode, TrieHash, TrieReader, ValueDigest,
1515
};
1616
use futures::{StreamExt, TryStreamExt};
1717
use metrics::counter;
18+
use smallvec::SmallVec;
1819
use std::collections::HashSet;
1920
use std::fmt::Debug;
2021
use std::future::ready;
@@ -133,7 +134,6 @@ impl<T: TrieReader> Merkle<T> {
133134
self.nodestore.root_node()
134135
}
135136

136-
#[cfg(test)]
137137
pub(crate) const fn nodestore(&self) -> &T {
138138
&self.nodestore
139139
}
@@ -160,7 +160,6 @@ impl<T: TrieReader> Merkle<T> {
160160
if proof.is_empty() {
161161
// No nodes, even the root, are before `key`.
162162
// The root alone proves the non-existence of `key`.
163-
// TODO reduce duplicate code with ProofNode::from<PathIterItem>
164163
let child_hashes = if let Some(branch) = root.as_branch() {
165164
branch.children_hashes()
166165
} else {
@@ -257,12 +256,285 @@ impl<T: TrieReader> Merkle<T> {
257256
/// incremental range proof verification
258257
pub fn verify_range_proof(
259258
&self,
260-
_first_key: Option<impl KeyType>,
261-
_last_key: Option<impl KeyType>,
262-
_root_hash: &TrieHash,
263-
_proof: &RangeProof<impl KeyType, impl ValueType, impl ProofCollection>,
259+
first_key: Option<impl KeyType>,
260+
last_key: Option<impl KeyType>,
261+
root_hash: &TrieHash,
262+
proof: &RangeProof<impl KeyType, impl ValueType, impl ProofCollection>,
264263
) -> Result<(), api::Error> {
265-
todo!()
264+
let first_key = first_key.map(Path::from).unwrap_or_default();
265+
let last_key = last_key.map(Path::from).unwrap_or_default();
266+
267+
// 1. Validate proof structure (similar to validateChangeProof in Go)
268+
self.validate_range_proof_structure(&first_key, &last_key, proof)?;
269+
270+
// 2. Verify start proof nodes.
271+
self.verify_proof_nodes_values(
272+
proof.start_proof(),
273+
&first_key,
274+
&last_key,
275+
// validate_range_proof_structure will have verified that the keys are
276+
// in order, allowing us to use binary search on lookup
277+
proof.key_values(),
278+
)?;
279+
280+
// 3. Verify end proof nodes
281+
self.verify_proof_nodes_values(
282+
proof.end_proof(),
283+
&first_key,
284+
&last_key,
285+
// validate_range_proof_structure will have verified that the keys are
286+
// in order, allowing us to use binary search on lookup
287+
proof.key_values(),
288+
)?;
289+
290+
// 4. Reconstruct trie and verify root
291+
self.verify_reconstructed_trie_root(proof, root_hash)?;
292+
293+
Ok(())
294+
}
295+
296+
/// Verify that the range proof is structurally valid and that we can use it
297+
/// to verify the trie root once reconstructed.
298+
fn validate_range_proof_structure(
299+
&self,
300+
first_key: &Path,
301+
last_key: &Path,
302+
proof: &RangeProof<impl KeyType, impl ValueType, impl ProofCollection>,
303+
) -> Result<(), ProofError> {
304+
// 1. Basic validation
305+
if proof.is_empty() {
306+
return Err(ProofError::Empty);
307+
}
308+
309+
// 2. Range validation
310+
if !first_key.is_empty() && !last_key.is_empty() && first_key > last_key {
311+
return Err(ProofError::InvalidRange);
312+
}
313+
314+
// 3. Proof structure validation
315+
match (
316+
first_key.is_empty(),
317+
last_key.is_empty(),
318+
proof.key_values().is_empty(),
319+
) {
320+
(_, true, true) if !proof.end_proof().is_empty() => {
321+
return Err(ProofError::UnexpectedEndProof);
322+
}
323+
(true, _, _) if !proof.start_proof().is_empty() => {
324+
return Err(ProofError::UnexpectedStartProof);
325+
}
326+
(_, false, _) | (_, _, false) if proof.end_proof().is_empty() => {
327+
return Err(ProofError::ExpectedEndProof);
328+
}
329+
_ => {} // Valid combination
330+
}
331+
332+
let last_key = if proof.key_values().is_empty() {
333+
// no key-values, `last_key` remains the `last_key` provided by the caller
334+
last_key
335+
} else {
336+
// two re-usable buffers to expand the keys into nibbles. re-using
337+
// these buffers avoids multiple allocations and deallocations in the
338+
// loop below.
339+
//
340+
// Uses a smallvec so we can convert directly to a Path without
341+
// an extra copy step.
342+
let mut this_key_buf = SmallVec::<[u8; 64]>::new();
343+
let mut last_key_buf = SmallVec::<[u8; 64]>::new();
344+
345+
// 4. verify key-values are in strict order by key (no duplicates either)
346+
for (key, _) in proof.key_values() {
347+
this_key_buf.clear();
348+
this_key_buf.extend(NibblesIterator::new(key.as_ref()));
349+
debug_assert!(!this_key_buf.is_empty(), "key must not be empty");
350+
debug_assert!(this_key_buf.len() % 2 == 0, "key must be even length");
351+
352+
// verify that the first key is not larger than the first key
353+
// in the range. Only check the first key as all remaining keys
354+
// are implicitly larger if other checks hold.
355+
if last_key_buf.is_empty() && !first_key.is_empty() && *this_key_buf < **first_key {
356+
return Err(ProofError::StateFromOutsideOfRange);
357+
}
358+
359+
// For every key, check that it is less than or equal to the last
360+
// key in the range.
361+
if !last_key.is_empty() && *this_key_buf <= **last_key {
362+
return Err(ProofError::StateFromOutsideOfRange);
363+
}
364+
365+
if !last_key_buf.is_empty() && this_key_buf < last_key_buf {
366+
// we have a last key but it is greater than the current key
367+
// therefore, the list is not sorted or has duplicates
368+
return Err(ProofError::NonIncreasingValues);
369+
}
370+
371+
// swap the buffers so that `last_key_buf` contains the key we
372+
// processed in this iteration.
373+
std::mem::swap(&mut last_key_buf, &mut this_key_buf);
374+
}
375+
376+
// at this point, `last_key_buf` is filled with the nibbles of the last
377+
// and largest key in the key-values. We can re-use it for the
378+
// verification below. It overrides the `last_key` provided by the
379+
// caller in order to verify the end proof.
380+
381+
&Path(last_key_buf)
382+
};
383+
384+
// 5. Validate proof paths (structural only, not root verification)
385+
if !proof.start_proof().is_empty() {
386+
proof.start_proof().verify_proof_path_structure(first_key)?;
387+
}
388+
389+
if !proof.end_proof().is_empty() {
390+
proof.end_proof().verify_proof_path_structure(last_key)?;
391+
}
392+
393+
Ok(())
394+
}
395+
396+
fn verify_proof_nodes_values(
397+
&self,
398+
proof: &Proof<impl ProofCollection>,
399+
first_key: &Path,
400+
last_key: &Path,
401+
key_values_sorted_by_key: &[(impl KeyType, impl ValueType)],
402+
) -> Result<(), ProofError> {
403+
// cache the root node to avoid multiple lookups
404+
let root = self.root();
405+
let root = root.as_deref();
406+
407+
let mut node_key = Path::default();
408+
for node in proof.as_ref() {
409+
node_key.0.clear();
410+
node_key.0.extend(node.key());
411+
412+
// skip partial paths as they cannot have values
413+
#[cfg(not(feature = "branch_factor_256"))]
414+
if node_key.len() % 2 != 0 {
415+
continue;
416+
}
417+
418+
if !first_key.is_empty() && *node_key < **first_key
419+
|| !last_key.is_empty() && *node_key > **last_key
420+
{
421+
// node not in range, ignore it
422+
continue;
423+
}
424+
425+
verify_opt_value_digest(
426+
// must be inline with the call to verify_opt_value_digest
427+
// in order for the lifetime of the temp storage to be valid,
428+
// which ends before the semicolon
429+
self.get_node_value_for_proof(
430+
key_values_sorted_by_key,
431+
&node_key,
432+
root,
433+
// temp storage for the fetched node so we don't need to
434+
// copy the value off the node
435+
&mut None,
436+
)?,
437+
node.value_digest(),
438+
)?; // temp storage is dropped here
439+
}
440+
441+
Ok(())
442+
}
443+
444+
fn verify_reconstructed_trie_root(
445+
&self,
446+
proof: &RangeProof<impl KeyType, impl ValueType, impl ProofCollection>,
447+
root_hash: &TrieHash,
448+
) -> Result<(), api::Error> {
449+
// Create in-memory trie for reconstruction
450+
let memstore = MemStore::new(vec![]);
451+
let nodestore = NodeStore::new_empty_proposal(memstore.into());
452+
let mut merkle = Merkle { nodestore };
453+
454+
// Insert all key-value pairs from the range proof
455+
for (key, value) in proof.key_values() {
456+
merkle
457+
.insert(key.as_ref(), value.as_ref().into())
458+
.map_err(ProofError::IO)?;
459+
}
460+
461+
// Hash the trie and get root
462+
let merkle: Merkle<NodeStore<Arc<ImmutableProposal>, _>> = merkle.try_into()?;
463+
let computed_root = merkle.nodestore().root_hash().ok_or(ProofError::Empty)?;
464+
465+
// Compare with expected root
466+
if computed_root == *root_hash {
467+
Ok(())
468+
} else {
469+
Err(api::Error::InvalidHash {
470+
reason: api::InvalidHashReason::MismatchedHash,
471+
invalid: Some(computed_root),
472+
expected: Some(root_hash.clone()),
473+
})
474+
}
475+
}
476+
477+
/// Get the value for the given key from the proof key-values. If not found,
478+
/// it will look for the value in the trie.
479+
///
480+
/// This lifetime means `'out` is dependent on `'this`, `'kvs`, and `'store`.
481+
///
482+
/// This allows us to return a reference to the value in the node without
483+
/// copying it to a new buffer.
484+
fn get_node_value_for_proof<'this: 'out, 'kvs: 'out, 'store: 'out, 'out>(
485+
&'this self,
486+
key_values_sorted_by_key: &'kvs [(impl KeyType, impl ValueType)],
487+
path: &Path,
488+
root: Option<&Node>,
489+
fetched_node: &'store mut Option<SharedNode>,
490+
) -> Result<Option<&'out [u8]>, FileIoError> {
491+
use std::cmp::Ordering::{self, Equal, Greater, Less};
492+
493+
fn cmp_path_with_key(path: &Path, key: &[u8]) -> Ordering {
494+
let mut path = path.iter();
495+
let mut key = NibblesIterator::new(key);
496+
loop {
497+
break match (path.next(), key.next()) {
498+
(Some(path), Some(key)) => match path.cmp(&key) {
499+
Equal => continue, // all other branches break
500+
ord => ord,
501+
},
502+
(Some(_), None) => Greater, // path is longer than key
503+
(None, Some(_)) => Less, // key is longer than path
504+
(None, None) => Equal, // both are empty
505+
};
506+
}
507+
}
508+
509+
// use binary search to find the value for the key in `key_values_sorted_by_key`
510+
// (if it exists)
511+
if let Ok(found) = key_values_sorted_by_key
512+
.binary_search_by(|(key, _)| cmp_path_with_key(path, key.as_ref()))
513+
.map(
514+
#[expect(
515+
clippy::indexing_slicing,
516+
reason = "binary_search guarantees the index is in bounds"
517+
)]
518+
|index| key_values_sorted_by_key[index].1.as_ref(),
519+
)
520+
{
521+
return Ok(Some(found));
522+
}
523+
524+
// otherwise, look for it in the trie
525+
let Some(root) = root else {
526+
// no root, so no value
527+
return Ok(None);
528+
};
529+
530+
let Some(node) = get_helper(&self.nodestore, root, path.as_ref())? else {
531+
// node was not found, so no value
532+
return Ok(None);
533+
};
534+
535+
let node = &*fetched_node.insert(node);
536+
537+
Ok(node.value())
266538
}
267539

268540
pub(crate) fn path_iter<'a>(

0 commit comments

Comments
 (0)