From cb66c7e323a20539e4af03b5f559e31b671a7b2f Mon Sep 17 00:00:00 2001 From: Dmenec Date: Thu, 3 Sep 2026 19:15:19 +0200 Subject: [PATCH 1/2] feat(chain)!: add `Locked` eligibility for timelocked outputs Add an `Eligibility::Locked` variant and a `Balance::locked` field, plus an `is_locked` predicate on `classify_outpoints`/`balance` so the caller marks confirmed outputs whose timelock has not matured yet. --- crates/bitcoind_rpc/tests/test_emitter.rs | 1 + crates/chain/benches/indexer.rs | 1 + crates/chain/benches/trust_classification.rs | 18 +++++--- crates/chain/src/balance.rs | 9 ++-- crates/chain/src/canonical.rs | 16 ++++++- crates/chain/tests/test_canonical_view.rs | 44 ++++++++++++++++--- crates/chain/tests/test_indexed_tx_graph.rs | 4 ++ crates/chain/tests/test_tx_graph_conflicts.rs | 1 + crates/electrum/tests/test_electrum.rs | 1 + 9 files changed, 77 insertions(+), 18 deletions(-) diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index f1853cfa44..1d04cb5ddf 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 1cf7a61a62..27be1bcbcd 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 a137e17d7f..e4e4674da6 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 2d4dc9dbe3..e5064b8067 100644 --- a/crates/chain/src/balance.rs +++ b/crates/chain/src/balance.rs @@ -12,6 +12,8 @@ pub struct Balance { pub untrusted_pending: Amount, /// Confirmed and immediately spendable balance pub confirmed: Amount, + /// Coins locked by some timelock + pub locked: Amount, } impl Balance { @@ -25,7 +27,7 @@ impl Balance { /// Get the whole balance visible to the wallet. pub fn total(&self) -> Amount { - self.confirmed + self.trusted_pending + self.untrusted_pending + self.immature + self.confirmed + self.trusted_pending + self.untrusted_pending + self.immature + self.locked } } @@ -33,8 +35,8 @@ impl core::fmt::Display for Balance { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!( f, - "{{ immature: {}, trusted_pending: {}, untrusted_pending: {}, confirmed: {} }}", - self.immature, self.trusted_pending, self.untrusted_pending, self.confirmed + "{{ immature: {}, trusted_pending: {}, untrusted_pending: {}, confirmed: {}, locked: {} }}", + self.immature, self.trusted_pending, self.untrusted_pending, self.confirmed, self.locked ) } } @@ -48,6 +50,7 @@ impl core::ops::Add for Balance { trusted_pending: self.trusted_pending + other.trusted_pending, untrusted_pending: self.untrusted_pending + other.untrusted_pending, confirmed: self.confirmed + other.confirmed, + locked: self.locked + other.locked, } } } diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index 20603c8dd8..fcc4c8ca59 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -44,6 +44,8 @@ pub enum Eligibility { Immature, /// An output not yet settled. Unsettled(Trust), + /// Locked outputs by a timelock. + Locked, } /// Describes whether an [`Unsettled`](Eligibility::Unsettled) output is trusted, untrusted, or of @@ -436,11 +438,13 @@ 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 locked by consensus. 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. @@ -453,7 +457,11 @@ impl CanonicalView { let eligibility = if !txout.is_mature(tip) { Eligibility::Immature } else if is_settled(&txout.pos) { - Eligibility::Settled + if is_locked(&txout) { + Eligibility::Locked + } else { + Eligibility::Settled + } } else { Eligibility::Unsettled(self.ancestry_trust( txout.outpoint.txid, @@ -462,6 +470,7 @@ impl CanonicalView { &mut cache, )) }; + (txout, eligibility) }) } @@ -590,6 +599,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( @@ -597,8 +607,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() } } @@ -617,6 +628,7 @@ impl FromIterator<(CanonicalTxOut>, Eligibility)> fo Eligibility::Unsettled(Trust::Untrusted | Trust::Unknown) => { &mut balance.untrusted_pending } + Eligibility::Locked => &mut balance.locked, }; *bucket += txout.txout.value; } diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 0f422c8074..958c0daa33 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 4ff40f99ce..88ab6555f2 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 @@ -684,6 +685,7 @@ fn test_list_owned_txouts() { trusted_pending: Amount::from_sat(25000), // tx3, tx5 untrusted_pending: Amount::from_sat(20000), // tx4 confirmed: Amount::from_sat(0), // tx2 got confirmed (but spent by 3) + locked: Amount::ZERO } ); } @@ -725,6 +727,7 @@ fn test_list_owned_txouts() { trusted_pending: Amount::from_sat(15000), // tx5 untrusted_pending: Amount::from_sat(20000), // tx4 confirmed: Amount::from_sat(10000), // tx3 got confirmed + locked: Amount::ZERO } ); } @@ -766,6 +769,7 @@ fn test_list_owned_txouts() { trusted_pending: Amount::from_sat(15000), // tx5 untrusted_pending: Amount::from_sat(20000), // tx4 confirmed: Amount::from_sat(10000), // tx3 is confirmed + locked: Amount::ZERO } ); } diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index 07760a8a1d..6724d462a8 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 d0632db422..6f130cb68b 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) } From 698d67a895723c51fef2fa339f7e7c727cfd1909 Mon Sep 17 00:00:00 2001 From: Dmenec Date: Mon, 21 Sep 2026 09:25:40 +0200 Subject: [PATCH 2/2] test(chain): cover Locked eligibility classification --- crates/chain/tests/test_canonical_view.rs | 218 +++++++++++++++++++++- 1 file changed, 214 insertions(+), 4 deletions(-) diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 958c0daa33..f21e43afd3 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -1,13 +1,19 @@ #![cfg(feature = "miniscript")] -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use bdk_chain::{ - local_chain::LocalChain, BlockId, ChainPosition, ConfirmationBlockTime, Eligibility, Trust, - TxGraph, + local_chain::LocalChain, BlockId, CanonicalTxOut, ChainPosition, ConfirmationBlockTime, + Eligibility, Trust, TxGraph, }; use bdk_testenv::{hash, utils::new_tx}; -use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; +use bitcoin::{ + key::Secp256k1, + opcodes::all::{OP_CLTV, OP_CSV}, + script::Instruction, + Amount, BlockHash, OutPoint, Script, ScriptBuf, Transaction, TxIn, TxOut, +}; +use miniscript::Descriptor; /// Builds an `is_settled` predicate requiring at least `min_confirmations` confirmations. fn settled( @@ -1062,3 +1068,207 @@ fn test_evicted_stale_anchored_tx_not_canonical() { "evicted leftover tx must not be canonical" ); } + +/// A settled output for which `is_locked` returns true is classified `Locked` and counted in +/// `Balance::locked` instead of `confirmed`. +#[test] +fn test_classify_locked() { + let blocks: BTreeMap = + [(0, hash!("g")), (1, hash!("tip"))].into_iter().collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); + let mut tx_graph = TxGraph::::default(); + let spk = ScriptBuf::new(); + + let tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("ext"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(40_000), + script_pubkey: spk.clone(), + }], + ..new_tx(0) + }; + let txid = tx.compute_txid(); + let _ = tx_graph.insert_tx(tx.clone()); + let _ = tx_graph.insert_anchor( + txid, + ConfirmationBlockTime { + block_id: chain.get(1).unwrap().block_id(), + confirmation_time: 100, + }, + ); + + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let ops = [OutPoint::new(txid, 0)]; + + // Timelock unmet + let (_, eligibility) = view + .classify_outpoints(ops, |_| false, |pos| pos.is_confirmed(), |_| true) + .next() + .unwrap(); + assert_eq!(eligibility, Eligibility::Locked); + + let balance = view.balance(ops, |_| false, |pos| pos.is_confirmed(), |_| true); + assert_eq!(balance.locked, Amount::from_sat(40_000)); + assert_eq!(balance.confirmed, Amount::ZERO); + + // Timelock met + let balance = view.balance(ops, |_| false, |pos| pos.is_confirmed(), |_| false); + assert_eq!(balance.confirmed, Amount::from_sat(40_000)); + assert_eq!(balance.locked, Amount::ZERO); + + let locked = view.balance(ops, |_| false, |pos| pos.is_confirmed(), |_| true); + assert_eq!((balance + locked).total(), Amount::from_sat(80_000)); +} + +/// A settled output behind OP_CSV is `Locked` until it has enough confirmations, and one behind +/// OP_CLTV until the tip reaches its lock height. +#[test] +fn test_classify_locked_script_timelocks() { + enum Timelock { + Relative(u32), + Absolute(u32), + } + + /// Reads the first ` OP_CSV` or ` OP_CLTV` pair in `script`. + fn read_timelock(script: &Script) -> Option { + let instructions = script.instructions().collect::, _>>().ok()?; + instructions.windows(2).find_map(|pair| { + let n = u32::try_from(pair[0].script_num()?).ok()?; + match &pair[1] { + Instruction::Op(op) if *op == OP_CSV => Some(Timelock::Relative(n)), + Instruction::Op(op) if *op == OP_CLTV => Some(Timelock::Absolute(n)), + _ => None, + } + }) + } + + /// Returns true if the UTXO is still locked by its timelock. + fn is_locked( + wallet: &HashMap, + tip: u32, + utxo: &CanonicalTxOut>, + ) -> bool { + let spk = &utxo.txout.script_pubkey; + let Some(script) = wallet.get(spk) else { + return false; + }; + let Some(timelock) = read_timelock(script) else { + return false; + }; + match timelock { + Timelock::Relative(n) => utxo.pos.confirmations_lower_bound(tip) < n, + Timelock::Absolute(h) => tip < h, + } + } + + let key = "tprv8ZgxMBicQKsPd3krDUsBAmtnRsK3rb8u5yi1zhQgMhF1tR8MW7xfE4rnrbbsrbPR52e7rKapu6ztw1jXveJSCGHEriUGZV7mCe88duLp5pj/0/*"; + let desc_csv = format!("wsh(and_v(v:pk({key}),older(100)))"); + let desc_cltv = format!("wsh(and_v(v:pk({key}),after(200)))"); + + let secp = Secp256k1::signing_only(); + + let (desc_csv, _) = Descriptor::parse_descriptor(&secp, &desc_csv).unwrap(); + let (desc_cltv, _) = Descriptor::parse_descriptor(&secp, &desc_cltv).unwrap(); + + let desc_csv = desc_csv.at_derivation_index(0).unwrap(); + let desc_cltv = desc_cltv.at_derivation_index(0).unwrap(); + + let wallet = HashMap::from([ + ( + desc_csv.script_pubkey(), + desc_csv.explicit_script().unwrap(), + ), + ( + desc_cltv.script_pubkey(), + desc_cltv.explicit_script().unwrap(), + ), + ]); + + let blocks: BTreeMap = [(0, hash!("genesis")), (100, hash!("100"))] + .into_iter() + .collect(); + let mut chain = LocalChain::from_blocks(blocks).unwrap(); + let mut tx_graph = TxGraph::::default(); + + let tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("ext"), 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: Amount::from_sat(10_000), + script_pubkey: desc_csv.script_pubkey(), + }, + TxOut { + value: Amount::from_sat(20_000), + script_pubkey: desc_cltv.script_pubkey(), + }, + ], + ..new_tx(0) + }; + + let txid: bitcoin::Txid = tx.compute_txid(); + let _ = tx_graph.insert_tx(tx); + let _ = tx_graph.insert_anchor( + txid, + ConfirmationBlockTime { + block_id: chain.get(100).unwrap().block_id(), + confirmation_time: 100, + }, + ); + + let csv_op = OutPoint::new(txid, 0); + let cltv_op = OutPoint::new(txid, 1); + + chain.insert_block(101, hash!("101")).unwrap(); + + let test_cases = [ + (198, true, true), // tip 198 -> CSV Locked, CLTV Locked + (199, false, true), // tip 199 -> CSV Settled, CLTV Locked + (200, false, false), // tip 200 -> CSV Settled, CLTV Settled + ]; + + for (tip_height, expect_csv_locked, expect_cltv_locked) in test_cases { + chain.insert_block(tip_height, hash!("tip")).unwrap(); + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let tip = view.tip().height; + + let (_, csv) = view + .classify_outpoints( + [csv_op], + |_| true, + |pos| pos.is_confirmed(), + |txout: &CanonicalTxOut<_>| is_locked(&wallet, tip, txout), + ) + .next() + .unwrap(); + + let (_, cltv) = view + .classify_outpoints( + [cltv_op], + |_| true, + |pos| pos.is_confirmed(), + |txout: &CanonicalTxOut<_>| is_locked(&wallet, tip, txout), + ) + .next() + .unwrap(); + + let csv_is_locked = csv == Eligibility::Locked; + let cltv_is_locked = cltv == Eligibility::Locked; + + assert_eq!( + csv_is_locked, expect_csv_locked, + "csv at tip {tip} expected: {}", + expect_csv_locked + ); + assert_eq!( + cltv_is_locked, expect_cltv_locked, + "cltv at tip {tip} expected: {}", + expect_cltv_locked + ); + } +}