From 27ee620ff281cd7805664891d81864f496ad3540 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 12 Aug 2025 13:58:51 -0400 Subject: [PATCH 01/22] feat: Add `create_psbt{_with_aux_rand}` for Wallet Add module psbt/params.rs and introduce PsbtParams. test: Add `test_create_psbt` Introduce `ReplaceParams` Add `Wallet::replace_by_fee_and_recipients` Add `Wallet::replace_by_fee_with_aux_rand` test: `test_sanitize_rbf_set` test: Add `test_replace_by_fee` test: Add `test_spend_non_canonical_txout` --- Cargo.toml | 2 + examples/psbt.rs | 117 ++++++++++ examples/rbf.rs | 92 ++++++++ src/psbt/mod.rs | 4 + src/psbt/params.rs | 329 ++++++++++++++++++++++++++ src/test_utils.rs | 24 ++ src/wallet/error.rs | 65 ++++++ src/wallet/mod.rs | 556 +++++++++++++++++++++++++++++++++++++++++++- tests/psbt.rs | 234 ++++++++++++++++++- tests/wallet.rs | 82 ++++++- 10 files changed, 1491 insertions(+), 14 deletions(-) create mode 100644 examples/psbt.rs create mode 100644 examples/rbf.rs create mode 100644 src/psbt/params.rs diff --git a/Cargo.toml b/Cargo.toml index cb08925c..f26292eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] bdk_chain = { version = "0.23.1", features = ["miniscript", "serde"], default-features = false } +bdk_coin_select = { version = "0.4.1" } +bdk_tx = { version = "0.1.0" } bitcoin = { version = "0.32.6", features = ["serde", "base64"], default-features = false } miniscript = { version = "12.3.1", features = ["serde"], default-features = false } rand_core = { version = "0.6.0" } diff --git a/examples/psbt.rs b/examples/psbt.rs new file mode 100644 index 00000000..2d621b84 --- /dev/null +++ b/examples/psbt.rs @@ -0,0 +1,117 @@ +#![allow(clippy::print_stdout)] + +use std::collections::HashMap; +use std::str::FromStr; + +use bdk_chain::BlockId; +use bdk_chain::ConfirmationBlockTime; +use bdk_wallet::psbt::{PsbtParams, SelectionStrategy::*}; +use bdk_wallet::test_utils::*; +use bdk_wallet::{KeychainKind::External, Wallet}; +use bitcoin::{ + bip32, consensus, + secp256k1::{self, rand}, + 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(); + let secp = secp256k1::Secp256k1::new(); + + // Xpriv to be used for signing the PSBT + let xprv = bip32::Xpriv::from_str("tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L")?; + + // Create wallet and fund it. + let mut wallet = Wallet::create(desc, change_desc) + .network(NETWORK) + .create_wallet_no_persist()?; + + fund_wallet(&mut wallet)?; + + let utxos = wallet + .list_unspent() + .map(|output| (output.outpoint, output)) + .collect::>(); + + // Build params. + let mut params = PsbtParams::default(); + let addr = Address::from_str(SEND_TO)?.require_network(NETWORK)?; + let feerate = feerate_unchecked(FEERATE); + params + .add_recipients([(addr, AMOUNT)]) + .feerate(feerate) + .coin_selection(SingleRandomDraw); + + // Create PSBT (which also returns the Finalizer). + let (mut psbt, finalizer) = wallet.create_psbt(params)?; + + dbg!(&psbt); + + 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(&xprv, &secp); + 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/rbf.rs b/examples/rbf.rs new file mode 100644 index 00000000..108528d4 --- /dev/null +++ b/examples/rbf.rs @@ -0,0 +1,92 @@ +#![allow(clippy::print_stdout)] + +use std::str::FromStr; +use std::sync::Arc; + +use bdk_chain::BlockId; +use bdk_wallet::test_utils::*; +use bdk_wallet::Wallet; +use bitcoin::{bip32, consensus, secp256k1, Address, FeeRate, Transaction}; + +// This example shows how to create a Replace-By-Fee (RBF) transaction using BDK Wallet. + +const NETWORK: bitcoin::Network = bitcoin::Network::Regtest; +const SEND_TO: &str = "bcrt1q3yfqg2v9d605r45y5ddt5unz5n8v7jl5yk4a4f"; + +fn main() -> anyhow::Result<()> { + let desc = "wpkh(tprv8ZgxMBicQKsPe5tkv8BYJRupCNULhJYDv6qrtVAK9fNVheU6TbscSedVi8KQk8vVZqXMnsGomtVkR4nprbgsxTS5mAQPV4dpPXNvsmYcgZU/84h/1h/0h/0/*)"; + let change_desc = "wpkh(tprv8ZgxMBicQKsPe5tkv8BYJRupCNULhJYDv6qrtVAK9fNVheU6TbscSedVi8KQk8vVZqXMnsGomtVkR4nprbgsxTS5mAQPV4dpPXNvsmYcgZU/84h/1h/0h/1/*)"; + let secp = secp256k1::Secp256k1::new(); + + // Xpriv to be used for signing the PSBT + let xprv = bip32::Xpriv::from_str("tprv8ZgxMBicQKsPe5tkv8BYJRupCNULhJYDv6qrtVAK9fNVheU6TbscSedVi8KQk8vVZqXMnsGomtVkR4nprbgsxTS5mAQPV4dpPXNvsmYcgZU")?; + + // Create wallet and "fund" it. + let mut wallet = Wallet::create(desc, change_desc) + .network(NETWORK) + .create_wallet_no_persist()?; + + // `tx_1` is the unconfirmed wallet tx that we want to replace. + let tx_1 = fund_wallet(&mut wallet)?; + wallet.apply_unconfirmed_txs([(tx_1.clone(), 1234567000)]); + + // We'll need to fill in the original recipient details. + let addr = Address::from_str(SEND_TO)?.require_network(NETWORK)?; + let txo = tx_1 + .output + .iter() + .find(|txo| txo.script_pubkey == addr.script_pubkey()) + .expect("failed to find orginal recipient") + .clone(); + + // Now build fee bump. + let (mut psbt, finalizer) = wallet.replace_by_fee_and_recipients( + &[Arc::clone(&tx_1)], + FeeRate::from_sat_per_vb_unchecked(5), + vec![(txo.script_pubkey, txo.value)], + )?; + + let _ = psbt.sign(&xprv, &secp); + 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)); + + wallet.apply_unconfirmed_txs([(tx.clone(), 1234567001)]); + + let txid_2 = tx.compute_txid(); + + assert!( + wallet + .tx_graph() + .direct_conflicts(&tx_1) + .any(|(_, txid)| txid == txid_2), + "ERROR: RBF tx does not replace `tx_1`", + ); + + Ok(()) +} + +fn fund_wallet(wallet: &mut Wallet) -> anyhow::Result> { + // The parent of `tx`. This is needed to compute the original fee. + let tx0: Transaction = consensus::encode::deserialize_hex( + "020000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff025100ffffffff0200f2052a010000001600144d34238b9c4c59b9e2781e2426a142a75b8901ab0000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf90120000000000000000000000000000000000000000000000000000000000000000000000000", + )?; + + let anchor_block = BlockId { + height: 101, + hash: "3bcc1c447c6b3886f43e416b5c21cf5c139dc4829a71dc78609bc8f6235611c5".parse()?, + }; + insert_tx_anchor(wallet, tx0, anchor_block); + + let tx: Transaction = consensus::encode::deserialize_hex( + "020000000001014cb96536e94ba3f840cb5c2c965c8f9a306209de63fcd02060219aaf14f1d7b30000000000fdffffff0280de80020000000016001489120429856e9f41d684a35aba7262a4cecf4bf4f312852701000000160014757a57b3009c0e9b2b9aa548434dc295e21aeb05024730440220400c0a767ce42e0ea02b72faabb7f3433e607b475111285e0975bba1e6fd2e13022059453d83cbacb6652ba075f59ca0437036f3f94cae1959c7c5c0f96a8954707a012102c0851c2d2bddc1dd0b05caeac307703ec0c4b96ecad5a85af47f6420e2ef6c661b000000", + )?; + + Ok(Arc::new(tx)) +} diff --git a/src/psbt/mod.rs b/src/psbt/mod.rs index 5f05b7b8..1f07fe6b 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..7746fb72 --- /dev/null +++ b/src/psbt/params.rs @@ -0,0 +1,329 @@ +//! Parameters for PSBT building. + +use alloc::sync::Arc; +use alloc::vec::Vec; + +use bdk_chain::{BlockId, CanonicalizationParams, TxGraph}; +use bdk_tx::DefiniteDescriptor; +use bitcoin::{ + absolute, transaction::Version, Amount, FeeRate, OutPoint, ScriptBuf, Sequence, Transaction, + Txid, +}; +use miniscript::plan::Assets; + +use crate::collections::HashSet; + +/// Parameters to create a PSBT. +#[derive(Debug)] +pub struct PsbtParams { + // Inputs + pub(crate) utxos: HashSet, + + // Outputs + pub(crate) recipients: Vec<(ScriptBuf, Amount)>, + pub(crate) change_descriptor: Option, + + // Coin Selection + pub(crate) assets: Option, + pub(crate) feerate: FeeRate, + pub(crate) longterm_feerate: FeeRate, + pub(crate) drain_wallet: bool, + pub(crate) coin_selection: SelectionStrategy, + pub(crate) canonical_params: CanonicalizationParams, + + // PSBT + pub(crate) version: Option, + pub(crate) locktime: Option, + pub(crate) fallback_sequence: Option, +} + +impl Default for PsbtParams { + fn default() -> Self { + Self { + utxos: Default::default(), + assets: Default::default(), + recipients: Default::default(), + change_descriptor: Default::default(), + feerate: bitcoin::FeeRate::BROADCAST_MIN, + longterm_feerate: bitcoin::FeeRate::from_sat_per_vb_unchecked(10), + drain_wallet: Default::default(), + coin_selection: Default::default(), + canonical_params: Default::default(), + version: Default::default(), + locktime: Default::default(), + fallback_sequence: Default::default(), + } + } +} + +impl PsbtParams { + /// Add UTXOs by outpoint to fund the transaction. + pub fn add_utxos(&mut self, outpoints: &[OutPoint]) -> &mut Self { + self.utxos.extend(outpoints); + self + } + + /// Get the currently selected spends. + pub fn utxos(&self) -> &HashSet { + &self.utxos + } + + /// Remove a UTXO from the currently selected inputs. + pub fn remove_utxo(&mut self, outpoint: OutPoint) -> &mut Self { + self.utxos.remove(&outpoint); + self + } + + /// Add the spend [`Assets`]. + /// + /// Assets are required to create a spending plan for an output controlled by the wallet's + /// descriptors. If none are provided here, then we assume all of the keys are equally likely + /// to sign. + /// + /// This may be called multiple times to add additional assets, however only the last + /// absolute or relative timelock is retained. See also `AssetsExt`. + pub fn add_assets(&mut self, assets: Assets) -> &mut Self { + let mut new = match self.assets { + Some(ref existing) => { + let mut new = Assets::new(); + new.extend(existing); + new + } + None => Assets::new(), + }; + new.extend(&assets); + self.assets = Some(new); + self + } + + /// Add recipients. + /// + /// - `recipients`: An iterator of `(S, Amount)` tuples where `S` can be a bitcoin [`Address`], + /// a scriptPubKey, or anything that can be converted straight into a [`ScriptBuf`]. + pub fn add_recipients(&mut self, recipients: I) -> &mut Self + where + I: IntoIterator, + S: Into, + { + self.recipients + .extend(recipients.into_iter().map(|(s, amt)| (s.into(), amt))); + self + } + + /// Set the target fee rate. + pub fn feerate(&mut self, feerate: FeeRate) -> &mut Self { + self.feerate = feerate; + self + } + + /// Set the strategy to be used when selecting coins. + pub fn coin_selection(&mut self, strategy: SelectionStrategy) -> &mut Self { + self.coin_selection = strategy; + self + } + + /// Set the definite descriptor used for generating the change output. + pub fn change_descriptor(&mut self, desc: DefiniteDescriptor) -> &mut Self { + self.change_descriptor = Some(desc); + self + } + + /// Replace spends of the given `txs` and return a [`ReplaceParams`] populated with the + /// the inputs to spend. + /// + /// This merges all of the spends into a single transaction while retaining the parameters + /// of `self`. Note however that any previously added UTXOs are removed. Call + /// [`replace_by_fee_with_aux_rand`](crate::Wallet::replace_by_fee_with_aux_rand) to finish + /// building the PSBT. + /// + /// ## Note + /// + /// There should be no ancestry linking the elements of `txs`, since replacing an + /// ancestor necessarily invalidates the descendant. + pub fn replace(self, txs: &[Arc]) -> ReplaceParams { + ReplaceParams::new(txs, self) + } +} + +/// `ReplaceParams` provides a thin wrapper around [`PsbtParams`] and is intended for +/// crafting Replace-By-Fee transactions (RBF). +#[derive(Debug, Default)] +pub struct ReplaceParams { + /// Txids of txs to replace. + pub(crate) replace: HashSet, + /// The inner PSBT parameters. + pub(crate) inner: PsbtParams, +} + +impl ReplaceParams { + /// Construct from PSBT `params` and an iterator of `txs` to replace. + pub(crate) fn new(txs: &[Arc], params: PsbtParams) -> Self { + Self { + inner: params, + ..Default::default() + } + .replace(txs) + } + + /// Replace spends of the provided `txs`. This will internally set the inner + /// params UTXOs to be spent. + pub fn replace(self, txs: &[Arc]) -> Self { + let txs: Vec> = txs.to_vec(); + let mut txids: HashSet = txs.iter().map(|tx| tx.compute_txid()).collect(); + let mut tx_graph = TxGraph::::default(); + let mut utxos: HashSet = HashSet::new(); + + for tx in txs { + let _ = tx_graph.insert_tx(tx); + } + + // Sanitize the RBF set by removing elements of `txs` which have ancestors + // in the same set. This is to avoid spending outputs of txs that are bound + // for replacement. + for tx_node in tx_graph.full_txs() { + let tx = &tx_node.tx; + if tx.is_coinbase() + || tx_graph + .walk_ancestors(Arc::clone(tx), |_, tx| Some(tx.compute_txid())) + .any(|ancestor_txid| txids.contains(&ancestor_txid)) + { + txids.remove(&tx_node.txid); + } else { + utxos.extend(tx.input.iter().map(|txin| txin.previous_output)); + } + } + + Self { + inner: PsbtParams { + utxos, + ..self.inner + }, + replace: txids, + } + } + + /// Add recipients. + pub fn add_recipients(&mut self, recipients: I) -> &mut Self + where + I: IntoIterator, + S: Into, + { + self.inner.add_recipients(recipients); + self + } + + /// Set the target fee rate. + pub fn feerate(&mut self, feerate: FeeRate) -> &mut Self { + self.inner.feerate(feerate); + self + } + + /// Get the currently selected spends. + pub fn utxos(&self) -> &HashSet { + self.inner.utxos() + } + + /// Remove a UTXO from the currently selected inputs. + pub fn remove_utxo(&mut self, outpoint: OutPoint) -> &mut Self { + self.inner.remove_utxo(outpoint); + self + } +} + +/// 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`](bdk_coin_select::metrics::LowestFee) metric for more. + LowestFee, +} + +/// Trait to extend the functionality of [`Assets`]. +pub(crate) trait AssetsExt { + /// Extend `self` with the contents of `other`. + fn extend(&mut self, other: &Self); +} + +impl AssetsExt for Assets { + /// Extend `self` with the contents of `other`. Note that if present this preferentially + /// uses the absolute and relative timelocks of `other`. + fn extend(&mut self, other: &Self) { + self.keys.extend(other.keys.clone()); + self.sha256_preimages.extend(other.sha256_preimages.clone()); + self.hash256_preimages + .extend(other.hash256_preimages.clone()); + self.ripemd160_preimages + .extend(other.ripemd160_preimages.clone()); + self.hash160_preimages + .extend(other.hash160_preimages.clone()); + + self.absolute_timelock = other.absolute_timelock.or(self.absolute_timelock); + self.relative_timelock = other.relative_timelock.or(self.relative_timelock); + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::test_utils::new_tx; + + use bitcoin::hashes::Hash; + use bitcoin::{TxIn, TxOut}; + + #[test] + fn test_sanitize_rbf_set() { + // To replace: { [A, B], [C] } (where B spends from A) + // We can't replace the inputs of B, since we're already replacing A + // therefore the inputs should only include the spends of [A, C]. + + // A is an ancestor + let tx_a = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(Hash::hash(b"parent_a"), 0), + ..Default::default() + }], + output: vec![TxOut::NULL], + ..new_tx(0) + }; + let txid_a = tx_a.compute_txid(); + // B spends A + let tx_b = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(txid_a, 0), + ..Default::default() + }], + output: vec![TxOut::NULL], + ..new_tx(1) + }; + // C is an ancestor + let tx_c = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(Hash::hash(b"parent_c"), 0), + ..Default::default() + }], + output: vec![TxOut::NULL], + ..new_tx(2) + }; + let txid_c = tx_c.compute_txid(); + // D is unrelated coinbase tx + let tx_d = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut::NULL], + ..new_tx(3) + }; + + let expect_spends: HashSet = + [tx_a.input[0].previous_output, tx_c.input[0].previous_output].into(); + + let txs: Vec> = + [tx_a, tx_b, tx_c, tx_d].into_iter().map(Arc::new).collect(); + let params = ReplaceParams::new(&txs, PsbtParams::default()); + assert_eq!(params.utxos(), &expect_spends); + assert_eq!(params.replace, [txid_a, txid_c].into()); + } +} diff --git a/src/test_utils.rs b/src/test_utils.rs index 11fd13b1..5c848667 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -317,6 +317,30 @@ pub fn insert_checkpoint(wallet: &mut Wallet, block: BlockId) { .unwrap(); } +/// Inserts a transaction with anchor (no last seen). This is useful for adding +/// a coinbase tx to the wallet for testing. +/// +/// 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 47be69df..dd3cdb60 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -256,3 +256,68 @@ impl fmt::Display for BuildFeeBumpError { #[cfg(feature = "std")] impl std::error::Error for BuildFeeBumpError {} + +/// Error when creating a PSBT. +#[derive(Debug)] +#[non_exhaustive] +pub enum CreatePsbtError { + /// No Bnb solution. + Bnb(bdk_coin_select::NoBnbSolution), + /// Non-sufficient funds + InsufficientFunds(bdk_coin_select::InsufficientFunds), + /// Failed to create a spend [`Plan`] for a manually selected output + Plan(OutPoint), + /// Failed to create PSBT + Psbt(bdk_tx::CreatePsbtError), + /// Selector error + Selector(bdk_tx::SelectorError), + /// The UTXO of outpoint could not be found + UnknownUtxo(OutPoint), +} + +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::Plan(op) => write!(f, "failed to create a plan for txout with outpoint {op}"), + Self::Psbt(e) => write!(f, "{e}"), + Self::Selector(e) => write!(f, "{e}"), + Self::UnknownUtxo(op) => write!(f, "unknown UTXO: {op}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for CreatePsbtError {} + +/// Error when creating a Replace-By-Fee transaction. +#[derive(Debug)] +#[non_exhaustive] +pub enum ReplaceByFeeError { + /// There was a problem creating the PSBT + CreatePsbt(CreatePsbtError), + /// Failed to compute the fee of an original transaction + PreviousFee(bdk_chain::tx_graph::CalculateFeeError), + /// Original transaction could not be found + MissingTransaction(Txid), +} + +impl fmt::Display for ReplaceByFeeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CreatePsbt(e) => write!(f, "{e}"), + Self::PreviousFee(e) => write!(f, "{e}"), + Self::MissingTransaction(txid) => write!(f, "missing transaction with txid: {txid}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for ReplaceByFeeError {} + +impl From for ReplaceByFeeError { + fn from(e: CreatePsbtError) -> Self { + Self::CreatePsbt(e) + } +} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 273b5f6f..7bb23418 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -30,14 +30,16 @@ 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, }; +#[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, BlockHash, FeeRate, Network, OutPoint, Psbt, ScriptBuf, @@ -46,6 +48,7 @@ use bitcoin::{ use miniscript::{ descriptor::KeyMap, psbt::{PsbtExt, PsbtInputExt, PsbtInputSatisfier}, + ForEachKey, }; use rand_core::RngCore; @@ -69,7 +72,9 @@ use crate::psbt::PsbtUtils; use crate::types::*; use crate::wallet::{ coin_selection::{DefaultCoinSelectionAlgorithm, Excess, InsufficientFunds}, - error::{BuildFeeBumpError, CreateTxError, MiniscriptPsbtError}, + error::{ + BuildFeeBumpError, CreatePsbtError, CreateTxError, MiniscriptPsbtError, ReplaceByFeeError, + }, signer::{SignOptions, SignerError, SignerOrdering, SignersContainer, TransactionSigner}, tx_builder::{FeePolicy, TxBuilder, TxParams}, utils::{check_nsequence_rbf, After, Older, SecpCtx}, @@ -80,8 +85,7 @@ pub use bdk_chain::Balance; pub use changeset::ChangeSet; pub use params::*; pub use persisted::*; -pub use utils::IsDust; -pub use utils::TxDetails; +pub use utils::{IsDust, TxDetails}; /// A Bitcoin wallet /// @@ -903,6 +907,19 @@ impl Wallet { .map(|((k, i), full_txo)| new_local_utxo(k, i, full_txo)) } + /// List indexed full txouts. Note: the result can be modified by the canonicalization `params`. + fn list_indexed_txouts( + &self, + params: CanonicalizationParams, + ) -> impl Iterator + '_ { + self.indexed_graph.graph().filter_chain_txouts( + &self.chain, + self.chain.tip().block_id(), + params, + self.indexed_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` @@ -1190,14 +1207,18 @@ impl Wallet { /// To iterate over all canonical transactions, including those that are irrelevant, use /// [`TxGraph::list_canonical_txs`]. pub fn transactions<'a>(&'a self) -> impl Iterator> + 'a { + self.transactions_with_params(CanonicalizationParams::default()) + } + + /// Transactions with params. + pub fn transactions_with_params<'a>( + &'a self, + params: CanonicalizationParams, + ) -> impl Iterator> + 'a { let tx_graph = self.indexed_graph.graph(); let tx_index = &self.indexed_graph.index; tx_graph - .list_canonical_txs( - &self.chain, - self.chain.tip().block_id(), - CanonicalizationParams::default(), - ) + .list_canonical_txs(&self.chain, self.chain.tip().block_id(), params) .filter(|c_tx| tx_index.is_tx_relevant(&c_tx.tx_node.tx)) } @@ -2466,6 +2487,30 @@ impl Wallet { Ok(()) } + /// Inserts a transaction into the inner transaction graph with no additional metadata. + /// + /// This is used to inform the wallet of newly created txs before they are known to exist + /// on chain (or in mempool), which is useful for discovering wallet-owned outputs of + /// not-yet-canonical transactions. + /// + /// The effect of insertion depends on the [relevance] of `tx` as determined by the indexer. + /// If the transaction was newly inserted and a txout matches a derived SPK, then the index + /// is updated with the relevant outpoint. This means the output may be selected in + /// subsequent transactions (if selected manually), enabling chains of dependent spends to + /// occur prior to broadcast time. If none of the outputs are relevant, the transaction is + /// kept but the index remains unchanged. If the transaction was already present in-graph, + /// the effect is a no-op. + /// + /// **You must persist the change set staged as a result of this call.** + /// + /// [relevance]: Indexer::is_tx_relevant + pub fn insert_tx(&mut self, tx: T) + where + T: Into>, + { + self.stage.merge(self.indexed_graph.insert_tx(tx).into()); + } + /// Apply relevant unconfirmed transactions to the wallet. /// /// Transactions that are not relevant are filtered out. @@ -2636,6 +2681,495 @@ impl Wallet { } } +use bdk_chain::KeychainIndexed; +use bdk_tx::{ + selection_algorithm_lowest_fee_bnb, ChangePolicyType, DefiniteDescriptor, Finalizer, Input, + InputCandidates, OriginalTxStats, Output, RbfParams, Selector, SelectorParams, TxStatus, +}; +use miniscript::plan::{Assets, Plan}; + +use crate::psbt::{AssetsExt, PsbtParams, ReplaceParams, SelectionStrategy}; + +/// Type +type IndexedTxOut = KeychainIndexed>; + +/// 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()?; + let time = + absolute::Time::from_consensus(anchor.confirmation_time.try_into().ok()?).ok()?; + Some(TxStatus { height, 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) + } + + /// Parses the common parameters used during PSBT creation. + /// + /// ## Returns + /// + /// - Assets + /// - Change script + /// - Indexed wallet txouts + fn parse_params( + &self, + params: &PsbtParams, + ) -> ( + Assets, + DefiniteDescriptor, + HashMap>, + ) { + // Get spend assets. + let assets = match params.assets { + None => self.assets(), + Some(ref params_assets) => { + let mut assets = Assets::new(); + assets.extend(params_assets); + // Fill in the "keys" assets if none are provided. + if assets.keys.is_empty() { + assets.extend(&self.assets()); + } + assets + } + }; + + // Get change script. + let change_script = params.change_descriptor.clone().unwrap_or_else(|| { + let change_keychain = self.map_keychain(KeychainKind::Internal); + let desc = self.public_descriptor(change_keychain); + let next_index = self.next_derivation_index(change_keychain); + desc.at_derivation_index(next_index) + .expect("should be valid derivation index") + }); + + // Get wallet txouts. + let mut canon_params = params.canonical_params.clone(); + canon_params + .assume_canonical + .extend(params.utxos.iter().map(|op| op.txid)); + let txouts = self + .list_indexed_txouts(canon_params) + .map(|(_, txo)| (txo.outpoint, txo)) + .collect(); + + (assets, change_script, txouts) + } + + /// Filters wallet `txos` by the spending criteria. + /// + /// - `exclude`: Closure indicating whether the output should be excluded, used by some callers + /// to apply additional filters as in the case of RBF. + fn filter_spendable<'a, I, F>( + &'a self, + txos: I, + params: &'a PsbtParams, + exclude: F, + ) -> impl Iterator> + 'a + where + I: IntoIterator> + 'a, + F: Fn(&FullTxOut) -> bool + 'a, + { + let current_height = self.latest_checkpoint().height(); + txos.into_iter().filter(move |txo| { + // Exclude outputs that are manually selected. + if params.utxos.contains(&txo.outpoint) { + return false; + } + // Exclude outputs according to `exclude` fn. + if exclude(txo) { + return false; + } + // Exclude outputs that are immature or already spent. + if !txo.is_mature(current_height) { + return false; + } + if txo.spent_by.is_some() { + return false; + } + true + }) + } + + /// Creates a PSBT with the given `params` and returns the updated [`Psbt`] and + /// [`Finalizer`]. + /// + /// # Example + /// + /// ```rust,no_run + /// # use std::str::FromStr; + /// # use bitcoin::{Amount, Address, FeeRate, OutPoint}; + /// # use bdk_wallet::psbt::{PsbtParams, SelectionStrategy}; + /// # let wallet = bdk_wallet::doctest_wallet!(); + /// # let outpoint = OutPoint::null(); + /// # let address = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5").unwrap().assume_checked(); + /// # let amount = Amount::ZERO; + /// let mut params = PsbtParams::default(); + /// params + /// .add_utxos(&[outpoint]) + /// .add_recipients([(address, amount)]) + /// .coin_selection(SelectionStrategy::LowestFee) + /// .feerate(FeeRate::BROADCAST_MIN); + /// + /// let (psbt, finalizer) = wallet.create_psbt(params)?; + /// # Ok::<_, anyhow::Error>(()) + /// ``` + #[cfg(feature = "std")] + pub fn create_psbt(&self, params: PsbtParams) -> Result<(Psbt, Finalizer), CreatePsbtError> { + self.create_psbt_with_aux_rand(params, &mut rand::thread_rng()) + } + + /// Creates a PSBT with the given `params` and auxiliary randomness. + /// + /// ### Parameters: + /// + /// - `params`: [`PsbtParams`] + /// - `rng`: Source of entropy, may be used during coin selection. + /// + /// Returns the updated [`Psbt`] and [`Finalizer`]. + pub fn create_psbt_with_aux_rand( + &self, + params: PsbtParams, + rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), CreatePsbtError> { + let (assets, change_script, txouts) = self.parse_params(¶ms); + + let must_spend: Vec = params + .utxos + .iter() + .map(|&op| -> Result<_, CreatePsbtError> { + let txo = txouts.get(&op).ok_or(CreatePsbtError::UnknownUtxo(op))?; + self.plan_input(txo, &assets) + .ok_or(CreatePsbtError::Plan(op)) + }) + .collect::>()?; + + // Get input candidates + let mut may_spend: Vec = self + .filter_spendable(txouts.into_values(), ¶ms, |_| false) + .flat_map(|txo| self.plan_input(&txo, &assets)) + .collect(); + + utils::shuffle_slice(&mut may_spend, rng); + + let input_candidates = InputCandidates::new(must_spend, may_spend); + + let target_outputs = params + .recipients + .iter() + .cloned() + .map(Output::from) + .collect(); + + let mut selector = Selector::new( + &input_candidates, + SelectorParams { + target_feerate: params.feerate, + target_outputs, + change_descriptor: change_script, + change_policy: ChangePolicyType::NoDustAndLeastWaste { + longterm_feerate: params.longterm_feerate, + }, + replace: None, + }, + ) + .map_err(CreatePsbtError::Selector)?; + + self.create_psbt_from_selector(&mut selector, ¶ms) + } + + /// Create the PSBT from [`Selector`] and `params`. + /// + /// Internal method for handling coin selection and building the + /// resulting PSBT. + fn create_psbt_from_selector( + &self, + selector: &mut Selector, + params: &PsbtParams, + ) -> Result<(Psbt, Finalizer), CreatePsbtError> { + // How many times to run bnb before giving up + const BNB_MAX_ROUNDS: usize = 10_000; + + // Select coins + if params.drain_wallet { + selector.select_all(); + } else { + match params.coin_selection { + SelectionStrategy::SingleRandomDraw => { + // We should have shuffled candidates earlier, so just select + // until the target is met. + selector + .select_until_target_met() + .map_err(CreatePsbtError::InsufficientFunds)?; + } + SelectionStrategy::LowestFee => { + selector + .select_with_algorithm(selection_algorithm_lowest_fee_bnb( + params.longterm_feerate, + BNB_MAX_ROUNDS, + )) + .map_err(CreatePsbtError::Bnb)?; + } + }; + } + let selection = selector.try_finalize().ok_or({ + let e = bdk_tx::CannotMeetTarget; + CreatePsbtError::Selector(bdk_tx::SelectorError::CannotMeetTarget(e)) + })?; + + let version = params.version.unwrap_or(transaction::Version::TWO); + let fallback_locktime = params + .locktime + .unwrap_or_else(|| absolute::LockTime::from_consensus(self.chain.tip().height())); + let fallback_sequence = params + .fallback_sequence + .unwrap_or(Sequence::ENABLE_LOCKTIME_NO_RBF); + + // Create psbt + let psbt = selection + .create_psbt(bdk_tx::PsbtParams { + version, + fallback_locktime, + fallback_sequence, + mandate_full_tx_for_segwit_v0: true, + }) + .map_err(CreatePsbtError::Psbt)?; + + let finalizer = selection.into_finalizer(); + + Ok((psbt, finalizer)) + } + + /// Creates a Replace-By-Fee transaction (RBF) and returns the updated [`Psbt`] and + /// [`Finalizer`]. + /// + /// This is a convenience for getting a new [`ReplaceParams`], and updating the recipients + /// and feerate before calling [`replace_by_fee_with_aux_rand`]. If further configuration is + /// desired, consider using [`PsbtParams::replace`] instead. + /// + /// # Example + /// + /// ```rust,no_run + /// # use std::sync::Arc; + /// # use bitcoin::FeeRate; + /// # use bdk_wallet::psbt::{PsbtParams, SelectionStrategy}; + /// # use bdk_wallet::test_utils; + /// # let wallet = bdk_wallet::doctest_wallet!(); + /// # let to_replace = Arc::new(test_utils::new_tx(0)); + /// # let vout = 0; + /// // Retrieve the original recipient from tx `to_replace`. + /// let txout = to_replace.tx_out(vout)?.clone(); + /// + /// let (psbt, finalizer) = wallet.replace_by_fee_and_recipients( + /// &[to_replace], + /// FeeRate::from_sat_per_vb_unchecked(10), + /// vec![(txout.script_pubkey, txout.value)], + /// )?; + /// # Ok::<_, anyhow::Error>(()) + /// ``` + /// + /// [`replace_by_fee_with_aux_rand`]: Wallet::replace_by_fee_with_aux_rand + #[cfg(feature = "std")] + pub fn replace_by_fee_and_recipients( + &self, + txs: &[Arc], + feerate: FeeRate, + recipients: Vec<(ScriptBuf, Amount)>, + ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { + self.replace_by_fee_with_aux_rand( + ReplaceParams::new( + txs, + PsbtParams { + recipients, + feerate, + ..Default::default() + }, + ), + &mut rand::thread_rng(), + ) + } + + /// Creates a Replace-By-Fee transaction (RBF) and returns the updated [`Psbt`] and + /// [`Finalizer`]. + /// + /// ### Parameters: + /// + /// - `params`: [`ReplaceParams`] + /// - `rng`: Source of entropy, may be used during coin selection. + pub fn replace_by_fee_with_aux_rand( + &self, + params: ReplaceParams, + rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { + let ReplaceParams { + replace: txids_to_replace, + inner: params, + } = params; + // Txs and their descendants to be replaced. This is used to filter outputs that can't + // be selected. + let mut to_replace = txids_to_replace.clone(); + for txid in txids_to_replace.iter().copied() { + to_replace.extend( + self.indexed_graph + .graph() + .walk_descendants(txid, |_, txid| Some(txid)), + ); + } + + let (assets, change_script, txouts) = self.parse_params(¶ms); + + let must_spend: Vec = params + .utxos + .iter() + .map(|&op| -> Result<_, CreatePsbtError> { + let txo = txouts.get(&op).ok_or(CreatePsbtError::UnknownUtxo(op))?; + self.plan_input(txo, &assets) + .ok_or(CreatePsbtError::Plan(op)) + }) + .collect::>()?; + + // Get input candidates + let mut may_spend: Vec = self + .filter_spendable(txouts.into_values(), ¶ms, |txo| { + // Exlude outputs of txs to be replaced. Also exclude unconfirmed outputs + // per replacement policy Rule 2. + to_replace.contains(&txo.outpoint.txid) || txo.chain_position.is_unconfirmed() + }) + .flat_map(|txo| self.plan_input(&txo, &assets)) + .collect(); + + utils::shuffle_slice(&mut may_spend, rng); + + let input_candidates = InputCandidates::new(must_spend, may_spend); + + let original_txs: Vec = txids_to_replace + .iter() + .map(|&txid| -> Result<_, ReplaceByFeeError> { + let tx = self + .indexed_graph + .graph() + .get_tx(txid) + .ok_or(ReplaceByFeeError::MissingTransaction(txid))?; + let fee = self + .calculate_fee(&tx) + .map_err(ReplaceByFeeError::PreviousFee)?; + Ok(OriginalTxStats { + weight: tx.weight(), + fee, + }) + }) + .collect::>()?; + + let rbf_params = RbfParams { + original_txs, + incremental_relay_feerate: FeeRate::BROADCAST_MIN, + }; + + let target_outputs = params + .recipients + .iter() + .cloned() + .map(Output::from) + .collect(); + + let mut selector = Selector::new( + &input_candidates, + SelectorParams { + target_feerate: params.feerate, + target_outputs, + change_descriptor: change_script, + change_policy: ChangePolicyType::NoDustAndLeastWaste { + longterm_feerate: params.longterm_feerate, + }, + replace: Some(rbf_params), + }, + ) + .map_err(CreatePsbtError::Selector)?; + + self.create_psbt_from_selector(&mut selector, ¶ms) + .map_err(ReplaceByFeeError::CreatePsbt) + } + + /// 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) + }); + + let mut assets = Assets::new(); + assets.extend(spend_assets); + assets = assets.after(abs_locktime); + assets = assets.older(rel_locktime); + + let plan = self.try_plan(op, &assets)?; + let tx = self.indexed_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.indexed_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.indexed_graph.graph() @@ -2759,7 +3293,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/tests/psbt.rs b/tests/psbt.rs index 08c4acc9..6568d323 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -1,11 +1,241 @@ -use bdk_wallet::bitcoin::{Amount, FeeRate, Psbt, TxIn}; +use bdk_chain::BlockId; +use bdk_chain::ConfirmationBlockTime; +use bdk_wallet::bitcoin; +use bdk_wallet::bitcoin::{ + hashes::Hash, secp256k1, Address, Amount, FeeRate, Network, OutPoint, Psbt, ScriptBuf, + Transaction, TxIn, TxOut, +}; use bdk_wallet::test_utils::*; -use bdk_wallet::{psbt, KeychainKind, SignOptions}; +use bdk_wallet::{psbt, KeychainKind, SignOptions, Wallet}; use core::str::FromStr; +use std::sync::Arc; // from bip 174 const PSBT_STR: &str = "cHNidP8BAKACAAAAAqsJSaCMWvfEm4IS9Bfi8Vqz9cM9zxU4IagTn4d6W3vkAAAAAAD+////qwlJoIxa98SbghL0F+LxWrP1wz3PFTghqBOfh3pbe+QBAAAAAP7///8CYDvqCwAAAAAZdqkUdopAu9dAy+gdmI5x3ipNXHE5ax2IrI4kAAAAAAAAGXapFG9GILVT+glechue4O/p+gOcykWXiKwAAAAAAAEHakcwRAIgR1lmF5fAGwNrJZKJSGhiGDR9iYZLcZ4ff89X0eURZYcCIFMJ6r9Wqk2Ikf/REf3xM286KdqGbX+EhtdVRs7tr5MZASEDXNxh/HupccC1AaZGoqg7ECy0OIEhfKaC3Ibi1z+ogpIAAQEgAOH1BQAAAAAXqRQ1RebjO4MsRwUPJNPuuTycA5SLx4cBBBYAFIXRNTfy4mVAWjTbr6nj3aAfuCMIAAAA"; +#[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(); + + // 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_desc = + miniscript::Descriptor::parse_descriptor(&secp256k1::Secp256k1::new(), change_desc) + .unwrap() + .0 + .at_derivation_index(0) + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External); + let mut params = psbt::PsbtParams::default(); + params + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]) + .change_descriptor(change_desc) + .feerate(FeeRate::from_sat_per_vb_unchecked(4)); + + let (psbt, _) = wallet.create_psbt(params).unwrap(); + assert!(psbt.fee().is_ok()); + let psbt_input = &psbt.inputs[0]; + assert_eq!( + psbt_input.witness_utxo.as_ref().map(|txo| txo.value), + Some(Amount::ONE_BTC), + ); + assert!(psbt_input.tap_internal_key.is_some()); + assert!(psbt_input + .tap_key_origins + .values() + .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b")); + assert!(psbt + .outputs + .iter() + .any(|output| output.tap_internal_key.is_some())); + assert!(psbt.outputs.iter().any(|output| output + .tap_key_origins + .values() + .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b"))); +} + +#[test] +fn test_create_psbt_cltv() { + use bdk_wallet::error::CreatePsbtError; + use bitcoin::absolute::LockTime; + use miniscript::plan::Assets; + + 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 params = psbt::PsbtParams::default(); + params + .add_utxos(&[op]) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let res = wallet.create_psbt(params); + assert!( + matches!(res, Err(CreatePsbtError::Plan(err)) if err == op), + "UTXO requires CLTV but the assets are insufficient", + ); + } + + // Add assets ok + { + let mut params = psbt::PsbtParams::default(); + params + .add_utxos(&[op]) + .add_assets(Assets::new().after(LockTime::from_consensus(100_000))) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let _ = wallet + .create_psbt(params) + .expect("Create psbt should succeed"); + } + + // 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 params = psbt::PsbtParams::default(); + params + .add_utxos(&[op]) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let _ = wallet + .create_psbt(params) + .expect("Create psbt should succeed"); + } +} + +#[test] +fn test_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(); + + // 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 params = psbt::PsbtParams::default(); + params + .add_utxos(&[op0]) + .add_recipients([(recip.clone(), Amount::from_sat(16_000))]); + let txa = wallet.create_psbt(params).unwrap().0.unsigned_tx; + insert_tx(&mut wallet, txa.clone()); + + // Create tx B (unconfirmed) + let mut params = psbt::PsbtParams::default(); + params + .add_utxos(&[op1]) + .add_recipients([(recip.clone(), Amount::from_sat(42_000))]); + let txb = wallet.create_psbt(params).unwrap().0.unsigned_tx; + insert_tx(&mut wallet, txb.clone()); + + // Now create RBF tx + let psbt = wallet + .replace_by_fee_and_recipients( + &[Arc::new(txa), Arc::new(txb)], + FeeRate::from_sat_per_vb_unchecked(4), + vec![(recip, Amount::from_btc(1.99).unwrap())], + ) + .unwrap() + .0; + + // Expect re-select 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] #[should_panic(expected = "InputIndexOutOfRange")] fn test_psbt_malformed_psbt_input_legacy() { diff --git a/tests/wallet.rs b/tests/wallet.rs index c779c0a4..9f4d3e2c 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -6,7 +6,7 @@ use bdk_chain::{BlockId, CanonicalizationParams, ConfirmationBlockTime}; use bdk_wallet::coin_selection; use bdk_wallet::descriptor::{calc_checksum, DescriptorError}; use bdk_wallet::error::CreateTxError; -use bdk_wallet::psbt::PsbtUtils; +use bdk_wallet::psbt::{self, PsbtUtils}; use bdk_wallet::signer::{SignOptions, SignerError}; use bdk_wallet::test_utils::*; use bdk_wallet::KeychainKind; @@ -25,6 +25,86 @@ use rand::SeedableRng; mod common; +// Test we can select and spend an indexed but not-yet-canonical utxo +#[test] +fn test_spend_non_canonical_txout() -> anyhow::Result<()> { + 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 recip = ScriptBuf::from_hex("0014446906a6560d8ad760db3156706e72e171f3a2aa").unwrap(); + + // Receive tx0 (coinbase) + let tx = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::ONE_BTC, + script_pubkey: wallet + .reveal_next_address(KeychainKind::External) + .script_pubkey(), + }], + ..new_tx(1) + }; + let block = BlockId { + height: 100, + hash: Hash::hash(b"100"), + }; + insert_tx_anchor(&mut wallet, tx, block); + let block = BlockId { + height: 1000, + hash: Hash::hash(b"1000"), + }; + insert_checkpoint(&mut wallet, block); + + // Create tx1 + let mut params = psbt::PsbtParams::default(); + params.add_recipients([(recip.clone(), Amount::from_btc(0.01)?)]); + let psbt = wallet.create_psbt(params)?.0; + let txid = psbt.unsigned_tx.compute_txid(); + let (vout, _) = psbt + .unsigned_tx + .output + .iter() + .enumerate() + .find(|(_, txo)| wallet.is_mine(txo.script_pubkey.clone())) + .unwrap(); + let to_select_op = OutPoint::new(txid, vout as u32); + + let txid1 = psbt.unsigned_tx.compute_txid(); + wallet.insert_tx(psbt.unsigned_tx); + + // Create tx2, spending the change of tx1 + let mut params = psbt::PsbtParams::default(); + params + .add_utxos(&[to_select_op]) + .add_recipients([(recip, Amount::from_btc(0.01)?)]); + + let psbt = wallet.create_psbt(params)?.0; + + assert_eq!(psbt.unsigned_tx.input.len(), 1); + assert_eq!(psbt.unsigned_tx.input[0].previous_output, to_select_op); + + let txid2 = psbt.unsigned_tx.compute_txid(); + wallet.insert_tx(psbt.unsigned_tx); + + // Query the set of unbroadcasted txs + let txs = wallet + .transactions_with_params(CanonicalizationParams { + assume_canonical: vec![txid2], + }) + .filter(|c| c.chain_position.is_unconfirmed()) + .collect::>(); + + assert_eq!(txs.len(), 2); + + assert!(txs.iter().any(|c| c.tx_node.txid == txid1)); + assert!(txs.iter().any(|c| c.tx_node.txid == txid2)); + + Ok(()) +} + #[test] fn test_error_external_and_internal_are_the_same() { // identical descriptors should fail to create wallet From c1622379dd696c1d144cd60621ba2427af11b331 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 11 Sep 2025 11:19:23 -0400 Subject: [PATCH 02/22] docs: Improve documentation --- src/psbt/params.rs | 16 +++++++++++----- src/test_utils.rs | 2 +- src/wallet/mod.rs | 25 ++++++++++++++++--------- tests/psbt.rs | 4 +++- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 7746fb72..91df1bf7 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -58,6 +58,10 @@ impl Default for PsbtParams { impl PsbtParams { /// Add UTXOs by outpoint to fund the transaction. + /// + /// A single outpoint may appear at most once in the list of UTXOs to spend. The caller is + /// responsible for ensuring that elements of `outpoints` correspond to outputs of previous + /// transactions and are currently unspent. pub fn add_utxos(&mut self, outpoints: &[OutPoint]) -> &mut Self { self.utxos.extend(outpoints); self @@ -145,6 +149,8 @@ impl PsbtParams { } } +// TODO: Bring back `TxOrdering` + /// `ReplaceParams` provides a thin wrapper around [`PsbtParams`] and is intended for /// crafting Replace-By-Fee transactions (RBF). #[derive(Debug, Default)] @@ -165,8 +171,8 @@ impl ReplaceParams { .replace(txs) } - /// Replace spends of the provided `txs`. This will internally set the inner - /// params UTXOs to be spent. + /// Replace spends of the provided `txs`. This will internally set the internal list + /// of UTXOs to be spent. pub fn replace(self, txs: &[Arc]) -> Self { let txs: Vec> = txs.to_vec(); let mut txids: HashSet = txs.iter().map(|tx| tx.compute_txid()).collect(); @@ -277,9 +283,9 @@ mod test { #[test] fn test_sanitize_rbf_set() { - // To replace: { [A, B], [C] } (where B spends from A) - // We can't replace the inputs of B, since we're already replacing A - // therefore the inputs should only include the spends of [A, C]. + // To replace the set { [A, B], [C] }, where B is a descendant of A: + // We shouldn't try to replace the inputs of B, because replacing A will render A's outputs + // unspendable. Therefore the RBF inputs should only contain the inputs of A and C. // A is an ancestor let tx_a = Transaction { diff --git a/src/test_utils.rs b/src/test_utils.rs index 5c848667..14af2142 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -317,7 +317,7 @@ pub fn insert_checkpoint(wallet: &mut Wallet, block: BlockId) { .unwrap(); } -/// Inserts a transaction with anchor (no last seen). This is useful for adding +/// Inserts a transaction to be anchored by `block_id` (no last seen). This can be used to add /// a coinbase tx to the wallet for testing. /// /// This will also insert the anchor `block_id`. See [`insert_anchor`] for more. diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 7bb23418..dc45c69b 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1210,7 +1210,13 @@ impl Wallet { self.transactions_with_params(CanonicalizationParams::default()) } - /// Transactions with params. + /// Iterate over relevant and canonical transactions in this wallet. + /// + /// - `params`: [`CanonicalizationParams`], modifies the wallet's internal logic for determining + /// which transaction is canonical. This can be used to resolve conflicts, or to assert that a + /// particular transaction should be treated as canonical. + /// + /// See [`Wallet::transactions`] for more. pub fn transactions_with_params<'a>( &'a self, params: CanonicalizationParams, @@ -2487,19 +2493,20 @@ impl Wallet { Ok(()) } - /// Inserts a transaction into the inner transaction graph with no additional metadata. + /// Inserts a transaction into the inner transaction graph, scanning for relevant txouts. /// /// This is used to inform the wallet of newly created txs before they are known to exist - /// on chain (or in mempool), which is useful for discovering wallet-owned outputs of - /// not-yet-canonical transactions. + /// on chain or in mempool, which is useful for discovering wallet-owned outputs of + /// not-yet-canonical transactions. The inserted transaction carries no additional metadata, + /// like the time it was seen or the confirmation height. To later retrieve it, refer to + /// [`Wallet::transactions_with_params`]. /// /// The effect of insertion depends on the [relevance] of `tx` as determined by the indexer. /// If the transaction was newly inserted and a txout matches a derived SPK, then the index - /// is updated with the relevant outpoint. This means the output may be selected in - /// subsequent transactions (if selected manually), enabling chains of dependent spends to - /// occur prior to broadcast time. If none of the outputs are relevant, the transaction is - /// kept but the index remains unchanged. If the transaction was already present in-graph, - /// the effect is a no-op. + /// is updated with the relevant outpoint(s). If no outputs are relevant, the transaction is + /// kept but the index remains unchanged. There should be no change to the wallet balance until + /// the transaction is accepted by the network and the wallet is synced. If `tx` was already + /// present in-graph, then the effect is a no-op. /// /// **You must persist the change set staged as a result of this call.** /// diff --git a/tests/psbt.rs b/tests/psbt.rs index 6568d323..a2684ea4 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -136,6 +136,8 @@ fn test_create_psbt_cltv() { } } +// 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() { use KeychainKind::*; @@ -219,7 +221,7 @@ fn test_replace_by_fee() { .unwrap() .0; - // Expect re-select inputs of A, B + // Expect replace inputs of A, B assert_eq!( psbt.unsigned_tx.input.len(), 2, From 735a5fb7342f0e4a083cd37bceeba7920bf7b8ce Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 30 Sep 2025 23:25:10 -0400 Subject: [PATCH 03/22] feat(psbt_params): Add `filter_utxos` `UtxoFilter` is a user-defined Fn closure which takes a `&FullTxOut` and decides whether to exclude it from coin selection. This can be used, for example, to mark an output unspendable or apply custom UTXO filtering logic. --- src/psbt/params.rs | 62 +++++++++++++++++++++++++++++++++++----------- src/wallet/mod.rs | 14 +++++++++-- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 91df1bf7..e0095f0c 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -2,8 +2,9 @@ use alloc::sync::Arc; use alloc::vec::Vec; +use core::fmt; -use bdk_chain::{BlockId, CanonicalizationParams, TxGraph}; +use bdk_chain::{BlockId, CanonicalizationParams, ConfirmationBlockTime, FullTxOut, TxGraph}; use bdk_tx::DefiniteDescriptor; use bitcoin::{ absolute, transaction::Version, Amount, FeeRate, OutPoint, ScriptBuf, Sequence, Transaction, @@ -30,6 +31,7 @@ pub struct PsbtParams { pub(crate) drain_wallet: bool, pub(crate) coin_selection: SelectionStrategy, pub(crate) canonical_params: CanonicalizationParams, + pub(crate) utxo_filter: Option, // PSBT pub(crate) version: Option, @@ -49,6 +51,7 @@ impl Default for PsbtParams { drain_wallet: Default::default(), coin_selection: Default::default(), canonical_params: Default::default(), + utxo_filter: None, version: Default::default(), locktime: Default::default(), fallback_sequence: Default::default(), @@ -147,10 +150,54 @@ impl PsbtParams { pub fn replace(self, txs: &[Arc]) -> ReplaceParams { ReplaceParams::new(txs, self) } + + /// Filter [`FullTxOut`]s by the provided closure. + /// + /// This option can be used to mark specific outputs unspendable or apply any sort of custom + /// UTXO filter. + /// + /// Note that returning `true` from the `exclude` function will exclude the output from coin + /// selection, otherwise any coin in the wallet that is mature and spendable will be + /// eligible for selection. + pub fn filter_utxos(&mut self, exclude: F) -> &mut Self + where + F: Fn(&FullTxOut) -> bool + Send + Sync + 'static, + { + self.utxo_filter = Some(UtxoFilter(Arc::new(exclude))); + self + } } // TODO: Bring back `TxOrdering` +/// 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`](bdk_coin_select::metrics::LowestFee) metric for more. + LowestFee, +} + +/// [`UtxoFilter`] is a user-defined `Fn` closure which decides whether to exclude a UTXO +/// from being selected. +// TODO: Consider having this also take a `&Wallet` in case the caller needs information +// not given by the FullTxOut. +#[allow(clippy::type_complexity)] +pub(crate) struct UtxoFilter( + pub Arc) -> bool + Send + Sync>, +); + +impl fmt::Debug for UtxoFilter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "UtxoFilter") + } +} + /// `ReplaceParams` provides a thin wrapper around [`PsbtParams`] and is intended for /// crafting Replace-By-Fee transactions (RBF). #[derive(Debug, Default)] @@ -236,19 +283,6 @@ impl ReplaceParams { } } -/// 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`](bdk_coin_select::metrics::LowestFee) metric for more. - LowestFee, -} - /// Trait to extend the functionality of [`Assets`]. pub(crate) trait AssetsExt { /// Extend `self` with the contents of `other`. diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index dc45c69b..c6fc7cfc 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2876,7 +2876,12 @@ impl Wallet { // Get input candidates let mut may_spend: Vec = self - .filter_spendable(txouts.into_values(), ¶ms, |_| false) + .filter_spendable(txouts.into_values(), ¶ms, |txo| { + params + .utxo_filter + .as_ref() + .is_some_and(|filter| (filter.0)(txo)) + }) .flat_map(|txo| self.plan_input(&txo, &assets)) .collect(); @@ -3063,7 +3068,12 @@ impl Wallet { .filter_spendable(txouts.into_values(), ¶ms, |txo| { // Exlude outputs of txs to be replaced. Also exclude unconfirmed outputs // per replacement policy Rule 2. - to_replace.contains(&txo.outpoint.txid) || txo.chain_position.is_unconfirmed() + to_replace.contains(&txo.outpoint.txid) + || txo.chain_position.is_unconfirmed() + || params + .utxo_filter + .as_ref() + .is_some_and(|filter| (filter.0)(txo)) }) .flat_map(|txo| self.plan_input(&txo, &assets)) .collect(); From a6153b3c762b1789030291c42a8b8887c8d94289 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Wed, 1 Oct 2025 13:32:28 -0400 Subject: [PATCH 04/22] refactor(tx_builder): Generalize `TxOrdering` inputs and outputs ..by exposing the generic from `TxSort` function. We use bitcoin `TxIn` and `TxOut` as the default type parameter for backward compatibility. Add `sort_with_aux_rand` for `TxOrdering` for sorting two generic mutable slices. --- src/wallet/tx_builder.rs | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/wallet/tx_builder.rs b/src/wallet/tx_builder.rs index f3bfe3f9..4808d4dd 100644 --- a/src/wallet/tx_builder.rs +++ b/src/wallet/tx_builder.rs @@ -844,7 +844,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, @@ -859,13 +859,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"), @@ -905,6 +905,27 @@ impl TxOrdering { } } +impl TxOrdering { + /// Sort the provided `input` and `output` slices by this [`TxOrdering`] and auxiliary + /// randomness. + pub fn sort_with_aux_rand(&self, input: &mut [I], output: &mut [O], rng: &mut impl RngCore) { + match self { + TxOrdering::Untouched => {} + TxOrdering::Shuffle => { + shuffle_slice(input, rng); + shuffle_slice(output, rng); + } + TxOrdering::Custom { + input_sort, + output_sort, + } => { + input.sort_unstable_by(|a, b| input_sort(a, b)); + output.sort_unstable_by(|a, b| output_sort(a, b)); + } + } + } +} + /// Policy regarding the use of change outputs when creating a transaction #[derive(Default, Debug, Ord, PartialOrd, Eq, PartialEq, Hash, Clone, Copy)] pub enum ChangeSpendPolicy { From c9e5597309e4d3b3e76257bf84e7203b1ab6f20e Mon Sep 17 00:00:00 2001 From: valued mammal Date: Wed, 1 Oct 2025 13:48:04 -0400 Subject: [PATCH 05/22] feat(psbt_params): Add method `ordering` for PsbtParams --- src/psbt/params.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index e0095f0c..3fd44503 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -5,7 +5,7 @@ use alloc::vec::Vec; use core::fmt; use bdk_chain::{BlockId, CanonicalizationParams, ConfirmationBlockTime, FullTxOut, TxGraph}; -use bdk_tx::DefiniteDescriptor; +use bdk_tx::{DefiniteDescriptor, Input, Output}; use bitcoin::{ absolute, transaction::Version, Amount, FeeRate, OutPoint, ScriptBuf, Sequence, Transaction, Txid, @@ -13,6 +13,7 @@ use bitcoin::{ use miniscript::plan::Assets; use crate::collections::HashSet; +use crate::TxOrdering; /// Parameters to create a PSBT. #[derive(Debug)] @@ -37,6 +38,7 @@ pub struct PsbtParams { pub(crate) version: Option, pub(crate) locktime: Option, pub(crate) fallback_sequence: Option, + pub(crate) ordering: TxOrdering, } impl Default for PsbtParams { @@ -55,6 +57,7 @@ impl Default for PsbtParams { version: Default::default(), locktime: Default::default(), fallback_sequence: Default::default(), + ordering: Default::default(), } } } @@ -166,9 +169,23 @@ impl PsbtParams { self.utxo_filter = Some(UtxoFilter(Arc::new(exclude))); self } -} -// TODO: Bring back `TxOrdering` + /// Set the [`TxOrdering`] for inputs and outputs of the PSBT. + /// + /// If not set here, the default ordering is to [`Shuffle`] all inputs and outputs. + /// + /// Set to [`Untouched`] to preserve the order of UTXOs and recipients in the manner in which + /// they are added to the params (FIXME). If additional inputs are required that aren't manually + /// selected, their order will be determined by the [`SelectionStrategy`]. Refer to + /// [`TxOrdering`] for more. + /// + /// [`Shuffle`]: TxOrdering::Shuffle + /// [`Untouched`]: TxOrdering::Untouched + pub fn ordering(&mut self, ordering: TxOrdering) -> &mut Self { + self.ordering = ordering; + self + } +} /// Coin select strategy. #[derive(Debug, Clone, Copy, Default)] From 314364031ef8fe6cdd3e33eb03e375c91d38d12d Mon Sep 17 00:00:00 2001 From: valued mammal Date: Wed, 1 Oct 2025 13:48:21 -0400 Subject: [PATCH 06/22] wallet: Update `create_psbt_from_selector` to call the TxOrdering ..function on the Selection inputs and outputs. --- src/wallet/mod.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index c6fc7cfc..c15d290c 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2910,7 +2910,7 @@ impl Wallet { ) .map_err(CreatePsbtError::Selector)?; - self.create_psbt_from_selector(&mut selector, ¶ms) + self.create_psbt_from_selector(&mut selector, ¶ms, rng) } /// Create the PSBT from [`Selector`] and `params`. @@ -2921,6 +2921,7 @@ impl Wallet { &self, selector: &mut Selector, params: &PsbtParams, + rng: &mut impl RngCore, ) -> Result<(Psbt, Finalizer), CreatePsbtError> { // How many times to run bnb before giving up const BNB_MAX_ROUNDS: usize = 10_000; @@ -2947,11 +2948,14 @@ impl Wallet { } }; } - let selection = selector.try_finalize().ok_or({ + let mut selection = selector.try_finalize().ok_or({ let e = bdk_tx::CannotMeetTarget; CreatePsbtError::Selector(bdk_tx::SelectorError::CannotMeetTarget(e)) })?; + let tx_ordering = ¶ms.ordering; + tx_ordering.sort_with_aux_rand(&mut selection.inputs, &mut selection.outputs, rng); + let version = params.version.unwrap_or(transaction::Version::TWO); let fallback_locktime = params .locktime @@ -3126,7 +3130,7 @@ impl Wallet { ) .map_err(CreatePsbtError::Selector)?; - self.create_psbt_from_selector(&mut selector, ¶ms) + self.create_psbt_from_selector(&mut selector, ¶ms, rng) .map_err(ReplaceByFeeError::CreatePsbt) } From 88582b181b7a9631756b5fdb8549b00c4db4a420 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 7 Oct 2025 19:38:03 -0400 Subject: [PATCH 07/22] Fix imports Also re-export types in `psbt` module --- src/lib.rs | 1 + src/wallet/mod.rs | 24 ++++++++++-------------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index d54eac92..75e758ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,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/wallet/mod.rs b/src/wallet/mod.rs index c15d290c..23da7bbc 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -31,7 +31,11 @@ use bdk_chain::{ }, tx_graph::{CalculateFeeError, CanonicalTx, TxGraph, TxUpdate}, Anchor, BlockId, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, - FullTxOut, Indexed, IndexedTxGraph, Indexer, Merge, + FullTxOut, Indexed, IndexedTxGraph, Indexer, KeychainIndexed, Merge, +}; +use bdk_tx::{ + selection_algorithm_lowest_fee_bnb, ChangePolicyType, DefiniteDescriptor, Finalizer, Input, + InputCandidates, OriginalTxStats, Output, RbfParams, Selector, SelectorParams, TxStatus, }; #[cfg(feature = "std")] use bitcoin::secp256k1::rand; @@ -47,6 +51,7 @@ use bitcoin::{ }; use miniscript::{ descriptor::KeyMap, + plan::{Assets, Plan}, psbt::{PsbtExt, PsbtInputExt, PsbtInputSatisfier}, ForEachKey, }; @@ -68,7 +73,7 @@ use crate::descriptor::{ DerivedDescriptor, DescriptorMeta, ExtendedDescriptor, ExtractPolicy, IntoWalletDescriptor, Policy, XKeyUtils, }; -use crate::psbt::PsbtUtils; +use crate::psbt::{AssetsExt, PsbtParams, PsbtUtils, ReplaceParams, SelectionStrategy}; use crate::types::*; use crate::wallet::{ coin_selection::{DefaultCoinSelectionAlgorithm, Excess, InsufficientFunds}, @@ -87,6 +92,9 @@ pub use params::*; pub use persisted::*; pub use utils::{IsDust, TxDetails}; +/// Alias [`FullTxOut`] indexed by keychain, index. +type IndexedTxOut = KeychainIndexed>; + /// A Bitcoin wallet /// /// The `Wallet` acts as a way of coherently interfacing with output descriptors and related @@ -2688,18 +2696,6 @@ impl Wallet { } } -use bdk_chain::KeychainIndexed; -use bdk_tx::{ - selection_algorithm_lowest_fee_bnb, ChangePolicyType, DefiniteDescriptor, Finalizer, Input, - InputCandidates, OriginalTxStats, Output, RbfParams, Selector, SelectorParams, TxStatus, -}; -use miniscript::plan::{Assets, Plan}; - -use crate::psbt::{AssetsExt, PsbtParams, ReplaceParams, SelectionStrategy}; - -/// Type -type IndexedTxOut = KeychainIndexed>; - /// Maps a chain position to tx confirmation status, if `pos` is the confirmed /// variant. /// From 353a49c805c76615da9ca8dc59ede69764d595af Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 9 Oct 2025 14:20:01 -0400 Subject: [PATCH 08/22] fix(wallet): Use `Output::with_descriptor` for self sending outputs --- src/wallet/mod.rs | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 23da7bbc..5a6cc3c0 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2817,6 +2817,27 @@ impl Wallet { }) } + /// Maps the recipients of the `params` to a collection of target [`Output`]s. + fn target_outputs(&self, params: &PsbtParams) -> Vec { + params + .recipients + .iter() + .cloned() + .map( + |(script, value)| match self.indexed_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() + } + /// Creates a PSBT with the given `params` and returns the updated [`Psbt`] and /// [`Finalizer`]. /// @@ -2885,18 +2906,11 @@ impl Wallet { let input_candidates = InputCandidates::new(must_spend, may_spend); - let target_outputs = params - .recipients - .iter() - .cloned() - .map(Output::from) - .collect(); - let mut selector = Selector::new( &input_candidates, SelectorParams { target_feerate: params.feerate, - target_outputs, + target_outputs: self.target_outputs(¶ms), change_descriptor: change_script, change_policy: ChangePolicyType::NoDustAndLeastWaste { longterm_feerate: params.longterm_feerate, @@ -3105,18 +3119,11 @@ impl Wallet { incremental_relay_feerate: FeeRate::BROADCAST_MIN, }; - let target_outputs = params - .recipients - .iter() - .cloned() - .map(Output::from) - .collect(); - let mut selector = Selector::new( &input_candidates, SelectorParams { target_feerate: params.feerate, - target_outputs, + target_outputs: self.target_outputs(¶ms), change_descriptor: change_script, change_policy: ChangePolicyType::NoDustAndLeastWaste { longterm_feerate: params.longterm_feerate, From 84712476b2cdfef7d03cf8036fae84d2d37c2a98 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 9 Oct 2025 14:57:43 -0400 Subject: [PATCH 09/22] wallet: Add missing `replace_by_fee` method which calls the thread-rng --- src/wallet/mod.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 5a6cc3c0..a9af1a29 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3038,6 +3038,20 @@ impl Wallet { ) } + /// Creates a Replace-By-Fee transaction (RBF) and returns the updated [`Psbt`] and + /// [`Finalizer`]. + /// + /// ### Parameters: + /// + /// - `params`: [`ReplaceParams`] + #[cfg(feature = "std")] + pub fn replace_by_fee( + &self, + params: ReplaceParams, + ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { + self.replace_by_fee_with_aux_rand(params, &mut rand::thread_rng()) + } + /// Creates a Replace-By-Fee transaction (RBF) and returns the updated [`Psbt`] and /// [`Finalizer`]. /// From 7925bc966e881bfbc977678aa91bc1d43ad0e0a4 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Wed, 1 Oct 2025 14:56:40 -0400 Subject: [PATCH 10/22] psbt: Add method `PsbtParams::add_planned_input` This can be used to add inputs that come with a plan or PSBT input provided. --- src/psbt/params.rs | 82 +++++++++++++++++++++++++++------------------- src/wallet/mod.rs | 2 ++ 2 files changed, 51 insertions(+), 33 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 3fd44503..54394e24 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -19,7 +19,9 @@ use crate::TxOrdering; #[derive(Debug)] pub struct PsbtParams { // Inputs - pub(crate) utxos: HashSet, + pub(crate) set: HashSet, + pub(crate) utxos: Vec, + pub(crate) inputs: Vec, // Outputs pub(crate) recipients: Vec<(ScriptBuf, Amount)>, @@ -44,7 +46,9 @@ pub struct PsbtParams { impl Default for PsbtParams { fn default() -> Self { Self { + set: Default::default(), utxos: Default::default(), + inputs: Default::default(), assets: Default::default(), recipients: Default::default(), change_descriptor: Default::default(), @@ -69,18 +73,21 @@ impl PsbtParams { /// responsible for ensuring that elements of `outpoints` correspond to outputs of previous /// transactions and are currently unspent. pub fn add_utxos(&mut self, outpoints: &[OutPoint]) -> &mut Self { - self.utxos.extend(outpoints); + self.utxos + .extend(outpoints.iter().copied().filter(|&op| self.set.insert(op))); self } /// Get the currently selected spends. pub fn utxos(&self) -> &HashSet { - &self.utxos + &self.set } /// Remove a UTXO from the currently selected inputs. - pub fn remove_utxo(&mut self, outpoint: OutPoint) -> &mut Self { - self.utxos.remove(&outpoint); + pub fn remove_utxo(&mut self, outpoint: &OutPoint) -> &mut Self { + if self.set.remove(outpoint) { + self.utxos.retain(|op| op != outpoint); + } self } @@ -175,7 +182,7 @@ impl PsbtParams { /// If not set here, the default ordering is to [`Shuffle`] all inputs and outputs. /// /// Set to [`Untouched`] to preserve the order of UTXOs and recipients in the manner in which - /// they are added to the params (FIXME). If additional inputs are required that aren't manually + /// they are added to the params. If additional inputs are required that aren't manually /// selected, their order will be determined by the [`SelectionStrategy`]. Refer to /// [`TxOrdering`] for more. /// @@ -185,6 +192,19 @@ impl PsbtParams { self.ordering = ordering; self } + + /// Add a planned input. + /// + /// This can be used to add inputs that come with a [`Plan`] or [`psbt::Input`] provided. + /// + /// [`Plan`]: miniscript::plan::Plan + /// [`psbt::Input`]: bitcoin::psbt::Input + pub fn add_planned_input(&mut self, input: Input) -> &mut Self { + if self.set.insert(input.prev_outpoint()) { + self.inputs.push(input); + } + self + } } /// Coin select strategy. @@ -226,26 +246,27 @@ pub struct ReplaceParams { } impl ReplaceParams { - /// Construct from PSBT `params` and an iterator of `txs` to replace. - pub(crate) fn new(txs: &[Arc], params: PsbtParams) -> Self { - Self { - inner: params, + /// Construct from `inner` params and the `txs` to replace. + pub(crate) fn new(txs: &[Arc], inner: PsbtParams) -> Self { + let mut params = Self { + inner, ..Default::default() - } - .replace(txs) + }; + params.replace(txs); + params } /// Replace spends of the provided `txs`. This will internally set the internal list /// of UTXOs to be spent. - pub fn replace(self, txs: &[Arc]) -> Self { - let txs: Vec> = txs.to_vec(); - let mut txids: HashSet = txs.iter().map(|tx| tx.compute_txid()).collect(); - let mut tx_graph = TxGraph::::default(); - let mut utxos: HashSet = HashSet::new(); - - for tx in txs { - let _ = tx_graph.insert_tx(tx); - } + pub fn replace(&mut self, txs: &[Arc]) { + self.inner.utxos.clear(); + let mut utxos = vec![]; + + let (mut txids_to_replace, txs): (HashSet, Vec) = txs + .iter() + .map(|tx| (tx.compute_txid(), tx.as_ref().clone())) + .unzip(); + let tx_graph = TxGraph::::new(txs); // Sanitize the RBF set by removing elements of `txs` which have ancestors // in the same set. This is to avoid spending outputs of txs that are bound @@ -255,21 +276,16 @@ impl ReplaceParams { if tx.is_coinbase() || tx_graph .walk_ancestors(Arc::clone(tx), |_, tx| Some(tx.compute_txid())) - .any(|ancestor_txid| txids.contains(&ancestor_txid)) + .any(|ancestor_txid| txids_to_replace.contains(&ancestor_txid)) { - txids.remove(&tx_node.txid); + txids_to_replace.remove(&tx_node.txid); } else { utxos.extend(tx.input.iter().map(|txin| txin.previous_output)); } } - Self { - inner: PsbtParams { - utxos, - ..self.inner - }, - replace: txids, - } + self.replace = txids_to_replace; + self.inner.add_utxos(&utxos); } /// Add recipients. @@ -290,11 +306,11 @@ impl ReplaceParams { /// Get the currently selected spends. pub fn utxos(&self) -> &HashSet { - self.inner.utxos() + &self.inner.set } /// Remove a UTXO from the currently selected inputs. - pub fn remove_utxo(&mut self, outpoint: OutPoint) -> &mut Self { + pub fn remove_utxo(&mut self, outpoint: &OutPoint) -> &mut Self { self.inner.remove_utxo(outpoint); self } @@ -380,7 +396,7 @@ mod test { let txs: Vec> = [tx_a, tx_b, tx_c, tx_d].into_iter().map(Arc::new).collect(); let params = ReplaceParams::new(&txs, PsbtParams::default()); - assert_eq!(params.utxos(), &expect_spends); + assert_eq!(params.inner.set, expect_spends); assert_eq!(params.replace, [txid_a, txid_c].into()); } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index a9af1a29..183445e3 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2889,6 +2889,7 @@ impl Wallet { self.plan_input(txo, &assets) .ok_or(CreatePsbtError::Plan(op)) }) + .chain(params.inputs.iter().cloned().map(Result::Ok)) .collect::>()?; // Get input candidates @@ -3089,6 +3090,7 @@ impl Wallet { self.plan_input(txo, &assets) .ok_or(CreatePsbtError::Plan(op)) }) + .chain(params.inputs.iter().cloned().map(Result::Ok)) .collect::>()?; // Get input candidates From 187d5b2bfb46ed3b851ae817969a59c36fc4b716 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Wed, 1 Oct 2025 14:59:22 -0400 Subject: [PATCH 11/22] test(psbt): Add `test_selected_outpoints_are_unique` Check that adding duplicate outpoints only results in a single outpoint added, and that it is contained within the inner set. --- src/psbt/params.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 54394e24..a4b95537 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -399,4 +399,32 @@ mod test { assert_eq!(params.inner.set, expect_spends); assert_eq!(params.replace, [txid_a, txid_c].into()); } + + #[test] + fn test_selected_outpoints_are_unique() { + let mut params = PsbtParams::default(); + let op = OutPoint::null(); + + // Try adding the same outpoint repeatedly. + for _ in 0..3 { + params.add_utxos(&[op]); + } + assert_eq!( + params.utxos(), + &[op].into(), + "Failed to filter duplicate outpoints" + ); + assert!(params.utxos.contains(&op)); + + params = PsbtParams::default(); + + // Try adding duplicates in the same set. + params.add_utxos(&[op, op, op]); + assert_eq!( + params.utxos(), + &[op].into(), + "Failed to filter duplicate outpoints" + ); + assert!(params.utxos.contains(&op)); + } } From 003e2f5ef13925ad696ffa2fe59fbc100b2b1dde Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 9 Oct 2025 15:16:44 -0400 Subject: [PATCH 12/22] test: Add `test_add_planned_psbt_input` --- tests/add_foreign_utxo.rs | 54 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/tests/add_foreign_utxo.rs b/tests/add_foreign_utxo.rs index 1dd0a8c9..23b3c1a4 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; @@ -290,3 +290,55 @@ fn test_taproot_foreign_utxo() { "foreign_utxo should be in there" ); } + +#[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 use `PsbtParams` to sweep a foreign anchor output. + 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, + )?; + + let send_to = wallet.reveal_next_address(KeychainKind::External).address; + + // Build tx: 2-in / 2-out + let mut params = bdk_wallet::PsbtParams::default(); + params.add_utxos(&[op1]); + params.add_planned_input(input); + params.add_recipients([(send_to, Amount::from_sat(20_000))]); + + let (psbt, _) = wallet.create_psbt(params)?; + + 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(()) +} From 8dc6229c2736a2a3857ffd2ac5c6e1e31823587f Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 9 Oct 2025 15:45:28 -0400 Subject: [PATCH 13/22] `UtxoFilter` has a Default implementation that applies no filtering --- src/psbt/params.rs | 12 +++++++++--- src/wallet/mod.rs | 10 ++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index a4b95537..025b8104 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -34,7 +34,7 @@ pub struct PsbtParams { pub(crate) drain_wallet: bool, pub(crate) coin_selection: SelectionStrategy, pub(crate) canonical_params: CanonicalizationParams, - pub(crate) utxo_filter: Option, + pub(crate) utxo_filter: UtxoFilter, // PSBT pub(crate) version: Option, @@ -57,7 +57,7 @@ impl Default for PsbtParams { drain_wallet: Default::default(), coin_selection: Default::default(), canonical_params: Default::default(), - utxo_filter: None, + utxo_filter: Default::default(), version: Default::default(), locktime: Default::default(), fallback_sequence: Default::default(), @@ -173,7 +173,7 @@ impl PsbtParams { where F: Fn(&FullTxOut) -> bool + Send + Sync + 'static, { - self.utxo_filter = Some(UtxoFilter(Arc::new(exclude))); + self.utxo_filter = UtxoFilter(Arc::new(exclude)); self } @@ -229,6 +229,12 @@ pub(crate) struct UtxoFilter( pub Arc) -> bool + Send + Sync>, ); +impl Default for UtxoFilter { + fn default() -> Self { + Self(Arc::new(|_| false)) + } +} + impl fmt::Debug for UtxoFilter { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "UtxoFilter") diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 183445e3..928aec58 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2895,10 +2895,7 @@ impl Wallet { // Get input candidates let mut may_spend: Vec = self .filter_spendable(txouts.into_values(), ¶ms, |txo| { - params - .utxo_filter - .as_ref() - .is_some_and(|filter| (filter.0)(txo)) + (params.utxo_filter.0)(txo) }) .flat_map(|txo| self.plan_input(&txo, &assets)) .collect(); @@ -3100,10 +3097,7 @@ impl Wallet { // per replacement policy Rule 2. to_replace.contains(&txo.outpoint.txid) || txo.chain_position.is_unconfirmed() - || params - .utxo_filter - .as_ref() - .is_some_and(|filter| (filter.0)(txo)) + || (params.utxo_filter.0)(txo) }) .flat_map(|txo| self.plan_input(&txo, &assets)) .collect(); From 9f0e25611a02772975749069380a1bd5ade2beca Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 21 Oct 2025 15:14:16 -0500 Subject: [PATCH 14/22] docs: Add docsrs cfg attribute for methods gated by "std" --- src/wallet/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 928aec58..327a90ff 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2862,6 +2862,7 @@ impl Wallet { /// # Ok::<_, anyhow::Error>(()) /// ``` #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] pub fn create_psbt(&self, params: PsbtParams) -> Result<(Psbt, Finalizer), CreatePsbtError> { self.create_psbt_with_aux_rand(params, &mut rand::thread_rng()) } @@ -3017,6 +3018,7 @@ impl Wallet { /// /// [`replace_by_fee_with_aux_rand`]: Wallet::replace_by_fee_with_aux_rand #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] pub fn replace_by_fee_and_recipients( &self, txs: &[Arc], From 5d99f6035c4b4ea90ada0225da885c31dce689be Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 21 Oct 2025 20:22:36 -0500 Subject: [PATCH 15/22] psbt: Add `PsbtParams::maturity_height` --- src/psbt/params.rs | 9 +++++++++ src/wallet/mod.rs | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 025b8104..5ff2bbf9 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -35,6 +35,7 @@ pub struct PsbtParams { pub(crate) coin_selection: SelectionStrategy, pub(crate) canonical_params: CanonicalizationParams, pub(crate) utxo_filter: UtxoFilter, + pub(crate) maturity_height: Option, // PSBT pub(crate) version: Option, @@ -58,6 +59,7 @@ impl Default for PsbtParams { coin_selection: Default::default(), canonical_params: Default::default(), utxo_filter: Default::default(), + maturity_height: Default::default(), version: Default::default(), locktime: Default::default(), fallback_sequence: Default::default(), @@ -127,6 +129,13 @@ impl PsbtParams { self } + /// Set the height to be used when evaluating the maturity of coinbase outputs during coin + /// selection. + pub fn maturity_height(&mut self, height: absolute::Height) -> &mut Self { + self.maturity_height = Some(height.to_consensus_u32()); + self + } + /// Set the target fee rate. pub fn feerate(&mut self, feerate: FeeRate) -> &mut Self { self.feerate = feerate; diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 327a90ff..77456e2e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2796,7 +2796,7 @@ impl Wallet { I: IntoIterator> + 'a, F: Fn(&FullTxOut) -> bool + 'a, { - let current_height = self.latest_checkpoint().height(); + let current_height = params.maturity_height.unwrap_or(self.chain.tip().height()); txos.into_iter().filter(move |txo| { // Exclude outputs that are manually selected. if params.utxos.contains(&txo.outpoint) { From f257aba26d5b080e45882aa2bd1a7686eb1f90ed Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 21 Oct 2025 20:23:28 -0500 Subject: [PATCH 16/22] psbt: Add `PsbtParams::locktime` --- src/psbt/params.rs | 10 +++++++++ src/wallet/mod.rs | 4 +++- tests/psbt.rs | 52 ++++++++++++++++++++++++++-------------------- 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 5ff2bbf9..a77bc1c6 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -129,6 +129,16 @@ impl PsbtParams { self } + /// Set the transaction `nLockTime`. + /// + /// This can be used as a fallback in case none of the inputs to the transaction require an + /// absolute locktime. If no locktime is required and nothing is specified here, then the + /// locktime is set to the last known chain tip. + pub fn locktime(&mut self, locktime: absolute::LockTime) -> &mut Self { + self.locktime = Some(locktime); + self + } + /// Set the height to be used when evaluating the maturity of coinbase outputs during coin /// selection. pub fn maturity_height(&mut self, height: absolute::Height) -> &mut Self { diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 77456e2e..7314234d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2968,7 +2968,9 @@ impl Wallet { let version = params.version.unwrap_or(transaction::Version::TWO); let fallback_locktime = params .locktime - .unwrap_or_else(|| absolute::LockTime::from_consensus(self.chain.tip().height())); + .unwrap_or(absolute::LockTime::from_consensus( + self.chain.tip().height(), + )); let fallback_sequence = params .fallback_sequence .unwrap_or(Sequence::ENABLE_LOCKTIME_NO_RBF); diff --git a/tests/psbt.rs b/tests/psbt.rs index a2684ea4..d028164f 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -1,13 +1,13 @@ -use bdk_chain::BlockId; -use bdk_chain::ConfirmationBlockTime; +use bdk_chain::{BlockId, ConfirmationBlockTime}; use bdk_wallet::bitcoin; -use bdk_wallet::bitcoin::{ - hashes::Hash, secp256k1, Address, Amount, FeeRate, Network, OutPoint, Psbt, ScriptBuf, - Transaction, TxIn, TxOut, -}; use bdk_wallet::test_utils::*; -use bdk_wallet::{psbt, KeychainKind, SignOptions, Wallet}; +use bdk_wallet::{error::CreatePsbtError, psbt, KeychainKind, PsbtParams, SignOptions, Wallet}; +use bitcoin::{ + absolute, hashes::Hash, secp256k1, Address, Amount, FeeRate, Network, OutPoint, Psbt, + ScriptBuf, Transaction, TxIn, TxOut, +}; use core::str::FromStr; +use miniscript::plan::Assets; use std::sync::Arc; // from bip 174 @@ -40,7 +40,7 @@ fn test_create_psbt() { .unwrap(); let addr = wallet.reveal_next_address(KeychainKind::External); - let mut params = psbt::PsbtParams::default(); + let mut params = PsbtParams::default(); params .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]) .change_descriptor(change_desc) @@ -70,9 +70,7 @@ fn test_create_psbt() { #[test] fn test_create_psbt_cltv() { - use bdk_wallet::error::CreatePsbtError; - use bitcoin::absolute::LockTime; - use miniscript::plan::Assets; + use absolute::LockTime; let desc = get_test_single_sig_cltv(); let mut wallet = Wallet::create_single(desc) @@ -95,7 +93,7 @@ fn test_create_psbt_cltv() { // No assets fail { - let mut params = psbt::PsbtParams::default(); + let mut params = PsbtParams::default(); params .add_utxos(&[op]) .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); @@ -108,14 +106,13 @@ fn test_create_psbt_cltv() { // Add assets ok { - let mut params = psbt::PsbtParams::default(); + let mut params = PsbtParams::default(); params .add_utxos(&[op]) .add_assets(Assets::new().after(LockTime::from_consensus(100_000))) .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); - let _ = wallet - .create_psbt(params) - .expect("Create psbt should succeed"); + let (psbt, _) = wallet.create_psbt(params).unwrap(); + assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), 100_000); } // New chain tip (no assets) ok @@ -126,13 +123,24 @@ fn test_create_psbt_cltv() { }; insert_checkpoint(&mut wallet, block_id); - let mut params = psbt::PsbtParams::default(); + let mut params = PsbtParams::default(); params .add_utxos(&[op]) .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); - let _ = wallet - .create_psbt(params) - .expect("Create psbt should succeed"); + let (psbt, _) = wallet.create_psbt(params).unwrap(); + assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), 100_000); + } + + // FIXME: Locktime greater than required + { + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .locktime(LockTime::from_consensus(200_000)) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + + // let (psbt, _) = wallet.create_psbt(params).unwrap(); + // assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), 200_000); } } @@ -196,7 +204,7 @@ fn test_replace_by_fee() { let recip = ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") .unwrap(); - let mut params = psbt::PsbtParams::default(); + let mut params = PsbtParams::default(); params .add_utxos(&[op0]) .add_recipients([(recip.clone(), Amount::from_sat(16_000))]); @@ -204,7 +212,7 @@ fn test_replace_by_fee() { insert_tx(&mut wallet, txa.clone()); // Create tx B (unconfirmed) - let mut params = psbt::PsbtParams::default(); + let mut params = PsbtParams::default(); params .add_utxos(&[op1]) .add_recipients([(recip.clone(), Amount::from_sat(42_000))]); From 5ef12746f89146db2a2dace7cc394da3ae16160e Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 21 Oct 2025 22:04:30 -0500 Subject: [PATCH 17/22] feat(psbt): Add `PsbtParams::manually_selected_only` --- src/psbt/params.rs | 15 +++++++++++++++ src/wallet/mod.rs | 18 ++++++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index a77bc1c6..085911b3 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -36,6 +36,7 @@ pub struct PsbtParams { pub(crate) canonical_params: CanonicalizationParams, pub(crate) utxo_filter: UtxoFilter, pub(crate) maturity_height: Option, + pub(crate) manually_selected_only: bool, // PSBT pub(crate) version: Option, @@ -60,6 +61,7 @@ impl Default for PsbtParams { canonical_params: Default::default(), utxo_filter: Default::default(), maturity_height: Default::default(), + manually_selected_only: Default::default(), version: Default::default(), locktime: Default::default(), fallback_sequence: Default::default(), @@ -93,6 +95,19 @@ impl PsbtParams { self } + /// Only include inputs that are selected manually using [`add_utxos`] or [`add_planned_input`]. + /// + /// Since the wallet will skip coin selection for additional candidates, the manually selected + /// inputs must be enough to fund the transaction or else an error will be thrown due to + /// insufficient funds. + /// + /// [`add_utxos`]: PsbtParams::add_utxos + /// [`add_planned_input`]: PsbtParams::add_planned_input + pub fn manually_selected_only(&mut self) -> &mut Self { + self.manually_selected_only = true; + self + } + /// Add the spend [`Assets`]. /// /// Assets are required to create a spending plan for an output controlled by the wallet's diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 7314234d..d756468f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2894,12 +2894,15 @@ impl Wallet { .collect::>()?; // Get input candidates - let mut may_spend: Vec = self - .filter_spendable(txouts.into_values(), ¶ms, |txo| { + let mut may_spend: Vec = if params.manually_selected_only { + vec![] + } else { + self.filter_spendable(txouts.into_values(), ¶ms, |txo| { (params.utxo_filter.0)(txo) }) .flat_map(|txo| self.plan_input(&txo, &assets)) - .collect(); + .collect() + }; utils::shuffle_slice(&mut may_spend, rng); @@ -3095,8 +3098,10 @@ impl Wallet { .collect::>()?; // Get input candidates - let mut may_spend: Vec = self - .filter_spendable(txouts.into_values(), ¶ms, |txo| { + let mut may_spend: Vec = if params.manually_selected_only { + vec![] + } else { + self.filter_spendable(txouts.into_values(), ¶ms, |txo| { // Exlude outputs of txs to be replaced. Also exclude unconfirmed outputs // per replacement policy Rule 2. to_replace.contains(&txo.outpoint.txid) @@ -3104,7 +3109,8 @@ impl Wallet { || (params.utxo_filter.0)(txo) }) .flat_map(|txo| self.plan_input(&txo, &assets)) - .collect(); + .collect() + }; utils::shuffle_slice(&mut may_spend, rng); From c73001c48f760731b1ea0f11556bf10a6b50dead Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 21 Oct 2025 22:26:05 -0500 Subject: [PATCH 18/22] psbt: Return InsufficientFunds if InputCandidates is empty --- src/wallet/mod.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d756468f..d28b8a13 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2906,13 +2906,22 @@ impl Wallet { utils::shuffle_slice(&mut may_spend, rng); + let target_outputs = self.target_outputs(¶ms); + let input_candidates = InputCandidates::new(must_spend, may_spend); + if input_candidates.inputs().next().is_none() { + let target_amount: Amount = target_outputs.iter().map(|output| output.value).sum(); + let err = bdk_coin_select::InsufficientFunds { + missing: target_amount.to_sat(), + }; + return Err(CreatePsbtError::InsufficientFunds(err)); + } let mut selector = Selector::new( &input_candidates, SelectorParams { target_feerate: params.feerate, - target_outputs: self.target_outputs(¶ms), + target_outputs, change_descriptor: change_script, change_policy: ChangePolicyType::NoDustAndLeastWaste { longterm_feerate: params.longterm_feerate, @@ -2985,6 +2994,8 @@ impl Wallet { fallback_locktime, fallback_sequence, mandate_full_tx_for_segwit_v0: true, + // TODO: Only witness utxo + // mandate_full_tx_for_segwit_v0: params.only_witness_utxo, }) .map_err(CreatePsbtError::Psbt)?; @@ -3114,7 +3125,16 @@ impl Wallet { utils::shuffle_slice(&mut may_spend, rng); + let target_outputs = self.target_outputs(¶ms); + let input_candidates = InputCandidates::new(must_spend, may_spend); + if input_candidates.inputs().next().is_none() { + let target_amount: Amount = target_outputs.iter().map(|output| output.value).sum(); + let err = bdk_coin_select::InsufficientFunds { + missing: target_amount.to_sat(), + }; + return Err(CreatePsbtError::InsufficientFunds(err))?; + } let original_txs: Vec = txids_to_replace .iter() @@ -3143,7 +3163,7 @@ impl Wallet { &input_candidates, SelectorParams { target_feerate: params.feerate, - target_outputs: self.target_outputs(¶ms), + target_outputs, change_descriptor: change_script, change_policy: ChangePolicyType::NoDustAndLeastWaste { longterm_feerate: params.longterm_feerate, From a1a8c5b42dff9098fb8a5b42a38bb4ccd5080f5c Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 21 Oct 2025 22:51:51 -0500 Subject: [PATCH 19/22] feat(psbt): Add `PsbtParams::only_witness_utxo` --- src/psbt/params.rs | 15 +++++++++++++++ src/wallet/mod.rs | 4 +--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 085911b3..8e973cc3 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -43,6 +43,7 @@ pub struct PsbtParams { pub(crate) locktime: Option, pub(crate) fallback_sequence: Option, pub(crate) ordering: TxOrdering, + pub(crate) only_witness_utxo: bool, } impl Default for PsbtParams { @@ -66,6 +67,7 @@ impl Default for PsbtParams { locktime: Default::default(), fallback_sequence: Default::default(), ordering: Default::default(), + only_witness_utxo: Default::default(), } } } @@ -239,6 +241,19 @@ impl PsbtParams { } self } + + /// Only fill in the [`witness_utxo`] field of PSBT inputs which spends funds under segwit (v0). + /// + /// This allows opting out of including the [`non_witness_utxo`] for segwit spends. This reduces + /// the size of the PSBT, however be aware that some signers might require the presence of the + /// `non_witness_utxo`. + /// + /// [`witness_utxo`]: bitcoin::psbt::Input::witness_utxo + /// [`non_witness_utxo`]: bitcoin::psbt::Input::non_witness_utxo + pub fn only_witness_utxo(&mut self) -> &mut Self { + self.only_witness_utxo = true; + self + } } /// Coin select strategy. diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d28b8a13..b9f9d3b0 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2993,9 +2993,7 @@ impl Wallet { version, fallback_locktime, fallback_sequence, - mandate_full_tx_for_segwit_v0: true, - // TODO: Only witness utxo - // mandate_full_tx_for_segwit_v0: params.only_witness_utxo, + mandate_full_tx_for_segwit_v0: !params.only_witness_utxo, }) .map_err(CreatePsbtError::Psbt)?; From bf51f18564c865635471a9806cecb41bb054cf49 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Wed, 22 Oct 2025 10:28:06 -0500 Subject: [PATCH 20/22] test: Add `test_create_psbt_csv` --- tests/psbt.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/psbt.rs b/tests/psbt.rs index d028164f..918808c6 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -144,6 +144,72 @@ fn test_create_psbt_cltv() { } } +#[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 params = PsbtParams::default(); + params + .add_utxos(&[op]) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let res = wallet.create_psbt(params); + assert!( + matches!(res, Err(CreatePsbtError::Plan(err)) if err == op), + "UTXO requires CSV but the assets are insufficient", + ); + } + + // Add assets ok + { + let mut params = PsbtParams::default(); + let rel_locktime = relative::LockTime::from_consensus(6).unwrap(); + params + .add_utxos(&[op]) + .add_assets(Assets::new().older(rel_locktime)) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let (psbt, _) = wallet.create_psbt(params).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 params = PsbtParams::default(); + params.add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let (psbt, _) = wallet.create_psbt(params).unwrap(); + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); + } +} + // Test that replacing two unconfirmed txs A, B results in a transaction // that spends the inputs of both A and B. #[test] From 5285937559742876724e815644f0c2d9b5fabe28 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Wed, 22 Oct 2025 11:06:56 -0500 Subject: [PATCH 21/22] psbt: Add `PsbtParams::canonicalization_params` --- src/psbt/params.rs | 13 +++++++++++++ src/wallet/mod.rs | 6 +----- tests/psbt.rs | 6 ++++-- tests/wallet.rs | 5 ++++- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 8e973cc3..39318f9f 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -175,6 +175,19 @@ impl PsbtParams { self } + /// Set the parameters for modifying the wallet's view of canonical transactions. + /// + /// The `params` can be used to resolve conflicts manually, or to assert that a particular + /// transaction should be treated as canonical for the purpose of building the current PSBT. + /// Refer to [`CanonicalizationParams`] for more. + pub fn canonicalization_params( + &mut self, + params: bdk_chain::CanonicalizationParams, + ) -> &mut Self { + self.canonical_params = params; + self + } + /// Set the definite descriptor used for generating the change output. pub fn change_descriptor(&mut self, desc: DefiniteDescriptor) -> &mut Self { self.change_descriptor = Some(desc); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index b9f9d3b0..be0276de 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2770,12 +2770,8 @@ impl Wallet { }); // Get wallet txouts. - let mut canon_params = params.canonical_params.clone(); - canon_params - .assume_canonical - .extend(params.utxos.iter().map(|op| op.txid)); let txouts = self - .list_indexed_txouts(canon_params) + .list_indexed_txouts(params.canonical_params.clone()) .map(|(_, txo)| (txo.outpoint, txo)) .collect(); diff --git a/tests/psbt.rs b/tests/psbt.rs index 918808c6..799bc8c6 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -147,7 +147,7 @@ fn test_create_psbt_cltv() { #[test] fn test_create_psbt_csv() { use bitcoin::relative; - use bitcoin::Sequence; + use bitcoin::Sequence; let desc = get_test_single_sig_csv(); let mut wallet = Wallet::create_single(desc) @@ -204,7 +204,9 @@ fn test_create_psbt_csv() { }; insert_checkpoint(&mut wallet, anchor.block_id); let mut params = PsbtParams::default(); - params.add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + params + .add_utxos(&[op]) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); let (psbt, _) = wallet.create_psbt(params).unwrap(); assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); } diff --git a/tests/wallet.rs b/tests/wallet.rs index 9f4d3e2c..6515f81d 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -77,8 +77,11 @@ fn test_spend_non_canonical_txout() -> anyhow::Result<()> { // Create tx2, spending the change of tx1 let mut params = psbt::PsbtParams::default(); + let canonical_params = bdk_chain::CanonicalizationParams { + assume_canonical: vec![to_select_op.txid], + }; params - .add_utxos(&[to_select_op]) + .canonicalization_params(canonical_params) .add_recipients([(recip, Amount::from_btc(0.01)?)]); let psbt = wallet.create_psbt(params)?.0; From 25e4b4e50a38fc3e7bed25f9e3a58ccbc78eaed4 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Fri, 24 Oct 2025 16:43:46 -0400 Subject: [PATCH 22/22] feat(psbt): Add `PsbtParams::add_global_xpubs` `add_global_xpubs` is a boolean which decides whether to fill in the PSBT global xpubs. If true, we proceed to gather extended keys of the wallet's descriptors and add them to the map of global xpubs with their origin. --- src/psbt/params.rs | 13 +++++++++++++ src/wallet/error.rs | 6 ++++++ src/wallet/mod.rs | 20 +++++++++++++++++++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 39318f9f..6703b5f2 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -44,6 +44,7 @@ pub struct PsbtParams { pub(crate) fallback_sequence: Option, pub(crate) ordering: TxOrdering, pub(crate) only_witness_utxo: bool, + pub(crate) add_global_xpubs: bool, } impl Default for PsbtParams { @@ -68,6 +69,7 @@ impl Default for PsbtParams { fallback_sequence: Default::default(), ordering: Default::default(), only_witness_utxo: Default::default(), + add_global_xpubs: Default::default(), } } } @@ -267,6 +269,17 @@ impl PsbtParams { self.only_witness_utxo = true; self } + + /// Fill in the global [`Psbt::xpub`]s field with the extended keys of the wallet's + /// descriptors. + /// + /// Some offline signers and/or multisig wallets may require this. + /// + /// [`Psbt::xpub`]: bitcoin::Psbt::xpub + pub fn add_global_xpubs(&mut self) -> &mut Self { + self.add_global_xpubs = true; + self + } } /// Coin select strategy. diff --git a/src/wallet/error.rs b/src/wallet/error.rs index dd3cdb60..520bb80e 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -265,6 +265,11 @@ pub enum CreatePsbtError { Bnb(bdk_coin_select::NoBnbSolution), /// 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::PsbtParams::add_global_xpubs + MissingKeyOrigin(bitcoin::bip32::Xpub), /// Failed to create a spend [`Plan`] for a manually selected output Plan(OutPoint), /// Failed to create PSBT @@ -280,6 +285,7 @@ impl fmt::Display for CreatePsbtError { match self { Self::Bnb(e) => write!(f, "{e}"), Self::InsufficientFunds(e) => write!(f, "{e}"), + Self::MissingKeyOrigin(e) => write!(f, "missing key origin: {e}"), Self::Plan(op) => write!(f, "failed to create a plan for txout with outpoint {op}"), Self::Psbt(e) => write!(f, "{e}"), Self::Selector(e) => write!(f, "{e}"), diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index be0276de..2987522d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2984,7 +2984,7 @@ impl Wallet { .unwrap_or(Sequence::ENABLE_LOCKTIME_NO_RBF); // Create psbt - let psbt = selection + let mut psbt = selection .create_psbt(bdk_tx::PsbtParams { version, fallback_locktime, @@ -2993,6 +2993,24 @@ impl Wallet { }) .map_err(CreatePsbtError::Psbt)?; + // 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); + } + } + let finalizer = selection.into_finalizer(); Ok((psbt, finalizer))