]> Untitled Git - bdk/commitdiff
feat(chain)!: classify timelocked outputs as `Immature`
authorDmenec <dmenec@proton.me>
Thu, 15 Oct 2026 14:22:53 +0000 (16:22 +0200)
committerDmenec <dmenec@proton.me>
Mon, 19 Oct 2026 06:23:14 +0000 (08:23 +0200)
Add an `is_locked` predicate on `classify_outpoints`/`balance` so the caller marks outputs whose timelock has not matured yet. They are classified as `Immature`.

crates/bitcoind_rpc/tests/test_emitter.rs
crates/chain/benches/indexer.rs
crates/chain/benches/trust_classification.rs
crates/chain/src/balance.rs
crates/chain/src/canonical.rs
crates/chain/tests/test_canonical_view.rs
crates/chain/tests/test_indexed_tx_graph.rs
crates/chain/tests/test_tx_graph_conflicts.rs
crates/electrum/tests/test_electrum.rs

index f1853cfa44bd3fd4c4f3dbd24e73c71ae6ed1b69..1d04cb5ddfa97a1f3b9b3da4287848d6a4fb1fc0 100644 (file)
@@ -329,6 +329,7 @@ fn get_balance(
             outpoints.into_iter().map(|(_, op)| op),
             bdk_chain::taints_unowned(&recv_graph.index),
             |pos| pos.is_confirmed(),
+            |_txout| false,
         );
     Ok(balance)
 }
index 1cf7a61a62afcd03b20c0995b469b566d716190c..27be1bcbcde09db33dcc9751deea60cc1d1ee7d4 100644 (file)
@@ -89,6 +89,7 @@ fn do_bench(indexed_tx_graph: &KeychainTxGraph, chain: &LocalChain) {
             op.into_iter().map(|(_, o)| o),
             bdk_chain::taints_unowned(&graph.index),
             |pos| pos.is_confirmed(),
+            |_txout| false,
         );
     assert_eq!(bal.total(), AMOUNT * TX_CT as u64);
 }
index a137e17d7f4df2a1491cbbe01e135077af2d01ab..e4e4674da6bf2c6c08ef9ca8a13f7778631bf23f 100644 (file)
@@ -21,15 +21,17 @@ fn make_chain() -> LocalChain {
     LocalChain::from_blocks(blocks).unwrap()
 }
 
-// The two predicates `classify_outpoints` takes, faking what a wallet would pass:
-// `does_taint` = the tx pulls in coins that aren't ours, `is_settled` = the tx is confirmed.
+// The three predicates `classify_outpoints` takes, faking what a wallet would pass:
+// `does_taint` = the tx pulls in coins that aren't ours; `is_settled` = the tx is confirmed;
+// `is_locked` = the tx has timelocked outputs.
 #[allow(clippy::type_complexity)]
-fn does_taint_and_is_settled<'a>(
+fn classify_outpoints_predicates<'a>(
     view: &'a CanonicalView<ConfirmationBlockTime>,
     owned: &ScriptBuf,
 ) -> (
     impl FnMut(&bdk_chain::CanonicalTx<ChainPosition<ConfirmationBlockTime>>) -> bool + 'a,
     impl Fn(&ChainPosition<ConfirmationBlockTime>) -> bool,
+    impl Fn(&bdk_chain::CanonicalTxOut<ChainPosition<ConfirmationBlockTime>>) -> bool,
 ) {
     let owned = owned.clone();
     let is_mine = move |spk: &Script| spk == owned.as_script();
@@ -47,7 +49,11 @@ fn does_taint_and_is_settled<'a>(
     };
     let is_settled =
         |pos: &ChainPosition<ConfirmationBlockTime>| matches!(pos, ChainPosition::Confirmed { .. });
-    (does_taint, is_settled)
+
+    let is_locked =
+        |_txout: &bdk_chain::CanonicalTxOut<ChainPosition<ConfirmationBlockTime>>| false;
+
+    (does_taint, is_settled, is_locked)
 }
 
 // Per-UTXO memoized classification (`classify_outpoints`).
