From: 志宇 Date: Thu, 15 Oct 2026 09:46:13 +0000 (+0000) Subject: fix(chain)!: reject changesets that alter the genesis block X-Git-Url: http://internal-gitweb-vhost/-script/src/example_cli/database/struct.InsertBlockNotMatchingError.html?a=commitdiff_plain;h=4629f55138da1ee946bd49f7332ccbf0cd5af646;p=bdk fix(chain)!: reject changesets that alter the genesis block `LocalChain::apply_changeset` could replace the genesis block when the changeset contained a different block at height 0. Check genesis only when there is no point of agreement below the changeset (the only path that touches height 0) and extend from the existing genesis checkpoint. `apply_changeset` now returns the new `ApplyChangeSetError`, since `ApplyBlockError::MissingGenesis` does not apply to a chain that already has a genesis. Replacing or removing genesis is reported as `ApplyChangeSetError::CannotAlterGenesis`. `from_changeset` now builds directly via `from_blocks`. Co-Authored-By: Claude Opus 5.5 --- diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index ee1d67fe..8b1fef0b 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -14,7 +14,7 @@ use bitcoin::BlockHash; fn apply_changeset_to_checkpoint( mut init_cp: CheckPoint, changeset: &ChangeSet, -) -> Result, ApplyBlockError> +) -> Result, ApplyChangeSetError> where D: ToBlockHash + fmt::Debug + Clone, { @@ -44,17 +44,29 @@ where }; } - let new_tip = match base { - Some(base) => base - .extend(extension) - // Since `extension` is in height order, the only failure case is `prev_blockhash` - // mismatch. - .map_err(|last_cp| ApplyBlockError::PrevBlockhashMismatch { - expected: last_cp.block_id(), - })?, - None => LocalChain::from_blocks(extension)?.tip(), + let base = match base { + Some(base) => base, + // No point of agreement means `changeset` touches genesis, which cannot be altered. + None => { + let genesis = init_cp.get(0).expect("`LocalChain` always has genesis"); + let update_hash = extension.remove(&0).as_ref().map(ToBlockHash::to_blockhash); + if update_hash != Some(genesis.hash()) { + return Err(ApplyChangeSetError::CannotAlterGenesis { + original_hash: genesis.hash(), + update_hash, + }); + } + genesis + } }; - init_cp = new_tip; + + init_cp = base + .extend(extension) + // Since `extension` is in height order, the only failure case is `prev_blockhash` + // mismatch. + .map_err(|last_cp| ApplyChangeSetError::PrevBlockhashMismatch { + expected: last_cp.block_id(), + })?; } Ok(init_cp) @@ -327,15 +339,19 @@ where } /// Construct a [`LocalChain`] from an initial `changeset`. + /// + /// # Errors + /// + /// Fails with [`ApplyBlockError::MissingGenesis`] if `changeset` has no genesis block, or + /// [`ApplyBlockError::PrevBlockhashMismatch`] if its blocks do not link by `prev_blockhash`. pub fn from_changeset(changeset: ChangeSet) -> Result { - let genesis_entry = changeset.blocks.get(&0).cloned().flatten(); - let genesis_data = match genesis_entry { - Some(data) => data, - None => return Err(ApplyBlockError::MissingGenesis), - }; - - let (mut chain, _) = Self::from_genesis(genesis_data); - chain.apply_changeset(&changeset)?; + let chain = Self::from_blocks( + changeset + .blocks + .iter() + .filter_map(|(&height, data)| Some((height, data.clone()?))) + .collect(), + )?; debug_assert!(chain._check_changeset_is_applied(&changeset)); Ok(chain) } @@ -375,7 +391,12 @@ where } /// Apply the given `changeset`. - pub fn apply_changeset(&mut self, changeset: &ChangeSet) -> Result<(), ApplyBlockError> { + /// + /// # Errors + /// + /// Fails if `changeset` replaces or removes the genesis block, or if the resulting blocks do + /// not link by `prev_blockhash`. `self` is unchanged on error. + pub fn apply_changeset(&mut self, changeset: &ChangeSet) -> Result<(), ApplyChangeSetError> { let old_tip = self.tip.clone(); let new_tip = apply_changeset_to_checkpoint(old_tip, changeset)?; self.tip = new_tip; @@ -579,6 +600,49 @@ impl core::fmt::Display for ApplyBlockError { impl core::error::Error for ApplyBlockError {} +/// Error when applying a [`ChangeSet`] to a [`LocalChain`]. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub enum ApplyChangeSetError { + /// The changeset attempts to replace or remove the genesis block. + CannotAlterGenesis { + /// The genesis block hash which cannot be replaced or removed. + original_hash: BlockHash, + /// The attempted replacement, or `None` if the changeset removes genesis. + update_hash: Option, + }, + /// Block's `prev_blockhash` doesn't match the expected block. + PrevBlockhashMismatch { + /// The block that `prev_blockhash` should reference. + expected: BlockId, + }, +} + +impl core::fmt::Display for ApplyChangeSetError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ApplyChangeSetError::CannotAlterGenesis { + original_hash, + update_hash: Some(update_hash), + } => write!( + f, + "cannot replace genesis block: original={original_hash} update={update_hash}" + ), + ApplyChangeSetError::CannotAlterGenesis { + original_hash, + update_hash: None, + } => write!(f, "cannot remove genesis block: original={original_hash}"), + ApplyChangeSetError::PrevBlockhashMismatch { expected } => write!( + f, + "`prev_blockhash` doesn't match block at height {} ({})", + expected.height, expected.hash + ), + } + } +} + +impl core::error::Error for ApplyChangeSetError {} + /// An error which occurs when a [`LocalChain`] is constructed without a genesis checkpoint. #[derive(Clone, Debug, PartialEq)] pub struct MissingGenesisError; @@ -696,15 +760,19 @@ where { let new_tip = apply_changeset_to_checkpoint(original_tip, &changeset).map_err(|err| { match err { - ApplyBlockError::MissingGenesis => CannotConnectError { - try_include_height: 0, - }, + // `merge_chains` rejects a genesis mismatch before reaching here. + err @ ApplyChangeSetError::CannotAlterGenesis { .. } => { + debug_assert!(false, "merge_chains should have rejected {err:?}"); + CannotConnectError { + try_include_height: 0, + } + } // The merge iteration is supposed to detect `prev_blockhash` conflicts and resolve // them by invalidating conflicting blocks in the changeset. Reaching this arm means // either the original chain was internally inconsistent or the iteration missed a // case — a bug on our side. Debug builds panic; release builds surface the height // where the mismatch surfaced so the caller at least has a useful pointer. - ApplyBlockError::PrevBlockhashMismatch { expected } => { + ApplyChangeSetError::PrevBlockhashMismatch { expected } => { debug_assert!( false, "merge_chains should have resolved prev_blockhash mismatch at {expected:?}", diff --git a/crates/chain/tests/test_local_chain.rs b/crates/chain/tests/test_local_chain.rs index bc5151d9..2e9757c7 100644 --- a/crates/chain/tests/test_local_chain.rs +++ b/crates/chain/tests/test_local_chain.rs @@ -5,8 +5,8 @@ use std::ops::{Bound, RangeBounds}; use bdk_chain::{ local_chain::{ - AlterCheckPointError, ApplyHeaderError, CannotConnectError, ChangeSet, CheckPoint, - LocalChain, MissingGenesisError, + AlterCheckPointError, ApplyBlockError, ApplyChangeSetError, ApplyHeaderError, + CannotConnectError, ChangeSet, CheckPoint, LocalChain, MissingGenesisError, }, BlockId, }; @@ -395,19 +395,19 @@ fn local_chain_insert_block() { } } -#[test] -fn local_chain_insert_header() { - fn header(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, - } +fn header(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, } +} +#[test] +fn local_chain_insert_header() { // Create consecutive headers of height `n`, where the genesis header is height 0. fn build_headers(n: u32) -> Vec
{ let mut headers = Vec::new(); @@ -566,6 +566,63 @@ fn local_chain_disconnect_from() { } } +#[test] +fn local_chain_apply_changeset_cannot_alter_genesis() { + let chain: LocalChain = local_chain![(0, hash!("G")), (1, hash!("A"))]; + for (update_hash, changeset) in [ + ( + Some(hash!("not_G")), + ChangeSet::from([(0, Some(hash!("not_G")))]), + ), + (None, ChangeSet::from([(0, None)])), + ] { + let mut chain = chain.clone(); + assert_eq!( + chain.apply_changeset(&changeset), + Err(ApplyChangeSetError::CannotAlterGenesis { + original_hash: hash!("G"), + update_hash, + }), + ); + assert_eq!(chain, local_chain![(0, hash!("G")), (1, hash!("A"))]); + } + + // A matching genesis entry is fine. + let mut chain = chain; + let changeset = ChangeSet::from([(0, Some(hash!("G"))), (2, Some(hash!("B")))]); + assert_eq!(chain.apply_changeset(&changeset), Ok(())); + assert_eq!( + chain, + local_chain![(0, hash!("G")), (1, hash!("A")), (2, hash!("B"))] + ); +} + +#[test] +fn local_chain_apply_changeset_with_genesis_checks_prev_blockhash() { + let genesis = header(hash!("_")); + let (mut chain, _) = LocalChain::from_genesis(genesis); + let changeset = ChangeSet::from([(0, Some(genesis)), (1, Some(header(hash!("not_G"))))]); + assert_eq!( + chain.apply_changeset(&changeset), + Err(ApplyChangeSetError::PrevBlockhashMismatch { + expected: BlockId { + height: 0, + hash: genesis.block_hash(), + }, + }), + ); + assert_eq!(chain, LocalChain::from_genesis(genesis).0); +} + +#[test] +fn local_chain_from_changeset_without_genesis() { + let changeset: ChangeSet = [(0, None), (1, Some(hash!("A")))].into_iter().collect(); + assert_eq!( + LocalChain::from_changeset(changeset), + Err(ApplyBlockError::MissingGenesis), + ); +} + // Test that `apply_update` can connect 1 `Header` at a time // and fails if a `prev_blockhash` conflict is detected. #[test]