Skip to content

Commit 9b44f75

Browse files
evanlinjinclaude
andcommitted
test(chain): add tests for CanonicalView::extract_subgraph
Test extraction of transaction subgraphs with descendants, verifying that spends and txs fields maintain consistency. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent abdf572 commit 9b44f75

1 file changed

Lines changed: 326 additions & 2 deletions

File tree

crates/chain/tests/test_canonical_view.rs

Lines changed: 326 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,155 @@
11
#![cfg(feature = "miniscript")]
22

3-
use std::collections::BTreeMap;
3+
use std::collections::{BTreeMap, HashMap};
44

55
use bdk_chain::{local_chain::LocalChain, CanonicalizationParams, ConfirmationBlockTime, TxGraph};
66
use bdk_testenv::{hash, utils::new_tx};
7-
use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut};
7+
use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid};
8+
9+
// =============================================================================
10+
// Test case pattern for extract_subgraph tests
11+
// =============================================================================
12+
13+
#[derive(Clone, Copy)]
14+
struct TestTx {
15+
/// Name to identify this tx
16+
name: &'static str,
17+
/// Which tx outputs this tx spends: &[(parent_name, vout)]
18+
spends: &'static [(&'static str, u32)],
19+
/// Number of outputs to create
20+
num_outputs: u32,
21+
/// Anchor height (None = unconfirmed, use seen_at)
22+
anchor_height: Option<u32>,
23+
}
24+
25+
struct ExtractSubgraphTestCase {
26+
/// Transactions in the graph
27+
txs: &'static [TestTx],
28+
/// Names of transactions to extract
29+
extract: &'static [&'static str],
30+
/// Expected tx names in extracted view
31+
expect_extracted: &'static [&'static str],
32+
/// Expected tx names remaining in original view
33+
expect_remaining: &'static [&'static str],
34+
}
35+
36+
fn build_chain(max_height: u32) -> LocalChain {
37+
use bitcoin::hashes::Hash;
38+
let blocks: BTreeMap<u32, BlockHash> = (0..=max_height)
39+
.map(|h| (h, BlockHash::hash(format!("block{}", h).as_bytes())))
40+
.collect();
41+
LocalChain::from_blocks(blocks).unwrap()
42+
}
43+
44+
fn build_test_graph(
45+
chain: &LocalChain,
46+
txs: &[TestTx],
47+
) -> (TxGraph<ConfirmationBlockTime>, HashMap<&'static str, Txid>) {
48+
let mut tx_graph = TxGraph::default();
49+
let mut name_to_txid = HashMap::new();
50+
51+
for (i, test_tx) in txs.iter().enumerate() {
52+
let inputs: Vec<TxIn> = test_tx
53+
.spends
54+
.iter()
55+
.map(|(parent, vout)| TxIn {
56+
previous_output: OutPoint::new(name_to_txid[parent], *vout),
57+
..Default::default()
58+
})
59+
.collect();
60+
61+
let tx = Transaction {
62+
input: inputs,
63+
output: (0..test_tx.num_outputs)
64+
.map(|_| TxOut {
65+
value: Amount::from_sat(10_000),
66+
script_pubkey: ScriptBuf::new(),
67+
})
68+
.collect(),
69+
..new_tx(i as u32)
70+
};
71+
72+
let txid = tx.compute_txid();
73+
name_to_txid.insert(test_tx.name, txid);
74+
let _ = tx_graph.insert_tx(tx);
75+
76+
match test_tx.anchor_height {
77+
Some(h) => {
78+
let _ = tx_graph.insert_anchor(
79+
txid,
80+
ConfirmationBlockTime {
81+
block_id: chain.get(h).unwrap().block_id(),
82+
confirmation_time: h as u64 * 100,
83+
},
84+
);
85+
}
86+
None => {
87+
let _ = tx_graph.insert_seen_at(txid, 1000);
88+
}
89+
}
90+
}
91+
92+
(tx_graph, name_to_txid)
93+
}
94+
95+
fn run_extract_subgraph_test(case: &ExtractSubgraphTestCase) {
96+
let max_height = case
97+
.txs
98+
.iter()
99+
.filter_map(|tx| tx.anchor_height)
100+
.max()
101+
.unwrap_or(0);
102+
let chain = build_chain(max_height + 1);
103+
104+
let (tx_graph, name_to_txid) = build_test_graph(&chain, case.txs);
105+
let mut view = tx_graph.canonical_view(&chain, chain.tip().block_id(), Default::default());
106+
107+
// Extract
108+
let extract_txids = case
109+
.extract
110+
.iter()
111+
.filter_map(|name| name_to_txid.get(name).copied());
112+
let extracted = view.extract_descendant_subgraph(extract_txids);
113+
114+
// Verify extracted
115+
for name in case.expect_extracted {
116+
assert!(
117+
extracted.tx(name_to_txid[name]).is_some(),
118+
"{} should be in extracted",
119+
name
120+
);
121+
}
122+
123+
// Verify not in extracted (should be remaining)
124+
for name in case.expect_remaining {
125+
assert!(
126+
extracted.tx(name_to_txid[name]).is_none(),
127+
"{} should not be in extracted",
128+
name
129+
);
130+
}
131+
132+
// Verify remaining
133+
for name in case.expect_remaining {
134+
assert!(
135+
view.tx(name_to_txid[name]).is_some(),
136+
"{} should remain",
137+
name
138+
);
139+
}
140+
141+
// Verify not remaining (should be extracted)
142+
for name in case.expect_extracted {
143+
assert!(
144+
view.tx(name_to_txid[name]).is_none(),
145+
"{} should not remain",
146+
name
147+
);
148+
}
149+
150+
verify_spends_consistency(&extracted);
151+
verify_spends_consistency(&view);
152+
}
8153

