From 3df06096f9209a0ed12e109e0d809174a7b1a73b Mon Sep 17 00:00:00 2001 From: =?utf8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 4 Feb 2026 15:29:40 +0000 Subject: [PATCH] test(chain): make `TestLocalChain` generic and add `prev_blockhash` test MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 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 b7769889..d1d0535b 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 7ad03f04..8f27dc93 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 06456d3d..8d7a60ed 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 8e866b97..7475e4f4 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)?) } -- 2.49.0