From 85b311f8bd9f1416450662253569c49be5a35501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sat, 17 Jan 2026 18:20:04 +0800 Subject: [PATCH 1/7] fix!: Account for MTP in locktime calculations Correctly calculate spendability of coins that are timelocked by taking MTP into account. API changes are a result of the different requirements between relative and absolute locktimes. --- examples/anti_fee_sniping.rs | 2 +- examples/common.rs | 32 +++-- examples/synopsis.rs | 4 +- src/canonical_unspents.rs | 7 +- src/input.rs | 225 +++++++++++++++++++++++++---------- src/input_candidates.rs | 8 +- src/selection.rs | 4 +- 7 files changed, 198 insertions(+), 84 deletions(-) diff --git a/examples/anti_fee_sniping.rs b/examples/anti_fee_sniping.rs index 5609726..36ab3af 100644 --- a/examples/anti_fee_sniping.rs +++ b/examples/anti_fee_sniping.rs @@ -71,7 +71,7 @@ fn main() -> anyhow::Result<()> { let selection = wallet .all_candidates() .regroup(group_by_spk()) - .filter(filter_unspendable_now(tip_height, tip_time)) + .filter(filter_unspendable_now(tip_height, Some(tip_time))) .into_selection( selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), SelectorParams::new( diff --git a/examples/common.rs b/examples/common.rs index 4423bf6..e639906 100644 --- a/examples/common.rs +++ b/examples/common.rs @@ -6,7 +6,9 @@ use bdk_chain::{ }; use bdk_coin_select::{ChangePolicy, DrainWeights}; use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; -use bdk_tx::{CanonicalUnspents, Input, InputCandidates, RbfParams, TxStatus, TxWithStatus}; +use bdk_tx::{ + CanonicalUnspents, ConfirmationStatus, Input, InputCandidates, RbfParams, TxWithStatus, +}; use bitcoin::{absolute, Address, Amount, BlockHash, OutPoint, Transaction, TxOut, Txid}; use miniscript::{ plan::{Assets, Plan}, @@ -71,17 +73,22 @@ impl Wallet { ) } - /// TODO: Add to chain sources. + /// Info for the block at the tip. + /// + /// Returns a tuple of: + /// - Tip's height. I.e. `tip.height` + /// - Tip's MTP. I.e. `MTP(tip.height)` pub fn tip_info( &self, client: &impl RpcApi, ) -> anyhow::Result<(absolute::Height, absolute::Time)> { - let tip = self.chain.tip().block_id(); - let tip_info = client.get_block_header_info(&tip.hash)?; - let tip_height = absolute::Height::from_consensus(tip.height)?; - let tip_time = - absolute::Time::from_consensus(tip_info.median_time.unwrap_or(tip_info.time) as _)?; - Ok((tip_height, tip_time)) + let tip_hash = self.chain.tip().block_id().hash; + let tip_info = client.get_block_header_info(&tip_hash)?; + let tip_height = absolute::Height::from_consensus(tip_info.height as u32)?; + let tip_mtp = absolute::Time::from_consensus( + tip_info.median_time.expect("must have median time") as _, + )?; + Ok((tip_height, tip_mtp)) } // TODO: Maybe create an `AssetsBuilder` or `AssetsExt` that makes it easier to add @@ -112,15 +119,16 @@ impl Wallet { } pub fn canonical_txs(&self) -> impl Iterator>> + '_ { - pub fn status_from_position(pos: ChainPosition) -> Option { + pub fn status_from_position( + pos: ChainPosition, + ) -> Option { match pos { - bdk_chain::ChainPosition::Confirmed { anchor, .. } => Some(TxStatus { + bdk_chain::ChainPosition::Confirmed { anchor, .. } => Some(ConfirmationStatus { height: absolute::Height::from_consensus( anchor.confirmation_height_upper_bound(), ) .expect("must convert to height"), - time: absolute::Time::from_consensus(anchor.confirmation_time as _) - .expect("must convert from time"), + prev_mtp: None, // TODO: Use `CheckPoint::prev_mtp` }), bdk_chain::ChainPosition::Unconfirmed { .. } => None, } diff --git a/examples/synopsis.rs b/examples/synopsis.rs index 66d36fa..f8b37f5 100644 --- a/examples/synopsis.rs +++ b/examples/synopsis.rs @@ -39,7 +39,7 @@ fn main() -> anyhow::Result<()> { println!("Received {txid}"); println!("Balance (pending): {}", wallet.balance()); - let (tip_height, tip_time) = wallet.tip_info(env.rpc_client())?; + let (tip_height, tip_mtp) = wallet.tip_info(env.rpc_client())?; let longterm_feerate = FeeRate::from_sat_per_vb_unchecked(1); let recipient_addr = env @@ -51,7 +51,7 @@ fn main() -> anyhow::Result<()> { let selection = wallet .all_candidates() .regroup(group_by_spk()) - .filter(filter_unspendable_now(tip_height, tip_time)) + .filter(filter_unspendable_now(tip_height, Some(tip_mtp))) .into_selection( selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), SelectorParams::new( diff --git a/src/canonical_unspents.rs b/src/canonical_unspents.rs index 3b05afa..5cc1eb7 100644 --- a/src/canonical_unspents.rs +++ b/src/canonical_unspents.rs @@ -6,17 +6,18 @@ use bitcoin::{psbt, OutPoint, Sequence, Transaction, TxOut, Txid}; use miniscript::{bitcoin, plan::Plan}; use crate::{ - collections::HashMap, input::CoinbaseMismatch, FromPsbtInputError, Input, RbfSet, TxStatus, + collections::HashMap, input::CoinbaseMismatch, ConfirmationStatus, FromPsbtInputError, Input, + RbfSet, }; /// Tx with confirmation status. -pub type TxWithStatus = (T, Option); +pub type TxWithStatus = (T, Option); /// Our canonical view of unspent outputs. #[derive(Debug, Clone)] pub struct CanonicalUnspents { txs: HashMap>, - statuses: HashMap, + statuses: HashMap, spends: HashMap, } diff --git a/src/input.rs b/src/input.rs index 8018303..03bcc45 100644 --- a/src/input.rs +++ b/src/input.rs @@ -10,25 +10,24 @@ use miniscript::bitcoin; use miniscript::bitcoin::{OutPoint, Transaction, TxOut}; use miniscript::plan::Plan; -/// Confirmation status of a tx data. +/// Confirmation status of tx data. #[derive(Debug, Clone, Copy)] -pub struct TxStatus { +pub struct ConfirmationStatus { /// Confirmation block height. pub height: absolute::Height, - /// Confirmation block median time past. - /// - /// TODO: Currently BDK cannot fetch MTP time. We can pretend that the latest block time is the - /// MTP time for now. - pub time: absolute::Time, + /// Previous block's MTP (median time past) value as per BIP-0068, if available. + pub prev_mtp: Option, } -impl TxStatus { - /// From consensus `height` and `time`. - pub fn new(height: u32, time: u64) -> Result { +impl ConfirmationStatus { + /// From consensus `height` and `prev_mtp`. + /// + /// * `height` - Height of the block that the transaction is confirmed in. + /// * `prev_mtp` - The previous block's MTP value. I.e. MTP(`height` - 1). + pub fn new(height: u32, prev_mtp: Option) -> Result { Ok(Self { height: absolute::Height::from_consensus(height)?, - // TODO: handle `.try_into::()` - time: absolute::Time::from_consensus(time as _)?, + prev_mtp: prev_mtp.map(absolute::Time::from_consensus).transpose()?, }) } } @@ -191,7 +190,7 @@ pub struct Input { prev_txout: TxOut, prev_tx: Option>, plan: PlanOrPsbtInput, - status: Option, + status: Option, is_coinbase: bool, } @@ -206,7 +205,7 @@ impl Input { plan: Plan, prev_tx: T, output_index: usize, - status: Option, + status: Option, ) -> Result where T: Into>, @@ -228,7 +227,7 @@ impl Input { plan: Plan, prev_outpoint: OutPoint, prev_txout: TxOut, - status: Option, + status: Option, is_coinbase: bool, ) -> Self { Self { @@ -254,7 +253,7 @@ impl Input { sequence: Sequence, psbt_input: psbt::Input, satisfaction_weight: usize, - status: Option, + status: Option, is_coinbase: bool, ) -> Result { let outpoint = prev_outpoint; @@ -332,7 +331,7 @@ impl Input { } /// Confirmation status. - pub fn status(&self) -> Option { + pub fn status(&self) -> Option { self.status } @@ -341,17 +340,19 @@ impl Input { self.is_coinbase } - /// Whether prev output is an immature coinbase output and cannot be spent in the next block. + /// Whether prev output is an immature coinbase output. pub fn is_immature(&self, tip_height: absolute::Height) -> bool { if !self.is_coinbase { return false; } match self.status { Some(status) => { - let age = tip_height + let spending_height = tip_height .to_consensus_u32() - .saturating_sub(status.height.to_consensus_u32()); - age + 1 < COINBASE_MATURITY + .checked_add(1) + .expect("must not overflow"); + let age = spending_height.saturating_sub(status.height.to_consensus_u32()); + age < COINBASE_MATURITY } None => { debug_assert!(false, "coinbase should never be unconfirmed"); @@ -360,39 +361,98 @@ impl Input { } } - /// Whether the output is still locked by timelock constraints and cannot be spent in the - /// next block. - pub fn is_timelocked(&self, tip_height: absolute::Height, tip_time: absolute::Time) -> bool { - if let Some(locktime) = self.plan.absolute_timelock() { - if !locktime.is_satisfied_by(tip_height, tip_time) { - return true; + /// Whether this is locked by a block-based timelock (absolute or relative). + pub fn is_block_timelocked(&self, tip_height: absolute::Height) -> bool { + let spending_height = tip_height + .to_consensus_u32() + .checked_add(1) + .expect("must not overflow"); + if let Some(absolute::LockTime::Blocks(lt_height)) = self.plan.absolute_timelock() { + // Bitcoin Core's `IsFinalTx` uses strict less-than: a tx is final (unlocked) when + // `nLockTime < blockHeight`. This means `nLockTime = 100` is first spendable in + // block 101, not block 100. We return "locked" when the inverse is true. + return lt_height.to_consensus_u32() >= spending_height; + } + + match (self.plan.relative_timelock(), self.status) { + (Some(relative::LockTime::Blocks(lt_height)), Some(conf_status)) => { + // BIP 68: relative lock is satisfied when `height_diff >= lock_value`. + // We return "locked" when `lock_value > height_diff`. + let height_diff = + spending_height.saturating_sub(conf_status.height.to_consensus_u32()); + lt_height.to_consensus_u32() > height_diff } + // A block-timelocked output that is unconfirmed must be locked. + (Some(relative::LockTime::Blocks(_)), None) => true, + // No relative block-timelock. + _ => false, } - if let Some(locktime) = self.plan.relative_timelock() { - // TODO: Make sure this logic is right. - let (relative_height, relative_time) = match self.status { - Some(status) => { - let relative_height = tip_height - .to_consensus_u32() - .saturating_sub(status.height.to_consensus_u32()); - let relative_time = tip_time - .to_consensus_u32() - .saturating_sub(status.time.to_consensus_u32()); - ( - relative::Height::from_height( - relative_height.try_into().unwrap_or(u16::MAX), - ), - relative::Time::from_seconds_floor(relative_time) - .unwrap_or(relative::Time::MAX), - ) - } - None => (relative::Height::ZERO, relative::Time::ZERO), - }; - if !locktime.is_satisfied_by(relative_height, relative_time) { - return true; + } + + /// Whether this is locked by a time-based timelock (absolute or relative). + /// + /// Returns `None` if [`ConfirmationStatus::prev_mtp`] is required but unavailable. + /// + /// `tip_mtp` is `MTP(tip)`, or `MTP(spending_block - 1)`, as per BIP-0068. + pub fn is_time_timelocked(&self, tip_mtp: absolute::Time) -> Option { + if let Some(absolute::LockTime::Seconds(lt_time)) = self.plan.absolute_timelock() { + // Bitcoin Core's `IsFinalTx` (with BIP 113) uses strict less-than: a tx is final + // (unlocked) when `nLockTime < MTP`. This means `nLockTime = T` is first spendable + // when `MTP > T`, not when `MTP == T`. We return "locked" when the inverse is true. + return Some(lt_time.to_consensus_u32() >= tip_mtp.to_consensus_u32()); + } + + match (self.plan.relative_timelock(), self.status) { + (Some(relative::LockTime::Time(lt_time)), Some(conf_status)) => { + // BIP 68: relative time lock is satisfied when `time_diff >= lock_value * 512`. + // We return "locked" when `lock_value * 512 > time_diff`. + let time_diff = tip_mtp + .to_consensus_u32() + // If we are missing `prev_mtp`, we cannot determine whether the output is still + // locked. + .saturating_sub(conf_status.prev_mtp?.to_consensus_u32()); + Some(lt_time.value() as u32 * 512 > time_diff) } + // A time-timelocked output that is unconfirmed must be locked. + (Some(relative::LockTime::Time(_)), None) => Some(true), + // No relative time-timelock. + _ => Some(false), } - false + } + + /// Whether this is locked by any timelock constraint. + /// + /// Returns `None` if a time-based lock exists but `spending_mtp` is not provided or + /// [`ConfirmationStatus::prev_mtp`] is unavailable. + /// + /// `tip_mtp` is `MTP(tip)`, or `MTP(spending_block - 1)`, as per BIP-0068. + pub fn is_timelocked( + &self, + tip_height: absolute::Height, + tip_mtp: Option, + ) -> Option { + if self.is_block_timelocked(tip_height) { + return Some(true); + } + + let has_time_timelock = self + .plan + .absolute_timelock() + .is_some_and(|l| l.is_block_time()) + || self + .plan + .relative_timelock() + .is_some_and(|l| l.is_block_time()); + + if has_time_timelock { + if let Some(mtp) = tip_mtp { + return self.is_time_timelocked(mtp); + } + return None; + } + + // No timelock exists + Some(false) } /// Confirmations of this tx. @@ -404,9 +464,15 @@ impl Input { }) } - /// Whether this output can be spent now. - pub fn is_spendable_now(&self, tip_height: absolute::Height, tip_time: absolute::Time) -> bool { - !self.is_immature(tip_height) && !self.is_timelocked(tip_height, tip_time) + /// Whether this output can be spent at the given height and mtp time. + /// + /// `tip_mtp` is `MTP(tip)`, or `MTP(spending_block - 1)`, as per BIP-0068. + pub fn is_spendable( + &self, + tip_height: absolute::Height, + tip_mtp: Option, + ) -> Option { + Some(!self.is_immature(tip_height) && !self.is_timelocked(tip_height, tip_mtp)?) } /// Absolute timelock. @@ -482,23 +548,60 @@ impl InputGroup { self.0.push(input); } - /// Whether any contained inputs are immature. + /// Whether any contained input is immature. pub fn is_immature(&self, tip_height: absolute::Height) -> bool { self.0.iter().any(|input| input.is_immature(tip_height)) } - /// Whether any contained inputs are time locked. - pub fn is_timelocked(&self, tip_height: absolute::Height, tip_time: absolute::Time) -> bool { + /// Whether any contained input is locked by a block-based timelock (absolute or relative). + pub fn is_block_timelocked(&self, tip_height: absolute::Height) -> bool { self.0 .iter() - .any(|input| input.is_timelocked(tip_height, tip_time)) + .any(|input| input.is_block_timelocked(tip_height)) + } + + /// Whether any contained input is locked by a time-based timelock (absolute or relative). + /// + /// `tip_mtp` is `MTP(tip)`, or `MTP(spending_block - 1)`, as per BIP-0068. + pub fn is_time_timelocked(&self, tip_mtp: absolute::Time) -> Option { + for input in &self.0 { + if input.is_time_timelocked(tip_mtp)? { + return Some(true); + } + } + Some(false) + } + + /// Whether any contained input is locked by any timelock constraint. + /// + /// `tip_mtp` is `MTP(tip)`, or `MTP(spending_block - 1)`, as per BIP-0068. + pub fn is_timelocked( + &self, + tip_height: absolute::Height, + tip_mtp: Option, + ) -> Option { + for input in &self.0 { + if input.is_timelocked(tip_height, tip_mtp)? { + return Some(true); + } + } + Some(false) } /// Whether all contained inputs are spendable now. - pub fn is_spendable_now(&self, tip_height: absolute::Height, tip_time: absolute::Time) -> bool { - self.0 - .iter() - .all(|input| input.is_spendable_now(tip_height, tip_time)) + /// + /// `tip_mtp` is `MTP(tip)`, or `MTP(spending_block - 1)`, as per BIP-0068. + pub fn is_spendable( + &self, + tip_height: absolute::Height, + tip_mtp: Option, + ) -> Option { + for input in &self.0 { + if !input.is_spendable(tip_height, tip_mtp)? { + return Some(false); + } + } + Some(true) } /// Returns the tx confirmation count this is the smallest in this group. diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 8e5b5c1..76f61a1 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -315,11 +315,13 @@ pub fn group_by_spk() -> impl Fn(&Input) -> bitcoin::ScriptBuf { } /// Filter out inputs that cannot be spent now. +/// +/// If an input's spendability cannot be determined, it will also be filtered out. pub fn filter_unspendable_now( - tip_height: absolute::Height, - tip_time: absolute::Time, + spend_height: absolute::Height, + spend_mtp: Option, ) -> impl Fn(&Input) -> bool { - move |input| input.is_spendable_now(tip_height, tip_time) + move |input| input.is_spendable(spend_height, spend_mtp).unwrap_or(false) } /// No filtering. diff --git a/src/selection.rs b/src/selection.rs index daf07ee..962c686 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -410,9 +410,9 @@ mod tests { }], }; - let status = crate::TxStatus { + let status = crate::ConfirmationStatus { height: absolute::Height::from_consensus(confirmation_height)?, - time: Time::from_consensus(500_000_000)?, + prev_mtp: Some(Time::from_consensus(500_000_000)?), }; let input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; From a52322de09dc1f637d3225e2664cd33f22be4bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sat, 31 Jan 2026 06:35:30 +0000 Subject: [PATCH 2/7] Update to miniscript 13.0.0 and adapt to bdk API changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump miniscript dependency from 12.3.5 to 13.0.0 - Remove `Signer` wrapper type (KeyMap now implements GetKey) - Adapt to bdk_chain CanonicalView API and CheckPoint
generics - Update Emitter usage for new mempool API (update/evicted fields) - Use prev_mtp from CheckPoint for ConfirmationStatus - Point [patch.crates-io] to feature/bitcoind_rpc_emit_header branch 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Cargo.toml | 14 +- examples/anti_fee_sniping.rs | 23 +- examples/common.rs | 110 +++-- examples/synopsis.rs | 34 +- src/finalizer.rs | 5 +- src/lib.rs | 2 - src/signer.rs | 204 --------- tests/timelock.rs | 843 +++++++++++++++++++++++++++++++++++ 8 files changed, 973 insertions(+), 262 deletions(-) delete mode 100644 src/signer.rs create mode 100644 tests/timelock.rs diff --git a/Cargo.toml b/Cargo.toml index 6016fde..0dd42ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ license = "MIT OR Apache-2.0" readme = "README.md" [dependencies] -miniscript = { version = "12.3.5", default-features = false } +miniscript = { version = "13.0.0", default-features = false } bdk_coin_select = "0.4.0" rand_core = { version = "0.6.4", default-features = false } rand = { version = "0.8", optional = true } @@ -21,7 +21,7 @@ anyhow = "1" bdk_tx = { path = "." } bitcoin = { version = "0.32", default-features = false, features = ["rand-std"] } bdk_testenv = "0.13.0" -bdk_bitcoind_rpc = "0.20.0" +bdk_bitcoind_rpc = "0.22.0" bdk_chain = { version = "0.23.0" } [features] @@ -37,3 +37,13 @@ crate-type = ["lib"] [[example]] name = "anti_fee_sniping" + +[patch.crates-io] +bdk_testenv = { git = "https://github.com/evanlinjin/bdk", branch = "feature/bitcoind_rpc_emit_header" } +bdk_chain = { git = "https://github.com/evanlinjin/bdk", branch = "feature/bitcoind_rpc_emit_header" } +bdk_core = { git = "https://github.com/evanlinjin/bdk", branch = "feature/bitcoind_rpc_emit_header" } +bdk_bitcoind_rpc = { git = "https://github.com/evanlinjin/bdk", branch = "feature/bitcoind_rpc_emit_header" } + +# bdk_chain = { git = "https://github.com/bitcoindevkit/bdk", branch = "master" } +# bdk_core = { git = "https://github.com/bitcoindevkit/bdk", branch = "master" } +# bdk_bitcoind_rpc = { git = "https://github.com/bitcoindevkit/bdk", branch = "master"} diff --git a/examples/anti_fee_sniping.rs b/examples/anti_fee_sniping.rs index 36ab3af..ca121f5 100644 --- a/examples/anti_fee_sniping.rs +++ b/examples/anti_fee_sniping.rs @@ -1,5 +1,5 @@ #![allow(dead_code)] -use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; +use bdk_testenv::TestEnv; use bdk_tx::{ filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, FeeStrategy, Output, PsbtParams, ScriptSource, SelectorParams, @@ -11,16 +11,30 @@ mod common; use common::Wallet; +fn old_rpc_client(env: &TestEnv) -> anyhow::Result { + Ok(bdk_bitcoind_rpc::bitcoincore_rpc::Client::new( + &env.bitcoind.rpc_url(), + bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile( + (&env.bitcoind.params).cookie_file.clone(), + ), + )?) +} + fn main() -> anyhow::Result<()> { let secp = Secp256k1::new(); let (external, _) = Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[0])?; let (internal, _) = Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[1])?; let env = TestEnv::new()?; + let old_client = old_rpc_client(&env)?; let genesis_hash = env.genesis_hash()?; + let genesis = env + .rpc_client() + .get_block_header(&genesis_hash)? + .block_header()?; env.mine_blocks(101, None)?; - let mut wallet = Wallet::new(genesis_hash, external, internal.clone())?; + let mut wallet = Wallet::new(genesis, external, internal.clone())?; wallet.sync(&env)?; let addr = wallet.next_address().expect("must derive address"); @@ -37,13 +51,16 @@ fn main() -> anyhow::Result<()> { println!("Balance (confirmed): {}", wallet.balance()); - let (tip_height, tip_time) = wallet.tip_info(env.rpc_client())?; + // let params = env.bitcoind.params; + + let (tip_height, tip_time) = wallet.tip_info(&old_client)?; println!("Current height: {}", tip_height); let longterm_feerate = FeeRate::from_sat_per_vb_unchecked(1); let recipient_addr = env .rpc_client() .get_new_address(None, None)? + .address()? .assume_checked(); // When anti-fee-sniping is enabled, the transaction will either use nLockTime or nSequence. diff --git a/examples/common.rs b/examples/common.rs index e639906..309b534 100644 --- a/examples/common.rs +++ b/examples/common.rs @@ -1,15 +1,20 @@ -use std::sync::Arc; +use std::{fmt::Debug, sync::Arc}; -use bdk_bitcoind_rpc::{Emitter, NO_EXPECTED_MEMPOOL_TXIDS}; +use bdk_bitcoind_rpc::{bitcoincore_rpc::RpcApi, Emitter, NO_EXPECTED_MEMPOOL_TXS}; use bdk_chain::{ - bdk_core, Anchor, Balance, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, + Anchor, Balance, BlockId, CanonicalView, CanonicalizationParams, ChainPosition, CheckPoint, + ToBlockHash, ToBlockTime, }; use bdk_coin_select::{ChangePolicy, DrainWeights}; -use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; +use bdk_testenv::TestEnv; use bdk_tx::{ CanonicalUnspents, ConfirmationStatus, Input, InputCandidates, RbfParams, TxWithStatus, }; -use bitcoin::{absolute, Address, Amount, BlockHash, OutPoint, Transaction, TxOut, Txid}; +use bitcoin::{ + absolute::{self, Time}, + block::Header, + Address, Amount, OutPoint, Transaction, TxOut, Txid, +}; use miniscript::{ plan::{Assets, Plan}, Descriptor, DescriptorPublicKey, ForEachKey, @@ -19,16 +24,26 @@ const EXTERNAL: &str = "external"; const INTERNAL: &str = "internal"; pub struct Wallet { - pub chain: bdk_chain::local_chain::LocalChain, + pub chain: bdk_chain::local_chain::LocalChain
, pub graph: bdk_chain::IndexedTxGraph< - bdk_core::ConfirmationBlockTime, + BlockId, bdk_chain::keychain_txout::KeychainTxOutIndex<&'static str>, >, + pub view: CanonicalView, +} + +fn old_rpc_client(env: &TestEnv) -> anyhow::Result { + Ok(bdk_bitcoind_rpc::bitcoincore_rpc::Client::new( + &env.bitcoind.rpc_url(), + bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile( + (&env.bitcoind.params).cookie_file.clone(), + ), + )?) } impl Wallet { pub fn new( - genesis_hash: BlockHash, + genesis_header: Header, external: Descriptor, internal: Descriptor, ) -> anyhow::Result { @@ -36,14 +51,20 @@ impl Wallet { indexer.insert_descriptor(EXTERNAL, external)?; indexer.insert_descriptor(INTERNAL, internal)?; let graph = bdk_chain::IndexedTxGraph::new(indexer); - let (chain, _) = bdk_chain::local_chain::LocalChain::from_genesis_hash(genesis_hash); - Ok(Self { chain, graph }) + let (chain, _) = bdk_chain::local_chain::LocalChain::from_genesis(genesis_header); + let view = graph.canonical_view( + &chain, + chain.tip().block_id(), + CanonicalizationParams::default(), + ); + Ok(Self { chain, graph, view }) } pub fn sync(&mut self, env: &TestEnv) -> anyhow::Result<()> { - let client = env.rpc_client(); + let client = old_rpc_client(env)?; + // let client = env.rpc_client(); let last_cp = self.chain.tip(); - let mut emitter = Emitter::new(client, last_cp, 0, NO_EXPECTED_MEMPOOL_TXIDS); + let mut emitter = Emitter::new(&client, last_cp, 0, NO_EXPECTED_MEMPOOL_TXS); while let Some(event) = emitter.next_block()? { let _ = self .graph @@ -51,9 +72,13 @@ impl Wallet { let _ = self.chain.apply_update(event.checkpoint); } let mempool = emitter.mempool()?; - let _ = self - .graph - .batch_insert_relevant_unconfirmed(mempool.new_txs); + let _ = self.graph.batch_insert_relevant_unconfirmed(mempool.update); + let _ = self.graph.batch_insert_relevant_evicted_at(mempool.evicted); + self.view = self.graph.canonical_view( + &self.chain, + self.chain.tip().block_id(), + CanonicalizationParams::default(), + ); Ok(()) } @@ -64,13 +89,7 @@ impl Wallet { pub fn balance(&self) -> Balance { let outpoints = self.graph.index.outpoints().clone(); - self.graph.graph().balance( - &self.chain, - self.chain.tip().block_id(), - CanonicalizationParams::default(), - outpoints, - |_, _| true, - ) + self.view.balance(outpoints, |_, _| true, 0) } /// Info for the block at the tip. @@ -119,28 +138,39 @@ impl Wallet { } pub fn canonical_txs(&self) -> impl Iterator>> + '_ { - pub fn status_from_position( - pos: ChainPosition, - ) -> Option { + pub fn status_from_position( + cp_tip: CheckPoint, + pos: ChainPosition, + ) -> Option + where + D: ToBlockHash + ToBlockTime + Clone + Debug, + { match pos { - bdk_chain::ChainPosition::Confirmed { anchor, .. } => Some(ConfirmationStatus { - height: absolute::Height::from_consensus( - anchor.confirmation_height_upper_bound(), - ) - .expect("must convert to height"), - prev_mtp: None, // TODO: Use `CheckPoint::prev_mtp` - }), + bdk_chain::ChainPosition::Confirmed { anchor, .. } => { + let cp = cp_tip.get(anchor.height)?; + if cp.hash() != anchor.hash { + // TODO: This should only happen if anchor is transitive. + return None; + } + let prev_mtp = cp + .prev() + .and_then(|prev_cp| prev_cp.median_time_past()) + .map(|time| Time::from_consensus(time).expect("must convert!")); + + Some(ConfirmationStatus { + height: absolute::Height::from_consensus( + anchor.confirmation_height_upper_bound(), + ) + .expect("must convert to height"), + prev_mtp, + }) + } bdk_chain::ChainPosition::Unconfirmed { .. } => None, } } - self.graph - .graph() - .list_canonical_txs( - &self.chain, - self.chain.tip().block_id(), - CanonicalizationParams::default(), - ) - .map(|c_tx| (c_tx.tx_node.tx, status_from_position(c_tx.chain_position))) + self.view + .txs() + .map(|c_tx| (c_tx.tx, status_from_position(self.chain.tip(), c_tx.pos))) } /// Computes the weight of a change output plus the future weight to spend it. diff --git a/examples/synopsis.rs b/examples/synopsis.rs index f8b37f5..1a9eb7f 100644 --- a/examples/synopsis.rs +++ b/examples/synopsis.rs @@ -1,15 +1,24 @@ -use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; +use bdk_testenv::TestEnv; use bdk_tx::{ filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, FeeStrategy, Output, - PsbtParams, ScriptSource, SelectorParams, Signer, + PsbtParams, ScriptSource, SelectorParams, }; use bitcoin::{key::Secp256k1, Amount, FeeRate, Sequence}; -use miniscript::Descriptor; +use miniscript::{descriptor::KeyMap, Descriptor}; mod common; use common::Wallet; +fn old_rpc_client(env: &TestEnv) -> anyhow::Result { + Ok(bdk_bitcoind_rpc::bitcoincore_rpc::Client::new( + &env.bitcoind.rpc_url(), + bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile( + (&env.bitcoind.params).cookie_file.clone(), + ), + )?) +} + fn main() -> anyhow::Result<()> { let secp = Secp256k1::new(); let (external, external_keymap) = @@ -17,13 +26,21 @@ fn main() -> anyhow::Result<()> { let (internal, internal_keymap) = Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[4])?; - let signer = Signer(external_keymap.into_iter().chain(internal_keymap).collect()); + let mut signer = KeyMap::new(); + signer.extend(external_keymap); + signer.extend(internal_keymap); let env = TestEnv::new()?; + let client = old_rpc_client(&env)?; + let genesis_hash = env.genesis_hash()?; + let genesis_header = env + .rpc_client() + .get_block_header(&genesis_hash)? + .block_header()?; env.mine_blocks(101, None)?; - let mut wallet = Wallet::new(genesis_hash, external, internal.clone())?; + let mut wallet = Wallet::new(genesis_header, external, internal.clone())?; wallet.sync(&env)?; let addr = wallet.next_address().expect("must derive address"); @@ -39,12 +56,13 @@ fn main() -> anyhow::Result<()> { println!("Received {txid}"); println!("Balance (pending): {}", wallet.balance()); - let (tip_height, tip_mtp) = wallet.tip_info(env.rpc_client())?; + let (tip_height, tip_mtp) = wallet.tip_info(&client)?; let longterm_feerate = FeeRate::from_sat_per_vb_unchecked(1); let recipient_addr = env .rpc_client() .get_new_address(None, None)? + .address()? .assume_checked(); // Okay now create tx. @@ -87,7 +105,7 @@ fn main() -> anyhow::Result<()> { ); // We will try bump this tx fee. - let txid = env.rpc_client().send_raw_transaction(&tx)?; + let txid = env.rpc_client().send_raw_transaction(&tx)?.txid()?; println!("tx broadcasted: {txid}"); wallet.sync(&env)?; println!("Balance (send tx): {}", wallet.balance()); @@ -173,7 +191,7 @@ fn main() -> anyhow::Result<()> { fee, ((fee.to_sat() as f32) / (tx.weight().to_vbytes_ceil() as f32)), ); - let txid = env.rpc_client().send_raw_transaction(&tx)?; + let txid = env.rpc_client().send_raw_transaction(&tx)?.txid()?; println!("tx broadcasted: {txid}"); wallet.sync(&env)?; println!("Balance (RBF): {}", wallet.balance()); diff --git a/src/finalizer.rs b/src/finalizer.rs index c3bf328..2f4829a 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -26,14 +26,13 @@ use miniscript::{bitcoin, plan::Plan, psbt::PsbtInputSatisfier}; /// ```rust,no_run /// # use bdk_tx::PsbtParams; /// # let secp = bitcoin::secp256k1::Secp256k1::new(); -/// # let keymap = std::collections::BTreeMap::new(); +/// # let keymap = miniscript::descriptor::KeyMap::new(); /// # let selection = bdk_tx::Selection { inputs: vec![], outputs: vec![] }; /// // Create PSBT from a selection of inputs and outputs. /// let mut psbt = selection.create_psbt(PsbtParams::default())?; /// /// // Sign the PSBT using your preferred method. -/// let signer = bdk_tx::Signer(keymap); -/// let _ = psbt.sign(&signer, &secp); +/// let _ = psbt.sign(&keymap, &secp); /// /// // Finalize the PSBT. /// let finalizer = selection.into_finalizer(); diff --git a/src/lib.rs b/src/lib.rs index ebfa713..828f1bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,6 @@ mod output; mod rbf; mod selection; mod selector; -mod signer; mod utils; pub use canonical_unspents::*; @@ -30,7 +29,6 @@ pub use output::*; pub use rbf::*; pub use selection::*; pub use selector::*; -pub use signer::*; use utils::*; #[cfg(feature = "std")] diff --git a/src/signer.rs b/src/signer.rs deleted file mode 100644 index f28aefe..0000000 --- a/src/signer.rs +++ /dev/null @@ -1,204 +0,0 @@ -use alloc::collections::BTreeMap; -use alloc::string::ToString; -use alloc::vec::Vec; - -use bitcoin::{ - psbt::{GetKey, GetKeyError, KeyRequest}, - secp256k1::{self, Secp256k1}, -}; -use miniscript::bitcoin; -use miniscript::descriptor::{DescriptorSecretKey, KeyMap}; - -/// A PSBT signer -/// -/// This is a simple wrapper type around miniscript [`KeyMap`] that implements [`GetKey`]. -#[derive(Debug, Clone)] -pub struct Signer(pub KeyMap); - -impl GetKey for Signer { - type Error = GetKeyError; - - fn get_key( - &self, - key_request: KeyRequest, - secp: &Secp256k1, - ) -> Result, Self::Error> { - for entry in &self.0 { - match entry { - (_, DescriptorSecretKey::Single(prv)) => { - let map: BTreeMap<_, _> = - core::iter::once((prv.key.public_key(secp), prv.key)).collect(); - if let Ok(Some(prv)) = GetKey::get_key(&map, key_request.clone(), secp) { - return Ok(Some(prv)); - } - } - (_, desc_sk) => { - for desc_sk in desc_sk.clone().into_single_keys() { - if let KeyRequest::Bip32((fingerprint, derivation)) = &key_request { - if let DescriptorSecretKey::XPrv(k) = desc_sk { - // We have the xprv for the request - if let Ok(Some(prv)) = - GetKey::get_key(&k.xkey, key_request.clone(), secp) - { - return Ok(Some(prv)); - } - // The key origin is a strict prefix of the request derivation - if let Some((fp, path)) = &k.origin { - if fingerprint == fp - && derivation.to_string().starts_with(&path.to_string()) - { - let to_derive = derivation - .into_iter() - .skip(path.len()) - .cloned() - .collect::>(); - let derived = k.xkey.derive_priv(secp, &to_derive)?; - return Ok(Some(derived.to_priv())); - } - } - } - } - } - } - } - } - Ok(None) - } -} - -#[cfg(test)] -mod test { - use crate::bitcoin::bip32::ChildNumber; - use core::str::FromStr; - use std::string::String; - - use bitcoin::bip32::{DerivationPath, Xpriv}; - use miniscript::Descriptor; - - use super::*; - - #[test] - fn get_key_pubkey() -> anyhow::Result<()> { - let secp = Secp256k1::new(); - let wif = "cU6BxEezV8FnkEPBCaFtc4WNuUKmgFaAu6sJErB154GXgMUjhgWe"; - let prv = bitcoin::PrivateKey::from_wif(wif)?; - let pk = prv.public_key(&secp); - - let s = format!("wpkh({wif})"); - let (_, keymap) = Descriptor::parse_descriptor(&secp, &s).unwrap(); - - let signer = Signer(keymap); - let req = KeyRequest::Pubkey(pk); - let res = signer.get_key(req, &secp); - assert!(matches!( - res, - Ok(Some(k)) if k == prv - )); - - Ok(()) - } - - #[test] - fn get_key_x_only_pubkey() -> anyhow::Result<()> { - let secp = Secp256k1::new(); - let wif = "cU6BxEezV8FnkEPBCaFtc4WNuUKmgFaAu6sJErB154GXgMUjhgWe"; - let prv = bitcoin::PrivateKey::from_wif(wif)?; - let (x_only_pk, _parity) = prv.inner.x_only_public_key(&secp); - - let s = format!("wpkh({wif})"); - let (_, keymap) = Descriptor::parse_descriptor(&secp, &s).unwrap(); - - let signer = Signer(keymap); - let req = KeyRequest::XOnlyPubkey(x_only_pk); - let res = signer.get_key(req, &secp); - assert!(matches!( - res, - Ok(Some(k)) if k.inner.x_only_public_key(&secp).0 == x_only_pk - )); - Ok(()) - } - - // Test `Signer` can fulfill a bip32 KeyRequest if we know the key origin - #[test] - fn get_key_bip32() -> anyhow::Result<()> { - let secp = Secp256k1::new(); - - // master xprv - let xprv: Xpriv = "tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L".parse()?; - let fp = xprv.fingerprint(&secp); - let path: DerivationPath = "86h/1h/0h".parse()?; - let derived = xprv.derive_priv(&secp, &path)?; - - struct TestCase { - name: &'static str, - desc: String, - derivation: String, - } - - let cases = vec![ - TestCase { - name: "key matches request fingerprint", - desc: format!("tr({xprv}/{path}/0/*)"), - derivation: format!("{path}/0/7"), - }, - TestCase { - name: "key is derivable from request derivation", - desc: format!("tr([{fp}/{path}]{derived}/0/*)"), - derivation: format!("{path}/0/7"), - }, - TestCase { - name: "key origin matches request derivation", - desc: format!("tr([{fp}/{path}]{derived}/0/*)"), - derivation: path.to_string(), - }, - ]; - - for test in cases { - let deriv: DerivationPath = test.derivation.parse()?; - let exp_prv = xprv.derive_priv(&secp, &deriv)?.to_priv(); - let request = KeyRequest::Bip32((fp, deriv)); - - let (_, keymap) = Descriptor::parse_descriptor(&secp, &test.desc)?; - let signer = Signer(keymap); - let res = signer.get_key(request, &secp); - assert!( - matches!(res, Ok(Some(k)) if k == exp_prv), - "test case failed: {}", - test.name - ); - } - - Ok(()) - } - - #[test] - fn get_key_xpriv_with_key_origin() -> anyhow::Result<()> { - let secp = Secp256k1::new(); - let s = "wpkh([d34db33f/84h/1h/0h]tprv8ZgxMBicQKsPd3EupYiPRhaMooHKUHJxNsTfYuScep13go8QFfHdtkG9nRkFGb7busX4isf6X9dURGCoKgitaApQ6MupRhZMcELAxTBRJgS/*)"; - let (_, keymap) = Descriptor::parse_descriptor(&secp, s)?; - - let desc_sk = DescriptorSecretKey::from_str("[d34db33f/84h/1h/0h]tprv8ZgxMBicQKsPd3EupYiPRhaMooHKUHJxNsTfYuScep13go8QFfHdtkG9nRkFGb7busX4isf6X9dURGCoKgitaApQ6MupRhZMcELAxTBRJgS/*")?; - let desc_xkey = match desc_sk { - DescriptorSecretKey::XPrv(k) => k, - _ => panic!(), - }; - - let (fp, _) = desc_xkey.origin.clone().unwrap(); - let path = DerivationPath::from_str("84h/1h/0h/7")?; - let req = KeyRequest::Bip32((fp, path)); - - let exp_prv = desc_xkey - .xkey - .derive_priv(&secp, &[ChildNumber::from(7)])? - .to_priv(); - - let res = Signer(keymap).get_key(req, &secp); - - assert!(matches!( - res, - Ok(Some(k)) if k == exp_prv, - )); - - Ok(()) - } -} diff --git a/tests/timelock.rs b/tests/timelock.rs new file mode 100644 index 0000000..c9c2a0f --- /dev/null +++ b/tests/timelock.rs @@ -0,0 +1,843 @@ +//! Integration tests for timelock functionality against Bitcoin Core. +//! +//! These tests verify that the `is_timelocked`, `is_block_timelocked`, `is_time_timelocked`, +//! and `is_spendable` methods correctly predict when transactions can be broadcast. + +use std::{fmt::Debug, sync::Arc}; + +use bdk_bitcoind_rpc::{bitcoincore_rpc::RpcApi, Emitter, NO_EXPECTED_MEMPOOL_TXS}; +use bdk_chain::{ + miniscript::ForEachKey, Anchor, Balance, BlockId, CanonicalizationParams, ChainPosition, + ToBlockHash, ToBlockTime, +}; +use bdk_coin_select::ChangePolicy; +use bdk_testenv::TestEnv; +use bdk_tx::{ + filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, CanonicalUnspents, + ConfirmationStatus, FeeStrategy, Input, InputCandidates, Output, PsbtParams, ScriptSource, + SelectorParams, TxWithStatus, +}; +use bitcoin::{ + absolute::{self, Time}, + block::Header, + key::Secp256k1, + relative, transaction, Address, Amount, FeeRate, OutPoint, Sequence, Transaction, TxIn, TxOut, +}; +use miniscript::{ + descriptor::KeyMap, + plan::{Assets, Plan}, + Descriptor, DescriptorPublicKey, +}; + +const EXTERNAL: &str = "external"; +const INTERNAL: &str = "internal"; + +// Test xprv for creating timelocked descriptors +const TEST_XPRV: &str = "tprv8ZgxMBicQKsPd3krDUsBAmtnRsK3rb8u5yi1zhQgMhF1tR8MW7xfE4rnrbbsrbPR52e7rKapu6ztw1jXveJSCGHEriUGZV7mCe88duLp5pj"; + +/// A minimal wallet for testing timelocks. +struct TestWallet { + chain: bdk_chain::local_chain::LocalChain
, + graph: bdk_chain::IndexedTxGraph< + BlockId, + bdk_chain::keychain_txout::KeychainTxOutIndex<&'static str>, + >, + view: bdk_chain::CanonicalView, + signer: KeyMap, + secp: bitcoin::secp256k1::Secp256k1, +} + +fn old_rpc_client(env: &TestEnv) -> anyhow::Result { + Ok(bdk_bitcoind_rpc::bitcoincore_rpc::Client::new( + &env.bitcoind.rpc_url(), + bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile( + (&env.bitcoind.params).cookie_file.clone(), + ), + )?) +} + +impl TestWallet { + fn new( + genesis_header: Header, + external: Descriptor, + internal: Descriptor, + keymap: KeyMap, + ) -> anyhow::Result { + let mut indexer = bdk_chain::keychain_txout::KeychainTxOutIndex::default(); + indexer.insert_descriptor(EXTERNAL, external)?; + indexer.insert_descriptor(INTERNAL, internal)?; + let graph = bdk_chain::IndexedTxGraph::new(indexer); + let (chain, _) = bdk_chain::local_chain::LocalChain::from_genesis(genesis_header); + let view = graph.canonical_view( + &chain, + chain.tip().block_id(), + CanonicalizationParams::default(), + ); + Ok(Self { + chain, + graph, + view, + signer: keymap, + secp: Secp256k1::new(), + }) + } + + fn sync(&mut self, env: &TestEnv) -> anyhow::Result<()> { + let client = old_rpc_client(env)?; + let last_cp = self.chain.tip(); + let mut emitter = Emitter::new(&client, last_cp, 0, NO_EXPECTED_MEMPOOL_TXS); + while let Some(event) = emitter.next_block()? { + let _ = self + .graph + .apply_block_relevant(&event.block, event.block_height()); + let _ = self.chain.apply_update(event.checkpoint); + } + let mempool = emitter.mempool()?; + let _ = self.graph.batch_insert_relevant_unconfirmed(mempool.update); + let _ = self.graph.batch_insert_relevant_evicted_at(mempool.evicted); + self.view = self.graph.canonical_view( + &self.chain, + self.chain.tip().block_id(), + CanonicalizationParams::default(), + ); + Ok(()) + } + + fn next_address(&mut self) -> Option
{ + let ((_, spk), _) = self.graph.index.next_unused_spk(EXTERNAL)?; + Address::from_script(&spk, bitcoin::consensus::Params::REGTEST).ok() + } + + fn balance(&self) -> Balance { + self.view + .balance(self.graph.index.outpoints().clone(), |_, _| true, 0) + } + + fn tip_height(&self) -> u32 { + self.chain.tip().block_id().height + } + + fn tip_info(&self, client: &impl RpcApi) -> anyhow::Result<(absolute::Height, absolute::Time)> { + let tip_hash = self.chain.tip().block_id().hash; + let tip_info = client.get_block_header_info(&tip_hash)?; + let tip_height = absolute::Height::from_consensus(tip_info.height as u32)?; + let tip_mtp = absolute::Time::from_consensus( + tip_info.median_time.expect("must have median time") as _, + )?; + Ok((tip_height, tip_mtp)) + } + + fn assets(&self) -> Assets { + let index = &self.graph.index; + let tip = self.chain.tip().block_id(); + Assets::new() + .after(absolute::LockTime::from_height(tip.height).expect("must be valid height")) + .add({ + let mut pks = vec![]; + for (_, desc) in index.keychains() { + desc.for_any_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + } + pks + }) + } + + fn plan_of_output(&self, outpoint: OutPoint, assets: &Assets) -> Option { + let index = &self.graph.index; + let ((k, i), _txout) = index.txout(outpoint)?; + let desc = index.get_descriptor(k)?.at_derivation_index(i).ok()?; + desc.plan(assets).ok() + } + + fn canonical_txs(&self) -> impl Iterator>> + '_ { + pub fn status_from_position( + cp_tip: bdk_chain::CheckPoint, + pos: ChainPosition, + ) -> Option + where + D: ToBlockHash + ToBlockTime + Clone + Debug, + { + match pos { + bdk_chain::ChainPosition::Confirmed { anchor, .. } => { + let cp = cp_tip.get(anchor.height)?; + if cp.hash() != anchor.hash { + // TODO: This should only happen if anchor is transitive. + return None; + } + let prev_mtp = cp + .prev() + .and_then(|prev_cp| prev_cp.median_time_past()) + .map(|time| Time::from_consensus(time).expect("must convert!")); + + Some(ConfirmationStatus { + height: absolute::Height::from_consensus( + anchor.confirmation_height_upper_bound(), + ) + .expect("must convert to height"), + prev_mtp, + }) + } + bdk_chain::ChainPosition::Unconfirmed { .. } => None, + } + } + self.view + .txs() + .map(|c_tx| (c_tx.tx, status_from_position(self.chain.tip(), c_tx.pos))) + } + + fn drain_weights(&self) -> bdk_coin_select::DrainWeights { + let desc = self + .graph + .index + .get_descriptor(INTERNAL) + .unwrap() + .at_derivation_index(0) + .unwrap(); + + let output_weight = TxOut { + script_pubkey: desc.script_pubkey(), + value: Amount::ZERO, + } + .weight() + .to_wu(); + + let assets = self.assets(); + let plan = desc.plan(&assets).expect("failed to create Plan"); + let spend_weight = + bitcoin::TxIn::default().segwit_weight().to_wu() + plan.satisfaction_weight() as u64; + + bdk_coin_select::DrainWeights { + output_weight, + spend_weight, + n_outputs: 1, + } + } + + fn change_policy(&self) -> ChangePolicy { + let spk_0 = self + .graph + .index + .spk_at_index(INTERNAL, 0) + .expect("spk should exist"); + ChangePolicy { + min_value: spk_0.minimal_non_dust().to_sat(), + drain_weights: self.drain_weights(), + } + } + + fn all_candidates(&self, assets: &Assets) -> InputCandidates { + let index = &self.graph.index; + let canon_utxos = CanonicalUnspents::new(self.canonical_txs()); + let can_select = canon_utxos.try_get_unspents( + index + .outpoints() + .iter() + .filter_map(|(_, op)| Some((*op, self.plan_of_output(*op, assets)?))), + ); + InputCandidates::new([], can_select) + } +} + +/// Test absolute block-height timelock checking logic. +/// +/// This test verifies that `is_block_timelocked` and `is_spendable` correctly +/// identify when an input with an absolute block height timelock can be spent. +#[test] +fn test_absolute_block_height_timelock_logic() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + + // Create a timelocked descriptor + let lock_height = 110u32; + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/*),after({lock_height})))"); + let (external, external_keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + + let (internal, internal_keymap) = + Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[4])?; + + let keymap = { + let mut keymap = KeyMap::new(); + keymap.extend(external_keymap); + keymap.extend(internal_keymap); + keymap + }; + + let env = TestEnv::new()?; + let client = old_rpc_client(&env)?; + + let genesis_hash = env.genesis_hash()?; + let genesis_header = env + .rpc_client() + .get_block_header(&genesis_hash)? + .block_header()?; + + env.mine_blocks(101, None)?; + + let mut wallet = TestWallet::new(genesis_header, external, internal, keymap)?; + wallet.sync(&env)?; + + // Fund the wallet + let addr = wallet.next_address().expect("must derive address"); + env.send(&addr, Amount::ONE_BTC)?; + env.mine_blocks(1, None)?; + wallet.sync(&env)?; + + assert!(wallet.balance().confirmed > Amount::ZERO); + + let current_height = wallet.tip_height(); + println!("Current height: {current_height}, lock height: {lock_height}"); + assert!( + current_height < lock_height, + "test setup: should be below lock height" + ); + + // Create assets with the lock height requirement + let abs_lock = absolute::LockTime::from_height(lock_height)?; + let assets = Assets::new().after(abs_lock).add({ + let mut pks = vec![]; + for (_, desc) in wallet.graph.index.keychains() { + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + } + pks + }); + + // Get the input + let (tip_height, tip_mtp) = wallet.tip_info(&client)?; + let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); + let inputs: Vec = wallet + .graph + .index + .outpoints() + .iter() + .filter_map(|(_, op)| { + let plan = wallet.plan_of_output(*op, &assets)?; + canon_utxos.try_get_unspent(*op, plan) + }) + .collect(); + + assert!(!inputs.is_empty(), "should have at least one input"); + let input = &inputs[0]; + + // Verify the input has an absolute timelock + assert!( + input.absolute_timelock().is_some(), + "input should have absolute timelock" + ); + println!("Input absolute timelock: {:?}", input.absolute_timelock()); + + // BEFORE lock height: should be locked + assert!( + input.is_block_timelocked(tip_height), + "should be block-timelocked at height {} (lock: {})", + tip_height.to_consensus_u32(), + lock_height + ); + assert_eq!( + input.is_spendable(tip_height, Some(tip_mtp)), + Some(false), + "should not be spendable before lock height" + ); + + // Mine to reach lock height + let blocks_to_mine = lock_height.saturating_sub(current_height) + 1; + env.mine_blocks(blocks_to_mine as usize, None)?; + wallet.sync(&env)?; + + let (new_tip_height, new_tip_mtp) = wallet.tip_info(&client)?; + println!("New height: {}", new_tip_height.to_consensus_u32()); + + // Refresh input + let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); + let inputs: Vec = wallet + .graph + .index + .outpoints() + .iter() + .filter_map(|(_, op)| { + let plan = wallet.plan_of_output(*op, &assets)?; + canon_utxos.try_get_unspent(*op, plan) + }) + .collect(); + + let input = &inputs[0]; + + // AFTER lock height: should NOT be locked + assert!( + !input.is_block_timelocked(new_tip_height), + "should NOT be block-timelocked at height {} (lock: {})", + new_tip_height.to_consensus_u32(), + lock_height + ); + assert_eq!( + input.is_spendable(new_tip_height, Some(new_tip_mtp)), + Some(true), + "should be spendable after lock height" + ); + + Ok(()) +} + +/// Test relative block-height timelock checking logic. +/// +/// This test verifies that `is_block_timelocked` and `is_spendable` correctly +/// identify when an input with a relative block timelock (CSV) can be spent. +#[test] +fn test_relative_block_height_timelock_logic() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + + // Create a descriptor with relative timelock + let relative_lock_blocks = 5u16; + let desc_str = + format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/*),older({relative_lock_blocks})))"); + let (external, external_keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + + let (internal, internal_keymap) = + Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[4])?; + + let keymap = { + let mut keymap = KeyMap::new(); + keymap.extend(external_keymap); + keymap.extend(internal_keymap); + keymap + }; + + let env = TestEnv::new()?; + let client = old_rpc_client(&env)?; + + let genesis_hash = env.genesis_hash()?; + let genesis_header = env + .rpc_client() + .get_block_header(&genesis_hash)? + .block_header()?; + + env.mine_blocks(101, None)?; + + let mut wallet = TestWallet::new(genesis_header, external, internal, keymap)?; + wallet.sync(&env)?; + + // Fund the wallet + let addr = wallet.next_address().expect("must derive address"); + let funding_txid = env.send(&addr, Amount::ONE_BTC)?; + env.mine_blocks(1, None)?; + wallet.sync(&env)?; + + let confirmation_height = wallet.tip_height(); + println!("Funding tx {funding_txid} confirmed at height {confirmation_height}"); + + assert!(wallet.balance().confirmed > Amount::ZERO); + + // Create assets with relative timelock requirement + let rel_lock = relative::LockTime::from_height(relative_lock_blocks); + let assets = Assets::new() + .after(absolute::LockTime::from_height(wallet.tip_height()).expect("must be valid height")) + .older(rel_lock) + .add({ + let mut pks = vec![]; + for (_, desc) in wallet.graph.index.keychains() { + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + } + pks + }); + + // Get the input + let (tip_height, tip_mtp) = wallet.tip_info(&client)?; + let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); + let inputs: Vec = wallet + .graph + .index + .outpoints() + .iter() + .filter_map(|(_, op)| { + let plan = wallet.plan_of_output(*op, &assets)?; + canon_utxos.try_get_unspent(*op, plan) + }) + .collect(); + + assert!(!inputs.is_empty(), "should have at least one input"); + let input = &inputs[0]; + + // Verify the input has a relative timelock + assert!( + input.relative_timelock().is_some(), + "input should have relative timelock" + ); + println!("Input relative timelock: {:?}", input.relative_timelock()); + println!( + "Input confirmed at height: {:?}", + input.status().map(|s| s.height.to_consensus_u32()) + ); + + // IMMEDIATELY after confirmation: should be locked + assert!( + input.is_block_timelocked(tip_height), + "should be block-timelocked immediately after confirmation" + ); + assert_eq!( + input.is_spendable(tip_height, Some(tip_mtp)), + Some(false), + "should not be spendable immediately after confirmation" + ); + + // Mine blocks to satisfy relative timelock + env.mine_blocks(relative_lock_blocks as usize, None)?; + wallet.sync(&env)?; + + let (new_tip_height, new_tip_mtp) = wallet.tip_info(&client)?; + let blocks_since_confirm = new_tip_height.to_consensus_u32() - confirmation_height + 1; + println!( + "New height: {}, blocks since confirmation: {}", + new_tip_height.to_consensus_u32(), + blocks_since_confirm + ); + + // Refresh input + let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); + let inputs: Vec = wallet + .graph + .index + .outpoints() + .iter() + .filter_map(|(_, op)| { + let plan = wallet.plan_of_output(*op, &assets)?; + canon_utxos.try_get_unspent(*op, plan) + }) + .collect(); + + let input = &inputs[0]; + + // AFTER relative lock: should NOT be locked + assert!( + !input.is_block_timelocked(new_tip_height), + "should NOT be block-timelocked after {} blocks", + blocks_since_confirm + ); + assert_eq!( + input.is_spendable(new_tip_height, Some(new_tip_mtp)), + Some(true), + "should be spendable after relative lock expires" + ); + + Ok(()) +} + +/// Test coinbase maturity (100 blocks required). +/// +/// This test verifies the full flow: maturity checking AND actual broadcast. +#[test] +fn test_coinbase_maturity() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + + let (external, external_keymap) = + Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[3])?; + let (internal, internal_keymap) = + Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[4])?; + + let keymap = { + let mut km = KeyMap::new(); + km.extend(external_keymap); + km.extend(internal_keymap); + km + }; + + let env = TestEnv::new()?; + let client = old_rpc_client(&env)?; + + let genesis_hash = env.genesis_hash()?; + let genesis_header = env + .rpc_client() + .get_block_header(&genesis_hash)? + .block_header()?; + + // Only mine a few blocks initially + env.mine_blocks(10, None)?; + + let mut wallet = TestWallet::new(genesis_header, external, internal.clone(), keymap)?; + wallet.sync(&env)?; + + // Get wallet address and mine a block to it (creates coinbase output) + let addr = wallet.next_address().expect("must derive address"); + env.mine_blocks(1, Some(addr.clone()))?; + wallet.sync(&env)?; + + let confirmation_height = wallet.tip_height(); + println!("Coinbase at height {confirmation_height}"); + + // Get the coinbase input + let (tip_height, tip_mtp) = wallet.tip_info(&client)?; + let assets = wallet.assets(); + let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); + let inputs: Vec = wallet + .graph + .index + .outpoints() + .iter() + .filter_map(|(_, op)| { + let plan = wallet.plan_of_output(*op, &assets)?; + canon_utxos.try_get_unspent(*op, plan) + }) + .collect(); + + // Find the coinbase input + let coinbase_input = inputs.iter().find(|i| i.is_coinbase()); + assert!(coinbase_input.is_some(), "should have coinbase input"); + let input = coinbase_input.unwrap(); + + // Check immaturity + let is_immature = input.is_immature(tip_height); + println!( + "At height {} (0 blocks after coinbase), is_immature: {}", + tip_height.to_consensus_u32(), + is_immature + ); + assert!(is_immature, "coinbase should be immature"); + + // Verify is_spendable returns false + let is_spendable = input.is_spendable(tip_height, Some(tip_mtp)); + assert_eq!( + is_spendable, + Some(false), + "immature coinbase should not be spendable" + ); + + // Mine 99 more blocks (total 100 for maturity) + env.mine_blocks(99, None)?; + wallet.sync(&env)?; + + let (tip_height, tip_mtp) = wallet.tip_info(&client)?; + println!( + "After 99 more blocks, tip height: {}", + tip_height.to_consensus_u32() + ); + + // Refresh input + let assets = wallet.assets(); + let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); + let inputs: Vec = wallet + .graph + .index + .outpoints() + .iter() + .filter_map(|(_, op)| { + let plan = wallet.plan_of_output(*op, &assets)?; + canon_utxos.try_get_unspent(*op, plan) + }) + .collect(); + + let coinbase_input = inputs.iter().find(|i| i.is_coinbase()).unwrap(); + + let is_immature = coinbase_input.is_immature(tip_height); + let is_spendable = coinbase_input.is_spendable(tip_height, Some(tip_mtp)); + println!( + "At height {}: is_immature={}, is_spendable={:?}", + tip_height.to_consensus_u32(), + is_immature, + is_spendable + ); + + assert!(!is_immature, "coinbase should be mature after 100 blocks"); + assert_eq!( + is_spendable, + Some(true), + "mature coinbase should be spendable" + ); + + // Verify we can actually broadcast + let recipient_addr = env + .rpc_client() + .get_new_address(None, None)? + .address()? + .assume_checked(); + + let selection = wallet + .all_candidates(&assets) + .regroup(group_by_spk()) + .filter(filter_unspendable_now(tip_height, Some(tip_mtp))) + .into_selection( + selection_algorithm_lowest_fee_bnb(FeeRate::from_sat_per_vb_unchecked(1), 100_000), + SelectorParams::new( + FeeStrategy::FeeRate(FeeRate::from_sat_per_vb_unchecked(10)), + vec![Output::with_script( + recipient_addr.script_pubkey(), + Amount::from_sat(10_000), + )], + ScriptSource::Descriptor(Box::new(internal.at_derivation_index(0)?)), + wallet.change_policy(), + ), + )?; + + let mut psbt = selection.create_psbt(PsbtParams { + fallback_sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + })?; + let finalizer = selection.into_finalizer(); + + let _ = psbt.sign(&wallet.signer, &wallet.secp); + let res = finalizer.finalize(&mut psbt); + assert!(res.is_finalized(), "should finalize"); + + let tx = psbt.extract_tx()?; + let txid = env.rpc_client().send_raw_transaction(&tx)?.txid()?; + println!("Mature coinbase spent: {txid}"); + + Ok(()) +} + +/// Unit test for `is_block_timelocked` using directly constructed Input. +/// +/// This test creates Input objects directly to test the timelock checking logic +/// without needing a full wallet setup. +#[test] +fn test_is_block_timelocked_unit() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + + // Create a simple timelocked descriptor + let lock_height = 100u32; + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_height})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + // Create a dummy transaction to get an outpoint + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + // Create assets for planning - must include the lock height and keys + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_height(lock_height)?) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + + // Create input without confirmation status (unconfirmed) + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + // Verify the input has the expected absolute timelock + assert_eq!( + input.absolute_timelock(), + Some(absolute::LockTime::from_height(lock_height)?) + ); + + // Test at various heights + let below_lock = absolute::Height::from_consensus(lock_height - 10)?; + let at_lock = absolute::Height::from_consensus(lock_height - 1)?; // spending_height = lock_height + let above_lock = absolute::Height::from_consensus(lock_height + 10)?; + + // Below lock height: should be timelocked + assert!( + input.is_block_timelocked(below_lock), + "should be timelocked at height {} (lock: {})", + below_lock.to_consensus_u32(), + lock_height + ); + + // At lock height (spending_height = tip + 1 = lock_height): should NOT be timelocked + assert!( + !input.is_block_timelocked(at_lock), + "should NOT be timelocked when spending_height = {} (lock: {})", + at_lock.to_consensus_u32() + 1, + lock_height + ); + + // Above lock height: should NOT be timelocked + assert!( + !input.is_block_timelocked(above_lock), + "should NOT be timelocked at height {} (lock: {})", + above_lock.to_consensus_u32(), + lock_height + ); + + Ok(()) +} + +/// Unit test for relative timelock checking. +#[test] +fn test_is_block_timelocked_relative_unit() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + + // Create a descriptor with relative timelock + let rel_blocks = 10u16; + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({rel_blocks})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + // Create assets with keys and timelocks + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_height(200)?) + .older(relative::LockTime::from_height(rel_blocks)) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + + // Confirmed at height 100 + let conf_height = 100u32; + let status = ConfirmationStatus::new(conf_height, None)?; + + let input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; + + // Verify the input has the expected relative timelock + assert_eq!( + input.relative_timelock(), + Some(relative::LockTime::from_height(rel_blocks)) + ); + + // Test at various heights relative to confirmation + // spending_height = tip_height + 1 + // height_diff = spending_height - conf_height + + // 5 blocks after confirmation: height_diff = 6 < 10, should be locked + let tip_5_after = absolute::Height::from_consensus(conf_height + 4)?; + assert!( + input.is_block_timelocked(tip_5_after), + "should be timelocked 5 blocks after confirmation (need {})", + rel_blocks + ); + + // 10 blocks after confirmation: height_diff = 11 >= 10, should NOT be locked + let tip_10_after = absolute::Height::from_consensus(conf_height + 9)?; + assert!( + !input.is_block_timelocked(tip_10_after), + "should NOT be timelocked 10 blocks after confirmation" + ); + + // 15 blocks after confirmation: should NOT be locked + let tip_15_after = absolute::Height::from_consensus(conf_height + 14)?; + assert!( + !input.is_block_timelocked(tip_15_after), + "should NOT be timelocked 15 blocks after confirmation" + ); + + Ok(()) +} From 322a013d22ea0c41d3491fc8819060899433ce66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sun, 1 Feb 2026 09:21:54 +0000 Subject: [PATCH 3/7] refactor: Unify Wallet/TestWallet into bdk_tx_testenv workspace crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert repo to a Cargo workspace with a new unpublished bdk_tx_testenv crate under testenv/ - Merge the duplicated Wallet (examples/common.rs) and TestWallet (tests/timelock.rs) into a single Wallet struct with signer and secp fields always present - Deduplicate old_rpc_client() helper (was copied in 4 files) - Add all_candidates_with(&Assets) method for custom asset parameters - Reduce examples/common.rs to a thin re-export shim 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Cargo.toml | 4 + examples/anti_fee_sniping.rs | 35 ++-- examples/common.rs | 264 +---------------------------- examples/synopsis.rs | 46 ++--- testenv/Cargo.toml | 15 ++ testenv/src/lib.rs | 313 +++++++++++++++++++++++++++++++++++ tests/timelock.rs | 307 ++++------------------------------ 7 files changed, 392 insertions(+), 592 deletions(-) create mode 100644 testenv/Cargo.toml create mode 100644 testenv/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 0dd42ab..20d46ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = [".", "testenv"] + [package] name = "bdk_tx" version = "0.1.0" @@ -19,6 +22,7 @@ rand = { version = "0.8", optional = true } [dev-dependencies] anyhow = "1" bdk_tx = { path = "." } +bdk_tx_testenv = { path = "testenv" } bitcoin = { version = "0.32", default-features = false, features = ["rand-std"] } bdk_testenv = "0.13.0" bdk_bitcoind_rpc = "0.22.0" diff --git a/examples/anti_fee_sniping.rs b/examples/anti_fee_sniping.rs index ca121f5..3726760 100644 --- a/examples/anti_fee_sniping.rs +++ b/examples/anti_fee_sniping.rs @@ -4,29 +4,16 @@ use bdk_tx::{ filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, FeeStrategy, Output, PsbtParams, ScriptSource, SelectorParams, }; -use bitcoin::{absolute::LockTime, key::Secp256k1, Amount, FeeRate, Sequence}; -use miniscript::Descriptor; +use bdk_tx_testenv::TestEnvExt; +use bitcoin::{absolute::LockTime, Amount, FeeRate, Sequence}; mod common; -use common::Wallet; - -fn old_rpc_client(env: &TestEnv) -> anyhow::Result { - Ok(bdk_bitcoind_rpc::bitcoincore_rpc::Client::new( - &env.bitcoind.rpc_url(), - bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile( - (&env.bitcoind.params).cookie_file.clone(), - ), - )?) -} +use common::{Wallet, EXTERNAL, INTERNAL}; fn main() -> anyhow::Result<()> { - let secp = Secp256k1::new(); - let (external, _) = Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[0])?; - let (internal, _) = Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[1])?; - let env = TestEnv::new()?; - let old_client = old_rpc_client(&env)?; + let old_client = env.old_rpc_client()?; let genesis_hash = env.genesis_hash()?; let genesis = env .rpc_client() @@ -34,10 +21,16 @@ fn main() -> anyhow::Result<()> { .block_header()?; env.mine_blocks(101, None)?; - let mut wallet = Wallet::new(genesis, external, internal.clone())?; + let mut wallet = Wallet::multi_keychain( + genesis, + [ + (EXTERNAL, bdk_testenv::utils::DESCRIPTORS[0]), + (INTERNAL, bdk_testenv::utils::DESCRIPTORS[1]), + ], + )?; wallet.sync(&env)?; - let addr = wallet.next_address().expect("must derive address"); + let addr = wallet.next_address(EXTERNAL).expect("must derive address"); let txid1 = env.send(&addr, Amount::ONE_BTC)?; env.mine_blocks(1, None)?; @@ -51,8 +44,6 @@ fn main() -> anyhow::Result<()> { println!("Balance (confirmed): {}", wallet.balance()); - // let params = env.bitcoind.params; - let (tip_height, tip_time) = wallet.tip_info(&old_client)?; println!("Current height: {}", tip_height); let longterm_feerate = FeeRate::from_sat_per_vb_unchecked(1); @@ -97,7 +88,7 @@ fn main() -> anyhow::Result<()> { recipient_addr.script_pubkey(), Amount::from_sat(50_000_000), )], - ScriptSource::Descriptor(Box::new(internal.at_derivation_index(0)?)), + ScriptSource::Descriptor(Box::new(wallet.definite_descriptor(INTERNAL, 0)?)), wallet.change_policy(), ), )?; diff --git a/examples/common.rs b/examples/common.rs index 309b534..ebdfbd1 100644 --- a/examples/common.rs +++ b/examples/common.rs @@ -1,263 +1 @@ -use std::{fmt::Debug, sync::Arc}; - -use bdk_bitcoind_rpc::{bitcoincore_rpc::RpcApi, Emitter, NO_EXPECTED_MEMPOOL_TXS}; -use bdk_chain::{ - Anchor, Balance, BlockId, CanonicalView, CanonicalizationParams, ChainPosition, CheckPoint, - ToBlockHash, ToBlockTime, -}; -use bdk_coin_select::{ChangePolicy, DrainWeights}; -use bdk_testenv::TestEnv; -use bdk_tx::{ - CanonicalUnspents, ConfirmationStatus, Input, InputCandidates, RbfParams, TxWithStatus, -}; -use bitcoin::{ - absolute::{self, Time}, - block::Header, - Address, Amount, OutPoint, Transaction, TxOut, Txid, -}; -use miniscript::{ - plan::{Assets, Plan}, - Descriptor, DescriptorPublicKey, ForEachKey, -}; - -const EXTERNAL: &str = "external"; -const INTERNAL: &str = "internal"; - -pub struct Wallet { - pub chain: bdk_chain::local_chain::LocalChain
, - pub graph: bdk_chain::IndexedTxGraph< - BlockId, - bdk_chain::keychain_txout::KeychainTxOutIndex<&'static str>, - >, - pub view: CanonicalView, -} - -fn old_rpc_client(env: &TestEnv) -> anyhow::Result { - Ok(bdk_bitcoind_rpc::bitcoincore_rpc::Client::new( - &env.bitcoind.rpc_url(), - bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile( - (&env.bitcoind.params).cookie_file.clone(), - ), - )?) -} - -impl Wallet { - pub fn new( - genesis_header: Header, - external: Descriptor, - internal: Descriptor, - ) -> anyhow::Result { - let mut indexer = bdk_chain::keychain_txout::KeychainTxOutIndex::default(); - indexer.insert_descriptor(EXTERNAL, external)?; - indexer.insert_descriptor(INTERNAL, internal)?; - let graph = bdk_chain::IndexedTxGraph::new(indexer); - let (chain, _) = bdk_chain::local_chain::LocalChain::from_genesis(genesis_header); - let view = graph.canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); - Ok(Self { chain, graph, view }) - } - - pub fn sync(&mut self, env: &TestEnv) -> anyhow::Result<()> { - let client = old_rpc_client(env)?; - // let client = env.rpc_client(); - let last_cp = self.chain.tip(); - let mut emitter = Emitter::new(&client, last_cp, 0, NO_EXPECTED_MEMPOOL_TXS); - while let Some(event) = emitter.next_block()? { - let _ = self - .graph - .apply_block_relevant(&event.block, event.block_height()); - let _ = self.chain.apply_update(event.checkpoint); - } - let mempool = emitter.mempool()?; - let _ = self.graph.batch_insert_relevant_unconfirmed(mempool.update); - let _ = self.graph.batch_insert_relevant_evicted_at(mempool.evicted); - self.view = self.graph.canonical_view( - &self.chain, - self.chain.tip().block_id(), - CanonicalizationParams::default(), - ); - Ok(()) - } - - pub fn next_address(&mut self) -> Option
{ - let ((_, spk), _) = self.graph.index.next_unused_spk(EXTERNAL)?; - Address::from_script(&spk, bitcoin::consensus::Params::REGTEST).ok() - } - - pub fn balance(&self) -> Balance { - let outpoints = self.graph.index.outpoints().clone(); - self.view.balance(outpoints, |_, _| true, 0) - } - - /// Info for the block at the tip. - /// - /// Returns a tuple of: - /// - Tip's height. I.e. `tip.height` - /// - Tip's MTP. I.e. `MTP(tip.height)` - pub fn tip_info( - &self, - client: &impl RpcApi, - ) -> anyhow::Result<(absolute::Height, absolute::Time)> { - let tip_hash = self.chain.tip().block_id().hash; - let tip_info = client.get_block_header_info(&tip_hash)?; - let tip_height = absolute::Height::from_consensus(tip_info.height as u32)?; - let tip_mtp = absolute::Time::from_consensus( - tip_info.median_time.expect("must have median time") as _, - )?; - Ok((tip_height, tip_mtp)) - } - - // TODO: Maybe create an `AssetsBuilder` or `AssetsExt` that makes it easier to add - // assets from descriptors, etc. - pub fn assets(&self) -> Assets { - let index = &self.graph.index; - let tip = self.chain.tip().block_id(); - Assets::new() - .after(absolute::LockTime::from_height(tip.height).expect("must be valid height")) - .add({ - let mut pks = vec![]; - for (_, desc) in index.keychains() { - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - } - pks - }) - } - - pub fn plan_of_output(&self, outpoint: OutPoint, assets: &Assets) -> Option { - let index = &self.graph.index; - let ((k, i), _txout) = index.txout(outpoint)?; - let desc = index.get_descriptor(k)?.at_derivation_index(i).ok()?; - let plan = desc.plan(assets).ok()?; - Some(plan) - } - - pub fn canonical_txs(&self) -> impl Iterator>> + '_ { - pub fn status_from_position( - cp_tip: CheckPoint, - pos: ChainPosition, - ) -> Option - where - D: ToBlockHash + ToBlockTime + Clone + Debug, - { - match pos { - bdk_chain::ChainPosition::Confirmed { anchor, .. } => { - let cp = cp_tip.get(anchor.height)?; - if cp.hash() != anchor.hash { - // TODO: This should only happen if anchor is transitive. - return None; - } - let prev_mtp = cp - .prev() - .and_then(|prev_cp| prev_cp.median_time_past()) - .map(|time| Time::from_consensus(time).expect("must convert!")); - - Some(ConfirmationStatus { - height: absolute::Height::from_consensus( - anchor.confirmation_height_upper_bound(), - ) - .expect("must convert to height"), - prev_mtp, - }) - } - bdk_chain::ChainPosition::Unconfirmed { .. } => None, - } - } - self.view - .txs() - .map(|c_tx| (c_tx.tx, status_from_position(self.chain.tip(), c_tx.pos))) - } - - /// Computes the weight of a change output plus the future weight to spend it. - pub fn drain_weights(&self) -> DrainWeights { - // Get descriptor of change keychain at a derivation index. - let desc = self - .graph - .index - .get_descriptor(INTERNAL) - .unwrap() - .at_derivation_index(0) - .unwrap(); - - // Compute the weight of a change output for this wallet. - let output_weight = TxOut { - script_pubkey: desc.script_pubkey(), - value: Amount::ZERO, - } - .weight() - .to_wu(); - - // The spend weight is the default input weight plus the plan satisfaction weight - // (this code assumes that we're only dealing with segwit transactions). - let plan = desc.plan(&self.assets()).expect("failed to create Plan"); - let spend_weight = - bitcoin::TxIn::default().segwit_weight().to_wu() + plan.satisfaction_weight() as u64; - - DrainWeights { - output_weight, - spend_weight, - n_outputs: 1, - } - } - - /// Get the default change policy for this wallet. - pub fn change_policy(&self) -> ChangePolicy { - let spk_0 = self - .graph - .index - .spk_at_index(INTERNAL, 0) - .expect("spk should exist in wallet"); - ChangePolicy { - min_value: spk_0.minimal_non_dust().to_sat(), - drain_weights: self.drain_weights(), - } - } - - pub fn all_candidates(&self) -> bdk_tx::InputCandidates { - let index = &self.graph.index; - let assets = self.assets(); - let canon_utxos = CanonicalUnspents::new(self.canonical_txs()); - let can_select = canon_utxos.try_get_unspents( - index - .outpoints() - .iter() - .filter_map(|(_, op)| Some((*op, self.plan_of_output(*op, &assets)?))), - ); - InputCandidates::new([], can_select) - } - - pub fn rbf_candidates( - &self, - replace: impl IntoIterator, - tip_height: absolute::Height, - ) -> anyhow::Result<(bdk_tx::InputCandidates, RbfParams)> { - let index = &self.graph.index; - let assets = self.assets(); - let mut canon_utxos = CanonicalUnspents::new(self.canonical_txs()); - - // Exclude txs that reside-in `rbf_set`. - let rbf_set = canon_utxos.extract_replacements(replace)?; - let must_select = rbf_set - .must_select_largest_input_of_each_original_tx(&canon_utxos)? - .into_iter() - .map(|op| canon_utxos.try_get_unspent(op, self.plan_of_output(op, &assets)?)) - .collect::>>() - .ok_or(anyhow::anyhow!( - "failed to find input of tx we are intending to replace" - ))?; - - let can_select = index.outpoints().iter().filter_map(|(_, op)| { - canon_utxos.try_get_unspent(*op, self.plan_of_output(*op, &assets)?) - }); - Ok(( - InputCandidates::new(must_select, can_select) - .filter(rbf_set.candidate_filter(tip_height)), - rbf_set.selector_rbf_params(), - )) - } -} +pub use bdk_tx_testenv::*; diff --git a/examples/synopsis.rs b/examples/synopsis.rs index 1a9eb7f..e4f17cd 100644 --- a/examples/synopsis.rs +++ b/examples/synopsis.rs @@ -3,35 +3,16 @@ use bdk_tx::{ filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, FeeStrategy, Output, PsbtParams, ScriptSource, SelectorParams, }; -use bitcoin::{key::Secp256k1, Amount, FeeRate, Sequence}; -use miniscript::{descriptor::KeyMap, Descriptor}; +use bdk_tx_testenv::TestEnvExt; +use bitcoin::{Amount, FeeRate, Sequence}; mod common; -use common::Wallet; - -fn old_rpc_client(env: &TestEnv) -> anyhow::Result { - Ok(bdk_bitcoind_rpc::bitcoincore_rpc::Client::new( - &env.bitcoind.rpc_url(), - bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile( - (&env.bitcoind.params).cookie_file.clone(), - ), - )?) -} +use common::{Wallet, EXTERNAL, INTERNAL}; fn main() -> anyhow::Result<()> { - let secp = Secp256k1::new(); - let (external, external_keymap) = - Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[3])?; - let (internal, internal_keymap) = - Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[4])?; - - let mut signer = KeyMap::new(); - signer.extend(external_keymap); - signer.extend(internal_keymap); - let env = TestEnv::new()?; - let client = old_rpc_client(&env)?; + let client = env.old_rpc_client()?; let genesis_hash = env.genesis_hash()?; let genesis_header = env @@ -40,10 +21,16 @@ fn main() -> anyhow::Result<()> { .block_header()?; env.mine_blocks(101, None)?; - let mut wallet = Wallet::new(genesis_header, external, internal.clone())?; + let mut wallet = Wallet::multi_keychain( + genesis_header, + [ + (EXTERNAL, bdk_testenv::utils::DESCRIPTORS[3]), + (INTERNAL, bdk_testenv::utils::DESCRIPTORS[4]), + ], + )?; wallet.sync(&env)?; - let addr = wallet.next_address().expect("must derive address"); + let addr = wallet.next_address(EXTERNAL).expect("must derive address"); let txid = env.send(&addr, Amount::ONE_BTC)?; env.mine_blocks(1, None)?; @@ -78,7 +65,7 @@ fn main() -> anyhow::Result<()> { recipient_addr.script_pubkey(), Amount::from_sat(21_000_000), )], - ScriptSource::Descriptor(Box::new(internal.at_derivation_index(0)?)), + ScriptSource::Descriptor(Box::new(wallet.definite_descriptor(INTERNAL, 0)?)), wallet.change_policy(), ), )?; @@ -89,7 +76,7 @@ fn main() -> anyhow::Result<()> { })?; let finalizer = selection.into_finalizer(); - let _ = psbt.sign(&signer, &secp); + let _ = psbt.sign(&wallet.signer, &wallet.secp); let res = finalizer.finalize(&mut psbt); assert!(res.is_finalized()); @@ -153,7 +140,7 @@ fn main() -> anyhow::Result<()> { // If you only want to fee bump, put the original txs' recipients here. target_outputs: vec![], change_script: ScriptSource::Descriptor(Box::new( - internal.at_derivation_index(1)?, + wallet.definite_descriptor(INTERNAL, 1)?, )), change_policy: wallet.change_policy(), // This ensures that we satisfy mempool-replacement policy rules 4 and 6. @@ -176,7 +163,8 @@ fn main() -> anyhow::Result<()> { ); let finalizer = selection.into_finalizer(); - psbt.sign(&signer, &secp).expect("failed to sign"); + psbt.sign(&wallet.signer, &wallet.secp) + .expect("failed to sign"); assert!( finalizer.finalize(&mut psbt).is_finalized(), "must finalize" diff --git a/testenv/Cargo.toml b/testenv/Cargo.toml new file mode 100644 index 0000000..81bca2c --- /dev/null +++ b/testenv/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "bdk_tx_testenv" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = "1" +bitcoin = { version = "0.32", default-features = false, features = ["rand-std"] } +miniscript = "13.0.0" +bdk_tx = { path = ".." } +bdk_testenv = "0.13.0" +bdk_bitcoind_rpc = "0.22.0" +bdk_chain = "0.23.0" +bdk_coin_select = "0.4.0" diff --git a/testenv/src/lib.rs b/testenv/src/lib.rs new file mode 100644 index 0000000..483a869 --- /dev/null +++ b/testenv/src/lib.rs @@ -0,0 +1,313 @@ +use std::{fmt::Debug, sync::Arc}; + +use bdk_bitcoind_rpc::{bitcoincore_rpc::RpcApi, Emitter, NO_EXPECTED_MEMPOOL_TXS}; +use bdk_chain::{ + Anchor, Balance, BlockId, CanonicalView, CanonicalizationParams, ChainPosition, CheckPoint, + ToBlockHash, ToBlockTime, +}; +use bdk_coin_select::{ChangePolicy, DrainWeights}; +use bdk_testenv::TestEnv; +use bdk_tx::{ + CanonicalUnspents, ConfirmationStatus, Input, InputCandidates, RbfParams, TxWithStatus, +}; +use bitcoin::{ + absolute::{self, Time}, + block::Header, + key::Secp256k1, + Address, Amount, OutPoint, Transaction, TxOut, Txid, +}; +use miniscript::{ + descriptor::KeyMap, + plan::{Assets, Plan}, + DefiniteDescriptorKey, Descriptor, DescriptorPublicKey, ForEachKey, +}; + +pub const EXTERNAL: &str = "external"; +pub const INTERNAL: &str = "internal"; + +pub trait TestEnvExt { + fn old_rpc_client(&self) -> anyhow::Result; +} + +impl TestEnvExt for TestEnv { + fn old_rpc_client(&self) -> anyhow::Result { + Ok(bdk_bitcoind_rpc::bitcoincore_rpc::Client::new( + &self.bitcoind.rpc_url(), + bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile( + self.bitcoind.params.cookie_file.clone(), + ), + )?) + } +} + +pub struct Wallet { + pub chain: bdk_chain::local_chain::LocalChain
, + pub graph: bdk_chain::IndexedTxGraph< + BlockId, + bdk_chain::keychain_txout::KeychainTxOutIndex<&'static str>, + >, + pub view: CanonicalView, + pub signer: KeyMap, + pub secp: bitcoin::secp256k1::Secp256k1, +} + +impl Wallet { + pub fn new( + genesis_header: Header, + keychains: impl IntoIterator)>, + keymap: KeyMap, + ) -> anyhow::Result { + let mut indexer = bdk_chain::keychain_txout::KeychainTxOutIndex::default(); + for (k, desc) in keychains { + indexer.insert_descriptor(k, desc)?; + } + let graph = bdk_chain::IndexedTxGraph::new(indexer); + let (chain, _) = bdk_chain::local_chain::LocalChain::from_genesis(genesis_header); + let view = graph.canonical_view( + &chain, + chain.tip().block_id(), + CanonicalizationParams::default(), + ); + Ok(Self { + chain, + graph, + view, + signer: keymap, + secp: bitcoin::secp256k1::Secp256k1::new(), + }) + } + + pub fn multi_keychain<'a>( + genesis_header: Header, + keychains: impl IntoIterator, + ) -> anyhow::Result { + let secp = Secp256k1::new(); + let mut keymap = KeyMap::new(); + let mut pk_keychains = Vec::<(&'static str, Descriptor)>::new(); + for (k, s) in keychains { + let (desc, km) = Descriptor::parse_descriptor(&secp, s)?; + pk_keychains.push((k, desc)); + keymap.extend(km); + } + Self::new(genesis_header, pk_keychains, keymap) + } + + pub fn single_keychain(genesis_header: Header, descriptor_str: &str) -> anyhow::Result { + Self::multi_keychain(genesis_header, core::iter::once((EXTERNAL, descriptor_str))) + } + + pub fn sync(&mut self, env: &TestEnv) -> anyhow::Result<()> { + let client = env.old_rpc_client()?; + let last_cp = self.chain.tip(); + let mut emitter = Emitter::new(&client, last_cp, 0, NO_EXPECTED_MEMPOOL_TXS); + while let Some(event) = emitter.next_block()? { + let _ = self + .graph + .apply_block_relevant(&event.block, event.block_height()); + let _ = self.chain.apply_update(event.checkpoint); + } + let mempool = emitter.mempool()?; + let _ = self.graph.batch_insert_relevant_unconfirmed(mempool.update); + let _ = self.graph.batch_insert_relevant_evicted_at(mempool.evicted); + self.view = self.graph.canonical_view( + &self.chain, + self.chain.tip().block_id(), + CanonicalizationParams::default(), + ); + Ok(()) + } + + pub fn next_address(&mut self, keychain: &'static str) -> Option
{ + let ((_, spk), _) = self.graph.index.next_unused_spk(keychain)?; + Address::from_script(&spk, bitcoin::consensus::Params::REGTEST).ok() + } + + pub fn balance(&self) -> Balance { + let outpoints = self.graph.index.outpoints().clone(); + self.view.balance(outpoints, |_, _| true, 0) + } + + pub fn tip_height(&self) -> u32 { + self.chain.tip().block_id().height + } + + /// Info for the block at the tip. + /// + /// Returns a tuple of: + /// - Tip's height. I.e. `tip.height` + /// - Tip's MTP. I.e. `MTP(tip.height)` + pub fn tip_info( + &self, + client: &impl RpcApi, + ) -> anyhow::Result<(absolute::Height, absolute::Time)> { + let tip_hash = self.chain.tip().block_id().hash; + let tip_info = client.get_block_header_info(&tip_hash)?; + let tip_height = absolute::Height::from_consensus(tip_info.height as u32)?; + let tip_mtp = absolute::Time::from_consensus( + tip_info.median_time.expect("must have median time") as _, + )?; + Ok((tip_height, tip_mtp)) + } + + // TODO: Maybe create an `AssetsBuilder` or `AssetsExt` that makes it easier to add + // assets from descriptors, etc. + pub fn assets(&self) -> Assets { + let index = &self.graph.index; + let tip = self.chain.tip().block_id(); + Assets::new() + .after(absolute::LockTime::from_height(tip.height).expect("must be valid height")) + .add({ + let mut pks = vec![]; + for (_, desc) in index.keychains() { + desc.for_any_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + } + pks + }) + } + + pub fn definite_descriptor( + &self, + keychain: &'static str, + index: u32, + ) -> anyhow::Result> { + Ok(self + .graph + .index + .get_descriptor(keychain) + .ok_or(anyhow::anyhow!("keychain not found"))? + .at_derivation_index(index)?) + } + + pub fn plan_of_output(&self, outpoint: OutPoint, assets: &Assets) -> Option { + let index = &self.graph.index; + let ((k, i), _txout) = index.txout(outpoint)?; + let desc = index.get_descriptor(k)?.at_derivation_index(i).ok()?; + desc.plan(assets).ok() + } + + pub fn canonical_txs(&self) -> impl Iterator>> + '_ { + pub fn status_from_position( + cp_tip: CheckPoint, + pos: ChainPosition, + ) -> Option + where + D: ToBlockHash + ToBlockTime + Clone + Debug, + { + match pos { + bdk_chain::ChainPosition::Confirmed { anchor, .. } => { + let cp = cp_tip.get(anchor.height)?; + if cp.hash() != anchor.hash { + // TODO: This should only happen if anchor is transitive. + return None; + } + let prev_mtp = cp + .prev() + .and_then(|prev_cp| prev_cp.median_time_past()) + .map(|time| Time::from_consensus(time).expect("must convert!")); + + Some(ConfirmationStatus { + height: absolute::Height::from_consensus( + anchor.confirmation_height_upper_bound(), + ) + .expect("must convert to height"), + prev_mtp, + }) + } + bdk_chain::ChainPosition::Unconfirmed { .. } => None, + } + } + self.view + .txs() + .map(|c_tx| (c_tx.tx, status_from_position(self.chain.tip(), c_tx.pos))) + } + + /// Computes the weight of a change output plus the future weight to spend it. + pub fn drain_weights(&self) -> DrainWeights { + // Get descriptor of change keychain at a derivation index. + let desc = self.definite_descriptor(INTERNAL, 0).unwrap(); + + // Compute the weight of a change output for this wallet. + let output_weight = TxOut { + script_pubkey: desc.script_pubkey(), + value: Amount::ZERO, + } + .weight() + .to_wu(); + + // The spend weight is the default input weight plus the plan satisfaction weight + // (this code assumes that we're only dealing with segwit transactions). + let assets = self.assets(); + let plan = desc.plan(&assets).expect("failed to create Plan"); + let spend_weight = + bitcoin::TxIn::default().segwit_weight().to_wu() + plan.satisfaction_weight() as u64; + + DrainWeights { + output_weight, + spend_weight, + n_outputs: 1, + } + } + + /// Get the default change policy for this wallet. + pub fn change_policy(&self) -> ChangePolicy { + let spk_0 = self + .graph + .index + .spk_at_index(INTERNAL, 0) + .expect("spk should exist in wallet"); + ChangePolicy { + min_value: spk_0.minimal_non_dust().to_sat(), + drain_weights: self.drain_weights(), + } + } + + pub fn all_candidates(&self) -> InputCandidates { + let assets = self.assets(); + self.all_candidates_with(&assets) + } + + pub fn all_candidates_with(&self, assets: &Assets) -> InputCandidates { + let index = &self.graph.index; + let canon_utxos = CanonicalUnspents::new(self.canonical_txs()); + let can_select = canon_utxos.try_get_unspents( + index + .outpoints() + .iter() + .filter_map(|(_, op)| Some((*op, self.plan_of_output(*op, assets)?))), + ); + InputCandidates::new([], can_select) + } + + pub fn rbf_candidates( + &self, + replace: impl IntoIterator, + tip_height: absolute::Height, + ) -> anyhow::Result<(InputCandidates, RbfParams)> { + let index = &self.graph.index; + let assets = self.assets(); + let mut canon_utxos = CanonicalUnspents::new(self.canonical_txs()); + + // Exclude txs that reside-in `rbf_set`. + let rbf_set = canon_utxos.extract_replacements(replace)?; + let must_select = rbf_set + .must_select_largest_input_of_each_original_tx(&canon_utxos)? + .into_iter() + .map(|op| canon_utxos.try_get_unspent(op, self.plan_of_output(op, &assets)?)) + .collect::>>() + .ok_or(anyhow::anyhow!( + "failed to find input of tx we are intending to replace" + ))?; + + let can_select = index.outpoints().iter().filter_map(|(_, op)| { + canon_utxos.try_get_unspent(*op, self.plan_of_output(*op, &assets)?) + }); + Ok(( + InputCandidates::new(must_select, can_select) + .filter(rbf_set.candidate_filter(tip_height)), + rbf_set.selector_rbf_params(), + )) + } +} diff --git a/tests/timelock.rs b/tests/timelock.rs index c9c2a0f..3efb2c3 100644 --- a/tests/timelock.rs +++ b/tests/timelock.rs @@ -3,268 +3,34 @@ //! These tests verify that the `is_timelocked`, `is_block_timelocked`, `is_time_timelocked`, //! and `is_spendable` methods correctly predict when transactions can be broadcast. -use std::{fmt::Debug, sync::Arc}; - -use bdk_bitcoind_rpc::{bitcoincore_rpc::RpcApi, Emitter, NO_EXPECTED_MEMPOOL_TXS}; -use bdk_chain::{ - miniscript::ForEachKey, Anchor, Balance, BlockId, CanonicalizationParams, ChainPosition, - ToBlockHash, ToBlockTime, -}; -use bdk_coin_select::ChangePolicy; +use bdk_chain::miniscript::ForEachKey; use bdk_testenv::TestEnv; use bdk_tx::{ filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, CanonicalUnspents, - ConfirmationStatus, FeeStrategy, Input, InputCandidates, Output, PsbtParams, ScriptSource, - SelectorParams, TxWithStatus, + ConfirmationStatus, FeeStrategy, Input, Output, PsbtParams, ScriptSource, SelectorParams, }; +use bdk_tx_testenv::{TestEnvExt, Wallet, EXTERNAL, INTERNAL}; use bitcoin::{ - absolute::{self, Time}, - block::Header, - key::Secp256k1, - relative, transaction, Address, Amount, FeeRate, OutPoint, Sequence, Transaction, TxIn, TxOut, -}; -use miniscript::{ - descriptor::KeyMap, - plan::{Assets, Plan}, - Descriptor, DescriptorPublicKey, + absolute, key::Secp256k1, relative, transaction, Amount, FeeRate, Sequence, Transaction, TxIn, + TxOut, }; - -const EXTERNAL: &str = "external"; -const INTERNAL: &str = "internal"; +use miniscript::{plan::Assets, Descriptor}; // Test xprv for creating timelocked descriptors const TEST_XPRV: &str = "tprv8ZgxMBicQKsPd3krDUsBAmtnRsK3rb8u5yi1zhQgMhF1tR8MW7xfE4rnrbbsrbPR52e7rKapu6ztw1jXveJSCGHEriUGZV7mCe88duLp5pj"; -/// A minimal wallet for testing timelocks. -struct TestWallet { - chain: bdk_chain::local_chain::LocalChain
, - graph: bdk_chain::IndexedTxGraph< - BlockId, - bdk_chain::keychain_txout::KeychainTxOutIndex<&'static str>, - >, - view: bdk_chain::CanonicalView, - signer: KeyMap, - secp: bitcoin::secp256k1::Secp256k1, -} - -fn old_rpc_client(env: &TestEnv) -> anyhow::Result { - Ok(bdk_bitcoind_rpc::bitcoincore_rpc::Client::new( - &env.bitcoind.rpc_url(), - bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile( - (&env.bitcoind.params).cookie_file.clone(), - ), - )?) -} - -impl TestWallet { - fn new( - genesis_header: Header, - external: Descriptor, - internal: Descriptor, - keymap: KeyMap, - ) -> anyhow::Result { - let mut indexer = bdk_chain::keychain_txout::KeychainTxOutIndex::default(); - indexer.insert_descriptor(EXTERNAL, external)?; - indexer.insert_descriptor(INTERNAL, internal)?; - let graph = bdk_chain::IndexedTxGraph::new(indexer); - let (chain, _) = bdk_chain::local_chain::LocalChain::from_genesis(genesis_header); - let view = graph.canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); - Ok(Self { - chain, - graph, - view, - signer: keymap, - secp: Secp256k1::new(), - }) - } - - fn sync(&mut self, env: &TestEnv) -> anyhow::Result<()> { - let client = old_rpc_client(env)?; - let last_cp = self.chain.tip(); - let mut emitter = Emitter::new(&client, last_cp, 0, NO_EXPECTED_MEMPOOL_TXS); - while let Some(event) = emitter.next_block()? { - let _ = self - .graph - .apply_block_relevant(&event.block, event.block_height()); - let _ = self.chain.apply_update(event.checkpoint); - } - let mempool = emitter.mempool()?; - let _ = self.graph.batch_insert_relevant_unconfirmed(mempool.update); - let _ = self.graph.batch_insert_relevant_evicted_at(mempool.evicted); - self.view = self.graph.canonical_view( - &self.chain, - self.chain.tip().block_id(), - CanonicalizationParams::default(), - ); - Ok(()) - } - - fn next_address(&mut self) -> Option
{ - let ((_, spk), _) = self.graph.index.next_unused_spk(EXTERNAL)?; - Address::from_script(&spk, bitcoin::consensus::Params::REGTEST).ok() - } - - fn balance(&self) -> Balance { - self.view - .balance(self.graph.index.outpoints().clone(), |_, _| true, 0) - } - - fn tip_height(&self) -> u32 { - self.chain.tip().block_id().height - } - - fn tip_info(&self, client: &impl RpcApi) -> anyhow::Result<(absolute::Height, absolute::Time)> { - let tip_hash = self.chain.tip().block_id().hash; - let tip_info = client.get_block_header_info(&tip_hash)?; - let tip_height = absolute::Height::from_consensus(tip_info.height as u32)?; - let tip_mtp = absolute::Time::from_consensus( - tip_info.median_time.expect("must have median time") as _, - )?; - Ok((tip_height, tip_mtp)) - } - - fn assets(&self) -> Assets { - let index = &self.graph.index; - let tip = self.chain.tip().block_id(); - Assets::new() - .after(absolute::LockTime::from_height(tip.height).expect("must be valid height")) - .add({ - let mut pks = vec![]; - for (_, desc) in index.keychains() { - desc.for_any_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - } - pks - }) - } - - fn plan_of_output(&self, outpoint: OutPoint, assets: &Assets) -> Option { - let index = &self.graph.index; - let ((k, i), _txout) = index.txout(outpoint)?; - let desc = index.get_descriptor(k)?.at_derivation_index(i).ok()?; - desc.plan(assets).ok() - } - - fn canonical_txs(&self) -> impl Iterator>> + '_ { - pub fn status_from_position( - cp_tip: bdk_chain::CheckPoint, - pos: ChainPosition, - ) -> Option - where - D: ToBlockHash + ToBlockTime + Clone + Debug, - { - match pos { - bdk_chain::ChainPosition::Confirmed { anchor, .. } => { - let cp = cp_tip.get(anchor.height)?; - if cp.hash() != anchor.hash { - // TODO: This should only happen if anchor is transitive. - return None; - } - let prev_mtp = cp - .prev() - .and_then(|prev_cp| prev_cp.median_time_past()) - .map(|time| Time::from_consensus(time).expect("must convert!")); - - Some(ConfirmationStatus { - height: absolute::Height::from_consensus( - anchor.confirmation_height_upper_bound(), - ) - .expect("must convert to height"), - prev_mtp, - }) - } - bdk_chain::ChainPosition::Unconfirmed { .. } => None, - } - } - self.view - .txs() - .map(|c_tx| (c_tx.tx, status_from_position(self.chain.tip(), c_tx.pos))) - } - - fn drain_weights(&self) -> bdk_coin_select::DrainWeights { - let desc = self - .graph - .index - .get_descriptor(INTERNAL) - .unwrap() - .at_derivation_index(0) - .unwrap(); - - let output_weight = TxOut { - script_pubkey: desc.script_pubkey(), - value: Amount::ZERO, - } - .weight() - .to_wu(); - - let assets = self.assets(); - let plan = desc.plan(&assets).expect("failed to create Plan"); - let spend_weight = - bitcoin::TxIn::default().segwit_weight().to_wu() + plan.satisfaction_weight() as u64; - - bdk_coin_select::DrainWeights { - output_weight, - spend_weight, - n_outputs: 1, - } - } - - fn change_policy(&self) -> ChangePolicy { - let spk_0 = self - .graph - .index - .spk_at_index(INTERNAL, 0) - .expect("spk should exist"); - ChangePolicy { - min_value: spk_0.minimal_non_dust().to_sat(), - drain_weights: self.drain_weights(), - } - } - - fn all_candidates(&self, assets: &Assets) -> InputCandidates { - let index = &self.graph.index; - let canon_utxos = CanonicalUnspents::new(self.canonical_txs()); - let can_select = canon_utxos.try_get_unspents( - index - .outpoints() - .iter() - .filter_map(|(_, op)| Some((*op, self.plan_of_output(*op, assets)?))), - ); - InputCandidates::new([], can_select) - } -} - /// Test absolute block-height timelock checking logic. /// /// This test verifies that `is_block_timelocked` and `is_spendable` correctly /// identify when an input with an absolute block height timelock can be spent. #[test] fn test_absolute_block_height_timelock_logic() -> anyhow::Result<()> { - let secp = Secp256k1::new(); - // Create a timelocked descriptor let lock_height = 110u32; let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/*),after({lock_height})))"); - let (external, external_keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - - let (internal, internal_keymap) = - Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[4])?; - - let keymap = { - let mut keymap = KeyMap::new(); - keymap.extend(external_keymap); - keymap.extend(internal_keymap); - keymap - }; let env = TestEnv::new()?; - let client = old_rpc_client(&env)?; + let client = env.old_rpc_client()?; let genesis_hash = env.genesis_hash()?; let genesis_header = env @@ -274,11 +40,11 @@ fn test_absolute_block_height_timelock_logic() -> anyhow::Result<()> { env.mine_blocks(101, None)?; - let mut wallet = TestWallet::new(genesis_header, external, internal, keymap)?; + let mut wallet = Wallet::single_keychain(genesis_header, &desc_str)?; wallet.sync(&env)?; // Fund the wallet - let addr = wallet.next_address().expect("must derive address"); + let addr = wallet.next_address(EXTERNAL).expect("must derive address"); env.send(&addr, Amount::ONE_BTC)?; env.mine_blocks(1, None)?; wallet.sync(&env)?; @@ -387,26 +153,13 @@ fn test_absolute_block_height_timelock_logic() -> anyhow::Result<()> { /// identify when an input with a relative block timelock (CSV) can be spent. #[test] fn test_relative_block_height_timelock_logic() -> anyhow::Result<()> { - let secp = Secp256k1::new(); - // Create a descriptor with relative timelock let relative_lock_blocks = 5u16; let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/*),older({relative_lock_blocks})))"); - let (external, external_keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - - let (internal, internal_keymap) = - Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[4])?; - - let keymap = { - let mut keymap = KeyMap::new(); - keymap.extend(external_keymap); - keymap.extend(internal_keymap); - keymap - }; let env = TestEnv::new()?; - let client = old_rpc_client(&env)?; + let client = env.old_rpc_client()?; let genesis_hash = env.genesis_hash()?; let genesis_header = env @@ -416,11 +169,17 @@ fn test_relative_block_height_timelock_logic() -> anyhow::Result<()> { env.mine_blocks(101, None)?; - let mut wallet = TestWallet::new(genesis_header, external, internal, keymap)?; + let mut wallet = Wallet::multi_keychain( + genesis_header, + [ + (EXTERNAL, desc_str.as_str()), + (INTERNAL, bdk_testenv::utils::DESCRIPTORS[4]), + ], + )?; wallet.sync(&env)?; // Fund the wallet - let addr = wallet.next_address().expect("must derive address"); + let addr = wallet.next_address(EXTERNAL).expect("must derive address"); let funding_txid = env.send(&addr, Amount::ONE_BTC)?; env.mine_blocks(1, None)?; wallet.sync(&env)?; @@ -532,22 +291,8 @@ fn test_relative_block_height_timelock_logic() -> anyhow::Result<()> { /// This test verifies the full flow: maturity checking AND actual broadcast. #[test] fn test_coinbase_maturity() -> anyhow::Result<()> { - let secp = Secp256k1::new(); - - let (external, external_keymap) = - Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[3])?; - let (internal, internal_keymap) = - Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[4])?; - - let keymap = { - let mut km = KeyMap::new(); - km.extend(external_keymap); - km.extend(internal_keymap); - km - }; - let env = TestEnv::new()?; - let client = old_rpc_client(&env)?; + let client = env.old_rpc_client()?; let genesis_hash = env.genesis_hash()?; let genesis_header = env @@ -558,11 +303,17 @@ fn test_coinbase_maturity() -> anyhow::Result<()> { // Only mine a few blocks initially env.mine_blocks(10, None)?; - let mut wallet = TestWallet::new(genesis_header, external, internal.clone(), keymap)?; + let mut wallet = Wallet::multi_keychain( + genesis_header, + [ + (EXTERNAL, bdk_testenv::utils::DESCRIPTORS[3]), + (INTERNAL, bdk_testenv::utils::DESCRIPTORS[4]), + ], + )?; wallet.sync(&env)?; // Get wallet address and mine a block to it (creates coinbase output) - let addr = wallet.next_address().expect("must derive address"); + let addr = wallet.next_address(EXTERNAL).expect("must derive address"); env.mine_blocks(1, Some(addr.clone()))?; wallet.sync(&env)?; @@ -656,7 +407,7 @@ fn test_coinbase_maturity() -> anyhow::Result<()> { .assume_checked(); let selection = wallet - .all_candidates(&assets) + .all_candidates_with(&assets) .regroup(group_by_spk()) .filter(filter_unspendable_now(tip_height, Some(tip_mtp))) .into_selection( @@ -667,7 +418,7 @@ fn test_coinbase_maturity() -> anyhow::Result<()> { recipient_addr.script_pubkey(), Amount::from_sat(10_000), )], - ScriptSource::Descriptor(Box::new(internal.at_derivation_index(0)?)), + ScriptSource::Descriptor(Box::new(wallet.definite_descriptor(INTERNAL, 0)?)), wallet.change_policy(), ), )?; From 5c4dcb14c0cd9aa5746af6cbd4834f4172cd1b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 2 Feb 2026 06:10:20 +0000 Subject: [PATCH 4/7] chore(ci): Remove `no-std` feature flag for `miniscript` The `no-std` feature flag no longer exists for miniscript 13.0.0 --- .github/workflows/rust.yml | 4 ++-- ci/pin-msrv.sh | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ebb8b53..481727b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -17,7 +17,7 @@ jobs: - version: stable - version: 1.85.0 features: - - --no-default-features --features miniscript/no-std + - --no-default-features - --all-features steps: - uses: actions/checkout@v4 @@ -44,7 +44,7 @@ jobs: with: toolchain: stable - name: Check no-std - run: cargo check --no-default-features --features miniscript/no-std + run: cargo check --no-default-features fmt-clippy: runs-on: ubuntu-latest diff --git a/ci/pin-msrv.sh b/ci/pin-msrv.sh index f9d5e3c..6742dc6 100755 --- a/ci/pin-msrv.sh +++ b/ci/pin-msrv.sh @@ -2,7 +2,3 @@ set -x set -euo pipefail - -cargo update -p home --precise "0.5.11" -cargo update -p time --precise "0.3.45" -cargo update -p time-core --precise "0.1.7" From 3d721daf1f33716dc1fdd2fc1fda847459973164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 3 Feb 2026 06:39:42 +0000 Subject: [PATCH 5/7] test: Add time-based timelock tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 4 new time-based timelock tests that verify BDK's predictions match Bitcoin Core's actual broadcast acceptance at exact boundaries: - test_absolute_time_timelock_logic (integration) - test_relative_time_timelock_logic (integration) - test_is_time_timelocked_absolute_unit - test_is_time_timelocked_relative_unit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Cargo.toml | 1 + tests/timelock.rs | 697 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 690 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 20d46ec..c7d1be1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ bdk_testenv = { git = "https://github.com/evanlinjin/bdk", branch = "feature/bit bdk_chain = { git = "https://github.com/evanlinjin/bdk", branch = "feature/bitcoind_rpc_emit_header" } bdk_core = { git = "https://github.com/evanlinjin/bdk", branch = "feature/bitcoind_rpc_emit_header" } bdk_bitcoind_rpc = { git = "https://github.com/evanlinjin/bdk", branch = "feature/bitcoind_rpc_emit_header" } +miniscript = { git = "https://github.com/evanlinjin/rust-miniscript", branch = "fix/plan-satisfy-does-not-append-witness-script-for-p2wsh" } # bdk_chain = { git = "https://github.com/bitcoindevkit/bdk", branch = "master" } # bdk_core = { git = "https://github.com/bitcoindevkit/bdk", branch = "master" } diff --git a/tests/timelock.rs b/tests/timelock.rs index 3efb2c3..2930039 100644 --- a/tests/timelock.rs +++ b/tests/timelock.rs @@ -4,7 +4,7 @@ //! and `is_spendable` methods correctly predict when transactions can be broadcast. use bdk_chain::miniscript::ForEachKey; -use bdk_testenv::TestEnv; +use bdk_testenv::{MineParams, TestEnv}; use bdk_tx::{ filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, CanonicalUnspents, ConfirmationStatus, FeeStrategy, Input, Output, PsbtParams, ScriptSource, SelectorParams, @@ -486,23 +486,37 @@ fn test_is_block_timelocked_unit() -> anyhow::Result<()> { Some(absolute::LockTime::from_height(lock_height)?) ); - // Test at various heights + // Test at various heights. + // Bitcoin Core `IsFinalTx` checks: `nLockTime < nBlockHeight` where nBlockHeight = tip + 1. + // So the tx is final (unlocked) when `lock_height < tip + 1`, i.e., `tip >= lock_height`. let below_lock = absolute::Height::from_consensus(lock_height - 10)?; - let at_lock = absolute::Height::from_consensus(lock_height - 1)?; // spending_height = lock_height + let at_lock_minus_1 = absolute::Height::from_consensus(lock_height - 1)?; // spending_height = lock_height + let at_lock = absolute::Height::from_consensus(lock_height)?; // spending_height = lock_height + 1 let above_lock = absolute::Height::from_consensus(lock_height + 10)?; - // Below lock height: should be timelocked + // Well below lock height: should be timelocked assert!( input.is_block_timelocked(below_lock), - "should be timelocked at height {} (lock: {})", + "should be timelocked at tip {} (lock: {})", below_lock.to_consensus_u32(), lock_height ); - // At lock height (spending_height = tip + 1 = lock_height): should NOT be timelocked + // At tip = lock_height - 1 (spending_height = lock_height): still locked + // Core: lock < spending_height → 100 < 100 → false → non-final + assert!( + input.is_block_timelocked(at_lock_minus_1), + "should be timelocked at tip {} (spending_height = lock = {})", + at_lock_minus_1.to_consensus_u32(), + lock_height + ); + + // At tip = lock_height (spending_height = lock_height + 1): unlocked + // Core: lock < spending_height → 100 < 101 → true → final assert!( !input.is_block_timelocked(at_lock), - "should NOT be timelocked when spending_height = {} (lock: {})", + "should NOT be timelocked at tip {} (spending_height {} > lock {})", + at_lock.to_consensus_u32(), at_lock.to_consensus_u32() + 1, lock_height ); @@ -510,7 +524,7 @@ fn test_is_block_timelocked_unit() -> anyhow::Result<()> { // Above lock height: should NOT be timelocked assert!( !input.is_block_timelocked(above_lock), - "should NOT be timelocked at height {} (lock: {})", + "should NOT be timelocked at tip {} (lock: {})", above_lock.to_consensus_u32(), lock_height ); @@ -592,3 +606,670 @@ fn test_is_block_timelocked_relative_unit() -> anyhow::Result<()> { Ok(()) } + +/// Test absolute time-based timelock boundary: BDK's prediction must match Bitcoin Core. +/// +/// At MTP = lock_time - 1: BDK says locked, Bitcoin Core rejects broadcast. +/// At MTP = lock_time: BDK says unlocked, Bitcoin Core accepts broadcast. +#[test] +fn test_absolute_time_timelock_logic() -> anyhow::Result<()> { + let env = TestEnv::new()?; + let client = env.old_rpc_client()?; + + let genesis_hash = env.genesis_hash()?; + let genesis_header = env + .rpc_client() + .get_block_header(&genesis_hash)? + .block_header()?; + + env.mine_blocks(101, None)?; + + // We need to know the current MTP to choose a lock_time in the future. + // Create a temporary wallet just to read MTP. + let mut wallet = Wallet::single_keychain(genesis_header, bdk_testenv::utils::DESCRIPTORS[0])?; + wallet.sync(&env)?; + let (_, initial_mtp) = wallet.tip_info(&client)?; + let lock_time = initial_mtp.to_consensus_u32() + 1800; // 30 minutes in the future + println!( + "Initial MTP: {}, lock_time: {lock_time}", + initial_mtp.to_consensus_u32() + ); + + // Now create the actual timelocked wallet + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/*),after({lock_time})))"); + let mut wallet = Wallet::multi_keychain( + genesis_header, + [ + (EXTERNAL, desc_str.as_str()), + (INTERNAL, bdk_testenv::utils::DESCRIPTORS[4]), + ], + )?; + wallet.sync(&env)?; + + // Fund the wallet + let addr = wallet.next_address(EXTERNAL).expect("must derive address"); + env.send(&addr, Amount::ONE_BTC)?; + env.mine_blocks(1, None)?; + wallet.sync(&env)?; + + assert!(wallet.balance().confirmed > Amount::ZERO); + + // Build assets with the time-based lock + let abs_lock = absolute::LockTime::from_consensus(lock_time); + let assets = Assets::new().after(abs_lock).add({ + let mut pks = vec![]; + for (_, desc) in wallet.graph.index.keychains() { + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + } + pks + }); + + // Verify the input has a time-based absolute timelock + { + let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); + let inputs: Vec = wallet + .graph + .index + .outpoints() + .iter() + .filter_map(|(_, op)| { + let plan = wallet.plan_of_output(*op, &assets)?; + canon_utxos.try_get_unspent(*op, plan) + }) + .collect(); + assert!(!inputs.is_empty(), "should have at least one input"); + assert!( + matches!( + inputs[0].absolute_timelock(), + Some(absolute::LockTime::Seconds(_)) + ), + "input should have time-based absolute timelock, got: {:?}", + inputs[0].absolute_timelock() + ); + } + + // Build + sign + finalize the spending tx once + let recipient_addr = env + .rpc_client() + .get_new_address(None, None)? + .address()? + .assume_checked(); + + let selection = wallet + .all_candidates_with(&assets) + .regroup(group_by_spk()) + .into_selection( + selection_algorithm_lowest_fee_bnb(FeeRate::from_sat_per_vb_unchecked(1), 100_000), + SelectorParams::new( + FeeStrategy::FeeRate(FeeRate::from_sat_per_vb_unchecked(10)), + vec![Output::with_script( + recipient_addr.script_pubkey(), + Amount::from_sat(50_000), + )], + ScriptSource::Descriptor(Box::new(wallet.definite_descriptor(INTERNAL, 0)?)), + wallet.change_policy(), + ), + )?; + + let mut psbt = selection.create_psbt(PsbtParams { + fallback_locktime: abs_lock, + fallback_sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + })?; + let finalizer = selection.into_finalizer(); + let _ = psbt.sign(&wallet.signer, &wallet.secp); + let res = finalizer.finalize(&mut psbt); + assert!(res.is_finalized(), "should finalize"); + let tx = psbt.extract_tx()?; + + // Verify the tx has the expected time-based locktime + assert_eq!( + tx.lock_time, abs_lock, + "tx locktime should match the absolute time lock" + ); + + // --- BOUNDARY - 1: MTP = lock_time - 1 --- + // Mine 6 blocks at lock_time - 1 to shift MTP. After 6 blocks at timestamp T, + // the last 11 blocks are [old*5, T*6], so the 6th value (median) = T. + for _ in 0..6 { + let mut params = MineParams::default(); + params.time = Some(lock_time - 1); + env.mine_block(params)?; + } + wallet.sync(&env)?; + + let (tip_height, tip_mtp) = wallet.tip_info(&client)?; + println!( + "After mining at lock_time-1: tip_height={}, tip_mtp={}", + tip_height.to_consensus_u32(), + tip_mtp.to_consensus_u32() + ); + assert_eq!( + tip_mtp.to_consensus_u32(), + lock_time - 1, + "MTP should be exactly lock_time - 1" + ); + + // Refresh input and check BDK says locked + let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); + let inputs: Vec = wallet + .graph + .index + .outpoints() + .iter() + .filter_map(|(_, op)| { + let plan = wallet.plan_of_output(*op, &assets)?; + canon_utxos.try_get_unspent(*op, plan) + }) + .collect(); + let input = &inputs[0]; + + assert_eq!( + input.is_time_timelocked(tip_mtp), + Some(true), + "BDK should say time-timelocked at MTP = lock_time - 1" + ); + assert_eq!( + input.is_spendable(tip_height, Some(tip_mtp)), + Some(false), + "BDK should say not spendable at MTP = lock_time - 1" + ); + + // Bitcoin Core should reject the broadcast + let broadcast_result = env.rpc_client().send_raw_transaction(&tx); + assert!( + broadcast_result.is_err(), + "Bitcoin Core should reject broadcast at MTP = lock_time - 1" + ); + println!("Broadcast correctly rejected at MTP = lock_time - 1"); + + // --- AT MTP = lock_time: still locked --- + // Bitcoin Core: nLockTime < MTP → lock_time < lock_time → false → non-final + // Mine 6 more blocks at lock_time to shift median + for _ in 0..6 { + let mut params = MineParams::default(); + params.time = Some(lock_time); + env.mine_block(params)?; + } + wallet.sync(&env)?; + + let (tip_height, tip_mtp) = wallet.tip_info(&client)?; + println!( + "After mining at lock_time: tip_height={}, tip_mtp={}", + tip_height.to_consensus_u32(), + tip_mtp.to_consensus_u32() + ); + assert_eq!( + tip_mtp.to_consensus_u32(), + lock_time, + "MTP should be exactly lock_time" + ); + + // Refresh input and check BDK says locked + let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); + let inputs: Vec = wallet + .graph + .index + .outpoints() + .iter() + .filter_map(|(_, op)| { + let plan = wallet.plan_of_output(*op, &assets)?; + canon_utxos.try_get_unspent(*op, plan) + }) + .collect(); + let input = &inputs[0]; + + assert_eq!( + input.is_time_timelocked(tip_mtp), + Some(true), + "BDK should say time-timelocked at MTP = lock_time" + ); + assert_eq!( + input.is_spendable(tip_height, Some(tip_mtp)), + Some(false), + "BDK should say not spendable at MTP = lock_time" + ); + + // Bitcoin Core should reject + let broadcast_result = env.rpc_client().send_raw_transaction(&tx); + assert!( + broadcast_result.is_err(), + "Bitcoin Core should reject broadcast at MTP = lock_time" + ); + println!("Broadcast correctly rejected at MTP = lock_time"); + + // --- EXACT BOUNDARY: MTP = lock_time + 1 --- + // Bitcoin Core: nLockTime < MTP → lock_time < lock_time+1 → true → final + for _ in 0..6 { + let mut params = MineParams::default(); + params.time = Some(lock_time + 1); + env.mine_block(params)?; + } + wallet.sync(&env)?; + + let (tip_height, tip_mtp) = wallet.tip_info(&client)?; + println!( + "After mining at lock_time+1: tip_height={}, tip_mtp={}", + tip_height.to_consensus_u32(), + tip_mtp.to_consensus_u32() + ); + assert_eq!( + tip_mtp.to_consensus_u32(), + lock_time + 1, + "MTP should be exactly lock_time + 1" + ); + + // Refresh input and check BDK says unlocked + let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); + let inputs: Vec = wallet + .graph + .index + .outpoints() + .iter() + .filter_map(|(_, op)| { + let plan = wallet.plan_of_output(*op, &assets)?; + canon_utxos.try_get_unspent(*op, plan) + }) + .collect(); + let input = &inputs[0]; + + assert_eq!( + input.is_time_timelocked(tip_mtp), + Some(false), + "BDK should say NOT time-timelocked at MTP = lock_time + 1" + ); + assert_eq!( + input.is_spendable(tip_height, Some(tip_mtp)), + Some(true), + "BDK should say spendable at MTP = lock_time + 1" + ); + + // Bitcoin Core should accept the broadcast + let txid = env.rpc_client().send_raw_transaction(&tx)?.txid()?; + println!("Broadcast accepted at MTP = lock_time + 1: {txid}"); + + Ok(()) +} + +/// Test relative time-based timelock boundary: BDK's prediction must match Bitcoin Core. +/// +/// At time_diff = (lock_value * 512) - 1: BDK says locked, Bitcoin Core rejects. +/// At time_diff = (lock_value * 512): BDK says unlocked, Bitcoin Core accepts. +#[test] +fn test_relative_time_timelock_logic() -> anyhow::Result<()> { + // Relative lock = 2 units of 512 seconds = 1024 seconds + // Raw older() value with time flag: 0x400000 | 2 = 4194306 + let relative_lock_units = 2u16; + let relative_lock_seconds = relative_lock_units as u32 * 512; // 1024 + let older_value = 0x400000u32 | relative_lock_units as u32; // 4194306 + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/*),older({older_value})))"); + + let env = TestEnv::new()?; + let client = env.old_rpc_client()?; + + let genesis_hash = env.genesis_hash()?; + let genesis_header = env + .rpc_client() + .get_block_header(&genesis_hash)? + .block_header()?; + + env.mine_blocks(101, None)?; + + let mut wallet = Wallet::multi_keychain( + genesis_header, + [ + (EXTERNAL, desc_str.as_str()), + (INTERNAL, bdk_testenv::utils::DESCRIPTORS[4]), + ], + )?; + wallet.sync(&env)?; + + // Fund the wallet + let addr = wallet.next_address(EXTERNAL).expect("must derive address"); + env.send(&addr, Amount::ONE_BTC)?; + env.mine_blocks(1, None)?; + wallet.sync(&env)?; + + assert!(wallet.balance().confirmed > Amount::ZERO); + + // Build assets with the relative time lock + let rel_lock = relative::LockTime::from_512_second_intervals(relative_lock_units); + let assets = Assets::new() + .after(absolute::LockTime::from_height(wallet.tip_height()).expect("must be valid height")) + .older(rel_lock) + .add({ + let mut pks = vec![]; + for (_, desc) in wallet.graph.index.keychains() { + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + } + pks + }); + + // Find the input's prev_mtp (MTP of the block before confirmation) + let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); + let inputs: Vec = wallet + .graph + .index + .outpoints() + .iter() + .filter_map(|(_, op)| { + let plan = wallet.plan_of_output(*op, &assets)?; + canon_utxos.try_get_unspent(*op, plan) + }) + .collect(); + assert!(!inputs.is_empty(), "should have at least one input"); + + let input = &inputs[0]; + assert!( + matches!(input.relative_timelock(), Some(relative::LockTime::Time(_))), + "input should have time-based relative timelock, got: {:?}", + input.relative_timelock() + ); + + let prev_mtp = input + .status() + .expect("input should be confirmed") + .prev_mtp + .expect("prev_mtp should be available") + .to_consensus_u32(); + println!("Input prev_mtp: {prev_mtp}"); + + // Build + sign + finalize the spending tx once + let recipient_addr = env + .rpc_client() + .get_new_address(None, None)? + .address()? + .assume_checked(); + + let selection = wallet + .all_candidates_with(&assets) + .regroup(group_by_spk()) + .into_selection( + selection_algorithm_lowest_fee_bnb(FeeRate::from_sat_per_vb_unchecked(1), 100_000), + SelectorParams::new( + FeeStrategy::FeeRate(FeeRate::from_sat_per_vb_unchecked(10)), + vec![Output::with_script( + recipient_addr.script_pubkey(), + Amount::from_sat(50_000), + )], + ScriptSource::Descriptor(Box::new(wallet.definite_descriptor(INTERNAL, 0)?)), + wallet.change_policy(), + ), + )?; + + let mut psbt = selection.create_psbt(PsbtParams { + fallback_sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + })?; + let finalizer = selection.into_finalizer(); + let _ = psbt.sign(&wallet.signer, &wallet.secp); + let res = finalizer.finalize(&mut psbt); + assert!(res.is_finalized(), "should finalize"); + let tx = psbt.extract_tx()?; + + // --- BOUNDARY - 1: time_diff = relative_lock_seconds - 1 --- + // Mine 6 blocks at the target timestamp to shift MTP. After 6 blocks at timestamp T, + // the last 11 blocks are [old*5, T*6], so the 6th value (median) = T. + let target_mtp_before = prev_mtp + relative_lock_seconds - 1; + for _ in 0..6 { + let mut params = MineParams::default(); + params.time = Some(target_mtp_before); + env.mine_block(params)?; + } + wallet.sync(&env)?; + + let (_tip_height, tip_mtp) = wallet.tip_info(&client)?; + let time_diff = tip_mtp.to_consensus_u32().saturating_sub(prev_mtp); + println!( + "Before boundary: tip_mtp={}, time_diff={}, required={}", + tip_mtp.to_consensus_u32(), + time_diff, + relative_lock_seconds + ); + assert_eq!( + tip_mtp.to_consensus_u32(), + target_mtp_before, + "MTP should be prev_mtp + lock_seconds - 1" + ); + + // Refresh input and check BDK says locked + let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); + let inputs: Vec = wallet + .graph + .index + .outpoints() + .iter() + .filter_map(|(_, op)| { + let plan = wallet.plan_of_output(*op, &assets)?; + canon_utxos.try_get_unspent(*op, plan) + }) + .collect(); + let input = &inputs[0]; + + assert_eq!( + input.is_time_timelocked(tip_mtp), + Some(true), + "BDK should say time-timelocked at time_diff = {} (need {})", + time_diff, + relative_lock_seconds + ); + + // Bitcoin Core should reject + let broadcast_result = env.rpc_client().send_raw_transaction(&tx); + assert!( + broadcast_result.is_err(), + "Bitcoin Core should reject at time_diff = {}", + time_diff + ); + println!("Broadcast correctly rejected at time_diff = {time_diff}"); + + // --- EXACT BOUNDARY: time_diff = relative_lock_seconds --- + let target_mtp_at = prev_mtp + relative_lock_seconds; + for _ in 0..6 { + let mut params = MineParams::default(); + params.time = Some(target_mtp_at); + env.mine_block(params)?; + } + wallet.sync(&env)?; + + let (_tip_height, tip_mtp) = wallet.tip_info(&client)?; + let time_diff = tip_mtp.to_consensus_u32().saturating_sub(prev_mtp); + println!( + "At boundary: tip_mtp={}, time_diff={}, required={}", + tip_mtp.to_consensus_u32(), + time_diff, + relative_lock_seconds + ); + assert_eq!( + tip_mtp.to_consensus_u32(), + target_mtp_at, + "MTP should be prev_mtp + lock_seconds" + ); + + // Refresh input and check BDK says unlocked + let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); + let inputs: Vec = wallet + .graph + .index + .outpoints() + .iter() + .filter_map(|(_, op)| { + let plan = wallet.plan_of_output(*op, &assets)?; + canon_utxos.try_get_unspent(*op, plan) + }) + .collect(); + let input = &inputs[0]; + + assert_eq!( + input.is_time_timelocked(tip_mtp), + Some(false), + "BDK should say NOT time-timelocked at time_diff = {}", + time_diff + ); + + // Bitcoin Core should accept + let txid = env.rpc_client().send_raw_transaction(&tx)?.txid()?; + println!("Broadcast accepted at time_diff = {time_diff}: {txid}"); + + Ok(()) +} + +/// Unit test for absolute time-based `is_time_timelocked` at exact boundaries. +#[test] +fn test_is_time_timelocked_absolute_unit() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + + let lock_time = 500_000_100u32; + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_time})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_consensus(lock_time)) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + // Verify it has a time-based absolute timelock + assert!( + matches!( + input.absolute_timelock(), + Some(absolute::LockTime::Seconds(_)) + ), + "should have time-based absolute timelock" + ); + + // Bitcoin Core `IsFinalTx` checks: `nLockTime < MTP(tip)`. + // So the tx is final (unlocked) when `lock_time < MTP`, i.e., `MTP > lock_time`. + + // Boundary - 1: mtp = lock_time - 1 → locked + let mtp_before = absolute::Time::from_consensus(lock_time - 1)?; + assert_eq!( + input.is_time_timelocked(mtp_before), + Some(true), + "should be timelocked at MTP = lock_time - 1" + ); + + // At mtp = lock_time → still locked (Core: lock < lock is false → non-final) + let mtp_at = absolute::Time::from_consensus(lock_time)?; + assert_eq!( + input.is_time_timelocked(mtp_at), + Some(true), + "should be timelocked at MTP = lock_time" + ); + + // At mtp = lock_time + 1 → unlocked (Core: lock < lock+1 → final) + let mtp_after = absolute::Time::from_consensus(lock_time + 1)?; + assert_eq!( + input.is_time_timelocked(mtp_after), + Some(false), + "should NOT be timelocked at MTP = lock_time + 1" + ); + + Ok(()) +} + +/// Unit test for relative time-based `is_time_timelocked` at exact boundaries. +#[test] +fn test_is_time_timelocked_relative_unit() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + + // Relative lock = 2 units of 512 seconds = 1024 seconds + let relative_lock_units = 2u16; + let relative_lock_seconds = relative_lock_units as u32 * 512; // 1024 + let older_value = 0x400000u32 | relative_lock_units as u32; // 4194306 + + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_consensus(500_000_000)) + .older(relative::LockTime::from_512_second_intervals( + relative_lock_units, + )) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + + // Confirmed at height 100, prev_mtp = 500_001_000 + let conf_prev_mtp = 500_001_000u32; + let status = ConfirmationStatus::new(100, Some(conf_prev_mtp))?; + let input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; + + // Verify it has a time-based relative timelock + assert!( + matches!(input.relative_timelock(), Some(relative::LockTime::Time(_))), + "should have time-based relative timelock" + ); + + // BDK check: value * 512 > (tip_mtp - prev_mtp) → locked + // i.e. 1024 > diff → locked + + // Boundary - 1: diff = 1023 → locked (1024 > 1023) + let mtp_before = absolute::Time::from_consensus(conf_prev_mtp + relative_lock_seconds - 1)?; + assert_eq!( + input.is_time_timelocked(mtp_before), + Some(true), + "should be timelocked at time_diff = {} (need {})", + relative_lock_seconds - 1, + relative_lock_seconds + ); + + // Exact boundary: diff = 1024 → NOT locked (1024 > 1024 is false) + let mtp_at = absolute::Time::from_consensus(conf_prev_mtp + relative_lock_seconds)?; + assert_eq!( + input.is_time_timelocked(mtp_at), + Some(false), + "should NOT be timelocked at time_diff = {}", + relative_lock_seconds + ); + + // After boundary: diff = 1025 → NOT locked + let mtp_after = absolute::Time::from_consensus(conf_prev_mtp + relative_lock_seconds + 1)?; + assert_eq!( + input.is_time_timelocked(mtp_after), + Some(false), + "should NOT be timelocked at time_diff = {}", + relative_lock_seconds + 1 + ); + + Ok(()) +} From 24d6ccb35609635120c5a63924dc76d06f690a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 6 Feb 2026 08:39:56 +0000 Subject: [PATCH 6/7] test: Add edge case tests for timelock methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive unit tests covering edge cases for is_block_timelocked, is_time_timelocked, and is_timelocked methods: - Unconfirmed inputs with relative timelocks (must be considered locked) - Missing prev_mtp for relative time locks (returns None) - No-lock and wrong-lock-type scenarios (return false/Some(false)) - Combined is_timelocked method with various lock combinations These tests cover the bug cases identified in PR #36 review. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/timelock.rs | 741 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 741 insertions(+) diff --git a/tests/timelock.rs b/tests/timelock.rs index 2930039..82e01ad 100644 --- a/tests/timelock.rs +++ b/tests/timelock.rs @@ -1273,3 +1273,744 @@ fn test_is_time_timelocked_relative_unit() -> anyhow::Result<()> { Ok(()) } + +/// Unit test for `is_block_timelocked` edge cases not covered by other tests. +/// +/// Covers: +/// - Relative block lock with unconfirmed input (status = None) → should return true +/// - No timelocks → should return false +/// - Only absolute time lock → should return false +/// - Only relative time lock → should return false +#[test] +fn test_is_block_timelocked_edge_cases() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + let tip_height = absolute::Height::from_consensus(200)?; + + // --- Case 1: Relative block lock with UNCONFIRMED input --- + // This is the BUG CASE: unconfirmed inputs with relative locks must be considered locked + { + let rel_blocks = 10u16; + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({rel_blocks})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_height(500)?) + .older(relative::LockTime::from_height(rel_blocks)) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + + // Unconfirmed input (status = None) + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + assert!( + input.is_block_timelocked(tip_height), + "unconfirmed input with relative block lock must be locked" + ); + } + + // --- Case 2: No timelocks at all --- + { + let desc_str = format!("wpkh({TEST_XPRV}/86'/1'/0'/0/0)"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new().add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + assert!( + !input.is_block_timelocked(tip_height), + "input with no timelocks should not be block-timelocked" + ); + } + + // --- Case 3: Only absolute TIME lock (not block) --- + { + let lock_time = 500_000_100u32; // Time-based (> 500_000_000) + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_time})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_consensus(lock_time)) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + // Verify it's a time-based lock + assert!( + matches!( + input.absolute_timelock(), + Some(absolute::LockTime::Seconds(_)) + ), + "should have time-based lock" + ); + + assert!( + !input.is_block_timelocked(tip_height), + "input with only time lock should not be block-timelocked" + ); + } + + // --- Case 4: Only relative TIME lock (not block) --- + { + let relative_lock_units = 2u16; + let older_value = 0x400000u32 | relative_lock_units as u32; // Time flag set + + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_consensus(500_000_000)) + .older(relative::LockTime::from_512_second_intervals( + relative_lock_units, + )) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + // Verify it's a time-based relative lock + assert!( + matches!(input.relative_timelock(), Some(relative::LockTime::Time(_))), + "should have time-based relative lock" + ); + + assert!( + !input.is_block_timelocked(tip_height), + "input with only relative time lock should not be block-timelocked" + ); + } + + Ok(()) +} + +/// Unit test for `is_time_timelocked` edge cases not covered by other tests. +/// +/// Covers: +/// - Relative time lock with unconfirmed input (status = None) → should return Some(true) +/// - Relative time lock with missing prev_mtp → should return None +/// - No timelocks → should return Some(false) +/// - Only absolute block lock → should return Some(false) +/// - Only relative block lock → should return Some(false) +#[test] +fn test_is_time_timelocked_edge_cases() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + let tip_mtp = absolute::Time::from_consensus(500_002_000)?; + + // --- Case 1: Relative time lock with UNCONFIRMED input --- + // This is the BUG CASE: unconfirmed inputs with relative time locks must be considered locked + { + let relative_lock_units = 2u16; + let older_value = 0x400000u32 | relative_lock_units as u32; // Time flag set + + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_consensus(500_000_000)) + .older(relative::LockTime::from_512_second_intervals( + relative_lock_units, + )) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + + // Unconfirmed input (status = None) + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + assert_eq!( + input.is_time_timelocked(tip_mtp), + Some(true), + "unconfirmed input with relative time lock must be locked" + ); + } + + // --- Case 2: Relative time lock with MISSING prev_mtp --- + { + let relative_lock_units = 2u16; + let older_value = 0x400000u32 | relative_lock_units as u32; + + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_consensus(500_000_000)) + .older(relative::LockTime::from_512_second_intervals( + relative_lock_units, + )) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + + // Confirmed but without prev_mtp + let status = ConfirmationStatus::new(100, None)?; + let input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; + + assert_eq!( + input.is_time_timelocked(tip_mtp), + None, + "confirmed input with relative time lock but missing prev_mtp should return None" + ); + } + + // --- Case 3: No timelocks at all --- + { + let desc_str = format!("wpkh({TEST_XPRV}/86'/1'/0'/0/0)"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new().add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + assert_eq!( + input.is_time_timelocked(tip_mtp), + Some(false), + "input with no timelocks should not be time-timelocked" + ); + } + + // --- Case 4: Only absolute BLOCK lock (not time) --- + { + let lock_height = 100u32; + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_height})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_height(lock_height)?) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + // Verify it's a block-based lock + assert!( + matches!( + input.absolute_timelock(), + Some(absolute::LockTime::Blocks(_)) + ), + "should have block-based lock" + ); + + assert_eq!( + input.is_time_timelocked(tip_mtp), + Some(false), + "input with only block lock should not be time-timelocked" + ); + } + + // --- Case 5: Only relative BLOCK lock (not time) --- + { + let rel_blocks = 10u16; + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({rel_blocks})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_height(500)?) + .older(relative::LockTime::from_height(rel_blocks)) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + // Verify it's a block-based relative lock + assert!( + matches!( + input.relative_timelock(), + Some(relative::LockTime::Blocks(_)) + ), + "should have block-based relative lock" + ); + + assert_eq!( + input.is_time_timelocked(tip_mtp), + Some(false), + "input with only relative block lock should not be time-timelocked" + ); + } + + Ok(()) +} + +/// Unit test for `is_timelocked` edge cases. +/// +/// Covers: +/// - Block lock NOT satisfied, no mtp → Some(true) +/// - Block lock satisfied, no mtp → Some(false) +/// - Absolute time lock only, no mtp → None (BUG CASE: should be None, was Some(false) with && bug) +/// - Relative time lock only, no mtp → None +/// - Time lock with mtp, satisfied → Some(false) +/// - Time lock with mtp, NOT satisfied → Some(true) +/// - Mixed: block NOT satisfied + time lock → Some(true) +/// - Mixed: block satisfied + time lock, no mtp → None +/// - No locks → Some(false) +#[test] +fn test_is_timelocked_edge_cases() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + + // --- Case 1: Block lock NOT satisfied, no mtp --- + { + let lock_height = 100u32; + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_height})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_height(lock_height)?) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + let low_height = absolute::Height::from_consensus(50)?; + assert_eq!( + input.is_timelocked(low_height, None), + Some(true), + "block lock not satisfied, no mtp → should be locked" + ); + } + + // --- Case 2: Block lock satisfied, no mtp --- + { + let lock_height = 100u32; + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_height})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_height(lock_height)?) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + let high_height = absolute::Height::from_consensus(200)?; + assert_eq!( + input.is_timelocked(high_height, None), + Some(false), + "block lock satisfied, no mtp → should NOT be locked" + ); + } + + // --- Case 3: Absolute time lock ONLY, no mtp --- + // BUG CASE: Previously with && bug, this would return Some(false) instead of None + { + let lock_time = 500_000_100u32; + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_time})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_consensus(lock_time)) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + let any_height = absolute::Height::from_consensus(200)?; + assert_eq!( + input.is_timelocked(any_height, None), + None, + "absolute time lock only, no mtp → cannot determine (should be None)" + ); + } + + // --- Case 4: Relative time lock ONLY, no mtp --- + { + let relative_lock_units = 2u16; + let older_value = 0x400000u32 | relative_lock_units as u32; + + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_consensus(500_000_000)) + .older(relative::LockTime::from_512_second_intervals( + relative_lock_units, + )) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + let any_height = absolute::Height::from_consensus(200)?; + assert_eq!( + input.is_timelocked(any_height, None), + None, + "relative time lock only, no mtp → cannot determine (should be None)" + ); + } + + // --- Case 5: Absolute time lock with mtp, SATISFIED --- + { + let lock_time = 500_000_100u32; + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_time})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_consensus(lock_time)) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + let any_height = absolute::Height::from_consensus(200)?; + let high_mtp = absolute::Time::from_consensus(lock_time + 1)?; + assert_eq!( + input.is_timelocked(any_height, Some(high_mtp)), + Some(false), + "time lock satisfied with mtp → should NOT be locked" + ); + } + + // --- Case 6: Absolute time lock with mtp, NOT satisfied --- + { + let lock_time = 500_000_100u32; + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_time})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new() + .after(absolute::LockTime::from_consensus(lock_time)) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + let any_height = absolute::Height::from_consensus(200)?; + let low_mtp = absolute::Time::from_consensus(lock_time - 100)?; + assert_eq!( + input.is_timelocked(any_height, Some(low_mtp)), + Some(true), + "time lock NOT satisfied with mtp → should be locked" + ); + } + + // --- Case 7: Mixed - block NOT satisfied + time lock --- + // Block lock takes precedence when not satisfied + { + let block_lock = 100u32; + let time_lock = 500_000_100u32; + // Use a descriptor with both block and time requirements (through different paths) + // For simplicity, we'll use a block-locked input with time check + let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({block_lock})))"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + // Note: Can't actually have both block and time absolute locks simultaneously in bitcoin + // But we can test the logic with a block lock + let assets = Assets::new() + .after(absolute::LockTime::from_height(block_lock)?) + .add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + let low_height = absolute::Height::from_consensus(50)?; + let any_mtp = absolute::Time::from_consensus(time_lock + 1)?; + assert_eq!( + input.is_timelocked(low_height, Some(any_mtp)), + Some(true), + "block lock not satisfied → should be locked (regardless of time)" + ); + } + + // --- Case 8: No locks at all --- + { + let desc_str = format!("wpkh({TEST_XPRV}/86'/1'/0'/0/0)"); + let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = Assets::new().add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + + let any_height = absolute::Height::from_consensus(200)?; + + // No locks, no mtp + assert_eq!( + input.is_timelocked(any_height, None), + Some(false), + "no locks, no mtp → should NOT be locked" + ); + + // No locks, with mtp + let any_mtp = absolute::Time::from_consensus(500_001_000)?; + assert_eq!( + input.is_timelocked(any_height, Some(any_mtp)), + Some(false), + "no locks, with mtp → should NOT be locked" + ); + } + + Ok(()) +} From 5be12c4c16710e576e11ada5f8db447d4ddce31c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 6 Feb 2026 09:12:46 +0000 Subject: [PATCH 7/7] refactor(test): Reduce boilerplate in timelock tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `create_test_input` helper for unit tests and `Wallet::get_inputs` helper for integration tests, reducing ~720 lines of repeated code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- testenv/src/lib.rs | 14 + tests/timelock.rs | 1326 ++++++++++---------------------------------- 2 files changed, 309 insertions(+), 1031 deletions(-) diff --git a/testenv/src/lib.rs b/testenv/src/lib.rs index 483a869..922f55b 100644 --- a/testenv/src/lib.rs +++ b/testenv/src/lib.rs @@ -269,6 +269,20 @@ impl Wallet { self.all_candidates_with(&assets) } + /// Get all unspent inputs from the wallet with the given assets. + pub fn get_inputs(&self, assets: &Assets) -> Vec { + let canon_utxos = CanonicalUnspents::new(self.canonical_txs()); + self.graph + .index + .outpoints() + .iter() + .filter_map(|(_, op)| { + let plan = self.plan_of_output(*op, assets)?; + canon_utxos.try_get_unspent(*op, plan) + }) + .collect() + } + pub fn all_candidates_with(&self, assets: &Assets) -> InputCandidates { let index = &self.graph.index; let canon_utxos = CanonicalUnspents::new(self.canonical_txs()); diff --git a/tests/timelock.rs b/tests/timelock.rs index 82e01ad..fe6e5bb 100644 --- a/tests/timelock.rs +++ b/tests/timelock.rs @@ -6,8 +6,8 @@ use bdk_chain::miniscript::ForEachKey; use bdk_testenv::{MineParams, TestEnv}; use bdk_tx::{ - filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, CanonicalUnspents, - ConfirmationStatus, FeeStrategy, Input, Output, PsbtParams, ScriptSource, SelectorParams, + filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, ConfirmationStatus, + FeeStrategy, Input, Output, PsbtParams, ScriptSource, SelectorParams, }; use bdk_tx_testenv::{TestEnvExt, Wallet, EXTERNAL, INTERNAL}; use bitcoin::{ @@ -19,6 +19,40 @@ use miniscript::{plan::Assets, Descriptor}; // Test xprv for creating timelocked descriptors const TEST_XPRV: &str = "tprv8ZgxMBicQKsPd3krDUsBAmtnRsK3rb8u5yi1zhQgMhF1tR8MW7xfE4rnrbbsrbPR52e7rKapu6ztw1jXveJSCGHEriUGZV7mCe88duLp5pj"; +/// Creates a test Input from a descriptor string, assets, and confirmation status. +/// +/// This handles the boilerplate of parsing the descriptor, creating a dummy transaction, +/// extracting public keys, building the plan, and creating the Input. +fn create_test_input( + secp: &Secp256k1, + desc_str: &str, + assets: Assets, + status: Option, +) -> anyhow::Result { + let (desc, _keymap) = Descriptor::parse_descriptor(secp, desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: def_desc.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + + let mut pks = vec![]; + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + let assets = assets.add(pks); + + let plan = def_desc.plan(&assets).expect("should create plan"); + Ok(Input::from_prev_tx(plan, prev_tx, 0, status)?) +} + /// Test absolute block-height timelock checking logic. /// /// This test verifies that `is_block_timelocked` and `is_spendable` correctly @@ -73,18 +107,7 @@ fn test_absolute_block_height_timelock_logic() -> anyhow::Result<()> { // Get the input let (tip_height, tip_mtp) = wallet.tip_info(&client)?; - let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); - let inputs: Vec = wallet - .graph - .index - .outpoints() - .iter() - .filter_map(|(_, op)| { - let plan = wallet.plan_of_output(*op, &assets)?; - canon_utxos.try_get_unspent(*op, plan) - }) - .collect(); - + let inputs = wallet.get_inputs(&assets); assert!(!inputs.is_empty(), "should have at least one input"); let input = &inputs[0]; @@ -117,18 +140,7 @@ fn test_absolute_block_height_timelock_logic() -> anyhow::Result<()> { println!("New height: {}", new_tip_height.to_consensus_u32()); // Refresh input - let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); - let inputs: Vec = wallet - .graph - .index - .outpoints() - .iter() - .filter_map(|(_, op)| { - let plan = wallet.plan_of_output(*op, &assets)?; - canon_utxos.try_get_unspent(*op, plan) - }) - .collect(); - + let inputs = wallet.get_inputs(&assets); let input = &inputs[0]; // AFTER lock height: should NOT be locked @@ -207,18 +219,7 @@ fn test_relative_block_height_timelock_logic() -> anyhow::Result<()> { // Get the input let (tip_height, tip_mtp) = wallet.tip_info(&client)?; - let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); - let inputs: Vec = wallet - .graph - .index - .outpoints() - .iter() - .filter_map(|(_, op)| { - let plan = wallet.plan_of_output(*op, &assets)?; - canon_utxos.try_get_unspent(*op, plan) - }) - .collect(); - + let inputs = wallet.get_inputs(&assets); assert!(!inputs.is_empty(), "should have at least one input"); let input = &inputs[0]; @@ -257,18 +258,7 @@ fn test_relative_block_height_timelock_logic() -> anyhow::Result<()> { ); // Refresh input - let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); - let inputs: Vec = wallet - .graph - .index - .outpoints() - .iter() - .filter_map(|(_, op)| { - let plan = wallet.plan_of_output(*op, &assets)?; - canon_utxos.try_get_unspent(*op, plan) - }) - .collect(); - + let inputs = wallet.get_inputs(&assets); let input = &inputs[0]; // AFTER relative lock: should NOT be locked @@ -323,17 +313,7 @@ fn test_coinbase_maturity() -> anyhow::Result<()> { // Get the coinbase input let (tip_height, tip_mtp) = wallet.tip_info(&client)?; let assets = wallet.assets(); - let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); - let inputs: Vec = wallet - .graph - .index - .outpoints() - .iter() - .filter_map(|(_, op)| { - let plan = wallet.plan_of_output(*op, &assets)?; - canon_utxos.try_get_unspent(*op, plan) - }) - .collect(); + let inputs = wallet.get_inputs(&assets); // Find the coinbase input let coinbase_input = inputs.iter().find(|i| i.is_coinbase()); @@ -369,18 +349,7 @@ fn test_coinbase_maturity() -> anyhow::Result<()> { // Refresh input let assets = wallet.assets(); - let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); - let inputs: Vec = wallet - .graph - .index - .outpoints() - .iter() - .filter_map(|(_, op)| { - let plan = wallet.plan_of_output(*op, &assets)?; - canon_utxos.try_get_unspent(*op, plan) - }) - .collect(); - + let inputs = wallet.get_inputs(&assets); let coinbase_input = inputs.iter().find(|i| i.is_coinbase()).unwrap(); let is_immature = coinbase_input.is_immature(tip_height); @@ -447,38 +416,14 @@ fn test_coinbase_maturity() -> anyhow::Result<()> { #[test] fn test_is_block_timelocked_unit() -> anyhow::Result<()> { let secp = Secp256k1::new(); - - // Create a simple timelocked descriptor let lock_height = 100u32; - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_height})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - // Create a dummy transaction to get an outpoint - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - // Create assets for planning - must include the lock height and keys - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() - .after(absolute::LockTime::from_height(lock_height)?) - .add(pks); - let plan = def_desc.plan(&assets).expect("should create plan"); - - // Create input without confirmation status (unconfirmed) - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_height})))"), + Assets::new().after(absolute::LockTime::from_height(lock_height)?), + None, + )?; // Verify the input has the expected absolute timelock assert_eq!( @@ -490,44 +435,21 @@ fn test_is_block_timelocked_unit() -> anyhow::Result<()> { // Bitcoin Core `IsFinalTx` checks: `nLockTime < nBlockHeight` where nBlockHeight = tip + 1. // So the tx is final (unlocked) when `lock_height < tip + 1`, i.e., `tip >= lock_height`. let below_lock = absolute::Height::from_consensus(lock_height - 10)?; - let at_lock_minus_1 = absolute::Height::from_consensus(lock_height - 1)?; // spending_height = lock_height - let at_lock = absolute::Height::from_consensus(lock_height)?; // spending_height = lock_height + 1 + let at_lock_minus_1 = absolute::Height::from_consensus(lock_height - 1)?; + let at_lock = absolute::Height::from_consensus(lock_height)?; let above_lock = absolute::Height::from_consensus(lock_height + 10)?; // Well below lock height: should be timelocked - assert!( - input.is_block_timelocked(below_lock), - "should be timelocked at tip {} (lock: {})", - below_lock.to_consensus_u32(), - lock_height - ); + assert!(input.is_block_timelocked(below_lock)); // At tip = lock_height - 1 (spending_height = lock_height): still locked - // Core: lock < spending_height → 100 < 100 → false → non-final - assert!( - input.is_block_timelocked(at_lock_minus_1), - "should be timelocked at tip {} (spending_height = lock = {})", - at_lock_minus_1.to_consensus_u32(), - lock_height - ); + assert!(input.is_block_timelocked(at_lock_minus_1)); // At tip = lock_height (spending_height = lock_height + 1): unlocked - // Core: lock < spending_height → 100 < 101 → true → final - assert!( - !input.is_block_timelocked(at_lock), - "should NOT be timelocked at tip {} (spending_height {} > lock {})", - at_lock.to_consensus_u32(), - at_lock.to_consensus_u32() + 1, - lock_height - ); + assert!(!input.is_block_timelocked(at_lock)); // Above lock height: should NOT be timelocked - assert!( - !input.is_block_timelocked(above_lock), - "should NOT be timelocked at tip {} (lock: {})", - above_lock.to_consensus_u32(), - lock_height - ); + assert!(!input.is_block_timelocked(above_lock)); Ok(()) } @@ -536,41 +458,17 @@ fn test_is_block_timelocked_unit() -> anyhow::Result<()> { #[test] fn test_is_block_timelocked_relative_unit() -> anyhow::Result<()> { let secp = Secp256k1::new(); - - // Create a descriptor with relative timelock let rel_blocks = 10u16; - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({rel_blocks})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - // Create assets with keys and timelocks - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() - .after(absolute::LockTime::from_height(200)?) - .older(relative::LockTime::from_height(rel_blocks)) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - - // Confirmed at height 100 let conf_height = 100u32; - let status = ConfirmationStatus::new(conf_height, None)?; - let input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({rel_blocks})))"), + Assets::new() + .after(absolute::LockTime::from_height(200)?) + .older(relative::LockTime::from_height(rel_blocks)), + Some(ConfirmationStatus::new(conf_height, None)?), + )?; // Verify the input has the expected relative timelock assert_eq!( @@ -579,30 +477,16 @@ fn test_is_block_timelocked_relative_unit() -> anyhow::Result<()> { ); // Test at various heights relative to confirmation - // spending_height = tip_height + 1 - // height_diff = spending_height - conf_height + // spending_height = tip_height + 1, height_diff = spending_height - conf_height // 5 blocks after confirmation: height_diff = 6 < 10, should be locked - let tip_5_after = absolute::Height::from_consensus(conf_height + 4)?; - assert!( - input.is_block_timelocked(tip_5_after), - "should be timelocked 5 blocks after confirmation (need {})", - rel_blocks - ); + assert!(input.is_block_timelocked(absolute::Height::from_consensus(conf_height + 4)?)); // 10 blocks after confirmation: height_diff = 11 >= 10, should NOT be locked - let tip_10_after = absolute::Height::from_consensus(conf_height + 9)?; - assert!( - !input.is_block_timelocked(tip_10_after), - "should NOT be timelocked 10 blocks after confirmation" - ); + assert!(!input.is_block_timelocked(absolute::Height::from_consensus(conf_height + 9)?)); // 15 blocks after confirmation: should NOT be locked - let tip_15_after = absolute::Height::from_consensus(conf_height + 14)?; - assert!( - !input.is_block_timelocked(tip_15_after), - "should NOT be timelocked 15 blocks after confirmation" - ); + assert!(!input.is_block_timelocked(absolute::Height::from_consensus(conf_height + 14)?)); Ok(()) } @@ -669,17 +553,7 @@ fn test_absolute_time_timelock_logic() -> anyhow::Result<()> { // Verify the input has a time-based absolute timelock { - let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); - let inputs: Vec = wallet - .graph - .index - .outpoints() - .iter() - .filter_map(|(_, op)| { - let plan = wallet.plan_of_output(*op, &assets)?; - canon_utxos.try_get_unspent(*op, plan) - }) - .collect(); + let inputs = wallet.get_inputs(&assets); assert!(!inputs.is_empty(), "should have at least one input"); assert!( matches!( @@ -754,17 +628,7 @@ fn test_absolute_time_timelock_logic() -> anyhow::Result<()> { ); // Refresh input and check BDK says locked - let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); - let inputs: Vec = wallet - .graph - .index - .outpoints() - .iter() - .filter_map(|(_, op)| { - let plan = wallet.plan_of_output(*op, &assets)?; - canon_utxos.try_get_unspent(*op, plan) - }) - .collect(); + let inputs = wallet.get_inputs(&assets); let input = &inputs[0]; assert_eq!( @@ -809,17 +673,7 @@ fn test_absolute_time_timelock_logic() -> anyhow::Result<()> { ); // Refresh input and check BDK says locked - let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); - let inputs: Vec = wallet - .graph - .index - .outpoints() - .iter() - .filter_map(|(_, op)| { - let plan = wallet.plan_of_output(*op, &assets)?; - canon_utxos.try_get_unspent(*op, plan) - }) - .collect(); + let inputs = wallet.get_inputs(&assets); let input = &inputs[0]; assert_eq!( @@ -863,17 +717,7 @@ fn test_absolute_time_timelock_logic() -> anyhow::Result<()> { ); // Refresh input and check BDK says unlocked - let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); - let inputs: Vec = wallet - .graph - .index - .outpoints() - .iter() - .filter_map(|(_, op)| { - let plan = wallet.plan_of_output(*op, &assets)?; - canon_utxos.try_get_unspent(*op, plan) - }) - .collect(); + let inputs = wallet.get_inputs(&assets); let input = &inputs[0]; assert_eq!( @@ -952,17 +796,7 @@ fn test_relative_time_timelock_logic() -> anyhow::Result<()> { }); // Find the input's prev_mtp (MTP of the block before confirmation) - let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); - let inputs: Vec = wallet - .graph - .index - .outpoints() - .iter() - .filter_map(|(_, op)| { - let plan = wallet.plan_of_output(*op, &assets)?; - canon_utxos.try_get_unspent(*op, plan) - }) - .collect(); + let inputs = wallet.get_inputs(&assets); assert!(!inputs.is_empty(), "should have at least one input"); let input = &inputs[0]; @@ -1039,17 +873,7 @@ fn test_relative_time_timelock_logic() -> anyhow::Result<()> { ); // Refresh input and check BDK says locked - let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); - let inputs: Vec = wallet - .graph - .index - .outpoints() - .iter() - .filter_map(|(_, op)| { - let plan = wallet.plan_of_output(*op, &assets)?; - canon_utxos.try_get_unspent(*op, plan) - }) - .collect(); + let inputs = wallet.get_inputs(&assets); let input = &inputs[0]; assert_eq!( @@ -1093,17 +917,7 @@ fn test_relative_time_timelock_logic() -> anyhow::Result<()> { ); // Refresh input and check BDK says unlocked - let canon_utxos = CanonicalUnspents::new(wallet.canonical_txs()); - let inputs: Vec = wallet - .graph - .index - .outpoints() - .iter() - .filter_map(|(_, op)| { - let plan = wallet.plan_of_output(*op, &assets)?; - canon_utxos.try_get_unspent(*op, plan) - }) - .collect(); + let inputs = wallet.get_inputs(&assets); let input = &inputs[0]; assert_eq!( @@ -1124,68 +938,40 @@ fn test_relative_time_timelock_logic() -> anyhow::Result<()> { #[test] fn test_is_time_timelocked_absolute_unit() -> anyhow::Result<()> { let secp = Secp256k1::new(); - let lock_time = 500_000_100u32; - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_time})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() - .after(absolute::LockTime::from_consensus(lock_time)) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_time})))"), + Assets::new().after(absolute::LockTime::from_consensus(lock_time)), + None, + )?; // Verify it has a time-based absolute timelock - assert!( - matches!( - input.absolute_timelock(), - Some(absolute::LockTime::Seconds(_)) - ), - "should have time-based absolute timelock" - ); + assert!(matches!( + input.absolute_timelock(), + Some(absolute::LockTime::Seconds(_)) + )); // Bitcoin Core `IsFinalTx` checks: `nLockTime < MTP(tip)`. // So the tx is final (unlocked) when `lock_time < MTP`, i.e., `MTP > lock_time`. - // Boundary - 1: mtp = lock_time - 1 → locked - let mtp_before = absolute::Time::from_consensus(lock_time - 1)?; + // mtp = lock_time - 1 → locked assert_eq!( - input.is_time_timelocked(mtp_before), - Some(true), - "should be timelocked at MTP = lock_time - 1" + input.is_time_timelocked(absolute::Time::from_consensus(lock_time - 1)?), + Some(true) ); - // At mtp = lock_time → still locked (Core: lock < lock is false → non-final) - let mtp_at = absolute::Time::from_consensus(lock_time)?; + // mtp = lock_time → still locked (Core: lock < lock is false) assert_eq!( - input.is_time_timelocked(mtp_at), - Some(true), - "should be timelocked at MTP = lock_time" + input.is_time_timelocked(absolute::Time::from_consensus(lock_time)?), + Some(true) ); - // At mtp = lock_time + 1 → unlocked (Core: lock < lock+1 → final) - let mtp_after = absolute::Time::from_consensus(lock_time + 1)?; + // mtp = lock_time + 1 → unlocked assert_eq!( - input.is_time_timelocked(mtp_after), - Some(false), - "should NOT be timelocked at MTP = lock_time + 1" + input.is_time_timelocked(absolute::Time::from_consensus(lock_time + 1)?), + Some(false) ); Ok(()) @@ -1198,77 +984,51 @@ fn test_is_time_timelocked_relative_unit() -> anyhow::Result<()> { // Relative lock = 2 units of 512 seconds = 1024 seconds let relative_lock_units = 2u16; - let relative_lock_seconds = relative_lock_units as u32 * 512; // 1024 - let older_value = 0x400000u32 | relative_lock_units as u32; // 4194306 - - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() - .after(absolute::LockTime::from_consensus(500_000_000)) - .older(relative::LockTime::from_512_second_intervals( - relative_lock_units, - )) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - - // Confirmed at height 100, prev_mtp = 500_001_000 + let relative_lock_seconds = relative_lock_units as u32 * 512; + let older_value = 0x400000u32 | relative_lock_units as u32; // time flag set let conf_prev_mtp = 500_001_000u32; - let status = ConfirmationStatus::new(100, Some(conf_prev_mtp))?; - let input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; + + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"), + Assets::new() + .after(absolute::LockTime::from_consensus(500_000_000)) + .older(relative::LockTime::from_512_second_intervals( + relative_lock_units, + )), + Some(ConfirmationStatus::new(100, Some(conf_prev_mtp))?), + )?; // Verify it has a time-based relative timelock - assert!( - matches!(input.relative_timelock(), Some(relative::LockTime::Time(_))), - "should have time-based relative timelock" - ); + assert!(matches!( + input.relative_timelock(), + Some(relative::LockTime::Time(_)) + )); // BDK check: value * 512 > (tip_mtp - prev_mtp) → locked - // i.e. 1024 > diff → locked - // Boundary - 1: diff = 1023 → locked (1024 > 1023) - let mtp_before = absolute::Time::from_consensus(conf_prev_mtp + relative_lock_seconds - 1)?; + // diff = 1023 → locked (1024 > 1023) assert_eq!( - input.is_time_timelocked(mtp_before), - Some(true), - "should be timelocked at time_diff = {} (need {})", - relative_lock_seconds - 1, - relative_lock_seconds + input.is_time_timelocked(absolute::Time::from_consensus( + conf_prev_mtp + relative_lock_seconds - 1 + )?), + Some(true) ); - // Exact boundary: diff = 1024 → NOT locked (1024 > 1024 is false) - let mtp_at = absolute::Time::from_consensus(conf_prev_mtp + relative_lock_seconds)?; + // diff = 1024 → NOT locked (1024 > 1024 is false) assert_eq!( - input.is_time_timelocked(mtp_at), - Some(false), - "should NOT be timelocked at time_diff = {}", - relative_lock_seconds + input.is_time_timelocked(absolute::Time::from_consensus( + conf_prev_mtp + relative_lock_seconds + )?), + Some(false) ); - // After boundary: diff = 1025 → NOT locked - let mtp_after = absolute::Time::from_consensus(conf_prev_mtp + relative_lock_seconds + 1)?; + // diff = 1025 → NOT locked assert_eq!( - input.is_time_timelocked(mtp_after), - Some(false), - "should NOT be timelocked at time_diff = {}", - relative_lock_seconds + 1 + input.is_time_timelocked(absolute::Time::from_consensus( + conf_prev_mtp + relative_lock_seconds + 1 + )?), + Some(false) ); Ok(()) @@ -1286,166 +1046,59 @@ fn test_is_block_timelocked_edge_cases() -> anyhow::Result<()> { let secp = Secp256k1::new(); let tip_height = absolute::Height::from_consensus(200)?; - // --- Case 1: Relative block lock with UNCONFIRMED input --- - // This is the BUG CASE: unconfirmed inputs with relative locks must be considered locked - { - let rel_blocks = 10u16; - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({rel_blocks})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() + // Case 1: Relative block lock with UNCONFIRMED input (BUG CASE) + let rel_blocks = 10u16; + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({rel_blocks})))"), + Assets::new() .after(absolute::LockTime::from_height(500)?) - .older(relative::LockTime::from_height(rel_blocks)) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - - // Unconfirmed input (status = None) - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - assert!( - input.is_block_timelocked(tip_height), - "unconfirmed input with relative block lock must be locked" - ); - } - - // --- Case 2: No timelocks at all --- - { - let desc_str = format!("wpkh({TEST_XPRV}/86'/1'/0'/0/0)"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new().add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - assert!( - !input.is_block_timelocked(tip_height), - "input with no timelocks should not be block-timelocked" - ); - } - - // --- Case 3: Only absolute TIME lock (not block) --- - { - let lock_time = 500_000_100u32; // Time-based (> 500_000_000) - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_time})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() - .after(absolute::LockTime::from_consensus(lock_time)) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - // Verify it's a time-based lock - assert!( - matches!( - input.absolute_timelock(), - Some(absolute::LockTime::Seconds(_)) - ), - "should have time-based lock" - ); - - assert!( - !input.is_block_timelocked(tip_height), - "input with only time lock should not be block-timelocked" - ); - } + .older(relative::LockTime::from_height(rel_blocks)), + None, + )?; + assert!(input.is_block_timelocked(tip_height)); + + // Case 2: No timelocks at all + let input = create_test_input( + &secp, + &format!("wpkh({TEST_XPRV}/86'/1'/0'/0/0)"), + Assets::new(), + None, + )?; + assert!(!input.is_block_timelocked(tip_height)); - // --- Case 4: Only relative TIME lock (not block) --- - { - let relative_lock_units = 2u16; - let older_value = 0x400000u32 | relative_lock_units as u32; // Time flag set - - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; + // Case 3: Only absolute TIME lock (not block) + let lock_time = 500_000_100u32; + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_time})))"), + Assets::new().after(absolute::LockTime::from_consensus(lock_time)), + None, + )?; + assert!(matches!( + input.absolute_timelock(), + Some(absolute::LockTime::Seconds(_)) + )); + assert!(!input.is_block_timelocked(tip_height)); - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() + // Case 4: Only relative TIME lock (not block) + let relative_lock_units = 2u16; + let older_value = 0x400000u32 | relative_lock_units as u32; + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"), + Assets::new() .after(absolute::LockTime::from_consensus(500_000_000)) .older(relative::LockTime::from_512_second_intervals( relative_lock_units, - )) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - // Verify it's a time-based relative lock - assert!( - matches!(input.relative_timelock(), Some(relative::LockTime::Time(_))), - "should have time-based relative lock" - ); - - assert!( - !input.is_block_timelocked(tip_height), - "input with only relative time lock should not be block-timelocked" - ); - } + )), + None, + )?; + assert!(matches!( + input.relative_timelock(), + Some(relative::LockTime::Time(_)) + )); + assert!(!input.is_block_timelocked(tip_height)); Ok(()) } @@ -1463,217 +1116,72 @@ fn test_is_time_timelocked_edge_cases() -> anyhow::Result<()> { let secp = Secp256k1::new(); let tip_mtp = absolute::Time::from_consensus(500_002_000)?; - // --- Case 1: Relative time lock with UNCONFIRMED input --- - // This is the BUG CASE: unconfirmed inputs with relative time locks must be considered locked - { - let relative_lock_units = 2u16; - let older_value = 0x400000u32 | relative_lock_units as u32; // Time flag set - - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() + // Case 1: Relative time lock with UNCONFIRMED input (BUG CASE) + let relative_lock_units = 2u16; + let older_value = 0x400000u32 | relative_lock_units as u32; + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"), + Assets::new() .after(absolute::LockTime::from_consensus(500_000_000)) .older(relative::LockTime::from_512_second_intervals( relative_lock_units, - )) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - - // Unconfirmed input (status = None) - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - assert_eq!( - input.is_time_timelocked(tip_mtp), - Some(true), - "unconfirmed input with relative time lock must be locked" - ); - } - - // --- Case 2: Relative time lock with MISSING prev_mtp --- - { - let relative_lock_units = 2u16; - let older_value = 0x400000u32 | relative_lock_units as u32; - - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; + )), + None, + )?; + assert_eq!(input.is_time_timelocked(tip_mtp), Some(true)); - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() + // Case 2: Relative time lock with MISSING prev_mtp + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"), + Assets::new() .after(absolute::LockTime::from_consensus(500_000_000)) .older(relative::LockTime::from_512_second_intervals( relative_lock_units, - )) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - - // Confirmed but without prev_mtp - let status = ConfirmationStatus::new(100, None)?; - let input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; - - assert_eq!( - input.is_time_timelocked(tip_mtp), - None, - "confirmed input with relative time lock but missing prev_mtp should return None" - ); - } - - // --- Case 3: No timelocks at all --- - { - let desc_str = format!("wpkh({TEST_XPRV}/86'/1'/0'/0/0)"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new().add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - assert_eq!( - input.is_time_timelocked(tip_mtp), - Some(false), - "input with no timelocks should not be time-timelocked" - ); - } - - // --- Case 4: Only absolute BLOCK lock (not time) --- - { - let lock_height = 100u32; - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_height})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() - .after(absolute::LockTime::from_height(lock_height)?) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - // Verify it's a block-based lock - assert!( - matches!( - input.absolute_timelock(), - Some(absolute::LockTime::Blocks(_)) - ), - "should have block-based lock" - ); - - assert_eq!( - input.is_time_timelocked(tip_mtp), - Some(false), - "input with only block lock should not be time-timelocked" - ); - } + )), + Some(ConfirmationStatus::new(100, None)?), // confirmed but no prev_mtp + )?; + assert_eq!(input.is_time_timelocked(tip_mtp), None); + + // Case 3: No timelocks at all + let input = create_test_input( + &secp, + &format!("wpkh({TEST_XPRV}/86'/1'/0'/0/0)"), + Assets::new(), + None, + )?; + assert_eq!(input.is_time_timelocked(tip_mtp), Some(false)); - // --- Case 5: Only relative BLOCK lock (not time) --- - { - let rel_blocks = 10u16; - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({rel_blocks})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; + // Case 4: Only absolute BLOCK lock (not time) + let lock_height = 100u32; + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_height})))"), + Assets::new().after(absolute::LockTime::from_height(lock_height)?), + None, + )?; + assert!(matches!( + input.absolute_timelock(), + Some(absolute::LockTime::Blocks(_)) + )); + assert_eq!(input.is_time_timelocked(tip_mtp), Some(false)); - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() + // Case 5: Only relative BLOCK lock (not time) + let rel_blocks = 10u16; + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({rel_blocks})))"), + Assets::new() .after(absolute::LockTime::from_height(500)?) - .older(relative::LockTime::from_height(rel_blocks)) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - // Verify it's a block-based relative lock - assert!( - matches!( - input.relative_timelock(), - Some(relative::LockTime::Blocks(_)) - ), - "should have block-based relative lock" - ); - - assert_eq!( - input.is_time_timelocked(tip_mtp), - Some(false), - "input with only relative block lock should not be time-timelocked" - ); - } + .older(relative::LockTime::from_height(rel_blocks)), + None, + )?; + assert!(matches!( + input.relative_timelock(), + Some(relative::LockTime::Blocks(_)) + )); + assert_eq!(input.is_time_timelocked(tip_mtp), Some(false)); Ok(()) } @@ -1688,329 +1196,85 @@ fn test_is_time_timelocked_edge_cases() -> anyhow::Result<()> { /// - Time lock with mtp, satisfied → Some(false) /// - Time lock with mtp, NOT satisfied → Some(true) /// - Mixed: block NOT satisfied + time lock → Some(true) -/// - Mixed: block satisfied + time lock, no mtp → None /// - No locks → Some(false) #[test] fn test_is_timelocked_edge_cases() -> anyhow::Result<()> { let secp = Secp256k1::new(); + let any_height = absolute::Height::from_consensus(200)?; - // --- Case 1: Block lock NOT satisfied, no mtp --- - { - let lock_height = 100u32; - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_height})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() - .after(absolute::LockTime::from_height(lock_height)?) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - let low_height = absolute::Height::from_consensus(50)?; - assert_eq!( - input.is_timelocked(low_height, None), - Some(true), - "block lock not satisfied, no mtp → should be locked" - ); - } - - // --- Case 2: Block lock satisfied, no mtp --- - { - let lock_height = 100u32; - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_height})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() - .after(absolute::LockTime::from_height(lock_height)?) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - let high_height = absolute::Height::from_consensus(200)?; - assert_eq!( - input.is_timelocked(high_height, None), - Some(false), - "block lock satisfied, no mtp → should NOT be locked" - ); - } - - // --- Case 3: Absolute time lock ONLY, no mtp --- - // BUG CASE: Previously with && bug, this would return Some(false) instead of None - { - let lock_time = 500_000_100u32; - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_time})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; + // Case 1: Block lock NOT satisfied, no mtp + let lock_height = 100u32; + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_height})))"), + Assets::new().after(absolute::LockTime::from_height(lock_height)?), + None, + )?; + let low_height = absolute::Height::from_consensus(50)?; + assert_eq!(input.is_timelocked(low_height, None), Some(true)); - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() - .after(absolute::LockTime::from_consensus(lock_time)) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - let any_height = absolute::Height::from_consensus(200)?; - assert_eq!( - input.is_timelocked(any_height, None), - None, - "absolute time lock only, no mtp → cannot determine (should be None)" - ); - } + // Case 2: Block lock satisfied, no mtp + assert_eq!(input.is_timelocked(any_height, None), Some(false)); - // --- Case 4: Relative time lock ONLY, no mtp --- - { - let relative_lock_units = 2u16; - let older_value = 0x400000u32 | relative_lock_units as u32; - - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; + // Case 3: Absolute time lock ONLY, no mtp (BUG CASE) + let lock_time = 500_000_100u32; + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_time})))"), + Assets::new().after(absolute::LockTime::from_consensus(lock_time)), + None, + )?; + assert_eq!(input.is_timelocked(any_height, None), None); - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() + // Case 4: Relative time lock ONLY, no mtp + let relative_lock_units = 2u16; + let older_value = 0x400000u32 | relative_lock_units as u32; + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),older({older_value})))"), + Assets::new() .after(absolute::LockTime::from_consensus(500_000_000)) .older(relative::LockTime::from_512_second_intervals( relative_lock_units, - )) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - let any_height = absolute::Height::from_consensus(200)?; - assert_eq!( - input.is_timelocked(any_height, None), - None, - "relative time lock only, no mtp → cannot determine (should be None)" - ); - } - - // --- Case 5: Absolute time lock with mtp, SATISFIED --- - { - let lock_time = 500_000_100u32; - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_time})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() - .after(absolute::LockTime::from_consensus(lock_time)) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - let any_height = absolute::Height::from_consensus(200)?; - let high_mtp = absolute::Time::from_consensus(lock_time + 1)?; - assert_eq!( - input.is_timelocked(any_height, Some(high_mtp)), - Some(false), - "time lock satisfied with mtp → should NOT be locked" - ); - } - - // --- Case 6: Absolute time lock with mtp, NOT satisfied --- - { - let lock_time = 500_000_100u32; - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_time})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new() - .after(absolute::LockTime::from_consensus(lock_time)) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - let any_height = absolute::Height::from_consensus(200)?; - let low_mtp = absolute::Time::from_consensus(lock_time - 100)?; - assert_eq!( - input.is_timelocked(any_height, Some(low_mtp)), - Some(true), - "time lock NOT satisfied with mtp → should be locked" - ); - } - - // --- Case 7: Mixed - block NOT satisfied + time lock --- - // Block lock takes precedence when not satisfied - { - let block_lock = 100u32; - let time_lock = 500_000_100u32; - // Use a descriptor with both block and time requirements (through different paths) - // For simplicity, we'll use a block-locked input with time check - let desc_str = format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({block_lock})))"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - // Note: Can't actually have both block and time absolute locks simultaneously in bitcoin - // But we can test the logic with a block lock - let assets = Assets::new() - .after(absolute::LockTime::from_height(block_lock)?) - .add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - let low_height = absolute::Height::from_consensus(50)?; - let any_mtp = absolute::Time::from_consensus(time_lock + 1)?; - assert_eq!( - input.is_timelocked(low_height, Some(any_mtp)), - Some(true), - "block lock not satisfied → should be locked (regardless of time)" - ); - } - - // --- Case 8: No locks at all --- - { - let desc_str = format!("wpkh({TEST_XPRV}/86'/1'/0'/0/0)"); - let (desc, _keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let def_desc = desc.at_derivation_index(0)?; - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: def_desc.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - - let mut pks = vec![]; - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - let assets = Assets::new().add(pks); - - let plan = def_desc.plan(&assets).expect("should create plan"); - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - - let any_height = absolute::Height::from_consensus(200)?; - - // No locks, no mtp - assert_eq!( - input.is_timelocked(any_height, None), - Some(false), - "no locks, no mtp → should NOT be locked" - ); - - // No locks, with mtp - let any_mtp = absolute::Time::from_consensus(500_001_000)?; - assert_eq!( - input.is_timelocked(any_height, Some(any_mtp)), - Some(false), - "no locks, with mtp → should NOT be locked" - ); - } + )), + None, + )?; + assert_eq!(input.is_timelocked(any_height, None), None); + + // Case 5: Absolute time lock with mtp, SATISFIED + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({lock_time})))"), + Assets::new().after(absolute::LockTime::from_consensus(lock_time)), + None, + )?; + let high_mtp = absolute::Time::from_consensus(lock_time + 1)?; + assert_eq!(input.is_timelocked(any_height, Some(high_mtp)), Some(false)); + + // Case 6: Absolute time lock with mtp, NOT satisfied + let low_mtp = absolute::Time::from_consensus(lock_time - 100)?; + assert_eq!(input.is_timelocked(any_height, Some(low_mtp)), Some(true)); + + // Case 7: Block lock NOT satisfied (regardless of mtp) + let block_lock = 100u32; + let input = create_test_input( + &secp, + &format!("wsh(and_v(v:pk({TEST_XPRV}/86'/1'/0'/0/0),after({block_lock})))"), + Assets::new().after(absolute::LockTime::from_height(block_lock)?), + None, + )?; + let any_mtp = absolute::Time::from_consensus(500_001_000)?; + assert_eq!(input.is_timelocked(low_height, Some(any_mtp)), Some(true)); + + // Case 8: No locks at all + let input = create_test_input( + &secp, + &format!("wpkh({TEST_XPRV}/86'/1'/0'/0/0)"), + Assets::new(), + None, + )?; + assert_eq!(input.is_timelocked(any_height, None), Some(false)); + assert_eq!(input.is_timelocked(any_height, Some(any_mtp)), Some(false)); Ok(()) }