9154
#[test]
10155
fn test_min_confirmations_parameter() {
@@ -301,3 +446,182 @@ fn test_min_confirmations_multiple_transactions() {
301446
);
302447
assert_eq!(balance_high.untrusted_pending, Amount::ZERO);
303448
}
449+
450+
#[test]
451+
fn test_extract_subgraph_basic_chain() {
452+
// tx0 -> tx1 -> tx2
453+
run_extract_subgraph_test(&ExtractSubgraphTestCase {
454+
txs: &[
455+
TestTx {
456+
name: "tx0",
457+
spends: &[],
458+
num_outputs: 1,
459+
anchor_height: Some(1),
460+
},
461+
TestTx {
462+
name: "tx1",
463+
spends: &[("tx0", 0)],
464+
num_outputs: 1,
465+
anchor_height: Some(2),
466+
},
467+
TestTx {
468+
name: "tx2",
469+
spends: &[("tx1", 0)],
470+
num_outputs: 1,
471+
anchor_height: None,
472+
},
473+
],
474+
extract: &["tx1"],
475+
expect_extracted: &["tx1", "tx2"],
476+
expect_remaining: &["tx0"],
477+
});
478+
}
479+
480+
#[test]
481+
fn test_extract_subgraph_diamond_graph() {
482+
// tx0 (2 outputs)
483+
// / \
484+
// tx1 tx2
485+
// \ /
486+
// tx3
487+
// |
488+
// tx4
489+
run_extract_subgraph_test(&ExtractSubgraphTestCase {
490+
txs: &[
491+
TestTx {
492+
name: "tx0",
493+
spends: &[],
494+
num_outputs: 2,
495+
anchor_height: Some(1),
496+
},
497+
TestTx {
498+
name: "tx1",
499+
spends: &[("tx0", 0)],
500+
num_outputs: 1,
501+
anchor_height: Some(2),
502+
},
503+
TestTx {
504+
name: "tx2",
505+
spends: &[("tx0", 1)],
506+
num_outputs: 1,
507+
anchor_height: Some(2),
508+
},
509+
TestTx {
510+
name: "tx3",
511+
spends: &[("tx1", 0), ("tx2", 0)],
512+
num_outputs: 1,
513+
anchor_height: Some(3),
514+
},
515+
TestTx {
516+
name: "tx4",
517+
spends: &[("tx3", 0)],
518+
num_outputs: 1,
519+
anchor_height: None,
520+
},
521+
],
522+
extract: &["tx1", "tx2"],
523+
expect_extracted: &["tx1", "tx2", "tx3", "tx4"],
524+
expect_remaining: &["tx0"],
525+
});
526+
}
527+
528+
#[test]
529+
fn test_extract_subgraph_nonexistent_tx() {
530+
run_extract_subgraph_test(&ExtractSubgraphTestCase {
531+
txs: &[TestTx {
532+
name: "tx0",
533+
spends: &[],
534+
num_outputs: 1,
535+
anchor_height: Some(1),
536+
}],
537+
extract: &["fake"],
538+
expect_extracted: &[],
539+
expect_remaining: &["tx0"],
540+
});
541+
}
542+
543+
#[test]
544+
fn test_extract_subgraph_partial_chain() {
545+
// tx0 -> tx1 -> tx2 -> tx3
546+
run_extract_subgraph_test(&ExtractSubgraphTestCase {
547+
txs: &[
548+
TestTx {
549+
name: "tx0",
550+
spends: &[],
551+
num_outputs: 1,
552+
anchor_height: Some(1),
553+
},
554+
TestTx {
555+
name: "tx1",
556+
spends: &[("tx0", 0)],
557+
num_outputs: 1,
558+
anchor_height: Some(2),
559+
},
560+
TestTx {
561+
name: "tx2",
562+
spends: &[("tx1", 0)],
563+
num_outputs: 1,
564+
anchor_height: Some(3),
565+
},
566+
TestTx {
567+
name: "tx3",
568+
spends: &[("tx2", 0)],
569+
num_outputs: 1,
570+
anchor_height: Some(4),
571+
},
572+
],
573+
extract: &["tx1"],
574+
expect_extracted: &["tx1", "tx2", "tx3"],
575+
expect_remaining: &["tx0"],
576+
});
577+
}
578+
579+
// Helper function to verify spends field consistency
580+
fn verify_spends_consistency<A: bdk_chain::Anchor>(view: &bdk_chain::CanonicalView<A>) {
581+
// Verify each transaction's outputs and their spent status
582+
for tx in view.txs() {
583+
// Verify the transaction exists
584+
assert!(view.tx(tx.txid).is_some());
585+
586+
// For each output, check if it's properly tracked as spent
587+
for vout in 0..tx.tx.output.len() {
588+
let op = OutPoint::new(tx.txid, vout as u32);
589+
if let Some(txout) = view.txout(op) {
590+
// If this output is spent, verify the spending tx exists
591+
if let Some((_, spending_txid)) = txout.spent_by {
592+
assert!(
593+
view.tx(spending_txid).is_some(),
594+
"Spending tx {spending_txid} not found in view"
595+
);
596+
597+
// Verify the spending tx actually has this input
598+
let spending_tx = view.tx(spending_txid).unwrap();
599+
assert!(
600+
spending_tx
601+
.tx
602+
.input
603+
.iter()
604+
.any(|input| input.previous_output == op),
605+
"Transaction {spending_txid} doesn't actually spend outpoint {op}"
606+
);
607+
}
608+
}
609+
}
610+
611+
// For each input (except coinbase), verify it references valid outpoints
612+
if !tx.tx.is_coinbase() {
613+
for input in &tx.tx.input {
614+
// If the parent tx is in this view, verify the output exists and shows as spent
615+
if let Some(parent_txout) = view.txout(input.previous_output) {
616+
assert_eq!(
617+
parent_txout.spent_by.as_ref().map(|(_, txid)| *txid),
618+
Some(tx.txid),
619+
"Output {:?} should be marked as spent by tx {}",
620+
input.previous_output,
621+
tx.txid
622+
);
623+
}
624+
}
625+
}
626+
}
627+
}

0 commit comments

Comments
 (0)