reth_trie/proof_v2/
mod.rs

1//! Proof calculation version 2: Leaf-only implementation.
2//!
3//! This module provides a rewritten proof calculator that:
4//! - Uses only leaf data (HashedAccounts/Storages) to generate proofs
5//! - Returns proof nodes sorted lexicographically by path
6//! - Automatically resets after each calculation
7//! - Re-uses cursors across calculations
8//! - Supports generic value types with lazy evaluation
9
10use crate::{
11    hashed_cursor::{HashedCursor, HashedStorageCursor},
12    trie_cursor::{TrieCursor, TrieStorageCursor},
13};
14use alloy_primitives::{B256, U256};
15use alloy_trie::TrieMask;
16use reth_execution_errors::trie::StateProofError;
17use reth_trie_common::{BranchNode, Nibbles, ProofTrieNode, RlpNode, TrieMasks, TrieNode};
18use tracing::{instrument, trace};
19
20mod value;
21pub use value::*;
22
23mod node;
24use node::*;
25
26/// Target to use with the `tracing` crate.
27static TRACE_TARGET: &str = "trie::proof_v2";
28
29/// A proof calculator that generates merkle proofs using only leaf data.
30///
31/// The calculator:
32/// - Accepts one or more B256 proof targets sorted lexicographically
33/// - Returns proof nodes sorted lexicographically by path
34/// - Automatically resets after each calculation
35/// - Re-uses cursors from one calculation to the next
36#[derive(Debug)]
37pub struct ProofCalculator<TC, HC, VE: LeafValueEncoder> {
38    /// Trie cursor for traversing stored branch nodes.
39    trie_cursor: TC,
40    /// Hashed cursor for iterating over leaf data.
41    hashed_cursor: HC,
42    /// Branches which are currently in the process of being constructed, each being a child of
43    /// the previous one.
44    branch_stack: Vec<ProofTrieBranch>,
45    /// The path of the last branch in `branch_stack`.
46    branch_path: Nibbles,
47    /// Children of branches in the `branch_stack`.
48    ///
49    /// Each branch in `branch_stack` tracks which children are in this stack using its
50    /// `state_mask`; the number of children the branch has in this stack is equal to the number of
51    /// bits set in its `state_mask`.
52    ///
53    /// The children for the bottom branch in `branch_stack` are found at the bottom of this stack,
54    /// and so on. When a branch is removed from `branch_stack` its children are removed from this
55    /// one, and the branch is pushed onto this stack in their place (see [`Self::pop_branch`].
56    child_stack: Vec<ProofTrieBranchChild<VE::DeferredEncoder>>,
57    /// Free-list of re-usable buffers of [`RlpNode`]s, used for encoding branch nodes to RLP.
58    ///
59    /// We are generally able to re-use these buffers across different branch nodes for the
60    /// duration of a proof calculation, but occasionally we will lose one when when a branch
61    /// node is returned as a `ProofTrieNode`.
62    rlp_nodes_bufs: Vec<Vec<RlpNode>>,
63    /// Re-usable byte buffer, used for RLP encoding.
64    rlp_encode_buf: Vec<u8>,
65}
66
67impl<TC, HC, VE: LeafValueEncoder> ProofCalculator<TC, HC, VE> {
68    /// Create a new [`ProofCalculator`] instance for calculating account proofs.
69    pub const fn new(trie_cursor: TC, hashed_cursor: HC) -> Self {
70        Self {
71            trie_cursor,
72            hashed_cursor,
73            branch_stack: Vec::<_>::new(),
74            branch_path: Nibbles::new(),
75            child_stack: Vec::<_>::new(),
76            rlp_nodes_bufs: Vec::<_>::new(),
77            rlp_encode_buf: Vec::<_>::new(),
78        }
79    }
80}
81
82impl<TC, HC, VE> ProofCalculator<TC, HC, VE>
83where
84    TC: TrieCursor,
85    HC: HashedCursor,
86    VE: LeafValueEncoder<Value = HC::Value>,
87{
88    /// Takes a re-usable `RlpNode` buffer from the internal free-list, or allocates a new one if
89    /// the free-list is empty.
90    ///
91    /// The returned Vec will have a length of zero.
92    fn take_rlp_nodes_buf(&mut self) -> Vec<RlpNode> {
93        self.rlp_nodes_bufs
94            .pop()
95            .map(|mut buf| {
96                buf.clear();
97                buf
98            })
99            .unwrap_or_else(|| Vec::with_capacity(16))
100    }
101
102    /// Pushes a new branch onto the `branch_stack`, while also pushing the given leaf onto the
103    /// `child_stack`.
104    ///
105    /// This method expects that there already exists a child on the `child_stack`, and that that
106    /// child has a non-zero short key. The new branch is constructed based on the top child from
107    /// the `child_stack` and the given leaf.
108    fn push_new_branch(&mut self, leaf_key: Nibbles, leaf_val: VE::DeferredEncoder) {
109        // First determine the new leaf's shortkey relative to the current branch. If there is no
110        // current branch then the short key is the full key.
111        let leaf_short_key = if self.branch_stack.is_empty() {
112            leaf_key
113        } else {
114            // When there is a current branch then trim off its path as well as the nibble that it
115            // has set for this leaf.
116            trim_nibbles_prefix(&leaf_key, self.branch_path.len() + 1)
117        };
118
119        trace!(
120            target: TRACE_TARGET,
121            ?leaf_short_key,
122            branch_path = ?self.branch_path,
123            "push_new_branch: called",
124        );
125
126        // Get the new branch's first child, which is the child on the top of the stack with which
127        // the new leaf shares the same nibble on the current branch.
128        let first_child = self
129            .child_stack
130            .last_mut()
131            .expect("push_branch can't be called with empty child_stack");
132
133        let first_child_short_key = first_child.short_key();
134        debug_assert!(
135            !first_child_short_key.is_empty(),
136            "push_branch called when top child on stack is not a leaf or extension with a short key",
137        );
138
139        // Determine how many nibbles are shared between the new branch's first child and the new
140        // leaf. This common prefix will be the extension of the new branch
141        let common_prefix_len = first_child_short_key.common_prefix_length(&leaf_short_key);
142
143        // Trim off the common prefix from the first child's short key, plus one nibble which will
144        // stored by the new branch itself in its state mask.
145        let first_child_nibble = first_child_short_key.get_unchecked(common_prefix_len);
146        first_child.trim_short_key_prefix(common_prefix_len + 1);
147
148        // Similarly, trim off the common prefix, plus one nibble for the new branch, from the new
149        // leaf's short key.
150        let leaf_nibble = leaf_short_key.get_unchecked(common_prefix_len);
151        let leaf_short_key = trim_nibbles_prefix(&leaf_short_key, common_prefix_len + 1);
152
153        // Push the new leaf onto the child stack; it will be the second child of the new branch.
154        // The new branch's first child is the child already on the top of the stack, for which
155        // we've already adjusted its short key.
156        self.child_stack
157            .push(ProofTrieBranchChild::Leaf { short_key: leaf_short_key, value: leaf_val });
158
159        // Construct the state mask of the new branch, and push the new branch onto the branch
160        // stack.
161        self.branch_stack.push(ProofTrieBranch {
162            ext_len: common_prefix_len as u8,
163            state_mask: {
164                let mut m = TrieMask::default();
165                m.set_bit(first_child_nibble);
166                m.set_bit(leaf_nibble);
167                m
168            },
169            tree_mask: TrieMask::default(),
170            hash_mask: TrieMask::default(),
171        });
172
173        // Update the branch path to reflect the new branch which was just pushed. Its path will be
174        // the path of the previous branch, plus the nibble shared by each child, plus the parent
175        // extension (denoted by a non-zero `ext_len`). Since the new branch's path is a prefix of
176        // the original leaf_key we can just slice that.
177        //
178        // If the branch is the first branch then we do not add the extra 1, as there is no nibble
179        // in a parent branch to account for.
180        let branch_path_len = self.branch_path.len() +
181            common_prefix_len +
182            if self.branch_stack.len() == 1 { 0 } else { 1 };
183        self.branch_path = leaf_key.slice_unchecked(0, branch_path_len);
184
185        trace!(
186            target: TRACE_TARGET,
187            ?leaf_short_key,
188            ?common_prefix_len,
189            new_branch = ?self.branch_stack.last().expect("branch_stack was just pushed to"),
190            ?branch_path_len,
191            branch_path = ?self.branch_path,
192            "push_new_branch: returning",
193        );
194    }
195
196    /// Pops the top branch off of the `branch_stack`, hashes its children on the `child_stack`, and
197    /// replaces those children on the `child_stack`. The `branch_path` field will be updated
198    /// accordingly.
199    ///
200    /// # Panics
201    ///
202    /// This method panics if `branch_stack` is empty.
203    fn pop_branch(&mut self) -> Result<(), StateProofError> {
204        let mut rlp_nodes_buf = self.take_rlp_nodes_buf();
205        let branch = self.branch_stack.pop().expect("branch_stack cannot be empty");
206
207        trace!(
208            target: TRACE_TARGET,
209            ?branch,
210            branch_path = ?self.branch_path,
211            "pop_branch: called",
212        );
213
214        // Take the branch's children off the stack, using the state mask to determine how many
215        // there are.
216        let num_children = branch.state_mask.count_ones() as usize;
217        debug_assert!(num_children > 1, "A branch must have at least two children");
218        debug_assert!(
219            self.child_stack.len() >= num_children,
220            "Stack is missing necessary children"
221        );
222        let children = self.child_stack.drain(self.child_stack.len() - num_children..);
223
224        // We will be pushing the branch onto the child stack, which will require its parent
225        // extension's short key (if it has a parent extension). Calculate this short key from the
226        // `branch_path` prior to modifying the `branch_path`.
227        let short_key = trim_nibbles_prefix(
228            &self.branch_path,
229            self.branch_path.len() - branch.ext_len as usize,
230        );
231
232        // Update the branch_path. If this branch is the only branch then only its extension needs
233        // to be trimmed, otherwise we also need to remove its nibble from its parent.
234        let new_path_len = self.branch_path.len() -
235            branch.ext_len as usize -
236            if self.branch_stack.is_empty() { 0 } else { 1 };
237
238        debug_assert!(self.branch_path.len() >= new_path_len);
239        self.branch_path = self.branch_path.slice_unchecked(0, new_path_len);
240
241        // From here we will be encoding the branch node and pushing it onto the child stack,
242        // replacing its children.
243
244        // Collect children into an `RlpNode` Vec by calling into_rlp on each.
245        for child in children {
246            self.rlp_encode_buf.clear();
247            let (child_rlp_node, freed_rlp_nodes_buf) = child.into_rlp(&mut self.rlp_encode_buf)?;
248            rlp_nodes_buf.push(child_rlp_node);
249
250            // If there is an `RlpNode` buffer which can be re-used then push it onto the free-list.
251            if let Some(buf) = freed_rlp_nodes_buf {
252                self.rlp_nodes_bufs.push(buf);
253            }
254        }
255
256        debug_assert_eq!(
257            rlp_nodes_buf.len(),
258            branch.state_mask.count_ones() as usize,
259            "children length must match number of bits set in state_mask"
260        );
261
262        // Construct the `BranchNode`.
263        let branch_node = BranchNode::new(rlp_nodes_buf, branch.state_mask);
264
265        // Wrap the `BranchNode` so it can be pushed onto the child stack.
266        let branch_as_child = if short_key.is_empty() {
267            // If there is no extension then push a branch node
268            ProofTrieBranchChild::Branch(branch_node)
269        } else {
270            // Otherwise push an extension node
271            ProofTrieBranchChild::Extension { short_key, child: branch_node }
272        };
273
274        self.child_stack.push(branch_as_child);
275        Ok(())
276    }
277
278    /// Adds a single leaf for a key to the stack, possibly collapsing an existing branch and/or
279    /// creating a new one depending on the path of the key.
280    fn add_leaf(&mut self, key: Nibbles, val: VE::DeferredEncoder) -> Result<(), StateProofError> {
281        loop {
282            // Get the branch currently being built. If there are no branches on the stack then it
283            // means either the trie is empty or only a single leaf has been added previously.
284            let curr_branch = match self.branch_stack.last_mut() {
285                Some(curr_branch) => curr_branch,
286                None if self.child_stack.is_empty() => {
287                    // If the child stack is empty then this is the first leaf, push it and be done
288                    self.child_stack
289                        .push(ProofTrieBranchChild::Leaf { short_key: key, value: val });
290                    return Ok(())
291                }
292                None => {
293                    // If the child stack is not empty then it must only have a single other child
294                    // which is either a leaf or extension with a non-zero short key.
295                    debug_assert_eq!(self.child_stack.len(), 1);
296                    debug_assert!(!self
297                        .child_stack
298                        .last()
299                        .expect("already checked for emptiness")
300                        .short_key()
301                        .is_empty());
302                    self.push_new_branch(key, val);
303                    return Ok(())
304                }
305            };
306
307            // Find the common prefix length, which is the number of nibbles shared between the
308            // current branch and the key.
309            let common_prefix_len = self.branch_path.common_prefix_length(&key);
310
311            // If the current branch does not share all of its nibbles with the new key then it is
312            // not the parent of the new key. In this case the current branch will have no more
313            // children. We can pop it and loop back to the top to try again with its parent branch.
314            if common_prefix_len < self.branch_path.len() {
315                self.pop_branch()?;
316                continue
317            }
318
319            // If the current branch is a prefix of the new key then the leaf is a child of the
320            // branch. If the branch doesn't have the leaf's nibble set then the leaf can be added
321            // directly, otherwise a new branch must be created in-between this branch and that
322            // existing child.
323            let nibble = key.get_unchecked(common_prefix_len);
324            if curr_branch.state_mask.is_bit_set(nibble) {
325                // This method will also push the new leaf onto the `child_stack`.
326                self.push_new_branch(key, val);
327            } else {
328                curr_branch.state_mask.set_bit(nibble);
329
330                // Add this leaf as a new child of the current branch (no intermediate branch
331                // needed).
332                self.child_stack.push(ProofTrieBranchChild::Leaf {
333                    short_key: key.slice_unchecked(common_prefix_len + 1, key.len()),
334                    value: val,
335                });
336            }
337
338            return Ok(())
339        }
340    }
341
342    /// Internal implementation of proof calculation. Assumes both cursors have already been reset.
343    /// See docs on [`Self::proof`] for expected behavior.
344    fn proof_inner(
345        &mut self,
346        value_encoder: &VE,
347        targets: impl IntoIterator<Item = Nibbles>,
348    ) -> Result<Vec<ProofTrieNode>, StateProofError> {
349        trace!(target: TRACE_TARGET, "proof_inner called");
350
351        // In debug builds, verify that targets are sorted
352        #[cfg(debug_assertions)]
353        let targets = {
354            let mut prev: Option<Nibbles> = None;
355            targets.into_iter().inspect(move |target| {
356                if let Some(prev) = prev {
357                    debug_assert!(
358                        prev <= *target,
359                        "targets must be sorted lexicographically: {:?} > {:?}",
360                        prev,
361                        target
362                    );
363                }
364                prev = Some(*target);
365            })
366        };
367
368        #[cfg(not(debug_assertions))]
369        let targets = targets.into_iter();
370
371        // Ensure initial state is cleared. By the end of the method call these should be empty once
372        // again.
373        debug_assert!(self.branch_stack.is_empty());
374        debug_assert!(self.branch_path.is_empty());
375        debug_assert!(self.child_stack.is_empty());
376
377        // Silence unused variable warning for now
378        let _ = targets;
379
380        let mut proof_nodes = Vec::new();
381        let mut hashed_cursor_current = self.hashed_cursor.seek(B256::ZERO)?;
382        loop {
383            trace!(target: TRACE_TARGET, ?hashed_cursor_current, "proof_inner loop");
384
385            // Fetch the next leaf from the hashed cursor, converting the key to Nibbles and
386            // immediately creating the DeferredValueEncoder so that encoding of the leaf value can
387            // begin ASAP.
388            let Some((key, val)) = hashed_cursor_current.map(|(key_b256, val)| {
389                debug_assert_eq!(key_b256.len(), 32);
390                // SAFETY: key is a B256 and so is exactly 32-bytes.
391                let key = unsafe { Nibbles::unpack_unchecked(key_b256.as_slice()) };
392                let val = value_encoder.deferred_encoder(key_b256, val);
393                (key, val)
394            }) else {
395                break
396            };
397
398            self.add_leaf(key, val)?;
399            hashed_cursor_current = self.hashed_cursor.next()?;
400        }
401
402        // Once there's no more leaves we can pop the remaining branches, if any.
403        while !self.branch_stack.is_empty() {
404            self.pop_branch()?;
405        }
406
407        // At this point the branch stack should be empty. If the child stack is empty it means no
408        // keys were ever iterated from the hashed cursor in the first place. Otherwise there should
409        // only be a single node left: the root node.
410        debug_assert!(self.branch_stack.is_empty());
411        debug_assert!(self.branch_path.is_empty());
412        debug_assert!(self.child_stack.len() < 2);
413
414        // Determine the root node based on the child stack, and push the proof of the root node
415        // onto the result stack.
416        let root_node = if let Some(node) = self.child_stack.pop() {
417            self.rlp_encode_buf.clear();
418            node.into_trie_node(&mut self.rlp_encode_buf)?
419        } else {
420            TrieNode::EmptyRoot
421        };
422
423        proof_nodes.push(ProofTrieNode {
424            path: Nibbles::new(), // root path
425            node: root_node,
426            masks: TrieMasks::none(),
427        });
428
429        Ok(proof_nodes)
430    }
431}
432
433impl<TC, HC, VE> ProofCalculator<TC, HC, VE>
434where
435    TC: TrieCursor,
436    HC: HashedCursor,
437    VE: LeafValueEncoder<Value = HC::Value>,
438{
439    /// Generate a proof for the given targets.
440    ///
441    /// Given lexicographically sorted targets, returns nodes whose paths are a prefix of any
442    /// target. The returned nodes will be sorted lexicographically by path.
443    ///
444    /// # Panics
445    ///
446    /// In debug builds, panics if the targets are not sorted lexicographically.
447    #[instrument(target = TRACE_TARGET, level = "trace", skip_all)]
448    pub fn proof(
449        &mut self,
450        value_encoder: &VE,
451        targets: impl IntoIterator<Item = Nibbles>,
452    ) -> Result<Vec<ProofTrieNode>, StateProofError> {
453        self.trie_cursor.reset();
454        self.hashed_cursor.reset();
455        self.proof_inner(value_encoder, targets)
456    }
457}
458
459/// A proof calculator for storage tries.
460pub type StorageProofCalculator<TC, HC> = ProofCalculator<TC, HC, StorageValueEncoder>;
461
462impl<TC, HC> StorageProofCalculator<TC, HC>
463where
464    TC: TrieStorageCursor,
465    HC: HashedStorageCursor<Value = U256>,
466{
467    /// Create a new [`StorageProofCalculator`] instance.
468    pub const fn new_storage(trie_cursor: TC, hashed_cursor: HC) -> Self {
469        Self::new(trie_cursor, hashed_cursor)
470    }
471
472    /// Generate a proof for a storage trie at the given hashed address.
473    ///
474    /// Given lexicographically sorted targets, returns nodes whose paths are a prefix of any
475    /// target. The returned nodes will be sorted lexicographically by path.
476    ///
477    /// # Panics
478    ///
479    /// In debug builds, panics if the targets are not sorted lexicographically.
480    #[instrument(target = TRACE_TARGET, level = "trace", skip(self, targets))]
481    pub fn storage_proof(
482        &mut self,
483        hashed_address: B256,
484        targets: impl IntoIterator<Item = Nibbles>,
485    ) -> Result<Vec<ProofTrieNode>, StateProofError> {
486        /// Static storage value encoder instance used by all storage proofs.
487        static STORAGE_VALUE_ENCODER: StorageValueEncoder = StorageValueEncoder;
488
489        self.hashed_cursor.set_hashed_address(hashed_address);
490
491        // Shortcut: check if storage is empty
492        if self.hashed_cursor.is_storage_empty()? {
493            // Return a single EmptyRoot node at the root path
494            return Ok(vec![ProofTrieNode {
495                path: Nibbles::default(),
496                node: TrieNode::EmptyRoot,
497                masks: TrieMasks::none(),
498            }])
499        }
500
501        // Don't call `set_hashed_address` on the trie cursor until after the previous shortcut has
502        // been checked.
503        self.trie_cursor.set_hashed_address(hashed_address);
504
505        // Use the static StorageValueEncoder and pass it to proof_inner
506        self.proof_inner(&STORAGE_VALUE_ENCODER, targets)
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::{
514        hashed_cursor::{mock::MockHashedCursorFactory, HashedCursorFactory},
515        proof::Proof,
516        trie_cursor::{mock::MockTrieCursorFactory, TrieCursorFactory},
517    };
518    use alloy_primitives::map::B256Map;
519    use alloy_rlp::Decodable;
520    use itertools::Itertools;
521    use reth_trie_common::{HashedPostState, MultiProofTargets};
522    use std::collections::BTreeMap;
523
524    /// Target to use with the `tracing` crate.
525    static TRACE_TARGET: &str = "trie::proof_v2::tests";
526
527    /// A test harness for comparing `ProofCalculator` and legacy `Proof` implementations.
528    ///
529    /// This harness creates mock cursor factories from a `HashedPostState` and provides
530    /// a method to test that both proof implementations produce equivalent results.
531    struct ProofTestHarness {
532        /// Mock factory for trie cursors (empty by default for leaf-only tests)
533        trie_cursor_factory: MockTrieCursorFactory,
534        /// Mock factory for hashed cursors, populated from `HashedPostState`
535        hashed_cursor_factory: MockHashedCursorFactory,
536    }
537
538    impl ProofTestHarness {
539        /// Creates a new test harness from a `HashedPostState`.
540        ///
541        /// The `HashedPostState` is used to populate the mock hashed cursor factory directly.
542        /// The trie cursor factory is empty by default, suitable for testing the leaf-only
543        /// proof calculator.
544        fn new(post_state: HashedPostState) -> Self {
545            trace!(target: TRACE_TARGET, ?post_state, "Creating ProofTestHarness");
546
547            // Extract accounts from post state, filtering out None (deleted accounts)
548            let hashed_accounts: BTreeMap<B256, _> = post_state
549                .accounts
550                .into_iter()
551                .filter_map(|(addr, account)| account.map(|acc| (addr, acc)))
552                .collect();
553
554            // Extract storage tries from post state
555            let hashed_storage_tries: B256Map<BTreeMap<B256, U256>> = post_state
556                .storages
557                .into_iter()
558                .map(|(addr, hashed_storage)| {
559                    // Convert HashedStorage to BTreeMap, filtering out zero values (deletions)
560                    let storage_map: BTreeMap<B256, U256> = hashed_storage
561                        .storage
562                        .into_iter()
563                        .filter_map(|(slot, value)| (value != U256::ZERO).then_some((slot, value)))
564                        .collect();
565                    (addr, storage_map)
566                })
567                .collect();
568
569            // Ensure that there's a storage trie dataset for every storage trie, even if empty.
570            let storage_trie_nodes: B256Map<BTreeMap<_, _>> = hashed_storage_tries
571                .keys()
572                .copied()
573                .map(|addr| (addr, Default::default()))
574                .collect();
575
576            // Create mock hashed cursor factory populated with the post state data
577            let hashed_cursor_factory =
578                MockHashedCursorFactory::new(hashed_accounts, hashed_storage_tries);
579
580            // Create empty trie cursor factory (leaf-only calculator doesn't need trie nodes)
581            let trie_cursor_factory =
582                MockTrieCursorFactory::new(BTreeMap::new(), storage_trie_nodes);
583
584            Self { trie_cursor_factory, hashed_cursor_factory }
585        }
586
587        /// Asserts that `ProofCalculator` and legacy `Proof` produce equivalent results for account
588        /// proofs.
589        ///
590        /// This method calls both implementations with the given account targets and compares
591        /// the results. For now, it performs a basic comparison by checking that both succeed
592        /// and produce non-empty results. More detailed comparison logic can be added as needed.
593        fn assert_proof(
594            &self,
595            // For now ProofCalculator doesn't support real targets, we just compare calculated
596            // roots.
597            _targets: impl IntoIterator<Item = B256> + Clone,
598        ) -> Result<(), StateProofError> {
599            // Create ProofCalculator (proof_v2) with account cursors
600            let trie_cursor = self.trie_cursor_factory.account_trie_cursor()?;
601            let hashed_cursor = self.hashed_cursor_factory.hashed_account_cursor()?;
602
603            // Call ProofCalculator::proof with account targets
604            let value_encoder = SyncAccountValueEncoder::new(
605                self.trie_cursor_factory.clone(),
606                self.hashed_cursor_factory.clone(),
607            );
608            let mut proof_calculator = ProofCalculator::new(trie_cursor, hashed_cursor);
609            let proof_v2_result = proof_calculator.proof(&value_encoder, [Nibbles::new()])?;
610
611            // Call Proof::multiproof (legacy implementation)
612            let proof_legacy_result =
613                Proof::new(self.trie_cursor_factory.clone(), self.hashed_cursor_factory.clone())
614                    .multiproof(MultiProofTargets::default())?;
615
616            // Decode and sort legacy proof nodes
617            let proof_legacy_nodes = proof_legacy_result
618                .account_subtree
619                .iter()
620                .map(|(path, node_enc)| {
621                    let mut buf = node_enc.as_ref();
622                    let node = TrieNode::decode(&mut buf)
623                        .expect("legacy implementation should not produce malformed proof nodes");
624
625                    ProofTrieNode {
626                        path: *path,
627                        node,
628                        masks: TrieMasks {
629                            hash_mask: proof_legacy_result
630                                .branch_node_hash_masks
631                                .get(path)
632                                .copied(),
633                            tree_mask: proof_legacy_result
634                                .branch_node_tree_masks
635                                .get(path)
636                                .copied(),
637                        },
638                    }
639                })
640                .sorted_by_key(|n| n.path)
641                .collect::<Vec<_>>();
642
643            // Basic comparison: both should succeed and produce identical results
644            assert_eq!(proof_legacy_nodes, proof_v2_result);
645
646            Ok(())
647        }
648    }
649
650    mod proptest_tests {
651        use super::*;
652        use alloy_primitives::{map::B256Map, U256};
653        use proptest::prelude::*;
654        use reth_primitives_traits::Account;
655        use reth_trie_common::HashedPostState;
656
657        /// Generate a strategy for Account values
658        fn account_strategy() -> impl Strategy<Value = Account> {
659            (any::<u64>(), any::<u64>(), any::<[u8; 32]>()).prop_map(
660                |(nonce, balance, code_hash)| Account {
661                    nonce,
662                    balance: U256::from(balance),
663                    bytecode_hash: Some(B256::from(code_hash)),
664                },
665            )
666        }
667
668        /// Generate a strategy for `HashedPostState` with random accounts
669        fn hashed_post_state_strategy() -> impl Strategy<Value = HashedPostState> {
670            prop::collection::vec((any::<[u8; 32]>(), account_strategy()), 0..20).prop_map(
671                |accounts| {
672                    let account_map = accounts
673                        .into_iter()
674                        .map(|(addr_bytes, account)| (B256::from(addr_bytes), Some(account)))
675                        .collect::<B256Map<_>>();
676
677                    // All accounts have empty storages.
678                    let storages = account_map
679                        .keys()
680                        .copied()
681                        .map(|addr| (addr, Default::default()))
682                        .collect::<B256Map<_>>();
683
684                    HashedPostState { accounts: account_map, storages }
685                },
686            )
687        }
688
689        proptest! {
690            #![proptest_config(ProptestConfig::with_cases(5000))]
691
692            /// Tests that ProofCalculator produces valid proofs for randomly generated
693            /// HashedPostState with empty target sets.
694            ///
695            /// This test:
696            /// - Generates random accounts in a HashedPostState
697            /// - Creates a test harness with the generated state
698            /// - Calls assert_proof with an empty target set
699            /// - Verifies both ProofCalculator and legacy Proof succeed
700            #[test]
701            fn proptest_proof_with_empty_targets(
702                post_state in hashed_post_state_strategy(),
703            ) {
704                reth_tracing::init_test_tracing();
705                let harness = ProofTestHarness::new(post_state);
706
707                // Pass empty target set
708                harness.assert_proof(std::iter::empty()).expect("Proof generation failed");
709            }
710        }
711    }
712}