@@ -57,8 +63,8 @@ fn run_classify(
     owned: &ScriptBuf,
 ) {
     let outpoints = utxo_txids.iter().map(|&txid| OutPoint::new(txid, 0));
-    let (does_taint, is_settled) = does_taint_and_is_settled(view, owned);
-    for item in view.classify_outpoints(outpoints, does_taint, is_settled) {
+    let (does_taint, is_settled, is_locked) = classify_outpoints_predicates(view, owned);
+    for item in view.classify_outpoints(outpoints, does_taint, is_settled, is_locked) {
         std::hint::black_box(item);
     }
 }
index 2d4dc9dbe325dec336737ae672766f6ccf2d8fd0..d062b93a4bd4b800f1b3d6971afbe468ba89b35f 100644 (file)
@@ -4,7 +4,8 @@ use bitcoin::Amount;
 #[derive(Debug, PartialEq, Eq, Clone, Default)]
 #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
 pub struct Balance {
-    /// All coinbase outputs not yet matured
+    /// Outputs not yet spendable by consensus (immature coinbase or unmet timelock), confirmed or
+    /// not.
     pub immature: Amount,
     /// Unconfirmed UTXOs generated by a wallet tx
     pub trusted_pending: Amount,
index 2f62b0f3e0707a56d1c75625f37ce6fd663dad4c..35ff02f972ce1ce6b903f7f14f2e89dc62a6985f 100644 (file)
@@ -40,7 +40,9 @@ pub enum Eligibility {
     /// [`classify_outpoints`](CanonicalView::classify_outpoints). Typically confirmed deeply
     /// enough to be unlikely to be replaced, but the caller decides.
     Settled,
-    /// A coinbase output that has not yet matured and is not spendable.
+    /// An output not yet spendable by consensus: an immature coinbase output, or one the
+    /// `is_locked` predicate reports as timelocked. May be unconfirmed; its [`Trust`] is not
+    /// evaluated.
     Immature,
     /// An output not yet settled.
     Unsettled(Trust),
@@ -428,7 +430,7 @@ impl<A: Anchor> CanonicalView<A> {
     /// This is the primitive behind [`balance`](Self::balance).
     ///  
     /// Callers that need richer handling (coin selection, coin control, or
-    /// wallet-specific categories like "locked") can fold over this instead of `balance`.
+    /// wallet-specific categories) can fold over this instead of `balance`.
     ///
     /// Outpoints that are already spent, or that aren't part of this canonical view, are skipped.
     ///
@@ -442,11 +444,16 @@ impl<A: Anchor> CanonicalView<A> {
     ///   [`taints_unowned`] to classify everything foreign as untrusted.
     /// * `is_settled` - Returns `true` for the [position](ChainPosition) of a transaction we
     ///   consider settled (unlikely to be replaced), for example one with enough confirmations.
+    /// * `is_locked` - Returns `true` for a txout whose timelock is not met yet, classifying it as
+    ///   [`Immature`](Eligibility::Immature). For an output confirmed
+    ///   [`transitively`](ChainPosition::Confirmed::transitively), the confirmation height is only
+    ///   an upper bound, so a relative timelock may look unmet when it is already met.
     pub fn classify_outpoints<'a>(
         &'a self,
         outpoints: impl IntoIterator<Item = OutPoint> + 'a,
         mut does_taint: impl FnMut(&CanonicalTx<ChainPosition<A>>) -> bool + 'a,
         is_settled: impl Fn(&ChainPosition<A>) -> bool + 'a,
+        is_locked: impl Fn(&CanonicalTxOut<ChainPosition<A>>) -> bool + 'a,
     ) -> impl Iterator<Item = (CanonicalTxOut<ChainPosition<A>>, Eligibility)> + 'a {
         let tip = self.tip.height;
         // Shared across outpoints so an ancestor reached by several of them is only walked once.
@@ -456,7 +463,7 @@ impl<A: Anchor> CanonicalView<A> {
             .filter_map(move |op| self.txout(op))
             .filter(|txo| txo.spent_by.is_none())
             .map(move |txout| {
-                let eligibility = if !txout.is_mature(tip) {
+                let eligibility = if !txout.is_mature(tip) || is_locked(&txout) {
                     Eligibility::Immature
                 } else if is_settled(&txout.pos) {
                     Eligibility::Settled
@@ -604,6 +611,7 @@ impl<A: Anchor> CanonicalView<A> {
     ///         pos.confirmation_height_upper_bound()
     ///             .is_some_and(|h| tip_height.saturating_sub(h).saturating_add(1) >= 6)
     ///     },
+    ///     |_txout| false,
     /// );
     /// ```
     pub fn balance(
@@ -611,8 +619,9 @@ impl<A: Anchor> CanonicalView<A> {
         outpoints: impl IntoIterator<Item = OutPoint>,
         does_taint: impl FnMut(&CanonicalTx<ChainPosition<A>>) -> bool,
         is_settled: impl Fn(&ChainPosition<A>) -> bool,
+        is_locked: impl Fn(&CanonicalTxOut<ChainPosition<A>>) -> bool,
     ) -> Balance {
-        self.classify_outpoints(outpoints, does_taint, is_settled)
+        self.classify_outpoints(outpoints, does_taint, is_settled, is_locked)
             .collect()
     }
 }
index 0f422c807476d0755f6b44049d68330c2f01a892..958c0daa33f422cd0dc058476f82d3502463163c 100644 (file)
@@ -98,6 +98,7 @@ fn test_is_settled_boundary() {
         [outpoint],
         |_tx| false, // leave trust to ancestry
         settled(tip_height, 1),
+        |_txout| false,
     );
 
     assert_eq!(balance_1_conf.confirmed, Amount::from_sat(50_000));
@@ -108,6 +109,7 @@ fn test_is_settled_boundary() {
         [outpoint],
         |_tx| false, // leave trust to ancestry
         settled(tip_height, 6),
+        |_txout| false,
     );
     assert_eq!(balance_6_conf.confirmed, Amount::from_sat(50_000));
     assert_eq!(balance_6_conf.trusted_pending, Amount::ZERO);
@@ -117,6 +119,7 @@ fn test_is_settled_boundary() {
         [outpoint],
         |_tx| false, // leave trust to ancestry
         settled(tip_height, 7),
+        |_txout| false,
     );
     assert_eq!(balance_7_conf.confirmed, Amount::ZERO);
     assert_eq!(balance_7_conf.trusted_pending, Amount::from_sat(50_000));
@@ -200,6 +203,7 @@ fn test_min_confirmations_with_untrusted_tx() {
         [outpoint],
         |_tx| true, // taint everything
         settled(tip_height, 5),
+        |_txout| false,
     );
 
     // Should be untrusted pending (not enough confirmations and not trusted)
@@ -212,6 +216,7 @@ fn test_min_confirmations_with_untrusted_tx() {
         [outpoint],
         |_tx| false, // leave trust to ancestry
         settled(tip_height, 5),
+        |_txout| false,
     );
     assert_eq!(balance.trusted_pending, Amount::from_sat(25_000));
     assert_eq!(balance.untrusted_pending, Amount::ZERO);
@@ -360,7 +365,12 @@ fn test_min_confirmations_multiple_transactions() {
     // tx0: 11 confirmations -> confirmed
     // tx1: 6 confirmations -> confirmed
     // tx2: 3 confirmations -> trusted pending
-    let balance = canonical_view.balance(outpoints.clone(), |_tx| false, settled(tip_height, 5));
+    let balance = canonical_view.balance(
+        outpoints.clone(),
+        |_tx| false,
+        settled(tip_height, 5),
+        |_txout| false,
+    );
 
     assert_eq!(
         balance.confirmed,
@@ -376,7 +386,12 @@ fn test_min_confirmations_multiple_transactions() {
     // tx0: 11 confirmations -> confirmed
     // tx1: 6 confirmations -> trusted pending
     // tx2: 3 confirmations -> trusted pending
-    let balance_high = canonical_view.balance(outpoints, |_tx| false, settled(tip_height, 10));
+    let balance_high = canonical_view.balance(
+        outpoints,
+        |_tx| false,
+        settled(tip_height, 10),
+        |_txout| false,
+    );
 
     assert_eq!(
         balance_high.confirmed,
@@ -498,6 +513,7 @@ fn test_balance_taint_propagates_through_unconfirmed_ancestry() {
                 .any(|txin| !owned.contains(&txin.previous_output))
         },
         |pos| pos.is_confirmed(),
+        |_txout| false,
     );
 
     assert_eq!(balance.confirmed, Amount::ZERO);
@@ -536,7 +552,7 @@ fn test_balance_is_settled_is_authoritative_for_unconfirmed() {
 
     // An `is_settled` that claims everything is settled counts the (mature, non-coinbase)
     // unconfirmed output as settled rather than dropping it, even when it is tainted.
-    let balance = view.balance([OutPoint::new(txid, 0)], |_| true, |_| true);
+    let balance = view.balance([OutPoint::new(txid, 0)], |_| true, |_| true, |_txout| false);
     assert_eq!(balance.confirmed, Amount::from_sat(50_000));
     assert_eq!(balance.immature, Amount::ZERO);
     assert_eq!(balance.trusted_pending, Amount::ZERO);
@@ -617,6 +633,7 @@ fn test_balance_taint_stops_at_settled_ancestor() {
                     .any(|txin| !owned.contains(&txin.previous_output))
             },
             |pos| pos.is_confirmed(),
+            |_txout| false,
         )
         .map(|(txout, eligibility)| (txout.outpoint, eligibility))
         .collect::<std::collections::HashMap<_, _>>();
@@ -685,7 +702,7 @@ fn test_classify_immature_and_settled() {
     ];
 
     let by_op = view
-        .classify_outpoints(ops, |_| false, |pos| pos.is_confirmed())
+        .classify_outpoints(ops, |_| false, |pos| pos.is_confirmed(), |_txout| false)
         .map(|(txout, eligibility)| (txout.outpoint, eligibility))
         .collect::<std::collections::HashMap<_, _>>();
     assert_eq!(
@@ -695,7 +712,7 @@ fn test_classify_immature_and_settled() {
     assert_eq!(by_op[&OutPoint::new(normal_txid, 0)], Eligibility::Settled);
 
     // The balance buckets reflect the same classification.
-    let balance = view.balance(ops, |_| false, |pos| pos.is_confirmed());
+    let balance = view.balance(ops, |_| false, |pos| pos.is_confirmed(), |_txout| false);
     assert_eq!(balance.immature, Amount::from_sat(50_000));
     assert_eq!(balance.confirmed, Amount::from_sat(30_000));
 }
@@ -787,6 +804,7 @@ fn test_balance_taint_shared_ancestor() {
                 .any(|txin| !owned.contains(&txin.previous_output))
         },
         |pos| pos.is_confirmed(),
+        |_txout| false,
     );
     assert_eq!(balance.trusted_pending, Amount::ZERO);
     assert_eq!(balance.untrusted_pending, Amount::from_sat(29_000 + 19_000));
@@ -842,6 +860,7 @@ fn test_classify_skips_spent_and_unknown() {
             ],
             |_| false,
             |pos| pos.is_confirmed(),
+            |_txout| false,
         )
         .collect::<Vec<_>>();
 
@@ -883,7 +902,12 @@ fn test_immature_coinbase_stays_immature_when_unsettled() {
 
     // Require 3 confirmations to be settled.
     let by_op = view
-        .classify_outpoints([OutPoint::new(txid, 0)], |_| false, settled(tip_height, 3))
+        .classify_outpoints(
+            [OutPoint::new(txid, 0)],
+            |_| false,
+            settled(tip_height, 3),
+            |_txout| false,
+        )
         .map(|(txout, e)| (txout.outpoint, e))
         .collect::<std::collections::HashMap<_, _>>();
 
@@ -924,7 +948,12 @@ fn test_mature_coinbase_is_settled_not_immature() {
     let tip_height = view.tip().height;
 
     let by_op = view
-        .classify_outpoints([OutPoint::new(txid, 0)], |_| false, settled(tip_height, 3))
+        .classify_outpoints(
+            [OutPoint::new(txid, 0)],
+            |_| false,
+            settled(tip_height, 3),
+            |_txout| false,
+        )
         .map(|(txout, e)| (txout.outpoint, e))
         .collect::<std::collections::HashMap<_, _>>();
 
@@ -979,6 +1008,7 @@ fn test_unsettled_unknown_when_parent_root_missing() {
             [OutPoint::new(child2_txid, 0)],
             |_| false,
             |pos| pos.is_confirmed(),
+            |_txout| false,
         )
         .map(|(txout, e)| (txout.outpoint, e))
         .collect::<std::collections::HashMap<_, _>>();
index 4ff40f99ce9b646afde66e872d7a182f5e97c3ec..c6e9443cec19d1309c5277ea121203cb047805df 100644 (file)
@@ -552,6 +552,7 @@ fn test_list_owned_txouts() {
                 graph.index.outpoints().iter().map(|(_, op)| *op),
                 bdk_chain::taints_unowned(&graph.index),
                 |pos| pos.is_confirmed(),
+                |_txout| false,
             );
 
             let confirmed_txouts_txid = txouts
index 107efeeb638d9e07e3dca515ed29da2bdc1dfd8e..2e2be72ff75e2d6a85bb1c1e72008a2a7ee6e4a5 100644 (file)
@@ -998,6 +998,7 @@ fn test_tx_conflict_handling() {
             env.indexer.outpoints().iter().map(|(_, op)| *op),
             bdk_chain::taints_unowned(&env.indexer),
             |pos| pos.is_confirmed(),
+            |_txout| false,
         );
         assert_eq!(
             balance, scenario.exp_balance,
index b12e23ca5ec791ba9834a71ba7f0524ae8a0ba15..bc40da974826e0a0524547d7c8d0c49a6f0cdce9 100644 (file)
@@ -66,6 +66,7 @@ fn get_balance(
             outpoints.into_iter().map(|(_, op)| op),
             bdk_chain::taints_unowned(&recv_graph.index),
             |pos| pos.is_confirmed(),
+            |_txout| false,
         );
     Ok(balance)
 }