From bfd09eda81e2f621ad79591e719ba4d404980b65 Mon Sep 17 00:00:00 2001 From: Davidson Souza Date: Wed, 31 Dec 2025 17:46:49 -0300 Subject: [PATCH 1/3] refactor!(stump): Move updated data to its own function Before this change, `modify` would return the data needed to update a proof for the new block. This requires additional internal computation and extra allocations. During IBD we may never use this, since proof update is only meant for updating a few blocks worth of changes. Now there's a method specific to pull the modify_data, and `modify` itself will only return a new Stump. When refactoring the addition function, I've modified it a little to allow a smarter one with a very nice property: it can pretend that it added a node, but actually represent it as deleted. The goal here is to do a Swift Sync-style protocol where you don't need deletions. If a txout is already spent, you give an empty hash (BitcoinNodeHash::empty()). This will be exactly equivalent as giving this txo's hash and later on calling delete for with this txo's hash. However, it does this with only one call to modify and doesn't require proofs to achieve that. This is an API breaking change, as modify now only returns one parameter, otherwise the behavior stays unchanged. --- benches/proof_benchmarks.rs | 2 +- benches/stump_benchmarks.rs | 2 +- examples/full-accumulator.rs | 3 +- examples/proof-update.rs | 8 +- examples/simple-stump-update.rs | 7 +- src/accumulator/proof.rs | 75 +++++++------- src/accumulator/stump.rs | 168 ++++++++++++++++++++++++++------ 7 files changed, 182 insertions(+), 83 deletions(-) diff --git a/benches/proof_benchmarks.rs b/benches/proof_benchmarks.rs index 33c3fa3..49d6014 100644 --- a/benches/proof_benchmarks.rs +++ b/benches/proof_benchmarks.rs @@ -55,7 +55,7 @@ fn proof_verification(c: &mut Criterion) { let accumulator_size = 100; let hashes = generate_test_hashes(accumulator_size, 42); let stump = Stump::new(); - let (stump, _) = stump.modify(&hashes, &[], &Proof::default()).unwrap(); + let stump = stump.modify(&hashes, &[], &Proof::default()).unwrap(); for target_count in [1, 10].iter() { let del_hashes = hashes[..*target_count].to_vec(); diff --git a/benches/stump_benchmarks.rs b/benches/stump_benchmarks.rs index 7fa5e16..c4927bc 100644 --- a/benches/stump_benchmarks.rs +++ b/benches/stump_benchmarks.rs @@ -50,7 +50,7 @@ fn stump_verify(c: &mut Criterion) { let test_size = 1000; let hashes = generate_test_hashes(test_size, 42); let stump = Stump::new(); - let (stump, _) = stump.modify(&hashes, &[], &Proof::default()).unwrap(); + let stump = stump.modify(&hashes, &[], &Proof::default()).unwrap(); for proof_size in [1, 10, 100].iter() { let del_hashes = hashes[..*proof_size].to_vec(); diff --git a/examples/full-accumulator.rs b/examples/full-accumulator.rs index 12160c0..6bffff7 100644 --- a/examples/full-accumulator.rs +++ b/examples/full-accumulator.rs @@ -29,8 +29,7 @@ fn main() { // Verify the proof. Notice how we use the del_hashes returned by `prove` here. let s = Stump::new() .modify(&elements, &[], &Proof::default()) - .unwrap() - .0; + .unwrap(); assert_eq!(s.verify(&proof, &[elements[0]]), Ok(true)); // Now we want to update the MemForest, by removing the first utxo, and adding a new one. // This would be in case we received a new block with a transaction spending the first utxo, diff --git a/examples/proof-update.rs b/examples/proof-update.rs index cb02cfd..b696aa7 100644 --- a/examples/proof-update.rs +++ b/examples/proof-update.rs @@ -22,7 +22,9 @@ fn main() { let utxos = get_utxo_hashes1(); // Add the UTXOs to the accumulator. update_data is the data we need to update the proof // after the accumulator is updated. - let (s, update_data) = s.modify(&utxos, &[], &Proof::default()).unwrap(); + let update_data = s.get_update_data(&utxos, &[], &Proof::default()).unwrap(); + let s = s.modify(&utxos, &[], &Proof::default()).unwrap(); + // Create an empty proof, we'll update it to hold our UTXOs let p = Proof::default(); // Update the proof with the UTXOs we added to the accumulator. This proof was initially empty, @@ -48,8 +50,10 @@ fn main() { // We'll remove `0` as it got spent, and add 1..7 to our cache. let new_utxos = get_utxo_hashes2(); // First, update the accumulator - let (stump, update_data) = s.modify(&new_utxos, &[utxos[0]], &p1).unwrap(); + let stump = s.modify(&new_utxos, &[utxos[0]], &p1).unwrap(); + // and the proof + let update_data = s.get_update_data(&utxos, &[], &Proof::default()).unwrap(); let (p2, cached_hashes) = p .update( cached_hashes, diff --git a/examples/simple-stump-update.rs b/examples/simple-stump-update.rs index 139b1b9..2304dfa 100644 --- a/examples/simple-stump-update.rs +++ b/examples/simple-stump-update.rs @@ -27,10 +27,7 @@ fn main() { // Create a new Stump, and add the utxos to it. Notice how we don't use the full return here, // but only the Stump. To understand what is the second return value, see the documentation // for `Stump::modify`, or the proof-update example. - let s = Stump::new() - .modify(&utxos, &[], &Proof::default()) - .unwrap() - .0; + let s = Stump::new().modify(&utxos, &[], &Proof::default()).unwrap(); // Create a proof that the first utxo is in the Stump. let proof = Proof::new(vec![0], vec![utxos[1]]); assert_eq!(s.verify(&proof, &[utxos[0]]), Ok(true)); @@ -42,7 +39,7 @@ fn main() { "d3bd63d53c5a70050a28612a2f4b2019f40951a653ae70736d93745efb1124fa", ) .unwrap(); - let s = s.modify(&[new_utxo], &[utxos[0]], &proof).unwrap().0; + let s = s.modify(&[new_utxo], &[utxos[0]], &proof).unwrap(); // Now we can verify that the new utxo is in the Stump, and the old one is not. let new_proof = Proof::new(vec![2], vec![new_utxo]); assert_eq!(s.verify(&new_proof, &[new_utxo]), Ok(true)); diff --git a/src/accumulator/proof.rs b/src/accumulator/proof.rs index b586fcc..bcf27a7 100644 --- a/src/accumulator/proof.rs +++ b/src/accumulator/proof.rs @@ -48,7 +48,7 @@ //! hashes.push(sha256::Hash::from_engine(engine).into()) //! } //! // Add the UTXOs to the accumulator -//! let s = s.modify(&hashes, &vec![], &Proof::default()).unwrap().0; +//! let s = s.modify(&hashes, &vec![], &Proof::default()).unwrap(); //! // Create a proof for the targets //! let p = Proof::new(targets, proof_hashes); //! // Verify the proof @@ -123,7 +123,7 @@ pub(crate) type NodesAndRootsCurrent = (Vec<(u64, Hash)>, Vec); /// This is used when we need to return the nodes and roots for a proof /// if we are concerned with deleting those elements. The difference is that /// we need to retun the old and updatated roots in the accumulator. -pub(crate) type NodesAndRootsOldNew = (Vec<(u64, Hash)>, Vec<(Hash, Hash)>); +pub(crate) type RootsOldNew = Vec<(Hash, Hash)>; impl Proof { /// Creates a proof from a vector of target and hashes. @@ -260,7 +260,7 @@ impl Proof { /// engine.input(&[i]); /// hashes.push(sha256::Hash::from_engine(engine).into()) /// } - /// let s = s.modify(&hashes, &vec![], &Proof::default()).unwrap().0; + /// let s = s.modify(&hashes, &vec![], &Proof::default()).unwrap(); /// let p = Proof::new(targets, proof_hashes); /// assert!(s.verify(&p, &[hashes[0]]).expect("This proof is valid")); /// ``` @@ -434,7 +434,7 @@ impl Proof { &self, del_hashes: &[(Hash, Hash)], num_leaves: u64, - ) -> Result, String> { + ) -> Result, String> { // Where all the root hashes that we've calculated will go to. let total_rows = util::tree_rows(num_leaves); @@ -495,13 +495,7 @@ impl Proof { computed.push((parent, (old_parent_hash, parent_hash))); } - // we shouldn't return the hashes in the proof - nodes.extend(computed); - let nodes = nodes - .into_iter() - .map(|(pos, (_, new_hash))| (pos, new_hash)) - .collect(); - Ok((nodes, calculated_root_hashes)) + Ok(calculated_root_hashes) } /// This function computes a set of roots from a proof. @@ -565,7 +559,13 @@ impl Proof { return Err(format!("Missing sibling for {next_pos}")); } - let parent_hash = AccumulatorHash::parent_hash(&next_hash, &sibling_hash); + let parent_hash = match (next_hash.is_empty(), sibling_hash.is_empty()) { + (true, true) => AccumulatorHash::empty(), + (true, false) => sibling_hash, + (false, true) => next_hash, + (false, false) => AccumulatorHash::parent_hash(&next_hash, &sibling_hash), + }; + let parent = util::parent(next_pos, total_rows); computed.push((parent, parent_hash)); } @@ -1006,7 +1006,10 @@ mod tests { let block_proof = Proof::new(case_values.update.proof.targets.clone(), block_proof_hashes); - let (stump, updated) = stump.modify(&utxos, &del_hashes, &block_proof).unwrap(); + let new_stump = stump.modify(&utxos, &del_hashes, &block_proof).unwrap(); + let updated = stump + .get_update_data(&utxos, &del_hashes, &block_proof) + .unwrap(); let (cached_proof, cached_hashes) = cached_proof .update( cached_hashes.clone(), @@ -1017,7 +1020,7 @@ mod tests { ) .unwrap(); - let res = stump.verify(&cached_proof, &cached_hashes); + let res = new_stump.verify(&cached_proof, &cached_hashes); let expected_roots: Vec<_> = case_values .expected_roots @@ -1032,7 +1035,7 @@ mod tests { .collect(); assert_eq!(res, Ok(true)); assert_eq!(cached_proof.targets, case_values.expected_targets); - assert_eq!(stump.roots, expected_roots); + assert_eq!(new_stump.roots, expected_roots); assert_eq!(cached_hashes, expected_cached_hashes); } } @@ -1192,7 +1195,7 @@ mod tests { fn test_update_proof_delete() { let preimages = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; let hashes = preimages.into_iter().map(hash_from_u8).collect::>(); - let (stump, _) = Stump::new() + let stump = Stump::new() .modify(&hashes, &[], &Proof::default()) .unwrap(); @@ -1220,13 +1223,22 @@ mod tests { let proof = Proof::new(vec![1, 2, 6], proof_hashes); - let (stump, modified) = stump + let new_stump = stump .modify( &[], &[hash_from_u8(1), hash_from_u8(2), hash_from_u8(6)], &proof, ) .unwrap(); + + let modified = stump + .get_update_data( + &[], + &[hash_from_u8(1), hash_from_u8(2), hash_from_u8(6)], + &proof, + ) + .unwrap(); + let (new_proof, _) = cached_proof .update_proof_remove( vec![1, 2, 6], @@ -1236,7 +1248,7 @@ mod tests { ) .unwrap(); - let res = stump.verify(&new_proof, &[hash_from_u8(0), hash_from_u8(7)]); + let res = new_stump.verify(&new_proof, &[hash_from_u8(0), hash_from_u8(7)]); assert_eq!(res, Ok(true)); } @@ -1249,8 +1261,7 @@ mod tests { // Create a new stump with 8 leaves and 1 root let s = Stump::new() .modify(&hashes, &[], &Proof::default()) - .expect("This stump is valid") - .0; + .expect("This stump is valid"); // Nodes that will be deleted let del_hashes = vec![hashes[0], hashes[2], hashes[4], hashes[6]]; @@ -1342,35 +1353,18 @@ mod tests { .map(|hash| (hash, BitcoinNodeHash::empty())) .collect::>(); - let (computed, roots) = p.calculate_hashes_delete(&del_hashes, 8).unwrap(); + let roots = p.calculate_hashes_delete(&del_hashes, 8).unwrap(); let expected_root_old = BitcoinNodeHash::from_str( "b151a956139bb821d4effa34ea95c17560e0135d1e4661fc23cedc3af49dac42", ) .unwrap(); + let expected_root_new = BitcoinNodeHash::from_str( "726fdd3b432cc59e68487d126e70f0db74a236267f8daeae30b31839a4e7ebed", ) .unwrap(); - let computed_positions = [0_u64, 1, 9, 13, 8, 12, 14].to_vec(); - let computed_hashes = [ - "0000000000000000000000000000000000000000000000000000000000000000", - "4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a", - "9576f4ade6e9bc3a6458b506ce3e4e890df29cb14cb5d3d887672aef55647a2b", - "29590a14c1b09384b94a2c0e94bf821ca75b62eacebc47893397ca88e3bbcbd7", - "4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a", - "2b77298feac78ab51bc5079099a074c6d789bd350442f5079fcba2b3402694e5", - "726fdd3b432cc59e68487d126e70f0db74a236267f8daeae30b31839a4e7ebed", - ] - .iter() - .map(|hash| BitcoinNodeHash::from_str(hash).unwrap()) - .collect::>(); - let expected_computed: Vec<_> = computed_positions - .into_iter() - .zip(computed_hashes) - .collect(); assert_eq!(roots, vec![(expected_root_old, expected_root_new)]); - assert_eq!(computed, expected_computed); } #[test] @@ -1392,8 +1386,7 @@ mod tests { // Create a new stump with 8 leaves and 1 root let s = Stump::new() .modify(&hashes, &[], &Proof::default()) - .expect("This stump is valid") - .0; + .expect("This stump is valid"); // Nodes that will be deleted let del_hashes = vec![hashes[0], hashes[2], hashes[4], hashes[6]]; diff --git a/src/accumulator/stump.rs b/src/accumulator/stump.rs index c337bb4..ec9e364 100644 --- a/src/accumulator/stump.rs +++ b/src/accumulator/stump.rs @@ -23,7 +23,7 @@ //! // that modify is a pure function that doesn't modify the old Stump. //! let s = s.modify(&utxos, &stxos, &Proof::::default()); //! assert!(s.is_ok()); -//! assert_eq!(s.unwrap().0.roots, utxos); +//! assert_eq!(s.unwrap().roots, utxos); //! ``` use std::collections::BTreeSet; @@ -38,9 +38,9 @@ use serde::Serialize; use super::node_hash::AccumulatorHash; use super::node_hash::BitcoinNodeHash; -use super::proof::NodesAndRootsOldNew; use super::proof::Proof; use super::util; +use crate::accumulator::proof::RootsOldNew; #[derive(Debug, Clone, Default)] pub struct UpdateData { @@ -113,7 +113,7 @@ impl Stump { /// .iter() /// .map(|&el| BitcoinNodeHash::from([el; 32])) /// .collect::>(); - /// let (stump, _) = Stump::new() + /// let stump = Stump::new() /// .modify(&hashes, &[], &Proof::default()) /// .unwrap(); /// let mut writer = Vec::new(); @@ -151,9 +151,9 @@ impl Stump { /// let s_old = Stump::new(); /// let mut s_new = Stump::new(); /// - /// let s_old = s_old.modify(&vec![], &vec![], &Proof::default()).unwrap().0; + /// let s_old = s_old.modify(&vec![], &vec![], &Proof::default()).unwrap(); /// s_new = s_old.clone(); - /// s_new = s_new.modify(&vec![], &vec![], &Proof::default()).unwrap().0; + /// s_new = s_new.modify(&vec![], &vec![], &Proof::default()).unwrap(); /// /// // A reorg happened /// s_new.undo(s_old); @@ -207,15 +207,15 @@ impl Stump { /// let stxos = vec![]; /// let s = s.modify(&utxos, &stxos, &Proof::default()); /// assert!(s.is_ok()); - /// assert_eq!(s.unwrap().0.roots, utxos); + /// assert_eq!(s.unwrap().roots, utxos); /// ``` pub fn modify( &self, utxos: &[Hash], del_hashes: &[Hash], proof: &Proof, - ) -> Result<(Self, UpdateData), StumpError> { - let (intermediate, mut computed_roots) = self.remove(del_hashes, proof)?; + ) -> Result { + let mut computed_roots = self.remove(del_hashes, proof)?; let mut new_roots = vec![]; for root in self.roots.iter() { @@ -236,13 +236,42 @@ impl Stump { return Err(StumpError::EmptyProof); } - let (roots, updated, destroyed) = Self::add(new_roots, utxos, self.leaves); + let roots = Self::add(new_roots, utxos, self.leaves); let new_stump = Self { leaves: self.leaves + utxos.len() as u64, roots, }; + Ok(new_stump) + } + + pub fn get_update_data( + &self, + utxos: &[Hash], + del_hashes: &[Hash], + proof: &Proof, + ) -> Result, StumpError> { + let zeroed_del_hashes = del_hashes.iter().map(|_| Hash::empty()).collect::>(); + let (intermediate, _) = proof + .calculate_hashes(&zeroed_del_hashes, self.leaves) + .map_err(StumpError::InvalidProof)?; + let mut computed_roots = self.remove(del_hashes, proof)?; + let mut new_roots = vec![]; + + for root in self.roots.iter() { + if let Some(pos) = computed_roots.iter().position(|(old, _new)| old == root) { + let (old_root, new_root) = computed_roots.remove(pos); + if old_root == *root { + new_roots.push(new_root); + continue; + } + } + + new_roots.push(*root); + } + let (_, updated, destroyed) = Self::compute_update_data_add(new_roots, utxos, self.leaves); + let update_data = UpdateData { new_add: updated, prev_num_leaves: self.leaves, @@ -250,7 +279,7 @@ impl Stump { new_del: intermediate, }; - Ok((new_stump, update_data)) + Ok(update_data) } /// Deserialize the Stump from a Reader @@ -269,7 +298,7 @@ impl Stump { /// .iter() /// .map(|&el| BitcoinNodeHash::from([el; 32])) /// .collect::>(); - /// let (stump, _) = Stump::new() + /// let stump = Stump::new() /// .modify(&hashes, &[], &Proof::default()) /// .unwrap(); /// assert_eq!( @@ -294,12 +323,9 @@ impl Stump { &self, del_hashes: &[Hash], proof: &Proof, - ) -> Result, StumpError> { + ) -> Result, StumpError> { if del_hashes.is_empty() { - return Ok(( - vec![], - self.roots.iter().map(|root| (*root, *root)).collect(), - )); + return Ok(self.roots.iter().map(|root| (*root, *root)).collect()); } let del_hashes = del_hashes @@ -312,8 +338,32 @@ impl Stump { .map_err(StumpError::InvalidProof) } + fn add(mut roots: Vec, utxos: &[Hash], mut leaves: u64) -> Vec { + for add in utxos.iter() { + let pos = leaves; + let mut h = 0; + let mut to_add = *add; + while (pos >> h) & 1 == 1 { + let root = roots.pop().unwrap(); + + to_add = match (root.is_empty(), to_add.is_empty()) { + (true, true) => Hash::empty(), + (true, false) => to_add, + (false, true) => root, + (false, false) => AccumulatorHash::parent_hash(&root, &to_add), + }; + + h += 1; + } + roots.push(to_add); + leaves += 1; + } + + roots + } + /// Adds new leaves into the root - fn add( + fn compute_update_data_add( mut roots: Vec, utxos: &[Hash], mut leaves: u64, @@ -456,7 +506,7 @@ mod test { .map(|&el| CustomHash([el; 32])) .collect::>(); - let (stump, _) = s + let stump = s .modify( &hashes, &[], @@ -504,6 +554,7 @@ mod test { .iter() .map(|hash| BitcoinNodeHash::from_str(hash).unwrap()) .collect(); + let stump = Stump { leaves: data.leaves, roots, @@ -525,7 +576,9 @@ mod test { .map(|hash| BitcoinNodeHash::from_str(hash).unwrap()) .collect::>(); let proof = Proof::new(data.proof_targets, proof_hashes); - let (_, updated) = stump.modify(&utxos, &del_hashes, &proof).unwrap(); + let updated = stump + .get_update_data(&utxos, &del_hashes, &proof) + .expect("Update data should be computed correctly"); // Positions returned after addition let new_add_hash: Vec<_> = data .new_add_hash @@ -563,8 +616,8 @@ mod test { let preimages = vec![0, 1, 2, 3]; let hashes = preimages.into_iter().map(hash_from_u8).collect::>(); - let (_, updated) = Stump::new() - .modify(&hashes, &[], &Proof::default()) + let updated = Stump::new() + .get_update_data(&hashes, &[], &Proof::default()) .unwrap(); let positions = vec![0, 1, 2, 3, 4, 5, 6]; @@ -592,8 +645,7 @@ mod test { fn test_serde_rtt() { let stump = Stump::new() .modify(&[hash_from_u8(0), hash_from_u8(1)], &[], &Proof::default()) - .unwrap() - .0; + .unwrap(); let serialized = serde_json::to_string(&stump).expect("Serialization failed"); let deserialized: Stump = serde_json::from_str(&serialized).expect("Deserialization failed"); @@ -601,6 +653,27 @@ mod test { } fn run_case_with_deletion(case: TestCase) { + // If you happen to know whether a leaf will be deleted in the future, you can + // pretend you've added it, but already account for its deletion. This way, you + // won't have to call modify twice (once for addition, once for deletion), but + // the final accumulator will be the same. + // + // This is useful for swift sync style clients, where you have a bitmap telling + // whether a txout is unspent or not. Using this, you don't need to download + // block proofs and perform fewer hashing, due to not calling `delete` at all. + let leaf_hashes_without_deletions = case + .leaf_preimages + .iter() + .map(|preimage| { + let targets = case.target_values.as_ref().unwrap(); + if targets.contains(&(*preimage as u64)) { + BitcoinNodeHash::empty() + } else { + hash_from_u8(*preimage) + } + }) + .collect::>(); + let leaf_hashes = case .leaf_preimages .into_iter() @@ -634,11 +707,16 @@ mod test { }) .collect::>(); - let (stump, _) = Stump::new() + let stump = Stump::new() .modify(&leaf_hashes, &[], &Proof::default()) .expect("This stump is valid"); - let (stump, _) = stump.modify(&[], &target_hashes, &proof).unwrap(); + let stump = stump.modify(&[], &target_hashes, &proof).unwrap(); + let stump_no_deletion = Stump::new() + .modify(&leaf_hashes_without_deletions, &[], &Proof::default()) + .expect("This stump is valid"); + assert_eq!(stump.roots, roots); + assert_eq!(stump.roots, stump_no_deletion.roots); } fn run_single_addition_case(case: TestCase) { @@ -651,7 +729,7 @@ mod test { .map(|value| hash_from_u8(*value)) .collect::>(); - let (s, _) = s + let s = s .modify(&hashes, &[], &Proof::default()) .expect("Stump from test cases are valid"); @@ -673,8 +751,7 @@ mod test { let s_old = Stump::new(); let s_old = s_old .modify(&hashes, &[], &Proof::default()) - .expect("Stump from test cases are valid") - .0; + .expect("Stump from test cases are valid"); let mut s_new = s_old.clone(); @@ -700,7 +777,7 @@ mod test { .iter() .map(|&el| BitcoinNodeHash::from([el; 32])) .collect::>(); - let (stump, _) = Stump::new() + let stump = Stump::new() .modify(&hashes, &[], &Proof::default()) .unwrap(); let mut writer = Vec::new(); @@ -732,6 +809,36 @@ mod test { run_case_with_deletion(i); } } + + #[test] + fn test_update_no_deletion() { + let leaf_preimages = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + let target_values = [0, 4, 5, 6, 7, 8]; + let leaves = leaf_preimages + .iter() + .map(|preimage| { + if target_values.contains(preimage) { + BitcoinNodeHash::empty() + } else { + hash_from_u8(*preimage) + } + }) + .collect::>(); + + let acc = Stump::new() + .modify(&leaves, &[], &Proof::default()) + .unwrap(); + + let expected_roots = [ + "2b77298feac78ab51bc5079099a074c6d789bd350442f5079fcba2b3402694e5", + "84915b5adf9243dd83d67bb7d25b7a0c595ea1c37b97412e21e480c1a46f93bf", + ] + .iter() + .map(|&hash| BitcoinNodeHash::from_str(hash).unwrap()) + .collect::>(); + + assert_eq!(acc.roots, expected_roots); + } } #[cfg(bench)] @@ -788,8 +895,7 @@ mod bench { .collect::>(); let acc = Stump::new() .modify(&leaves, &vec![], &Proof::default()) - .unwrap() - .0; + .unwrap(); let proof = Proof::new(target_values.to_vec(), proofhashes); bencher.iter(move || acc.modify(&leaves, &target_hashes, &proof)); } From e61386177905314e903eaff19a60b00fb328fccd Mon Sep 17 00:00:00 2001 From: Davidson Souza Date: Fri, 2 Jan 2026 14:29:14 -0300 Subject: [PATCH 2/3] docs: update documentation for the API --- src/accumulator/proof.rs | 78 +++++++++++++++++++++++++---- src/accumulator/stump.rs | 103 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 168 insertions(+), 13 deletions(-) diff --git a/src/accumulator/proof.rs b/src/accumulator/proof.rs index bcf27a7..2e86b51 100644 --- a/src/accumulator/proof.rs +++ b/src/accumulator/proof.rs @@ -70,6 +70,8 @@ use super::util; use super::util::get_proof_positions; use super::util::read_u64; use super::util::tree_rows; +#[cfg(doc)] +use crate::accumulator::stump::Stump; #[derive(Clone, Debug, Eq, PartialEq)] #[cfg_attr(feature = "with-serde", derive(Serialize, Deserialize))] @@ -120,9 +122,9 @@ impl Default for Proof { /// This alias is used when we need to return the nodes and roots for a proof /// if we are not concerned with deleting those elements. pub(crate) type NodesAndRootsCurrent = (Vec<(u64, Hash)>, Vec); -/// This is used when we need to return the nodes and roots for a proof -/// if we are concerned with deleting those elements. The difference is that -/// we need to retun the old and updatated roots in the accumulator. + +/// This pairs old and new values for roots. We use this when computing deletions, +/// as we need to return both the old root (before deletion) and the new root (after deletion). pub(crate) type RootsOldNew = Vec<(Hash, Hash)>; impl Proof { @@ -426,10 +428,10 @@ impl Proof { /// It will compute all roots that contains elements in the proof, by hasing the nodes /// in the path to the root. This function returns the calculated roots and the hashes /// that were calculated in the process. + /// /// This function is used for updating the accumulator **and** verifying proofs. It returns /// the roots computed from the proof (that should be equal to some roots in the present - /// accumulator) and the hashes for a accumulator where the proof elements are removed. - /// If at least one returned element doesn't exist in the accumulator, the proof is invalid. + /// accumulator). If at least one returned element doesn't exist in the accumulator, the proof is invalid. pub(crate) fn calculate_hashes_delete( &self, del_hashes: &[(Hash, Hash)], @@ -505,7 +507,9 @@ impl Proof { /// hashes that were calculated in the process. /// This differs from `calculate_hashes_delelte` as this one is only used for verifying /// proofs, it doesn't compute the roots after the deletion, only the roots that are - /// needed for verification (i.e. the current accumulator). + /// needed for verification (i.e. the current accumulator). `calculate_hashes_delete` also + /// won't return the hashes that were calculated, we won't need them for updating the + /// accumulator. pub(crate) fn calculate_hashes( &self, del_hashes: &[Hash], @@ -607,10 +611,64 @@ impl Proof { } } - /// Uses the data passed in to update a proof, creating a valid proof for a given - /// set of targets, after an update. This is useful for caching UTXOs. You grab a proof - /// for it once and then keep updating it every block, yielding an always valid proof - /// over those UTXOs. + /// Utreexo is a dynamic accumulator, meaning that leaves can be added and removed. + /// This causes the proof to also change over time, if you have a proof for a given + /// block height, it may not be valid for block height + 1, the elements in this proof + /// may change position, or even be deleted. + /// + /// If you have a proof that's valid, but is for a block a few heights behind, you can use + /// [`Proof::update`] to update the proof to be valid for the current [`Stump`]. + /// + /// Before calling this method, you will need to use [`Stump::get_update_data`] to + /// get the changes that happened in the accumulator for that block. This includes the nodes + /// added and deleted in that block, as well as the previous number of leaves. + /// + /// After computing this, you can use [`Proof::update`] to update your proof to be valid for + /// the next accumulator. Call these for all blocks between your proof's height and the current + /// height, and you'll have a valid proof for the current [`Stump`]. + /// + /// # Example + /// + /// ``` + /// use std::str::FromStr; + /// + /// use rustreexo::accumulator::node_hash::BitcoinNodeHash; + /// use rustreexo::accumulator::proof::Proof; + /// use rustreexo::accumulator::stump::Stump; + /// + /// let s = Stump::new(); + /// let utxos = vec![BitcoinNodeHash::from_str( + /// "b151a956139bb821d4effa34ea95c17560e0135d1e4661fc23cedc3af49dac42", + /// ) + /// .unwrap()]; + /// let del_hashes = vec![]; + /// let proof = Proof::default(); + /// let final_stump = s.modify(&utxos, &del_hashes, &proof).unwrap(); + /// + /// // The update data, computed from the block changes + /// let update_data = s.get_update_data(&utxos, &del_hashes, &proof).unwrap(); + /// + /// // These are the hashes for all the targets currently in the proof. For us, since + /// // the proof is empty, this is also empty. + /// let cached_hashes = vec![]; + /// + /// // This tells `update` which UTXOs created on this block we should cache. After + /// // using this, our proof will now contain those UTXOs. It is an index, in the same order + /// // as they appear in `utxos`. + /// let cache_new_utxos = vec![0]; + /// + /// // The target for UTXOs being deleted in this block. You will usually find this along + /// // with this block's proof + /// let targets = vec![]; + /// + /// // Call update to get the new proof and the hashes for the utxos being cached + /// let (proof_updated, cached_hashes) = proof + /// .update(cached_hashes, utxos, targets, cache_new_utxos, update_data) + /// .unwrap(); + /// + /// // Now we can verify this proof with the UTXO added in this block + /// assert!(final_stump.verify(&proof_updated, &cached_hashes).unwrap()); + /// ``` pub fn update( self, cached_hashes: Vec, diff --git a/src/accumulator/stump.rs b/src/accumulator/stump.rs index ec9e364..29c8258 100644 --- a/src/accumulator/stump.rs +++ b/src/accumulator/stump.rs @@ -1,6 +1,6 @@ //! A [Stump] is a basic data structure used in Utreexo. It only holds the roots and the number of leaves -//! in the accumulator. This is useful to create lightweight nodes, the still validates, but is more compact, -//! perfect to clients running on low-power devices. +//! in the accumulator. This is useful to create lightweight nodes, the still validates, but is more compact. +//! This is useful for clients running is super constrained environments, such as mobile devices. //! //! ## Example //! ``` @@ -191,7 +191,20 @@ impl Stump { /// matters, you can only modify, providing a list of utxos to be added, /// and txos to be removed, along with it's proof. Either may be /// empty. + /// + /// ## Adding stoxs to the stump + /// + /// If you know that an element will be deleted in the future, you can + /// pass an empty hash as the utxo to be added. There's a possible + /// optimization here, where you can apply the changes caused by deletion + /// during addition. If you do so, you don't need to call `modify` when + /// this leaf gets deleted. This will save you some hashing, as well as + /// you won't need proofs anymore for those leaves, thus saving you + /// bandwidth. + /// ///# Example + /// Using the normal addition + /// /// ``` /// use std::str::FromStr; /// @@ -209,6 +222,30 @@ impl Stump { /// assert!(s.is_ok()); /// assert_eq!(s.unwrap().roots, utxos); /// ``` + /// + /// Using addition with implicit deletion + /// + /// ``` + /// use std::str::FromStr; + /// + /// use rustreexo::accumulator::node_hash::AccumulatorHash; + /// use rustreexo::accumulator::node_hash::BitcoinNodeHash; + /// use rustreexo::accumulator::proof::Proof; + /// use rustreexo::accumulator::stump::Stump; + /// + /// let s = Stump::new(); + /// let utxos = vec![ + /// BitcoinNodeHash::from_str( + /// "b151a956139bb821d4effa34ea95c17560e0135d1e4661fc23cedc3af49dac42", + /// ) + /// .unwrap(), + /// BitcoinNodeHash::empty(), // This UTXO is going to be deleted in the future + /// ]; + /// let stxos = vec![]; + /// let s = s.modify(&utxos, &stxos, &Proof::default()).unwrap(); + /// assert!(s.roots.contains(&utxos[0])); + /// assert!(!s.roots.contains(&utxos[1])); // The empty hash won't be in the roots + /// ``` pub fn modify( &self, utxos: &[Hash], @@ -246,6 +283,66 @@ impl Stump { Ok(new_stump) } + /// Utreexo is a dynamic accumulator, meaning that leaves can be added and removed. + /// This causes the proof to also change over time, if you have a proof for a given + /// block height, it may not be valid for block height + 1, the elements in this proof + /// may change position, or even be deleted. + /// + /// If you have a proof that's valid, but is for a block a few heights behind, you can use + /// [`Proof::update`] to update the proof to be valid for the current [`Stump`]. However, to + /// update a proof, you need to know everything that changed in the accumulator since the proof + /// was created. This function will give you everything you need. + /// + /// To use it, you must know what was added -- this is computed by looking at the block. You + /// also need the block's [`Proof`] and the hashes that were deleted in that block. For this + /// one, you either store them on disk, or request them from the network. + /// + /// After computing this, you can use [`Proof::update`] to update your proof to be valid for + /// the next accumulator. Call these for all blocks between your proof's height and the current + /// height, and you'll have a valid proof for the current [`Stump`]. + /// + /// # Example + /// + /// ``` + /// use std::str::FromStr; + /// + /// use rustreexo::accumulator::node_hash::BitcoinNodeHash; + /// use rustreexo::accumulator::proof::Proof; + /// use rustreexo::accumulator::stump::Stump; + /// + /// let s = Stump::new(); + /// let utxos = vec![BitcoinNodeHash::from_str( + /// "b151a956139bb821d4effa34ea95c17560e0135d1e4661fc23cedc3af49dac42", + /// ) + /// .unwrap()]; + /// let del_hashes = vec![]; + /// let proof = Proof::default(); + /// let final_stump = s.modify(&utxos, &del_hashes, &proof).unwrap(); + /// + /// // The update data, computed from the block changes + /// let update_data = s.get_update_data(&utxos, &del_hashes, &proof).unwrap(); + /// + /// // These are the hashes for all the targets currently in the proof. For us, since + /// // the proof is empty, this is also empty. + /// let cached_hashes = vec![]; + /// + /// // This tells `update` which UTXOs created on this block we should cache. After + /// // using this, our proof will now contain those UTXOs. It is an index, in the same order + /// // as they appear in `utxos`. + /// let cache_new_utxos = vec![0]; + /// + /// // The target for UTXOs being deleted in this block. You will usually find this along + /// // with this block's proof + /// let targets = vec![]; + /// + /// // Call update to get the new proof and the hashes for the utxos being cached + /// let (proof_updated, cached_hashes) = proof + /// .update(cached_hashes, utxos, targets, cache_new_utxos, update_data) + /// .unwrap(); + /// + /// // Now we can verify this proof with the UTXO added in this block + /// assert!(final_stump.verify(&proof_updated, &cached_hashes).unwrap()); + /// ``` pub fn get_update_data( &self, utxos: &[Hash], @@ -811,7 +908,7 @@ mod test { } #[test] - fn test_update_no_deletion() { + fn test_update_no_explicit_deletion() { let leaf_preimages = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; let target_values = [0, 4, 5, 6, 7, 8]; let leaves = leaf_preimages From 38e545e27afe0ab76d6590f5c4c76d55996a8627 Mon Sep 17 00:00:00 2001 From: Davidson Souza Date: Fri, 2 Jan 2026 14:31:55 -0300 Subject: [PATCH 3/3] docs(examples): update docstring to match new flow --- examples/proof-update.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/examples/proof-update.rs b/examples/proof-update.rs index b696aa7..9df9407 100644 --- a/examples/proof-update.rs +++ b/examples/proof-update.rs @@ -18,15 +18,20 @@ use rustreexo::accumulator::stump::Stump; fn main() { let s = Stump::new(); + // Get the hashes of the UTXOs we want to insert let utxos = get_utxo_hashes1(); - // Add the UTXOs to the accumulator. update_data is the data we need to update the proof - // after the accumulator is updated. + + // Compute the update data for this block. Notice that we called this before updating the + // accumulator, as the accumulator state is required to compute this data. let update_data = s.get_update_data(&utxos, &[], &Proof::default()).unwrap(); + + // Now, update the accumulator with these UTXOs, no STXOs are being spent in this example. let s = s.modify(&utxos, &[], &Proof::default()).unwrap(); // Create an empty proof, we'll update it to hold our UTXOs let p = Proof::default(); + // Update the proof with the UTXOs we added to the accumulator. This proof was initially empty, // but we can instruct this function to remember some UTXOs, given their positions in the list of // UTXOs we added to the accumulator. In this example, we ask it to cache 0 and 1. @@ -37,6 +42,7 @@ fn main() { let (p, cached_hashes) = p .update(vec![], utxos.clone(), vec![], vec![0, 1], update_data) .unwrap(); + // This should be a valid proof over 0 and 1. assert_eq!(p.n_targets(), 2); assert_eq!(s.verify(&p, &cached_hashes), Ok(true)); @@ -44,11 +50,13 @@ fn main() { // Get a subset of the proof, for the first UTXO only let p1 = p.get_proof_subset(&cached_hashes, &[0], s.leaves).unwrap(); + // Should still be valid assert_eq!(s.verify(&p1, &cached_hashes), Ok(true)); // Assume we have a block that (beyond coinbase) spends our UTXO `0` and creates 7 new UTXOs // We'll remove `0` as it got spent, and add 1..7 to our cache. let new_utxos = get_utxo_hashes2(); + // First, update the accumulator let stump = s.modify(&new_utxos, &[utxos[0]], &p1).unwrap();