|
1 | 1 | #![cfg(feature = "miniscript")] |
2 | 2 |
|
3 | | -use std::collections::BTreeMap; |
| 3 | +use std::collections::{BTreeMap, HashMap}; |
4 | 4 |
|
5 | 5 | use bdk_chain::{local_chain::LocalChain, CanonicalizationParams, ConfirmationBlockTime, TxGraph}; |
6 | 6 | 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 | +} |
8 | 153 |
|
9 | 154 | #[test] |
10 | 155 | fn test_min_confirmations_parameter() { |
@@ -301,3 +446,182 @@ fn test_min_confirmations_multiple_transactions() { |
301 | 446 | ); |
302 | 447 | assert_eq!(balance_high.untrusted_pending, Amount::ZERO); |
303 | 448 | } |
| 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