From b0fadeba1a427ae65ce0632f96395a6e4d3599b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 9 Jan 2026 17:09:03 +0800 Subject: [PATCH 01/11] feat(core): Add `prev_blockhash` method to `ToBlockHash` trait --- crates/core/src/checkpoint.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/core/src/checkpoint.rs b/crates/core/src/checkpoint.rs index 0a394122a..edb420dc0 100644 --- a/crates/core/src/checkpoint.rs +++ b/crates/core/src/checkpoint.rs @@ -65,6 +65,11 @@ impl Drop for CPInner { pub trait ToBlockHash { /// Returns the [`BlockHash`] for the associated [`CheckPoint`] `data` type. fn to_blockhash(&self) -> BlockHash; + + /// Returns `None` if the type has no knowledge of the previous [`BlockHash`]. + fn prev_blockhash(&self) -> Option { + None + } } impl ToBlockHash for BlockHash { @@ -77,6 +82,10 @@ impl ToBlockHash for Header { fn to_blockhash(&self) -> BlockHash { self.block_hash() } + + fn prev_blockhash(&self) -> Option { + Some(self.prev_blockhash) + } } /// Trait that extracts a block time from [`CheckPoint`] `data`. From 04b83f3b6de6c4c49515b7153021a727aaf26841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 9 Jan 2026 17:26:07 +0800 Subject: [PATCH 02/11] feat(core): Initial work on `CheckPointEntry` --- crates/core/src/checkpoint.rs | 32 ++++- crates/core/src/checkpoint_entry.rs | 207 ++++++++++++++++++++++++++++ crates/core/src/lib.rs | 3 + 3 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 crates/core/src/checkpoint_entry.rs diff --git a/crates/core/src/checkpoint.rs b/crates/core/src/checkpoint.rs index edb420dc0..bff833621 100644 --- a/crates/core/src/checkpoint.rs +++ b/crates/core/src/checkpoint.rs @@ -5,7 +5,7 @@ use alloc::sync::Arc; use alloc::vec::Vec; use bitcoin::{block::Header, BlockHash}; -use crate::BlockId; +use crate::{BlockId, CheckPointEntry, CheckPointEntryIter}; /// A checkpoint is a node of a reference-counted linked list of [`BlockId`]s. /// @@ -210,6 +210,26 @@ impl CheckPoint { } } +impl CheckPoint +where + D: ToBlockHash, +{ + /// Iterate entries from this checkpoint in descending height. + pub fn entry_iter(&self) -> CheckPointEntryIter { + self.to_entry().into_iter() + } + + /// Transforms this checkpoint into a [`CheckPointEntry`]. + pub fn into_entry(self) -> CheckPointEntry { + CheckPointEntry::Occupied(self) + } + + /// Creates a [`CheckPointEntry`]. + pub fn to_entry(&self) -> CheckPointEntry { + CheckPointEntry::Occupied(self.clone()) + } +} + // Methods where `D: ToBlockHash` impl CheckPoint where @@ -349,15 +369,15 @@ where /// Iterates over checkpoints backwards. pub struct CheckPointIter { - current: Option>>, + next: Option>>, } impl Iterator for CheckPointIter { type Item = CheckPoint; fn next(&mut self) -> Option { - let current = self.current.clone()?; - self.current.clone_from(¤t.prev); + let current = self.next.clone()?; + self.next.clone_from(¤t.prev); Some(CheckPoint(current)) } } @@ -367,9 +387,7 @@ impl IntoIterator for CheckPoint { type IntoIter = CheckPointIter; fn into_iter(self) -> Self::IntoIter { - CheckPointIter { - current: Some(self.0), - } + CheckPointIter { next: Some(self.0) } } } diff --git a/crates/core/src/checkpoint_entry.rs b/crates/core/src/checkpoint_entry.rs new file mode 100644 index 000000000..8e866b97a --- /dev/null +++ b/crates/core/src/checkpoint_entry.rs @@ -0,0 +1,207 @@ +use core::ops::RangeBounds; + +use bitcoin::BlockHash; + +use crate::{BlockId, CheckPoint, ToBlockHash}; + +/// An entry yielded by [`CheckPointIter`]. +#[derive(Debug, Clone)] +pub enum CheckPointEntry { + /// A placeholder entry: there is no checkpoint stored at this height, + /// but the checkpoint one height above links back here via its `prev_blockhash`. + Placeholder { + /// The block ID at *this* height. + block_id: BlockId, + /// The checkpoint one height *above* that links back to `block_id`. + checkpoint_above: CheckPoint, + }, + /// A real checkpoint recorded at this height. + Occupied(CheckPoint), +} + +impl CheckPointEntry { + /// The checkpoint at this height (if any). + pub fn checkpoint(&self) -> Option> { + match self { + CheckPointEntry::Placeholder { .. } => None, + CheckPointEntry::Occupied(checkpoint) => Some(checkpoint.clone()), + } + } + + /// Returns `true` if this entry is a placeholder (inferred from `prev_blockhash`). + pub fn is_placeholder(&self) -> bool { + matches!(self, CheckPointEntry::Placeholder { .. }) + } + + /// The checkpoint that is the *source* of this entry. + /// + /// For an `Occupied` entry, this is the checkpoint itself. + /// For a `Placeholder` entry, this is the checkpoint above that references this height. + pub fn source_checkpoint(&self) -> CheckPoint { + match self { + CheckPointEntry::Placeholder { + checkpoint_above, .. + } => checkpoint_above.clone(), + CheckPointEntry::Occupied(checkpoint) => checkpoint.clone(), + } + } + + /// Returns the checkpoint at or below this entry's height. + pub fn floor_checkpoint(&self) -> Option> { + match self { + CheckPointEntry::Placeholder { + checkpoint_above: linking_checkpoint, + .. + } => linking_checkpoint.prev(), + CheckPointEntry::Occupied(checkpoint) => Some(checkpoint.clone()), + } + } + + /// Returns a reference to the data recorded at this exact height (if any). + pub fn data_ref(&self) -> Option<&D> { + match self { + CheckPointEntry::Placeholder { .. } => None, + CheckPointEntry::Occupied(checkpoint) => Some(checkpoint.data_ref()), + } + } + + /// Returns the data recorded at this exact height (if any). + pub fn data(&self) -> Option + where + D: Clone, + { + self.data_ref().cloned() + } +} + +impl CheckPointEntry { + /// The block ID of this entry. + pub fn block_id(&self) -> BlockId { + match self { + CheckPointEntry::Placeholder { block_id, .. } => *block_id, + CheckPointEntry::Occupied(checkpoint) => checkpoint.block_id(), + } + } + + /// The blockhash of this entry. + pub fn hash(&self) -> BlockHash { + self.block_id().hash + } + + /// The block height of this entry. + pub fn height(&self) -> u32 { + self.block_id().height + } + + /// Get the previous entry in the chain. + pub fn prev(&self) -> Option { + let checkpoint = match self { + Self::Placeholder { + checkpoint_above, .. + } => { + return checkpoint_above.prev().map(Self::Occupied); + } + Self::Occupied(checkpoint) => checkpoint, + }; + + let prev_height = checkpoint.height().checked_sub(1)?; + + let prev_blockhash = match checkpoint.data_ref().prev_blockhash() { + Some(blockhash) => blockhash, + None => return checkpoint.prev().map(Self::Occupied), + }; + + if let Some(prev_checkpoint) = checkpoint.prev() { + if prev_checkpoint.height() == prev_height { + return Some(Self::Occupied(prev_checkpoint)); + } + } + + Some(Self::Placeholder { + block_id: BlockId { + height: prev_height, + hash: prev_blockhash, + }, + checkpoint_above: checkpoint.clone(), + }) + } + + /// Iterate over checkpoint entries backwards. + pub fn iter(&self) -> CheckPointEntryIter + where + D: Clone, + { + self.clone().into_iter() + } + + /// Get checkpoint entry at `height`. + /// + /// Returns `None` if checkpoint at `height` does not exist. + pub fn get(&self, height: u32) -> Option + where + D: Clone, + { + self.range(height..=height).next() + } + + /// Iterate checkpoints over a height range. + pub fn range(&self, range: R) -> impl Iterator> + where + D: Clone, + R: RangeBounds, + { + let start_bound = range.start_bound().cloned(); + let end_bound = range.end_bound().cloned(); + self.iter() + .skip_while(move |cp_entry| match end_bound { + core::ops::Bound::Included(inc_bound) => cp_entry.height() > inc_bound, + core::ops::Bound::Excluded(exc_bound) => cp_entry.height() >= exc_bound, + core::ops::Bound::Unbounded => false, + }) + .take_while(move |cp_entry| match start_bound { + core::ops::Bound::Included(inc_bound) => cp_entry.height() >= inc_bound, + core::ops::Bound::Excluded(exc_bound) => cp_entry.height() > exc_bound, + core::ops::Bound::Unbounded => true, + }) + } + + /// Returns the entry at `height` if one exists, otherwise the nearest checkpoint at a lower + /// height. + pub fn floor_at(&self, height: u32) -> Option + where + D: Clone, + { + self.range(..=height).next() + } + + /// Returns the entry located a number of heights below this one. + pub fn floor_below(&self, offset: u32) -> Option + where D: Clone + { + self.floor_at(self.height().checked_sub(offset)?) + } +} + +/// Iterates over checkpoint entries backwards. +pub struct CheckPointEntryIter { + next: Option>, +} + +impl Iterator for CheckPointEntryIter { + type Item = CheckPointEntry; + + fn next(&mut self) -> Option { + let item = self.next.take()?; + self.next = item.prev(); + Some(item) + } +} + +impl IntoIterator for CheckPointEntry { + type Item = CheckPointEntry; + type IntoIter = CheckPointEntryIter; + + fn into_iter(self) -> Self::IntoIter { + CheckPointEntryIter { next: Some(self) } + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 95bebe907..cf02a99b0 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -65,6 +65,9 @@ pub use block_id::*; mod checkpoint; pub use checkpoint::*; +mod checkpoint_entry; +pub use checkpoint_entry::*; + mod tx_update; pub use tx_update::*; From 820b76063a51c2b30b2b48ca2338267209f1b045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 13 Jan 2026 14:02:00 +0800 Subject: [PATCH 03/11] fix(core): `push` now errors on `prev_blockhash` mismatch --- crates/core/src/checkpoint.rs | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/crates/core/src/checkpoint.rs b/crates/core/src/checkpoint.rs index bff833621..168922177 100644 --- a/crates/core/src/checkpoint.rs +++ b/crates/core/src/checkpoint.rs @@ -349,21 +349,30 @@ where /// 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. + /// 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. + /// * The `prev_blockhash` does not match. 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) + // Reject if trying to push at or below current height - chain must grow forward. + if height <= self.height() { + return Err(self); } + + // For contiguous height, ensure prev_blockhash does not conflict. + if let Some(prev_blockhash) = data.prev_blockhash() { + if self.height() + 1 == height && self.hash() != prev_blockhash { + return Err(self); + } + } + + Ok(Self(Arc::new(CPInner { + block_id: BlockId { + height, + hash: data.to_blockhash(), + }, + data, + prev: Some(self.0), + }))) } } From c2e9ade73de3e4ec36d50c255055f4815784d933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 13 Jan 2026 21:33:48 +0800 Subject: [PATCH 04/11] fix(core): `Checkpoint::insert` now evicts on `prev_blockhash` mismatch Additionally, `insert` now panics if the genesis block gets displaced (if it existed in the first place). Co-authored-by: valued mammal --- crates/core/src/checkpoint.rs | 85 ++++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 25 deletions(-) diff --git a/crates/core/src/checkpoint.rs b/crates/core/src/checkpoint.rs index 168922177..06456d3d8 100644 --- a/crates/core/src/checkpoint.rs +++ b/crates/core/src/checkpoint.rs @@ -1,9 +1,8 @@ -use core::fmt; -use core::ops::RangeBounds; - use alloc::sync::Arc; use alloc::vec::Vec; use bitcoin::{block::Header, BlockHash}; +use core::fmt; +use core::ops::RangeBounds; use crate::{BlockId, CheckPointEntry, CheckPointEntryIter}; @@ -233,7 +232,7 @@ where // Methods where `D: ToBlockHash` impl CheckPoint where - D: ToBlockHash + fmt::Debug + Copy, + D: ToBlockHash + fmt::Debug + Clone, { const MTP_BLOCK_COUNT: u32 = 11; @@ -298,8 +297,9 @@ where /// Extends the checkpoint linked list by a iterator containing `height` and `data`. /// - /// Returns an `Err(self)` if there is block which does not have a greater height than the - /// previous one. + /// Returns an `Err(self)` if there is a block which does not have a greater height than the + /// previous one, or doesn't properly link to an adjacent block via its `prev_blockhash`. + /// See docs for [`CheckPoint::push`]. pub fn extend(self, blockdata: impl IntoIterator) -> Result { let mut cp = self.clone(); for (height, data) in blockdata { @@ -310,41 +310,76 @@ 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`. + /// If a checkpoint already exists at `height` with a matching hash, returns `self` unchanged. + /// If a checkpoint exists at `height` with a different hash, or if `prev_blockhash` conflicts + /// occur, the conflicting checkpoint and all checkpoints above it are removed. /// /// # Panics /// - /// This panics if called with a genesis block that differs from that of `self`. + /// Panics if the insertion would replace (or omit) the checkpoint at height 0 (a.k.a + /// "genesis"). Although [`CheckPoint`] isn't structurally required to contain a genesis + /// block, if one is present, it stays immutable and can't be replaced. #[must_use] pub fn insert(self, height: u32, data: D) -> Self { let mut cp = self.clone(); let mut tail = vec![]; + + // Traverse from tip to base, looking for where to insert. let base = loop { - if cp.height() == height { + // Genesis (height 0) must remain immutable. + if cp.height() == 0 { + let implied_genesis = match height { + 0 => Some(data.to_blockhash()), + 1 => data.prev_blockhash(), + _ => None, + }; + if let Some(hash) = implied_genesis { + assert_eq!(hash, cp.hash(), "inserted data implies different genesis"); + } + } + + // Above insertion: collect for potential re-insertion later. + // No need to check data.prev_blockhash here since that points below insertion. The + // reverse relationship (cp.prev_blockhash vs data.hash) is validated during rebuild. + if cp.height() > height { + tail.push((cp.height(), cp.data())); + + // At insertion: determine whether we need to clear tail, or early return. + } else if cp.height() == height { 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"); + tail.clear(); + + // Displacement: data's prev_blockhash conflicts with this checkpoint, + // so skip it and invalidate everything above. + } else if cp.height() + 1 == height + && data.prev_blockhash().is_some_and(|h| h != cp.hash()) + { + tail.clear(); + + // Below insertion: this is our base (since data's prev_blockhash does not conflict). + } else if cp.height() < height { + break Some(cp); } - if cp.height() < height { - break cp; + // Continue traversing down (if possible). + match cp.prev() { + Some(prev) => cp = prev, + None => break None, } - - tail.push((cp.height(), cp.data())); - cp = cp.prev().expect("will break before genesis block"); }; - base.extend(core::iter::once((height, data)).chain(tail.into_iter().rev())) - .expect("tail is in order") + tail.push((height, data)); + let tail = tail.into_iter().rev(); + + // Reconstruct the chain: If a block above insertion has a prev_blockhash that doesn't match + // the inserted data's hash, that block and everything above it are evicted. + let (Ok(cp) | Err(cp)) = match base { + Some(base_cp) => base_cp.extend(tail), + None => CheckPoint::from_blocks(tail).map_err(|err| err.expect("tail is non-empty")), + }; + cp } /// Puts another checkpoint onto the linked list representing the blockchain. From 696b8da6dc4953d21ca455ac6a8eb49b1d24155d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 14 Jan 2026 17:53:18 +0800 Subject: [PATCH 05/11] fix(chain): `merge_chains` now takes account of `prev_blockhash`es --- crates/chain/src/local_chain.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 81f4a1796..5d333baab 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -6,7 +6,7 @@ use core::ops::RangeBounds; use crate::collections::BTreeMap; use crate::{BlockId, ChainOracle, Merge}; -use bdk_core::ToBlockHash; +use bdk_core::{CheckPointEntry, ToBlockHash}; pub use bdk_core::{CheckPoint, CheckPointIter}; use bitcoin::block::Header; use bitcoin::BlockHash; @@ -598,14 +598,14 @@ where { let mut changeset = ChangeSet::::default(); - let mut orig = original_tip.iter(); - let mut update = update_tip.iter(); + let mut orig = original_tip.entry_iter(); + let mut update = update_tip.entry_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 prev_orig: Option> = None; + let mut prev_update: Option> = None; let mut point_of_agreement_found = false; @@ -634,13 +634,18 @@ where 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())); + // Only append to `ChangeSet` when this is an actual checkpoint. + if let Some(data) = u.data() { + changeset.blocks.insert(u.height(), Some(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()); + if !o.is_placeholder() { + // 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(); @@ -671,7 +676,7 @@ where 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 o.source_checkpoint().eq_ptr(&u.source_checkpoint()) { if is_update_height_superset_of_original { return Ok((update_tip, changeset)); } else { @@ -685,7 +690,7 @@ where } 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())); + changeset.blocks.insert(u.height(), u.data()); for invalidated_height in potentially_invalidated_heights.drain(..) { changeset.blocks.insert(invalidated_height, None); } From 1f4ddbf8ddd5f7995562568a71a5bb22e50a5183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 4 Feb 2026 15:29:40 +0000 Subject: [PATCH 06/11] test(chain): make `TestLocalChain` generic and add `prev_blockhash` test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make `TestLocalChain` and `ExpectedResult` generic over checkpoint data type `D`, allowing the same test infrastructure to work with both `BlockHash` and `TestBlock` types. Add `merge_chains_with_prev_blockhash` test to verify that `prev_blockhash` correctly invalidates conflicting blocks and connects disjoint chains. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/chain/src/local_chain.rs | 2 +- crates/chain/tests/test_local_chain.rs | 216 +++++++++++++++++++++++-- crates/core/src/checkpoint.rs | 3 +- crates/core/src/checkpoint_entry.rs | 3 +- 4 files changed, 212 insertions(+), 12 deletions(-) diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 5d333baab..0f9decb61 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -6,8 +6,8 @@ use core::ops::RangeBounds; use crate::collections::BTreeMap; use crate::{BlockId, ChainOracle, Merge}; -use bdk_core::{CheckPointEntry, ToBlockHash}; pub use bdk_core::{CheckPoint, CheckPointIter}; +use bdk_core::{CheckPointEntry, ToBlockHash}; use bitcoin::block::Header; use bitcoin::BlockHash; diff --git a/crates/chain/tests/test_local_chain.rs b/crates/chain/tests/test_local_chain.rs index 7ad03f04f..8f27dc93a 100644 --- a/crates/chain/tests/test_local_chain.rs +++ b/crates/chain/tests/test_local_chain.rs @@ -15,23 +15,26 @@ use bitcoin::{block::Header, hashes::Hash, BlockHash}; use proptest::prelude::*; #[derive(Debug)] -struct TestLocalChain<'a> { +struct TestLocalChain<'a, D> { name: &'static str, - chain: LocalChain, - update: CheckPoint, - exp: ExpectedResult<'a>, + chain: LocalChain, + update: CheckPoint, + exp: ExpectedResult<'a, D>, } #[derive(Debug, PartialEq)] -enum ExpectedResult<'a> { +enum ExpectedResult<'a, D> { Ok { - changeset: &'a [(u32, Option)], - init_changeset: &'a [(u32, Option)], + changeset: &'a [(u32, Option)], + init_changeset: &'a [(u32, Option)], }, Err(CannotConnectError), } -impl TestLocalChain<'_> { +impl TestLocalChain<'_, D> +where + D: bdk_core::ToBlockHash + Copy + PartialEq + std::fmt::Debug, +{ fn run(mut self) { let got_changeset = match self.chain.apply_update(self.update) { Ok(changeset) => changeset, @@ -75,7 +78,7 @@ impl TestLocalChain<'_> { #[test] fn update_local_chain() { [ - TestLocalChain { + TestLocalChain:: { name: "add first tip", chain: local_chain![(0, hash!("A"))], update: chain_update![(0, hash!("A"))], @@ -953,3 +956,198 @@ proptest! { prop_assert_eq!(heights, exp_heights); } } + +/// A test block type that returns `Some` for `prev_blockhash`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TestBlock { + hash: BlockHash, + prev_hash: BlockHash, +} + +impl bdk_core::ToBlockHash for TestBlock { + fn to_blockhash(&self) -> BlockHash { + self.hash + } + + fn prev_blockhash(&self) -> Option { + Some(self.prev_hash) + } +} + +/// Tests for `prev_blockhash` behavior in chain merging. +#[test] +fn merge_chains_with_prev_blockhash() { + // Common test blocks + let block_genesis = TestBlock { + hash: hash!("_"), + prev_hash: BlockHash::all_zeros(), + }; + let block_a = TestBlock { + hash: hash!("A"), + prev_hash: hash!("_"), + }; + let block_b = TestBlock { + hash: hash!("B"), + prev_hash: hash!("A"), + }; + let block_c = TestBlock { + hash: hash!("C"), + prev_hash: hash!("B"), + }; + let block_c_prime = TestBlock { + hash: hash!("C'"), + prev_hash: hash!("B'"), // conflicts with B + }; + let block_d_orphan = TestBlock { + hash: hash!("D"), + prev_hash: hash!("C_nonexistent"), + }; + let block_d_linked = TestBlock { + hash: hash!("D"), + prev_hash: hash!("C"), // matches block_c + }; + let block_a_prime = TestBlock { + hash: hash!("A'"), + prev_hash: hash!("_'"), // points to different genesis + }; + let block_b_prime = TestBlock { + hash: hash!("B'"), + prev_hash: hash!("A"), + }; + + [ + // Test: prev_blockhash can invalidate blocks in the original chain + // + // ```text + // | 0 | 1 | 2 | 3 | 4 | + // chain | _ A B D + // update | _ A C'(prev=B') + // result | _ A C' + // ``` + // + // The update at height 3 has `prev_blockhash = B'` which conflicts with the original + // `B` at height 2. This should invalidate blocks at heights 2 and 4. + TestLocalChain:: { + name: "prev_blockhash invalidates conflicting blocks", + chain: LocalChain::from_blocks( + [ + (0, block_genesis), + (1, block_a), + (2, block_b), + (4, block_d_orphan), + ] + .into(), + ) + .unwrap(), + update: CheckPoint::from_blocks([(0, block_genesis), (1, block_a), (3, block_c_prime)]) + .unwrap(), + exp: ExpectedResult::Ok { + changeset: &[(2, None), (3, Some(block_c_prime)), (4, None)], + init_changeset: &[ + (0, Some(block_genesis)), + (1, Some(block_a)), + (3, Some(block_c_prime)), + ], + }, + }, + // Test: prev_blockhash can connect disjoint chains + // + // ```text + // | 0 | 2 | 3 | 4 | + // chain | _ B C + // update | _ B D(prev=C) + // result | _ B C D + // ``` + // + // The update at height 4 has `prev_blockhash = C` which matches height 3 in the + // original. Even though the update doesn't include height 3 explicitly, the chains + // should connect. + TestLocalChain { + name: "prev_blockhash connects disjoint chains", + chain: LocalChain::from_blocks([(0, block_genesis), (2, block_b), (3, block_c)].into()) + .unwrap(), + update: CheckPoint::from_blocks([ + (0, block_genesis), + (2, block_b), + (4, block_d_linked), + ]) + .unwrap(), + exp: ExpectedResult::Ok { + changeset: &[(4, Some(block_d_linked))], + init_changeset: &[ + (0, Some(block_genesis)), + (2, Some(block_b)), + (3, Some(block_c)), + (4, Some(block_d_linked)), + ], + }, + }, + // Test: update's block evicts chain blocks via prev_blockhash conflict + // + // ```text + // | 0 | 1 | 2 | 3 | + // chain | _ A C(prev=B) + // update | _ A B' + // result | _ A B' + // ``` + // + // The chain's block C at height 3 has `prev_blockhash = B`. The update has B' at height 2 + // which doesn't match. C gets evicted because its prev_blockhash is invalidated. + TestLocalChain { + name: "update evicts chain blocks via prev_blockhash conflict", + chain: LocalChain::from_blocks([(0, block_genesis), (1, block_a), (3, block_c)].into()) + .unwrap(), + update: CheckPoint::from_blocks([(0, block_genesis), (1, block_a), (2, block_b_prime)]) + .unwrap(), + exp: ExpectedResult::Ok { + changeset: &[(2, Some(block_b_prime)), (3, None)], + init_changeset: &[ + (0, Some(block_genesis)), + (1, Some(block_a)), + (2, Some(block_b_prime)), + ], + }, + }, + // Test: prev_blockhash at height 1 implies different genesis + // + // ```text + // | 0 | 1 | + // chain | _ A + // update | A'(prev=_') + // result | error + // ``` + // + // The update only has a block at height 1, whose `prev_blockhash = _'` conflicts with + // the chain's genesis `_`. This should fail to connect. + TestLocalChain { + name: "prev_blockhash implies different genesis", + chain: LocalChain::from_blocks([(0, block_genesis), (1, block_a)].into()).unwrap(), + update: CheckPoint::new(1, block_a_prime), + exp: ExpectedResult::Err(CannotConnectError { + try_include_height: 0, + }), + }, + // Test: unverifiable connection despite shared genesis + // + // ```text + // | 0 | 1 | 2 | 4 | + // chain | _ B(prev=A) + // update | _ D(prev=C) + // result | error (height 1) + // ``` + // + // Both chains share genesis, but the chain's B implies A at height 1 (via prev_blockhash). + // The update doesn't have anything at height 1 to verify this connection, so the merge + // fails at height 1. + TestLocalChain { + name: "unverifiable connection despite shared genesis", + chain: LocalChain::from_blocks([(0, block_genesis), (2, block_b)].into()).unwrap(), + update: CheckPoint::from_blocks([(0, block_genesis), (4, block_d_linked)]).unwrap(), + exp: ExpectedResult::Err(CannotConnectError { + try_include_height: 1, + }), + }, + ] + .into_iter() + .for_each(TestLocalChain::run); +} diff --git a/crates/core/src/checkpoint.rs b/crates/core/src/checkpoint.rs index 06456d3d8..8d7a60ed4 100644 --- a/crates/core/src/checkpoint.rs +++ b/crates/core/src/checkpoint.rs @@ -385,7 +385,8 @@ where /// 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. + /// * The block you are pushing on is not at a greater height that the one you are pushing on + /// to. /// * The `prev_blockhash` does not match. pub fn push(self, height: u32, data: D) -> Result { // Reject if trying to push at or below current height - chain must grow forward. diff --git a/crates/core/src/checkpoint_entry.rs b/crates/core/src/checkpoint_entry.rs index 8e866b97a..7475e4f4c 100644 --- a/crates/core/src/checkpoint_entry.rs +++ b/crates/core/src/checkpoint_entry.rs @@ -176,7 +176,8 @@ impl CheckPointEntry { /// Returns the entry located a number of heights below this one. pub fn floor_below(&self, offset: u32) -> Option - where D: Clone + where + D: Clone, { self.floor_at(self.height().checked_sub(offset)?) } From e17a7e3b2c8722bf53a60ef7803240ea01e68b7f Mon Sep 17 00:00:00 2001 From: valued mammal Date: Wed, 14 Jan 2026 21:16:57 -0500 Subject: [PATCH 07/11] test(chain): Test `apply_update` with a single `CheckPoint
` --- crates/chain/tests/test_local_chain.rs | 48 ++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/chain/tests/test_local_chain.rs b/crates/chain/tests/test_local_chain.rs index 8f27dc93a..33f640732 100644 --- a/crates/chain/tests/test_local_chain.rs +++ b/crates/chain/tests/test_local_chain.rs @@ -11,6 +11,7 @@ use bdk_chain::{ BlockId, }; use bdk_testenv::{chain_update, hash, local_chain}; +use bitcoin::consensus::encode::deserialize_hex; use bitcoin::{block::Header, hashes::Hash, BlockHash}; use proptest::prelude::*; @@ -553,6 +554,53 @@ fn local_chain_disconnect_from() { } } +// Test that `apply_update` can connect 1 `Header` at a time +// and fails if a `prev_blockhash` conflict is detected. +#[test] +fn test_apply_update_single_header() { + let headers: Vec
= [ + "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff7f2002000000", + "0000002006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f1c96cc459dbb0c7bbc722af14f913da868779290ad48ff87ee314ebb8ae08f384b166069ffff7f2001000000", + "0000002078ce518c7dcfd99ad5859c35bd2be15794c0e5dc8e60c1fea3b0461c45da181ca80f828504f88c645ea46cfcf93156269807e3bd409e1317271a1546d238e9b24c166069ffff7f2001000000", + "000000200dfd6c5af6ea3cb341a08db344f93743d94b630d122867faff855f6310e58864e6e0c859fda703d66d051b86f16f09b0cead182f3cbe13767cd91b6371ec252c4c166069ffff7f2000000000", + ] + .into_iter() + .map(|s| deserialize_hex::
(s).expect("failed to deserialize header")) + .collect(); + + let header_0 = headers[0]; + let header_1 = headers[1]; + let header_2 = headers[2]; + let header_3 = headers[3]; + + let (mut chain, _) = LocalChain::from_genesis(header_0); + + // Apply 1 `CheckPoint
` at a time + for (height, header) in (1..).zip([header_1, header_2, header_3]) { + let changeset = chain.apply_update(CheckPoint::new(height, header)).unwrap(); + assert_eq!( + changeset, + ChangeSet { + blocks: [(height, Some(headers[height as usize]))].into() + }, + ); + } + assert_eq!(chain.tip().iter().count(), 4); + for height in 0..4 { + assert!(chain + .get(height) + .is_some_and(|cp| cp.hash() == headers[height as usize].block_hash())) + } + + // `apply_update` should error if update does not connect. + // Reset chain for error test. + chain = LocalChain::from_blocks([(0, header_0), (1, header_1)].into()).unwrap(); + let mut header_2_alt = header_2; + header_2_alt.prev_blockhash = hash!("header_1_new"); + let result = chain.apply_update(CheckPoint::new(2, header_2_alt)); + assert!(result.is_err(), "Failed to detect prev_blockhash conflict"); +} + #[test] fn checkpoint_from_block_ids() { struct TestCase<'a> { From 014c52797620225ffb814cdf85d58b099ee9c955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 25 Sep 2025 02:24:27 +0000 Subject: [PATCH 08/11] test(core): add tests for CheckPoint::push and insert methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive tests for CheckPoint::push error cases: - Push fails when height is not greater than current - Push fails when prev_blockhash conflicts with self - Push succeeds when prev_blockhash matches Include tests for CheckPoint::insert conflict handling: - Insert with conflicting prev_blockhash - Insert purges conflicting tail - Insert between conflicting checkpoints 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Co-Authored-By: valued mammal --- crates/core/tests/test_checkpoint.rs | 384 +++++++++++++++++++++++++++ 1 file changed, 384 insertions(+) diff --git a/crates/core/tests/test_checkpoint.rs b/crates/core/tests/test_checkpoint.rs index 811c56fad..19c06a417 100644 --- a/crates/core/tests/test_checkpoint.rs +++ b/crates/core/tests/test_checkpoint.rs @@ -186,3 +186,387 @@ fn test_mtp_sparse_chain() { assert_eq!(cp.median_time_past(), None); assert_eq!(cp.get(11).unwrap().median_time_past(), None); } + +// Custom struct for testing with prev_blockhash +#[derive(Debug, Clone, Copy)] +struct TestBlock { + blockhash: BlockHash, + prev_blockhash: BlockHash, +} + +impl ToBlockHash for TestBlock { + fn to_blockhash(&self) -> BlockHash { + self.blockhash + } + + fn prev_blockhash(&self) -> Option { + Some(self.prev_blockhash) + } +} + +/// Test inserting data with conflicting prev_blockhash should displace checkpoint and create +/// placeholder. +/// +/// When inserting data at height `h` with a `prev_blockhash` that conflicts with the checkpoint +/// at height `h-1`, the checkpoint at `h-1` should be displaced and replaced with a placeholder +/// containing the `prev_blockhash` from the inserted data. +/// +/// Expected: Checkpoint at 99 gets displaced when inserting at 100 with conflicting prev_blockhash. +#[test] +fn checkpoint_insert_conflicting_prev_blockhash() { + // Create initial checkpoint at height 99 + let block_99 = TestBlock { + blockhash: hash!("block_at_99"), + prev_blockhash: hash!("block_at_98"), + }; + let cp = CheckPoint::new(99, block_99); + + // Insert data at height 100 with a prev_blockhash that conflicts with checkpoint at 99 + let block_100_conflicting = TestBlock { + blockhash: hash!("block_at_100"), + prev_blockhash: hash!("different_block_at_99"), // Conflicts with block_99.blockhash + }; + + let result = cp.insert(100, block_100_conflicting); + + // Expected behavior: The checkpoint at 99 should be displaced + assert!(result.get(99).is_none(), "99 was displaced"); + + // The checkpoint at 100 should be inserted correctly + let height_100 = result.get(100).expect("checkpoint at 100 should exist"); + assert_eq!(height_100.hash(), block_100_conflicting.blockhash); + + // Verify chain structure + assert_eq!(result.height(), 100, "tip should be at height 100"); + assert_eq!(result.iter().count(), 1, "should have 1 checkpoints (100)"); +} + +/// Test inserting data that conflicts with prev_blockhash of higher checkpoints should purge them. +/// +/// When inserting data at height `h` where the blockhash conflicts with the `prev_blockhash` of +/// checkpoint at height `h+1`, the checkpoint at `h+1` and all checkpoints above it should be +/// purged from the chain. +/// +/// Expected: Checkpoints at 100, 101, 102 get purged when inserting at 99 with conflicting +/// blockhash. +#[test] +fn checkpoint_insert_purges_conflicting_tail() { + // Create a chain with multiple checkpoints + let block_98 = TestBlock { + blockhash: hash!("block_at_98"), + prev_blockhash: hash!("block_at_97"), + }; + let block_99 = TestBlock { + blockhash: hash!("block_at_99"), + prev_blockhash: hash!("block_at_98"), + }; + let block_100 = TestBlock { + blockhash: hash!("block_at_100"), + prev_blockhash: hash!("block_at_99"), + }; + let block_101 = TestBlock { + blockhash: hash!("block_at_101"), + prev_blockhash: hash!("block_at_100"), + }; + let block_102 = TestBlock { + blockhash: hash!("block_at_102"), + prev_blockhash: hash!("block_at_101"), + }; + + let cp = CheckPoint::from_blocks(vec![ + (98, block_98), + (99, block_99), + (100, block_100), + (101, block_101), + (102, block_102), + ]) + .expect("should create valid checkpoint chain"); + + // Verify initial chain has all checkpoints + assert_eq!(cp.iter().count(), 5); + + // Insert a conflicting block at height 99 + // The new block's hash will conflict with block_100's prev_blockhash + let conflicting_block_99 = TestBlock { + blockhash: hash!("different_block_at_99"), + prev_blockhash: hash!("block_at_98"), // Matches existing block_98 + }; + + let result = cp.insert(99, conflicting_block_99); + + // Expected: Heights 100, 101, 102 should be purged because block_100's + // prev_blockhash conflicts with the new block_99's hash + assert_eq!( + result.height(), + 99, + "tip should be at height 99 after purging higher checkpoints" + ); + + // Check that only 98 and 99 remain + assert_eq!( + result.iter().count(), + 2, + "should have 2 checkpoints (98, 99)" + ); + + // Verify height 99 has the new conflicting block + let height_99 = result.get(99).expect("checkpoint at 99 should exist"); + assert_eq!(height_99.hash(), conflicting_block_99.blockhash); + + // Verify height 98 remains unchanged + let height_98 = result.get(98).expect("checkpoint at 98 should exist"); + assert_eq!(height_98.hash(), block_98.blockhash); + + // Verify heights 100, 101, 102 are purged + assert!( + result.get(100).is_none(), + "checkpoint at 100 should be purged" + ); + assert!( + result.get(101).is_none(), + "checkpoint at 101 should be purged" + ); + assert!( + result.get(102).is_none(), + "checkpoint at 102 should be purged" + ); +} + +/// Test inserting between checkpoints with conflicts on both sides. +/// +/// When inserting at height between two checkpoints where the inserted data's `prev_blockhash` +/// conflicts with the lower checkpoint and its `blockhash` conflicts with the upper checkpoint's +/// `prev_blockhash`, both checkpoints should be handled: lower displaced, upper purged. +/// +/// Expected: Checkpoint at 4 displaced with placeholder, checkpoint at 6 purged. +#[test] +fn checkpoint_insert_between_conflicting_both_sides() { + // Create checkpoints at heights 4 and 6 + let block_4 = TestBlock { + blockhash: hash!("block_at_4"), + prev_blockhash: hash!("block_at_3"), + }; + let block_6 = TestBlock { + blockhash: hash!("block_at_6"), + prev_blockhash: hash!("block_at_5_original"), // This will conflict with inserted block 5 + }; + + let cp = CheckPoint::from_blocks(vec![(4, block_4), (6, block_6)]) + .expect("should create valid checkpoint chain"); + + // Verify initial state + assert_eq!(cp.iter().count(), 2); + + // Insert at height 5 with conflicts on both sides + let block_5_conflicting = TestBlock { + blockhash: hash!("block_at_5_new"), // Conflicts with block_6.prev_blockhash + prev_blockhash: hash!("different_block_at_4"), // Conflicts with block_4.blockhash + }; + + let result = cp.insert(5, block_5_conflicting); + + // Expected behavior: + // - Checkpoint at 4 should be displaced (omitted) + // - Checkpoint at 5 should have the inserted data + // - Checkpoint at 6 should be purged due to prev_blockhash conflict + + // Verify height 4 is displaced with placeholder + assert!(result.get(4).is_none()); + + // Verify height 5 has the inserted data + let checkpoint_5 = result.get(5).expect("checkpoint at 5 should exist"); + assert_eq!(checkpoint_5.height(), 5); + assert_eq!(checkpoint_5.hash(), block_5_conflicting.blockhash); + + // Verify height 6 is purged + assert!( + result.get(6).is_none(), + "checkpoint at 6 should be purged due to prev_blockhash conflict" + ); + + // Verify chain structure + assert_eq!(result.height(), 5, "tip should be at height 5"); + // Should have: checkpoint 5 only + assert_eq!( + result.iter().count(), + 1, + "should have 1 checkpoint(s) (4 was displaced, 6 was evicted)" + ); +} + +/// Test that push returns Err(self) when trying to push at the same height. +#[test] +fn checkpoint_push_fails_same_height() { + let cp: CheckPoint = CheckPoint::new(100, hash!("block_100")); + + // Try to push at the same height (100) + let result = cp.clone().push(100, hash!("another_block_100")); + + assert!( + result.is_err(), + "push should fail when height is same as current" + ); + assert!( + result.unwrap_err().eq_ptr(&cp), + "should return self on error" + ); +} + +/// Test that push returns Err(self) when trying to push at a lower height. +#[test] +fn checkpoint_push_fails_lower_height() { + let cp: CheckPoint = CheckPoint::new(100, hash!("block_100")); + + // Try to push at a lower height (99) + let result = cp.clone().push(99, hash!("block_99")); + + assert!( + result.is_err(), + "push should fail when height is lower than current" + ); + assert!( + result.unwrap_err().eq_ptr(&cp), + "should return self on error" + ); +} + +/// Test that push returns Err(self) when prev_blockhash conflicts with self's hash. +#[test] +fn checkpoint_push_fails_conflicting_prev_blockhash() { + let cp: CheckPoint = CheckPoint::new( + 100, + TestBlock { + blockhash: hash!("block_100"), + prev_blockhash: hash!("block_99"), + }, + ); + + // Create a block with a prev_blockhash that doesn't match cp's hash + let conflicting_block = TestBlock { + blockhash: hash!("block_101"), + prev_blockhash: hash!("wrong_block_100"), // This conflicts with cp's hash + }; + + // Try to push at height 101 (contiguous) with conflicting prev_blockhash + let result = cp.clone().push(101, conflicting_block); + + assert!( + result.is_err(), + "push should fail when prev_blockhash conflicts" + ); + assert!( + result.unwrap_err().eq_ptr(&cp), + "should return self on error" + ); +} + +/// Test that push succeeds when prev_blockhash matches self's hash for contiguous height. +#[test] +fn checkpoint_push_succeeds_matching_prev_blockhash() { + let cp: CheckPoint = CheckPoint::new( + 100, + TestBlock { + blockhash: hash!("block_100"), + prev_blockhash: hash!("block_99"), + }, + ); + + // Create a block with matching prev_blockhash + let matching_block = TestBlock { + blockhash: hash!("block_101"), + prev_blockhash: hash!("block_100"), // Matches cp's hash + }; + + // Push at height 101 with matching prev_blockhash + let result = cp.push(101, matching_block); + + assert!( + result.is_ok(), + "push should succeed when prev_blockhash matches" + ); + let new_cp = result.unwrap(); + assert_eq!(new_cp.height(), 101); + assert_eq!(new_cp.hash(), hash!("block_101")); +} + +/// Test that push creates a placeholder for non-contiguous heights with prev_blockhash. +#[test] +fn checkpoint_push_creates_non_contiguous_chain() { + let cp: CheckPoint = CheckPoint::new( + 100, + TestBlock { + blockhash: hash!("block_100"), + prev_blockhash: hash!("block_99"), + }, + ); + + // Create a block at non-contiguous height with prev_blockhash + let block_105 = TestBlock { + blockhash: hash!("block_105"), + prev_blockhash: hash!("block_104"), + }; + + // Push at height 105 (non-contiguous) + let result = cp.push(105, block_105); + + assert!( + result.is_ok(), + "push should succeed for non-contiguous height" + ); + let new_cp = result.unwrap(); + + // Verify the tip is at 105 + assert_eq!(new_cp.height(), 105); + assert_eq!(new_cp.hash(), hash!("block_105")); + + // Verify chain structure: 100, 105 + assert_eq!(new_cp.iter().count(), 2); +} + +/// Test `insert` should panic if trying to replace genesis with a different block. +#[test] +#[should_panic(expected = "inserted data implies different genesis")] +fn checkpoint_insert_cannot_replace_genesis() { + let block_0 = TestBlock { + blockhash: hash!("block_0"), + prev_blockhash: hash!("genesis_parent"), + }; + let block_1 = TestBlock { + blockhash: hash!("block_1"), + prev_blockhash: hash!("block_0"), + }; + + let cp = CheckPoint::from_blocks(vec![(0, block_0), (1, block_1)]) + .expect("should create valid chain"); + + // Try to replace genesis with a different block - should panic + let block_0_new = TestBlock { + blockhash: hash!("block_0_new"), + prev_blockhash: hash!("genesis_parent_new"), + }; + let _ = cp.insert(0, block_0_new); +} + +/// Test `insert` should panic if inserted data's prev_blockhash implies a different genesis. +#[test] +#[should_panic(expected = "inserted data implies different genesis")] +fn checkpoint_insert_cannot_displace_genesis() { + let block_0 = TestBlock { + blockhash: hash!("block_0"), + prev_blockhash: hash!("genesis_parent"), + }; + let block_1 = TestBlock { + blockhash: hash!("block_1"), + prev_blockhash: hash!("block_0"), + }; + + let cp = CheckPoint::from_blocks(vec![(0, block_0), (1, block_1)]) + .expect("should create valid chain"); + + // Insert at height 1 with prev_blockhash that conflicts with genesis - should panic + let block_1_new = TestBlock { + blockhash: hash!("block_1_new"), + prev_blockhash: hash!("different_block_0"), // Conflicts with block_0.hash + }; + let _ = cp.insert(1, block_1_new); +} From 5a568dc9693bfa447e23c1970b5a422d23c9f637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 6 Feb 2026 09:37:26 +0000 Subject: [PATCH 09/11] feat(chain)!: Relax the generic parameter for `LocalChain` Use `D: Clone` instead of `D: Copy`. --- crates/chain/src/local_chain.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 0f9decb61..99ac97d70 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -17,7 +17,7 @@ fn apply_changeset_to_checkpoint( changeset: &ChangeSet, ) -> Result, MissingGenesisError> 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,10 +34,10 @@ 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); @@ -234,7 +234,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) { @@ -263,7 +263,7 @@ where /// 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), @@ -412,7 +412,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(|d| d.to_blockhash()) { return false; } @@ -594,7 +594,7 @@ fn merge_chains( update_tip: CheckPoint, ) -> Result<(CheckPoint, ChangeSet), CannotConnectError> where - D: ToBlockHash + fmt::Debug + Copy, + D: ToBlockHash + fmt::Debug + Clone, { let mut changeset = ChangeSet::::default(); From 5c1fd23a9bd49dc0f7dc2d9130e335c600724613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 6 Feb 2026 15:19:50 +0000 Subject: [PATCH 10/11] feat(chain)!: Add `ApplyBlockError` for `prev_blockhash` validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `ApplyBlockError` enum with two variants: - `MissingGenesis`: genesis block is missing or would be altered - `PrevBlockhashMismatch`: block's `prev_blockhash` doesn't match expected This replaces `MissingGenesisError` in several `LocalChain` methods: - `from_blocks` - `from_changeset` - `apply_changeset` Also adds test cases for `merge_chains` with `prev_blockhash` scenarios: - Update displaces invalid block below point of agreement - Update fills gap with matching `prev_blockhash` - Cascading eviction through multiple blocks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/chain/src/local_chain.rs | 102 ++++++++++++++++++++----- crates/chain/tests/test_local_chain.rs | 98 ++++++++++++++++++++++++ crates/core/src/checkpoint.rs | 6 +- 3 files changed, 182 insertions(+), 24 deletions(-) diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 99ac97d70..91375417c 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -15,7 +15,7 @@ use bitcoin::BlockHash; fn apply_changeset_to_checkpoint( mut init_cp: CheckPoint, changeset: &ChangeSet, -) -> Result, MissingGenesisError> +) -> Result, ApplyBlockError> where D: ToBlockHash + fmt::Debug + Clone, { @@ -48,7 +48,11 @@ where let new_tip = match base { Some(base) => base .extend(extension) - .expect("extension is strictly greater than base"), + // 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(), }; init_cp = new_tip; @@ -251,22 +255,27 @@ 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 { + pub fn from_blocks(blocks: BTreeMap) -> Result { if !blocks.contains_key(&0) { - return Err(MissingGenesisError); + return Err(ApplyBlockError::MissingGenesis); } - Ok(Self { - tip: CheckPoint::from_blocks(blocks).expect("blocks must be in order"), - }) + CheckPoint::from_blocks(blocks) + .map(|tip| Self { tip }) + .map_err(|err| { + let last_cp = err.expect("must have atleast one block (genesis)"); + ApplyBlockError::PrevBlockhashMismatch { + expected: last_cp.block_id(), + } + }) } /// Construct a [`LocalChain`] from an initial `changeset`. - pub fn from_changeset(changeset: ChangeSet) -> Result { + 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(MissingGenesisError), + None => return Err(ApplyBlockError::MissingGenesis), }; let (mut chain, _) = Self::from_genesis(genesis_data); @@ -310,7 +319,7 @@ where } /// Apply the given `changeset`. - pub fn apply_changeset(&mut self, changeset: &ChangeSet) -> Result<(), MissingGenesisError> { + pub fn apply_changeset(&mut self, changeset: &ChangeSet) -> Result<(), ApplyBlockError> { let old_tip = self.tip.clone(); let new_tip = apply_changeset_to_checkpoint(old_tip, changeset)?; self.tip = new_tip; @@ -485,6 +494,36 @@ impl FromIterator<(u32, D)> for ChangeSet { } } +/// Error when applying blocks to a local chain. +#[derive(Clone, Debug, PartialEq)] +pub enum ApplyBlockError { + /// Genesis block is missing or would be altered. + MissingGenesis, + /// 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 ApplyBlockError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ApplyBlockError::MissingGenesis => { + write!(f, "genesis block is missing or would be altered") + } + ApplyBlockError::PrevBlockhashMismatch { expected } => write!( + f, + "`prev_blockhash` doesn't match block at height {} ({})", + expected.height, expected.hash + ), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for ApplyBlockError {} + /// An error which occurs when a [`LocalChain`] is constructed without a genesis checkpoint. #[derive(Clone, Debug, PartialEq)] pub struct MissingGenesisError; @@ -596,6 +635,30 @@ fn merge_chains( where D: ToBlockHash + fmt::Debug + Clone, { + // Apply the changeset to produce the final merged chain. + // + // `PrevBlockhashMismatch` should never happen because the merge iteration detects + // `prev_blockhash` conflicts and resolves them by invalidating conflicting blocks (setting + // them to `None` in the changeset) before we reach this point. + fn finish( + original_tip: CheckPoint, + changeset: ChangeSet, + ) -> Result<(CheckPoint, ChangeSet), CannotConnectError> + where + D: ToBlockHash + fmt::Debug + Clone, + { + let new_tip = apply_changeset_to_checkpoint(original_tip, &changeset).map_err(|err| { + debug_assert!( + matches!(err, ApplyBlockError::MissingGenesis), + "PrevBlockhashMismatch should never happen" + ); + CannotConnectError { + try_include_height: 0, + } + })?; + Ok((new_tip, changeset)) + } + let mut changeset = ChangeSet::::default(); let mut orig = original_tip.entry_iter(); @@ -680,11 +743,13 @@ where 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)); + return finish(original_tip, changeset); + } + } + // Update placeholder with real data (if necessary). + if let Some(u_data) = u.data_ref() { + if o.is_placeholder() { + changeset.blocks.insert(u.height(), Some(u_data.clone())); } } } else { @@ -719,10 +784,5 @@ where } } - let new_tip = apply_changeset_to_checkpoint(original_tip, &changeset).map_err(|_| { - CannotConnectError { - try_include_height: 0, - } - })?; - Ok((new_tip, changeset)) + finish(original_tip, changeset) } diff --git a/crates/chain/tests/test_local_chain.rs b/crates/chain/tests/test_local_chain.rs index 33f640732..109ac5f52 100644 --- a/crates/chain/tests/test_local_chain.rs +++ b/crates/chain/tests/test_local_chain.rs @@ -1062,6 +1062,14 @@ fn merge_chains_with_prev_blockhash() { hash: hash!("B'"), prev_hash: hash!("A"), }; + let block_a_alt = TestBlock { + hash: hash!("A_alt"), + prev_hash: hash!("_"), + }; + let block_d = TestBlock { + hash: hash!("D"), + prev_hash: hash!("C"), + }; [ // Test: prev_blockhash can invalidate blocks in the original chain @@ -1195,6 +1203,96 @@ fn merge_chains_with_prev_blockhash() { try_include_height: 1, }), }, + // Test: update displaces invalid block below point of agreement + // + // ```text + // | 0 | 1 | 2 | 4 | + // chain | _ C(prev=B) D + // update | _ A D + // result | _ A D + // ``` + // + // Both chains agree on D at height 4. Chain has C at height 2 with `prev_blockhash = B`, + // but no B exists. Update introduces A at height 1, which displaces C because + // C's `prev_blockhash` ("B") doesn't match A's hash ("A"). + // + // Note: This can only happen if chains are constructed incorrectly. + TestLocalChain { + name: "update displaces invalid block below point of agreement", + chain: LocalChain::from_blocks( + [(0, block_genesis), (2, block_c), (4, block_d_linked)].into(), + ) + .unwrap(), + update: CheckPoint::from_blocks([ + (0, block_genesis), + (1, block_a), + (4, block_d_linked), + ]) + .unwrap(), + exp: ExpectedResult::Ok { + changeset: &[(1, Some(block_a)), (2, None)], + init_changeset: &[ + (0, Some(block_genesis)), + (1, Some(block_a)), + (4, Some(block_d_linked)), + ], + }, + }, + // Test: update fills gap with matching prev_blockhash + // + // ```text + // | 0 | 1 | 2 | + // chain | _ B(prev=A) + // update | _ A B + // result | _ A B + // ``` + // + // Chain has gap at height 1. Update provides A which matches B's `prev_blockhash`. + // The chains connect perfectly. + TestLocalChain { + name: "update fills gap with matching prev_blockhash", + chain: LocalChain::from_blocks([(0, block_genesis), (2, block_b)].into()).unwrap(), + update: CheckPoint::from_blocks([(0, block_genesis), (1, block_a), (2, block_b)]) + .unwrap(), + exp: ExpectedResult::Ok { + changeset: &[(1, Some(block_a))], + init_changeset: &[ + (0, Some(block_genesis)), + (1, Some(block_a)), + (2, Some(block_b)), + ], + }, + }, + // Test: cascading eviction through multiple blocks + // + // ```text + // | 0 | 1 | 2 | 3 | 4 | + // chain | _ A B(prev=A) C(prev=B) D(prev=C) + // update | _ A_alt + // result | _ A_alt + // ``` + // + // Update replaces A with A_alt. B's `prev_blockhash = A` doesn't match A_alt, + // so B is evicted. C and D depend on B via prev_blockhash, so they cascade evict. + TestLocalChain { + name: "cascading eviction through multiple blocks", + chain: LocalChain::from_blocks( + [ + (0, block_genesis), + (1, block_a), + (2, block_b), + (3, block_c), + (4, block_d), + ] + .into(), + ) + .unwrap(), + update: CheckPoint::from_blocks([(0, block_genesis), (1, block_a_alt)]).unwrap(), + exp: ExpectedResult::Ok { + changeset: &[(1, Some(block_a_alt)), (2, None), (3, None), (4, None)], + init_changeset: &[(0, Some(block_genesis)), (1, Some(block_a_alt))], + }, + }, ] .into_iter() .for_each(TestLocalChain::run); diff --git a/crates/core/src/checkpoint.rs b/crates/core/src/checkpoint.rs index 8d7a60ed4..842949b4f 100644 --- a/crates/core/src/checkpoint.rs +++ b/crates/core/src/checkpoint.rs @@ -284,13 +284,13 @@ where /// Construct from an iterator of block data. /// /// Returns `Err(None)` if `blocks` doesn't yield any data. If the blocks are not in ascending - /// height order, then returns an `Err(..)` containing the last checkpoint that would have been - /// extended. + /// height order, or there are any `prev_blockhash` mismatches, then returns an `Err(..)` + /// containing the last checkpoint that would have been extended. pub fn from_blocks(blocks: impl IntoIterator) -> Result> { let mut blocks = blocks.into_iter(); let (height, data) = blocks.next().ok_or(None)?; let mut cp = CheckPoint::new(height, data); - cp = cp.extend(blocks)?; + cp = cp.extend(blocks).map_err(Some)?; Ok(cp) } From 2c29ff1438f5c737fd87ece6e39e5ebd43b8eb49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sat, 7 Feb 2026 10:12:00 +0000 Subject: [PATCH 11/11] docs(core): Add module-level docs for `checkpoint_entry` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explain the purpose of `CheckPointEntry` and its two variants: - `Occupied`: real checkpoint at this height - `Placeholder`: implied by `prev_blockhash` from checkpoint above Also fix typo: "atleast" → "at least" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/chain/src/local_chain.rs | 2 +- crates/core/src/checkpoint_entry.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 91375417c..1cafed252 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -263,7 +263,7 @@ where CheckPoint::from_blocks(blocks) .map(|tip| Self { tip }) .map_err(|err| { - let last_cp = err.expect("must have atleast one block (genesis)"); + let last_cp = err.expect("must have at least one block (genesis)"); ApplyBlockError::PrevBlockhashMismatch { expected: last_cp.block_id(), } diff --git a/crates/core/src/checkpoint_entry.rs b/crates/core/src/checkpoint_entry.rs index 7475e4f4c..b0151ec98 100644 --- a/crates/core/src/checkpoint_entry.rs +++ b/crates/core/src/checkpoint_entry.rs @@ -1,3 +1,16 @@ +//! Checkpoint entries for `prev_blockhash`-aware iteration. +//! +//! A [`CheckPoint`] chain may have gaps (non-contiguous heights). However, each checkpoint's +//! data can include a `prev_blockhash` that references the block one height below. This module +//! provides [`CheckPointEntry`], which represents either: +//! +//! - **Occupied**: A real checkpoint stored at this height. +//! - **Placeholder**: No checkpoint exists at this height, but the checkpoint above references it +//! via `prev_blockhash`. The placeholder contains the implied [`BlockId`]. +//! +//! Use [`CheckPoint::entry_iter`] to iterate over entries, which yields placeholders for any +//! gaps where `prev_blockhash` implies a block. + use core::ops::RangeBounds; use bitcoin::BlockHash;