diff --git a/Cargo.toml b/Cargo.toml index 120fc6a6..2022af65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [dependencies] bdk_chain = { version = "0.23.3", features = ["miniscript", "serde"], default-features = false } +bdk_tx = { git = "https://github.com/evanlinjin/bdk-tx", branch = "feature/tx-template", default-features = false } bitcoin = { version = "0.32.8", features = ["serde", "base64"], default-features = false } miniscript = { version = "12.3.5", features = ["serde"], default-features = false } rand_core = { version = "0.6.4" } @@ -33,9 +34,10 @@ bdk_file_store = { version = "0.22.0", optional = true } bip39 = { version = "2.2.2", optional = true } tempfile = { version = "3.26.0", optional = true } + [features] default = ["std"] -std = ["bitcoin/std", "bitcoin/rand-std", "miniscript/std", "bdk_chain/std"] +std = ["bitcoin/std", "bitcoin/rand-std", "miniscript/std", "bdk_chain/std", "bdk_tx/std"] compiler = ["miniscript/compiler"] all-keys = ["keys-bip39"] keys-bip39 = ["bip39"] @@ -77,3 +79,9 @@ name = "esplora_blocking" [[example]] name = "bitcoind_rpc" + +[[example]] +name = "psbt" + +[[example]] +name = "replace_by_fee" diff --git a/examples/psbt.rs b/examples/psbt.rs new file mode 100644 index 00000000..dba8f1bc --- /dev/null +++ b/examples/psbt.rs @@ -0,0 +1,136 @@ +#![allow(clippy::print_stdout)] + +use std::collections::HashMap; +use std::str::FromStr; + +use bdk_chain::BlockId; +use bdk_chain::ConfirmationBlockTime; +use bdk_wallet::psbt::{FinishParams, SelectParams, SelectionStrategy::*}; +use bdk_wallet::test_utils::*; +use bdk_wallet::{KeychainKind::External, Wallet}; +use bitcoin::{consensus, secp256k1::rand, transaction::Version, Address, Amount, TxIn, TxOut}; +use rand::Rng; + +// This example shows how to create a PSBT using BDK Wallet. + +const NETWORK: bitcoin::Network = bitcoin::Network::Signet; +const SEND_TO: &str = "tb1pw3g5qvnkryghme7pyal228ekj6vq48zc5k983lqtlr2a96n4xw0q5ejknw"; +const AMOUNT: Amount = Amount::from_sat(42_000); +const FEERATE: f64 = 2.0; // sat/vb + +fn main() -> anyhow::Result<()> { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + + // Create wallet and fund it. + let mut wallet = Wallet::create(desc, change_desc) + .network(NETWORK) + .create_wallet_no_persist()?; + + fund_wallet(&mut wallet)?; + + // Create PSBT Signer, external to the wallet + let signer = { + let secp = wallet.secp_ctx(); + let (_, external_keymap) = miniscript::Descriptor::parse_descriptor(secp, desc)?; + let (_, internal_keymap) = miniscript::Descriptor::parse_descriptor(secp, change_desc)?; + bdk_tx::Signer(external_keymap.into_iter().chain(internal_keymap).collect()) + }; + + let utxos = wallet + .list_unspent() + .map(|output| (output.outpoint, output)) + .collect::>(); + + // Build address. + let addr = Address::from_str(SEND_TO)?.require_network(NETWORK)?; + + // Stage 1: resolve the spendable candidates. + let coins = wallet.candidates()?; + + // Stage 2: run coin selection, yielding a `TxTemplate`. + let template = wallet.select( + coins, + SelectParams { + recipients: vec![(addr.script_pubkey(), AMOUNT)], + coin_selection: LowestFee { + longterm_feerate: feerate_unchecked(3.0), + max_rounds: 500_000, + }, + fee_rate: feerate_unchecked(FEERATE), + ..Default::default() + }, + )?; + + // Shape the template before emitting. We pin the tx version, and — importantly — shuffle the + // outputs so the change output is not left in its default, trivially-identifiable position + // (`select` returns an *unshuffled* template). Other shaping (locktime, anti-fee-sniping, + // input ordering) is likewise done on the template. + let mut rng = rand::thread_rng(); + let template = template.set_version(Version(3))?.shuffle_outputs(&mut rng); + + // Stage 3: emit the PSBT (which also returns the Finalizer). + let (mut psbt, finalizer) = wallet.finish(template, FinishParams::default())?; + + let tx = &psbt.unsigned_tx; + for txin in &tx.input { + let op = txin.previous_output; + let output = utxos.get(&op).unwrap(); + println!("TxIn: {}", output.txout.value); + } + for txout in &tx.output { + println!("TxOut: {}", txout.value); + } + + let _ = psbt + .sign(&signer, wallet.secp_ctx()) + .map_err(|(_, errors)| anyhow::anyhow!("failed to sign PSBT: {errors:?}"))?; + + println!("Signed: {}", !psbt.inputs[0].partial_sigs.is_empty()); + let finalize_res = finalizer.finalize(&mut psbt); + println!("Finalized: {}", finalize_res.is_finalized()); + + let tx = psbt.extract_tx()?; + let feerate = wallet.calculate_fee_rate(&tx)?; + println!("Fee rate: {} sat/vb", bdk_wallet::floating_rate!(feerate)); + + println!("{}", consensus::encode::serialize_hex(&tx)); + + Ok(()) +} + +fn fund_wallet(wallet: &mut Wallet) -> anyhow::Result<()> { + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 260071, + hash: "000000099f67ae6469d1ad0525d756e24d4b02fbf27d65b3f413d5feb367ec48".parse()?, + }, + confirmation_time: 1752184658, + }; + insert_checkpoint(wallet, anchor.block_id); + + let mut rng = rand::thread_rng(); + + // Fund wallet with several random utxos + for i in 0..21 { + let addr = wallet.reveal_next_address(External).address; + let value = 10_000 * (i + 1) + (100 * rng.gen_range(0..10)); + let tx = bitcoin::Transaction { + lock_time: bitcoin::absolute::LockTime::ZERO, + version: bitcoin::transaction::Version::TWO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: addr.script_pubkey(), + value: Amount::from_sat(value), + }], + }; + insert_tx_anchor(wallet, tx, anchor.block_id); + } + + let tip = BlockId { + height: 260171, + hash: "0000000b9efb77450e753ae9fd7be9f69219511c27b6e95c28f4126f3e1591c3".parse()?, + }; + insert_checkpoint(wallet, tip); + + Ok(()) +} diff --git a/examples/replace_by_fee.rs b/examples/replace_by_fee.rs new file mode 100644 index 00000000..dff05f83 --- /dev/null +++ b/examples/replace_by_fee.rs @@ -0,0 +1,189 @@ +#![allow(clippy::print_stdout)] + +use std::sync::Arc; + +use bdk_chain::BlockId; +use bdk_wallet::psbt::{FinishParams, SelectParams}; +use bdk_wallet::test_utils::*; +use bdk_wallet::{KeychainKind, Wallet}; +use bitcoin::{Amount, FeeRate, TxIn, TxOut}; +use miniscript::{DefiniteDescriptorKey, Descriptor}; + +// This example demonstrates creating a transaction with `SelectParams` and replacing it with a +// higher feerate. + +const NETWORK: bitcoin::Network = bitcoin::Network::Regtest; + +fn main() -> anyhow::Result<()> { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + + // Create wallet and "fund" it with a single UTXO. + let mut wallet = Wallet::create(desc, change_desc) + .network(NETWORK) + .create_wallet_no_persist()?; + + fund_wallet(&mut wallet)?; + + // Create PSBT Signer, external to the wallet + let signer = { + let secp = wallet.secp_ctx(); + let (_, external_keymap) = miniscript::Descriptor::parse_descriptor(secp, desc)?; + let (_, internal_keymap) = miniscript::Descriptor::parse_descriptor(secp, change_desc)?; + bdk_tx::Signer(external_keymap.into_iter().chain(internal_keymap).collect()) + }; + + // Get a derived descriptor from the wallet to sweep funds to + let derived_descriptor: Descriptor = wallet + .public_descriptor(KeychainKind::External) + .at_derivation_index(1)?; + + println!( + "Wallet funded with {}\n", + wallet.balance().total().display_dynamic() + ); + println!("Creating first transaction (tx1)..."); + + // Create tx1: pay an amount to our own derived address at a low feerate. + let coins = wallet.candidates()?; + let template1 = wallet.select( + coins, + SelectParams { + recipients: vec![(derived_descriptor.script_pubkey(), Amount::from_sat(10_000))], + fee_rate: FeeRate::from_sat_per_vb(2).expect("valid feerate"), + ..Default::default() + }, + )?; + let (mut psbt1, finalizer1) = wallet.finish(template1, FinishParams::default())?; + + // Sign and finalize tx1 + let _ = psbt1 + .sign(&signer, wallet.secp_ctx()) + .map_err(|(_, errors)| anyhow::anyhow!("failed to sign PSBT: {errors:?}"))?; + println!("tx1 signed: {}", !psbt1.inputs[0].partial_sigs.is_empty()); + + let finalize_res = finalizer1.finalize(&mut psbt1); + println!("tx1 finalized: {}", finalize_res.is_finalized()); + + let tx1 = Arc::new(psbt1.extract_tx()?); + let feerate1 = wallet.calculate_fee_rate(&tx1)?; + let fee1 = wallet.calculate_fee(&tx1)?; + + println!(" txid: {}", tx1.compute_txid()); + println!( + " fee rate: {} sat/vb", + bdk_wallet::floating_rate!(feerate1) + ); + println!(" absolute fee: {} sats", fee1.to_sat()); + + // Apply tx1 to wallet as unconfirmed + wallet.apply_unconfirmed_txs([(tx1.clone(), 1234567000)]); + + println!("\nCreating RBF replacement transaction (tx2)..."); + + // Create tx2: Replace tx1 at a higher feerate, paying the same recipient. Seed a candidate set + // with the tx to replace, then shape the output with bumped fee rate. + let coins = wallet.rbf_candidates(&[tx1.compute_txid()])?; + let template2 = wallet.select( + coins, + SelectParams { + recipients: vec![(derived_descriptor.script_pubkey(), Amount::from_sat(10_000))], + fee_rate: FeeRate::from_sat_per_vb(5).expect("valid feerate"), + ..Default::default() + }, + )?; + let (mut psbt2, finalizer2) = wallet.finish(template2, FinishParams::default())?; + + // Sign and finalize tx2 + let _ = psbt2 + .sign(&signer, wallet.secp_ctx()) + .map_err(|(_, errors)| anyhow::anyhow!("failed to sign PSBT: {errors:?}"))?; + println!("tx2 signed: {}", !psbt2.inputs[0].partial_sigs.is_empty()); + + let finalize_res = finalizer2.finalize(&mut psbt2); + println!("tx2 finalized: {}", finalize_res.is_finalized()); + + let tx2 = psbt2.extract_tx()?; + let feerate2 = wallet.calculate_fee_rate(&tx2)?; + let fee2 = wallet.calculate_fee(&tx2)?; + + println!(" txid: {}", tx2.compute_txid()); + println!( + " fee rate: {} sat/vb", + bdk_wallet::floating_rate!(feerate2) + ); + println!(" absolute fee: {} sats", fee2.to_sat()); + + println!("\nVerifying RBF properties..."); + + // Verify that tx1 and tx2 conflict (spend the same input) + let tx1_input = tx1.input[0].previous_output; + let tx2_input = tx2.input[0].previous_output; + + assert_eq!( + tx1_input, tx2_input, + "ERROR: tx1 and tx2 must spend the same input" + ); + println!("✓ Both transactions spend the same input: {}", tx1_input); + + // Verify that tx2 has a higher feerate than tx1 + assert!( + feerate2 > feerate1, + "ERROR: tx2 must have a higher feerate than tx1" + ); + println!( + "✓ Replacement has higher fee rate ({} vs {} sat/vb)", + bdk_wallet::floating_rate!(feerate2), + bdk_wallet::floating_rate!(feerate1) + ); + + // Verify absolute fee increase + assert!(fee2 > fee1, "ERROR: tx2 must have a higher fee than tx1"); + let fee_increase = fee2.to_sat() as i64 - fee1.to_sat() as i64; + println!("✓ Absolute fee increased by {} sats", fee_increase); + + // Apply tx2 to wallet so it recognizes the conflict + wallet.apply_unconfirmed_txs([(tx2.clone(), 1234567001)]); + + // Verify that the wallet recognizes the conflict + let txid2 = tx2.compute_txid(); + assert!( + wallet + .tx_graph() + .direct_conflicts(&tx1) + .any(|(_, txid)| txid == txid2), + "ERROR: Wallet does not recognize tx2 as replacing tx1", + ); + println!("✓ Wallet recognizes the transaction conflict"); + + println!("\n✓✓✓ RBF sweep complete! ✓✓✓"); + + Ok(()) +} + +fn fund_wallet(wallet: &mut Wallet) -> anyhow::Result<()> { + let anchor_block = BlockId { + height: 1, + hash: "3bcc1c447c6b3886f43e416b5c21cf5c139dc4829a71dc78609bc8f6235611c5".parse()?, + }; + let chain_tip = BlockId { + height: 101, + hash: "7f96292d115d19450e4bf7d4c4e15c9f3ad21e3a3cf616c498110b988963470b".parse()?, + }; + + insert_checkpoint(wallet, anchor_block); + + let addr = wallet.reveal_next_address(KeychainKind::External).address; + let tx = bitcoin::Transaction { + lock_time: bitcoin::absolute::LockTime::ZERO, + version: bitcoin::transaction::Version::TWO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: addr.script_pubkey(), + value: Amount::from_sat(50_000_000), + }], + }; + insert_tx_anchor(wallet, tx, anchor_block); + insert_checkpoint(wallet, chain_tip); + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index ecc15b85..a19da8a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,7 @@ pub use bdk_chain::rusqlite; pub use bdk_chain::rusqlite_impl; pub use descriptor::template; pub use descriptor::HdKeyPaths; +pub use psbt::*; pub use signer; pub use signer::SignOptions; pub use tx_builder::*; diff --git a/src/psbt/mod.rs b/src/psbt/mod.rs index 3655d9e4..dfc0b016 100644 --- a/src/psbt/mod.rs +++ b/src/psbt/mod.rs @@ -17,6 +17,10 @@ use bitcoin::FeeRate; use bitcoin::Psbt; use bitcoin::TxOut; +mod params; + +pub use params::*; + // TODO upstream the functions here to `rust-bitcoin`? /// Trait to add functions to extract utxos and calculate fees. diff --git a/src/psbt/params.rs b/src/psbt/params.rs new file mode 100644 index 00000000..2b8dd7ae --- /dev/null +++ b/src/psbt/params.rs @@ -0,0 +1,345 @@ +//! Parameters for creating a PSBT. +//! +//! PSBT building is split into three stages: +//! +//! 1. **Candidate construction** — [`CandidateParams`] configures which coins may fund the +//! transaction; [`Wallet::candidates_with`] resolves them into a [`CandidateSet`]. A replacement +//! (RBF) is just a candidate set built from options whose [`replace`] list is non-empty (or via +//! the [`Wallet::rbf_candidates`] shortcut). +//! 2. **Selection** — [`SelectParams`] describes the recipients, fee rate and coin-selection +//! strategy; it is passed alongside a [`CandidateSet`] to [`Wallet::select`], which runs coin +//! selection and returns a [`bdk_tx::TxTemplate`]. To sweep (drain), use no recipients with +//! [`SelectionStrategy::DrainAll`]. +//! 3. **Emission** — the caller shapes the [`bdk_tx::TxTemplate`] (version, locktime, ordering, +//! anti-fee-sniping) using its own methods, then emits the final [`Psbt`](bitcoin::Psbt) via +//! [`Wallet::finish`] with [`FinishParams`]. +//! +//! [`replace`]: CandidateParams::replace +//! [`Wallet::candidates_with`]: crate::Wallet::candidates_with +//! [`Wallet::rbf_candidates`]: crate::Wallet::rbf_candidates +//! [`Wallet::select`]: crate::Wallet::select +//! [`Wallet::finish`]: crate::Wallet::finish + +use alloc::vec::Vec; + +use bdk_chain::CanonicalizationParams; +use bdk_tx::{ChangeScript, Input, InputCandidates, RbfParams}; +use bitcoin::{absolute, Amount, FeeRate, OutPoint, ScriptBuf, Txid}; +use miniscript::plan::Assets; + +use crate::collections::{BTreeSet, HashSet}; +use crate::types::LocalOutput; +use crate::wallet::error::CandidatesError; + +/// Parameters for building the set of spendable input candidates (PSBT-building stage 1). +/// +/// Configures how candidates are derived **from the wallet** — manually selected ("must spend") +/// UTXOs, the spend [`Assets`], canonicalization, and Replace-By-Fee. Pass it to +/// [`Wallet::candidates_with`] to resolve a [`CandidateSet`]. +/// +/// All fields are public; construct with [`new`](Self::new) (or [`Default`]) and set what you +/// need. Manually-selected [`must_spend`](Self::must_spend) outpoints are de-duplicated when the +/// [`CandidateSet`] is resolved. +/// +/// To spend a UTXO that did not originate from this wallet (a pre-built foreign [`Input`]), don't +/// configure it here — push it onto the resolved [`CandidateSet`] with +/// [`push_must_select`](CandidateSet::push_must_select) / +/// [`push_can_select`](CandidateSet::push_can_select). +/// +/// To build a replacement transaction (RBF), list the txids to replace in +/// [`replace`](Self::replace); the resulting [`CandidateSet`] carries the replacement context +/// forward to stage 2, so any output shape (pay or sweep) can replace. +/// +/// [`Wallet::candidates_with`]: crate::Wallet::candidates_with +#[derive(Debug, Default)] +pub struct CandidateParams { + /// Manually-selected UTXO outpoints that must be spent. + /// + /// Each outpoint must correspond to an output of a transaction tracked by the wallet and be + /// currently unspent, otherwise resolving the [`CandidateSet`] yields [`UnknownUtxo`]. To spend + /// a UTXO that did not originate from this wallet, push a foreign [`Input`] onto the resolved + /// [`CandidateSet`] instead (see [`CandidateSet::push_must_select`]). + /// + /// [`UnknownUtxo`]: crate::wallet::error::CandidatesError::UnknownUtxo + pub must_spend: BTreeSet, + /// Spend [`Assets`] used to create spending plans for the wallet's own outputs. + /// + /// An empty value (the default) means no assets are provided, in which case all keys are + /// assumed equally likely to sign. + pub assets: Assets, + /// Parameters for modifying the wallet's view of canonical transactions. + /// + /// Refer to [`CanonicalizationParams`] for more. + pub canonical_params: CanonicalizationParams, + /// Height used when evaluating the maturity of coinbase outputs during coin selection. + /// + /// Defaults to the chain tip height when `None`. + pub maturity_height: Option, + /// Only include inputs selected manually via [`must_spend`](Self::must_spend) (plus any foreign + /// inputs pushed onto the resolved [`CandidateSet`]); skip coin selection for additional + /// candidates. + /// + /// The manually-selected inputs must then be enough to fund the transaction. + pub manually_selected_only: bool, + /// Txids to replace (Replace-By-Fee). + /// + /// The must-spend inputs of the resulting [`CandidateSet`] are derived from the inputs of the + /// replaced transactions (resolved against the wallet's transaction graph). There should be no + /// ancestry linking these txids — replacing an ancestor invalidates the descendant — and such + /// ancestry is sanitized away during resolution. + pub replace: Vec, +} + +impl CandidateParams { + /// Create new, empty [`CandidateParams`]. + pub fn new() -> Self { + Self::default() + } +} + +/// A resolved set of spendable input candidates (output of PSBT-building stage 1). +/// +/// Produced by [`Wallet::candidates_with`] from [`CandidateParams`]: every owned UTXO has been +/// planned against the wallet's descriptors and spendability filters applied. It owns its inputs +/// (no wallet borrow), so it can be held as a snapshot and used to build one or more PSBTs via +/// [`Wallet::select`]. +/// +/// Add foreign (non-wallet) inputs with [`push_must_select`](Self::push_must_select) / +/// [`push_can_select`](Self::push_can_select), and apply your own post-resolution filters with +/// [`filter`](Self::filter) / [`regroup`](Self::regroup). +/// +/// If the [`CandidateParams`] had a non-empty [`replace`](CandidateParams::replace) list, the set +/// carries the [`bdk_tx::RbfParams`] (replaced-tx fee statistics) forward so stage 2 applies the +/// correct fee floor, and exposes the wallet-owned outputs being stripped by the replacement via +/// [`replaced_unspent`](Self::replaced_unspent) (handy for batching the replaced txs' payments). +/// +/// [`Wallet::candidates_with`]: crate::Wallet::candidates_with +/// [`Wallet::select`]: crate::Wallet::select +#[derive(Debug, Clone)] +pub struct CandidateSet { + pub(crate) candidates: InputCandidates, + pub(crate) rbf: Option, + /// Txids being replaced/evicted (direct conflicts + descendants). A pushed input may not spend + /// an output of any of these. + pub(crate) replaced: HashSet, + /// Wallet-owned UTXOs stripped from the canonical view by the replacement. + pub(crate) replaced_unspent: Vec, +} + +impl CandidateSet { + /// Iterate over all resolved input candidates (both must-select and optional). + pub fn inputs(&self) -> impl Iterator + '_ { + self.candidates.inputs() + } + + /// Whether the set contains no candidates at all. + pub fn is_empty(&self) -> bool { + self.candidates.inputs().next().is_none() + } + + /// Whether this set is a Replace-By-Fee set (built from a non-empty + /// [`CandidateParams::replace`] list). + pub fn is_rbf(&self) -> bool { + self.rbf.is_some() + } + + /// Wallet-owned UTXOs that the replacement strips out of the canonical view — the outputs of + /// the replaced (and descendant) txs that were unspent in the wallet's view before the replace. + /// + /// These are the still-live payments of the txs being replaced; a caller batching several txs + /// into one replacement can use them to decide which payments to re-create. Empty for a + /// non-Replace-By-Fee set. + pub fn replaced_unspent(&self) -> &[LocalOutput] { + &self.replaced_unspent + } + + /// Add a foreign [`Input`] to the must-select group (always spent). + /// + /// Use this for a UTXO that did not originate from the wallet, supplied with a pre-built + /// [`Plan`]/[`psbt::Input`] — its validity (UTXO existence, satisfaction weight, ...) relies on + /// the caller-supplied values, so only push inputs you trust. + /// + /// # Errors + /// + /// Returns [`ConflictingInput`] if the input spends an output of a transaction being replaced + /// (RBF) — that output won't exist after the replacement. + /// + /// [`ConflictingInput`]: CandidatesError::ConflictingInput + /// [`Plan`]: miniscript::plan::Plan + /// [`psbt::Input`]: bitcoin::psbt::Input + pub fn push_must_select(mut self, input: Input) -> Result { + self.ensure_not_replaced(&input)?; + self.candidates = self.candidates.push_must_select(input); + Ok(self) + } + + /// Add a foreign [`Input`] as an optional (can-select) candidate. + /// + /// Like [`push_must_select`](Self::push_must_select), but the input is offered to coin + /// selection rather than always spent. + /// + /// # Errors + /// + /// Returns [`ConflictingInput`](CandidatesError::ConflictingInput) if the input spends an + /// output of a transaction being replaced (RBF). + pub fn push_can_select(mut self, input: Input) -> Result { + self.ensure_not_replaced(&input)?; + self.candidates = self.candidates.push_can_select(input); + Ok(self) + } + + /// Reject an input that spends an output of a replaced (evicted) transaction. + fn ensure_not_replaced(&self, input: &Input) -> Result<(), CandidatesError> { + let op = input.prev_outpoint(); + if self.replaced.contains(&op.txid) { + return Err(CandidatesError::ConflictingInput(op)); + } + Ok(()) + } + + /// Keep only the candidates for which `policy` returns `true`. + /// + /// Forwards to [`bdk_tx::InputCandidates::filter`]. The closure receives each + /// [`bdk_tx::Input`], which exposes enough to filter by value, script, or confirmation: e.g. + /// [`prev_txout`](Input::prev_txout) (amount/script), [`status`](Input::status) and + /// [`confirmations`](Input::confirmations) (confirmed-only: `|i| i.status().is_some()`), + /// [`is_coinbase`](Input::is_coinbase), and [`is_immature`](Input::is_immature). + pub fn filter

