From 881245774c1ffedacc4582bc7de0bd02960d0f71 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 28 Apr 2026 01:15:44 -0400 Subject: [PATCH 01/10] feat(tx_builder): Generalize TxOrdering inputs, outputs `TxOrdering` is made generic by exposing the generic from `TxSort` function. This means we're not limited to ordering lists of only `TxIn` and `TxOut`, which will be useful for sorting inputs/outputs of a `bdk_tx::Selection`. We use bitcoin `TxIn` and `TxOut` as the default type parameter to maintain backward compatibility. Add `sort_with_rng` 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 f22a62cf3..e26afec06 100644 --- a/src/wallet/tx_builder.rs +++ b/src/wallet/tx_builder.rs @@ -832,7 +832,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, @@ -847,13 +847,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"), @@ -893,6 +893,27 @@ impl TxOrdering { } } +impl TxOrdering { + /// Sort the provided `input` and `output` slices by this [`TxOrdering`] and auxiliary + /// randomness. + pub fn sort_with_rng(&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 32c49e851fb7b6ac3d4c80c8d7d22b39b4fcf9ad Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 28 Apr 2026 01:14:29 -0400 Subject: [PATCH 02/10] feat(psbt): Add PsbtParams We add the `psbt::params` module along with new types including `PsbtParams` and `SelectionStrategy`. `PsbtParams` is mostly inspired by `TxParams` from `tx_builder.rs`, except that we've removed support for `policy_path` in favor of `add_assets` API. `PsbtParams` contains a type parameter `C` indicating the context in which the parameters can be used. Methods related to PSBT creation exist within the `CreateTx` context, and methods related to replacements (RBF) exist within the `ReplaceTx` context. In `lib.rs` re-export everything under `psbt` module. - deps: Add git dependency `bdk_tx` 0.2.0 --- Cargo.toml | 8 +- src/lib.rs | 1 + src/psbt/mod.rs | 5 + src/psbt/params.rs | 632 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 645 insertions(+), 1 deletion(-) create mode 100644 src/psbt/params.rs diff --git a/Cargo.toml b/Cargo.toml index 120fc6a61..ec2eeacf0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,9 +33,15 @@ bdk_file_store = { version = "0.22.0", optional = true } bip39 = { version = "2.2.2", optional = true } tempfile = { version = "3.26.0", optional = true } +[dependencies.bdk_tx] +version = "0.2.0" +default-features = false +git = "https://github.com/valuedmammal/bdk-tx" +branch = "release/0_2_0" + [features] default = ["std"] -std = ["bitcoin/std", "bitcoin/rand-std", "miniscript/std", "bdk_chain/std"] +std = ["bitcoin/std", "bitcoin/rand-std", "miniscript/std", "bdk_chain/std", "bdk_tx/std"] compiler = ["miniscript/compiler"] all-keys = ["keys-bip39"] keys-bip39 = ["bip39"] diff --git a/src/lib.rs b/src/lib.rs index ecc15b85f..a19da8a6e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,7 @@ pub use bdk_chain::rusqlite; pub use bdk_chain::rusqlite_impl; pub use descriptor::template; pub use descriptor::HdKeyPaths; +pub use psbt::*; pub use signer; pub use signer::SignOptions; pub use tx_builder::*; diff --git a/src/psbt/mod.rs b/src/psbt/mod.rs index 5f05b7b86..aaa6cd438 100644 --- a/src/psbt/mod.rs +++ b/src/psbt/mod.rs @@ -17,6 +17,11 @@ use bitcoin::FeeRate; use bitcoin::Psbt; use bitcoin::TxOut; +#[allow(unused)] +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 000000000..11d9fab68 --- /dev/null +++ b/src/psbt/params.rs @@ -0,0 +1,632 @@ +//! Parameters for creating a PSBT. + +use alloc::sync::Arc; +use alloc::vec::Vec; +use core::fmt; + +use bdk_chain::{BlockId, CanonicalizationParams, ConfirmationBlockTime, FullTxOut, TxGraph}; +use bdk_tx::{ChangeScript, Input, Output}; +use bitcoin::{ + absolute, psbt::PsbtSighashType, transaction::Version, Amount, FeeRate, OutPoint, ScriptBuf, + Sequence, Transaction, Txid, +}; +use miniscript::plan::Assets; + +use crate::collections::HashSet; +use crate::TxOrdering; + +/// Marker type representing the PSBT creation state. +#[derive(Debug)] +pub struct CreateTx; + +/// Marker type representing the Replace-By-Fee (RBF) state. +#[derive(Debug)] +pub struct ReplaceTx; + +/// Alias for [`ReplaceTx`] context marker. +pub type Rbf = ReplaceTx; + +/// Parameters to create a PSBT. +// TODO: Can we derive `Clone` for this? +#[derive(Debug)] +pub struct PsbtParams { + /// Set of selected UTXO outpoints. + pub(crate) set: HashSet, + /// List of UTXO outpoints to spend. + pub(crate) utxos: Vec, + /// List of planned transaction [`Input`]s. + pub(crate) inputs: Vec, + /// List of recipient script/amount pairs. + pub(crate) recipients: Vec<(ScriptBuf, Amount)>, + /// Optional script or descriptor designated for change. + pub(crate) change_script: Option, + /// Optional assets for creating a spend plan. + pub(crate) assets: Option, + /// Target fee rate. + pub(crate) fee_rate: FeeRate, + /// Whether to spend all available coins. + pub(crate) drain_wallet: bool, + /// Coin selection strategy to use. + pub(crate) coin_selection: SelectionStrategy, + /// Parameters for transaction canonicalization. + pub(crate) canonical_params: CanonicalizationParams, + /// UTXO filtering function. + pub(crate) utxo_filter: UtxoFilter, + /// Optional height for evaluating coinbase maturity. + pub(crate) maturity_height: Option, + /// Only allow spending UTXOs which are selected manually. + pub(crate) manually_selected_only: bool, + /// Optional transaction [`Version`]. + pub(crate) version: Option, + /// Optional transaction [`LockTime`](absolute::LockTime). + pub(crate) locktime: Option, + /// Optional fallback [`Sequence`] for inputs. + pub(crate) fallback_sequence: Option, + /// Ordering of the transaction's inputs and outputs. + pub(crate) ordering: TxOrdering, + /// Only set the [`witness_utxo`](bitcoin::psbt::Input::witness_utxo) in PSBT inputs. This + /// allows opting out of setting the + /// [`non_witness_utxo`](bitcoin::psbt::Input::non_witness_utxo). + pub(crate) only_witness_utxo: bool, + /// Optional PSBT sighash type. + pub(crate) sighash_type: Option, + /// Whether to try filling in the PSBT global xpubs from the wallet's descriptors. + pub(crate) add_global_xpubs: bool, + /// Whether to enable BIP326 Sequence based anti-fee sniping. + pub(crate) enable_anti_fee_sniping: bool, + /// Set of txids being replaced if this is a RBF transaction. + pub(crate) replace: HashSet, + /// The context in which the params are used. + pub(crate) marker: core::marker::PhantomData, +} + +impl Default for PsbtParams { + fn default() -> Self { + Self { + set: Default::default(), + utxos: Default::default(), + inputs: Default::default(), + assets: Default::default(), + recipients: Default::default(), + change_script: Default::default(), + fee_rate: FeeRate::BROADCAST_MIN, + drain_wallet: Default::default(), + coin_selection: Default::default(), + 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(), + ordering: Default::default(), + only_witness_utxo: Default::default(), + sighash_type: Default::default(), + add_global_xpubs: Default::default(), + enable_anti_fee_sniping: Default::default(), + replace: Default::default(), + marker: core::marker::PhantomData, + } + } +} + +impl PsbtParams { + /// Create a new [`PsbtParams`]. + pub fn new() -> Self { + Self::default() + } + + /// 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 items of `outpoints` correspond to outputs of previous + /// transactions and are currently unspent. + /// + /// If an outpoint doesn't correspond to an indexed script pubkey, a [`UnknownUtxo`] + /// error will occur. See [`Wallet::create_psbt`] for more. + /// + /// To add a UTXO that did not originate from this wallet (i.e. a "foreign" UTXO), see + /// [`PsbtParams::add_planned_input`]. + /// + /// [`UnknownUtxo`]: crate::wallet::error::CreatePsbtError::UnknownUtxo + /// [`Wallet::create_psbt`]: crate::Wallet::create_psbt + pub fn add_utxos(&mut self, outpoints: &[OutPoint]) -> &mut Self { + self.utxos + .extend(outpoints.iter().copied().filter(|&op| self.set.insert(op))); + self + } + + /// Replace spends of the provided `txs` and return a [`PsbtParams`] populated with the + /// inputs to spend. + /// + /// This merges all of the spends into a single transaction while retaining the parameters + /// of `self`. Note that any previously added UTXOs are removed. Call + /// [`replace_by_fee_with_rng`](crate::Wallet::replace_by_fee_with_rng) 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_txs(self, txs: &[Arc]) -> PsbtParams { + let mut params = self.into_replace_params(); + params.replace(txs); + params + } + + /// Transition this [`PsbtParams`] to the [`Rbf`] state. + fn into_replace_params(self) -> PsbtParams { + PsbtParams { + set: self.set, + utxos: self.utxos, + inputs: self.inputs, + assets: self.assets, + recipients: self.recipients, + change_script: self.change_script, + fee_rate: self.fee_rate, + drain_wallet: self.drain_wallet, + coin_selection: self.coin_selection, + canonical_params: self.canonical_params, + utxo_filter: self.utxo_filter, + maturity_height: self.maturity_height, + manually_selected_only: self.manually_selected_only, + version: self.version, + locktime: self.locktime, + fallback_sequence: self.fallback_sequence, + ordering: self.ordering, + only_witness_utxo: self.only_witness_utxo, + sighash_type: self.sighash_type, + add_global_xpubs: self.add_global_xpubs, + enable_anti_fee_sniping: self.enable_anti_fee_sniping, + replace: self.replace, + marker: core::marker::PhantomData, + } + } +} + +impl PsbtParams { + /// Get the currently selected spends. + pub fn utxos(&self) -> &HashSet { + &self.set + } + + /// Remove a UTXO from the currently selected inputs. + pub fn remove_utxo(&mut self, outpoint: &OutPoint) -> &mut Self { + if self.set.remove(outpoint) { + self.utxos.retain(|op| op != outpoint); + } + 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 + /// 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. + 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 outgoing recipients to the transaction. + /// + /// - `recipients`: An iterator of `(S, Amount)` tuples where `S` can be a [`bitcoin::Address`], + /// a script pubkey, 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 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 { + self.maturity_height = Some(height.to_consensus_u32()); + self + } + + /// Set the target [`FeeRate`]. + /// + /// If not set, defaults to [`FeeRate::BROADCAST_MIN`]. + pub fn fee_rate(&mut self, fee_rate: FeeRate) -> &mut Self { + self.fee_rate = fee_rate; + 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 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 [`Descriptor`] or raw [`Script`] to be used for generating the change output. + /// + /// [`Descriptor`]: ChangeScript::Descriptor + /// [`Script`]: ChangeScript::Script + pub fn change_script(&mut self, change_script: ChangeScript) -> &mut Self { + self.change_script = Some(change_script); + self + } + + /// Filter [`FullTxOut`]s by the provided closure. + /// + /// This option can be used to mark specific outputs unspendable or apply custom UTXO + /// filtering logic. + /// + /// Any txouts for which the `predicate` returns `false` will be excluded 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, predicate: F) -> &mut Self + where + F: Fn(&FullTxOut) -> bool + Send + Sync + 'static, + { + self.utxo_filter = UtxoFilter(Arc::new(predicate)); + self + } + + /// 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. 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 + } + + /// Add a planned input. + /// + /// This can be used to add inputs that come with a [`Plan`] or [`psbt::Input`] provided. + /// See [`Input`] for more on how to create inputs manually. Be aware that creating inputs + /// in this manner relies on certain assumptions, like the UTXO validity, the satisfaction + /// weight, and so on. As such you should only use this method to add inputs you definitely + /// trust the values for. + /// + /// # Example + /// + /// ```rust,no_run + /// use bdk_tx::Input; + /// # use bdk_wallet::psbt::PsbtParams; + /// # use bitcoin::{psbt, OutPoint, Sequence, TxOut}; + /// # let outpoint = OutPoint::null(); + /// # let sequence = Sequence::ENABLE_LOCKTIME_NO_RBF; + /// # let psbt_input = psbt::Input::default(); + /// # let satisfaction_weight = 0; + /// # let tx_status = None; + /// # let is_coinbase = false; + /// let mut params = PsbtParams::default(); + /// let input = Input::from_psbt_input( + /// outpoint, + /// sequence, + /// psbt_input, + /// satisfaction_weight, + /// tx_status, + /// is_coinbase, + /// )?; + /// params.add_planned_input(input); + /// # Ok::<_, anyhow::Error>(()) + /// ``` + /// + /// [`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 + } + + /// 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 + } + + /// Drain wallet. + /// + /// This will force selection of the available input candidates. As such, the option is only + /// applied to inputs that meet the spending criteria. + pub fn drain_wallet(&mut self) -> &mut Self { + self.drain_wallet = true; + self + } + + /// Set the transaction [`Version`]. + pub fn version(&mut self, version: Version) -> &mut Self { + self.version = Some(version); + self + } + + /// Set the [`Sequence`] value to be used as a fallback if not specified by the input. + pub fn fallback_sequence(&mut self, sequence: Sequence) -> &mut Self { + self.fallback_sequence = Some(sequence); + self + } + + /// Set a specific [`PsbtSighashType`]. + pub fn sighash_type(&mut self, sighash_type: PsbtSighashType) -> &mut Self { + self.sighash_type = Some(sighash_type); + 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 + } + + /// Whether to enable [BIP326] anti-fee sniping. + /// + /// When enabled, the transaction's `nLockTime` or `nSequence` will be set to indicate the + /// transaction should only be valid after the current block height. This discourages + /// miners from reorganizing recent blocks to capture fees. For details refer to + /// the corresponding [`enable_anti_fee_sniping`] option which covers the rules + /// and caveats. + /// + /// [`enable_anti_fee_sniping`]: bdk_tx::PsbtParams::enable_anti_fee_sniping + /// [BIP326]: + pub fn enable_anti_fee_sniping(&mut self) -> &mut Self { + self.enable_anti_fee_sniping = true; + 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`] metric for more. + /// + /// [`LowestFee`]: bdk_tx::bdk_coin_select::metrics::LowestFee + LowestFee { + /// Hypothetical average long-term feerate of the change spending transaction. + longterm_feerate: FeeRate, + /// How many times to run BnB before giving up. + max_rounds: usize, + }, +} + +/// [`UtxoFilter`] is a user-defined `Fn` closure which decides whether to include a UTXO +/// for coin selection. This has a default implementation that enables selection of all +/// txouts passed to it. +#[allow(clippy::type_complexity)] +#[derive(Clone)] +pub(crate) struct UtxoFilter( + pub Arc) -> bool + Send + Sync>, +); + +impl Default for UtxoFilter { + fn default() -> Self { + Self(Arc::new(|_| true)) + } +} + +impl fmt::Debug for UtxoFilter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "UtxoFilter") + } +} + +impl PsbtParams { + /// Replace spends of the provided `txs`. This will internally set the list of UTXOs + /// to be spent. + fn replace(&mut self, txs: &[Arc]) { + self.utxos.clear(); + self.set.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 + // 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_to_replace.contains(&ancestor_txid)) + { + txids_to_replace.remove(&tx_node.txid); + } else { + utxos.extend(tx.input.iter().map(|txin| txin.previous_output)); + } + } + + self.replace = txids_to_replace; + self.utxos + .extend(utxos.iter().copied().filter(|&op| self.set.insert(op))); + } +} + +/// 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 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 { + 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 = PsbtParams::new().replace_txs(&txs); + assert_eq!(params.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 244769988a0df6aaad0a62be16201d8aa9e1b5a0 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 28 Apr 2026 01:16:33 -0400 Subject: [PATCH 03/10] feat(wallet): Add `Wallet::create_psbt` We use the new `PsbtParams` to add methods on `Wallet` for creating PSBTs, including RBF transactions. `Wallet::create_psbt` and `Wallet::replace_by_fee` each have no-std counterparts that take an additional `impl RngCore` parameter. Also adds a convenience method `replace_by_fee_and_recipients` that exposes the minimum information needed to create an RBF. This commit re-introduces the `Wallet::insert_tx` API for adding newly created transactions to the wallet. Added `Wallet::transactions_with_params` that allows customizing the internal canonicalization logic. Added errors to `wallet::errors` module: - `CreatePsbtError` - `ReplaceByFeeError` --- src/psbt/mod.rs | 1 - src/wallet/error.rs | 72 +++++ src/wallet/mod.rs | 672 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 731 insertions(+), 14 deletions(-) diff --git a/src/psbt/mod.rs b/src/psbt/mod.rs index aaa6cd438..1f07fe6b4 100644 --- a/src/psbt/mod.rs +++ b/src/psbt/mod.rs @@ -17,7 +17,6 @@ use bitcoin::FeeRate; use bitcoin::Psbt; use bitcoin::TxOut; -#[allow(unused)] mod params; pub use params::*; diff --git a/src/wallet/error.rs b/src/wallet/error.rs index ddd074785..60ec1e07f 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -19,6 +19,7 @@ use alloc::{ boxed::Box, string::{String, ToString}, }; +use bdk_tx::bdk_coin_select; use bitcoin::{absolute, psbt, Amount, BlockHash, Network, OutPoint, Sequence, Txid}; use core::fmt; @@ -365,3 +366,74 @@ impl fmt::Display for BuildFeeBumpError { } impl core::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), + /// 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 spending 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::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}"), + 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: {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 1cdd1cc7b..df50cab51 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -31,14 +31,21 @@ use bdk_chain::{ SyncResponse, }, tx_graph::{CalculateFeeError, CanonicalTx, TxGraph, TxUpdate}, - BlockId, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, + Anchor, BlockId, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, FullTxOut, Indexed, IndexedTxGraph, Indexer, Merge, }; +use bdk_tx::{ + bdk_coin_select, selection_algorithm_lowest_fee_bnb, ChangeScript, ConfirmationStatus, + Finalizer, Input, InputCandidates, OriginalTxStats, Output, RbfParams, Selector, + SelectorParams, +}; +#[cfg(feature = "std")] +use bitcoin::secp256k1::rand; use bitcoin::{ absolute, consensus::encode::serialize, constants::genesis_block, - psbt, + psbt, relative, secp256k1::Secp256k1, sighash::{EcdsaSighashType, TapSighashType}, transaction, Address, Amount, Block, FeeRate, Network, NetworkKind, OutPoint, Psbt, ScriptBuf, @@ -46,7 +53,9 @@ use bitcoin::{ }; use miniscript::{ descriptor::KeyMap, + plan::{Assets, Plan}, psbt::{PsbtExt, PsbtInputExt, PsbtInputSatisfier}, + ForEachKey, }; use rand_core::RngCore; @@ -70,11 +79,13 @@ use crate::descriptor::{ DerivedDescriptor, DescriptorMeta, ExtendedDescriptor, ExtractPolicy, IntoWalletDescriptor, Policy, XKeyUtils, }; -use crate::psbt::PsbtUtils; +use crate::psbt::{AssetsExt, CreateTx, PsbtParams, PsbtUtils, Rbf, SelectionStrategy}; 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}, @@ -87,8 +98,10 @@ pub use error::{LoadError, LoadMismatch}; pub use event::*; pub use params::*; pub use persisted::*; -pub use utils::IsDust; -pub use utils::TxDetails; +pub use utils::{IsDust, TxDetails}; + +/// Alias [`FullTxOut`] with associated keychain and derivation index. +type IndexedTxOut = ((KeychainKind, u32), FullTxOut); /// A Bitcoin wallet /// @@ -789,6 +802,19 @@ impl Wallet { .map(|((k, i), full_txo)| new_local_utxo(k, i, full_txo)) } + /// List indexed [`FullTxOut`]s. + fn list_indexed_txouts( + &self, + params: CanonicalizationParams, + ) -> impl Iterator + '_ { + self.tx_graph.graph().filter_chain_txouts( + &self.chain, + self.chain.tip().block_id(), + params, + self.tx_graph.index.outpoints().iter().cloned(), + ) + } + /// Get the [`TxDetails`] of a wallet transaction. /// /// If the transaction with txid [`Txid`] cannot be found in the wallet's transactions, `None` @@ -1075,15 +1101,25 @@ impl Wallet { /// /// To iterate over all canonical transactions, including those that are irrelevant, use /// [`TxGraph::list_canonical_txs`]. - pub fn transactions(&self) -> impl Iterator> + '_ { + pub fn transactions<'a>(&'a self) -> impl Iterator> + 'a { + self.transactions_with_params(CanonicalizationParams::default()) + } + + /// 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, + ) -> impl Iterator> + 'a { let tx_graph = self.tx_graph.graph(); let tx_index = &self.tx_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)) } @@ -2491,6 +2527,36 @@ impl Wallet { self.events_helper(|wallet| wallet.apply_block_connected_to(block, height, connected_to)) } + /// Inserts a transaction into the inner transaction graph, scanning for relevant outputs. + /// + /// This can be used to inform the wallet of created transactions before they are known to exist + /// on chain or in the mempool. Inserting a transaction on its own doesn't affect the balance of + /// the wallet until the transaction is seen by the network and the wallet is synced. + /// + /// The effect of insertion depends on the [relevance] of `tx` as determined by the [indexer]. + /// If the transaction was newly inserted and an output matches a derived script pubkey, then + /// the index is updated with the relevant outpoints. If no outputs are relevant, the + /// transaction is kept and the index remains unchanged. If `tx` already exists in the wallet + /// under the same txid, then the effect is a no-op. + /// + /// **You must persist the change set staged as a result of this call.** + /// + /// [relevance]: Indexer::is_tx_relevant + /// [indexer]: Self::spk_index + pub fn insert_tx(&mut self, tx: T) + where + T: Into>, + { + let mut tx_update = TxUpdate::default(); + tx_update.txs = vec![tx.into()]; + let update = Update { + tx_update, + ..Default::default() + }; + self.apply_update(update) + .expect("Applying a `TxUpdate` should not fail"); + } + /// Apply relevant unconfirmed transactions to the wallet. /// /// Transactions that are not relevant are filtered out. @@ -2778,6 +2844,586 @@ impl Wallet { } } +/// Maps a chain position to tx confirmation status, if `pos` is the confirmed +/// variant. +/// +/// - Returns None if the confirmation height or time is not a valid absolute [`Height`] or +/// [`Time`]. +/// +/// [`Height`]: bitcoin::absolute::Height +/// [`Time`]: bitcoin::absolute::Time +fn status_from_position(pos: ChainPosition) -> Option { + if let ChainPosition::Confirmed { anchor, .. } = pos { + let conf_height = anchor.confirmation_height_upper_bound(); + let height = absolute::Height::from_consensus(conf_height).ok()?; + // TODO: Currently BDK has no notion of MTP, we can use the confirmation block time for now. + let time = + absolute::Time::from_consensus(anchor.confirmation_time.try_into().ok()?).ok()?; + Some(ConfirmationStatus { + height, + prev_mtp: Some(time), + }) + } else { + None + } +} + +impl Wallet { + /// Return the "keys" assets, i.e. the ones we can trivially infer by scanning + /// the pubkeys of the wallet's descriptors. + fn assets(&self) -> Assets { + let mut pks = vec![]; + for (_, desc) in self.keychains() { + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + } + + Assets::new().add(pks) + } + + /// Obtain the next change script (descriptor) from the wallet. + fn change_script(&self, change_script: Option) -> ChangeScript { + change_script.unwrap_or_else(|| { + let change_keychain = self.map_keychain(KeychainKind::Internal); + let descriptor = self.public_descriptor(change_keychain); + let next_index = self.next_derivation_index(change_keychain); + let definite_descriptor = descriptor + .at_derivation_index(next_index) + .expect("should be valid derivation index"); + ChangeScript::from_descriptor(definite_descriptor) + }) + } + + /// Parses the common parameters used during PSBT creation. + /// + /// ## Returns + /// + /// - Assets + /// - Change script + /// - Indexed wallet txouts + fn parse_params( + &self, + params: &PsbtParams, + ) -> (Assets, 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 wallet txouts. + let txouts = self + .list_indexed_txouts(params.canonical_params.clone()) + .map(|(_, txo)| (txo.outpoint, txo)) + .collect(); + + (assets, txouts) + } + + /// Filters wallet `txos` by the spending criteria. + /// + /// - `policy`: Closure indicating whether the output should be kept, used by some callers to + /// apply additional filters as in the case of RBF. + fn filter_spendable<'a, I, C, F>( + &'a self, + txos: I, + params: &'a PsbtParams, + policy: F, + ) -> impl Iterator> + 'a + where + I: IntoIterator> + 'a, + F: Fn(&FullTxOut) -> bool + 'a, + { + 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) { + return false; + } + // Filter outputs according to `policy` fn. + if !policy(txo) { + return false; + } + // Exclude locked UTXOs. + if self.is_outpoint_locked(txo.outpoint) { + return false; + } + // Exclude immature outputs. + if !txo.is_mature(current_height) { + return false; + } + // Exclude spent outputs. + if txo.spent_by.is_some() { + return false; + } + true + }) + } + + /// Maps the recipients of the `params` to a collection of target [`Output`]s. + fn target_outputs(&self, params: &PsbtParams) -> Vec { + params + .recipients + .iter() + .cloned() + .map( + |(script, value)| match self.tx_graph.index.index_of_spk(script.clone()) { + Some(&(keychain, index)) => { + let descriptor = self + .public_descriptor(keychain) + .at_derivation_index(index) + .expect("should be valid derivation index"); + Output::with_descriptor(descriptor, value) + } + None => Output::with_script(script, value), + }, + ) + .collect() + } + + /// Creates a PSBT with the given `params` and returns the updated [`Psbt`] and + /// [`Finalizer`]. + /// + /// This function uses the thread-local random number generator (RNG) to generate + /// randomness. To supply your own source of entropy see [`Wallet::create_psbt_with_rng`]. + /// + /// # 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::SingleRandomDraw) + /// .fee_rate(FeeRate::BROADCAST_MIN); + /// + /// let (psbt, finalizer) = wallet.create_psbt(params)?; + /// # Ok::<_, anyhow::Error>(()) + /// ``` + /// + /// # Errors + /// + /// A [`CreatePsbtError`] will be thrown if any of the following occurs + /// + /// - A manually selected input is missing from the wallet, or could not be planned + /// - The input value is insufficient to fund the outputs + /// - Failure to complete coin selection + /// - Failure to create or update the PSBT. + #[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_rng(params, &mut rand::thread_rng()) + } + + /// Creates a PSBT with the given `params` and random number generator (RNG). + /// + /// Return the updated [`Psbt`] and [`Finalizer`]. + /// + /// ## Parameters: + /// + /// - `params`: [`PsbtParams`] + /// - `rng`: Source of entropy, may be used during coin selection and to sort inputs and outputs + /// by the [`TxOrdering`](crate::wallet::tx_builder::TxOrdering). + pub fn create_psbt_with_rng( + &self, + mut params: PsbtParams, + rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), CreatePsbtError> { + // Get change script. + // This is currently awkward as ChangeScript is not yet Clone, so here we take + // the optional value directly from the params. + let change_script = self.change_script(params.change_script.take()); + + let (assets, 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)) + }) + .chain(params.inputs.iter().cloned().map(Result::Ok)) + .collect::>()?; + + // Get input candidates + 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() + }; + + 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::new(params.fee_rate, target_outputs, change_script), + ) + .map_err(CreatePsbtError::Selector)?; + + self.create_psbt_from_selector(&mut selector, ¶ms, rng) + } + + /// 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, + rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), CreatePsbtError> { + // 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 { + longterm_feerate, + max_rounds, + } => { + selector + .select_with_algorithm(selection_algorithm_lowest_fee_bnb( + longterm_feerate, + max_rounds, + )) + .map_err(CreatePsbtError::Bnb)?; + } + }; + } + 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_rng(&mut selection.inputs, &mut selection.outputs, rng); + + let version = params.version.unwrap_or(transaction::Version::TWO); + let fallback_locktime = params + .locktime + .unwrap_or(absolute::LockTime::from_consensus( + self.chain.tip().height(), + )); + let fallback_sequence = params + .fallback_sequence + .unwrap_or(Sequence::ENABLE_LOCKTIME_NO_RBF); + + // Create psbt + let mut psbt = selection + .create_psbt_with_rng( + bdk_tx::PsbtParams { + version, + fallback_locktime, + fallback_sequence, + mandate_full_tx_for_segwit_v0: !params.only_witness_utxo, + sighash_type: params.sighash_type, + enable_anti_fee_sniping: params.enable_anti_fee_sniping, + }, + rng, + ) + .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)) + } + + /// Creates a Replace-By-Fee transaction (RBF) and returns the updated [`Psbt`] and + /// [`Finalizer`]. + /// + /// This is a convenience for getting a new [`PsbtParams`], and updating the recipients + /// and feerate before calling [`Wallet::replace_by_fee_with_rng`]. If further configuration is + /// desired, consider using [`PsbtParams::replace_txs`] 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(10).expect("valid feerate"), + /// vec![(txout.script_pubkey, txout.value)], + /// )?; + /// # Ok::<_, anyhow::Error>(()) + /// ``` + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn replace_by_fee_and_recipients( + &self, + txs: &[Arc], + fee_rate: FeeRate, + recipients: Vec<(ScriptBuf, Amount)>, + ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { + let params = PsbtParams { + fee_rate, + recipients, + ..Default::default() + } + .replace_txs(txs); + self.replace_by_fee_with_rng(params, &mut rand::thread_rng()) + } + + /// Creates a Replace-By-Fee transaction (RBF) and returns the updated [`Psbt`] and + /// [`Finalizer`]. + /// + /// This function uses the thread-local random number generator (RNG) to generate + /// randomness. To supply your own source of entropy see [`Wallet::replace_by_fee_with_rng`]. + /// + /// # Errors + /// + /// A [`ReplaceByFeeError`] will be thrown if any of the following occurs + /// + /// - An original transaction is missing from the wallet + /// - Failure to calculate the [fee](Wallet::calculate_fee) of an original transaction + /// - Failure to complete coin selection + /// - Failure to create or update the PSBT. + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn replace_by_fee( + &self, + params: PsbtParams, + ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { + self.replace_by_fee_with_rng(params, &mut rand::thread_rng()) + } + + /// Creates a Replace-By-Fee transaction (RBF) and returns the updated [`Psbt`] and + /// [`Finalizer`]. + /// + /// ## Parameters: + /// + /// - `params`: [`PsbtParams`] + /// - `rng`: Source of entropy, may be used during coin selection and to sort inputs and outputs + /// by the [`TxOrdering`](crate::wallet::tx_builder::TxOrdering). + pub fn replace_by_fee_with_rng( + &self, + mut params: PsbtParams, + rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { + let change_script = self.change_script(params.change_script.take()); + + let PsbtParams { + replace: txids_to_replace, + .. + } = ¶ms; + // 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.tx_graph + .graph() + .walk_descendants(txid, |_, txid| Some(txid)), + ); + } + + let (assets, 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)) + }) + .chain(params.inputs.iter().cloned().map(Result::Ok)) + .collect::>()?; + + // Get input candidates + let mut may_spend: Vec = if params.manually_selected_only { + vec![] + } else { + self.filter_spendable(txouts.into_values(), ¶ms, |txo| { + // To be included for coin selection the UTXO + // - must not exist in `to_replace` + // - must be confirmed (per replacement policy Rule 2) + // - must pass a user-defined filter + !to_replace.contains(&txo.outpoint.txid) + && txo.chain_position.is_confirmed() + && (params.utxo_filter.0)(txo) + }) + .flat_map(|txo| self.plan_input(&txo, &assets)) + .collect() + }; + + 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() + .map(|&txid| -> Result<_, ReplaceByFeeError> { + let tx = self + .tx_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 mut selector = Selector::new( + &input_candidates, + SelectorParams { + replace: Some(rbf_params), + ..SelectorParams::new(params.fee_rate, target_outputs, change_script) + }, + ) + .map_err(CreatePsbtError::Selector)?; + + self.create_psbt_from_selector(&mut selector, ¶ms, rng) + .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.tx_graph.graph().get_tx(txid)?; + let tx_status = status_from_position(txo.chain_position); + + Input::from_prev_tx(plan, tx, op.vout as usize, tx_status).ok() + } + + /// Attempt to create a spending plan for the UTXO of the given `outpoint` + /// with the provided `assets`. + /// + /// Return `None` if `outpoint` doesn't correspond to an indexed txout, or + /// if the assets are not sufficient to create a plan. + fn try_plan(&self, outpoint: OutPoint, assets: &Assets) -> Option { + let indexer = &self.tx_graph.index; + let ((keychain, index), _) = indexer.txout(outpoint)?; + let def_desc = indexer + .get_descriptor(keychain)? + .at_derivation_index(index) + .expect("must be valid derivation index"); + def_desc.plan(assets).ok() + } +} + impl AsRef> for Wallet { fn as_ref(&self) -> &bdk_chain::tx_graph::TxGraph { self.tx_graph.graph() @@ -2901,7 +3547,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::*; From d44c1dda13d380883dee945afa743405a7c8f995 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 28 Apr 2026 01:20:32 -0400 Subject: [PATCH 04/10] test: Add coverage for `create_psbt` and related fns Added unit test `psbt::params::test::test_replace_params` To tests/add_foreign_utxo.rs added - `test_add_planned_psbt_input` To tests/psbt.rs added - `test_create_psbt` - `test_create_psbt_insufficient_funds_error` - `test_create_psbt_maturity_height` - `test_create_psbt_cltv` - `test_create_psbt_csv` - `test_replace_by_fee_and_recpients` - `test_create_psbt_utxo_filter` To tests/wallet.rs added - `test_spend_non_canonical_txout` - `test-utils`: Add `insert_tx_anchor` test helper for adding a transaction to the wallet with associated anchor block. --- src/psbt/params.rs | 41 +++ src/test_utils.rs | 25 ++ tests/add_foreign_utxo.rs | 54 +++- tests/psbt.rs | 536 +++++++++++++++++++++++++++++++++++++- tests/wallet.rs | 85 +++++- 5 files changed, 737 insertions(+), 4 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 11d9fab68..8548914e4 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -550,6 +550,47 @@ mod test { use bitcoin::hashes::Hash; use bitcoin::{TxIn, TxOut}; + // Test that `replace_txs` maintains the expected params. + #[test] + fn test_replace_params() { + use crate::KeychainKind::Internal; + let (wallet, txid0) = crate::test_utils::get_funded_wallet_wpkh(); + let outpoint_0 = OutPoint::new(txid0, 0); + let change_descriptor = wallet + .public_descriptor(Internal) + .at_derivation_index(0) + .unwrap(); + + // Create psbt + let mut params = PsbtParams::default(); + params.change_script(ChangeScript::from_descriptor(change_descriptor)); + params.drain_wallet(); + let (psbt, _) = wallet.create_psbt(params).unwrap(); + let tx = psbt.unsigned_tx; + let txid1 = tx.compute_txid(); + + // Replace tx + let mut params = PsbtParams::default().replace_txs(&[Arc::new(tx)]); + params.add_recipients([(ScriptBuf::new_op_return([0xb1, 0x0c]), Amount::ZERO)]); + let feerate = FeeRate::from_sat_per_vb(8).unwrap(); + params.fee_rate(feerate); + + // Get utxos + assert_eq!(params.utxos(), &[outpoint_0].into()); + + assert_eq!(params.replace, [txid1].into()); + assert_eq!(params.fee_rate, feerate); + assert_eq!( + params.recipients, + [(ScriptBuf::new_op_return([0xb1, 0x0c]), Amount::ZERO)] + ); + + // Remove utxo + params.remove_utxo(&outpoint_0); + assert!(params.utxos().is_empty()); + assert!(params.utxos.is_empty()); + } + #[test] fn test_sanitize_rbf_set() { // To replace the set { [A, B], [C] }, where B is a descendant of A: diff --git a/src/test_utils.rs b/src/test_utils.rs index c0a514647..c9658a415 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -319,6 +319,31 @@ pub fn insert_checkpoint(wallet: &mut Wallet, block: BlockId) { .unwrap(); } +/// Inserts a transaction to be anchored by `block_id`. This is particularly useful for +/// adding a coinbase tx to the wallet for testing, since transactions of this kind +/// must always appear confirmed. +/// +/// This will also insert the anchor `block_id`. See [`insert_anchor`] for more. +pub fn insert_tx_anchor(wallet: &mut Wallet, tx: Transaction, block_id: BlockId) { + insert_checkpoint(wallet, block_id); + let anchor = ConfirmationBlockTime { + block_id, + confirmation_time: 1234567000, + }; + let txid = tx.compute_txid(); + + let mut tx_update = TxUpdate::default(); + tx_update.txs = vec![Arc::new(tx)]; + tx_update.anchors = [(anchor, txid)].into(); + + wallet + .apply_update(Update { + tx_update, + ..Default::default() + }) + .expect("failed to apply update"); +} + /// Inserts a transaction into the local view, assuming it is currently present in the mempool. /// /// This can be used, for example, to track a transaction immediately after it is broadcast. diff --git a/tests/add_foreign_utxo.rs b/tests/add_foreign_utxo.rs index 1dd0a8c9d..23b3c1a4c 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(()) +} diff --git a/tests/psbt.rs b/tests/psbt.rs index 08c4acc9e..8507dbb08 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -1,11 +1,543 @@ -use bdk_wallet::bitcoin::{Amount, FeeRate, Psbt, TxIn}; +use bdk_chain::{BlockId, ConfirmationBlockTime}; +use bdk_tx::bdk_coin_select; +use bdk_tx::ChangeScript; +use bdk_wallet::bitcoin; use bdk_wallet::test_utils::*; -use bdk_wallet::{psbt, KeychainKind, SignOptions}; +use bdk_wallet::{error::CreatePsbtError, psbt, KeychainKind, PsbtParams, SignOptions, Wallet}; +use bitcoin::{ + absolute, hashes::Hash, 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 const PSBT_STR: &str = "cHNidP8BAKACAAAAAqsJSaCMWvfEm4IS9Bfi8Vqz9cM9zxU4IagTn4d6W3vkAAAAAAD+////qwlJoIxa98SbghL0F+LxWrP1wz3PFTghqBOfh3pbe+QBAAAAAP7///8CYDvqCwAAAAAZdqkUdopAu9dAy+gdmI5x3ipNXHE5ax2IrI4kAAAAAAAAGXapFG9GILVT+glechue4O/p+gOcykWXiKwAAAAAAAEHakcwRAIgR1lmF5fAGwNrJZKJSGhiGDR9iYZLcZ4ff89X0eURZYcCIFMJ6r9Wqk2Ikf/REf3xM286KdqGbX+EhtdVRs7tr5MZASEDXNxh/HupccC1AaZGoqg7ECy0OIEhfKaC3Ibi1z+ogpIAAQEgAOH1BQAAAAAXqRQ1RebjO4MsRwUPJNPuuTycA5SLx4cBBBYAFIXRNTfy4mVAWjTbr6nj3aAfuCMIAAAA"; +// Test that `create_psbt` results in the expected PSBT. +#[test] +fn test_create_psbt() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let expected_xpub = match wallet.public_descriptor(KeychainKind::External) { + miniscript::Descriptor::Tr(tr) => match tr.internal_key() { + miniscript::DescriptorPublicKey::XPub(desc) => desc.xkey, + _ => unreachable!(), + }, + _ => unreachable!(), + }; + + // Receive coins + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 100, + hash: Hash::hash(b"100"), + }, + confirmation_time: 1234567000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + receive_output(&mut wallet, Amount::ONE_BTC, ReceiveTo::Block(anchor)); + + let change_descriptor = wallet + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External); + let mut params = PsbtParams::default(); + let feerate = FeeRate::from_sat_per_vb(4).unwrap(); + let selection_strategy = psbt::SelectionStrategy::LowestFee { + longterm_feerate: FeeRate::from_sat_per_vb(2).unwrap(), + max_rounds: 1000, + }; + params + .version(bitcoin::transaction::Version(3)) + .coin_selection(selection_strategy) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]) + .change_script(ChangeScript::from_descriptor(change_descriptor)) + .fee_rate(feerate) + .fallback_sequence(bitcoin::Sequence::MAX) + .ordering(bdk_wallet::TxOrdering::Shuffle) + .add_global_xpubs(); + + let (psbt, _) = wallet.create_psbt(params).unwrap(); + let tx = &psbt.unsigned_tx; + assert_eq!(tx.version.0, 3); + assert_eq!(tx.lock_time.to_consensus_u32(), anchor.block_id.height); + assert_eq!(tx.input.len(), 1); + assert_eq!(tx.output.len(), 2); + + // global xpubs + assert_eq!( + psbt.xpub, + [(expected_xpub, ("f6a5cb8b".parse().unwrap(), vec![].into()))].into(), + ); + // witness utxo + let psbt_input = &psbt.inputs[0]; + assert_eq!( + psbt_input.witness_utxo.as_ref().map(|txo| txo.value), + Some(Amount::ONE_BTC), + ); + // input internal key + assert!(psbt_input.tap_internal_key.is_some()); + // input key origins + assert!(psbt_input + .tap_key_origins + .values() + .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b")); + // output internal key + assert!(psbt + .outputs + .iter() + .any(|output| output.tap_internal_key.is_some())); + // output key origins + assert!(psbt.outputs.iter().any(|output| output + .tap_key_origins + .values() + .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b"))); +} + +#[test] +fn test_create_psbt_insufficient_funds_error() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External); + + let mut params = PsbtParams::default(); + params.add_recipients([(addr.script_pubkey(), Amount::from_sat(10_000))]); + + let result = wallet.create_psbt(params); + assert!(matches!( + result, + Err(CreatePsbtError::InsufficientFunds( + bdk_coin_select::InsufficientFunds { missing: 10_000 } + )), + )); +} + +#[test] +fn test_create_psbt_maturity_height() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let receive_address = wallet.reveal_next_address(KeychainKind::External); + let send_to_address = wallet.reveal_next_address(KeychainKind::External).address; + + let block_1 = BlockId { + height: 1, + hash: Hash::hash(b"1"), + }; + insert_checkpoint(&mut wallet, block_1); + + // Receive coinbase output at height = 1. + // maturity height = (1 + 100) = 101 + let tx = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::ONE_BTC, + script_pubkey: receive_address.script_pubkey(), + }], + ..new_tx(0) + }; + insert_tx_anchor(&mut wallet, tx, block_1); + + // The output is still immature at height = 99. + let mut p = PsbtParams::default(); + p.add_recipients([(send_to_address.clone(), Amount::from_sat(58_000))]) + .maturity_height(bitcoin::absolute::Height::from_consensus(99).unwrap()); + + let _ = wallet + .create_psbt(p) + .expect_err("immature output must not be selected"); + + // We can use the params to coerce the coinbase maturity. + let mut p = PsbtParams::default(); + p.add_recipients([(send_to_address.clone(), Amount::from_sat(58_000))]) + .maturity_height(bitcoin::absolute::Height::from_consensus(100).unwrap()); + + let _ = wallet + .create_psbt(p) + .expect("`maturity_height` should enable selection"); + + // The output is eligible for selection once the wallet tip reaches maturity height minus 1 + // (100), as it can be confirmed in the next block (101). + let block_100 = BlockId { + height: 100, + hash: Hash::hash(b"100"), + }; + insert_checkpoint(&mut wallet, block_100); + let mut p = PsbtParams::default(); + p.add_recipients([(send_to_address.clone(), Amount::from_sat(58_000))]); + + let _ = wallet + .create_psbt(p) + .expect("mature coinbase should be selected"); +} + +#[test] +fn test_create_psbt_cltv() { + use absolute::LockTime; + + let desc = get_test_single_sig_cltv(); + let mut wallet = Wallet::create_single(desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // Receive coins + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 99_999, + hash: Hash::hash(b"abc"), + }, + confirmation_time: 1234567000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let op = receive_output(&mut wallet, Amount::ONE_BTC, ReceiveTo::Block(anchor)); + + let addr = wallet.reveal_next_address(KeychainKind::External); + + // No assets fail + { + let mut 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 CLTV but the assets are insufficient", + ); + } + + // Add assets ok + { + 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 (psbt, _) = wallet.create_psbt(params).unwrap(); + assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), 100_000); + } + + // New chain tip (no assets) ok + { + let block_id = BlockId { + height: 100_000, + hash: Hash::hash(b"123"), + }; + insert_checkpoint(&mut wallet, block_id); + + let mut params = PsbtParams::default(); + 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.lock_time.to_consensus_u32(), 100_000); + } + + // 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); + } +} + +#[test] +fn test_create_psbt_cltv_timestamp() { + use absolute::LockTime; + + let lock_time = LockTime::from_consensus(1734230218); + let desc = get_test_single_sig_cltv_timestamp(); + let mut wallet = Wallet::create_single(desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // Receive coins + let op = receive_output(&mut wallet, Amount::ONE_BTC, ReceiveTo::Mempool(1)); + + let addr = wallet.reveal_next_address(KeychainKind::External); + + // No assets fail + { + let mut 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 CLTV but the assets are insufficient", + ); + } + + // Add assets ok + { + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .add_assets(Assets::new().after(lock_time)) + .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, lock_time); + } + + // Locktime greater than required + { + let new_lock_time = 1772167108; + assert!(new_lock_time > lock_time.to_consensus_u32()); + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .add_assets(Assets::new().after(lock_time)) + .locktime(LockTime::from_consensus(new_lock_time)) + .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(), new_lock_time); + } +} + +#[test] +fn test_create_psbt_csv() { + use bitcoin::relative; + use bitcoin::Sequence; + + let desc = get_test_single_sig_csv(); + let mut wallet = Wallet::create_single(desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // Receive coins + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 10_000, + hash: Hash::hash(b"abc"), + }, + confirmation_time: 1234567000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let op = receive_output(&mut wallet, Amount::ONE_BTC, ReceiveTo::Block(anchor)); + + let addr = wallet.reveal_next_address(KeychainKind::External); + + // No assets fail + { + let mut 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_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)); + } +} + +// Test that replacing two unconfirmed txs A, B results in a transaction +// that spends the inputs of both A and B. +#[test] +fn test_replace_by_fee_and_recpients() { + 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 = 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 = 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(4).unwrap(), + vec![(recip, Amount::from_btc(1.99).unwrap())], + ) + .unwrap() + .0; + + // Expect replace inputs of A, B + assert_eq!( + psbt.unsigned_tx.input.len(), + 2, + "We should have selected two inputs" + ); + for op in [op0, op1] { + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|txin| txin.previous_output == op), + "We should have replaced the original spends" + ); + } +} + +#[test] +fn test_create_psbt_utxo_filter() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 1000, + hash: Hash::hash(b"1000"), + }, + confirmation_time: 1234567, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + + for value in [200, 300, 600, 1000] { + let _ = receive_output( + &mut wallet, + Amount::from_sat(value), + ReceiveTo::Block(anchor), + ); + } + assert_eq!(wallet.list_unspent().count(), 4); + assert_eq!(wallet.balance().total().to_sat(), 2100); + + let mut params = PsbtParams::default(); + params.fee_rate(FeeRate::ZERO); + // Avoid selection of dust utxos + params.filter_utxos(|txo| { + let min_non_dust = txo.txout.script_pubkey.minimal_non_dust(); // 330 + txo.txout.value >= min_non_dust + }); + let change_script = ChangeScript::from_descriptor( + wallet + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(), + ); + params.change_script(change_script); + params.drain_wallet(); + let (psbt, _) = wallet.create_psbt(params).unwrap(); + assert_eq!(psbt.unsigned_tx.input.len(), 2); + assert_eq!(psbt.unsigned_tx.output.len(), 1); + assert_eq!( + psbt.unsigned_tx.output[0].value.to_sat(), + 1600, + "We should have selected 2 non-dust utxos" + ); +} + #[test] #[should_panic(expected = "InputIndexOutOfRange")] fn test_psbt_malformed_psbt_input_legacy() { diff --git a/tests/wallet.rs b/tests/wallet.rs index 268c66f8a..6aea7bfc5 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,89 @@ 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(); + let canonical_params = bdk_chain::CanonicalizationParams { + assume_canonical: vec![to_select_op.txid], + }; + params + .canonicalization_params(canonical_params) + .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); + + // Check we can retrieve the unsigned 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 cd57208a49c175b83cdb51e782201332ad8e70da Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 28 Apr 2026 01:20:37 -0400 Subject: [PATCH 05/10] docs: Add example code snippets to `examples` directory - `examples/psbt.rs` - `examples/replace_by_fee.rs` --- Cargo.toml | 6 ++ examples/psbt.rs | 117 +++++++++++++++++++++++++ examples/replace_by_fee.rs | 173 +++++++++++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 examples/psbt.rs create mode 100644 examples/replace_by_fee.rs diff --git a/Cargo.toml b/Cargo.toml index ec2eeacf0..c76d519a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,3 +83,9 @@ name = "esplora_blocking" [[example]] name = "bitcoind_rpc" + +[[example]] +name = "psbt" + +[[example]] +name = "replace_by_fee" diff --git a/examples/psbt.rs b/examples/psbt.rs new file mode 100644 index 000000000..22e22cd0e --- /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)]) + .fee_rate(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/replace_by_fee.rs b/examples/replace_by_fee.rs new file mode 100644 index 000000000..2c2f23b58 --- /dev/null +++ b/examples/replace_by_fee.rs @@ -0,0 +1,173 @@ +#![allow(clippy::print_stdout)] + +use std::str::FromStr; +use std::sync::Arc; + +use bdk_chain::BlockId; +use bdk_tx::ChangeScript; +use bdk_wallet::psbt::PsbtParams; +use bdk_wallet::test_utils::*; +use bdk_wallet::{KeychainKind, Wallet}; +use bitcoin::{bip32, consensus, secp256k1, FeeRate, Transaction}; +use miniscript::{DefiniteDescriptorKey, Descriptor}; + +// This example demonstrates creating a sweep transaction using PsbtParams and replacing it with a +// higher feerate. + +const NETWORK: bitcoin::Network = bitcoin::Network::Regtest; +const XPRIV: &str = "tprv8ZgxMBicQKsPe5tkv8BYJRupCNULhJYDv6qrtVAK9fNVheU6TbscSedVi8KQk8vVZqXMnsGomtVkR4nprbgsxTS5mAQPV4dpPXNvsmYcgZU"; + +fn main() -> anyhow::Result<()> { + let desc = "wpkh([7a5a223e/84'/1'/0']tpubDCpz3tR7UiAy1crSewah3t4kYgcSoBS2bJhGpK8VxrMnv8Ecbmw31DvYwhcsouVpETr8t2NinEyryMQtXbw1ujpQLu6WjHGnhqZRi7tV7pi/0/*)#ls3ewa0d"; + let change_desc = "wpkh([7a5a223e/84'/1'/0']tpubDCpz3tR7UiAy1crSewah3t4kYgcSoBS2bJhGpK8VxrMnv8Ecbmw31DvYwhcsouVpETr8t2NinEyryMQtXbw1ujpQLu6WjHGnhqZRi7tV7pi/1/*)#wy5cngl4"; + let secp = secp256k1::Secp256k1::new(); + + // Xpriv to be used for signing the PSBT + let xprv = bip32::Xpriv::from_str(XPRIV)?; + + // Create wallet and "fund" it with a single UTXO. + let mut wallet = Wallet::create(desc, change_desc) + .network(NETWORK) + .create_wallet_no_persist()?; + + let _funding_tx = fund_wallet(&mut wallet)?; + + // Get a derived descriptor from the wallet to sweep funds to + let derived_descriptor: Descriptor = wallet + .public_descriptor(KeychainKind::External) + .at_derivation_index(1)?; + + println!( + "Wallet funded with {}\n", + wallet.balance().total().display_dynamic() + ); + println!("Creating first sweep transaction (tx1)..."); + + // Create tx1: sweep all funds to our own address at a low feerate + let mut params = PsbtParams::new(); + params + .drain_wallet() + .change_script(ChangeScript::from_descriptor(derived_descriptor.clone())) + .fee_rate(FeeRate::from_sat_per_vb(2).expect("valid feerate")); + + let (mut psbt1, finalizer1) = wallet.create_psbt(params)?; + + // Sign and finalize tx1 + let _ = psbt1.sign(&xprv, &secp); + println!("tx1 signed: {}", !psbt1.inputs[0].partial_sigs.is_empty()); + + let finalize_res = finalizer1.finalize(&mut psbt1); + println!("tx1 finalized: {}", finalize_res.is_finalized()); + + let tx1 = Arc::new(psbt1.extract_tx()?); + let feerate1 = wallet.calculate_fee_rate(&tx1)?; + let fee1 = wallet.calculate_fee(&tx1)?; + + println!(" txid: {}", tx1.compute_txid()); + println!( + " fee rate: {} sat/vb", + bdk_wallet::floating_rate!(feerate1) + ); + println!(" absolute fee: {} sats", fee1.to_sat()); + + // Apply tx1 to wallet as unconfirmed + wallet.apply_unconfirmed_txs([(tx1.clone(), 1234567000)]); + + println!("\nCreating RBF replacement transaction (tx2)..."); + + // Create tx2: Replace tx1 at a higher feerate using PsbtParams + let mut rbf_params = PsbtParams::new().replace_txs(&[Arc::clone(&tx1)]); + + // Set higher feerate for the replacement + rbf_params.fee_rate(FeeRate::from_sat_per_vb(5).expect("valid feerate")); + + // Retain the original sweep destination + rbf_params.change_script(ChangeScript::from_descriptor(derived_descriptor)); + + let (mut psbt2, finalizer2) = wallet.replace_by_fee(rbf_params)?; + + // Sign and finalize tx2 + let _ = psbt2.sign(&xprv, &secp); + println!("tx2 signed: {}", !psbt2.inputs[0].partial_sigs.is_empty()); + + let finalize_res = finalizer2.finalize(&mut psbt2); + println!("tx2 finalized: {}", finalize_res.is_finalized()); + + let tx2 = psbt2.extract_tx()?; + let feerate2 = wallet.calculate_fee_rate(&tx2)?; + let fee2 = wallet.calculate_fee(&tx2)?; + + println!(" txid: {}", tx2.compute_txid()); + println!( + " fee rate: {} sat/vb", + bdk_wallet::floating_rate!(feerate2) + ); + println!(" absolute fee: {} sats", fee2.to_sat()); + + println!("\nVerifying RBF properties..."); + + // Verify that tx1 and tx2 conflict (spend the same input) + let tx1_input = tx1.input[0].previous_output; + let tx2_input = tx2.input[0].previous_output; + + assert_eq!( + tx1_input, tx2_input, + "ERROR: tx1 and tx2 must spend the same input" + ); + println!("✓ Both transactions spend the same input: {}", tx1_input); + + // Verify that tx2 has a higher feerate than tx1 + assert!( + feerate2 > feerate1, + "ERROR: tx2 must have a higher feerate than tx1" + ); + println!( + "✓ Replacement has higher fee rate ({} vs {} sat/vb)", + bdk_wallet::floating_rate!(feerate2), + bdk_wallet::floating_rate!(feerate1) + ); + + // Verify absolute fee increase + assert!(fee2 > fee1, "ERROR: tx2 must have a higher fee than tx1"); + let fee_increase = fee2.to_sat() as i64 - fee1.to_sat() as i64; + println!("✓ Absolute fee increased by {} sats", fee_increase); + + // Apply tx2 to wallet so it recognizes the conflict + wallet.apply_unconfirmed_txs([(tx2.clone(), 1234567001)]); + + // Verify that the wallet recognizes the conflict + let txid2 = tx2.compute_txid(); + assert!( + wallet + .tx_graph() + .direct_conflicts(&tx1) + .any(|(_, txid)| txid == txid2), + "ERROR: Wallet does not recognize tx2 as replacing tx1", + ); + println!("✓ Wallet recognizes the transaction conflict"); + + println!("\n✓✓✓ RBF sweep complete! ✓✓✓"); + + Ok(()) +} + +fn fund_wallet(wallet: &mut Wallet) -> anyhow::Result> { + // First, we need a confirmed coinbase transaction + let coinbase_tx: Transaction = consensus::encode::deserialize_hex( + "020000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff025100ffffffff0200f2052a010000001600144d34238b9c4c59b9e2781e2426a142a75b8901ab0000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf90120000000000000000000000000000000000000000000000000000000000000000000000000", + )?; + + let anchor_block = BlockId { + height: 1, + hash: "3bcc1c447c6b3886f43e416b5c21cf5c139dc4829a71dc78609bc8f6235611c5".parse()?, + }; + let chain_tip = BlockId { + height: anchor_block.height + bitcoin::constants::COINBASE_MATURITY, + hash: "7f96292d115d19450e4bf7d4c4e15c9f3ad21e3a3cf616c498110b988963470b".parse()?, + }; + + insert_tx_anchor(wallet, coinbase_tx.clone(), anchor_block); + insert_checkpoint(wallet, chain_tip); + + Ok(Arc::new(coinbase_tx)) +} From 31d903c85c854f3fe09017400067137a912c748f Mon Sep 17 00:00:00 2001 From: valued mammal Date: Wed, 13 May 2026 15:15:19 -0400 Subject: [PATCH 06/10] fix(wallet): include descendants in RBF OriginalTxStats Previously, `replace_by_fee_with_rng` only built `OriginalTxStats` from `txids_to_replace` (the specified conflicts), ignoring their unconfirmed descendants and undercounting the minimum required fee. Fix by walking descendants of each txid_to_replace and collecting the full set into `to_replace`. `to_replace` is then used in two ways: 1. Filtering UTXOs that must not be selected as new inputs 2. Building the `OriginalTxStats` passed to `RbfParams` Add `test_replace_by_fee_replaces_descendant_fees` to verify the behavior. The test constructs a three-tx chain A -> {B, C} and confirms that replacing A produces a fee >= fee(A) + fee(B) + fee(C). --- src/wallet/mod.rs | 43 ++++++++------- tests/psbt.rs | 131 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 20 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index df50cab51..a8d4af069 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2896,13 +2896,8 @@ impl Wallet { }) } - /// Parses the common parameters used during PSBT creation. - /// - /// ## Returns - /// - /// - Assets - /// - Change script - /// - Indexed wallet txouts + /// Parses the common parameters used during PSBT creation and returns the spend assets + /// and a map of indexed tx outputs. fn parse_params( &self, params: &PsbtParams, @@ -3273,22 +3268,30 @@ impl Wallet { ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { let change_script = self.change_script(params.change_script.take()); + let (assets, txouts) = self.parse_params(¶ms); + let PsbtParams { replace: txids_to_replace, .. } = ¶ms; - // 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.tx_graph - .graph() - .walk_descendants(txid, |_, txid| Some(txid)), - ); - } - let (assets, txouts) = self.parse_params(¶ms); + // Txs and their descendants to be replaced + // + // Here `to_replace` serves a dual purpose: + // + // 1. Filtering outputs that *must not* be selected + // 2. Constructing the OriginalTxStats for RbfParams + let to_replace: HashSet = txids_to_replace + .iter() + .copied() + .flat_map(|txid| { + core::iter::once(txid).chain( + self.tx_graph + .graph() + .walk_descendants(txid, |_, txid| Some(txid)), + ) + }) + .collect(); let must_spend: Vec = params .utxos @@ -3308,7 +3311,7 @@ impl Wallet { self.filter_spendable(txouts.into_values(), ¶ms, |txo| { // To be included for coin selection the UTXO // - must not exist in `to_replace` - // - must be confirmed (per replacement policy Rule 2) + // - must be confirmed per replacement policy Rule 2 (removed in Core v31) // - must pass a user-defined filter !to_replace.contains(&txo.outpoint.txid) && txo.chain_position.is_confirmed() @@ -3331,7 +3334,7 @@ impl Wallet { return Err(CreatePsbtError::InsufficientFunds(err))?; } - let original_txs: Vec = txids_to_replace + let original_txs: Vec = to_replace .iter() .map(|&txid| -> Result<_, ReplaceByFeeError> { let tx = self diff --git a/tests/psbt.rs b/tests/psbt.rs index 8507dbb08..77cc5b4ef 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -486,6 +486,137 @@ fn test_replace_by_fee_and_recpients() { } } +// Test that replacing tx A also accounts for the fees of A's unconfirmed descendants +// B and C when calculating the minimum required replacement fee (RBF Rule 3). +// +// A A' +// / \ +// B C +// +// A' conflicts with A. The replacement fee should exceed +// fee(A) + fee(B) + fee(C). +#[test] +fn test_replace_by_fee_replaces_descendant_fees() { + use KeychainKind::*; + + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let block_id = BlockId { + height: 100, + hash: Hash::hash(b"100"), + }; + + // addr0 receives the confirmed funding; addr1 and addr2 are wallet change + // addresses that tx A pays into so that B and C can spend them. + let addr0 = wallet.reveal_next_address(External).address; + let addr1 = wallet.reveal_next_address(Internal).address; + let addr2 = wallet.reveal_next_address(Internal).address; + + // External (non-wallet) output script used as a sink for recipients. + let external = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + + // Confirmed funding tx: 1_000_000 sats to addr0. + let funding_tx = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(1_000_000), + script_pubkey: addr0.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx_anchor(&mut wallet, funding_tx.clone(), block_id); + + // Tx A (unconfirmed): spends the confirmed UTXO; two outputs return to wallet. + // fee_a = 1_000_000 - 50_000 - 450_000 - 450_000 = 50_000 sats + let tx_a = Transaction { + input: vec![TxIn { + previous_output: funding_op, + ..TxIn::default() + }], + output: vec![ + TxOut { + value: Amount::from_sat(50_000), + script_pubkey: external.clone(), + }, + TxOut { + value: Amount::from_sat(450_000), + script_pubkey: addr1.script_pubkey(), + }, + TxOut { + value: Amount::from_sat(450_000), + script_pubkey: addr2.script_pubkey(), + }, + ], + ..new_tx(0) + }; + let a_txid = tx_a.compute_txid(); + let fee_a = wallet.calculate_fee(&tx_a).unwrap(); + insert_tx(&mut wallet, tx_a.clone()); + + // Tx B (unconfirmed): spends A's first change output. + // fee_b = 450_000 - 430_000 = 20_000 sats + let tx_b = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(a_txid, 1), + ..TxIn::default() + }], + output: vec![TxOut { + value: Amount::from_sat(430_000), + script_pubkey: external.clone(), + }], + ..new_tx(0) + }; + let fee_b = wallet.calculate_fee(&tx_b).unwrap(); + insert_tx(&mut wallet, tx_b); + + // Tx C (unconfirmed): spends A's second change output. + // fee_c = 450_000 - 430_000 = 20_000 sats + let tx_c = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(a_txid, 2), + ..TxIn::default() + }], + output: vec![TxOut { + value: Amount::from_sat(430_000), + script_pubkey: external.clone(), + }], + ..new_tx(0) + }; + let fee_c = wallet.calculate_fee(&tx_c).unwrap(); + insert_tx(&mut wallet, tx_c.clone()); + + // The replacement must pay at least the combined fee of all three transactions + // (Bitcoin Core RBF Rule 3). + let total_original_fee = fee_a + fee_b + fee_c; + assert_eq!(total_original_fee.to_sat(), 90_000); + + // Build replacement A'. The wallet walks A's descendants (B and C) so their + // fees are included in the minimum required replacement fee. + let (psbt, _) = wallet + .replace_by_fee_and_recipients( + &[Arc::new(tx_a)], + FeeRate::from_sat_per_vb(4).unwrap(), + vec![(external, Amount::from_sat(100_000))], + ) + .expect("should create replacement psbt"); + + let replacement_fee = wallet + .calculate_fee(&psbt.unsigned_tx) + .expect("replacement tx fee should be calculable"); + + assert!( + replacement_fee >= total_original_fee, + "replacement fee ({replacement_fee}) must be >= sum of fees for A + B + C ({total_original_fee})", + ); +} + #[test] fn test_create_psbt_utxo_filter() { let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); From 5ac2603787abbdc4aa08b061df953e7354359632 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Wed, 13 May 2026 15:50:28 -0400 Subject: [PATCH 07/10] fix: `replace_by_fee` rejects confirmed original txs If one of the txids-to-replace is already confirmed, then attempting to replace it will result in an invalid RBF transaction. Add `ReplaceByFeeError::TransactionConfirmed` variant to handle this case. This mirrors the behavior of `build_fee_bump` by explicitly checking the confirmation status of the original txs and refusing to build the PSBT if any are confirmed. --- src/wallet/error.rs | 5 ++++ src/wallet/mod.rs | 18 +++++++++++++ tests/psbt.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/src/wallet/error.rs b/src/wallet/error.rs index 60ec1e07f..c7c42b565 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -417,6 +417,8 @@ pub enum ReplaceByFeeError { PreviousFee(bdk_chain::tx_graph::CalculateFeeError), /// Original transaction could not be found MissingTransaction(Txid), + /// One of the transactions to be replaced is already confirmed + TransactionConfirmed(Txid), } impl fmt::Display for ReplaceByFeeError { @@ -425,6 +427,9 @@ impl fmt::Display for ReplaceByFeeError { Self::CreatePsbt(e) => write!(f, "{e}"), Self::PreviousFee(e) => write!(f, "{e}"), Self::MissingTransaction(txid) => write!(f, "missing transaction: {txid}"), + Self::TransactionConfirmed(txid) => { + write!(f, "transaction already confirmed: {txid}") + } } } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index a8d4af069..c5404882a 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3240,6 +3240,7 @@ impl Wallet { /// /// A [`ReplaceByFeeError`] will be thrown if any of the following occurs /// + /// - An original transaction is already confirmed /// - An original transaction is missing from the wallet /// - Failure to calculate the [fee](Wallet::calculate_fee) of an original transaction /// - Failure to complete coin selection @@ -3275,6 +3276,23 @@ impl Wallet { .. } = ¶ms; + // None of the txids-to-replace may already be confirmed + let chain_tip = self.chain.tip().block_id(); + let chain_positions: HashMap> = self + .tx_graph + .graph() + .list_canonical_txs(&self.chain, chain_tip, CanonicalizationParams::default()) + .map(|canonical_tx| (canonical_tx.tx_node.txid, canonical_tx.chain_position)) + .collect(); + for &txid in txids_to_replace.iter() { + if chain_positions + .get(&txid) + .is_some_and(|chain_position| chain_position.is_confirmed()) + { + return Err(ReplaceByFeeError::TransactionConfirmed(txid)); + } + } + // Txs and their descendants to be replaced // // Here `to_replace` serves a dual purpose: diff --git a/tests/psbt.rs b/tests/psbt.rs index 77cc5b4ef..206ad8fcb 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -617,6 +617,69 @@ fn test_replace_by_fee_replaces_descendant_fees() { ); } +// Test that `replace_by_fee`` rejects a confirmed original tx +#[test] +fn test_replace_by_fee_confirmed_tx_error() { + use bdk_wallet::error::ReplaceByFeeError; + use KeychainKind::*; + + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let block = BlockId { + height: 100, + hash: Hash::hash(b"100"), + }; + let addr = wallet.reveal_next_address(External).address; + + // Fund the wallet with a confirmed output. + let funding_tx = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(200_000), + script_pubkey: addr.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx_anchor(&mut wallet, funding_tx, block); + + // Create an unconfirmed tx spending the confirmed UTXO. + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + let mut params = PsbtParams::default(); + params + .add_utxos(&[funding_op]) + .add_recipients([(recip, Amount::from_sat(100_000))]); + let unconfirmed_tx = wallet.create_psbt(params).unwrap().0.unsigned_tx; + insert_tx(&mut wallet, unconfirmed_tx.clone()); + + // Now confirm that tx. + let confirmed_txid = unconfirmed_tx.compute_txid(); + let confirm_block = BlockId { + height: 1001, + hash: Hash::hash(b"1001"), + }; + insert_tx_anchor(&mut wallet, unconfirmed_tx.clone(), confirm_block); + insert_checkpoint(&mut wallet, confirm_block); + + // Attempting to replace the now-confirmed tx should return TransactionConfirmed. + let result = wallet.replace_by_fee_and_recipients( + &[Arc::new(unconfirmed_tx)], + FeeRate::from_sat_per_vb(10).unwrap(), + vec![], + ); + + assert!( + matches!(result, Err(ReplaceByFeeError::TransactionConfirmed(txid)) if txid == confirmed_txid), + "expected TransactionConfirmed error, got: {result:?}", + ); +} + #[test] fn test_create_psbt_utxo_filter() { let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); From 3154047a77200612af6212619440e7d121968a6e Mon Sep 17 00:00:00 2001 From: valued mammal Date: Wed, 13 May 2026 17:05:51 -0400 Subject: [PATCH 08/10] fix(wallet): Reveal change address on create_psbt/replace_by_fee success Replace `change_script` helper with `peek_change_info`, which picks the next unused address without mutating state. Change methods `create_psbt`, `create_psbt_with_rng`, `replace_by_fee`, `replace_by_fee_and_recipients`, and `replace_by_fee_with_rng` to accept `&mut self`. Once `create_psbt_from_selector` succeeds, check whether the peeked change SPK appears in the PSBT outputs, and if so, proceed to `reveal_to_target` and stage the resulting changeset. Do not call `mark_used` internally to prevent eagerly incrementing the internal derivation index. The change address is reused on subsequent calls unless a sync detects the change address as used; Users who need to prevent this should supply the intended `change_script` directly or call `mark_used` on the internal address index. This is documented in a new `# Change address` section on the public `create_psbt` and `replace_by_fee` docs. --- src/psbt/params.rs | 2 +- src/wallet/mod.rs | 150 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 125 insertions(+), 27 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 8548914e4..92440b201 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -554,7 +554,7 @@ mod test { #[test] fn test_replace_params() { use crate::KeychainKind::Internal; - let (wallet, txid0) = crate::test_utils::get_funded_wallet_wpkh(); + let (mut wallet, txid0) = crate::test_utils::get_funded_wallet_wpkh(); let outpoint_0 = OutPoint::new(txid0, 0); let change_descriptor = wallet .public_descriptor(Internal) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index c5404882a..7f5b7c097 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2883,17 +2883,47 @@ impl Wallet { Assets::new().add(pks) } - /// Obtain the next change script (descriptor) from the wallet. - fn change_script(&self, change_script: Option) -> ChangeScript { - change_script.unwrap_or_else(|| { - let change_keychain = self.map_keychain(KeychainKind::Internal); - let descriptor = self.public_descriptor(change_keychain); - let next_index = self.next_derivation_index(change_keychain); - let definite_descriptor = descriptor - .at_derivation_index(next_index) - .expect("should be valid derivation index"); - ChangeScript::from_descriptor(definite_descriptor) - }) + /// Peek at the next change address without revealing it, returning the auto-derived + /// change info `(keychain, index, spk)` alongside the [`ChangeScript`]. + /// + /// When the caller supplies a [`ChangeScript`] via `override_script`, it is passed through + /// unchanged and `None` is returned for the change info (no address needs to be revealed). + /// + /// Otherwise we select the next unused address (already-revealed-but-unused first, then the + /// next-to-be-revealed index) **without** mutating wallet state. Revelation is deferred to + /// after all error paths have been cleared by the caller. + fn peek_change_info( + &self, + override_script: Option, + ) -> (Option<(KeychainKind, u32, ScriptBuf)>, ChangeScript) { + match override_script { + Some(cs) => (None, cs), + None => { + let change_keychain = self.map_keychain(KeychainKind::Internal); + let (index, spk) = self + .tx_graph + .index + .unused_keychain_spks(change_keychain) + .next() + .unwrap_or_else(|| { + let (next_index, _) = self + .tx_graph + .index + .next_index(change_keychain) + .expect("keychain must exist"); + let spk = self + .peek_address(change_keychain, next_index) + .script_pubkey(); + (next_index, spk) + }); + let descriptor = self.public_descriptor(change_keychain); + let definite_descriptor = descriptor + .at_derivation_index(index) + .expect("should be valid derivation index"); + let change_script = ChangeScript::from_descriptor(definite_descriptor); + (Some((change_keychain, index, spk)), change_script) + } + } } /// Parses the common parameters used during PSBT creation and returns the spend assets @@ -2998,7 +3028,7 @@ impl Wallet { /// # use std::str::FromStr; /// # use bitcoin::{Amount, Address, FeeRate, OutPoint}; /// # use bdk_wallet::psbt::{PsbtParams, SelectionStrategy}; - /// # let wallet = bdk_wallet::doctest_wallet!(); + /// # let mut wallet = bdk_wallet::doctest_wallet!(); /// # let outpoint = OutPoint::null(); /// # let address = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5").unwrap().assume_checked(); /// # let amount = Amount::ZERO; @@ -3021,10 +3051,22 @@ impl Wallet { /// - The input value is insufficient to fund the outputs /// - Failure to complete coin selection /// - Failure to create or update the PSBT. + /// + /// # Change address + /// + /// When no [`ChangeScript`] is supplied via [`PsbtParams`], the wallet automatically selects + /// the next unused internal address and reveals it so that incoming change is tracked on + /// the next sync. The change address will not be marked used, so calling `create_psbt` + /// again before syncing will return the same change address. If you intend to build + /// multiple transactions without syncing between them, either provide the change script in + /// the [`PsbtParams`], or call [`Wallet::mark_used`] after each call to prevent reuse. + /// + /// **You must persist the change set staged as a result of this call.** + /// See [`Wallet::take_staged`]. #[cfg(feature = "std")] #[cfg_attr(docsrs, doc(cfg(feature = "std")))] pub fn create_psbt( - &self, + &mut self, params: PsbtParams, ) -> Result<(Psbt, Finalizer), CreatePsbtError> { self.create_psbt_with_rng(params, &mut rand::thread_rng()) @@ -3039,15 +3081,17 @@ impl Wallet { /// - `params`: [`PsbtParams`] /// - `rng`: Source of entropy, may be used during coin selection and to sort inputs and outputs /// by the [`TxOrdering`](crate::wallet::tx_builder::TxOrdering). + /// + /// See [`Wallet::create_psbt`] for notes on change address handling. + /// + /// **You must persist the change set staged as a result of this call.** + /// See [`Wallet::take_staged`]. pub fn create_psbt_with_rng( - &self, + &mut self, mut params: PsbtParams, rng: &mut impl RngCore, ) -> Result<(Psbt, Finalizer), CreatePsbtError> { - // Get change script. - // This is currently awkward as ChangeScript is not yet Clone, so here we take - // the optional value directly from the params. - let change_script = self.change_script(params.change_script.take()); + let (change_info, change_script) = self.peek_change_info(params.change_script.take()); let (assets, txouts) = self.parse_params(¶ms); @@ -3092,7 +3136,25 @@ impl Wallet { ) .map_err(CreatePsbtError::Selector)?; - self.create_psbt_from_selector(&mut selector, ¶ms, rng) + let (psbt, finalizer) = self.create_psbt_from_selector(&mut selector, ¶ms, rng)?; + + // Reveal the auto-selected change address. + if let Some((keychain, index, spk)) = change_info { + if psbt + .unsigned_tx + .output + .iter() + .any(|txo| txo.script_pubkey == spk) + { + if let Some((_, index_changeset)) = + self.tx_graph.index.reveal_to_target(keychain, index) + { + self.stage.merge(index_changeset.into()); + } + } + } + + Ok((psbt, finalizer)) } /// Create the PSBT from [`Selector`] and `params`. @@ -3200,7 +3262,7 @@ impl Wallet { /// # use bitcoin::FeeRate; /// # use bdk_wallet::psbt::{PsbtParams, SelectionStrategy}; /// # use bdk_wallet::test_utils; - /// # let wallet = bdk_wallet::doctest_wallet!(); + /// # let mut 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`. @@ -3216,7 +3278,7 @@ impl Wallet { #[cfg(feature = "std")] #[cfg_attr(docsrs, doc(cfg(feature = "std")))] pub fn replace_by_fee_and_recipients( - &self, + &mut self, txs: &[Arc], fee_rate: FeeRate, recipients: Vec<(ScriptBuf, Amount)>, @@ -3245,10 +3307,22 @@ impl Wallet { /// - Failure to calculate the [fee](Wallet::calculate_fee) of an original transaction /// - Failure to complete coin selection /// - Failure to create or update the PSBT. + /// + /// # Change address + /// + /// When no [`ChangeScript`] is supplied via [`PsbtParams`], the wallet automatically selects + /// the next unused internal address and reveals it so that incoming change is tracked on + /// the next sync. The change address will not be marked used, so calling `create_psbt` + /// again before syncing will return the same change address. If you intend to build + /// multiple transactions without syncing between them, either provide the change script in + /// the [`PsbtParams`], or call [`Wallet::mark_used`] after each call to prevent reuse. + /// + /// **You must persist the change set staged as a result of this call.** + /// See [`Wallet::take_staged`]. #[cfg(feature = "std")] #[cfg_attr(docsrs, doc(cfg(feature = "std")))] pub fn replace_by_fee( - &self, + &mut self, params: PsbtParams, ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { self.replace_by_fee_with_rng(params, &mut rand::thread_rng()) @@ -3262,12 +3336,17 @@ impl Wallet { /// - `params`: [`PsbtParams`] /// - `rng`: Source of entropy, may be used during coin selection and to sort inputs and outputs /// by the [`TxOrdering`](crate::wallet::tx_builder::TxOrdering). + /// + /// See [`Wallet::replace_by_fee`] for notes on change address handling. + /// + /// **You must persist the change set staged as a result of this call.** + /// See [`Wallet::take_staged`]. pub fn replace_by_fee_with_rng( - &self, + &mut self, mut params: PsbtParams, rng: &mut impl RngCore, ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { - let change_script = self.change_script(params.change_script.take()); + let (change_info, change_script) = self.peek_change_info(params.change_script.take()); let (assets, txouts) = self.parse_params(¶ms); @@ -3384,8 +3463,27 @@ impl Wallet { ) .map_err(CreatePsbtError::Selector)?; - self.create_psbt_from_selector(&mut selector, ¶ms, rng) - .map_err(ReplaceByFeeError::CreatePsbt) + let (psbt, finalizer) = self + .create_psbt_from_selector(&mut selector, ¶ms, rng) + .map_err(ReplaceByFeeError::CreatePsbt)?; + + // Reveal the auto-selected change address + if let Some((keychain, index, spk)) = change_info { + if psbt + .unsigned_tx + .output + .iter() + .any(|txo| txo.script_pubkey == spk) + { + if let Some((_, index_changeset)) = + self.tx_graph.index.reveal_to_target(keychain, index) + { + self.stage.merge(index_changeset.into()); + } + } + } + + Ok((psbt, finalizer)) } /// Plan the output with the available assets and return a new [`Input`] if possible. See also From fd69788bc7697710161a92e7c6a0ad59cf900542 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Wed, 13 May 2026 17:10:13 -0400 Subject: [PATCH 09/10] fix(params): Remove planned inputs in PsbtParams::remove_utxo --- src/psbt/params.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 92440b201..1991cad27 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -194,6 +194,8 @@ impl PsbtParams { pub fn remove_utxo(&mut self, outpoint: &OutPoint) -> &mut Self { if self.set.remove(outpoint) { self.utxos.retain(|op| op != outpoint); + self.inputs + .retain(|input| input.prev_outpoint() != *outpoint); } self } From f84c9dcd99dc3937d77e2f89879da15c10e1f8f8 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Wed, 13 May 2026 17:42:32 -0400 Subject: [PATCH 10/10] docs: Show signing process using bdk_tx::Signer --- examples/psbt.rs | 23 +++++++++------- examples/replace_by_fee.rs | 54 +++++++++++++++++++++++--------------- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/examples/psbt.rs b/examples/psbt.rs index 22e22cd0e..7e7ebcd7d 100644 --- a/examples/psbt.rs +++ b/examples/psbt.rs @@ -8,11 +8,7 @@ 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 bitcoin::{consensus, secp256k1::rand, Address, Amount, TxIn, TxOut}; use rand::Rng; // This example shows how to create a PSBT using BDK Wallet. @@ -24,10 +20,6 @@ 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) @@ -36,6 +28,14 @@ fn main() -> anyhow::Result<()> { fund_wallet(&mut wallet)?; + // Create PSBT Signer, external to the wallet + let signer = { + let secp = wallet.secp_ctx(); + let (_, external_keymap) = miniscript::Descriptor::parse_descriptor(secp, desc)?; + let (_, internal_keymap) = miniscript::Descriptor::parse_descriptor(secp, change_desc)?; + bdk_tx::Signer(external_keymap.into_iter().chain(internal_keymap).collect()) + }; + let utxos = wallet .list_unspent() .map(|output| (output.outpoint, output)) @@ -65,7 +65,10 @@ fn main() -> anyhow::Result<()> { println!("TxOut: {}", txout.value); } - let _ = psbt.sign(&xprv, &secp); + let _ = psbt + .sign(&signer, wallet.secp_ctx()) + .map_err(|(_, errors)| anyhow::anyhow!("failed to sign PSBT: {errors:?}"))?; + println!("Signed: {}", !psbt.inputs[0].partial_sigs.is_empty()); let finalize_res = finalizer.finalize(&mut psbt); println!("Finalized: {}", finalize_res.is_finalized()); diff --git a/examples/replace_by_fee.rs b/examples/replace_by_fee.rs index 2c2f23b58..1263423b5 100644 --- a/examples/replace_by_fee.rs +++ b/examples/replace_by_fee.rs @@ -1,6 +1,5 @@ #![allow(clippy::print_stdout)] -use std::str::FromStr; use std::sync::Arc; use bdk_chain::BlockId; @@ -8,29 +7,31 @@ use bdk_tx::ChangeScript; use bdk_wallet::psbt::PsbtParams; use bdk_wallet::test_utils::*; use bdk_wallet::{KeychainKind, Wallet}; -use bitcoin::{bip32, consensus, secp256k1, FeeRate, Transaction}; +use bitcoin::{Amount, FeeRate, TxIn, TxOut}; use miniscript::{DefiniteDescriptorKey, Descriptor}; // This example demonstrates creating a sweep transaction using PsbtParams and replacing it with a // higher feerate. const NETWORK: bitcoin::Network = bitcoin::Network::Regtest; -const XPRIV: &str = "tprv8ZgxMBicQKsPe5tkv8BYJRupCNULhJYDv6qrtVAK9fNVheU6TbscSedVi8KQk8vVZqXMnsGomtVkR4nprbgsxTS5mAQPV4dpPXNvsmYcgZU"; fn main() -> anyhow::Result<()> { - let desc = "wpkh([7a5a223e/84'/1'/0']tpubDCpz3tR7UiAy1crSewah3t4kYgcSoBS2bJhGpK8VxrMnv8Ecbmw31DvYwhcsouVpETr8t2NinEyryMQtXbw1ujpQLu6WjHGnhqZRi7tV7pi/0/*)#ls3ewa0d"; - let change_desc = "wpkh([7a5a223e/84'/1'/0']tpubDCpz3tR7UiAy1crSewah3t4kYgcSoBS2bJhGpK8VxrMnv8Ecbmw31DvYwhcsouVpETr8t2NinEyryMQtXbw1ujpQLu6WjHGnhqZRi7tV7pi/1/*)#wy5cngl4"; - let secp = secp256k1::Secp256k1::new(); - - // Xpriv to be used for signing the PSBT - let xprv = bip32::Xpriv::from_str(XPRIV)?; + let (desc, change_desc) = get_test_wpkh_and_change_desc(); // Create wallet and "fund" it with a single UTXO. let mut wallet = Wallet::create(desc, change_desc) .network(NETWORK) .create_wallet_no_persist()?; - let _funding_tx = fund_wallet(&mut wallet)?; + fund_wallet(&mut wallet)?; + + // Create PSBT Signer, external to the wallet + let signer = { + let secp = wallet.secp_ctx(); + let (_, external_keymap) = miniscript::Descriptor::parse_descriptor(secp, desc)?; + let (_, internal_keymap) = miniscript::Descriptor::parse_descriptor(secp, change_desc)?; + bdk_tx::Signer(external_keymap.into_iter().chain(internal_keymap).collect()) + }; // Get a derived descriptor from the wallet to sweep funds to let derived_descriptor: Descriptor = wallet @@ -53,7 +54,9 @@ fn main() -> anyhow::Result<()> { let (mut psbt1, finalizer1) = wallet.create_psbt(params)?; // Sign and finalize tx1 - let _ = psbt1.sign(&xprv, &secp); + let _ = psbt1 + .sign(&signer, wallet.secp_ctx()) + .map_err(|(_, errors)| anyhow::anyhow!("failed to sign PSBT: {errors:?}"))?; println!("tx1 signed: {}", !psbt1.inputs[0].partial_sigs.is_empty()); let finalize_res = finalizer1.finalize(&mut psbt1); @@ -87,7 +90,9 @@ fn main() -> anyhow::Result<()> { let (mut psbt2, finalizer2) = wallet.replace_by_fee(rbf_params)?; // Sign and finalize tx2 - let _ = psbt2.sign(&xprv, &secp); + let _ = psbt2 + .sign(&signer, wallet.secp_ctx()) + .map_err(|(_, errors)| anyhow::anyhow!("failed to sign PSBT: {errors:?}"))?; println!("tx2 signed: {}", !psbt2.inputs[0].partial_sigs.is_empty()); let finalize_res = finalizer2.finalize(&mut psbt2); @@ -151,23 +156,30 @@ fn main() -> anyhow::Result<()> { Ok(()) } -fn fund_wallet(wallet: &mut Wallet) -> anyhow::Result> { - // First, we need a confirmed coinbase transaction - let coinbase_tx: Transaction = consensus::encode::deserialize_hex( - "020000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff025100ffffffff0200f2052a010000001600144d34238b9c4c59b9e2781e2426a142a75b8901ab0000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf90120000000000000000000000000000000000000000000000000000000000000000000000000", - )?; - +fn fund_wallet(wallet: &mut Wallet) -> anyhow::Result<()> { let anchor_block = BlockId { height: 1, hash: "3bcc1c447c6b3886f43e416b5c21cf5c139dc4829a71dc78609bc8f6235611c5".parse()?, }; let chain_tip = BlockId { - height: anchor_block.height + bitcoin::constants::COINBASE_MATURITY, + height: 101, hash: "7f96292d115d19450e4bf7d4c4e15c9f3ad21e3a3cf616c498110b988963470b".parse()?, }; - insert_tx_anchor(wallet, coinbase_tx.clone(), anchor_block); + insert_checkpoint(wallet, anchor_block); + + let addr = wallet.reveal_next_address(KeychainKind::External).address; + let tx = bitcoin::Transaction { + lock_time: bitcoin::absolute::LockTime::ZERO, + version: bitcoin::transaction::Version::TWO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: addr.script_pubkey(), + value: Amount::from_sat(50_000_000), + }], + }; + insert_tx_anchor(wallet, tx, anchor_block); insert_checkpoint(wallet, chain_tip); - Ok(Arc::new(coinbase_tx)) + Ok(()) }