From: Dmenec Date: Thu, 15 Oct 2026 14:22:56 +0000 (+0200) Subject: test(chain): cover timelocked outputs classified as `Immature` X-Git-Url: http://internal-gitweb-vhost/-client-interface/%22https:/enum.FileStoreError.html?a=commitdiff_plain;h=ba702bd73a3a318347cc7be7afea41a3a4ea50ed;p=bdk test(chain): cover timelocked outputs classified as `Immature` --- diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 958c0daa..ed5e4079 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,244 @@ 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 `Immature` and counted in +/// `Balance::immature` 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::Immature); + + let balance = view.balance(ops, |_| false, |pos| pos.is_confirmed(), |_| true); + assert_eq!(balance.immature, 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.immature, Amount::ZERO); +} + +/// An output behind OP_CSV or OP_CLTV is `Immature` while unconfirmed. Once confirmed, the CSV one +/// stays `Immature` until it has enough confirmations, and the CLTV one 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")), + (50, hash!("50")), + (100, hash!("100")), + ] + .into_iter() + .collect(); + let mut chain = LocalChain::from_blocks(blocks).unwrap(); + let mut tx_graph = TxGraph::::default(); + + let parent = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("ext"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(40_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(0) + }; + let parent_txid = parent.compute_txid(); + let _ = tx_graph.insert_tx(parent); + let _ = tx_graph.insert_anchor( + parent_txid, + ConfirmationBlockTime { + block_id: chain.get(50).unwrap().block_id(), + confirmation_time: 50, + }, + ); + + let tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(parent_txid, 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 csv_op = OutPoint::new(txid, 0); + let cltv_op = OutPoint::new(txid, 1); + + let _ = tx_graph.insert_tx(tx); + let _ = tx_graph.insert_seen_at(txid, 100); + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let tip = view.tip().height; + let unconfirmed = view + .classify_outpoints( + [csv_op, cltv_op], + |_| false, + |pos| pos.is_confirmed(), + |txout: &CanonicalTxOut<_>| is_locked(&wallet, tip, txout), + ) + .map(|(_, eligibility)| eligibility) + .collect::>(); + assert_eq!(unconfirmed, [Eligibility::Immature, Eligibility::Immature]); + + let _ = tx_graph.insert_anchor( + txid, + ConfirmationBlockTime { + block_id: chain.get(100).unwrap().block_id(), + confirmation_time: 100, + }, + ); + + chain.insert_block(101, hash!("101")).unwrap(); + + let test_cases = [ + (198, true, true), // tip 198 -> CSV Immature, CLTV Immature + (199, false, true), // tip 199 -> CSV Settled, CLTV Immature + (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::Immature; + let cltv_is_locked = cltv == Eligibility::Immature; + + 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 + ); + } +}