(mut self, policy: P) -> Self + where + P: FnMut(&Input) -> bool, + { + self.candidates = self.candidates.filter(policy); + self + } + + /// Regroup the candidates by the group key returned by `policy`. + /// + /// Forwards to [`bdk_tx::InputCandidates::regroup`]. + pub fn regroup(mut self, policy: P) -> Self + where + P: FnMut(&Input) -> G, + G: Ord + Clone, + { + self.candidates = self.candidates.regroup(policy); + self + } + + /// Consume into the underlying `bdk_tx` parts: the [`InputCandidates`] and, if this is a + /// Replace-By-Fee set (see [`is_rbf`](Self::is_rbf)), the [`RbfParams`] carrying the + /// replaced-tx fee floor. + /// + /// Pass both on to `bdk_tx` (e.g. via [`SelectorParams::replace`]) to build a PSBT directly + /// while still enforcing the RBF minimum fee. The `RbfParams` is wallet-derived and cannot be + /// reconstructed without the wallet, so it is returned here rather than dropped. + /// + /// [`SelectorParams::replace`]: bdk_tx::SelectorParams::replace + pub fn into_parts(self) -> (InputCandidates, Option) { + (self.candidates, self.rbf) + } +} + +/// Parameters to create a PSBT that pays a set of recipients (PSBT-building stage 2). +/// +/// Built with [`SelectParams::new`], passed alongside a [`CandidateSet`] to +/// [`Wallet::select`], which runs coin selection and returns a [`bdk_tx::TxTemplate`]. The caller +/// then shapes the template (version, locktime, anti-fee-sniping, input/output ordering) using +/// the template's own methods before emitting the PSBT via [`Wallet::finish`]. +/// +/// [`Wallet::select`]: crate::Wallet::select +/// [`Wallet::finish`]: crate::Wallet::finish +#[derive(Debug)] +pub struct SelectParams { + /// List of recipient script/amount pairs. + pub recipients: Vec<(ScriptBuf, Amount)>, + /// Optional script or descriptor designated for change. + pub change_script: Option, + /// Coin selection strategy to use. + /// + /// Defaults to [`SelectionStrategy::SingleRandomDraw`]. Use [`SelectionStrategy::DrainAll`] + /// (with no recipients) to sweep the whole candidate set. + pub coin_selection: SelectionStrategy, + /// Target fee rate. + pub fee_rate: FeeRate, +} + +impl Default for SelectParams { + fn default() -> Self { + Self::new() + } +} + +impl SelectParams { + /// Create `SelectParams` with no recipients, default coin selection, and the + /// `FeeRate::BROADCAST_MIN` fee rate. + pub fn new() -> Self { + Self { + recipients: Vec::new(), + change_script: None, + coin_selection: SelectionStrategy::default(), + fee_rate: FeeRate::BROADCAST_MIN, + } + } +} + +/// Parameters for emitting the final [`Psbt`] from a [`bdk_tx::TxTemplate`] (PSBT-building stage 3). +/// +/// Carries only PSBT-emission options. Transaction-shape decisions (version, locktime, sequence, +/// anti-fee-sniping, input/output ordering) live on the [`bdk_tx::TxTemplate`] returned by +/// [`Wallet::select`] and are applied with the template's own methods before being passed to +/// [`Wallet::finish`]. +/// +/// [`Psbt`]: bitcoin::Psbt +/// [`Wallet::select`]: crate::Wallet::select +/// [`Wallet::finish`]: crate::Wallet::finish +#[derive(Debug, Clone, Default)] +pub struct FinishParams { + /// Only set the [`witness_utxo`](bitcoin::psbt::Input::witness_utxo) in segwit-v0 PSBT inputs. + pub only_witness_utxo: bool, + /// Whether to try filling in the PSBT global xpubs from the wallet's descriptors. + pub add_global_xpubs: bool, +} + +/// Coin select strategy. +#[derive(Debug, Clone, Copy, Default)] +#[non_exhaustive] +pub enum SelectionStrategy { + /// Single random draw. + #[default] + SingleRandomDraw, + /// Lowest fee, a variation of Branch 'n Bound that allows for change + /// while minimizing transaction fees. Refer to + /// [`LowestFee`] metric for more. + /// + /// [`LowestFee`]: bdk_tx::bdk_coin_select::metrics::LowestFee + LowestFee { + /// Hypothetical average long-term feerate of the change spending transaction. + longterm_feerate: FeeRate, + /// How many times to run BnB before giving up. + max_rounds: usize, + }, + /// Select **all** available candidates (drain), ignoring any target amount. + /// + /// The remainder (everything minus fees) goes to the change output. With no recipients this + /// sweeps the whole candidate set to change (auto-derived if no `change_script` is set, or an + /// explicit destination); with recipients it pays them and sends the rest to change + /// ("drain while paying"). + DrainAll, +} + +/// Merge the available signing keys and hash preimages from `src` into `dst`. +/// +/// Only these additive (set-union) secrets are merged. The absolute/relative **timelocks are +/// deliberately left untouched** — they are single-valued ceilings with no unambiguous merge (and +/// [`absolute::LockTime`]/[`relative::LockTime`] are only partially ordered, so there is no +/// well-defined "stricter" across a height- and a time-based lock). Callers that care set the +/// timelocks explicitly after merging. +/// +/// [`relative::LockTime`]: bitcoin::relative::LockTime +pub(crate) fn merge_assets_secrets(dst: &mut Assets, src: &Assets) { + dst.keys.extend(src.keys.clone()); + dst.sha256_preimages.extend(src.sha256_preimages.clone()); + dst.hash256_preimages.extend(src.hash256_preimages.clone()); + dst.ripemd160_preimages + .extend(src.ripemd160_preimages.clone()); + dst.hash160_preimages.extend(src.hash160_preimages.clone()); +} diff --git a/src/test_utils.rs b/src/test_utils.rs index c0a51464..c9658a41 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -319,6 +319,31 @@ pub fn insert_checkpoint(wallet: &mut Wallet, block: BlockId) { .unwrap(); } +/// Inserts a transaction to be anchored by `block_id`. This is particularly useful for +/// adding a coinbase tx to the wallet for testing, since transactions of this kind +/// must always appear confirmed. +/// +/// This will also insert the anchor `block_id`. See [`insert_anchor`] for more. +pub fn insert_tx_anchor(wallet: &mut Wallet, tx: Transaction, block_id: BlockId) { + insert_checkpoint(wallet, block_id); + let anchor = ConfirmationBlockTime { + block_id, + confirmation_time: 1234567000, + }; + let txid = tx.compute_txid(); + + let mut tx_update = TxUpdate::default(); + tx_update.txs = vec![Arc::new(tx)]; + tx_update.anchors = [(anchor, txid)].into(); + + wallet + .apply_update(Update { + tx_update, + ..Default::default() + }) + .expect("failed to apply update"); +} + /// Inserts a transaction into the local view, assuming it is currently present in the mempool. /// /// This can be used, for example, to track a transaction immediately after it is broadcast. diff --git a/src/wallet/error.rs b/src/wallet/error.rs index ddd07478..124469c5 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -19,6 +19,7 @@ use alloc::{ boxed::Box, string::{String, ToString}, }; +use bdk_tx::bdk_coin_select; use bitcoin::{absolute, psbt, Amount, BlockHash, Network, OutPoint, Sequence, Txid}; use core::fmt; @@ -365,3 +366,101 @@ impl fmt::Display for BuildFeeBumpError { } impl core::error::Error for BuildFeeBumpError {} + +/// Error when resolving the spendable [`CandidateSet`] (PSBT-building stage 1). +/// +/// [`CandidateSet`]: crate::psbt::CandidateSet +#[derive(Debug)] +#[non_exhaustive] +pub enum CandidatesError { + /// The UTXO of outpoint could not be found. + UnknownUtxo(OutPoint), + /// Failed to create a spending plan for a manually selected output. + Plan(OutPoint), + /// A transaction to be replaced (RBF) is already confirmed. + TransactionConfirmed(Txid), + /// A transaction being replaced (RBF) could not be found. + MissingTransaction(Txid), + /// The wallet controls none of the inputs of a transaction to be replaced (RBF), so it cannot + /// build a replacement that conflicts with (and therefore evicts) it. + CannotReplace(Txid), + /// A manually-selected input spends an output of a transaction in the replaced (RBF) set + /// (a direct conflict or one of its descendants); including it would produce an invalid + /// transaction. + ConflictingInput(OutPoint), + /// Failed to compute the fee of a transaction being replaced (RBF). + PreviousFee(bdk_chain::tx_graph::CalculateFeeError), +} + +impl fmt::Display for CandidatesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownUtxo(op) => write!(f, "unknown UTXO: {op}"), + Self::Plan(op) => write!(f, "failed to create a plan for txout with outpoint {op}"), + Self::TransactionConfirmed(txid) => { + write!(f, "transaction already confirmed: {txid}") + } + Self::MissingTransaction(txid) => write!(f, "missing transaction: {txid}"), + Self::CannotReplace(txid) => { + write!( + f, + "cannot replace transaction {txid}: the wallet controls none of its inputs" + ) + } + Self::ConflictingInput(outpoint) => { + write!( + f, + "manually-selected input {outpoint} conflicts with the replacement set" + ) + } + Self::PreviousFee(e) => write!(f, "{e}"), + } + } +} + +impl core::error::Error for CandidatesError {} + +/// Error when creating a PSBT. +#[derive(Debug)] +#[non_exhaustive] +pub enum CreatePsbtError { + /// No Bnb solution. + Bnb(bdk_coin_select::NoBnbSolution), + /// No recipients were configured with a non-drain coin selection. A [`select`] requires at + /// least one recipient; to send all funds to a single destination, use + /// [`SelectionStrategy::DrainAll`] with no recipients. + /// + /// [`select`]: crate::Wallet::select + /// [`SelectionStrategy::DrainAll`]: crate::psbt::SelectionStrategy::DrainAll + NoRecipients, + /// After coin selection, all outputs fell below the dust threshold and were + /// dropped to fees. + AllOutputsBelowDust, + /// Non-sufficient funds. + InsufficientFunds(bdk_coin_select::InsufficientFunds), + /// In order to use the [`add_global_xpubs`] option, every extended key in the descriptor must + /// either be a master key itself, having a depth of 0, or have an explicit origin provided. + /// + /// [`add_global_xpubs`]: crate::psbt::FinishParams::add_global_xpubs + MissingKeyOrigin(bitcoin::bip32::Xpub), + /// Failed to build the PSBT. + Build(bdk_tx::BuildPsbtError), + /// Selector error. + Selector(bdk_tx::SelectorError), +} + +impl fmt::Display for CreatePsbtError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bnb(e) => write!(f, "{e}"), + Self::InsufficientFunds(e) => write!(f, "{e}"), + Self::NoRecipients => write!(f, "no output destinations were configured"), + Self::AllOutputsBelowDust => write!(f, "all outputs are below the dust threshold",), + Self::MissingKeyOrigin(e) => write!(f, "missing key origin: {e}"), + Self::Build(e) => write!(f, "{e}"), + Self::Selector(e) => write!(f, "{e}"), + } + } +} + +impl core::error::Error for CreatePsbtError {} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 73d71f1e..602f60fe 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -31,14 +31,21 @@ use bdk_chain::{ SyncResponse, }, tx_graph::{CalculateFeeError, CanonicalTx, TxGraph, TxUpdate}, - BlockId, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, + Anchor, BlockId, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, FullTxOut, Indexed, IndexedTxGraph, Indexer, Merge, }; +use bdk_tx::{ + bdk_coin_select, selection_algorithm_lowest_fee_bnb, selection_algorithm_single_random_draw, + ChangeScript, ConfirmationStatus, Finalizer, Input, InputCandidates, OriginalTxStats, Output, + PsbtBuildParams, RbfParams, Selector, SelectorParams, TxTemplate, +}; +#[cfg(feature = "std")] +use bitcoin::secp256k1::rand; use bitcoin::{ absolute, consensus::encode::serialize, constants::genesis_block, - psbt, + psbt, relative, secp256k1::Secp256k1, sighash::{EcdsaSighashType, TapSighashType}, transaction, Address, Amount, Block, FeeRate, Network, NetworkKind, OutPoint, Psbt, ScriptBuf, @@ -46,7 +53,9 @@ use bitcoin::{ }; use miniscript::{ descriptor::KeyMap, + plan::{Assets, Plan}, psbt::{PsbtExt, PsbtInputExt, PsbtInputSatisfier}, + ForEachKey, }; use rand_core::RngCore; @@ -64,17 +73,22 @@ pub mod signer; pub mod tx_builder; pub(crate) mod utils; -use crate::collections::{BTreeMap, HashMap, HashSet}; +use crate::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use crate::descriptor::{ check_wallet_descriptor, checksum::calc_checksum, error::Error as DescriptorError, policy::BuildSatisfaction, DerivedDescriptor, DescriptorMeta, ExtendedDescriptor, ExtractPolicy, IntoWalletDescriptor, Policy, XKeyUtils, }; -use crate::psbt::PsbtUtils; +use crate::psbt::{ + merge_assets_secrets, CandidateParams, CandidateSet, FinishParams, PsbtUtils, SelectParams, + SelectionStrategy, +}; use crate::types::*; use crate::wallet::{ coin_selection::{DefaultCoinSelectionAlgorithm, Excess, InsufficientFunds}, - error::{BuildFeeBumpError, CreateTxError, MiniscriptPsbtError}, + error::{ + BuildFeeBumpError, CandidatesError, CreatePsbtError, CreateTxError, MiniscriptPsbtError, + }, signer::{SignOptions, SignerError, SignerOrdering, SignersContainer, TransactionSigner}, tx_builder::{FeePolicy, TxBuilder, TxParams}, utils::{check_nsequence_rbf, After, Older, SecpCtx}, @@ -87,8 +101,10 @@ pub use error::{LoadError, LoadMismatch}; pub use event::*; pub use params::*; pub use persisted::*; -pub use utils::IsDust; -pub use utils::TxDetails; +pub use utils::{IsDust, TxDetails}; + +/// Alias [`FullTxOut`] with associated keychain and derivation index. +type IndexedTxOut = ((KeychainKind, u32), FullTxOut); /// A Bitcoin wallet /// @@ -789,6 +805,19 @@ impl Wallet { .map(|((k, i), full_txo)| new_local_utxo(k, i, full_txo)) } + /// List indexed [`FullTxOut`]s. + fn list_indexed_txouts( + &self, + params: CanonicalizationParams, + ) -> impl Iterator + '_ { + self.tx_graph.graph().filter_chain_txouts( + &self.chain, + self.chain.tip().block_id(), + params, + self.tx_graph.index.outpoints().iter().cloned(), + ) + } + /// Get the [`TxDetails`] of a wallet transaction. /// /// If the transaction with txid [`Txid`] cannot be found in the wallet's transactions, `None` @@ -1075,7 +1104,7 @@ impl Wallet { /// /// To iterate over all canonical transactions, including those that are irrelevant, use /// [`TxGraph::list_canonical_txs`]. - pub fn transactions(&self) -> impl Iterator> + '_ { + pub fn transactions<'a>(&'a self) -> impl Iterator> + 'a { let tx_graph = self.tx_graph.graph(); let tx_index = &self.tx_graph.index; tx_graph @@ -2778,6 +2807,739 @@ impl Wallet { } } +/// Maps a chain position to tx confirmation status, if `pos` is the confirmed +/// variant. +/// +/// - Returns None if the confirmation height or time is not a valid absolute [`Height`] or +/// [`Time`]. +/// +/// [`Height`]: bitcoin::absolute::Height +/// [`Time`]: bitcoin::absolute::Time +fn status_from_position(pos: ChainPosition) -> Option { + if let ChainPosition::Confirmed { anchor, .. } = pos { + let conf_height = anchor.confirmation_height_upper_bound(); + let height = absolute::Height::from_consensus(conf_height).ok()?; + // TODO: Currently BDK has no notion of MTP, we can use the confirmation block time for now. + let time = + absolute::Time::from_consensus(anchor.confirmation_time.try_into().ok()?).ok()?; + Some(ConfirmationStatus { + height, + prev_mtp: Some(time), + }) + } else { + None + } +} + +impl Wallet { + /// Return the "keys" assets, i.e. the ones we can trivially infer by scanning + /// the pubkeys of the wallet's descriptors. + fn assets(&self) -> Assets { + let mut pks = vec![]; + for (_, desc) in self.keychains() { + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + } + + Assets::new().add(pks) + } + + /// Peek at the next change address without revealing it, returning the auto-derived + /// change info `(keychain, index, spk)` alongside the [`ChangeScript`]. + /// + /// When the caller supplies a [`ChangeScript`] via `override_script`, it is passed through + /// unchanged and `None` is returned for the change info (no address needs to be revealed). + /// + /// Otherwise we select the next unused address (already-revealed-but-unused first, then the + /// next-to-be-revealed index) **without** mutating wallet state. Revelation is deferred to + /// after all error paths have been cleared by the caller. + fn peek_change_info( + &self, + override_script: Option, + ) -> (Option<(KeychainKind, u32, ScriptBuf)>, ChangeScript) { + match override_script { + Some(cs) => (None, cs), + None => { + let change_keychain = self.map_keychain(KeychainKind::Internal); + let (index, spk) = self + .tx_graph + .index + .unused_keychain_spks(change_keychain) + .next() + .unwrap_or_else(|| { + let (next_index, _) = self + .tx_graph + .index + .next_index(change_keychain) + .expect("keychain must exist"); + let spk = self + .peek_address(change_keychain, next_index) + .script_pubkey(); + (next_index, spk) + }); + let descriptor = self.public_descriptor(change_keychain); + let definite_descriptor = descriptor + .at_derivation_index(index) + .expect("should be valid derivation index"); + let change_script = ChangeScript::from_descriptor(definite_descriptor); + (Some((change_keychain, index, spk)), change_script) + } + } + } + + /// Parses the common parameters used during PSBT creation and returns the spend assets + /// and a map of indexed tx outputs. + fn parse_params( + &self, + opts: &CandidateParams, + ) -> (Assets, HashMap>) { + // The caller's `opts.assets` are authoritative. Copy in its keys/preimages and carry its + // timelocks over verbatim (no implicit merge). If the caller supplied no signing keys — the + // common case, where they only want to declare timelocks — assume all of the wallet's keys + // are available so wallet-controlled outputs can still be planned. + let mut assets = Assets::new(); + merge_assets_secrets(&mut assets, &opts.assets); + assets.absolute_timelock = opts.assets.absolute_timelock; + assets.relative_timelock = opts.assets.relative_timelock; + if assets.keys.is_empty() { + merge_assets_secrets(&mut assets, &self.assets()); + } + + // Get wallet txouts. + let txouts = self + .list_indexed_txouts(opts.canonical_params.clone()) + .map(|(_, txo)| (txo.outpoint, txo)) + .collect(); + + (assets, txouts) + } + + /// Filters wallet `txos` by the built-in spending criteria. + /// + /// - `policy`: Closure indicating whether the output should be kept, used by some callers to + /// apply additional filters as in the case of RBF. User-defined filters are applied + /// post-resolution via [`CandidateSet::filter`] instead. + fn filter_spendable<'a, I, F>( + &'a self, + txos: I, + opts: &'a CandidateParams, + policy: F, + ) -> impl Iterator> + 'a + where + I: IntoIterator> + 'a, + F: Fn(&FullTxOut) -> bool + 'a, + { + let current_height = opts + .maturity_height + .map(|h| h.to_consensus_u32()) + .unwrap_or_else(|| self.chain.tip().height()); + txos.into_iter().filter(move |txo| { + // Exclude outputs that are manually selected. + if opts.must_spend.contains(&txo.outpoint) { + return false; + } + // Filter outputs according to `policy` fn. + if !policy(txo) { + return false; + } + // Exclude locked UTXOs. + if self.is_outpoint_locked(txo.outpoint) { + return false; + } + // Exclude immature outputs. + if !txo.is_mature(current_height) { + return false; + } + // Exclude spent outputs. + if txo.spent_by.is_some() { + return false; + } + true + }) + } + + /// Maps the recipients of the `params` to a collection of target [`Output`]s. + fn target_outputs(&self, recipients: &[(ScriptBuf, Amount)]) -> Vec { + recipients + .iter() + .cloned() + .map( + |(script, value)| match self.tx_graph.index.index_of_spk(script.clone()) { + Some(&(keychain, index)) => { + let descriptor = self + .public_descriptor(keychain) + .at_derivation_index(index) + .expect("should be valid derivation index"); + Output::with_descriptor(descriptor, value) + } + None => Output::with_script(script, value), + }, + ) + .collect() + } + + /// **Stage 2.** Run coin selection over a resolved [`CandidateSet`], returning a + /// [`TxTemplate`] that pays the given recipients. + /// + /// The returned template is the workspace between coin selection and PSBT emission: shape it + /// (version, locktime, anti-fee-sniping, input/output ordering) using [`TxTemplate`]'s own + /// methods, then emit the final [`Psbt`] with [`Wallet::finish`]. + /// + /// # Privacy + /// + /// **The returned [`TxTemplate`] is _unshuffled_ and carries no anti-fee-sniping by + /// default.** Outputs are emitted in `[recipients…, change]` order, so the change output sits + /// in a deterministic, trivially-identifiable position. If you care about change-output + /// privacy you **must** randomize it yourself before [`finish`](Self::finish): call + /// `template.shuffle_outputs(rng)` (and optionally `template.shuffle_inputs(rng)`). Apply + /// anti-fee-sniping with `template.apply_anti_fee_sniping(tip_height, rng)`. + /// + /// # Example + /// + /// ```rust,no_run + /// # use std::str::FromStr; + /// # use bitcoin::{Amount, Address, FeeRate, OutPoint}; + /// # use bdk_wallet::psbt::{CandidateParams, SelectParams, FinishParams, SelectionStrategy}; + /// # let mut wallet = bdk_wallet::doctest_wallet!(); + /// # let outpoint = OutPoint::null(); + /// # let address = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5").unwrap().assume_checked(); + /// # let amount = Amount::ZERO; + /// // Stage 1: resolve the spendable candidates. + /// let mut opts = CandidateParams::new(); + /// opts.must_spend = [outpoint].into(); + /// let coins = wallet.candidates_with(&opts)?; + /// + /// // Stage 2: select coins to pay the recipients, yielding a `TxTemplate`. + /// let mut params = SelectParams::new(); + /// params.recipients = vec![(address.script_pubkey(), amount)]; + /// params.coin_selection = SelectionStrategy::SingleRandomDraw; + /// params.fee_rate = FeeRate::BROADCAST_MIN; + /// let template = wallet.select(coins, params)?; + /// + /// // Stage 3: emit the PSBT (optionally shaping the template first). + /// let (psbt, finalizer) = wallet.finish(template, FinishParams::default())?; + /// # Ok::<_, anyhow::Error>(()) + /// ``` + /// + /// # Errors + /// + /// A [`CreatePsbtError`] will be thrown if any of the following occurs + /// + /// - No recipients were provided ([`NoRecipients`]) with a non-drain coin selection — to send + /// everything to one destination, use [`SelectionStrategy::DrainAll`] with no recipients + /// - The input value is insufficient to fund the outputs ([`InsufficientFunds`]) + /// - Coin selection failed to find a solution ([`Bnb`] for [`LowestFee`], or [`Selector`]) — + /// this also covers draining a wallet with no spendable value, which cannot even cover fees + /// - After coin selection, all outputs fell below the dust threshold + /// ([`AllOutputsBelowDust`]) + /// + /// Errors relating to resolving the inputs (unknown/un-plannable UTXOs, sequence conflicts, or + /// Replace-By-Fee validation) surface earlier, from [`Wallet::candidates_with`]. + /// + /// [`NoRecipients`]: CreatePsbtError::NoRecipients + /// [`InsufficientFunds`]: CreatePsbtError::InsufficientFunds + /// [`Bnb`]: CreatePsbtError::Bnb + /// [`LowestFee`]: SelectionStrategy::LowestFee + /// [`Selector`]: CreatePsbtError::Selector + /// [`AllOutputsBelowDust`]: CreatePsbtError::AllOutputsBelowDust + /// + /// # Change address + /// + /// When no [`ChangeScript`] is supplied via [`SelectParams`], the wallet automatically + /// selects the next unused internal address and reveals it so that incoming change is tracked + /// on the next sync. The change address will not be marked used, so calling this function + /// again before syncing will use the same change address. If you intend to build + /// multiple transactions without syncing between them, either provide the change script in + /// the [`SelectParams`], or do [`Wallet::mark_used`] after each call to prevent reuse. + /// + /// **You must persist the change set staged as a result of this call.** + /// See [`Wallet::take_staged`]. + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn select( + &mut self, + coins: CandidateSet, + params: SelectParams, + ) -> Result { + self.select_with_rng(coins, params, &mut rand::thread_rng()) + } + + /// **Stage 2.** [`select`](Self::select) with an explicit RNG. + /// + /// The RNG is consumed only by [`SelectionStrategy::SingleRandomDraw`], which uses it to draw + /// candidates in a uniformly-random order; other strategies are deterministic and ignore it. + pub fn select_with_rng( + &mut self, + coins: CandidateSet, + mut params: SelectParams, + rng: &mut impl RngCore, + ) -> Result { + // `DrainAll` selects every candidate and sends the remainder to change, so it may have no + // recipients (a sweep). Any other strategy requires at least one recipient — this guards + // against accidentally draining the whole wallet by passing empty recipients. + let drain = matches!(params.coin_selection, SelectionStrategy::DrainAll); + if params.recipients.is_empty() && !drain { + return Err(CreatePsbtError::NoRecipients); + } + let (change_info, change_script) = self.peek_change_info(params.change_script.take()); + let target_outputs = self.target_outputs(¶ms.recipients); + + let CandidateSet { + candidates: input_candidates, + rbf, + .. + } = coins; + // With no candidates there is nothing to select. For a targeted selection the shortfall is + // the whole target; a drain has no meaningful "missing" amount, so let it fall through to + // `CannotMeetTarget` rather than report a misleading `missing: 0`. + if input_candidates.inputs().next().is_none() && !drain { + let target_amount: Amount = target_outputs.iter().map(|output| output.value).sum(); + return Err(CreatePsbtError::InsufficientFunds( + bdk_coin_select::InsufficientFunds { + missing: target_amount.to_sat(), + }, + )); + } + + let mut selector = Selector::new( + &input_candidates, + SelectorParams { + replace: rbf, + ..SelectorParams::new(params.fee_rate, target_outputs, change_script) + }, + ) + .map_err(CreatePsbtError::Selector)?; + + // Run coin selection per the chosen strategy. `SingleRandomDraw` shuffles the candidates + // with `rng` and selects until the target is met. `DrainAll` ignores the target and + // selects everything (sweep / drain-while-paying). + match params.coin_selection { + SelectionStrategy::DrainAll => { + selector.select_all(); + } + SelectionStrategy::SingleRandomDraw => { + selector + .select_with_algorithm(selection_algorithm_single_random_draw(rng)) + .map_err(CreatePsbtError::InsufficientFunds)?; + } + SelectionStrategy::LowestFee { + longterm_feerate, + max_rounds, + } => { + selector + .select_with_algorithm(selection_algorithm_lowest_fee_bnb( + longterm_feerate, + max_rounds, + )) + .map_err(CreatePsbtError::Bnb)?; + } + } + + let template = selector.try_finalize().ok_or({ + let e = bdk_tx::CannotMeetTarget; + CreatePsbtError::Selector(bdk_tx::SelectorError::CannotMeetTarget(e)) + })?; + + // Change fell below the dust threshold and was dropped to fees, leaving the transaction + // with no outputs. + if template.outputs().is_empty() { + return Err(CreatePsbtError::AllOutputsBelowDust); + } + + // Reveal the auto-selected change address if it ended up in the template's outputs. + if let Some((keychain, index, spk)) = change_info { + if template + .outputs() + .iter() + .any(|output| output.script_pubkey() == spk) + { + if let Some((_, index_changeset)) = + self.tx_graph.index.reveal_to_target(keychain, index) + { + self.stage.merge(index_changeset.into()); + } + } + } + + Ok(template) + } + + /// **Stage 1.** Resolve the wallet's spendable [`CandidateSet`] with default options. + /// + /// Equivalent to [`candidates_with`](Self::candidates_with) called with + /// [`CandidateParams::default`]. + pub fn candidates(&self) -> Result { + self.build_candidates(&CandidateParams::default()) + } + + /// **Stage 1.** Resolve a Replace-By-Fee [`CandidateSet`] replacing the given `txids`. + /// + /// Shortcut for [`candidates_with`](Self::candidates_with) with [`CandidateParams`] whose + /// [`replace`](CandidateParams::replace) list is `txids`. The replaced transactions must be + /// present in the wallet's transaction graph. + pub fn rbf_candidates(&self, txids: &[Txid]) -> Result { + let opts = CandidateParams { + replace: txids.to_vec(), + ..Default::default() + }; + self.build_candidates(&opts) + } + + /// **Stage 1.** Resolve the wallet's spendable [`CandidateSet`] for `opts`. + /// + /// Plans manually-selected UTXOs, gathers and filters the wallet's + /// spendable coins, and — when [`opts.replace`] is non-empty — sets up the Replace-By-Fee + /// context. The returned [`CandidateSet`] is an owned snapshot suitable for + /// [`select`](Self::select). + /// + /// # Errors + /// + /// A [`CandidatesError`] will be thrown if any of the following occurs: + /// + /// - A manually selected input is missing from the wallet ([`UnknownUtxo`]) or could not be + /// planned ([`Plan`]) + /// - For a Replace-By-Fee set: a replaced tx is already confirmed + /// ([`TransactionConfirmed`]), missing ([`MissingTransaction`]), one whose inputs the wallet + /// doesn't control ([`CannotReplace`]), its fee can't be computed ([`PreviousFee`]), or a + /// manually-selected input conflicts with the replaced set ([`ConflictingInput`]) + /// + /// [`opts.replace`]: CandidateParams::replace + /// [`UnknownUtxo`]: CandidatesError::UnknownUtxo + /// [`Plan`]: CandidatesError::Plan + /// [`TransactionConfirmed`]: CandidatesError::TransactionConfirmed + /// [`MissingTransaction`]: CandidatesError::MissingTransaction + /// [`CannotReplace`]: CandidatesError::CannotReplace + /// [`PreviousFee`]: CandidatesError::PreviousFee + /// [`ConflictingInput`]: CandidatesError::ConflictingInput + pub fn candidates_with(&self, opts: &CandidateParams) -> Result { + self.build_candidates(opts) + } + + /// Resolve a [`CandidateSet`] from `opts`, handling both the normal and Replace-By-Fee paths. + /// + /// Candidate construction is deterministic; any randomization (e.g. single random draw) happens + /// later, at [`select`](Self::select). + fn build_candidates(&self, opts: &CandidateParams) -> Result { + let (assets, txouts) = self.parse_params(opts); + + let is_rbf = !opts.replace.is_empty(); + + // Replace-By-Fee setup. We derive the set of txids being replaced (sanitizing ancestry), + // the must-spend inputs from those replaced txs, the descendant set to exclude from coin + // selection, and the fee floor. + let mut rbf_must_spend: Vec = vec![]; + let mut to_replace: HashSet = HashSet::new(); + let mut rbf_params: Option = None; + if is_rbf { + // Build the set of replaced txids, dropping any tx that is a coinbase or whose + // ancestors are also being replaced (replacing an ancestor invalidates the + // descendant). + let candidate_replace: HashSet = opts.replace.iter().copied().collect(); + let mut direct_conflicts: HashSet = HashSet::new(); + let mut replace_outpoints: Vec = vec![]; + for &txid in opts.replace.iter() { + let tx = self + .tx_graph + .graph() + .get_tx(txid) + .ok_or(CandidatesError::MissingTransaction(txid))?; + let has_replaced_ancestor = self + .tx_graph + .graph() + .walk_ancestors(Arc::clone(&tx), |_, ancestor| Some(ancestor.compute_txid())) + .any(|ancestor_txid| candidate_replace.contains(&ancestor_txid)); + if tx.is_coinbase() || has_replaced_ancestor { + continue; + } + if direct_conflicts.insert(txid) { + // The wallet must control at least one of the tx's inputs, otherwise it can't + // build a replacement that double-spends (and therefore evicts) it. + if !tx + .input + .iter() + .any(|txin| txouts.contains_key(&txin.previous_output)) + { + return Err(CandidatesError::CannotReplace(txid)); + } + replace_outpoints.extend(tx.input.iter().map(|txin| txin.previous_output)); + } + } + + // None of the (sanitized) txids-to-replace may already be confirmed. + let chain_tip = self.chain.tip().block_id(); + let chain_positions: HashMap> = self + .tx_graph + .graph() + .list_canonical_txs(&self.chain, chain_tip, opts.canonical_params.clone()) + .map(|canonical_tx| (canonical_tx.tx_node.txid, canonical_tx.chain_position)) + .collect(); + for &txid in direct_conflicts.iter() { + if chain_positions + .get(&txid) + .is_some_and(|chain_position| chain_position.is_confirmed()) + { + return Err(CandidatesError::TransactionConfirmed(txid)); + } + } + + // The must-spend inputs are the wallet-owned inputs of the (sanitized) replaced txs. + // They appear "spent" so the normal spendable scan excludes them; plan them directly + // from their txouts (like `build_must_spend_inputs` does for the manual must-spend + // coins). Outpoints the caller supplies via `must_spend` are skipped to avoid duplicates + // (added by `build_must_spend_inputs` below). A replaced input the wallet doesn't own + // (a foreign input) can't be planned here and is skipped too — the caller re-adds it + // with [`CandidateSet::push_must_select`]. + for outpoint in &replace_outpoints { + if opts.must_spend.contains(outpoint) { + continue; + } + let Some(txo) = txouts.get(outpoint) else { + continue; + }; + let input = self + .plan_input(txo, &assets) + .ok_or(CandidatesError::Plan(*outpoint))?; + rbf_must_spend.push(input); + } + + // Replaced txs and their descendants are excluded from coin selection; the direct + // conflicts feed the RBF fee floor, the descendants accumulate into `descendant_fee`. + let descendants: HashSet = direct_conflicts + .iter() + .flat_map(|&txid| { + self.tx_graph + .graph() + .walk_descendants(txid, |_, txid| Some(txid)) + }) + .filter(|txid| !direct_conflicts.contains(txid)) + .collect(); + to_replace = direct_conflicts + .iter() + .chain(descendants.iter()) + .copied() + .collect(); + + let original_txs: Vec = direct_conflicts + .iter() + .map(|&txid| -> Result<_, CandidatesError> { + let tx = self + .tx_graph + .graph() + .get_tx(txid) + .ok_or(CandidatesError::MissingTransaction(txid))?; + let fee = self + .calculate_fee(&tx) + .map_err(CandidatesError::PreviousFee)?; + Ok(OriginalTxStats { + weight: tx.weight(), + fee, + }) + }) + .collect::>()?; + + // Sum fees from all descendants (assumes each is currently in the mempool, so it must + // be evicted too). These came from a graph walk, so a missing tx or uncomputable fee + // is unexpected and surfaced as an error rather than silently under-counting the fee + // floor (which would produce a replacement too cheap to evict the package). + let mut descendant_fee = Amount::ZERO; + for &txid in descendants.iter() { + let tx = self + .tx_graph + .graph() + .get_tx(txid) + .ok_or(CandidatesError::MissingTransaction(txid))?; + descendant_fee += self + .calculate_fee(&tx) + .map_err(CandidatesError::PreviousFee)?; + } + + rbf_params = Some(RbfParams { + original_txs, + descendant_fee, + incremental_relay_feerate: FeeRate::BROADCAST_MIN, + }); + } + + // Combine the RBF-derived must-spend inputs with the user-supplied must-spend inputs. + let mut must_spend = rbf_must_spend; + must_spend.extend(self.build_must_spend_inputs(&opts.must_spend, &txouts, &assets)?); + + // A user-supplied must-spend input must not conflict with the replaced set. (Foreign inputs + // pushed later onto the `CandidateSet` are checked at push time.) + if is_rbf { + for input in &must_spend { + let op = input.prev_outpoint(); + if to_replace.contains(&op.txid) { + return Err(CandidatesError::ConflictingInput(op)); + } + } + } + + let may_spend: Vec = if opts.manually_selected_only { + vec![] + } else { + self.filter_spendable(txouts.into_values(), opts, |txo| { + // In the RBF case the UTXO must not be in `to_replace` and must be confirmed. + // TODO: BIP125 Rule 2 was removed in Core v31; make this an explicit RBF policy + // knob. + !to_replace.contains(&txo.outpoint.txid) + && (!is_rbf || txo.chain_position.is_confirmed()) + }) + .flat_map(|txo| self.plan_input(&txo, &assets)) + .collect() + }; + + // Wallet-owned UTXOs the replacement strips from the canonical view: outputs of the + // replaced/evicted txs that are unspent in the wallet's current view. Exposed so a caller + // batching txs can re-create their still-live payments. + let replaced_unspent: Vec = if is_rbf { + self.list_unspent() + .filter(|utxo| to_replace.contains(&utxo.outpoint.txid)) + .collect() + } else { + Vec::new() + }; + + Ok(CandidateSet { + candidates: InputCandidates::new(must_spend, may_spend), + rbf: rbf_params, + replaced: to_replace, + replaced_unspent, + }) + } + + /// **Stage 3.** Emit the final [`Psbt`] and its [`Finalizer`] from a shaped [`TxTemplate`]. + /// + /// The `template` is produced by [`select`](Self::select) and may + /// have been shaped (version, locktime, anti-fee-sniping, input/output ordering) using + /// [`TxTemplate`]'s own methods before being passed here. + /// + /// [`FinishParams`] carries the PSBT-emission options: whether to only set the `witness_utxo` + /// for segwit-v0 inputs, and whether to fill in the global xpubs from the wallet's descriptors. + /// + /// # Errors + /// + /// - [`CreatePsbtError::Build`] if the PSBT can't be built from the template. + /// - [`CreatePsbtError::MissingKeyOrigin`] if [`add_global_xpubs`] is set but an extended key + /// in a descriptor is neither a master key (depth 0) nor has an explicit origin. + /// + /// [`add_global_xpubs`]: FinishParams::add_global_xpubs + pub fn finish( + &self, + template: TxTemplate, + params: FinishParams, + ) -> Result<(Psbt, Finalizer), CreatePsbtError> { + let (mut psbt, finalizer) = template + .create_psbt(PsbtBuildParams { + mandate_full_tx_for_segwit_v0: !params.only_witness_utxo, + }) + .map_err(CreatePsbtError::Build)?; + + // Add global xpubs. + if params.add_global_xpubs { + for xpub in self + .keychains() + .flat_map(|(_, desc)| desc.get_extended_keys()) + { + let origin = match xpub.origin { + Some(origin) => origin, + None if xpub.xkey.depth == 0 => { + (xpub.root_fingerprint(&self.secp), vec![].into()) + } + _ => return Err(CreatePsbtError::MissingKeyOrigin(xpub.xkey)), + }; + + psbt.xpub.insert(xpub.xkey, origin); + } + } + + Ok((psbt, finalizer)) + } + + /// Builds the required inputs from the caller's de-duplicated must-spend outpoints. + /// + /// Plans each `must_spend` outpoint as an [`Input`]. Sequence values are left at their + /// plan-derived defaults — callers tune them on the returned [`TxTemplate`] + /// (`set_fallback_sequence` / `input_mut`). + fn build_must_spend_inputs( + &self, + must_spend: &BTreeSet, + txouts: &HashMap>, + assets: &Assets, + ) -> Result, CandidatesError> { + must_spend + .iter() + .map(|&outpoint| { + let txo = txouts + .get(&outpoint) + .ok_or(CandidatesError::UnknownUtxo(outpoint))?; + let input = self + .plan_input(txo, assets) + .ok_or(CandidatesError::Plan(outpoint))?; + Ok(input) + }) + .collect() + } + + /// Plan the output with the available assets and return a new [`Input`] if possible. See also + /// [`Self::try_plan`]. + fn plan_input( + &self, + txo: &FullTxOut, + spend_assets: &Assets, + ) -> Option { + let op = txo.outpoint; + let txid = op.txid; + + // We want to afford the output with as many assets as we can. The plan + // will use only the ones needed to produce the minimum satisfaction. + let cur_height = self.latest_checkpoint().height(); + let abs_locktime = spend_assets + .absolute_timelock + .unwrap_or(absolute::LockTime::from_consensus(cur_height)); + + let rel_locktime = spend_assets.relative_timelock.unwrap_or_else(|| { + let age = match txo.chain_position.confirmation_height_upper_bound() { + Some(conf_height) => cur_height + .saturating_add(1) + .saturating_sub(conf_height) + .try_into() + .unwrap_or(u16::MAX), + None => 0, + }; + relative::LockTime::from_height(age) + }); + + // Keep the caller's keys/preimages, but pin the timelocks to the values derived above. + let mut assets = Assets::new(); + merge_assets_secrets(&mut assets, spend_assets); + let assets = assets.after(abs_locktime).older(rel_locktime); + + let plan = self.try_plan(op, &assets)?; + let tx = self.tx_graph.graph().get_tx(txid)?; + let tx_status = status_from_position(txo.chain_position); + + Input::from_prev_tx(plan, tx, op.vout as usize, tx_status).ok() + } + + /// Attempt to create a spending plan for the UTXO of the given `outpoint` + /// with the provided `assets`. + /// + /// Return `None` if `outpoint` doesn't correspond to an indexed txout, or + /// if the assets are not sufficient to create a plan. + fn try_plan(&self, outpoint: OutPoint, assets: &Assets) -> Option { + let indexer = &self.tx_graph.index; + let ((keychain, index), _) = indexer.txout(outpoint)?; + let def_desc = indexer + .get_descriptor(keychain)? + .at_derivation_index(index) + .expect("must be valid derivation index"); + def_desc.plan(assets).ok() + } +} + impl AsRef> for Wallet { fn as_ref(&self) -> &bdk_chain::tx_graph::TxGraph { self.tx_graph.graph() @@ -2907,7 +3669,7 @@ macro_rules! floating_rate { /// Macro for getting a [`Wallet`] for use in a doctest. macro_rules! doctest_wallet { () => {{ - use $crate::bitcoin::{BlockHash, Transaction, absolute, TxOut, Network, hashes::Hash}; + use $crate::bitcoin::{transaction, absolute, Amount, BlockHash, Transaction, TxOut, Network, hashes::Hash}; use $crate::chain::{ConfirmationBlockTime, BlockId, TxGraph, tx_graph}; use $crate::{Update, KeychainKind, Wallet}; use $crate::test_utils::*; diff --git a/src/wallet/tx_builder.rs b/src/wallet/tx_builder.rs index ec50be9a..ca4a7f5c 100644 --- a/src/wallet/tx_builder.rs +++ b/src/wallet/tx_builder.rs @@ -828,7 +828,7 @@ type TxSort = dyn (Fn(&T, &T) -> core::cmp::Ordering) + Send + Sync; /// Ordering of the transaction's inputs and outputs #[derive(Clone, Default)] -pub enum TxOrdering { +pub enum TxOrdering { /// Randomized (default) #[default] Shuffle, @@ -843,13 +843,13 @@ pub enum TxOrdering { /// Provide custom comparison functions for sorting Custom { /// Transaction inputs sort function - input_sort: Arc>, + input_sort: Arc>, /// Transaction outputs sort function - output_sort: Arc>, + output_sort: Arc>, }, } -impl core::fmt::Debug for TxOrdering { +impl core::fmt::Debug for TxOrdering { fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { match self { TxOrdering::Shuffle => write!(f, "Shuffle"), diff --git a/tests/add_foreign_utxo.rs b/tests/add_foreign_utxo.rs index 409d71fd..cf734396 100644 --- a/tests/add_foreign_utxo.rs +++ b/tests/add_foreign_utxo.rs @@ -5,7 +5,7 @@ use bdk_wallet::signer::SignOptions; use bdk_wallet::test_utils::*; use bdk_wallet::tx_builder::AddForeignUtxoError; use bdk_wallet::KeychainKind; -use bitcoin::{psbt, Address, Amount}; +use bitcoin::{hashes::Hash, psbt, Address, Amount, OutPoint, ScriptBuf, Sequence, TxOut}; mod common; @@ -323,3 +323,59 @@ fn test_add_foreign_utxo_rejects_wrong_non_witness_utxo_even_with_witness_utxo() "should reject non_witness_utxo with wrong txid even when witness_utxo is present" ); } + +#[test] +fn test_add_planned_psbt_input() -> anyhow::Result<()> { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let op1 = wallet.list_unspent().next().unwrap().outpoint; + + // We'll add a foreign anchor output as a planned input. + let op2 = OutPoint::new(Hash::hash(b"txid"), 2); + let txout = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_p2a(), + }; + let psbt_input = psbt::Input { + witness_utxo: Some(txout), + ..Default::default() + }; + let input = bdk_tx::Input::from_psbt_input( + op2, + Sequence::ENABLE_LOCKTIME_NO_RBF, + psbt_input, + /* satisfaction_weight: */ 0, + /* status: */ None, + /* is_coinbase: */ false, + /* absolute_timelock: */ None, + )?; + + let send_to = wallet.reveal_next_address(KeychainKind::External).address; + + // Build tx: 2-in / 2-out + let mut opts = bdk_wallet::CandidateParams::new(); + opts.must_spend = [op1].into(); + let coins = wallet.candidates_with(&opts)?.push_must_select(input)?; + + let mut params = bdk_wallet::SelectParams::new(); + params.recipients = vec![(send_to.script_pubkey(), Amount::from_sat(20_000))]; + + let template = wallet.select(coins, params)?; + let (psbt, _) = wallet.finish(template, bdk_wallet::psbt::FinishParams::default())?; + + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|input| input.previous_output == op1), + "Psbt should contain the wallet spend" + ); + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|input| input.previous_output == op2), + "Psbt should contain the planned input" + ); + + Ok(()) +} diff --git a/tests/psbt.rs b/tests/psbt.rs index 08c4acc9..bcd8e6e9 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -1,11 +1,1273 @@ -use bdk_wallet::bitcoin::{Amount, FeeRate, Psbt, TxIn}; +use bdk_chain::{BlockId, ConfirmationBlockTime}; +use bdk_tx::bdk_coin_select; +use bdk_tx::ChangeScript; +use bdk_wallet::bitcoin; use bdk_wallet::test_utils::*; -use bdk_wallet::{psbt, KeychainKind, SignOptions}; +use bdk_wallet::{ + error::{CandidatesError, CreatePsbtError}, + psbt, + psbt::FinishParams, + CandidateParams, KeychainKind, SelectParams, SignOptions, Wallet, +}; +use bitcoin::{ + absolute, hashes::Hash, Address, Amount, FeeRate, Network, OutPoint, Psbt, ScriptBuf, Sequence, + Transaction, TxIn, TxOut, +}; use core::str::FromStr; +use miniscript::plan::Assets; // from bip 174 const PSBT_STR: &str = "cHNidP8BAKACAAAAAqsJSaCMWvfEm4IS9Bfi8Vqz9cM9zxU4IagTn4d6W3vkAAAAAAD+////qwlJoIxa98SbghL0F+LxWrP1wz3PFTghqBOfh3pbe+QBAAAAAP7///8CYDvqCwAAAAAZdqkUdopAu9dAy+gdmI5x3ipNXHE5ax2IrI4kAAAAAAAAGXapFG9GILVT+glechue4O/p+gOcykWXiKwAAAAAAAEHakcwRAIgR1lmF5fAGwNrJZKJSGhiGDR9iYZLcZ4ff89X0eURZYcCIFMJ6r9Wqk2Ikf/REf3xM286KdqGbX+EhtdVRs7tr5MZASEDXNxh/HupccC1AaZGoqg7ECy0OIEhfKaC3Ibi1z+ogpIAAQEgAOH1BQAAAAAXqRQ1RebjO4MsRwUPJNPuuTycA5SLx4cBBBYAFIXRNTfy4mVAWjTbr6nj3aAfuCMIAAAA"; +// Test that `create_psbt` results in the expected PSBT. +#[test] +fn test_create_psbt() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let expected_xpub = match wallet.public_descriptor(KeychainKind::External) { + miniscript::Descriptor::Tr(tr) => match tr.internal_key() { + miniscript::DescriptorPublicKey::XPub(desc) => desc.xkey, + _ => unreachable!(), + }, + _ => unreachable!(), + }; + + // Receive coins + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 100, + hash: Hash::hash(b"100"), + }, + confirmation_time: 1234567000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + receive_output(&mut wallet, Amount::ONE_BTC, ReceiveTo::Block(anchor)); + + let change_descriptor = wallet + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External); + let mut params = SelectParams::new(); + let feerate = FeeRate::from_sat_per_vb(4).unwrap(); + let selection_strategy = psbt::SelectionStrategy::LowestFee { + longterm_feerate: FeeRate::from_sat_per_vb(2).unwrap(), + max_rounds: 1000, + }; + params.coin_selection = selection_strategy; + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + params.change_script = Some(ChangeScript::from_descriptor(change_descriptor)); + params.fee_rate = feerate; + + let coins = wallet.candidates().unwrap(); + let template = wallet + .select(coins, params) + .unwrap() + .set_version(bitcoin::transaction::Version(3)) + .unwrap(); + let finish_params = FinishParams { + add_global_xpubs: true, + ..Default::default() + }; + let (psbt, _) = wallet.finish(template, finish_params).unwrap(); + let tx = &psbt.unsigned_tx; + assert_eq!(tx.version.0, 3); + assert_eq!(tx.lock_time.to_consensus_u32(), 0); + assert_eq!(tx.input.len(), 1); + assert_eq!(tx.output.len(), 2); + + // global xpubs + assert_eq!( + psbt.xpub, + [(expected_xpub, ("f6a5cb8b".parse().unwrap(), vec![].into()))].into(), + ); + // witness utxo + let psbt_input = &psbt.inputs[0]; + assert_eq!( + psbt_input.witness_utxo.as_ref().map(|txo| txo.value), + Some(Amount::ONE_BTC), + ); + // input internal key + assert!(psbt_input.tap_internal_key.is_some()); + // input key origins + assert!(psbt_input + .tap_key_origins + .values() + .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b")); + // output internal key + assert!(psbt + .outputs + .iter() + .any(|output| output.tap_internal_key.is_some())); + // output key origins + assert!(psbt.outputs.iter().any(|output| output + .tap_key_origins + .values() + .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b"))); +} + +#[test] +fn test_create_psbt_insufficient_funds_error() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External); + + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_sat(10_000))]; + + let coins = wallet.candidates().unwrap(); + let result = wallet.select(coins, params); + assert!(matches!( + result, + Err(CreatePsbtError::InsufficientFunds( + bdk_coin_select::InsufficientFunds { missing: 10_000 } + )), + )); +} + +#[test] +fn test_create_psbt_maturity_height() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let receive_address = wallet.reveal_next_address(KeychainKind::External); + let send_to_address = wallet.reveal_next_address(KeychainKind::External).address; + + let block_1 = BlockId { + height: 1, + hash: Hash::hash(b"1"), + }; + insert_checkpoint(&mut wallet, block_1); + + // Receive coinbase output at height = 1. + // maturity height = (1 + 100) = 101 + let tx = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::ONE_BTC, + script_pubkey: receive_address.script_pubkey(), + }], + ..new_tx(0) + }; + insert_tx_anchor(&mut wallet, tx, block_1); + + // The output is still immature at height = 99. + let mut cp = CandidateParams::new(); + cp.maturity_height = Some(bitcoin::absolute::Height::from_consensus(99).unwrap()); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut p = SelectParams::new(); + p.recipients = vec![(send_to_address.script_pubkey(), Amount::from_sat(58_000))]; + + let _ = wallet + .select(coins, p) + .expect_err("immature output must not be selected"); + + // We can use the params to coerce the coinbase maturity. + let mut cp = CandidateParams::new(); + cp.maturity_height = Some(bitcoin::absolute::Height::from_consensus(100).unwrap()); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut p = SelectParams::new(); + p.recipients = vec![(send_to_address.script_pubkey(), Amount::from_sat(58_000))]; + + let _ = wallet + .select(coins, p) + .expect("`maturity_height` should enable selection"); + + // The output is eligible for selection once the wallet tip reaches maturity height minus 1 + // (100), as it can be confirmed in the next block (101). + let block_100 = BlockId { + height: 100, + hash: Hash::hash(b"100"), + }; + insert_checkpoint(&mut wallet, block_100); + let coins = wallet.candidates().unwrap(); + let mut p = SelectParams::new(); + p.recipients = vec![(send_to_address.script_pubkey(), Amount::from_sat(58_000))]; + + let _ = wallet + .select(coins, p) + .expect("mature coinbase should be selected"); +} + +#[test] +fn test_create_psbt_cltv() { + use absolute::LockTime; + + let desc = get_test_single_sig_cltv(); + let mut wallet = Wallet::create_single(desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // Receive coins + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 99_999, + hash: Hash::hash(b"abc"), + }, + confirmation_time: 1234567000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let op = receive_output(&mut wallet, Amount::ONE_BTC, ReceiveTo::Block(anchor)); + + let addr = wallet.reveal_next_address(KeychainKind::External); + + // No assets fail + { + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + let res = wallet.candidates_with(&cp); + assert!( + matches!(res, Err(CandidatesError::Plan(err)) if err == op), + "UTXO requires CLTV but the assets are insufficient", + ); + } + + // Add assets ok + { + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + cp.assets = Assets::new().after(LockTime::from_consensus(100_000)); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + let template = wallet.select(coins, params).unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); + assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), 100_000); + } + + // New chain tip (no assets) ok + { + let block_id = BlockId { + height: 100_000, + hash: Hash::hash(b"123"), + }; + insert_checkpoint(&mut wallet, block_id); + + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + let template = wallet.select(coins, params).unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); + assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), 100_000); + } + + // Locktime greater than required + { + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + + let template = wallet + .select(coins, params) + .unwrap() + .set_locktime(LockTime::from_consensus(200_000)) + .unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); + assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), 200_000); + } +} + +#[test] +fn test_create_psbt_cltv_timestamp() { + use absolute::LockTime; + + let lock_time = LockTime::from_consensus(1734230218); + let desc = get_test_single_sig_cltv_timestamp(); + let mut wallet = Wallet::create_single(desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // Receive coins + let op = receive_output(&mut wallet, Amount::ONE_BTC, ReceiveTo::Mempool(1)); + + let addr = wallet.reveal_next_address(KeychainKind::External); + + // No assets fail + { + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + let res = wallet.candidates_with(&cp); + assert!( + matches!(res, Err(CandidatesError::Plan(err)) if err == op), + "UTXO requires CLTV but the assets are insufficient", + ); + } + + // Add assets ok + { + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + cp.assets = Assets::new().after(lock_time); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + let template = wallet.select(coins, params).unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); + assert_eq!(psbt.unsigned_tx.lock_time, lock_time); + } + + // Locktime greater than required + { + let new_lock_time = 1772167108; + assert!(new_lock_time > lock_time.to_consensus_u32()); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + cp.assets = Assets::new().after(lock_time); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + + let template = wallet + .select(coins, params) + .unwrap() + .set_locktime(LockTime::from_consensus(new_lock_time)) + .unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); + assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), new_lock_time); + } +} + +#[test] +fn test_create_psbt_csv() { + use bitcoin::relative; + use bitcoin::Sequence; + + let desc = get_test_single_sig_csv(); + let mut wallet = Wallet::create_single(desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // Receive coins + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 10_000, + hash: Hash::hash(b"abc"), + }, + confirmation_time: 1234567000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let op = receive_output(&mut wallet, Amount::ONE_BTC, ReceiveTo::Block(anchor)); + + let addr = wallet.reveal_next_address(KeychainKind::External); + + // No assets fail + { + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + let res = wallet.candidates_with(&cp); + assert!( + matches!(res, Err(CandidatesError::Plan(err)) if err == op), + "UTXO requires CSV but the assets are insufficient", + ); + } + + // Add assets ok + { + let rel_locktime = relative::LockTime::from_consensus(6).unwrap(); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + cp.assets = Assets::new().older(rel_locktime); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + let template = wallet.select(coins, params).unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); + } + + // Add 6 confirmations (no assets) + { + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 10_005, + hash: Hash::hash(b"xyz"), + }, + confirmation_time: 1234567000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + let template = wallet.select(coins, params).unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); + } +} + +/// Fallback sequence is applied to a coin-selected input that has no CSV +/// requirement. +#[test] +fn test_create_psbt_fallback_sequence_applied_to_coin_selected_input() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let addr = wallet.next_unused_address(KeychainKind::External); + let coins = wallet.candidates().unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_sat(25_000))]; + let template = wallet + .select(coins, params) + .unwrap() + .set_fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); + let psbt = wallet.finish(template, FinishParams::default()).unwrap().0; + assert_eq!( + psbt.unsigned_tx.input[0].sequence, + Sequence::ENABLE_RBF_NO_LOCKTIME + ); +} + +/// Fallback sequence is NOT applied when the input already has a CSV-derived sequence +/// requirement — the CSV value wins. +#[test] +fn test_create_psbt_fallback_sequence_skipped_for_csv_input() { + use bitcoin::relative; + let mut wallet = Wallet::create_single(get_test_single_sig_csv()) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 10_000, + hash: Hash::hash(b"csv_fallback"), + }, + confirmation_time: 1_234_567_000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let op = receive_output( + &mut wallet, + Amount::from_sat(100_000), + ReceiveTo::Block(anchor), + ); + + let addr = wallet.next_unused_address(KeychainKind::External); + let rel_locktime = relative::LockTime::from_consensus(6).unwrap(); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + cp.assets = Assets::new().older(rel_locktime); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_sat(25_000))]; + let template = wallet + .select(coins, params) + .unwrap() + .set_fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); + let psbt = wallet.finish(template, FinishParams::default()).unwrap().0; + // CSV descriptor requires older(6); fallback must not clobber the CSV-derived sequence. + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); +} + +/// A per-input sequence override is applied to a manually-selected UTXO. +#[test] +fn test_create_psbt_sequence_override_manually_selected_input() { + let (mut wallet, txid) = get_funded_wallet_wpkh(); + let utxo = OutPoint::new(txid, 0); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut cp = CandidateParams::new(); + cp.must_spend = [utxo].into(); + cp.manually_selected_only = true; + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_sat(25_000))]; + let mut template = wallet.select(coins, params).unwrap(); + template + .input_mut(utxo) + .unwrap() + .set_sequence(Sequence(42)) + .unwrap(); + let psbt = wallet.finish(template, FinishParams::default()).unwrap().0; + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(42)); +} + +/// A per-input sequence override takes precedence over a fallback sequence. +#[test] +fn test_create_psbt_sequence_override_takes_precedence_over_fallback() { + let (mut wallet, txid) = get_funded_wallet_wpkh(); + let utxo = OutPoint::new(txid, 0); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut cp = CandidateParams::new(); + cp.must_spend = [utxo].into(); + cp.manually_selected_only = true; + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_sat(25_000))]; + let mut template = wallet + .select(coins, params) + .unwrap() + .set_fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); + template + .input_mut(utxo) + .unwrap() + .set_sequence(Sequence(42)) + .unwrap(); + let psbt = wallet.finish(template, FinishParams::default()).unwrap().0; + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(42)); +} + +/// Setting a template input sequence that violates the CSV requirement is rejected. +#[test] +fn test_create_psbt_sequence_override_csv_conflict_returns_error() { + use bitcoin::relative; + let mut wallet = Wallet::create_single(get_test_single_sig_csv()) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 10_000, + hash: Hash::hash(b"csv_override"), + }, + confirmation_time: 1_234_567_000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let op = receive_output( + &mut wallet, + Amount::from_sat(100_000), + ReceiveTo::Block(anchor), + ); + + let addr = wallet.next_unused_address(KeychainKind::External); + let rel_locktime = relative::LockTime::from_consensus(6).unwrap(); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + cp.assets = Assets::new().older(rel_locktime); + cp.manually_selected_only = true; + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_sat(25_000))]; + let mut template = wallet.select(coins, params).unwrap(); + // CSV requires older(6); setting a lower sequence on the template input is rejected. + let res = template.input_mut(op).unwrap().set_sequence(Sequence(3)); + assert!(matches!( + res, + Err(bdk_tx::SetSequenceError::RelativeTimelockNotSatisfied { .. }) + )); +} + +// Test that replacing two unconfirmed txs A, B results in a transaction +// that spends the inputs of both A and B. +#[test] +fn test_replace_by_fee_and_recipients() { + use KeychainKind::*; + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // The anchor block + let block = BlockId { + height: 100, + hash: Hash::hash(b"100"), + }; + + let mut addrs: Vec

