]> Untitled Git - bdk/commitdiff
fix(chain): position resolution for assumed txs
authorLeonardo Lima <oleonardolima@users.noreply.github.com>
Mon, 16 Mar 2026 23:01:22 +0000 (20:01 -0300)
committer志宇 <hello@evanlinjin.me>
Sat, 13 Jun 2026 20:24:43 +0000 (20:24 +0000)
- add new `test_canonical_view_task.rs` to handle different scenarios
  of chain position resolution.
- fixes the assumed canonical txs chain position resolution, especially for transitively
  assumed canonical transactions, where there's an anchored/confirmed descendant.

crates/chain/src/canonical_view_task.rs
crates/chain/tests/test_canonical_view_task.rs [new file with mode: 0644]

index a6d0d87a4502ff2ac9d026ab6b39f10797cd1c39..d981bcef44eb486c23cd6df6421913419d3b40c6 100644 (file)
@@ -2,6 +2,7 @@
 
 use crate::canonical_task::{CanonicalReason, ObservedIn};
 use crate::collections::{HashMap, VecDeque};
+use crate::tx_graph::TxDescendants;
 use alloc::collections::BTreeSet;
 use alloc::sync::Arc;
 use alloc::vec::Vec;
@@ -152,16 +153,39 @@ impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> {
 
                 // Determine chain position based on reason
                 let chain_position = match reason {
-                    CanonicalReason::Assumed { .. } => match self.direct_anchors.get(txid) {
-                        Some(anchor) => ChainPosition::Confirmed {
-                            anchor,
-                            transitively: None,
-                        },
-                        None => ChainPosition::Unconfirmed {
-                            first_seen: tx_node.first_seen,
-                            last_seen: tx_node.last_seen,
-                        },
-                    },
+                    CanonicalReason::Assumed { .. } => {
+                        match self.direct_anchors.get(txid) {
+                            // it has a direct anchor found
+                            // regardless if it's directly or transitively assumed canonical
+                            Some(anchor) => ChainPosition::Confirmed {
+                                anchor,
+                                transitively: None,
+                            },
+                            None => TxDescendants::new_exclude_root(
+                                self.tx_graph,
+                                *txid,
+                                // ensure descendant is canonical
+                                |_, desc_txid| -> Option<Txid> {
+                                    self.canonical_txs
+                                        .contains_key(&desc_txid)
+                                        .then_some(desc_txid)
+                                },
+                            )
+                            // ensure descendant has direct anchor
+                            .find_map(|desc_txid| {
+                                self.direct_anchors.get(&desc_txid).map(|anchor| {
+                                    ChainPosition::Confirmed {
+                                        anchor,
+                                        transitively: Some(desc_txid),
+                                    }
+                                })
+                            })
+                            .unwrap_or(ChainPosition::Unconfirmed {
+                                first_seen: tx_node.first_seen,
+                                last_seen: tx_node.last_seen,
+                            }),
+                        }
+                    }
                     CanonicalReason::Anchor { anchor, descendant } => match descendant {
                         Some(_) => match self.direct_anchors.get(txid) {
                             Some(anchor) => ChainPosition::Confirmed {
diff --git a/crates/chain/tests/test_canonical_view_task.rs b/crates/chain/tests/test_canonical_view_task.rs
new file mode 100644 (file)
index 0000000..397819f
--- /dev/null
@@ -0,0 +1,190 @@
+#![cfg(feature = "miniscript")]
+
+mod common;
+
+use bdk_chain::{CanonicalReason, ChainPosition};
+use bdk_testenv::{block_id, hash, local_chain};
+use bitcoin::Txid;
+use common::*;
+use std::collections::HashSet;
+
+#[test]
+fn test_assumed_canonical_scenarios() {
+    // scenario: "txC spends txB; txB spends txA; txB is anchored; txC is assumed canonical"
+
+    // create a local chain
+    let local_chain = local_chain![
+        (0, hash!("genesis")),
+        (1, hash!("block1")),
+        (2, hash!("block2")),
+        (3, hash!("block3")),
+        (4, hash!("block4")),
+        (5, hash!("block5")),
+        (6, hash!("block6")),
+        (7, hash!("block7")),
+        (8, hash!("block8")),
+        (9, hash!("block9")),
+        (10, hash!("block10"))
+    ];
+    let chain_tip = local_chain.tip().block_id();
+
+    // create arrays before scenario to avoid lifetime issues
+    let tx_templates = [
+        TxTemplate {
+            tx_name: "txA",
+            inputs: &[TxInTemplate::Bogus],
+            outputs: &[TxOutTemplate::new(100000, Some(0))],
+            anchors: &[],
+            last_seen: None,
+            assume_canonical: false,
+        },
+        TxTemplate {
+            tx_name: "txB",
+            inputs: &[TxInTemplate::PrevTx("txA", 0)],
+            outputs: &[TxOutTemplate::new(50000, Some(0))],
+            anchors: &[block_id!(5, "block5")],
+            last_seen: None,
+            assume_canonical: false,
+        },
+        TxTemplate {
+            tx_name: "txC",
+            inputs: &[TxInTemplate::PrevTx("txB", 0)],
+            outputs: &[TxOutTemplate::new(25000, Some(0))],
+            anchors: &[],
+            last_seen: None,
+            assume_canonical: true,
+        },
+    ];
+
+    let exp_canonical_txs = HashSet::from(["txA", "txB", "txC"]);
+
+    let env = init_graph(&tx_templates);
+
+    // get the actual txid from given tx_name.
+    let txid_c = *env.txid_to_name.get("txC").unwrap();
+
+    // build the expected `CanonicalReason` with specific descendant txid's
+    //
+    // in this scenario: txC is assumed canonical, and it's descendant of txB and txA
+    // therefore the whole chain should become assumed canonical.
+    //
+    // the descendant txid field refers to the directly **assumed canonical txC**
+    let exp_reasons = vec![
+        (
+            "txA",
+            CanonicalReason::Assumed {
+                descendant: Some(txid_c),
+            },
+        ),
+        (
+            "txB",
+            CanonicalReason::Assumed {
+                descendant: Some(txid_c),
+            },
+        ),
+        ("txC", CanonicalReason::Assumed { descendant: None }),
+    ];
+
+    // build task & canonicalize
+    let canonical_params = env.canonicalization_params;
+    let canonical_task = env.tx_graph.canonical_task(chain_tip, canonical_params);
+    let canonical_txs = local_chain.canonicalize(canonical_task);
+
+    // assert canonical transactions
+    let exp_canonical_txids: HashSet<Txid> = exp_canonical_txs
+        .iter()
+        .map(|tx_name| {
+            *env.txid_to_name
+                .get(tx_name)
+                .expect("txid should exist for tx_name")
+        })
+        .collect::<HashSet<Txid>>();
+
+    let canonical_txids = canonical_txs
+        .txs()
+        .map(|canonical_tx| canonical_tx.txid)
+        .collect::<HashSet<Txid>>();
+
+    assert_eq!(
+        canonical_txids, exp_canonical_txids,
+        "[{}] canonical transactions mismatch",
+        "txC spends txB; txB spends txA; txB is anchored; txC is assumed canonical"
+    );
+
+    // assert canonical reasons
+    for (tx_name, exp_reason) in exp_reasons {
+        let txid = env
+            .txid_to_name
+            .get(tx_name)
+            .expect("txid should exist for tx_name");
+
+        let canonical_reason = canonical_txs
+            .txs()
+            .find(|ctx| &ctx.txid == txid)
+            .expect("expected txid should exist in canonical txs")
+            .pos;
+
+        assert_eq!(
+            canonical_reason, exp_reason,
+            "[{}] canonical reason mismatch for {}",
+            "txC spends txB; txB spends txA; txB is anchored; txC is assumed canonical", tx_name
+        )
+    }
+
+    let txid_b = *env.txid_to_name.get("txB").unwrap();
+
+    // build the expected `ChainPosition` with specific txid's for transitively confirmed txs.
+    //
+    // in this scenario:
+    //
+    // txA: should be confirmed transitively by txB.
+    // txB: should be confirmed, has a direct anchor(block5).
+    // txC: should be unconfirmed, has been assumed canonical though has no direct anchors.
+    let exp_positions = vec![
+        (
+            "txA",
+            ChainPosition::Confirmed {
+                anchor: block_id!(5, "block5"),
+                transitively: Some(txid_b),
+            },
+        ),
+        (
+            "txB",
+            ChainPosition::Confirmed {
+                anchor: block_id!(5, "block5"),
+                transitively: None,
+            },
+        ),
+        (
+            "txC",
+            ChainPosition::Unconfirmed {
+                first_seen: None,
+                last_seen: None,
+            },
+        ),
+    ];
+
+    // build task & resolve positions
+    let view_task = canonical_txs.view_task(&env.tx_graph);
+    let canonical_view = local_chain.canonicalize(view_task);
+
+    // assert final positions
+    for (tx_name, exp_position) in exp_positions {
+        let txid = *env
+            .txid_to_name
+            .get(tx_name)
+            .expect("txid should exist for tx_name");
+
+        let canonical_position = canonical_view
+            .txs()
+            .find(|ctx| ctx.txid == txid)
+            .expect("expected txid should exist in canonical view")
+            .pos;
+
+        assert_eq!(
+            canonical_position, exp_position,
+            "[{}] canonical position mismatch for {}",
+            "txC spends txB; txB spends txA; txB is anchored; txC is assumed canonical", tx_name
+        );
+    }
+}