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/Cargo.toml b/Cargo.toml index 6016fde..c7d1be1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = [".", "testenv"] + [package] name = "bdk_tx" version = "0.1.0" @@ -11,7 +14,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 } @@ -19,9 +22,10 @@ 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.20.0" +bdk_bitcoind_rpc = "0.22.0" bdk_chain = { version = "0.23.0" } [features] @@ -37,3 +41,14 @@ 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" } +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" } +# bdk_bitcoind_rpc = { git = "https://github.com/bitcoindevkit/bdk", branch = "master"} 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" diff --git a/examples/anti_fee_sniping.rs b/examples/anti_fee_sniping.rs index 5609726..3726760 100644 --- a/examples/anti_fee_sniping.rs +++ b/examples/anti_fee_sniping.rs @@ -1,29 +1,36 @@ #![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, }; -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; +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 = env.old_rpc_client()?; 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::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)?; @@ -37,13 +44,14 @@ fn main() -> anyhow::Result<()> { println!("Balance (confirmed): {}", wallet.balance()); - let (tip_height, tip_time) = wallet.tip_info(env.rpc_client())?; + 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. @@ -71,7 +79,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( @@ -80,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 4423bf6..ebdfbd1 100644 --- a/examples/common.rs +++ b/examples/common.rs @@ -1,225 +1 @@ -use std::sync::Arc; - -use bdk_bitcoind_rpc::{Emitter, NO_EXPECTED_MEMPOOL_TXIDS}; -use bdk_chain::{ - bdk_core, Anchor, Balance, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, -}; -use bdk_coin_select::{ChangePolicy, DrainWeights}; -use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; -use bdk_tx::{CanonicalUnspents, Input, InputCandidates, RbfParams, TxStatus, TxWithStatus}; -use bitcoin::{absolute, Address, Amount, BlockHash, 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< - bdk_core::ConfirmationBlockTime, - bdk_chain::keychain_txout::KeychainTxOutIndex<&'static str>, - >, -} - -impl Wallet { - pub fn new( - genesis_hash: BlockHash, - 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_hash(genesis_hash); - Ok(Self { chain, graph }) - } - - pub fn sync(&mut self, env: &TestEnv) -> anyhow::Result<()> { - let client = env.rpc_client(); - let last_cp = self.chain.tip(); - let mut emitter = Emitter::new(client, last_cp, 0, NO_EXPECTED_MEMPOOL_TXIDS); - 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.new_txs); - 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.graph.graph().balance( - &self.chain, - self.chain.tip().block_id(), - CanonicalizationParams::default(), - outpoints, - |_, _| true, - ) - } - - /// TODO: Add to chain sources. - 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)) - } - - // 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(pos: ChainPosition) -> Option { - match pos { - bdk_chain::ChainPosition::Confirmed { anchor, .. } => Some(TxStatus { - 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"), - }), - 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))) - } - - /// 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 66d36fa..e4f17cd 100644 --- a/examples/synopsis.rs +++ b/examples/synopsis.rs @@ -1,32 +1,36 @@ -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 bdk_tx_testenv::TestEnvExt; +use bitcoin::{Amount, FeeRate, Sequence}; mod common; -use common::Wallet; +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 signer = Signer(external_keymap.into_iter().chain(internal_keymap).collect()); - 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::new(genesis_hash, 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)?; @@ -39,19 +43,20 @@ 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(&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. 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( @@ -60,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(), ), )?; @@ -71,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()); @@ -87,7 +92,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()); @@ -135,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. @@ -158,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" @@ -173,7 +179,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/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/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/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/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/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))?; 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/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..922f55b --- /dev/null +++ b/testenv/src/lib.rs @@ -0,0 +1,327 @@ +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) + } + + /// 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()); + 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 new file mode 100644 index 0000000..fe6e5bb --- /dev/null +++ b/tests/timelock.rs @@ -0,0 +1,1280 @@ +//! 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 bdk_chain::miniscript::ForEachKey; +use bdk_testenv::{MineParams, TestEnv}; +use bdk_tx::{ + 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::{ + absolute, key::Secp256k1, relative, transaction, Amount, FeeRate, Sequence, Transaction, TxIn, + TxOut, +}; +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 +/// identify when an input with an absolute block height timelock can be spent. +#[test] +fn test_absolute_block_height_timelock_logic() -> anyhow::Result<()> { + // 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 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::single_keychain(genesis_header, &desc_str)?; + 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); + + 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 inputs = wallet.get_inputs(&assets); + 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 inputs = wallet.get_inputs(&assets); + 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<()> { + // 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 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"); + 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 inputs = wallet.get_inputs(&assets); + 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 inputs = wallet.get_inputs(&assets); + 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 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()?; + + // Only mine a few blocks initially + env.mine_blocks(10, None)?; + + 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(EXTERNAL).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 inputs = wallet.get_inputs(&assets); + + // 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 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); + 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_with(&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(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()?; + 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(); + 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, + )?; + + // Verify the input has the expected absolute timelock + assert_eq!( + input.absolute_timelock(), + Some(absolute::LockTime::from_height(lock_height)?) + ); + + // 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_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)); + + // At tip = lock_height - 1 (spending_height = lock_height): still locked + assert!(input.is_block_timelocked(at_lock_minus_1)); + + // At tip = lock_height (spending_height = lock_height + 1): unlocked + assert!(!input.is_block_timelocked(at_lock)); + + // Above lock height: should NOT be timelocked + assert!(!input.is_block_timelocked(above_lock)); + + Ok(()) +} + +/// Unit test for relative timelock checking. +#[test] +fn test_is_block_timelocked_relative_unit() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + let rel_blocks = 10u16; + let conf_height = 100u32; + + 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!( + 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 + assert!(input.is_block_timelocked(absolute::Height::from_consensus(conf_height + 4)?)); + + // 10 blocks after confirmation: height_diff = 11 >= 10, should NOT be locked + assert!(!input.is_block_timelocked(absolute::Height::from_consensus(conf_height + 9)?)); + + // 15 blocks after confirmation: should NOT be locked + assert!(!input.is_block_timelocked(absolute::Height::from_consensus(conf_height + 14)?)); + + 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 inputs = wallet.get_inputs(&assets); + 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 inputs = wallet.get_inputs(&assets); + 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 inputs = wallet.get_inputs(&assets); + 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 inputs = wallet.get_inputs(&assets); + 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 inputs = wallet.get_inputs(&assets); + 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 inputs = wallet.get_inputs(&assets); + 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 inputs = wallet.get_inputs(&assets); + 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 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(_)) + )); + + // Bitcoin Core `IsFinalTx` checks: `nLockTime < MTP(tip)`. + // So the tx is final (unlocked) when `lock_time < MTP`, i.e., `MTP > lock_time`. + + // mtp = lock_time - 1 → locked + assert_eq!( + input.is_time_timelocked(absolute::Time::from_consensus(lock_time - 1)?), + Some(true) + ); + + // mtp = lock_time → still locked (Core: lock < lock is false) + assert_eq!( + input.is_time_timelocked(absolute::Time::from_consensus(lock_time)?), + Some(true) + ); + + // mtp = lock_time + 1 → unlocked + assert_eq!( + input.is_time_timelocked(absolute::Time::from_consensus(lock_time + 1)?), + Some(false) + ); + + 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; + let older_value = 0x400000u32 | relative_lock_units as u32; // time flag set + let conf_prev_mtp = 500_001_000u32; + + 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(_)) + )); + + // BDK check: value * 512 > (tip_mtp - prev_mtp) → locked + + // diff = 1023 → locked (1024 > 1023) + assert_eq!( + input.is_time_timelocked(absolute::Time::from_consensus( + conf_prev_mtp + relative_lock_seconds - 1 + )?), + Some(true) + ); + + // diff = 1024 → NOT locked (1024 > 1024 is false) + assert_eq!( + input.is_time_timelocked(absolute::Time::from_consensus( + conf_prev_mtp + relative_lock_seconds + )?), + Some(false) + ); + + // diff = 1025 → NOT locked + assert_eq!( + input.is_time_timelocked(absolute::Time::from_consensus( + conf_prev_mtp + relative_lock_seconds + 1 + )?), + Some(false) + ); + + 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 (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)), + 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 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)); + + // 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, + )), + None, + )?; + assert!(matches!( + input.relative_timelock(), + Some(relative::LockTime::Time(_)) + )); + assert!(!input.is_block_timelocked(tip_height)); + + 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 (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, + )), + None, + )?; + assert_eq!(input.is_time_timelocked(tip_mtp), Some(true)); + + // 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, + )), + 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 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)); + + // 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)), + None, + )?; + assert!(matches!( + input.relative_timelock(), + Some(relative::LockTime::Blocks(_)) + )); + assert_eq!(input.is_time_timelocked(tip_mtp), Some(false)); + + 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) +/// - 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 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)); + + // Case 2: Block lock satisfied, no mtp + assert_eq!(input.is_timelocked(any_height, None), Some(false)); + + // 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); + + // 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, + )), + 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(()) +}