= vec![]; + for _ in 0..3 { + let addr = wallet.reveal_next_address(External); + addrs.push(addr.address); + } + + // Insert parent 0 (coinbase) + let p0 = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::ONE_BTC, + script_pubkey: addrs[0].script_pubkey(), + }], + ..new_tx(1) + }; + let op0 = OutPoint::new(p0.compute_txid(), 0); + + insert_tx_anchor(&mut wallet, p0.clone(), block); + + // Insert parent 1 (coinbase) + let p1 = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::ONE_BTC, + script_pubkey: addrs[1].script_pubkey(), + }], + ..new_tx(1) + }; + let op1 = OutPoint::new(p1.compute_txid(), 0); + + insert_tx_anchor(&mut wallet, p1.clone(), block); + + // Add new tip, for maturity + let block = BlockId { + height: 1000, + hash: Hash::hash(b"1000"), + }; + insert_checkpoint(&mut wallet, block); + + // Create tx A (unconfirmed) + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + let mut cp = CandidateParams::new(); + cp.must_spend = [op0].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip.clone(), Amount::from_sat(16_000))]; + let template = wallet.select(coins, params).unwrap(); + let txa = wallet + .finish(template, FinishParams::default()) + .unwrap() + .0 + .unsigned_tx; + insert_tx(&mut wallet, txa.clone()); + + // Create tx B (unconfirmed) + let mut cp = CandidateParams::new(); + cp.must_spend = [op1].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip.clone(), Amount::from_sat(42_000))]; + let template = wallet.select(coins, params).unwrap(); + let txb = wallet + .finish(template, FinishParams::default()) + .unwrap() + .0 + .unsigned_tx; + insert_tx(&mut wallet, txb.clone()); + + // Now create RBF tx + let coins = wallet + .rbf_candidates(&[txa.compute_txid(), txb.compute_txid()]) + .unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip, Amount::from_btc(1.99).unwrap())]; + params.fee_rate = FeeRate::from_sat_per_vb(4).unwrap(); + let template = wallet.select(coins, params).unwrap(); + let psbt = wallet.finish(template, FinishParams::default()).unwrap().0; + + // Expect replace inputs of A, B + assert_eq!( + psbt.unsigned_tx.input.len(), + 2, + "We should have selected two inputs" + ); + for op in [op0, op1] { + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|txin| txin.previous_output == op), + "We should have replaced the original spends" + ); + } +} + +// Test that replacing tx A also accounts for the fees of A's unconfirmed descendants +// B and C when calculating the minimum required replacement fee (RBF Rule 3). +// +// A A' +// / \ +// B C +// +// A' conflicts with A. The replacement fee should exceed +// fee(A) + fee(B) + fee(C). +#[test] +fn test_replace_by_fee_replaces_descendant_fees() { + use KeychainKind::*; + + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let block_id = BlockId { + height: 100, + hash: Hash::hash(b"100"), + }; + + // addr0 receives the confirmed funding; addr1 and addr2 are wallet change + // addresses that tx A pays into so that B and C can spend them. + let addr0 = wallet.reveal_next_address(External).address; + let addr1 = wallet.reveal_next_address(Internal).address; + let addr2 = wallet.reveal_next_address(Internal).address; + + // External (non-wallet) output script used as a sink for recipients. + let external = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + + // Confirmed funding tx: 1_000_000 sats to addr0. + let funding_tx = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(1_000_000), + script_pubkey: addr0.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx_anchor(&mut wallet, funding_tx.clone(), block_id); + + // Tx A (unconfirmed): spends the confirmed UTXO; two outputs return to wallet. + // fee_a = 1_000_000 - 50_000 - 450_000 - 450_000 = 50_000 sats + let tx_a = Transaction { + input: vec![TxIn { + previous_output: funding_op, + ..TxIn::default() + }], + output: vec![ + TxOut { + value: Amount::from_sat(50_000), + script_pubkey: external.clone(), + }, + TxOut { + value: Amount::from_sat(450_000), + script_pubkey: addr1.script_pubkey(), + }, + TxOut { + value: Amount::from_sat(450_000), + script_pubkey: addr2.script_pubkey(), + }, + ], + ..new_tx(0) + }; + let a_txid = tx_a.compute_txid(); + let fee_a = wallet.calculate_fee(&tx_a).unwrap(); + insert_tx(&mut wallet, tx_a.clone()); + + // Tx B (unconfirmed): spends A's first change output. + // fee_b = 450_000 - 430_000 = 20_000 sats + let tx_b = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(a_txid, 1), + ..TxIn::default() + }], + output: vec![TxOut { + value: Amount::from_sat(430_000), + script_pubkey: external.clone(), + }], + ..new_tx(0) + }; + let fee_b = wallet.calculate_fee(&tx_b).unwrap(); + insert_tx(&mut wallet, tx_b); + + // Tx C (unconfirmed): spends A's second change output. + // fee_c = 450_000 - 430_000 = 20_000 sats + let tx_c = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(a_txid, 2), + ..TxIn::default() + }], + output: vec![TxOut { + value: Amount::from_sat(430_000), + script_pubkey: external.clone(), + }], + ..new_tx(0) + }; + let fee_c = wallet.calculate_fee(&tx_c).unwrap(); + insert_tx(&mut wallet, tx_c.clone()); + + // The replacement must pay at least the combined fee of all three transactions + // (Bitcoin Core RBF Rule 3). + let total_original_fee = fee_a + fee_b + fee_c; + assert_eq!(total_original_fee.to_sat(), 90_000); + + // Build replacement A'. The wallet walks A's descendants (B and C) so their + // fees are included in the minimum required replacement fee. + let coins = wallet.rbf_candidates(&[a_txid]).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(external, Amount::from_sat(100_000))]; + params.fee_rate = FeeRate::from_sat_per_vb(4).unwrap(); + let template = wallet + .select(coins, params) + .expect("should select for replacement psbt"); + let (psbt, _) = wallet + .finish(template, FinishParams::default()) + .expect("should create replacement psbt"); + + let replacement_fee = wallet + .calculate_fee(&psbt.unsigned_tx) + .expect("replacement tx fee should be calculable"); + + assert!( + replacement_fee >= total_original_fee, + "replacement fee ({replacement_fee}) must be >= sum of fees for A + B + C ({total_original_fee})", + ); +} + +// Test that RBF rejects a confirmed original tx +#[test] +fn test_replace_by_fee_confirmed_tx_error() { + use KeychainKind::*; + + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let block = BlockId { + height: 100, + hash: Hash::hash(b"100"), + }; + let addr = wallet.reveal_next_address(External).address; + + // Fund the wallet with a confirmed output. + let funding_tx = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(200_000), + script_pubkey: addr.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx_anchor(&mut wallet, funding_tx, block); + + // Create an unconfirmed tx spending the confirmed UTXO. + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + let mut cp = CandidateParams::new(); + cp.must_spend = [funding_op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip.clone(), Amount::from_sat(100_000))]; + let template = wallet.select(coins, params).unwrap(); + let unconfirmed_tx = wallet + .finish(template, FinishParams::default()) + .unwrap() + .0 + .unsigned_tx; + insert_tx(&mut wallet, unconfirmed_tx.clone()); + + // Now confirm that tx. + let confirmed_txid = unconfirmed_tx.compute_txid(); + let confirm_block = BlockId { + height: 1001, + hash: Hash::hash(b"1001"), + }; + insert_tx_anchor(&mut wallet, unconfirmed_tx.clone(), confirm_block); + + // Attempting to replace the now-confirmed tx should return TransactionConfirmed. + let result = wallet.rbf_candidates(&[confirmed_txid]); + + assert!( + matches!(result, Err(CandidatesError::TransactionConfirmed(txid)) if txid == confirmed_txid), + "expected TransactionConfirmed error, got: {result:?}", + ); +} + +// Test that a replacement derived from the wallet graph keeps the original transaction's inputs +// as the must-spend set. (In the reshaped API the replaced txs' inputs are re-derived from the +// wallet graph, so the replacement always retains at least one input from each replaced tx.) +#[test] +fn test_replace_by_fee_keeps_original_inputs() { + use KeychainKind::*; + + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let addr = wallet.reveal_next_address(External).address; + + // Fund the wallet with an unconfirmed output. + let funding_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(Hash::hash(b"funding_parent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(200_000), + script_pubkey: addr.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx(&mut wallet, funding_tx); + + // Create an unconfirmed tx spending the funded UTXO. + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + let mut cp = CandidateParams::new(); + cp.must_spend = [funding_op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip, Amount::from_sat(100_000))]; + let template = wallet.select(coins, params).unwrap(); + let unconfirmed_tx = wallet + .finish(template, FinishParams::default()) + .unwrap() + .0 + .unsigned_tx; + let unconfirmed_txid = unconfirmed_tx.compute_txid(); + insert_tx(&mut wallet, unconfirmed_tx.clone()); + + // The replacement set re-derives the original tx's inputs as must-spend candidates. + let coins = wallet.rbf_candidates(&[unconfirmed_txid]).unwrap(); + assert!(coins.is_rbf()); + assert!( + coins + .inputs() + .any(|input| input.prev_outpoint() == funding_op), + "the replacement must keep the original transaction's input", + ); +} + +// Test that RBF rejects a manually-selected input that spends +// from a descendant of the one being replaced. +#[test] +fn test_replace_by_fee_conflicting_input_descendant() { + use bdk_tx::Input as BdkInput; + use bitcoin::{psbt as btc_psbt, Sequence}; + + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External).address; + + // Fund the wallet so there is a spendable UTXO. + let funding_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(Hash::hash(b"funding_parent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(500_000), + script_pubkey: addr.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx(&mut wallet, funding_tx); + + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + + // tx_parent: the transaction we will eventually replace. + let mut cp = CandidateParams::new(); + cp.must_spend = [funding_op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip.clone(), Amount::from_sat(100_000))]; + let template = wallet.select(coins, params).unwrap(); + let tx_parent = wallet + .finish(template, FinishParams::default()) + .unwrap() + .0 + .unsigned_tx; + let txid_parent = tx_parent.compute_txid(); + insert_tx(&mut wallet, tx_parent.clone()); + + // tx_child: spends one of tx_parent's outputs (a descendant of the tx being replaced). + let child_output = TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh( + &bitcoin::WPubkeyHash::from_slice(&[0u8; 20]).unwrap(), + ), + }; + let child_op = OutPoint::new(txid_parent, 0); + let tx_child = Transaction { + input: vec![TxIn { + previous_output: child_op, + ..Default::default() + }], + output: vec![child_output.clone()], + ..new_tx(1) + }; + let txid_child = tx_child.compute_txid(); + insert_tx(&mut wallet, tx_child.clone()); + + // A planned input that spends an output of tx_child (a descendant of the replaced tx). + // This is the indirect conflict that params-level stripping cannot catch. + let grandchild_op = OutPoint::new(txid_child, 0); + let grandchild_input = BdkInput::from_psbt_input( + grandchild_op, + Sequence::ENABLE_RBF_NO_LOCKTIME, + btc_psbt::Input { + witness_utxo: Some(child_output), + ..Default::default() + }, + /* satisfaction_weight */ 0, + /* status */ None, + /* is_coinbase */ false, + /* absolute_timelock */ None, + ) + .unwrap(); + + // Build replacement for tx_parent, then try to add the grandchild foreign input — it spends + // an output of a replaced tx, so the push is rejected. + let mut cp = CandidateParams::new(); + cp.replace = vec![txid_parent]; + let coins = wallet.candidates_with(&cp).unwrap(); + let result = coins.push_must_select(grandchild_input); + assert!( + matches!(&result, Err(CandidatesError::ConflictingInput(op)) if *op == grandchild_op), + "expected ConflictingInput({grandchild_op}), got: {result:?}", + ); +} + +// Replacing a tx whose inputs the wallet doesn't control is rejected — the wallet couldn't build a +// replacement that conflicts with (and evicts) it. +#[test] +fn test_replace_uncontrolled_tx_errors() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // A foreign, unconfirmed tx in the graph that spends no wallet-owned output. + let foreign_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(Hash::hash(b"not_ours"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new_p2a(), + }], + ..new_tx(0) + }; + let foreign_txid = foreign_tx.compute_txid(); + insert_tx(&mut wallet, foreign_tx); + + let mut cp = CandidateParams::new(); + cp.replace = vec![foreign_txid]; + let result = wallet.candidates_with(&cp); + assert!( + matches!(result, Err(CandidatesError::CannotReplace(txid)) if txid == foreign_txid), + "expected CannotReplace({foreign_txid}), got: {result:?}", + ); +} + +#[test] +fn test_create_psbt_utxo_filter() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 1000, + hash: Hash::hash(b"1000"), + }, + confirmation_time: 1234567, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + + for value in [200, 300, 600, 1000] { + let _ = receive_output( + &mut wallet, + Amount::from_sat(value), + ReceiveTo::Block(anchor), + ); + } + assert_eq!(wallet.list_unspent().count(), 4); + assert_eq!(wallet.balance().total().to_sat(), 2100); + + let change_script = ChangeScript::from_descriptor( + wallet + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(), + ); + // Avoid selection of dust utxos + let coins = wallet.candidates().unwrap().filter(|input| { + let txout = input.prev_txout(); + let min_non_dust = txout.script_pubkey.minimal_non_dust(); // 330 + txout.value >= min_non_dust + }); + let mut params = SelectParams::new(); + params.coin_selection = psbt::SelectionStrategy::DrainAll; + params.change_script = Some(change_script); + params.fee_rate = FeeRate::ZERO; + let template = wallet.select(coins, params).unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); + assert_eq!(psbt.unsigned_tx.input.len(), 2); + assert_eq!(psbt.unsigned_tx.output.len(), 1); + assert_eq!( + psbt.unsigned_tx.output[0].value.to_sat(), + 1600, + "We should have selected 2 non-dust utxos" + ); +} + +// Verify that `create_psbt` returns `NoRecipients` when no recipients are provided and +// `drain_wallet` is not set, even when the wallet contains multiple UTXOs. +#[test] +fn test_create_psbt_no_recipients_error() { + use bdk_chain::{BlockId, ConfirmationBlockTime}; + use bdk_wallet::error::CreatePsbtError; + + let (mut wallet, _) = get_funded_wallet_wpkh(); + + // Add a second confirmed UTXO so we can confirm it's not "just draining one". + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 200, + hash: bitcoin::hashes::Hash::hash(b"200"), + }, + confirmation_time: 2000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + receive_output(&mut wallet, bitcoin::Amount::from_sat(25_000), anchor); + + // No recipients on `select` → should error. + let coins = wallet.candidates().unwrap(); + let err = wallet.select(coins, SelectParams::new()).unwrap_err(); + assert!( + matches!(err, CreatePsbtError::NoRecipients), + "expected NoRecipients, got {err:?}" + ); + + // Sending everything to a single destination is expressed via `DrainAll` with no recipients. + let change_descriptor = wallet + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(); + let coins = wallet.candidates().unwrap(); + let mut params = SelectParams::new(); + params.coin_selection = psbt::SelectionStrategy::DrainAll; + params.change_script = Some(ChangeScript::from_descriptor(change_descriptor)); + let _template = wallet + .select(coins, params) + .expect("drain to an explicit destination should succeed"); +} + +// A drain with no explicit change script makes the wallet auto-derive and *reveal* an internal +// change address (it becomes the sole sweep destination). A drain to an explicit change script +// must not reveal anything new on the internal keychain. +#[test] +fn test_drain_reveals_auto_change() { + // (1) Auto-change drain reveals an internal change address. + let (mut wallet, _) = get_funded_wallet_wpkh(); + let before = wallet.derivation_index(KeychainKind::Internal); + let coins = wallet.candidates().unwrap(); + let mut params = SelectParams::new(); + params.coin_selection = psbt::SelectionStrategy::DrainAll; + let _template = wallet + .select(coins, params) + .expect("auto-change drain should succeed"); + assert!( + wallet.derivation_index(KeychainKind::Internal) > before, + "auto-change drain must reveal an internal change address (before={before:?})" + ); + + // (2) Drain to an explicit change script reveals nothing new internally. + let (mut wallet, _) = get_funded_wallet_wpkh(); + let before = wallet.derivation_index(KeychainKind::Internal); + let change_descriptor = wallet + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(); + let coins = wallet.candidates().unwrap(); + let mut params = SelectParams::new(); + params.coin_selection = psbt::SelectionStrategy::DrainAll; + params.change_script = Some(ChangeScript::from_descriptor(change_descriptor)); + let _template = wallet + .select(coins, params) + .expect("explicit-destination drain should succeed"); + assert_eq!( + wallet.derivation_index(KeychainKind::Internal), + before, + "drain to an explicit change script must not reveal a new internal address" + ); +} + +// Manually-selected coins are de-duplicated when the `CandidateSet` is resolved. +#[test] +fn test_candidates_dedup_manual_inputs() { + let (wallet, txid) = get_funded_wallet_wpkh(); + let op = OutPoint::new(txid, 0); + + // (1) An outpoint listed more than once in `must_spend` resolves to a single input. + let mut cp = CandidateParams::new(); + cp.must_spend = [op, op, op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + assert_eq!( + coins.inputs().filter(|i| i.prev_outpoint() == op).count(), + 1, + "duplicate must-spend outpoints must resolve to a single input" + ); + + // (2) Pushing a foreign input whose outpoint is already a must-spend candidate is de-duplicated + // (the existing candidate is kept; its contents are irrelevant here). + let psbt_input = bitcoin::psbt::Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new_p2a(), + }), + ..Default::default() + }; + let foreign = bdk_tx::Input::from_psbt_input( + op, + Sequence::ENABLE_LOCKTIME_NO_RBF, + psbt_input, + /* satisfaction_weight: */ 0, + /* status: */ None, + /* is_coinbase: */ false, + /* absolute_timelock: */ None, + ) + .unwrap(); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + let coins = wallet + .candidates_with(&cp) + .unwrap() + .push_must_select(foreign) + .unwrap(); + assert_eq!( + coins.inputs().filter(|i| i.prev_outpoint() == op).count(), + 1, + "an outpoint that is both a must-spend and a pushed foreign input resolves once" + ); +} + +// Draining a wallet with no spendable value cannot even cover fees, so selection fails rather than +// producing an empty/invalid transaction. +#[test] +fn test_drain_empty_wallet_errors() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let coins = wallet.candidates().unwrap(); + assert!(coins.is_empty(), "fresh wallet has no candidates"); + + let mut params = SelectParams::new(); + params.coin_selection = psbt::SelectionStrategy::DrainAll; + let err = wallet.select(coins, params).unwrap_err(); + assert!( + matches!(err, CreatePsbtError::Selector(_)), + "empty-wallet drain should fail to meet target, got {err:?}" + ); +} + #[test] #[should_panic(expected = "InputIndexOutOfRange")] fn test_psbt_malformed_psbt_input_legacy() { @@ -221,3 +1483,228 @@ fn test_psbt_multiple_internalkey_signers() { let verify_res = secp.verify_schnorr(&signature, &message, &xonlykey); assert!(verify_res.is_ok(), "The wrong internal key was used"); } + +// When a sweep's only output would fall below the dust threshold, verify that `sweep` +// surfaces this as an error rather than returning a zero-output PSBT. +#[test] +fn test_sweep_change_below_dust_error() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 100, + hash: Hash::hash(b"100"), + }, + confirmation_time: 0, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + + // 200 sats: enough to meet the minimum fee for a P2TR spend, + // but after deducting fees for a tx that *includes* a change output the + // residual change falls below the dust threshold. + receive_output(&mut wallet, Amount::from_sat(200), ReceiveTo::Block(anchor)); + + let change_descriptor = wallet + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(); + let coins = wallet.candidates().unwrap(); + let mut params = SelectParams::new(); + params.coin_selection = psbt::SelectionStrategy::DrainAll; + params.change_script = Some(ChangeScript::from_descriptor(change_descriptor)); + + let err = wallet.select(coins, params).unwrap_err(); + assert!( + matches!(err, CreatePsbtError::AllOutputsBelowDust), + "expected AllOutputsBelowDust when swept output is below dust threshold, got {err:?}" + ); +} + +#[test] +fn test_replace_tx_with_planned_input() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External).address; + + // Fund the wallet with an unconfirmed output. + let funding_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(Hash::hash(b"funding_parent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(200_000), + script_pubkey: addr.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx(&mut wallet, funding_tx); + + // Create an unconfirmed tx spending the funded UTXO. + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + let op2 = OutPoint::new(Hash::hash(b"txid"), 2); + let txout = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_p2a(), + }; + wallet.insert_txout(op2, txout.clone()); + let psbt_input = bitcoin::psbt::Input { + witness_utxo: Some(txout), + ..Default::default() + }; + let planned_input = bdk_tx::Input::from_psbt_input( + op2, + Sequence::ENABLE_LOCKTIME_NO_RBF, + psbt_input, + /* satisfaction_weight: */ 0, + /* status: */ None, + /* is_coinbase: */ false, + /* absolute_timelock: */ None, + ) + .unwrap(); + + let mut cp = CandidateParams::new(); + cp.must_spend = [funding_op].into(); + let coins = wallet + .candidates_with(&cp) + .unwrap() + .push_must_select(planned_input.clone()) + .unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip.clone(), Amount::from_sat(100_000))]; + let template = wallet.select(coins, params).unwrap(); + let unconfirmed_tx = wallet + .finish(template, FinishParams::default()) + .unwrap() + .0 + .unsigned_tx; + let unconfirmed_txid = unconfirmed_tx.compute_txid(); + insert_tx(&mut wallet, unconfirmed_tx.clone()); + + // Add the foreign input alongside the replacement. The pushed foreign input must be respected + // (and de-duplicated) in the replacement's candidate set. + let mut cp = CandidateParams::new(); + cp.replace = vec![unconfirmed_txid]; + let coins = wallet + .candidates_with(&cp) + .unwrap() + .push_must_select(planned_input.clone()) + .unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip, Amount::from_sat(99_000))]; + + let template = wallet + .select(coins, params) + .expect("replacement should succeed"); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); + assert_eq!( + psbt.unsigned_tx.input.len(), + 2, + "replacement tx must include both the wallet input and the planned input" + ); + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|txin| txin.previous_output == funding_op), + "replacement must include the wallet-controlled input" + ); + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|txin| txin.previous_output == op2), + "replacement must include the planned input" + ); +} + +// Test that a Replace-By-Fee candidate set can be fed to `sweep` (now newly possible): +// create + broadcast an unconfirmed tx, then sweep-replace it at a higher feerate and +// assert the replacement spends the same input. +#[test] +fn test_sweep_replace_by_fee() { + use KeychainKind::*; + + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let block = BlockId { + height: 100, + hash: Hash::hash(b"100"), + }; + let addr = wallet.reveal_next_address(External).address; + + // Fund the wallet with a confirmed output. + let funding_tx = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(1_000_000), + script_pubkey: addr.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx_anchor(&mut wallet, funding_tx, block); + + // Create + "broadcast" (insert) an unconfirmed tx paying an external recipient at a low + // feerate. + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + let mut cp = CandidateParams::new(); + cp.must_spend = [funding_op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip.clone(), Amount::from_sat(100_000))]; + params.fee_rate = FeeRate::from_sat_per_vb(1).unwrap(); + let template = wallet.select(coins, params).unwrap(); + let original_tx = wallet + .finish(template, FinishParams::default()) + .unwrap() + .0 + .unsigned_tx; + let original_txid = original_tx.compute_txid(); + insert_tx(&mut wallet, original_tx.clone()); + + // Sweep-replace the original tx at a higher feerate, draining everything to a single + // destination. + let dest = ChangeScript::from_descriptor( + wallet + .public_descriptor(Internal) + .at_derivation_index(0) + .unwrap(), + ); + let coins = wallet.rbf_candidates(&[original_txid]).unwrap(); + assert!(coins.is_rbf(), "candidate set should carry RBF context"); + let mut sweep_params = SelectParams::new(); + sweep_params.coin_selection = psbt::SelectionStrategy::DrainAll; + sweep_params.change_script = Some(dest); + sweep_params.fee_rate = FeeRate::from_sat_per_vb(10).unwrap(); + let template = wallet + .select(coins, sweep_params) + .expect("sweep replacement should succeed"); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); + + // The replacement must spend the same input as the original tx. + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|txin| txin.previous_output == funding_op), + "sweep replacement must spend the original input" + ); +}