diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index f6d8af9f..a4c22615 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -5,6 +5,7 @@ use core::convert::Infallible; use crate::collections::BTreeMap; use crate::{BlockId, ChainOracle}; use alloc::sync::Arc; +use bitcoin::block::Header; use bitcoin::BlockHash; /// The [`ChangeSet`] represents changes to [`LocalChain`]. @@ -369,6 +370,91 @@ impl LocalChain { Ok(changeset) } + /// Update the chain with a given [`Header`] and a `connected_to` [`BlockId`]. + /// + /// The `header` will be transformed into checkpoints - one for the current block and one for + /// the previous block. Note that a genesis header will be transformed into only one checkpoint + /// (as there are no previous blocks). The checkpoints will be applied to the chain via + /// [`apply_update`]. + /// + /// # Errors + /// + /// [`ApplyHeaderError::InconsistentBlocks`] occurs if the `connected_to` block and the + /// [`Header`] is inconsistent. For example, if the `connected_to` block is the same height as + /// `header` or `prev_blockhash`, but has a different block hash. Or if the `connected_to` + /// height is greater than the header's `height`. + /// + /// [`ApplyHeaderError::CannotConnect`] occurs if the internal call to [`apply_update`] fails. + /// + /// [`apply_update`]: LocalChain::apply_update + pub fn apply_header_connected_to( + &mut self, + header: &Header, + height: u32, + connected_to: BlockId, + ) -> Result { + let this = BlockId { + height, + hash: header.block_hash(), + }; + let prev = height.checked_sub(1).map(|prev_height| BlockId { + height: prev_height, + hash: header.prev_blockhash, + }); + let conn = match connected_to { + // `connected_to` can be ignored if same as `this` or `prev` (duplicate) + conn if conn == this || Some(conn) == prev => None, + // this occurs if: + // - `connected_to` height is the same as `prev`, but different hash + // - `connected_to` height is the same as `this`, but different hash + // - `connected_to` height is greater than `this` (this is not allowed) + conn if conn.height >= height.saturating_sub(1) => { + return Err(ApplyHeaderError::InconsistentBlocks) + } + conn => Some(conn), + }; + + let update = Update { + tip: CheckPoint::from_block_ids([conn, prev, Some(this)].into_iter().flatten()) + .expect("block ids must be in order"), + introduce_older_blocks: false, + }; + + self.apply_update(update) + .map_err(ApplyHeaderError::CannotConnect) + } + + /// Update the chain with a given [`Header`] connecting it with the previous block. + /// + /// This is a convenience method to call [`apply_header_connected_to`] with the `connected_to` + /// parameter being `height-1:prev_blockhash`. If there is no previous block (i.e. genesis), we + /// use the current block as `connected_to`. + /// + /// [`apply_header_connected_to`]: LocalChain::apply_header_connected_to + pub fn apply_header( + &mut self, + header: &Header, + height: u32, + ) -> Result { + let connected_to = match height.checked_sub(1) { + Some(prev_height) => BlockId { + height: prev_height, + hash: header.prev_blockhash, + }, + None => BlockId { + height, + hash: header.block_hash(), + }, + }; + self.apply_header_connected_to(header, height, connected_to) + .map_err(|err| match err { + ApplyHeaderError::InconsistentBlocks => { + unreachable!("connected_to is derived from the block so is always consistent") + } + ApplyHeaderError::CannotConnect(err) => err, + }) + } + /// Apply the given `changeset`. pub fn apply_changeset(&mut self, changeset: &ChangeSet) -> Result<(), MissingGenesisError> { if let Some(start_height) = changeset.keys().next().cloned() { @@ -579,6 +665,30 @@ impl core::fmt::Display for CannotConnectError { #[cfg(feature = "std")] impl std::error::Error for CannotConnectError {} +/// The error type for [`LocalChain::apply_header_connected_to`]. +#[derive(Debug, Clone, PartialEq)] +pub enum ApplyHeaderError { + /// Occurs when `connected_to` block conflicts with either the current block or previous block. + InconsistentBlocks, + /// Occurs when the update cannot connect with the original chain. + CannotConnect(CannotConnectError), +} + +impl core::fmt::Display for ApplyHeaderError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + ApplyHeaderError::InconsistentBlocks => write!( + f, + "the `connected_to` block conflicts with either the current or previous block" + ), + ApplyHeaderError::CannotConnect(err) => core::fmt::Display::fmt(err, f), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for ApplyHeaderError {} + fn merge_chains( original_tip: CheckPoint, update_tip: CheckPoint, diff --git a/crates/chain/tests/test_local_chain.rs b/crates/chain/tests/test_local_chain.rs index 7e6f73bf..c190ae52 100644 --- a/crates/chain/tests/test_local_chain.rs +++ b/crates/chain/tests/test_local_chain.rs @@ -1,11 +1,11 @@ use bdk_chain::{ local_chain::{ - AlterCheckPointError, CannotConnectError, ChangeSet, CheckPoint, LocalChain, - MissingGenesisError, Update, + AlterCheckPointError, ApplyHeaderError, CannotConnectError, ChangeSet, CheckPoint, + LocalChain, MissingGenesisError, Update, }, BlockId, }; -use bitcoin::BlockHash; +use bitcoin::{block::Header, hashes::Hash, BlockHash}; #[macro_use] mod common; @@ -506,3 +506,155 @@ fn checkpoint_from_block_ids() { } } } + +#[test] +fn local_chain_apply_header_connected_to() { + fn header_from_prev_blockhash(prev_blockhash: BlockHash) -> Header { + Header { + version: bitcoin::block::Version::default(), + prev_blockhash, + merkle_root: bitcoin::hash_types::TxMerkleNode::all_zeros(), + time: 0, + bits: bitcoin::CompactTarget::default(), + nonce: 0, + } + } + + struct TestCase { + name: &'static str, + chain: LocalChain, + header: Header, + height: u32, + connected_to: BlockId, + exp_result: Result)>, ApplyHeaderError>, + } + + let test_cases = [ + { + let header = header_from_prev_blockhash(h!("A")); + let hash = header.block_hash(); + let height = 2; + let connected_to = BlockId { height, hash }; + TestCase { + name: "connected_to_self_header_applied_to_self", + chain: local_chain![(0, h!("_")), (height, hash)], + header, + height, + connected_to, + exp_result: Ok(vec![]), + } + }, + { + let prev_hash = h!("A"); + let prev_height = 1; + let header = header_from_prev_blockhash(prev_hash); + let hash = header.block_hash(); + let height = prev_height + 1; + let connected_to = BlockId { + height: prev_height, + hash: prev_hash, + }; + TestCase { + name: "connected_to_prev_header_applied_to_self", + chain: local_chain![(0, h!("_")), (prev_height, prev_hash)], + header, + height, + connected_to, + exp_result: Ok(vec![(height, Some(hash))]), + } + }, + { + let header = header_from_prev_blockhash(BlockHash::all_zeros()); + let hash = header.block_hash(); + let height = 0; + let connected_to = BlockId { height, hash }; + TestCase { + name: "genesis_applied_to_self", + chain: local_chain![(0, hash)], + header, + height, + connected_to, + exp_result: Ok(vec![]), + } + }, + { + let header = header_from_prev_blockhash(h!("Z")); + let height = 10; + let hash = header.block_hash(); + let prev_height = height - 1; + let prev_hash = header.prev_blockhash; + TestCase { + name: "connect_at_connected_to", + chain: local_chain![(0, h!("_")), (2, h!("B")), (3, h!("C"))], + header, + height: 10, + connected_to: BlockId { + height: 3, + hash: h!("C"), + }, + exp_result: Ok(vec![(prev_height, Some(prev_hash)), (height, Some(hash))]), + } + }, + { + let prev_hash = h!("A"); + let prev_height = 1; + let header = header_from_prev_blockhash(prev_hash); + let connected_to = BlockId { + height: prev_height, + hash: h!("not_prev_hash"), + }; + TestCase { + name: "inconsistent_prev_hash", + chain: local_chain![(0, h!("_")), (prev_height, h!("not_prev_hash"))], + header, + height: prev_height + 1, + connected_to, + exp_result: Err(ApplyHeaderError::InconsistentBlocks), + } + }, + { + let prev_hash = h!("A"); + let prev_height = 1; + let header = header_from_prev_blockhash(prev_hash); + let height = prev_height + 1; + let connected_to = BlockId { + height, + hash: h!("not_current_hash"), + }; + TestCase { + name: "inconsistent_current_block", + chain: local_chain![(0, h!("_")), (height, h!("not_current_hash"))], + header, + height, + connected_to, + exp_result: Err(ApplyHeaderError::InconsistentBlocks), + } + }, + { + let header = header_from_prev_blockhash(h!("B")); + let height = 3; + let connected_to = BlockId { + height: 4, + hash: h!("D"), + }; + TestCase { + name: "connected_to_is_greater", + chain: local_chain![(0, h!("_")), (2, h!("B"))], + header, + height, + connected_to, + exp_result: Err(ApplyHeaderError::InconsistentBlocks), + } + }, + ]; + + for (i, t) in test_cases.into_iter().enumerate() { + println!("running test case {}: '{}'", i, t.name); + let mut chain = t.chain; + let result = chain.apply_header_connected_to(&t.header, t.height, t.connected_to); + let exp_result = t + .exp_result + .map(|cs| cs.iter().cloned().collect::()); + assert_eq!(result, exp_result, "[{}:{}] unexpected result", i, t.name); + } +}