From: Dmenec Date: Thu, 15 Oct 2026 14:22:53 +0000 (+0200) Subject: feat(chain)!: classify timelocked outputs as `Immature` X-Git-Url: http://internal-gitweb-vhost/-script/src/example_cli/database/enum.AnyDatabase.html?a=commitdiff_plain;h=7d7e746e03960d3e21bf5afded40eacaeb77d569;p=bdk feat(chain)!: classify timelocked outputs as `Immature` 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`. --- diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index f1853cfa..1d04cb5d 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -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) } diff --git a/crates/chain/benches/indexer.rs b/crates/chain/benches/indexer.rs index 1cf7a61a..27be1bcb 100644 --- a/crates/chain/benches/indexer.rs +++ b/crates/chain/benches/indexer.rs @@ -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); } diff --git a/crates/chain/benches/trust_classification.rs b/crates/chain/benches/trust_classification.rs index a137e17d..e4e4674d 100644 --- a/crates/chain/benches/trust_classification.rs +++ b/crates/chain/benches/trust_classification.rs @@ -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, owned: &ScriptBuf, ) -> ( impl FnMut(&bdk_chain::CanonicalTx>) -> bool + 'a, impl Fn(&ChainPosition) -> bool, + impl Fn(&bdk_chain::CanonicalTxOut>) -> 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| matches!(pos, ChainPosition::Confirmed { .. }); - (does_taint, is_settled) + + let is_locked = + |_txout: &bdk_chain::CanonicalTxOut>| 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); } } diff --git a/crates/chain/src/balance.rs b/crates/chain/src/balance.rs index 2d4dc9db..d062b93a 100644 --- a/crates/chain/src/balance.rs +++ b/crates/chain/src/balance.rs @@ -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, diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index 2f62b0f3..35ff02f9 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -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 CanonicalView { /// 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 CanonicalView { /// [`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 + 'a, mut does_taint: impl FnMut(&CanonicalTx>) -> bool + 'a, is_settled: impl Fn(&ChainPosition) -> bool + 'a, + is_locked: impl Fn(&CanonicalTxOut>) -> bool + 'a, ) -> impl Iterator>, 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 CanonicalView { .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 CanonicalView { /// 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 CanonicalView { outpoints: impl IntoIterator, does_taint: impl FnMut(&CanonicalTx>) -> bool, is_settled: impl Fn(&ChainPosition) -> bool, + is_locked: impl Fn(&CanonicalTxOut>) -> bool, ) -> Balance { - self.classify_outpoints(outpoints, does_taint, is_settled) + self.classify_outpoints(outpoints, does_taint, is_settled, is_locked) .collect() } } diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 0f422c80..958c0daa 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -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::>(); @@ -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::>(); 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::>(); @@ -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::>(); @@ -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::>(); @@ -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::>(); diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index 4ff40f99..c6e9443c 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -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 diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index 107efeeb..2e2be72f 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -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, diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index b12e23ca..bc40da97 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -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) }