diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 81f4a1796..9e512dbb5 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -11,13 +11,24 @@ pub use bdk_core::{CheckPoint, CheckPointIter}; use bitcoin::block::Header; use bitcoin::BlockHash; +/// Error for `apply_changeset_to_checkpoint`. +#[derive(Debug)] +struct ApplyChangeSetError { + error: Option>, +} + /// Apply `changeset` to the checkpoint. +/// +/// # Errors +/// +/// - If constructing the new chain from the provided `changeset` fails, then a +/// [`ApplyChangeSetError`] is returned. fn apply_changeset_to_checkpoint( mut init_cp: CheckPoint, changeset: &ChangeSet, -) -> Result, MissingGenesisError> +) -> Result, ApplyChangeSetError> where - D: ToBlockHash + fmt::Debug + Copy, + D: ToBlockHash + fmt::Debug + Clone, { if let Some(start_height) = changeset.blocks.keys().next().cloned() { // changes after point of agreement @@ -34,13 +45,13 @@ where } } - for (&height, &data) in &changeset.blocks { + for (height, data) in &changeset.blocks { match data { Some(data) => { - extension.insert(height, data); + extension.insert(*height, data.clone()); } None => { - extension.remove(&height); + extension.remove(height); } }; } @@ -48,8 +59,11 @@ where let new_tip = match base { Some(base) => base .extend(extension) - .expect("extension is strictly greater than base"), - None => LocalChain::from_blocks(extension)?.tip(), + .map_err(Option::Some) + .map_err(|error| ApplyChangeSetError { error })?, + None => LocalChain::from_blocks(extension) + .map_err(|error| ApplyChangeSetError { error })? + .tip(), }; init_cp = new_tip; } @@ -234,7 +248,7 @@ impl LocalChain { // Methods where `D: ToBlockHash` impl LocalChain where - D: ToBlockHash + fmt::Debug + Copy, + D: ToBlockHash + fmt::Debug + Clone, { /// Constructs a [`LocalChain`] from genesis data. pub fn from_genesis(data: D) -> (Self, ChangeSet) { @@ -251,19 +265,22 @@ where /// /// The [`BTreeMap`] enforces the height order. However, the caller must ensure the blocks are /// all of the same chain. - pub fn from_blocks(blocks: BTreeMap) -> Result { + /// + /// Returns `Err(None)` if `blocks` doesn't contain a value at height `0` a.k.a + /// "genesis" block. + pub fn from_blocks(blocks: BTreeMap) -> Result>> { if !blocks.contains_key(&0) { - return Err(MissingGenesisError); + return Err(None); } Ok(Self { - tip: CheckPoint::from_blocks(blocks).expect("blocks must be in order"), + tip: CheckPoint::from_blocks(blocks)?, }) } /// Construct a [`LocalChain`] from an initial `changeset`. pub fn from_changeset(changeset: ChangeSet) -> Result { - let genesis_entry = changeset.blocks.get(&0).copied().flatten(); + let genesis_entry = changeset.blocks.get(&0).cloned().flatten(); let genesis_data = match genesis_entry { Some(data) => data, None => return Err(MissingGenesisError), @@ -303,7 +320,7 @@ where &mut self, update: CheckPoint, ) -> Result, CannotConnectError> { - let (new_tip, changeset) = merge_chains(self.tip.clone(), update)?; + let (new_tip, changeset) = self.merge_chains(update)?; self.tip = new_tip; debug_assert!(self._check_changeset_is_applied(&changeset)); Ok(changeset) @@ -312,7 +329,8 @@ where /// Apply the given `changeset`. pub fn apply_changeset(&mut self, changeset: &ChangeSet) -> Result<(), MissingGenesisError> { let old_tip = self.tip.clone(); - let new_tip = apply_changeset_to_checkpoint(old_tip, changeset)?; + let new_tip = + apply_changeset_to_checkpoint(old_tip, changeset).map_err(|_| MissingGenesisError)?; self.tip = new_tip; debug_assert!(self._check_changeset_is_applied(changeset)); Ok(()) @@ -412,7 +430,7 @@ where match cur.get(exp_height) { Some(cp) => { if cp.height() != exp_height - || Some(cp.hash()) != exp_data.map(|d| d.to_blockhash()) + || Some(cp.hash()) != exp_data.as_ref().map(ToBlockHash::to_blockhash) { return false; } @@ -576,148 +594,138 @@ impl core::fmt::Display for ApplyHeaderError { #[cfg(feature = "std")] impl std::error::Error for ApplyHeaderError {} -/// Applies `update_tip` onto `original_tip`. -/// -/// On success, a tuple is returned ([`CheckPoint`], [`ChangeSet`]). -/// -/// # Errors -/// -/// [`CannotConnectError`] occurs when the `original_tip` and `update_tip` chains are disjoint: -/// -/// - If no point of agreement is found between the update and original chains. -/// - A point of agreement is found but the update is ambiguous above the point of agreement (a.k.a. -/// the update and original chain both have a block above the point of agreement, but their -/// heights do not overlap). -/// - The update attempts to replace the genesis block of the original chain. -fn merge_chains( - original_tip: CheckPoint, - update_tip: CheckPoint, -) -> Result<(CheckPoint, ChangeSet), CannotConnectError> +impl LocalChain where - D: ToBlockHash + fmt::Debug + Copy, + D: ToBlockHash + fmt::Debug + Clone, { - let mut changeset = ChangeSet::::default(); - - let mut orig = original_tip.iter(); - let mut update = update_tip.iter(); - - let mut curr_orig = None; - let mut curr_update = None; - - let mut prev_orig: Option> = None; - let mut prev_update: Option> = None; - - let mut point_of_agreement_found = false; - - let mut prev_orig_was_invalidated = false; - - let mut potentially_invalidated_heights = vec![]; - - // If we can, we want to return the update tip as the new tip because this allows checkpoints - // in multiple locations to keep the same `Arc` pointers when they are being updated from each - // other using this function. We can do this as long as the update contains every - // block's height of the original chain. - let mut is_update_height_superset_of_original = true; - - // To find the difference between the new chain and the original we iterate over both of them - // from the tip backwards in tandem. We are always dealing with the highest one from either - // chain first and move to the next highest. The crucial logic is applied when they have - // blocks at the same height. - loop { - if curr_orig.is_none() { - curr_orig = orig.next(); - } - if curr_update.is_none() { - curr_update = update.next(); - } + /// Applies `update_tip` onto `original_tip`. + /// + /// On success, a tuple is returned ([`CheckPoint`], [`ChangeSet`]). + /// + /// # Errors + /// + /// [`CannotConnectError`] occurs when the `original_tip` and `update_tip` chains are disjoint: + /// + /// - If no point of agreement is found between the update and original chains, and no explicit + /// invalidation occurred. + /// - A point of agreement is found but the update is ambiguous above the point of agreement + /// (a.k.a. the update and original chain both have a block above the point of agreement, but + /// their heights do not overlap). + /// - The update attempts to replace the genesis block of the original chain. + fn merge_chains( + &mut self, + update_tip: CheckPoint, + ) -> Result<(CheckPoint, ChangeSet), CannotConnectError> { + use alloc::vec::Vec; + use core::cmp::Ordering; + + let mut original_iter = self.tip().iter().peekable(); + let mut update_iter = update_tip.iter().peekable(); + + let mut point_of_agreement = Option::::None; + let mut previous_original_height = Option::::None; + let mut previous_update_height = Option::::None; + let mut is_update_height_superset_of_original = false; + let mut potentially_invalid_block_ids = Vec::::new(); + let mut is_previous_original_invalid = false; + let mut changeset = ChangeSet::::default(); - match (curr_orig.as_ref(), curr_update.as_ref()) { - // Update block that doesn't exist in the original chain - (o, Some(u)) if Some(u.height()) > o.map(|o| o.height()) => { - changeset.blocks.insert(u.height(), Some(u.data())); - prev_update = curr_update.take(); - } - // Original block that isn't in the update - (Some(o), u) if Some(o.height()) > u.map(|u| u.height()) => { - // this block might be gone if an earlier block gets invalidated - potentially_invalidated_heights.push(o.height()); - prev_orig_was_invalidated = false; - prev_orig = curr_orig.take(); - - is_update_height_superset_of_original = false; - - // OPTIMIZATION: we have run out of update blocks so we don't need to continue - // iterating because there's no possibility of adding anything to changeset. - if u.is_none() { + loop { + match (original_iter.peek(), update_iter.peek()) { + // Error if attempting to change the genesis block. + (_, Some(update)) + if update.height() == 0 && update.hash() != self.genesis_hash() => + { + return Err(CannotConnectError { + try_include_height: 0, + }); + } + // We're done when all updates are processed. + (_, None) => { break; } - } - (Some(o), Some(u)) => { - if o.hash() == u.hash() { - // We have found our point of agreement 🎉 -- we require that the previous (i.e. - // higher because we are iterating backwards) block in the original chain was - // invalidated (if it exists). This ensures that there is an unambiguous point - // of connection to the original chain from the update chain - // (i.e. we know the precisely which original blocks are - // invalid). - if !prev_orig_was_invalidated && !point_of_agreement_found { - if let (Some(prev_orig), Some(_prev_update)) = (&prev_orig, &prev_update) { - return Err(CannotConnectError { - try_include_height: prev_orig.height(), - }); + (Some(original), Some(update)) => { + // First compare heights. For any updates that aren't in the original + // chain add them to the changeset. Retain block IDs of the original chain + // that are not in the update, in case we need to invalidate them. + // We only advance each iterator on the higher of the two chains, or both + // if equal height, and update the previously seen height on each turn. + match update.height().cmp(&original.height()) { + // Update height not in original. + Ordering::Greater => { + changeset + .blocks + .insert(update.height(), Some(update.data())); + previous_update_height = Some(update.height()); + update_iter.next(); } - } - point_of_agreement_found = true; - prev_orig_was_invalidated = false; - // OPTIMIZATION 2 -- if we have the same underlying pointer at this point, we - // can guarantee that no older blocks are introduced. - if o.eq_ptr(u) { - if is_update_height_superset_of_original { - return Ok((update_tip, changeset)); - } else { - let new_tip = apply_changeset_to_checkpoint(original_tip, &changeset) - .map_err(|_| CannotConnectError { - try_include_height: 0, - })?; - return Ok((new_tip, changeset)); + // Original height not in update. + Ordering::Less => { + potentially_invalid_block_ids.push(original.block_id()); + is_previous_original_invalid = false; + previous_original_height = Some(original.height()); + is_update_height_superset_of_original = false; + original_iter.next(); + } + // Compare hashes. + Ordering::Equal => { + if update.hash() == original.hash() { + // Reached a point of agreement but the chains are disjoint + if !is_previous_original_invalid && point_of_agreement.is_none() { + if let (Some(previous_original_height), Some(..)) = + (previous_original_height, previous_update_height) + { + return Err(CannotConnectError { + try_include_height: previous_original_height, + }); + } + } + point_of_agreement = Some(original.height()); + is_previous_original_invalid = false; + // OPTIMIZATION: If we have the same underlying pointer, we can + // return the new tip as it's guaranteed to connect. + if update.eq_ptr(original) && is_update_height_superset_of_original + { + return Ok((update_tip, changeset)); + } + // We have an explicit invalidation, so we need to mark all previously + // seen blocks of the original tip invalid. + } else { + for block_id in potentially_invalid_block_ids.drain(..) { + changeset.blocks.insert(block_id.height, None); + } + is_previous_original_invalid = true; + changeset + .blocks + .insert(update.height(), Some(update.data())); + } + previous_update_height = Some(update.height()); + previous_original_height = Some(original.height()); + update_iter.next(); + original_iter.next(); } } - } else { - // We have an invalidation height so we set the height to the updated hash and - // also purge all the original chain block hashes above this block. - changeset.blocks.insert(u.height(), Some(u.data())); - for invalidated_height in potentially_invalidated_heights.drain(..) { - changeset.blocks.insert(invalidated_height, None); - } - prev_orig_was_invalidated = true; } - prev_update = curr_update.take(); - prev_orig = curr_orig.take(); - } - (None, None) => { - break; - } - _ => { - unreachable!("compiler cannot tell that everything has been covered") + (None, Some(..)) => unreachable!("Original can't be exhausted before update"), } } - } - // When we don't have a point of agreement you can imagine it is implicitly the - // genesis block so we need to do the final connectivity check which in this case - // just means making sure the entire original chain was invalidated. - if !prev_orig_was_invalidated && !point_of_agreement_found { - if let Some(prev_orig) = prev_orig { - return Err(CannotConnectError { - try_include_height: prev_orig.height(), - }); + // Fail if no point of agreement is found, and no explicit invalidation occurred. + if !is_previous_original_invalid && point_of_agreement.is_none() { + if let Some(previous_original_height) = previous_original_height { + return Err(CannotConnectError { + try_include_height: previous_original_height, + }); + } } - } - let new_tip = apply_changeset_to_checkpoint(original_tip, &changeset).map_err(|_| { - CannotConnectError { - try_include_height: 0, - } - })?; - Ok((new_tip, changeset)) + // Apply changeset to tip. + let new_tip = apply_changeset_to_checkpoint(self.tip(), &changeset).map_err(|e| { + CannotConnectError { + try_include_height: e.error.as_ref().map_or(0, CheckPoint::height), + } + })?; + + Ok((new_tip, changeset)) + } } diff --git a/crates/chain/tests/test_local_chain.rs b/crates/chain/tests/test_local_chain.rs index 7ad03f04f..c3078ce81 100644 --- a/crates/chain/tests/test_local_chain.rs +++ b/crates/chain/tests/test_local_chain.rs @@ -1,5 +1,4 @@ #![cfg(feature = "miniscript")] - use std::collections::BTreeMap; use std::ops::{Bound, RangeBounds}; @@ -76,48 +75,47 @@ impl TestLocalChain<'_> { fn update_local_chain() { [ TestLocalChain { - name: "add first tip", - chain: local_chain![(0, hash!("A"))], - update: chain_update![(0, hash!("A"))], + name: "No change", + chain: local_chain![(0, hash!("_"))], + update: chain_update![(0, hash!("_"))], exp: ExpectedResult::Ok { changeset: &[], - init_changeset: &[(0, Some(hash!("A")))], + init_changeset: &[(0, Some(hash!("_")))], }, }, TestLocalChain { - name: "add second tip", - chain: local_chain![(0, hash!("A"))], - update: chain_update![(0, hash!("A")), (1, hash!("B"))], + name: "Add first tip", + chain: local_chain![(0, hash!("_"))], + update: chain_update!((0, hash!("_")), (1, hash!("A"))), exp: ExpectedResult::Ok { - changeset: &[(1, Some(hash!("B")))], - init_changeset: &[(0, Some(hash!("A"))), (1, Some(hash!("B")))], + changeset: &[(1, Some(hash!("A")))], + init_changeset: &[(0, Some(hash!("_"))), (1, Some(hash!("A")))], }, }, + // Two disjoint chain can't merge + // | 0 | 1 | 2 | 3 + // chain | _ B + // update | A C TestLocalChain { name: "two disjoint chains cannot merge", - chain: local_chain![(0, hash!("_")), (1, hash!("A"))], - update: chain_update![(0, hash!("_")), (2, hash!("B"))], + chain: local_chain![(0, hash!("_")), (2, hash!("B"))], + update: chain_update![(1, hash!("A")), (3, hash!("C"))], exp: ExpectedResult::Err(CannotConnectError { - try_include_height: 1, + try_include_height: 2, }), }, + // Two disjoint chain can't merge (existing longer) + // | 0 | 1 | 2 | 3 + // chain | _ B + // update | A TestLocalChain { name: "two disjoint chains cannot merge (existing chain longer)", - chain: local_chain![(0, hash!("_")), (2, hash!("A"))], - update: chain_update![(0, hash!("_")), (1, hash!("B"))], + chain: local_chain![(0, hash!("_")), (2, hash!("B"))], + update: chain_update![(1, hash!("A"))], exp: ExpectedResult::Err(CannotConnectError { try_include_height: 2, }), }, - TestLocalChain { - name: "duplicate chains should merge", - chain: local_chain![(0, hash!("A"))], - update: chain_update![(0, hash!("A"))], - exp: ExpectedResult::Ok { - changeset: &[], - init_changeset: &[(0, Some(hash!("A")))], - }, - }, // Introduce an older checkpoint (B) // | 0 | 1 | 2 | 3 // chain | _ C D @@ -128,7 +126,12 @@ fn update_local_chain() { update: chain_update![(0, hash!("_")), (1, hash!("B")), (2, hash!("C"))], exp: ExpectedResult::Ok { changeset: &[(1, Some(hash!("B")))], - init_changeset: &[(0, Some(hash!("_"))), (1, Some(hash!("B"))), (2, Some(hash!("C"))), (3, Some(hash!("D")))], + init_changeset: &[ + (0, Some(hash!("_"))), + (1, Some(hash!("B"))), + (2, Some(hash!("C"))), + (3, Some(hash!("D"))), + ], }, }, // Introduce an older checkpoint (A) that is not directly behind PoA @@ -141,8 +144,13 @@ fn update_local_chain() { update: chain_update![(0, hash!("_")), (2, hash!("A")), (4, hash!("C"))], exp: ExpectedResult::Ok { changeset: &[(2, Some(hash!("A")))], - init_changeset: &[(0, Some(hash!("_"))), (2, Some(hash!("A"))), (3, Some(hash!("B"))), (4, Some(hash!("C")))], - } + init_changeset: &[ + (0, Some(hash!("_"))), + (2, Some(hash!("A"))), + (3, Some(hash!("B"))), + (4, Some(hash!("C"))), + ], + }, }, // Introduce an older checkpoint (B) that is not the oldest checkpoint // | 0 | 1 | 2 | 3 @@ -154,8 +162,13 @@ fn update_local_chain() { update: chain_update![(0, hash!("_")), (2, hash!("B")), (3, hash!("C"))], exp: ExpectedResult::Ok { changeset: &[(2, Some(hash!("B")))], - init_changeset: &[(0, Some(hash!("_"))), (1, Some(hash!("A"))), (2, Some(hash!("B"))), (3, Some(hash!("C")))], - } + init_changeset: &[ + (0, Some(hash!("_"))), + (1, Some(hash!("A"))), + (2, Some(hash!("B"))), + (3, Some(hash!("C"))), + ], + }, }, // Introduce two older checkpoints below the PoA // | 0 | 1 | 2 | 3 @@ -164,30 +177,69 @@ fn update_local_chain() { TestLocalChain { name: "introduce two older checkpoints below PoA", chain: local_chain![(0, hash!("_")), (3, hash!("C"))], - update: chain_update![(0, hash!("_")), (1, hash!("A")), (2, hash!("B")), (3, hash!("C"))], + update: chain_update![ + (0, hash!("_")), + (1, hash!("A")), + (2, hash!("B")), + (3, hash!("C")) + ], exp: ExpectedResult::Ok { changeset: &[(1, Some(hash!("A"))), (2, Some(hash!("B")))], - init_changeset: &[(0, Some(hash!("_"))), (1, Some(hash!("A"))), (2, Some(hash!("B"))), (3, Some(hash!("C")))], + init_changeset: &[ + (0, Some(hash!("_"))), + (1, Some(hash!("A"))), + (2, Some(hash!("B"))), + (3, Some(hash!("C"))), + ], }, }, + // B and C are in both chain and update + // | 0 | 1 | 2 | + // chain | _ A B + // update | _ A' B + // This should succeed with the point of agreement being C and A should be added in + // addition. TestLocalChain { name: "fix blockhash before agreement point", - chain: local_chain![(0, hash!("im-wrong")), (1, hash!("we-agree"))], - update: chain_update![(0, hash!("fix")), (1, hash!("we-agree"))], + chain: local_chain![(0, hash!("_")), (1, hash!("wrong")), (2, hash!("B"))], + update: chain_update![(1, hash!("A")), (2, hash!("B"))], exp: ExpectedResult::Ok { - changeset: &[(0, Some(hash!("fix")))], - init_changeset: &[(0, Some(hash!("fix"))), (1, Some(hash!("we-agree")))], + changeset: &[(1, Some(hash!("A")))], + init_changeset: &[ + (0, Some(hash!("_"))), + (1, Some(hash!("A"))), + (2, Some(hash!("B"))), + ], }, }, + // Cannot replace genesis block + // | 0 | 1 | 2 | 3 | 4 + // chain | _ A + // update | _' A + TestLocalChain { + name: "cannot replace the genesis hash", + chain: local_chain![(0, hash!("G")), (1, hash!("A"))], + update: chain_update![(0, hash!("g")), (1, hash!("A"))], + exp: ExpectedResult::Err(CannotConnectError { + try_include_height: 0, + }), + }, // B and C are in both chain and update // | 0 | 1 | 2 | 3 | 4 // chain | _ B C // update | _ A B C D - // This should succeed with the point of agreement being C and A should be added in addition. + // This should succeed with the point of agreement being C and A should be added in + // addition. TestLocalChain { name: "two points of agreement", chain: local_chain![(0, hash!("_")), (2, hash!("B")), (3, hash!("C"))], - update: chain_update![(0, hash!("_")), (1, hash!("A")), (2, hash!("B")), (3, hash!("C")), (4, hash!("D"))], + update: chain_update![ + (0, hash!("_")), + (1, hash!("A")), + (2, hash!("B")), + (3, hash!("C")), + (4, hash!("D")) + ], exp: ExpectedResult::Ok { changeset: &[(1, Some(hash!("A"))), (4, Some(hash!("D")))], init_changeset: &[ @@ -207,20 +259,52 @@ fn update_local_chain() { TestLocalChain { name: "update and chain does not connect", chain: local_chain![(0, hash!("_")), (2, hash!("B")), (3, hash!("C"))], - update: chain_update![(0, hash!("_")), (1, hash!("A")), (2, hash!("B")), (4, hash!("D"))], + update: chain_update![ + (0, hash!("_")), + (1, hash!("A")), + (2, hash!("B")), + (4, hash!("D")) + ], exp: ExpectedResult::Err(CannotConnectError { try_include_height: 3, }), }, - // Transient invalidation: + // Invalidation + extend chain + // | 0 | 1 | 2 | 3 + // chain | _ A + // update | A' B' + TestLocalChain { + name: "invalidate tip and extend chain", + chain: local_chain![(0, hash!("_")), (1, hash!("A"))], + update: chain_update![(1, hash!("A'")), (2, hash!("B'"))], + exp: ExpectedResult::Ok { + changeset: &[(1, Some(hash!("A'"))), (2, Some(hash!("B'")))], + init_changeset: &[ + (0, Some(hash!("_"))), + (1, Some(hash!("A'"))), + (2, Some(hash!("B'"))), + ], + }, + }, + // Transitive invalidation: // | 0 | 1 | 2 | 3 | 4 | 5 // chain | _ B C E // update | _ B' C' D // This should succeed and invalidate B,C and E with point of agreement being A. TestLocalChain { name: "transitive invalidation applies to checkpoints higher than invalidation", - chain: local_chain![(0, hash!("_")), (2, hash!("B")), (3, hash!("C")), (5, hash!("E"))], - update: chain_update![(0, hash!("_")), (2, hash!("B'")), (3, hash!("C'")), (4, hash!("D"))], + chain: local_chain![ + (0, hash!("A")), + (2, hash!("B")), + (3, hash!("C")), + (5, hash!("E")) + ], + update: chain_update![ + (0, hash!("A")), + (2, hash!("B'")), + (3, hash!("C'")), + (4, hash!("D")) + ], exp: ExpectedResult::Ok { changeset: &[ (2, Some(hash!("B'"))), @@ -229,28 +313,38 @@ fn update_local_chain() { (5, None), ], init_changeset: &[ - (0, Some(hash!("_"))), + (0, Some(hash!("A"))), (2, Some(hash!("B'"))), (3, Some(hash!("C'"))), (4, Some(hash!("D"))), ], }, }, - // Transient invalidation: + // Transitive invalidation: // | 0 | 1 | 2 | 3 | 4 // chain | _ B C E // update | _ B' C' D - // This should succeed and invalidate B, C and E with no point of agreement + // This should succeed and invalidate B, C and E with point of agreement genesis TestLocalChain { - name: "transitive invalidation applies to checkpoints higher than invalidation no point of agreement", - chain: local_chain![(0, hash!("_")), (1, hash!("B")), (2, hash!("C")), (4, hash!("E"))], - update: chain_update![(0, hash!("_")), (1, hash!("B'")), (2, hash!("C'")), (3, hash!("D"))], + name: "transitive invalidation applies to checkpoints higher than invalidation", + chain: local_chain![ + (0, hash!("_")), + (1, hash!("B")), + (2, hash!("C")), + (4, hash!("E")) + ], + update: chain_update![ + (0, hash!("_")), + (1, hash!("B'")), + (2, hash!("C'")), + (3, hash!("D")) + ], exp: ExpectedResult::Ok { changeset: &[ (1, Some(hash!("B'"))), (2, Some(hash!("C'"))), (3, Some(hash!("D"))), - (4, None) + (4, None), ], init_changeset: &[ (0, Some(hash!("_"))), @@ -260,17 +354,30 @@ fn update_local_chain() { ], }, }, - // Transient invalidation: + // Partial invalidation no connection. // | 0 | 1 | 2 | 3 | 4 | 5 // chain | _ A B C E // update | _ B' C' D - // This should fail since although it tells us that B and C are invalid it doesn't tell us whether - // A was invalid. + // This should fail since although it tells us that B and C are invalid it doesn't tell us + // whether A was invalid. TestLocalChain { name: "invalidation but no connection", - chain: local_chain![(0, hash!("_")), (1, hash!("A")), (2, hash!("B")), (3, hash!("C")), (5, hash!("E"))], - update: chain_update![(0, hash!("_")), (2, hash!("B'")), (3, hash!("C'")), (4, hash!("D"))], - exp: ExpectedResult::Err(CannotConnectError { try_include_height: 1 }), + chain: local_chain![ + (0, hash!("_")), + (1, hash!("A")), + (2, hash!("B")), + (3, hash!("C")), + (5, hash!("E")) + ], + update: chain_update![ + (0, hash!("_")), + (2, hash!("B'")), + (3, hash!("C'")), + (4, hash!("D")) + ], + exp: ExpectedResult::Err(CannotConnectError { + try_include_height: 1, + }), }, // Introduce blocks between two points of agreement // | 0 | 1 | 2 | 3 | 4 | 5 @@ -278,13 +385,20 @@ fn update_local_chain() { // update | A C E F TestLocalChain { name: "introduce blocks between two points of agreement", - chain: local_chain![(0, hash!("A")), (1, hash!("B")), (3, hash!("D")), (4, hash!("E"))], - update: chain_update![(0, hash!("A")), (2, hash!("C")), (4, hash!("E")), (5, hash!("F"))], + chain: local_chain![ + (0, hash!("A")), + (1, hash!("B")), + (3, hash!("D")), + (4, hash!("E")) + ], + update: chain_update![ + (0, hash!("A")), + (2, hash!("C")), + (4, hash!("E")), + (5, hash!("F")) + ], exp: ExpectedResult::Ok { - changeset: &[ - (2, Some(hash!("C"))), - (5, Some(hash!("F"))), - ], + changeset: &[(2, Some(hash!("C"))), (5, Some(hash!("F")))], init_changeset: &[ (0, Some(hash!("A"))), (1, Some(hash!("B"))), @@ -301,16 +415,18 @@ fn update_local_chain() { // update | A C D' TestLocalChain { name: "allow update that is shorter than original chain", - chain: local_chain![(0, hash!("_")), (2, hash!("C")), (3, hash!("D")), (4, hash!("E")), (5, hash!("F"))], - update: chain_update![(0, hash!("_")), (2, hash!("C")), (3, hash!("D'"))], + chain: local_chain![ + (0, hash!("A")), + (2, hash!("C")), + (3, hash!("D")), + (4, hash!("E")), + (5, hash!("F")) + ], + update: chain_update![(0, hash!("A")), (2, hash!("C")), (3, hash!("D'"))], exp: ExpectedResult::Ok { - changeset: &[ - (3, Some(hash!("D'"))), - (4, None), - (5, None), - ], + changeset: &[(3, Some(hash!("D'"))), (4, None), (5, None)], init_changeset: &[ - (0, Some(hash!("_"))), + (0, Some(hash!("A"))), (2, Some(hash!("C"))), (3, Some(hash!("D'"))), ], diff --git a/crates/core/src/checkpoint.rs b/crates/core/src/checkpoint.rs index 5f0ef3e20..501f25f36 100644 --- a/crates/core/src/checkpoint.rs +++ b/crates/core/src/checkpoint.rs @@ -64,6 +64,14 @@ impl Drop for CPInner { pub trait ToBlockHash { /// Returns the [`BlockHash`] for the associated [`CheckPoint`] `data` type. fn to_blockhash(&self) -> BlockHash; + + /// Returns the previous [`BlockHash`] of the associated [`CheckPoint::data`] type if known. + /// + /// This has a default implementation that returns `None`. Implementors are expected to override + /// this if the previous block hash is known. + fn prev_blockhash(&self) -> Option { + None + } } impl ToBlockHash for BlockHash { @@ -76,6 +84,10 @@ impl ToBlockHash for Header { fn to_blockhash(&self) -> BlockHash { self.block_hash() } + + fn prev_blockhash(&self) -> Option { + Some(self.prev_blockhash) + } } impl PartialEq for CheckPoint { @@ -189,7 +201,7 @@ impl CheckPoint { // Methods where `D: ToBlockHash` impl CheckPoint where - D: ToBlockHash + fmt::Debug + Copy, + D: ToBlockHash + fmt::Debug + Clone, { /// Construct a new base [`CheckPoint`] from given `height` and `data` at the front of a linked /// list. @@ -204,6 +216,31 @@ where })) } + /// Get the previous [`BlockId`] of this checkpoint. + /// + /// I.e. the height and hash of the block immediately preceding this one by consensus, + /// if it can be inferred from the [`prev_blockhash`](ToBlockHash::prev_blockhash) of the inner + /// block data. + /// + /// Will be `None` if this is the genesis checkpoint, or the + /// [`prev_blockhash`](ToBlockHash::prev_blockhash) returns `None`. + fn prev_block_id(&self) -> Option { + let prev_height = self.height().checked_sub(1)?; + let prev_hash = self.0.data.prev_blockhash()?; + Some(BlockId { + height: prev_height, + hash: prev_hash, + }) + } + + /// Get an iterator over the [`CheckPointEntry`] items of this [`CheckPoint`]. + pub fn entries(&self) -> CheckPointEntryIter { + CheckPointEntryIter { + current_cp: Some(self.clone()), + next_block_id: None, + } + } + /// Construct from an iterator of block data. /// /// Returns `Err(None)` if `blocks` doesn't yield any data. If the blocks are not in ascending @@ -233,59 +270,103 @@ where /// Inserts `data` at its `height` within the chain. /// /// The effect of `insert` depends on whether a height already exists. If it doesn't, the data - /// we inserted and all pre-existing entries higher than it will be re-inserted after it. If the - /// height already existed and has a conflicting block hash then it will be purged along with - /// all entries following it. The returned chain will have a tip of the data passed in. Of - /// course, if the data was already present then this just returns `self`. + /// we inserted and all pre-existing entries higher than it will be re-inserted after it + /// (assuming no conflict occurred). If the inserted data conflicts with either the hash of the + /// checkpoint at `height` or the previous, then the conflicting block will be purged along + /// with all of the indirect conflicts higher than it, and the returned chain will have a + /// tip of the data passed in. If the data was already present then this just + /// returns `self`. /// /// # Panics /// - /// This panics if called with a genesis block that differs from that of `self`. + /// This panics if either `data` or its `prev_blockhash` conflicts with the checkpoint at + /// height 0. #[must_use] pub fn insert(self, height: u32, data: D) -> Self { let mut cp = self.clone(); let mut tail = vec![]; - let base = loop { + let mut base = loop { + // Existing cp at `height` if cp.height() == height { + // Same hash, no change if cp.hash() == data.to_blockhash() { return self; } assert_ne!(cp.height(), 0, "cannot replace genesis block"); - // If we have a conflict we just return the inserted data because the tail is by - // implication invalid. - tail = vec![]; - break cp.prev().expect("can't be called on genesis block"); + // We're replacing a block, so the tail is no longer valid. + tail.clear(); + break cp.prev(); } - + // Found a base if cp.height() < height { - break cp; + break Some(cp); } - + // Remember the block for reinsertion tail.push((cp.height(), cp.data())); - cp = cp.prev().expect("will break before genesis block"); - }; + // Keep moving down the chain + cp = cp.prev().expect("must break before genesis"); + } + .expect("should find a base"); + + // If we detect a conflict we need to revert to the previous checkpoint, + // effectively displacing the old base. This will leave a gap in the chain + // until the correct data is inserted. + if base.is_conflict_of(height, &data) { + base = base.prev().expect("cannot displace genesis block"); + } + // TODO: Try remove .expect here base.extend(core::iter::once((height, data)).chain(tail.into_iter().rev())) - .expect("tail is in order") + .expect("failed to insert block") } /// Puts another checkpoint onto the linked list representing the blockchain. /// - /// Returns an `Err(self)` if the block you are pushing on is not at a greater height that the - /// one you are pushing on to. + /// # Errors + /// + /// - Returns an `Err(self)` if the block you are pushing on is not at a greater height that the + /// one you are pushing on to. + /// - If the previous block hash of `data` doesn't match the hash of the checkpoint at `height - + /// 1`, then returns `Err(self)`. pub fn push(self, height: u32, data: D) -> Result { - if self.height() < height { - Ok(Self(Arc::new(CPInner { - block_id: BlockId { - height, - hash: data.to_blockhash(), - }, - data, - prev: Some(self.0), - }))) - } else { - Err(self) + // `height` must be greater than self height. + if self.height() >= height { + return Err(self); + } + // The pushed data must not conflict with self. + if self.is_conflict_of(height, &data) { + return Err(self); } + Ok(Self(Arc::new(CPInner { + block_id: BlockId { + height, + hash: data.to_blockhash(), + }, + data, + prev: Some(self.0), + }))) + } + + /// Whether `self` conflicts with the given `height` and `data`. + /// + /// This can happen whenever the incoming data tries to build upon the current + /// height but `prev_blockhash` of `data` doesn't match the hash of this [`CheckPoint`]. + fn is_conflict_of(&self, height: u32, data: &D) -> bool { + self.height().saturating_add(1) == height + && data + .prev_blockhash() + .is_some_and(|prev_hash| self.hash() != prev_hash) + } + + /// Whether this [`CheckPoint`] may be considered a valid dependency (parent) of the given + /// `height` and `data`. + #[allow(unused)] + fn is_dependency_of(&self, height: u32, data: &D) -> bool { + let height_diff = height.saturating_sub(self.height()); + // Either a gap exists, in which case there will not be a conflict + height_diff > 1 + // Or `data` directly connects to this CheckPoint. + || (height_diff == 1 && data.prev_blockhash() == Some(self.hash())) } } @@ -315,9 +396,51 @@ impl IntoIterator for CheckPoint { } } +/// [`CheckPointEntry`] +#[derive(Debug, Clone, PartialEq)] +pub enum CheckPointEntry { + /// A node in the [`CheckPoint`] chain. + CheckPoint(CheckPoint), + /// An entry which represents a checkpoint we know to exist but don't yet have data for. + BlockId(BlockId), +} + +/// Iterator of [`CheckPointEntry`]s. +pub struct CheckPointEntryIter { + current_cp: Option>, + next_block_id: Option, +} + +impl Iterator for CheckPointEntryIter +where + D: ToBlockHash + fmt::Debug + Clone, +{ + type Item = CheckPointEntry; + + fn next(&mut self) -> Option { + // There's a next inferred entry, return it. + if let Some(block_id) = self.next_block_id.take() { + return Some(CheckPointEntry::BlockId(block_id)); + } + // Get the current `CheckPoint` in the chain + let cp = self.current_cp.take()?; + // If a gap is detected (height_diff > 1), prepare the next `BlockId`. + if let Some(prev_cp) = cp.prev() { + if cp.height().saturating_sub(prev_cp.height()) > 1 { + self.next_block_id = cp.prev_block_id(); + } + // Store the next `CheckPoint`. + self.current_cp = Some(prev_cp); + } + + Some(CheckPointEntry::CheckPoint(cp)) + } +} + #[cfg(test)] mod tests { use super::*; + use alloc::vec::Vec; /// Make sure that dropping checkpoints does not result in recursion and stack overflow. #[test] @@ -377,4 +500,143 @@ mod tests { "the checkpoint node should be freed when all strong references are dropped", ); } + + #[test] + fn insert_displaces_conflicting_block() { + let header_0: Header = bitcoin::consensus::encode::deserialize_hex("0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff7f2002000000") + .unwrap(); + let header_1: Header = bitcoin::consensus::encode::deserialize_hex("0000002006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f1c96cc459dbb0c7bbc722af14f913da868779290ad48ff87ee314ebb8ae08f384b166069ffff7f2001000000") + .unwrap(); + let header_2: Header = bitcoin::consensus::encode::deserialize_hex("0000002078ce518c7dcfd99ad5859c35bd2be15794c0e5dc8e60c1fea3b0461c45da181ca80f828504f88c645ea46cfcf93156269807e3bd409e1317271a1546d238e9b24c166069ffff7f2001000000") + .unwrap(); + let header_3: Header = bitcoin::consensus::encode::deserialize_hex("000000200dfd6c5af6ea3cb341a08db344f93743d94b630d122867faff855f6310e58864e6e0c859fda703d66d051b86f16f09b0cead182f3cbe13767cd91b6371ec252c4c166069ffff7f2000000000") + .unwrap(); + + // header 2a + let header_2a: Header = bitcoin::consensus::encode::deserialize_hex("000000204817eed64625c3011046dffe4d3bb752da3efc94b3ca56e7192d49163dccba3f8671b68c7a2c5cdeee84696d511344f436819670740a83dd7e897815eb05667e5d166069ffff7f2002000000") + .unwrap(); + // header 3a + let header_3a: Header = bitcoin::consensus::encode::deserialize_hex("000000204817eed64625c3011046dffe4d3bb752da3efc94b3ca56e7192d49163dccba3f8671b68c7a2c5cdeee84696d511344f436819670740a83dd7e897815eb05667e5d166069ffff7f2002000000") + .unwrap(); + + // Test: 2' points to 1', but conflicts with 1 + // tip 0--1--2--3 + // insert p1'-2' + // new 0-----2' (1 is displaced, 3 is gone) + let mut cp = CheckPoint::new(0, header_0); + cp = cp.push(1, header_1).unwrap(); + cp = cp.push(2, header_2).unwrap(); + cp = cp.push(3, header_3).unwrap(); + + cp = cp.insert(2, header_2a); + + assert_eq!(cp.iter().count(), 2); + assert_eq!(cp.height(), 2); + assert_eq!(cp.hash(), header_2a.block_hash()); + + // Test: 3' points to 2', but conflicts with 2 + // tip 0--1--2- + // insert p2'--3' + // new 0--1-----3' (2 is displaced) + let mut cp = CheckPoint::new(0, header_0); + cp = cp.push(1, header_1).unwrap(); + cp = cp.push(2, header_2).unwrap(); + + cp = cp.insert(3, header_3a); + + assert_eq!(cp.iter().count(), 3); + assert_eq!(cp.height(), 3); + assert_eq!(cp.hash(), header_3a.block_hash()); + } + + #[should_panic(expected = "cannot displace genesis block")] + #[test] + fn insert_should_not_displace_genesis() { + let header_0: Header = bitcoin::consensus::encode::deserialize_hex("0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff7f2002000000") + .unwrap(); + let header_1: Header = bitcoin::consensus::encode::deserialize_hex("0000002006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f1c96cc459dbb0c7bbc722af14f913da868779290ad48ff87ee314ebb8ae08f384b166069ffff7f2001000000") + .unwrap(); + // header 2a + let header_2a: Header = bitcoin::consensus::encode::deserialize_hex("000000204817eed64625c3011046dffe4d3bb752da3efc94b3ca56e7192d49163dccba3f8671b68c7a2c5cdeee84696d511344f436819670740a83dd7e897815eb05667e5d166069ffff7f2002000000") + .unwrap(); + + // Test: This should fail + // tip 0--1 + // insert p0'-1' + let mut cp = CheckPoint::new(0, header_0); + cp = cp.push(1, header_1).unwrap(); + let _cp = cp.insert(1, header_2a); + } + + #[test] + fn test_checkpoint_entries() { + // ``` + // #!/bin/bash + // for n in $(seq 0 11); do bitcoin-cli -regtest getblockheader $(bitcoin-cli -regtest getblockhash $n) false + // done + //``` + let headers: Vec
= [ + "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff7f2002000000", + "0000002006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f23b4e61b625f26a50e33aa5a14cef81b92db912079dbdac9bce1dd5e6f02a20bae686069ffff7f2002000000", + "000000201f9b439e2274e1df4a70c3b3b28d6f1412a16aca043e8ddee1a561ba2483f70aeae2c2251894bcaa1b12e46bf72a0e3114a3d3c9d24c13439161b6c3afb236acaf686069ffff7f2000000000", + "000000202c070770b9033e3b76edafa29369774675f4efce93b6879fe732f6b7166e387a2ff70a22d5b7532e9bd509746f742c0004f3bd2c8664ea5a952f5ff851641dbdaf686069ffff7f2001000000", + "00000020e0c5f58ac674b56e87cf0234702b4a233df89d53a56092296ac7c085f9f0740cda05a8377da366739ae73895c9d6d6e5accce1529d421ff611f667c0fc192052b0686069ffff7f2001000000", + "00000020eba5058fdbffdab1c3643a0b0ac740657382ef1ff30a98e361fc2e17d84e745a671d728d09d3e55b88e544c9de3d79210738c0987b7cb26d32dd808ece338146b0686069ffff7f2001000000", + "00000020a949e23d8b64ccf0a1ea870ea37fdeb71451f5b1de0fa7615a16355b678fee492afb7b27b4ea020c786945e9d5e513aa300c03cf29b2abc70a1af6465caf2b65b0686069ffff7f2001000000", + "000000208d2ca4b8fcbffe858c4ad39dc57c60a32ec19eb5edfb74ede0da920b4388a631aab1f8ff0ab584ddfbd6c7da67693fe74a9badc2a6c78ad03ace46b1950700e5b0686069ffff7f2000000000", + "0000002087be4922d644dfcd43cf1f8de7138850b366111503229435b10c3fc4c24bf83ec9a3db521a416a01daa114bbafae98b92a70f0603e81a93e251cb7a48b8e90c2b1686069ffff7f2000000000", + "0000002064677da4c9971de66fc1a7d8284c460c61c8c2d399c2c3f8da52671f466f8070ed356a19b9507bdaba11145a11f4684d07c53dbf800c278511e63762f7b77724b1686069ffff7f2000000000", + "00000020f71012139fc64029711780370e8b2b29ad33fa073a74a6a2d0124013fdcce94f1ca1f097e0ca7991373a951543619c3a2130ca889b2fc498a83ddbb61d9fd3d5b1686069ffff7f2001000000", + "000000202ffc839d282fdc2a6f89f86ab63683e0e3f95d9fef3b0934a643864e4f7fd02087f71bec4c960d8f526022cbb02694ba4820af03f3087a1cd5e7958037dcca79b1686069ffff7f2002000000", + ] + .into_iter() + .map(|s| { + let header: Header = bitcoin::consensus::encode::deserialize_hex(s).unwrap(); + header + }) + .collect(); + + // A checkpoint chain of blocks + // 0--3--5--11 + // Should yield entries with heights + // 11--10--5--4--3--2--0 + // ..assuming the block data `D` has a `prev_blockhash`. + let header_0 = headers[0]; + let header_3 = headers[3]; + let header_5 = headers[5]; + let header_11 = headers[11]; + let mut cp = CheckPoint::new(0, header_0); + cp = cp.push(3, header_3).unwrap(); + cp = cp.push(5, header_5).unwrap(); + cp = cp.push(11, header_11).unwrap(); + + let cp_0 = cp.get(0).unwrap(); + let cp_3 = cp.get(3).unwrap(); + let cp_5 = cp.get(5).unwrap(); + let cp_11 = cp.get(11).unwrap(); + + let entries: Vec> = cp.entries().collect(); + assert_eq!(entries.len(), 7); + assert_eq!( + entries, + vec![ + CheckPointEntry::CheckPoint(cp_11), + CheckPointEntry::BlockId(BlockId { + height: 10, + hash: headers[11].prev_blockhash + }), + CheckPointEntry::CheckPoint(cp_5), + CheckPointEntry::BlockId(BlockId { + height: 4, + hash: headers[5].prev_blockhash + }), + CheckPointEntry::CheckPoint(cp_3), + CheckPointEntry::BlockId(BlockId { + height: 2, + hash: headers[3].prev_blockhash + }), + CheckPointEntry::CheckPoint(cp_0), + ] + ); + } } diff --git a/crates/testenv/src/utils.rs b/crates/testenv/src/utils.rs index 93ca1f217..88f979ee4 100644 --- a/crates/testenv/src/utils.rs +++ b/crates/testenv/src/utils.rs @@ -32,38 +32,9 @@ macro_rules! local_chain { #[allow(unused_macros)] #[macro_export] macro_rules! chain_update { - [ $(($height:expr, $hash:expr)), * ] => {{ - #[allow(unused_mut)] - bdk_chain::local_chain::LocalChain::from_blocks([$(($height, $hash).into()),*].into_iter().collect()) - .expect("chain must have genesis block") - .tip() - }}; -} - -#[allow(unused_macros)] -#[macro_export] -macro_rules! changeset { - (checkpoints: $($tail:tt)*) => { changeset!(index: TxHeight, checkpoints: $($tail)*) }; - ( - index: $ind:ty, - checkpoints: [ $(( $height:expr, $cp_to:expr )),* ] - $(,txids: [ $(( $txid:expr, $tx_to:expr )),* ])? - ) => {{ - use bdk_chain::collections::BTreeMap; - - #[allow(unused_mut)] - bdk_chain::sparse_chain::ChangeSet::<$ind> { - checkpoints: { - let mut changes = BTreeMap::default(); - $(changes.insert($height, $cp_to);)* - changes - }, - txids: { - let mut changes = BTreeMap::default(); - $($(changes.insert($txid, $tx_to.map(|h: TxHeight| h.into()));)*)? - changes - } - } + ( $(($height:expr, $data:expr)), * ) => {{ + bdk_chain::local_chain::CheckPoint::from_blocks([$(($height, $data)),*]) + .expect("blocks should be in ascending height order") }}; }