Skip to content

Commit 942d438

Browse files
committed
test(chain): add test for merge_chains
1 parent 833dcb6 commit 942d438

File tree

1 file changed

+152
-1
lines changed

1 file changed

+152
-1
lines changed

crates/chain/tests/test_local_chain.rs

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use bdk_chain::{
1111
BlockId,
1212
};
1313
use bdk_testenv::{chain_update, hash, local_chain};
14-
use bitcoin::{block::Header, hashes::Hash, BlockHash};
14+
use bitcoin::{block::Header, hashes::Hash, BlockHash, CompactTarget, TxMerkleNode};
1515
use proptest::prelude::*;
1616

1717
#[derive(Debug)]
@@ -474,6 +474,157 @@ fn local_chain_insert_header() {
474474
}
475475
}
476476

477+
/// Validates `merge_chains` behavior on chains that contain placeholder checkpoints (`data: None`).
478+
///
479+
/// Placeholders are created when a `CheckPoint`’s `prev_blockhash` references a block at a height
480+
/// with no stored checkpoint. This test ensures `merge_chains` handles them correctly and that the
481+
/// resulting chain never exposes a placeholder checkpoint.
482+
#[test]
483+
fn merge_chains_handles_placeholders() {
484+
fn header(prev_blockhash: bitcoin::BlockHash, nonce: u32) -> Header {
485+
Header {
486+
version: bitcoin::block::Version::default(),
487+
prev_blockhash,
488+
merkle_root: TxMerkleNode::all_zeros(),
489+
time: 0,
490+
bits: CompactTarget::default(),
491+
nonce,
492+
}
493+
}
494+
495+
fn local_chain(blocks: Vec<(u32, Header)>) -> LocalChain<Header> {
496+
LocalChain::from_blocks(blocks.into_iter().collect::<BTreeMap<_, _>>())
497+
.expect("chain must have genesis block")
498+
}
499+
500+
fn update_chain(blocks: &[(u32, Header)]) -> CheckPoint<Header> {
501+
CheckPoint::from_blocks(blocks.iter().copied()).expect("checkpoint must be valid")
502+
}
503+
504+
let a = header(hash!("genesis"), 0);
505+
let b = header(a.block_hash(), 0);
506+
let c = header(b.block_hash(), 0);
507+
let d = header(c.block_hash(), 0);
508+
509+
// Set a different `nonce` for conflicting `Header`s to ensure different `BlockHash`.
510+
let c_conflict = header(b.block_hash(), 1);
511+
let d_conflict = header(c_conflict.block_hash(), 1);
512+
513+
struct TestCase {
514+
name: &'static str,
515+
updates: Vec<CheckPoint<Header>>,
516+
invalidate_heights: Vec<u32>,
517+
expected_placeholder_heights: Vec<u32>,
518+
expected_chain: LocalChain<Header>,
519+
}
520+
521+
let test_cases = [
522+
// Test case 1: Create a placeholder for B via C.
523+
TestCase {
524+
name: "insert_placeholder",
525+
updates: vec![update_chain(&[(0, a), (2, c)])],
526+
invalidate_heights: vec![],
527+
expected_placeholder_heights: vec![1],
528+
expected_chain: local_chain(vec![(0, a), (2, c)]),
529+
},
530+
// Test cast 2: Create a placeholder for B via C, then update provides conflicting C'.
531+
TestCase {
532+
name: "conflict_at_tip_keeps_placeholder",
533+
updates: vec![
534+
update_chain(&[(0, a), (2, c)]),
535+
update_chain(&[(2, c_conflict)]),
536+
],
537+
invalidate_heights: vec![],
538+
expected_placeholder_heights: vec![1],
539+
expected_chain: local_chain(vec![(0, a), (1, b), (2, c_conflict)]),
540+
},
541+
// Test case 3: Create placeholder for C via D.
542+
TestCase {
543+
name: "conflict_at_filled_height",
544+
updates: vec![update_chain(&[(0, a), (3, d)])],
545+
invalidate_heights: vec![],
546+
expected_placeholder_heights: vec![2],
547+
expected_chain: local_chain(vec![(0, a), (3, d)]),
548+
},
549+
// Test case 4: Create placeholder for C via D, then insert conflicting C' which should
550+
// drop D and replace C.
551+
TestCase {
552+
name: "conflict_at_filled_height",
553+
updates: vec![
554+
update_chain(&[(0, a), (3, d)]),
555+
update_chain(&[(0, a), (2, c_conflict)]),
556+
],
557+
invalidate_heights: vec![],
558+
expected_placeholder_heights: vec![],
559+
expected_chain: local_chain(vec![(0, a), (2, c_conflict)]),
560+
},
561+
// Test case 5: Create placeholder for B via C, then invalidate C.
562+
TestCase {
563+
name: "invalidate_tip_falls_back",
564+
updates: vec![update_chain(&[(0, a), (2, c)])],
565+
invalidate_heights: vec![2],
566+
expected_placeholder_heights: vec![1],
567+
expected_chain: local_chain(vec![(0, a)]),
568+
},
569+
// Test case 6: Create placeholder for C via D, then insert D' which has `prev_blockhash`
570+
// that does not point to C. TODO: Handle error?
571+
TestCase {
572+
name: "expected_error",
573+
updates: vec![
574+
update_chain(&[(0, a), (3, d)]),
575+
update_chain(&[(3, d_conflict)]),
576+
],
577+
invalidate_heights: vec![],
578+
expected_placeholder_heights: vec![],
579+
expected_chain: local_chain(vec![(0, a), (3, d)]),
580+
},
581+
];
582+
583+
for (i, t) in test_cases.into_iter().enumerate() {
584+
let mut chain = local_chain(vec![(0, a)]);
585+
for upd in t.updates {
586+
// If `apply_update` errors, it is because the new chain cannot be merged. So it should
587+
// follow that this validates behavior if the final `expected_chain` state is correct.
588+
if chain.apply_update(upd).is_ok() {
589+
for &height in &t.expected_placeholder_heights {
590+
let has_placeholder = chain
591+
.tip()
592+
.iter()
593+
.any(|cp| cp.height() == height && cp.data_ref().is_none());
594+
assert!(
595+
has_placeholder,
596+
"[{}] {}: expected placeholder at height {}",
597+
i, t.name, height
598+
);
599+
}
600+
601+
if !t.invalidate_heights.is_empty() {
602+
let cs: ChangeSet<Header> = t
603+
.invalidate_heights
604+
.iter()
605+
.copied()
606+
.map(|h| (h, None))
607+
.collect();
608+
chain.apply_changeset(&cs).expect("changeset should apply");
609+
}
610+
611+
// Ensure we never end up with a placeholder tip.
612+
assert!(
613+
chain.tip().data_ref().is_some(),
614+
"[{}] {}: tip must always be materialized",
615+
i,
616+
t.name
617+
);
618+
}
619+
}
620+
assert_eq!(
621+
chain, t.expected_chain,
622+
"[{}] {}: unexpected final chain",
623+
i, t.name
624+
);
625+
}
626+
}
627+
477628
#[test]
478629
fn local_chain_disconnect_from() {
479630
struct TestCase {

0 commit comments

Comments
 (0)