From a874f32743d65e5dae495c27dadff6f84b571e61 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 16 Jul 2026 08:37:03 -0400 Subject: [PATCH 01/19] 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. --- src/wallet/tx_builder.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wallet/tx_builder.rs b/src/wallet/tx_builder.rs index ec50be9a..ca4a7f5c 100644 --- a/src/wallet/tx_builder.rs +++ b/src/wallet/tx_builder.rs @@ -828,7 +828,7 @@ type TxSort = dyn (Fn(&T, &T) -> core::cmp::Ordering) + Send + Sync; /// Ordering of the transaction's inputs and outputs #[derive(Clone, Default)] -pub enum TxOrdering { +pub enum TxOrdering { /// Randomized (default) #[default] Shuffle, @@ -843,13 +843,13 @@ pub enum TxOrdering { /// Provide custom comparison functions for sorting Custom { /// Transaction inputs sort function - input_sort: Arc>, + input_sort: Arc>, /// Transaction outputs sort function - output_sort: Arc>, + output_sort: Arc>, }, } -impl core::fmt::Debug for TxOrdering { +impl core::fmt::Debug for TxOrdering { fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { match self { TxOrdering::Shuffle => write!(f, "Shuffle"), From 53edea14e87a1927dc8171c44d7f4cc40b4e25ec Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 16 Jul 2026 08:37:09 -0400 Subject: [PATCH 02/19] 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 `bdk_tx` 0.2.0 to Cargo.toml --- Cargo.toml | 4 +- src/lib.rs | 1 + src/psbt/mod.rs | 5 + src/psbt/params.rs | 665 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 src/psbt/params.rs diff --git a/Cargo.toml b/Cargo.toml index 00c0c21e..ac2b0c99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [dependencies] bdk_chain = { version = "0.23.3", features = ["miniscript", "serde"], default-features = false } +bdk_tx = { version = "0.2.0", default-features = false } bitcoin = { version = "0.32.8", features = ["serde", "base64"], default-features = false } miniscript = { version = "12.3.5", features = ["serde"], default-features = false } rand_core = { version = "0.6.4" } @@ -33,9 +34,10 @@ bdk_file_store = { version = "0.22.0", optional = true } bip39 = { version = "2.2.2", optional = true } tempfile = { version = "3.26.0", optional = true } + [features] default = ["std"] -std = ["bitcoin/std", "bitcoin/rand-std", "miniscript/std", "bdk_chain/std"] +std = ["bitcoin/std", "bitcoin/rand-std", "miniscript/std", "bdk_chain/std", "bdk_tx/std"] compiler = ["miniscript/compiler"] all-keys = ["keys-bip39"] keys-bip39 = ["bip39"] diff --git a/src/lib.rs b/src/lib.rs index ecc15b85..a19da8a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,7 @@ pub use bdk_chain::rusqlite; pub use bdk_chain::rusqlite_impl; pub use descriptor::template; pub use descriptor::HdKeyPaths; +pub use psbt::*; pub use signer; pub use signer::SignOptions; pub use tx_builder::*; diff --git a/src/psbt/mod.rs b/src/psbt/mod.rs index 3655d9e4..2063ab9f 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 00000000..cd17dfc2 --- /dev/null +++ b/src/psbt/params.rs @@ -0,0 +1,665 @@ +//! 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, transaction::Version, Amount, FeeRate, OutPoint, ScriptBuf, Sequence, Transaction, + Txid, +}; +use miniscript::plan::Assets; + +use crate::collections::{HashMap, 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, + /// Minimum transaction locktime — a floor on the resulting `tx.lock_time`. + pub(crate) min_locktime: Option, + /// Optional height for BIP326 anti-fee sniping. + pub(crate) anti_fee_sniping: 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, + /// Whether to try filling in the PSBT global xpubs from the wallet's descriptors. + pub(crate) add_global_xpubs: bool, + /// Set of txids being replaced if this is a RBF transaction. + pub(crate) replace: HashSet, + /// Per-input sequence overrides keyed by outpoint. + /// + /// Only applies to inputs added via [`PsbtParams::add_utxos`]. Takes precedence over + /// [`fallback_sequence`](Self::fallback_sequence). + pub(crate) sequence_overrides: HashMap, + /// Fallback sequence applied to wallet-managed inputs that have no per-input override and + /// no CSV-derived sequence requirement. + pub(crate) fallback_sequence: Option, + /// 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(), + min_locktime: Default::default(), + anti_fee_sniping: Default::default(), + ordering: Default::default(), + only_witness_utxo: Default::default(), + add_global_xpubs: Default::default(), + replace: Default::default(), + sequence_overrides: Default::default(), + fallback_sequence: 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. + /// + /// # Panics + /// + /// Panics if `txs` is empty. + pub fn replace_txs(self, txs: &[Arc]) -> PsbtParams { + assert!( + !txs.is_empty(), + "replace_txs requires at least one transaction" + ); + 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, + min_locktime: self.min_locktime, + anti_fee_sniping: self.anti_fee_sniping, + ordering: self.ordering, + only_witness_utxo: self.only_witness_utxo, + add_global_xpubs: self.add_global_xpubs, + replace: self.replace, + sequence_overrides: self.sequence_overrides, + fallback_sequence: self.fallback_sequence, + 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.inputs + .retain(|input| input.prev_outpoint() != *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 is a floor on the transaction's `lock_time`. The final `lock_time` will be the + /// maximum of this value and any absolute locktime required by an input's CLTV, provided + /// the units (block height vs. timestamp) are compatible. If no minimum is specified here, + /// `lock_time` defaults to zero unless raised by CLTV requirements or the + /// [`anti_fee_sniping_height`]. + /// + /// [`anti_fee_sniping_height`]: Self::anti_fee_sniping_height + pub fn locktime(&mut self, locktime: absolute::LockTime) -> &mut Self { + self.min_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, + /// None, + /// )?; + /// 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 + } + + /// 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 + } + + /// Enable [`anti_fee_sniping`] using the given chain-tip height. + /// + /// 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. See for more. + /// + /// [`Wallet::create_psbt`]: crate::Wallet::create_psbt + /// [`anti_fee_sniping`]: bdk_tx::PsbtParams::anti_fee_sniping + pub fn anti_fee_sniping_height(&mut self, tip_height: absolute::Height) -> &mut Self { + self.anti_fee_sniping = Some(tip_height); + self + } + + /// Override the sequence for a specific manually-selected input. + /// + /// Only applies to outpoints added via [`add_utxos`]. Validated at PSBT construction time: + /// if the input has a CSV requirement the override must satisfy it, and if the input + /// requires CLTV the override must not be [`Sequence::MAX`]. + /// + /// Takes precedence over [`fallback_sequence`]. + /// + /// [`add_utxos`]: PsbtParams::add_utxos + /// [`fallback_sequence`]: PsbtParams::fallback_sequence + pub fn sequence_override(&mut self, outpoint: OutPoint, sequence: Sequence) -> &mut Self { + self.sequence_overrides.insert(outpoint, sequence); + self + } + + /// Set a fallback sequence for wallet-managed inputs. + /// + /// Applied to every input sourced from [`add_utxos`] or auto-selected by coin selection + /// that has no per-input [`sequence_override`] and no CSV requirement. Inputs with a + /// relative timelock (OP_CSV) keep their plan-derived sequence. + /// + /// [`add_utxos`]: PsbtParams::add_utxos + /// [`sequence_override`]: PsbtParams::sequence_override + pub fn fallback_sequence(&mut self, sequence: Sequence) -> &mut Self { + self.fallback_sequence = Some(sequence); + 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 92be7eaf53a37b8a91dd0786f770b267189662b4 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 16 Jul 2026 08:37:10 -0400 Subject: [PATCH 03/19] 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 | 93 +++++ src/wallet/mod.rs | 867 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 946 insertions(+), 15 deletions(-) diff --git a/src/psbt/mod.rs b/src/psbt/mod.rs index 2063ab9f..dfc0b016 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 ddd07478..6a1b4574 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,95 @@ 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), + /// Failed to set the sequence on an input. + Sequence(bdk_tx::SetSequenceError), +} + +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}"), + Self::Sequence(e) => write!(f, "invalid sequence: {e}"), + } + } +} + +impl core::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), + /// One of the transactions to be replaced is already confirmed + TransactionConfirmed(Txid), + /// The replacement transaction has no inputs from the replaced transaction. + /// + /// A replacement must spend at least one of the same inputs as the transaction it replaces, + /// since two transactions cannot spend the same UTXO. This error is returned when + /// [`PsbtParams::remove_utxo`] has been used to remove all original inputs belonging to + /// the given replaced transaction. + /// + /// [`PsbtParams::remove_utxo`]: crate::psbt::PsbtParams::remove_utxo + NoInputsFromOriginal(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}"), + Self::TransactionConfirmed(txid) => { + write!(f, "transaction already confirmed: {txid}") + } + Self::NoInputsFromOriginal(txid) => { + write!( + f, + "replacement has no inputs from replaced transaction: {txid}" + ) + } + } + } +} + +impl core::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 5bd42ba7..17a91c7b 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,13 +79,15 @@ use crate::descriptor::{ policy::BuildSatisfaction, 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}, + tx_builder::{FeePolicy, TxBuilder, TxOrdering, 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)) } @@ -2608,6 +2644,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. @@ -2895,6 +2961,779 @@ impl Wallet { } } +/// Maps a chain position to tx confirmation status, if `pos` is the confirmed +/// variant. +/// +/// - Returns None if the confirmation height or time is not a valid absolute [`Height`] or +/// [`Time`]. +/// +/// [`Height`]: bitcoin::absolute::Height +/// [`Time`]: bitcoin::absolute::Time +fn status_from_position(pos: ChainPosition) -> Option { + if let ChainPosition::Confirmed { anchor, .. } = pos { + let conf_height = anchor.confirmation_height_upper_bound(); + let height = absolute::Height::from_consensus(conf_height).ok()?; + // TODO: Currently BDK has no notion of MTP, we can use the confirmation block time for now. + let time = + absolute::Time::from_consensus(anchor.confirmation_time.try_into().ok()?).ok()?; + Some(ConfirmationStatus { + height, + prev_mtp: Some(time), + }) + } else { + None + } +} + +impl Wallet { + /// Return the "keys" assets, i.e. the ones we can trivially infer by scanning + /// the pubkeys of the wallet's descriptors. + fn assets(&self) -> Assets { + let mut pks = vec![]; + for (_, desc) in self.keychains() { + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + } + + Assets::new().add(pks) + } + + /// Peek at the next change address without revealing it, returning the auto-derived + /// change info `(keychain, index, spk)` alongside the [`ChangeScript`]. + /// + /// When the caller supplies a [`ChangeScript`] via `override_script`, it is passed through + /// unchanged and `None` is returned for the change info (no address needs to be revealed). + /// + /// Otherwise we select the next unused address (already-revealed-but-unused first, then the + /// next-to-be-revealed index) **without** mutating wallet state. Revelation is deferred to + /// after all error paths have been cleared by the caller. + fn peek_change_info( + &self, + override_script: Option, + ) -> (Option<(KeychainKind, u32, ScriptBuf)>, ChangeScript) { + match override_script { + Some(cs) => (None, cs), + None => { + let change_keychain = self.map_keychain(KeychainKind::Internal); + let (index, spk) = self + .tx_graph + .index + .unused_keychain_spks(change_keychain) + .next() + .unwrap_or_else(|| { + let (next_index, _) = self + .tx_graph + .index + .next_index(change_keychain) + .expect("keychain must exist"); + let spk = self + .peek_address(change_keychain, next_index) + .script_pubkey(); + (next_index, spk) + }); + let descriptor = self.public_descriptor(change_keychain); + let definite_descriptor = descriptor + .at_derivation_index(index) + .expect("should be valid derivation index"); + let change_script = ChangeScript::from_descriptor(definite_descriptor); + (Some((change_keychain, index, spk)), change_script) + } + } + } + + /// Parses the common parameters used during PSBT creation and returns the spend assets + /// and a map of indexed tx outputs. + fn parse_params( + &self, + 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.set.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 mut 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. + /// + /// # 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 this function + /// again before syncing will use the same change address. If you intend to build + /// multiple transactions without syncing between them, either provide the change script in + /// the [`PsbtParams`], or do [`Wallet::mark_used`] after each call to prevent reuse. + /// + /// **You must persist the change set staged as a result of this call.** + /// See [`Wallet::take_staged`]. + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn create_psbt( + &mut 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). + /// + /// 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( + &mut self, + mut params: PsbtParams, + rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), CreatePsbtError> { + let (change_info, change_script) = self.peek_change_info(params.change_script.take()); + + let (assets, txouts) = self.parse_params(¶ms); + + let must_spend = self.build_must_spend_inputs(¶ms, &txouts, &assets)?; + + // 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() + }; + + // Apply fallback sequence to coin-selection candidates without a CSV requirement. + if let Some(seq) = params.fallback_sequence { + for input in &mut may_spend { + if input.sequence().is_none() { + input.set_sequence(seq).map_err(CreatePsbtError::Sequence)?; + } + } + } + + 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)?; + + 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`. + /// + /// 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)) + })?; + + match ¶ms.ordering { + TxOrdering::Untouched => {} + TxOrdering::Shuffle => { + selection.shuffle_inputs(rng); + selection.shuffle_outputs(rng); + } + TxOrdering::Custom { + input_sort, + output_sort, + } => { + selection.sort_inputs_by(|a, b| input_sort(a, b)); + selection.sort_outputs_by(|a, b| output_sort(a, b)); + } + } + + let version = params.version.unwrap_or(transaction::Version::TWO); + let min_locktime = params.min_locktime.unwrap_or(absolute::LockTime::ZERO); + + // Create psbt + let mut psbt = selection + .create_psbt_with_rng( + bdk_tx::PsbtParams { + version, + min_locktime, + mandate_full_tx_for_segwit_v0: !params.only_witness_utxo, + anti_fee_sniping: params.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 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`. + /// 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( + &mut 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 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 + /// - 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 this function + /// again before syncing will use the same change address. If you intend to build + /// multiple transactions without syncing between them, either provide the change script in + /// the [`PsbtParams`], or do [`Wallet::mark_used`] after each call to prevent reuse. + /// + /// **You must persist the change set staged as a result of this call.** + /// See [`Wallet::take_staged`]. + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn replace_by_fee( + &mut 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). + /// + /// 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( + &mut self, + mut params: PsbtParams, + rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { + let (change_info, change_script) = self.peek_change_info(params.change_script.take()); + + let (assets, txouts) = self.parse_params(¶ms); + + let PsbtParams { + replace: txids_to_replace, + .. + } = ¶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, params.canonical_params.clone()) + .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)); + } + } + + // For each txid being replaced, verify that at least one of its original inputs + // remains in the selected set. A replacement must conflict with every transaction it + // replaces — two transactions cannot spend the same UTXO. + for &txid in txids_to_replace.iter() { + if let Some(tx) = self.tx_graph.graph().get_tx(txid) { + if !tx + .input + .iter() + .any(|txin| params.set.contains(&txin.previous_output)) + { + return Err(ReplaceByFeeError::NoInputsFromOriginal(txid)); + } + } + } + + // Txs and their descendants to be replaced + // + // `direct_conflicts` are the transactions named in `params.replace`. Only these + // feed into `original_txs` for the RBF fee rate floor. + // + // `to_replace` also includes walked descendants so they are excluded from coin + // selection; their fees accumulate into `descendant_fee`. + let direct_conflicts: HashSet = txids_to_replace.iter().copied().collect(); + + let descendants: HashSet = direct_conflicts + .iter() + .flat_map(|&txid| { + self.tx_graph + .graph() + .walk_descendants(txid, |_, txid| Some(txid)) + }) + .filter(|txid| !direct_conflicts.contains(txid)) + .collect(); + + let to_replace: HashSet = direct_conflicts + .iter() + .chain(descendants.iter()) + .copied() + .collect(); + + let must_spend = self.build_must_spend_inputs(¶ms, &txouts, &assets)?; + + // 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 be contained in `to_replace` + // - 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() + && (params.utxo_filter.0)(txo) + }) + .flat_map(|txo| self.plan_input(&txo, &assets)) + .collect() + }; + + // Apply fallback sequence to coin-selection candidates without a CSV requirement. + if let Some(seq) = params.fallback_sequence { + for input in &mut may_spend { + if input.sequence().is_none() { + input.set_sequence(seq).map_err(CreatePsbtError::Sequence)?; + } + } + } + + 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 = direct_conflicts + .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::>()?; + + // Sum fees from all descendants known to the tx graph. This assumes every + // descendant is currently in the mempool, which could slightly overestimate + // the fee floor if a descendant was evicted or never relayed. + let descendant_fee: Amount = descendants + .iter() + .filter_map(|&txid| { + let tx = self.tx_graph.graph().get_tx(txid)?; + self.calculate_fee(&tx).ok() + }) + .sum(); + + let rbf_params = RbfParams { + original_txs, + descendant_fee, + 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)?; + + 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)) + } + + /// Builds the required inputs from user-added UTXOS and pre-built [`Input`]s in `params`. + /// + /// Processes [`params.utxos`] by planning each outpoint as an [`Input`], then applies any + /// per-input sequence override or the fallback sequence. Pre-built inputs from + /// [`params.inputs`] are appended unchanged. + /// + /// [`params.utxos`]: PsbtParams::add_utxos + /// [`params.inputs`]: PsbtParams::add_planned_input + fn build_must_spend_inputs( + &self, + params: &PsbtParams, + txouts: &HashMap>, + assets: &Assets, + ) -> Result, CreatePsbtError> { + params + .utxos + .iter() + .map(|&outpoint| { + let txo = txouts + .get(&outpoint) + .ok_or(CreatePsbtError::UnknownUtxo(outpoint))?; + let mut input = self + .plan_input(txo, assets) + .ok_or(CreatePsbtError::Plan(outpoint))?; + if let Some(&seq) = params.sequence_overrides.get(&outpoint) { + input.set_sequence(seq).map_err(CreatePsbtError::Sequence)?; + } else if let Some(seq) = params.fallback_sequence { + if input.sequence().is_none() { + input.set_sequence(seq).map_err(CreatePsbtError::Sequence)?; + } + } + Ok(input) + }) + .chain(params.inputs.iter().cloned().map(Result::Ok)) + .collect() + } + + /// Plan the output with the available assets and return a new [`Input`] if possible. See also + /// [`Self::try_plan`]. + fn plan_input( + &self, + txo: &FullTxOut, + spend_assets: &Assets, + ) -> Option { + let op = txo.outpoint; + let txid = op.txid; + + // We want to afford the output with as many assets as we can. The plan + // will use only the ones needed to produce the minimum satisfaction. + let cur_height = self.latest_checkpoint().height(); + let abs_locktime = spend_assets + .absolute_timelock + .unwrap_or(absolute::LockTime::from_consensus(cur_height)); + + let rel_locktime = spend_assets.relative_timelock.unwrap_or_else(|| { + let age = match txo.chain_position.confirmation_height_upper_bound() { + Some(conf_height) => cur_height + .saturating_add(1) + .saturating_sub(conf_height) + .try_into() + .unwrap_or(u16::MAX), + None => 0, + }; + relative::LockTime::from_height(age) + }); + + 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() @@ -3024,7 +3863,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 71fe399a168b433e544e2c63c45e175133ba3dfa Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 16 Jul 2026 08:37:11 -0400 Subject: [PATCH 04/19] test: Add coverage for `create_psbt` and related fns Added unit test to `psbt/params.rs` - `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_cltv_timestamp` - `test_create_psbt_csv` - `test_replace_by_fee_and_recpients` - `test_replace_by_fee_replaces_descendant_fees` - `test_replace_by_fee_confirmed_tx_error` - `test_replace_by_fee_no_inputs_from_original` - `test_create_psbt_utxo_filter` Plus several Sequence fallback and override scenarios - `test_create_psbt_fallback_sequence_applied_to_coin_selected_input` - `test_create_psbt_fallback_sequence_skipped_for_csv_input` - `test_create_psbt_sequence_override_manually_selected_input` - `test_create_psbt_sequence_override_takes_precedence_over_fallback` - `test_create_psbt_sequence_override_csv_conflict_returns_error` 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 | 55 ++- tests/psbt.rs | 902 +++++++++++++++++++++++++++++++++++++- tests/wallet.rs | 85 +++- 5 files changed, 1104 insertions(+), 4 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index cd17dfc2..6463a94f 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -583,6 +583,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 (mut 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 c0a51464..c9658a41 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -319,6 +319,31 @@ pub fn insert_checkpoint(wallet: &mut Wallet, block: BlockId) { .unwrap(); } +/// Inserts a transaction to be anchored by `block_id`. This is particularly useful for +/// adding a coinbase tx to the wallet for testing, since transactions of this kind +/// must always appear confirmed. +/// +/// This will also insert the anchor `block_id`. See [`insert_anchor`] for more. +pub fn insert_tx_anchor(wallet: &mut Wallet, tx: Transaction, block_id: BlockId) { + insert_checkpoint(wallet, block_id); + let anchor = ConfirmationBlockTime { + block_id, + confirmation_time: 1234567000, + }; + let txid = tx.compute_txid(); + + let mut tx_update = TxUpdate::default(); + tx_update.txs = vec![Arc::new(tx)]; + tx_update.anchors = [(anchor, txid)].into(); + + wallet + .apply_update(Update { + tx_update, + ..Default::default() + }) + .expect("failed to apply update"); +} + /// Inserts a transaction into the local view, assuming it is currently present in the mempool. /// /// This can be used, for example, to track a transaction immediately after it is broadcast. diff --git a/tests/add_foreign_utxo.rs b/tests/add_foreign_utxo.rs index 409d71fd..981c9924 100644 --- a/tests/add_foreign_utxo.rs +++ b/tests/add_foreign_utxo.rs @@ -5,7 +5,7 @@ use bdk_wallet::signer::SignOptions; use bdk_wallet::test_utils::*; use bdk_wallet::tx_builder::AddForeignUtxoError; use bdk_wallet::KeychainKind; -use bitcoin::{psbt, Address, Amount}; +use bitcoin::{hashes::Hash, psbt, Address, Amount, OutPoint, ScriptBuf, Sequence, TxOut}; mod common; @@ -323,3 +323,56 @@ fn test_add_foreign_utxo_rejects_wrong_non_witness_utxo_even_with_witness_utxo() "should reject non_witness_utxo with wrong txid even when witness_utxo is present" ); } + +#[test] +fn test_add_planned_psbt_input() -> anyhow::Result<()> { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let op1 = wallet.list_unspent().next().unwrap().outpoint; + + // We'll 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, + /* absolute_timelock: */ None, + )?; + + 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 08c4acc9..4c49ca79 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -1,11 +1,909 @@ -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, Sequence, + 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) + .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(), 0); + assert_eq!(tx.input.len(), 1); + assert_eq!(tx.output.len(), 2); + + // global xpubs + assert_eq!( + psbt.xpub, + [(expected_xpub, ("f6a5cb8b".parse().unwrap(), vec![].into()))].into(), + ); + // witness utxo + let psbt_input = &psbt.inputs[0]; + assert_eq!( + psbt_input.witness_utxo.as_ref().map(|txo| txo.value), + Some(Amount::ONE_BTC), + ); + // input internal key + assert!(psbt_input.tap_internal_key.is_some()); + // input key origins + assert!(psbt_input + .tap_key_origins + .values() + .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b")); + // output internal key + assert!(psbt + .outputs + .iter() + .any(|output| output.tap_internal_key.is_some())); + // output key origins + assert!(psbt.outputs.iter().any(|output| output + .tap_key_origins + .values() + .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b"))); +} + +#[test] +fn test_create_psbt_insufficient_funds_error() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External); + + let mut params = 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)); + } +} + +/// Fallback sequence is applied to a coin-selected input that has no CSV +/// requirement. +#[test] +fn test_create_psbt_fallback_sequence_applied_to_coin_selected_input() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut params = PsbtParams::default(); + params + .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) + .fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); + let psbt = wallet.create_psbt(params).unwrap().0; + assert_eq!( + psbt.unsigned_tx.input[0].sequence, + Sequence::ENABLE_RBF_NO_LOCKTIME + ); +} + +/// Fallback sequence is NOT applied when the input already has a CSV-derived sequence +/// requirement — the CSV value wins. +#[test] +fn test_create_psbt_fallback_sequence_skipped_for_csv_input() { + use bitcoin::relative; + let mut wallet = Wallet::create_single(get_test_single_sig_csv()) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 10_000, + hash: Hash::hash(b"csv_fallback"), + }, + confirmation_time: 1_234_567_000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let op = receive_output( + &mut wallet, + Amount::from_sat(100_000), + ReceiveTo::Block(anchor), + ); + + let addr = wallet.next_unused_address(KeychainKind::External); + let rel_locktime = relative::LockTime::from_consensus(6).unwrap(); + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .add_assets(Assets::new().older(rel_locktime)) + .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) + .fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); + let psbt = wallet.create_psbt(params).unwrap().0; + // CSV descriptor requires older(6); fallback must not clobber the CSV-derived sequence. + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); +} + +/// A per-input sequence override is applied to a manually-selected UTXO. +#[test] +fn test_create_psbt_sequence_override_manually_selected_input() { + let (mut wallet, txid) = get_funded_wallet_wpkh(); + let utxo = OutPoint::new(txid, 0); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut params = PsbtParams::default(); + params + .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) + .add_utxos(&[utxo]) + .manually_selected_only() + .sequence_override(utxo, Sequence(42)); + let psbt = wallet.create_psbt(params).unwrap().0; + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(42)); +} + +/// A per-input sequence override takes precedence over a fallback sequence. +#[test] +fn test_create_psbt_sequence_override_takes_precedence_over_fallback() { + let (mut wallet, txid) = get_funded_wallet_wpkh(); + let utxo = OutPoint::new(txid, 0); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut params = PsbtParams::default(); + params + .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) + .add_utxos(&[utxo]) + .manually_selected_only() + .sequence_override(utxo, Sequence(42)) + .fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); + let psbt = wallet.create_psbt(params).unwrap().0; + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(42)); +} + +/// A sequence override that violates the CSV requirement returns a Sequence error. +#[test] +fn test_create_psbt_sequence_override_csv_conflict_returns_error() { + use bitcoin::relative; + let mut wallet = Wallet::create_single(get_test_single_sig_csv()) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 10_000, + hash: Hash::hash(b"csv_override"), + }, + confirmation_time: 1_234_567_000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let op = receive_output( + &mut wallet, + Amount::from_sat(100_000), + ReceiveTo::Block(anchor), + ); + + let addr = wallet.next_unused_address(KeychainKind::External); + let rel_locktime = relative::LockTime::from_consensus(6).unwrap(); + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .add_assets(Assets::new().older(rel_locktime)) + .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) + .manually_selected_only() + .sequence_override(op, Sequence(3)); // CSV requires >= 6 + let result = wallet.create_psbt(params); + assert!(matches!(result, Err(CreatePsbtError::Sequence(_)))); +} + +// 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 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 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 that `replace_by_fee` errors when all original inputs have been removed via +// `remove_utxo`, leaving the replacement with no inputs from the replaced transaction. +#[test] +fn test_replace_by_fee_no_inputs_from_original() { + 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 addr = wallet.reveal_next_address(External).address; + + // Fund the wallet with an unconfirmed output. + let funding_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(Hash::hash(b"funding_parent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(200_000), + script_pubkey: addr.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx(&mut wallet, funding_tx); + + // Create an unconfirmed tx spending the funded UTXO. + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + let mut 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; + let unconfirmed_txid = unconfirmed_tx.compute_txid(); + insert_tx(&mut wallet, unconfirmed_tx.clone()); + + // Build replacement params, but remove the original inputs + let mut params = PsbtParams::default().replace_txs(&[Arc::new(unconfirmed_tx)]); + params.remove_utxo(&funding_op); + + let result = wallet.replace_by_fee(params); + assert!( + matches!(result, Err(ReplaceByFeeError::NoInputsFromOriginal(txid)) if txid == unconfirmed_txid), + "expected NoInputsFromOriginal error, got: {result:?}", + ); +} + +#[test] +fn test_create_psbt_utxo_filter() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 1000, + hash: Hash::hash(b"1000"), + }, + confirmation_time: 1234567, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + + for value in [200, 300, 600, 1000] { + let _ = receive_output( + &mut wallet, + Amount::from_sat(value), + ReceiveTo::Block(anchor), + ); + } + assert_eq!(wallet.list_unspent().count(), 4); + assert_eq!(wallet.balance().total().to_sat(), 2100); + + let 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 0204cfd5..68cae317 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, IntoWalletDescriptor}; use bdk_wallet::error::CreateTxError; -use bdk_wallet::psbt::PsbtUtils; +use bdk_wallet::psbt::{self, PsbtUtils}; use bdk_wallet::signer::{SignOptions, SignerError, SignersContainer}; use bdk_wallet::test_utils::*; use bdk_wallet::KeychainKind; @@ -28,6 +28,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 b85f1183bec85d9a762b4755714fd9e35e2758c4 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 16 Jul 2026 08:37:12 -0400 Subject: [PATCH 05/19] docs: Add `psbt.rs`, `replace_by_fee.rs` to examples --- Cargo.toml | 6 ++ examples/psbt.rs | 120 ++++++++++++++++++++++++ examples/replace_by_fee.rs | 185 +++++++++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 examples/psbt.rs create mode 100644 examples/replace_by_fee.rs diff --git a/Cargo.toml b/Cargo.toml index ac2b0c99..e63593d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,3 +79,9 @@ name = "esplora_blocking" [[example]] name = "bitcoind_rpc" + +[[example]] +name = "psbt" + +[[example]] +name = "replace_by_fee" diff --git a/examples/psbt.rs b/examples/psbt.rs new file mode 100644 index 00000000..7e7ebcd7 --- /dev/null +++ b/examples/psbt.rs @@ -0,0 +1,120 @@ +#![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::{consensus, secp256k1::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(); + + // Create wallet and fund it. + let mut wallet = Wallet::create(desc, change_desc) + .network(NETWORK) + .create_wallet_no_persist()?; + + fund_wallet(&mut wallet)?; + + // Create PSBT Signer, external to the wallet + let signer = { + let secp = wallet.secp_ctx(); + let (_, external_keymap) = miniscript::Descriptor::parse_descriptor(secp, desc)?; + let (_, internal_keymap) = miniscript::Descriptor::parse_descriptor(secp, change_desc)?; + bdk_tx::Signer(external_keymap.into_iter().chain(internal_keymap).collect()) + }; + + let utxos = wallet + .list_unspent() + .map(|output| (output.outpoint, output)) + .collect::>(); + + // Build 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(&signer, wallet.secp_ctx()) + .map_err(|(_, errors)| anyhow::anyhow!("failed to sign PSBT: {errors:?}"))?; + + println!("Signed: {}", !psbt.inputs[0].partial_sigs.is_empty()); + let finalize_res = finalizer.finalize(&mut psbt); + println!("Finalized: {}", finalize_res.is_finalized()); + + let tx = psbt.extract_tx()?; + let feerate = wallet.calculate_fee_rate(&tx)?; + println!("Fee rate: {} sat/vb", bdk_wallet::floating_rate!(feerate)); + + println!("{}", consensus::encode::serialize_hex(&tx)); + + Ok(()) +} + +fn fund_wallet(wallet: &mut Wallet) -> anyhow::Result<()> { + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 260071, + hash: "000000099f67ae6469d1ad0525d756e24d4b02fbf27d65b3f413d5feb367ec48".parse()?, + }, + confirmation_time: 1752184658, + }; + insert_checkpoint(wallet, anchor.block_id); + + let mut rng = rand::thread_rng(); + + // Fund wallet with several random utxos + for i in 0..21 { + let addr = wallet.reveal_next_address(External).address; + let value = 10_000 * (i + 1) + (100 * rng.gen_range(0..10)); + let tx = bitcoin::Transaction { + lock_time: bitcoin::absolute::LockTime::ZERO, + version: bitcoin::transaction::Version::TWO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: addr.script_pubkey(), + value: Amount::from_sat(value), + }], + }; + insert_tx_anchor(wallet, tx, anchor.block_id); + } + + let tip = BlockId { + height: 260171, + hash: "0000000b9efb77450e753ae9fd7be9f69219511c27b6e95c28f4126f3e1591c3".parse()?, + }; + insert_checkpoint(wallet, tip); + + Ok(()) +} diff --git a/examples/replace_by_fee.rs b/examples/replace_by_fee.rs new file mode 100644 index 00000000..1263423b --- /dev/null +++ b/examples/replace_by_fee.rs @@ -0,0 +1,185 @@ +#![allow(clippy::print_stdout)] + +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::{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; + +fn main() -> anyhow::Result<()> { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + + // Create wallet and "fund" it with a single UTXO. + let mut wallet = Wallet::create(desc, change_desc) + .network(NETWORK) + .create_wallet_no_persist()?; + + fund_wallet(&mut wallet)?; + + // Create PSBT Signer, external to the wallet + let signer = { + let secp = wallet.secp_ctx(); + let (_, external_keymap) = miniscript::Descriptor::parse_descriptor(secp, desc)?; + let (_, internal_keymap) = miniscript::Descriptor::parse_descriptor(secp, change_desc)?; + bdk_tx::Signer(external_keymap.into_iter().chain(internal_keymap).collect()) + }; + + // Get a derived descriptor from the wallet to sweep funds to + let derived_descriptor: Descriptor = wallet + .public_descriptor(KeychainKind::External) + .at_derivation_index(1)?; + + println!( + "Wallet funded with {}\n", + wallet.balance().total().display_dynamic() + ); + println!("Creating first 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(&signer, wallet.secp_ctx()) + .map_err(|(_, errors)| anyhow::anyhow!("failed to sign PSBT: {errors:?}"))?; + println!("tx1 signed: {}", !psbt1.inputs[0].partial_sigs.is_empty()); + + let finalize_res = finalizer1.finalize(&mut psbt1); + println!("tx1 finalized: {}", finalize_res.is_finalized()); + + let tx1 = Arc::new(psbt1.extract_tx()?); + let feerate1 = wallet.calculate_fee_rate(&tx1)?; + let fee1 = wallet.calculate_fee(&tx1)?; + + println!(" txid: {}", tx1.compute_txid()); + println!( + " fee rate: {} sat/vb", + bdk_wallet::floating_rate!(feerate1) + ); + println!(" absolute fee: {} sats", fee1.to_sat()); + + // Apply tx1 to wallet as unconfirmed + wallet.apply_unconfirmed_txs([(tx1.clone(), 1234567000)]); + + println!("\nCreating RBF replacement transaction (tx2)..."); + + // Create tx2: Replace tx1 at a higher feerate 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(&signer, wallet.secp_ctx()) + .map_err(|(_, errors)| anyhow::anyhow!("failed to sign PSBT: {errors:?}"))?; + println!("tx2 signed: {}", !psbt2.inputs[0].partial_sigs.is_empty()); + + let finalize_res = finalizer2.finalize(&mut psbt2); + println!("tx2 finalized: {}", finalize_res.is_finalized()); + + let tx2 = psbt2.extract_tx()?; + let feerate2 = wallet.calculate_fee_rate(&tx2)?; + let fee2 = wallet.calculate_fee(&tx2)?; + + println!(" txid: {}", tx2.compute_txid()); + println!( + " fee rate: {} sat/vb", + bdk_wallet::floating_rate!(feerate2) + ); + println!(" absolute fee: {} sats", fee2.to_sat()); + + println!("\nVerifying RBF properties..."); + + // Verify that tx1 and tx2 conflict (spend the same input) + let tx1_input = tx1.input[0].previous_output; + let tx2_input = tx2.input[0].previous_output; + + assert_eq!( + tx1_input, tx2_input, + "ERROR: tx1 and tx2 must spend the same input" + ); + println!("✓ Both transactions spend the same input: {}", tx1_input); + + // Verify that tx2 has a higher feerate than tx1 + assert!( + feerate2 > feerate1, + "ERROR: tx2 must have a higher feerate than tx1" + ); + println!( + "✓ Replacement has higher fee rate ({} vs {} sat/vb)", + bdk_wallet::floating_rate!(feerate2), + bdk_wallet::floating_rate!(feerate1) + ); + + // Verify absolute fee increase + assert!(fee2 > fee1, "ERROR: tx2 must have a higher fee than tx1"); + let fee_increase = fee2.to_sat() as i64 - fee1.to_sat() as i64; + println!("✓ Absolute fee increased by {} sats", fee_increase); + + // Apply tx2 to wallet so it recognizes the conflict + wallet.apply_unconfirmed_txs([(tx2.clone(), 1234567001)]); + + // Verify that the wallet recognizes the conflict + let txid2 = tx2.compute_txid(); + assert!( + wallet + .tx_graph() + .direct_conflicts(&tx1) + .any(|(_, txid)| txid == txid2), + "ERROR: Wallet does not recognize tx2 as replacing tx1", + ); + println!("✓ Wallet recognizes the transaction conflict"); + + println!("\n✓✓✓ RBF sweep complete! ✓✓✓"); + + Ok(()) +} + +fn fund_wallet(wallet: &mut Wallet) -> anyhow::Result<()> { + let anchor_block = BlockId { + height: 1, + hash: "3bcc1c447c6b3886f43e416b5c21cf5c139dc4829a71dc78609bc8f6235611c5".parse()?, + }; + let chain_tip = BlockId { + height: 101, + hash: "7f96292d115d19450e4bf7d4c4e15c9f3ad21e3a3cf616c498110b988963470b".parse()?, + }; + + insert_checkpoint(wallet, anchor_block); + + let addr = wallet.reveal_next_address(KeychainKind::External).address; + let tx = bitcoin::Transaction { + lock_time: bitcoin::absolute::LockTime::ZERO, + version: bitcoin::transaction::Version::TWO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: addr.script_pubkey(), + value: Amount::from_sat(50_000_000), + }], + }; + insert_tx_anchor(wallet, tx, anchor_block); + insert_checkpoint(wallet, chain_tip); + + Ok(()) +} From 8848359f6cfe5c20c17c011a70c1e134681a0bda Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 16 Jul 2026 08:37:13 -0400 Subject: [PATCH 06/19] refactor(psbt): Change replace_txs to take an iterable of Into> The empty txs case is now caught in `replace_by_fee_with_rng`, which returns `ReplaceByFeeError::NoOriginalTransactions` when `params.replace` is empty. Adds a test in `tests/psbt.rs` covering the error path. --- examples/replace_by_fee.rs | 2 +- src/psbt/params.rs | 39 +++++++++++++++++++++----------------- src/wallet/error.rs | 14 +++++--------- src/wallet/mod.rs | 9 ++++++--- tests/psbt.rs | 32 +++++++++++++++++++++++++------ 5 files changed, 60 insertions(+), 36 deletions(-) diff --git a/examples/replace_by_fee.rs b/examples/replace_by_fee.rs index 1263423b..feb4d156 100644 --- a/examples/replace_by_fee.rs +++ b/examples/replace_by_fee.rs @@ -79,7 +79,7 @@ fn main() -> anyhow::Result<()> { 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)]); + 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")); diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 6463a94f..cf03b666 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -153,14 +153,13 @@ impl PsbtParams { /// There should be no ancestry linking the elements of `txs`, since replacing an /// ancestor necessarily invalidates the descendant. /// - /// # Panics + /// `txs` must not be empty, or creating the PSBT will return [`NoOriginalTransactions`]. /// - /// Panics if `txs` is empty. - pub fn replace_txs(self, txs: &[Arc]) -> PsbtParams { - assert!( - !txs.is_empty(), - "replace_txs requires at least one transaction" - ); + /// [`NoOriginalTransactions`]: crate::error::ReplaceByFeeError::NoOriginalTransactions + pub fn replace_txs(self, txs: impl IntoIterator) -> PsbtParams + where + T: Into>, + { let mut params = self.into_replace_params(); params.replace(txs); params @@ -518,16 +517,24 @@ impl fmt::Debug for 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]) { + fn replace(&mut self, txs: impl IntoIterator) + where + T: Into>, + { 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); + let mut tx_graph = TxGraph::::default(); + let mut txids_to_replace: HashSet = txs + .into_iter() + .map(|tx| { + let tx: Arc = tx.into(); + let txid = tx.compute_txid(); + let _ = tx_graph.insert_tx(tx); + txid + }) + .collect(); // 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 @@ -603,7 +610,7 @@ mod test { let txid1 = tx.compute_txid(); // Replace tx - let mut params = PsbtParams::default().replace_txs(&[Arc::new(tx)]); + let mut params = PsbtParams::default().replace_txs([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); @@ -669,9 +676,7 @@ mod test { 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); + let params = PsbtParams::new().replace_txs([tx_a, tx_b, tx_c, tx_d]); assert_eq!(params.set, expect_spends); assert_eq!(params.replace, [txid_a, txid_c].into()); } diff --git a/src/wallet/error.rs b/src/wallet/error.rs index 6a1b4574..b91e458f 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -421,14 +421,9 @@ pub enum ReplaceByFeeError { MissingTransaction(Txid), /// One of the transactions to be replaced is already confirmed TransactionConfirmed(Txid), - /// The replacement transaction has no inputs from the replaced transaction. - /// - /// A replacement must spend at least one of the same inputs as the transaction it replaces, - /// since two transactions cannot spend the same UTXO. This error is returned when - /// [`PsbtParams::remove_utxo`] has been used to remove all original inputs belonging to - /// the given replaced transaction. - /// - /// [`PsbtParams::remove_utxo`]: crate::psbt::PsbtParams::remove_utxo + /// No original transactions were specified. + NoOriginalTransactions, + /// The replacement transaction has no inputs from the original transaction. NoInputsFromOriginal(Txid), } @@ -441,10 +436,11 @@ impl fmt::Display for ReplaceByFeeError { Self::TransactionConfirmed(txid) => { write!(f, "transaction already confirmed: {txid}") } + Self::NoOriginalTransactions => write!(f, "no original transactions were specified"), Self::NoInputsFromOriginal(txid) => { write!( f, - "replacement has no inputs from replaced transaction: {txid}" + "replacement has no inputs from original transaction: {txid}" ) } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 17a91c7b..54c4c448 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3389,7 +3389,7 @@ impl Wallet { /// let txout = to_replace.tx_out(vout)?.clone(); /// /// let (psbt, finalizer) = wallet.replace_by_fee_and_recipients( - /// &[to_replace], + /// [to_replace], /// FeeRate::from_sat_per_vb(10).expect("valid feerate"), /// vec![(txout.script_pubkey, txout.value)], /// )?; @@ -3397,9 +3397,9 @@ impl Wallet { /// ``` #[cfg(feature = "std")] #[cfg_attr(docsrs, doc(cfg(feature = "std")))] - pub fn replace_by_fee_and_recipients( + pub fn replace_by_fee_and_recipients>>( &mut self, - txs: &[Arc], + txs: impl IntoIterator, fee_rate: FeeRate, recipients: Vec<(ScriptBuf, Amount)>, ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { @@ -3466,6 +3466,9 @@ impl Wallet { mut params: PsbtParams, rng: &mut impl RngCore, ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { + if params.replace.is_empty() { + return Err(ReplaceByFeeError::NoOriginalTransactions); + } let (change_info, change_script) = self.peek_change_info(params.change_script.take()); let (assets, txouts) = self.parse_params(¶ms); diff --git a/tests/psbt.rs b/tests/psbt.rs index 4c49ca79..b0313901 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -10,7 +10,6 @@ use bitcoin::{ }; 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"; @@ -506,7 +505,7 @@ fn test_create_psbt_sequence_override_csv_conflict_returns_error() { // 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() { +fn test_replace_by_fee_and_recipients() { use KeychainKind::*; let (desc, change_desc) = get_test_wpkh_and_change_desc(); let mut wallet = Wallet::create(desc, change_desc) @@ -581,7 +580,7 @@ fn test_replace_by_fee_and_recpients() { // Now create RBF tx let psbt = wallet .replace_by_fee_and_recipients( - &[Arc::new(txa), Arc::new(txb)], + [txa, txb], FeeRate::from_sat_per_vb(4).unwrap(), vec![(recip, Amount::from_btc(1.99).unwrap())], ) @@ -720,7 +719,7 @@ fn test_replace_by_fee_replaces_descendant_fees() { // fees are included in the minimum required replacement fee. let (psbt, _) = wallet .replace_by_fee_and_recipients( - &[Arc::new(tx_a)], + [tx_a], FeeRate::from_sat_per_vb(4).unwrap(), vec![(external, Amount::from_sat(100_000))], ) @@ -788,7 +787,7 @@ fn test_replace_by_fee_confirmed_tx_error() { // Attempting to replace the now-confirmed tx should return TransactionConfirmed. let result = wallet.replace_by_fee_and_recipients( - &[Arc::new(unconfirmed_tx)], + [unconfirmed_tx], FeeRate::from_sat_per_vb(10).unwrap(), vec![], ); @@ -842,7 +841,7 @@ fn test_replace_by_fee_no_inputs_from_original() { insert_tx(&mut wallet, unconfirmed_tx.clone()); // Build replacement params, but remove the original inputs - let mut params = PsbtParams::default().replace_txs(&[Arc::new(unconfirmed_tx)]); + let mut params = PsbtParams::default().replace_txs([unconfirmed_tx]); params.remove_utxo(&funding_op); let result = wallet.replace_by_fee(params); @@ -852,6 +851,27 @@ fn test_replace_by_fee_no_inputs_from_original() { ); } +// Test that `replace_by_fee` returns `NoOriginalTransactions` when `replace_txs` is called +// with an empty list, i.e. no transactions were provided for replacement. +#[test] +fn test_replace_by_fee_no_original_transactions() { + use bdk_wallet::error::ReplaceByFeeError; + + 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(); + + // replace_txs with an empty iterator produces PsbtParams with an empty replace set. + let params = PsbtParams::default().replace_txs(core::iter::empty::()); + let result = wallet.replace_by_fee(params); + assert!( + matches!(result, Err(ReplaceByFeeError::NoOriginalTransactions)), + "expected NoOriginalTransactions, got: {result:?}", + ); +} + #[test] fn test_create_psbt_utxo_filter() { let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); From 37b202a1271064e32db8c37936be6f3143f611d7 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 16 Jul 2026 08:37:14 -0400 Subject: [PATCH 07/19] docs: Fix typo --- examples/psbt.rs | 2 -- src/psbt/params.rs | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/psbt.rs b/examples/psbt.rs index 7e7ebcd7..ccf26c98 100644 --- a/examples/psbt.rs +++ b/examples/psbt.rs @@ -53,8 +53,6 @@ fn main() -> anyhow::Result<()> { // 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; diff --git a/src/psbt/params.rs b/src/psbt/params.rs index cf03b666..3a95e1ec 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -126,7 +126,7 @@ impl PsbtParams { /// 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`] + /// If an outpoint doesn't correspond to an indexed script pubkey, an [`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 From 1fe0f530e791da4912721c05f7a9dd2d19014708 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 16 Jul 2026 08:37:15 -0400 Subject: [PATCH 08/19] fix(wallet): Add NoRecipients and AllOutputsBelowDust error variants Adds two `CreatePsbtError` variants to cover distinct failure modes when building a PSBT: - `NoRecipients`: no recipients were added, or `drain_wallet` was set without an explicit `change_script`. - `AllOutputsBelowDust`: after coin selection the only output fell below dust and was dropped to fees, leaving the transaction with zero outputs. Early-exit guards in `create_psbt_with_rng` and `replace_by_fee_with_rng` return `NoRecipients`. The post-selection guard in `create_psbt_from_selector` returns `AllOutputsBelowDust`. Tests: - `test_create_psbt_no_recipients_error` - `test_create_psbt_drain_wallet_change_below_dust_error` - `test_replace_by_fee_drain_wallet_change_below_dust_error` --- src/wallet/error.rs | 11 +++ src/wallet/mod.rs | 18 +++++ tests/psbt.rs | 161 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 184 insertions(+), 6 deletions(-) diff --git a/src/wallet/error.rs b/src/wallet/error.rs index b91e458f..a17a3aa3 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -373,6 +373,15 @@ impl core::error::Error for BuildFeeBumpError {} pub enum CreatePsbtError { /// No Bnb solution. Bnb(bdk_coin_select::NoBnbSolution), + /// No output destinations were configured. At least one recipient, or + /// [`drain_wallet`] with an explicit [`change_script`], is required. + /// + /// [`drain_wallet`]: crate::PsbtParams::drain_wallet + /// [`change_script`]: crate::PsbtParams::change_script + NoRecipients, + /// After coin selection, all outputs fell below the dust threshold and were + /// dropped to fees. + AllOutputsBelowDust, /// Non-sufficient funds. InsufficientFunds(bdk_coin_select::InsufficientFunds), /// In order to use the [`add_global_xpubs`] option, every extended key in the descriptor must @@ -397,6 +406,8 @@ impl fmt::Display for CreatePsbtError { match self { Self::Bnb(e) => write!(f, "{e}"), Self::InsufficientFunds(e) => write!(f, "{e}"), + Self::NoRecipients => write!(f, "no output destinations were configured"), + Self::AllOutputsBelowDust => write!(f, "all outputs are below the dust threshold",), Self::MissingKeyOrigin(e) => write!(f, "missing key origin: {e}"), Self::Plan(op) => write!(f, "failed to create a plan for txout with outpoint {op}"), Self::Psbt(e) => write!(f, "{e}"), diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 54c4c448..70085ca4 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3208,6 +3208,12 @@ impl Wallet { mut params: PsbtParams, rng: &mut impl RngCore, ) -> Result<(Psbt, Finalizer), CreatePsbtError> { + // Only permit no recipients if we're doing a sweep and an explicit change script is + // provided. + if params.recipients.is_empty() && !(params.drain_wallet && params.change_script.is_some()) + { + return Err(CreatePsbtError::NoRecipients); + } let (change_info, change_script) = self.peek_change_info(params.change_script.take()); let (assets, txouts) = self.parse_params(¶ms); @@ -3314,6 +3320,12 @@ impl Wallet { CreatePsbtError::Selector(bdk_tx::SelectorError::CannotMeetTarget(e)) })?; + // Change fell below the dust threshold and was dropped to fees, leaving + // the transaction with no outputs. + if selection.outputs().is_empty() { + return Err(CreatePsbtError::AllOutputsBelowDust); + } + match ¶ms.ordering { TxOrdering::Untouched => {} TxOrdering::Shuffle => { @@ -3469,6 +3481,12 @@ impl Wallet { if params.replace.is_empty() { return Err(ReplaceByFeeError::NoOriginalTransactions); } + // Only permit no recipients if we're doing a sweep and an explicit change script is + // provided. + if params.recipients.is_empty() && !(params.drain_wallet && params.change_script.is_some()) + { + return Err(ReplaceByFeeError::CreatePsbt(CreatePsbtError::NoRecipients)); + } let (change_info, change_script) = self.peek_change_info(params.change_script.take()); let (assets, txouts) = self.parse_params(¶ms); diff --git a/tests/psbt.rs b/tests/psbt.rs index b0313901..573e6086 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -772,7 +772,7 @@ fn test_replace_by_fee_confirmed_tx_error() { let mut params = PsbtParams::default(); params .add_utxos(&[funding_op]) - .add_recipients([(recip, Amount::from_sat(100_000))]); + .add_recipients([(recip.clone(), Amount::from_sat(100_000))]); let unconfirmed_tx = wallet.create_psbt(params).unwrap().0.unsigned_tx; insert_tx(&mut wallet, unconfirmed_tx.clone()); @@ -783,13 +783,12 @@ fn test_replace_by_fee_confirmed_tx_error() { 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( [unconfirmed_tx], FeeRate::from_sat_per_vb(10).unwrap(), - vec![], + vec![(recip, Amount::from_sat(10_000))], ); assert!( @@ -835,14 +834,16 @@ fn test_replace_by_fee_no_inputs_from_original() { let mut params = PsbtParams::default(); params .add_utxos(&[funding_op]) - .add_recipients([(recip, Amount::from_sat(100_000))]); + .add_recipients([(recip.clone(), Amount::from_sat(100_000))]); let unconfirmed_tx = wallet.create_psbt(params).unwrap().0.unsigned_tx; let unconfirmed_txid = unconfirmed_tx.compute_txid(); insert_tx(&mut wallet, unconfirmed_tx.clone()); - // Build replacement params, but remove the original inputs + // Build replacement params with a recipient but remove the original inputs. let mut params = PsbtParams::default().replace_txs([unconfirmed_tx]); - params.remove_utxo(&funding_op); + params + .remove_utxo(&funding_op) + .add_recipients([(recip, Amount::from_sat(50_000))]); let result = wallet.replace_by_fee(params); assert!( @@ -924,6 +925,48 @@ fn test_create_psbt_utxo_filter() { ); } +// Verify that `create_psbt` returns `NoRecipients` when no recipients are provided and +// `drain_wallet` is not set, even when the wallet contains multiple UTXOs. +#[test] +fn test_create_psbt_no_recipients_error() { + use bdk_chain::{BlockId, ConfirmationBlockTime}; + use bdk_wallet::error::CreatePsbtError; + + let (mut wallet, _) = get_funded_wallet_wpkh(); + + // Add a second confirmed UTXO so we can confirm it's not "just draining one". + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 200, + hash: bitcoin::hashes::Hash::hash(b"200"), + }, + confirmation_time: 2000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + receive_output(&mut wallet, bitcoin::Amount::from_sat(25_000), anchor); + + // No recipients, no drain_wallet → should error. + let err = wallet.create_psbt(PsbtParams::default()).unwrap_err(); + assert!( + matches!(err, CreatePsbtError::NoRecipients), + "expected NoRecipients, got {err:?}" + ); + + // drain_wallet with an explicit change_script and no recipients should succeed (sweep to + // change). + let mut params = PsbtParams::default(); + let change_descriptor = wallet + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(); + params + .drain_wallet() + .change_script(ChangeScript::from_descriptor(change_descriptor)); + wallet + .create_psbt(params) + .expect("drain_wallet with explicit change_script should succeed"); +} + #[test] #[should_panic(expected = "InputIndexOutOfRange")] fn test_psbt_malformed_psbt_input_legacy() { @@ -1139,3 +1182,109 @@ fn test_psbt_multiple_internalkey_signers() { let verify_res = secp.verify_schnorr(&signature, &message, &xonlykey); assert!(verify_res.is_ok(), "The wrong internal key was used"); } + +// When `drain_wallet` is set but the only output (change) would fall below the dust threshold, +// verify that `create_psbt` surfaces this as an error rather than returning a zero-output +// PSBT. +#[test] +fn test_create_psbt_drain_wallet_change_below_dust_error() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 100, + hash: Hash::hash(b"100"), + }, + confirmation_time: 0, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + + // 200 sats: enough to meet the minimum fee for a P2TR spend, + // but after deducting fees for a tx that *includes* a change output the + // residual change falls below the dust threshold. + receive_output(&mut wallet, Amount::from_sat(200), ReceiveTo::Block(anchor)); + + let change_descriptor = wallet + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(); + let mut params = PsbtParams::default(); + params + .drain_wallet() + .change_script(ChangeScript::from_descriptor(change_descriptor)); + + let err = wallet.create_psbt(params).unwrap_err(); + assert!( + matches!(err, CreatePsbtError::AllOutputsBelowDust), + "expected AllOutputsBelowDust when change is below dust threshold, got {err:?}" + ); +} + +// Same dust-drop edge case but via `replace_by_fee`. When `drain_wallet` is set +// and the only output (change) falls below dust, the resulting transaction +// would have zero outputs. Verify that `replace_by_fee` returns the expected error. +#[test] +fn test_replace_by_fee_drain_wallet_change_below_dust_error() { + use bdk_wallet::error::ReplaceByFeeError; + use bitcoin::transaction; + + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 100, + hash: Hash::hash(b"100"), + }, + confirmation_time: 0, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + + // 400 sats: at 1 sat/vb, fees for a P2TR tx with 1 input + 1 change output ≈ 111 sat, + // leaving ~289 sat change — below the P2TR dust threshold (~303 sat). + let op = receive_output(&mut wallet, Amount::from_sat(400), ReceiveTo::Block(anchor)); + + // Build an original unconfirmed tx that spends `op` with RBF enabled. + let original_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: op, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(300), + script_pubkey: wallet + .peek_address(KeychainKind::External, 1) + .script_pubkey(), + }], + }; + insert_tx(&mut wallet, original_tx.clone()); + + // RBF with `drain_wallet`, no recipients, default fee rate (1 sat/vb). + // The only possible output (change) falls below dust. + let change_descriptor = wallet + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(); + let mut params = PsbtParams::default().replace_txs([original_tx]); + params + .drain_wallet() + .change_script(ChangeScript::from_descriptor(change_descriptor)); + let err = wallet.replace_by_fee(params).unwrap_err(); + assert!( + matches!( + err, + ReplaceByFeeError::CreatePsbt(CreatePsbtError::AllOutputsBelowDust) + ), + "expected AllOutputsBelowDust when RBF change is below dust threshold, got {err:?}" + ); +} From a02e04befa432d483f94cc3f53c49f9743156b0c Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 16 Jul 2026 08:37:16 -0400 Subject: [PATCH 09/19] fix: Remove unused methods --- src/wallet/mod.rs | 96 +++-------------------------------- tests/psbt.rs | 126 +++++----------------------------------------- tests/wallet.rs | 85 +------------------------------ 3 files changed, 19 insertions(+), 288 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 70085ca4..1d3266d1 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1101,25 +1101,15 @@ impl Wallet { /// /// To iterate over all canonical transactions, including those that are irrelevant, use /// [`TxGraph::list_canonical_txs`]. - pub fn transactions<'a>(&'a self) -> impl Iterator> + 'a { - self.transactions_with_params(CanonicalizationParams::default()) - } - - /// 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 { + pub fn transactions(&self) -> impl Iterator> + '_ { 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(), params) + .list_canonical_txs( + &self.chain, + self.chain.tip().block_id(), + CanonicalizationParams::default(), + ) .filter(|c_tx| tx_index.is_tx_relevant(&c_tx.tx_node.tx)) } @@ -2644,36 +2634,6 @@ 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. @@ -3380,50 +3340,6 @@ impl Wallet { 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 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`. - /// 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>>( - &mut self, - txs: impl IntoIterator, - 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`]. /// diff --git a/tests/psbt.rs b/tests/psbt.rs index 573e6086..e3051a8b 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -5,7 +5,7 @@ use bdk_wallet::bitcoin; use bdk_wallet::test_utils::*; use bdk_wallet::{error::CreatePsbtError, psbt, KeychainKind, PsbtParams, SignOptions, Wallet}; use bitcoin::{ - absolute, hashes::Hash, Address, Amount, FeeRate, Network, OutPoint, Psbt, ScriptBuf, Sequence, + absolute, hashes::Hash, Amount, FeeRate, Network, OutPoint, Psbt, ScriptBuf, Sequence, Transaction, TxIn, TxOut, }; use core::str::FromStr; @@ -502,108 +502,6 @@ fn test_create_psbt_sequence_override_csv_conflict_returns_error() { assert!(matches!(result, Err(CreatePsbtError::Sequence(_)))); } -// Test that replacing two unconfirmed txs A, B results in a transaction -// that spends the inputs of both A and B. -#[test] -fn test_replace_by_fee_and_recipients() { - use KeychainKind::*; - let (desc, change_desc) = get_test_wpkh_and_change_desc(); - let mut wallet = Wallet::create(desc, change_desc) - .network(Network::Regtest) - .create_wallet_no_persist() - .unwrap(); - - // The anchor block - let block = BlockId { - height: 100, - hash: Hash::hash(b"100"), - }; - - let mut addrs: Vec
= vec![]; - for _ in 0..3 { - let addr = wallet.reveal_next_address(External); - addrs.push(addr.address); - } - - // Insert parent 0 (coinbase) - let p0 = Transaction { - input: vec![TxIn::default()], - output: vec![TxOut { - value: Amount::ONE_BTC, - script_pubkey: addrs[0].script_pubkey(), - }], - ..new_tx(1) - }; - let op0 = OutPoint::new(p0.compute_txid(), 0); - - insert_tx_anchor(&mut wallet, p0.clone(), block); - - // Insert parent 1 (coinbase) - let p1 = Transaction { - input: vec![TxIn::default()], - output: vec![TxOut { - value: Amount::ONE_BTC, - script_pubkey: addrs[1].script_pubkey(), - }], - ..new_tx(1) - }; - let op1 = OutPoint::new(p1.compute_txid(), 0); - - insert_tx_anchor(&mut wallet, p1.clone(), block); - - // Add new tip, for maturity - let block = BlockId { - height: 1000, - hash: Hash::hash(b"1000"), - }; - insert_checkpoint(&mut wallet, block); - - // Create tx A (unconfirmed) - let recip = - ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") - .unwrap(); - let mut 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( - [txa, 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 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). // @@ -717,13 +615,13 @@ fn test_replace_by_fee_replaces_descendant_fees() { // Build replacement A'. The wallet walks A's descendants (B and C) so their // fees are included in the minimum required replacement fee. + let mut params = PsbtParams::default(); + params.add_recipients([(external, Amount::from_sat(100_000))]); + params.fee_rate(FeeRate::from_sat_per_vb(4).unwrap()); + let params = params.replace_txs([tx_a]); let (psbt, _) = wallet - .replace_by_fee_and_recipients( - [tx_a], - FeeRate::from_sat_per_vb(4).unwrap(), - vec![(external, Amount::from_sat(100_000))], - ) - .expect("should create replacement psbt"); + .replace_by_fee(params) + .expect("failed to create RBF PSBT"); let replacement_fee = wallet .calculate_fee(&psbt.unsigned_tx) @@ -785,11 +683,11 @@ fn test_replace_by_fee_confirmed_tx_error() { insert_tx_anchor(&mut wallet, unconfirmed_tx.clone(), confirm_block); // Attempting to replace the now-confirmed tx should return TransactionConfirmed. - let result = wallet.replace_by_fee_and_recipients( - [unconfirmed_tx], - FeeRate::from_sat_per_vb(10).unwrap(), - vec![(recip, Amount::from_sat(10_000))], - ); + let mut params = PsbtParams::default(); + params.add_recipients([(recip, Amount::from_sat(10_000))]); + params.fee_rate(FeeRate::from_sat_per_vb(10).unwrap()); + let params = params.replace_txs([unconfirmed_tx]); + let result = wallet.replace_by_fee(params); assert!( matches!(result, Err(ReplaceByFeeError::TransactionConfirmed(txid)) if txid == confirmed_txid), diff --git a/tests/wallet.rs b/tests/wallet.rs index 68cae317..0204cfd5 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, IntoWalletDescriptor}; use bdk_wallet::error::CreateTxError; -use bdk_wallet::psbt::{self, PsbtUtils}; +use bdk_wallet::psbt::PsbtUtils; use bdk_wallet::signer::{SignOptions, SignerError, SignersContainer}; use bdk_wallet::test_utils::*; use bdk_wallet::KeychainKind; @@ -28,89 +28,6 @@ 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 e7579a7fc948d557d3b5385bf03ca5a63942c2e0 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 16 Jul 2026 08:37:17 -0400 Subject: [PATCH 10/19] fix(psbt): Preserve planned inputs in `replace` - Move `add_planned_input` to `impl PsbtParams`. It was previously available on `PsbtParams` (all contexts), allowing inputs to be erroneously registered after the state transition. - Fix `replace()` to preserve pre-registered planned inputs. A `Transaction` cannot reconstruct `Input` metadata (psbt fields, satisfaction weight, etc.), so planned inputs must be added before calling `replace_txs`. We remove any planned input whose `prev_txid` is directly in the replacement set. - Add `ReplaceByFeeError::ConflictingInput(OutPoint)` in `replace_by_fee_with_rng` after computing the full `to_replace` set, and validate every manually-selected input against it. - Add `test_replace_tx_with_planned_input`, `test_replace_strips_conflicting_planned_input`, and `test_replace_by_fee_conflicting_input_descendant`. --- src/psbt/params.rs | 230 ++++++++++++++++++++++++++++++++++---------- src/wallet/error.rs | 10 ++ src/wallet/mod.rs | 9 ++ tests/psbt.rs | 183 +++++++++++++++++++++++++++++++++++ 4 files changed, 383 insertions(+), 49 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 3a95e1ec..fbfa6171 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -30,7 +30,7 @@ pub type Rbf = ReplaceTx; // TODO: Can we derive `Clone` for this? #[derive(Debug)] pub struct PsbtParams { - /// Set of selected UTXO outpoints. + /// Set of selected UTXO outpoints, `HashSet` ensures uniqueness pub(crate) set: HashSet, /// List of UTXO outpoints to spend. pub(crate) utxos: Vec, @@ -140,13 +140,67 @@ impl PsbtParams { 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. + /// + /// # Warning + /// + /// When combined with [`replace_txs`], planned inputs must **not** spend outputs of any + /// transaction being replaced. The replacement invalidates those outputs, so including them + /// would produce a consensus-invalid transaction. The wallet does not validate this — it is + /// the caller's responsibility to ensure that all planned inputs spend UTXOs that are + /// independent of the replacement set. + /// + /// [`replace_txs`]: PsbtParams::replace_txs + /// + /// # 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, + /// None, + /// )?; + /// 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 + } + /// 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. + /// of `self`. Any previously added UTXOs (via [`add_utxos`]) are cleared and replaced with + /// the inputs of `txs`. Call + /// [`replace_by_fee_with_rng`](crate::Wallet::replace_by_fee_with_rng) to finish building + /// the PSBT. /// /// ## Note /// @@ -155,6 +209,13 @@ impl PsbtParams { /// /// `txs` must not be empty, or creating the PSBT will return [`NoOriginalTransactions`]. /// + /// If the original transaction included inputs added via [`add_planned_input`], those inputs + /// cannot be reconstructed from the transaction alone. To preserve them in the replacement, + /// call [`add_planned_input`] with the same [`Input`] values *before* calling `replace_txs`. + /// + /// [`add_utxos`]: PsbtParams::add_utxos + /// [`add_planned_input`]: PsbtParams::add_planned_input + /// [`Input`]: bdk_tx::Input /// [`NoOriginalTransactions`]: crate::error::ReplaceByFeeError::NoOriginalTransactions pub fn replace_txs(self, txs: impl IntoIterator) -> PsbtParams where @@ -349,49 +410,6 @@ impl PsbtParams { 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, - /// None, - /// )?; - /// 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 @@ -521,8 +539,12 @@ impl PsbtParams { where T: Into>, { - self.utxos.clear(); - self.set.clear(); + // We're resetting the inputs, so remove any existing utxos from + // the set. Pre existing planned inputs are retained. + for outpoint in self.utxos.drain(..) { + self.set.remove(&outpoint); + } + let mut utxos = vec![]; let mut tx_graph = TxGraph::::default(); @@ -553,6 +575,20 @@ impl PsbtParams { } self.replace = txids_to_replace; + + // Strip any pre-registered planned inputs whose immediate parent is a tx being + // replaced. Such an input would spend an output that the replacement invalidates, + // which is invalid. The complete descendant walk is deferred to `replace_by_fee_with_rng` + // which has access to the TxGraph. + self.inputs.retain(|input| { + let prev_txid = input.prev_outpoint().txid; + let conflicts = self.replace.contains(&prev_txid); + if conflicts { + self.set.remove(&input.prev_outpoint()); + } + !conflicts + }); + self.utxos .extend(utxos.iter().copied().filter(|&op| self.set.insert(op))); } @@ -708,4 +744,100 @@ mod test { ); assert!(params.utxos.contains(&op)); } + + // A pre-registered planned input whose `prev_txid` is in `txids_to_replace` must be + // stripped by `replace()`. Retaining it would produce a consensus-invalid transaction + // because the replacement invalidates the very output the planned input is trying to spend. + #[test] + fn test_replace_strips_conflicting_planned_input() { + use bdk_tx::Input as BdkInput; + use bitcoin::{psbt, Sequence}; + + let parent_op = OutPoint::new(Hash::hash(b"parent"), 0); + + // tx_a is the transaction we intend to replace. + let tx_a = Transaction { + input: vec![TxIn { + previous_output: parent_op, + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new_p2wpkh( + &bitcoin::WPubkeyHash::from_slice(&[0u8; 20]).unwrap(), + ), + }], + ..new_tx(0) + }; + let txid_a = tx_a.compute_txid(); + + // A planned input that spends an output of tx_a — the direct conflict case. + let conflicted_op = OutPoint::new(txid_a, 0); + let conflicted_input = BdkInput::from_psbt_input( + conflicted_op, + Sequence::ENABLE_RBF_NO_LOCKTIME, + psbt::Input { + witness_utxo: Some(tx_a.output[0].clone()), + ..Default::default() + }, + /* satisfaction_weight */ 0, + /* status */ None, + /* is_coinbase */ false, + /* absolute_timelock */ None, + ) + .unwrap(); + + // An unrelated planned input that is safe to keep. + let safe_op = OutPoint::new(Hash::hash(b"unrelated_parent"), 1); + let safe_input = BdkInput::from_psbt_input( + safe_op, + Sequence::ENABLE_RBF_NO_LOCKTIME, + psbt::Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh( + &bitcoin::WPubkeyHash::from_slice(&[1u8; 20]).unwrap(), + ), + }), + ..Default::default() + }, + /* satisfaction_weight */ 0, + /* status */ None, + /* is_coinbase */ false, + /* absolute_timelock */ None, + ) + .unwrap(); + + let mut params = PsbtParams::default(); + params + .add_planned_input(conflicted_input) + .add_planned_input(safe_input); + let params = params.replace_txs([tx_a]); + + // The conflicting input must have been stripped from both `inputs` and `set`. + assert!( + !params + .inputs + .iter() + .any(|i| i.prev_outpoint() == conflicted_op), + "conflicting planned input must be stripped from inputs" + ); + assert!( + !params.set.contains(&conflicted_op), + "conflicting outpoint must be removed from the dedup set" + ); + + // The safe input must be preserved. + assert!( + params.inputs.iter().any(|i| i.prev_outpoint() == safe_op), + "unrelated planned input must be retained" + ); + assert!(params.set.contains(&safe_op)); + + // tx_a's own spend must still appear in utxos (the replacement input). + assert!( + params.utxos.contains(&parent_op), + "tx_a's input must be present in replacement utxos" + ); + } } diff --git a/src/wallet/error.rs b/src/wallet/error.rs index a17a3aa3..e24a462c 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -436,6 +436,10 @@ pub enum ReplaceByFeeError { NoOriginalTransactions, /// The replacement transaction has no inputs from the original transaction. NoInputsFromOriginal(Txid), + /// A manually-selected input spends an output of a transaction in the replaced set + /// (either a direct conflict or one of its descendants); including it would produce + /// an invalid transaction. + ConflictingInput(OutPoint), } impl fmt::Display for ReplaceByFeeError { @@ -454,6 +458,12 @@ impl fmt::Display for ReplaceByFeeError { "replacement has no inputs from original transaction: {txid}" ) } + Self::ConflictingInput(outpoint) => { + write!( + f, + "manually-selected input {outpoint} conflicts with the replacement set" + ) + } } } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1d3266d1..bb24e504 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3471,6 +3471,15 @@ impl Wallet { let must_spend = self.build_must_spend_inputs(¶ms, &txouts, &assets)?; + // Validate that no manually-selected input spends an output of a transaction + // in `to_replace`. + for input in &must_spend { + let op = input.prev_outpoint(); + if to_replace.contains(&op.txid) { + return Err(ReplaceByFeeError::ConflictingInput(op)); + } + } + // Get input candidates let mut may_spend: Vec = if params.manually_selected_only { vec![] diff --git a/tests/psbt.rs b/tests/psbt.rs index e3051a8b..1ed5ad39 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -771,6 +771,99 @@ fn test_replace_by_fee_no_original_transactions() { ); } +// Test that `replace_by_fee` rejects a manually-selected input that spends +// from a descendant of the one being replaced. +#[test] +fn test_replace_by_fee_conflicting_input_descendant() { + use bdk_tx::Input as BdkInput; + use bdk_wallet::error::ReplaceByFeeError; + use bitcoin::{psbt as btc_psbt, Sequence}; + + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External).address; + + // Fund the wallet so there is a spendable UTXO. + let funding_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(Hash::hash(b"funding_parent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(500_000), + script_pubkey: addr.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx(&mut wallet, funding_tx); + + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + + // tx_parent: the transaction we will eventually replace. + let mut params = PsbtParams::default(); + params + .add_utxos(&[funding_op]) + .add_recipients([(recip.clone(), Amount::from_sat(100_000))]); + let tx_parent = wallet.create_psbt(params).unwrap().0.unsigned_tx; + let txid_parent = tx_parent.compute_txid(); + insert_tx(&mut wallet, tx_parent.clone()); + + // tx_child: spends one of tx_parent's outputs (a descendant of the tx being replaced). + let child_output = TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh( + &bitcoin::WPubkeyHash::from_slice(&[0u8; 20]).unwrap(), + ), + }; + let child_op = OutPoint::new(txid_parent, 0); + let tx_child = Transaction { + input: vec![TxIn { + previous_output: child_op, + ..Default::default() + }], + output: vec![child_output.clone()], + ..new_tx(1) + }; + let txid_child = tx_child.compute_txid(); + insert_tx(&mut wallet, tx_child.clone()); + + // A planned input that spends an output of tx_child (a descendant of the replaced tx). + // This is the indirect conflict that params-level stripping cannot catch. + let grandchild_op = OutPoint::new(txid_child, 0); + let grandchild_input = BdkInput::from_psbt_input( + grandchild_op, + Sequence::ENABLE_RBF_NO_LOCKTIME, + btc_psbt::Input { + witness_utxo: Some(child_output), + ..Default::default() + }, + /* satisfaction_weight */ 0, + /* status */ None, + /* is_coinbase */ false, + /* absolute_timelock */ None, + ) + .unwrap(); + + // Build replacement for tx_parent, adding the grandchild planned input. + let mut params = PsbtParams::default(); + params.add_planned_input(grandchild_input); + params.add_recipients([(recip, Amount::from_sat(50_000))]); + let params = params.replace_txs([tx_parent]); + + let result = wallet.replace_by_fee(params); + assert!( + matches!(result, Err(ReplaceByFeeError::ConflictingInput(op)) if op == grandchild_op), + "expected ConflictingInput({grandchild_op}), got: {result:?}", + ); +} + #[test] fn test_create_psbt_utxo_filter() { let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); @@ -1186,3 +1279,93 @@ fn test_replace_by_fee_drain_wallet_change_below_dust_error() { "expected AllOutputsBelowDust when RBF change is below dust threshold, got {err:?}" ); } + +#[test] +fn test_replace_tx_with_planned_input() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External).address; + + // Fund the wallet with an unconfirmed output. + let funding_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(Hash::hash(b"funding_parent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(200_000), + script_pubkey: addr.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx(&mut wallet, funding_tx); + + // Create an unconfirmed tx spending the funded UTXO. + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + let op2 = OutPoint::new(Hash::hash(b"txid"), 2); + let txout = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_p2a(), + }; + wallet.insert_txout(op2, txout.clone()); + let psbt_input = bitcoin::psbt::Input { + witness_utxo: Some(txout), + ..Default::default() + }; + let planned_input = bdk_tx::Input::from_psbt_input( + op2, + Sequence::ENABLE_LOCKTIME_NO_RBF, + psbt_input, + /* satisfaction_weight: */ 0, + /* status: */ None, + /* is_coinbase: */ false, + /* absolute_timelock: */ None, + ) + .unwrap(); + + let mut params = PsbtParams::default(); + params + .add_utxos(&[funding_op]) + .add_planned_input(planned_input.clone()) + .add_recipients([(recip.clone(), Amount::from_sat(100_000))]); + let unconfirmed_tx = wallet.create_psbt(params).unwrap().0.unsigned_tx; + insert_tx(&mut wallet, unconfirmed_tx.clone()); + + // Add the planned input *before* calling replace_txs. The replace() method + // should respect pre-registered planned inputs in the unique set. + let mut params = PsbtParams::default(); + params + .add_planned_input(planned_input.clone()) + .add_recipients([(recip, Amount::from_sat(99_000))]); + let params = params.replace_txs([unconfirmed_tx]); + + let (psbt, _) = wallet + .replace_by_fee(params) + .expect("replacement should succeed"); + assert_eq!( + psbt.unsigned_tx.input.len(), + 2, + "replacement tx must include both the wallet input and the planned input" + ); + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|txin| txin.previous_output == funding_op), + "replacement must include the wallet-controlled input" + ); + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|txin| txin.previous_output == op2), + "replacement must include the planned input" + ); +} From a7a5606f6778241bd51b76124f8454ae8c7f2985 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 16 Jul 2026 08:37:18 -0400 Subject: [PATCH 11/19] feat: Re-export bdk_tx --- src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib.rs b/src/lib.rs index a19da8a6..a8b45ce9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub extern crate alloc; pub extern crate bdk_chain as chain; #[cfg(feature = "file_store")] pub extern crate bdk_file_store as file_store; +pub extern crate bdk_tx; #[cfg(feature = "keys-bip39")] pub extern crate bip39; pub extern crate bitcoin; From 75bf298d1230f090bca22a1f612651b9648de064 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 16 Jul 2026 08:37:19 -0400 Subject: [PATCH 12/19] feat(psbt): Remove drain_wallet in favor of SelectionStrategy::All --- examples/replace_by_fee.rs | 4 +-- src/psbt/params.rs | 66 +++++++++++++++++++++++++++----------- src/wallet/error.rs | 4 +-- src/wallet/mod.rs | 57 +++++++++++++++++--------------- tests/psbt.rs | 12 ++++--- 5 files changed, 89 insertions(+), 54 deletions(-) diff --git a/examples/replace_by_fee.rs b/examples/replace_by_fee.rs index feb4d156..f69cfd39 100644 --- a/examples/replace_by_fee.rs +++ b/examples/replace_by_fee.rs @@ -47,9 +47,9 @@ fn main() -> anyhow::Result<()> { // 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")); + .fee_rate(FeeRate::from_sat_per_vb(2).expect("valid feerate")) + .coin_selection(bdk_wallet::SelectionStrategy::All); let (mut psbt1, finalizer1) = wallet.create_psbt(params)?; diff --git a/src/psbt/params.rs b/src/psbt/params.rs index fbfa6171..4f6fba42 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -5,7 +5,7 @@ use alloc::vec::Vec; use core::fmt; use bdk_chain::{BlockId, CanonicalizationParams, ConfirmationBlockTime, FullTxOut, TxGraph}; -use bdk_tx::{ChangeScript, Input, Output}; +use bdk_tx::{ChangeScript, Input, Output, Selector, SelectorError}; use bitcoin::{ absolute, transaction::Version, Amount, FeeRate, OutPoint, ScriptBuf, Sequence, Transaction, Txid, @@ -44,8 +44,6 @@ pub struct PsbtParams { 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. @@ -94,7 +92,6 @@ impl Default for PsbtParams { 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(), @@ -236,7 +233,6 @@ impl PsbtParams { 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, @@ -356,6 +352,17 @@ impl PsbtParams { self } + /// Set a custom coin selection algorithm. + pub fn coin_selection_algorithm(&mut self, algorithm: F) -> &mut Self + where + F: Fn(&mut Selector) -> Result<(), SelectorError> + Send + Sync + 'static, + { + self.coin_selection = SelectionStrategy::Custom { + algorithm: Arc::new(algorithm), + }; + 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 @@ -423,15 +430,6 @@ impl PsbtParams { 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); @@ -492,12 +490,21 @@ impl PsbtParams { } /// Coin select strategy. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Clone, Default)] #[non_exhaustive] pub enum SelectionStrategy { - /// Single random draw. - #[default] - SingleRandomDraw, + /// Select all available coins, i.e. drain the wallet funds. + /// + /// Note: this will only select coins that are eligible to be spent in the transaction, + /// and not those that are already excluded or ineligible e.g. if a spending plan can't be + /// created. + All, + /// Custom + Custom { + /// Selection algorithm + #[allow(clippy::type_complexity)] + algorithm: Arc Result<(), SelectorError>>, + }, /// Lowest fee, a variation of Branch 'n Bound that allows for change /// while minimizing transaction fees. Refer to /// [`LowestFee`] metric for more. @@ -509,6 +516,27 @@ pub enum SelectionStrategy { /// How many times to run BnB before giving up. max_rounds: usize, }, + /// Single random draw. + #[default] + SingleRandomDraw, +} + +impl fmt::Debug for SelectionStrategy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::All => write!(f, "All"), + Self::Custom { .. } => write!(f, "Custom"), + Self::LowestFee { + longterm_feerate, + max_rounds, + } => f + .debug_struct("LowestFee") + .field("long_term_feerate", longterm_feerate) + .field("max_rounds", max_rounds) + .finish(), + Self::SingleRandomDraw => write!(f, "SingleRandomDraw"), + } + } } /// [`UtxoFilter`] is a user-defined `Fn` closure which decides whether to include a UTXO @@ -640,7 +668,7 @@ mod test { // Create psbt let mut params = PsbtParams::default(); params.change_script(ChangeScript::from_descriptor(change_descriptor)); - params.drain_wallet(); + params.coin_selection(SelectionStrategy::All); let (psbt, _) = wallet.create_psbt(params).unwrap(); let tx = psbt.unsigned_tx; let txid1 = tx.compute_txid(); diff --git a/src/wallet/error.rs b/src/wallet/error.rs index e24a462c..c4b4ee09 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -374,9 +374,9 @@ pub enum CreatePsbtError { /// No Bnb solution. Bnb(bdk_coin_select::NoBnbSolution), /// No output destinations were configured. At least one recipient, or - /// [`drain_wallet`] with an explicit [`change_script`], is required. + /// [`SelectionStrategy::All`] with an explicit [`change_script`], is required. /// - /// [`drain_wallet`]: crate::PsbtParams::drain_wallet + /// [`SelectionStrategy::All`]: crate::SelectionStrategy::All /// [`change_script`]: crate::PsbtParams::change_script NoRecipients, /// After coin selection, all outputs fell below the dust threshold and were diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index bb24e504..4ebcbd7f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3170,7 +3170,9 @@ impl Wallet { ) -> Result<(Psbt, Finalizer), CreatePsbtError> { // Only permit no recipients if we're doing a sweep and an explicit change script is // provided. - if params.recipients.is_empty() && !(params.drain_wallet && params.change_script.is_some()) + if params.recipients.is_empty() + && !(matches!(params.coin_selection, SelectionStrategy::All) + && params.change_script.is_some()) { return Err(CreatePsbtError::NoRecipients); } @@ -3251,30 +3253,31 @@ impl Wallet { 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)?; - } - }; - } + match params.coin_selection { + SelectionStrategy::All => selector.select_all(), + SelectionStrategy::Custom { ref algorithm } => selector + .select_with_algorithm(|s| algorithm(s)) + .map_err(CreatePsbtError::Selector)?, + SelectionStrategy::LowestFee { + longterm_feerate, + max_rounds, + } => { + selector + .select_with_algorithm(selection_algorithm_lowest_fee_bnb( + longterm_feerate, + max_rounds, + )) + .map_err(CreatePsbtError::Bnb)?; + } + SelectionStrategy::SingleRandomDraw => { + // We should have shuffled candidates earlier, so just select + // until the target is met. + // TODO: Can we shuffle candidates here instead using select_with_algo? + selector + .select_until_target_met() + .map_err(CreatePsbtError::InsufficientFunds)?; + } + }; let mut selection = selector.try_finalize().ok_or({ let e = bdk_tx::CannotMeetTarget; CreatePsbtError::Selector(bdk_tx::SelectorError::CannotMeetTarget(e)) @@ -3399,7 +3402,9 @@ impl Wallet { } // Only permit no recipients if we're doing a sweep and an explicit change script is // provided. - if params.recipients.is_empty() && !(params.drain_wallet && params.change_script.is_some()) + if params.recipients.is_empty() + && !(matches!(params.coin_selection, SelectionStrategy::All) + && params.change_script.is_some()) { return Err(ReplaceByFeeError::CreatePsbt(CreatePsbtError::NoRecipients)); } diff --git a/tests/psbt.rs b/tests/psbt.rs index 1ed5ad39..536a3bb4 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -3,7 +3,9 @@ use bdk_tx::bdk_coin_select; use bdk_tx::ChangeScript; use bdk_wallet::bitcoin; use bdk_wallet::test_utils::*; -use bdk_wallet::{error::CreatePsbtError, psbt, KeychainKind, PsbtParams, SignOptions, Wallet}; +use bdk_wallet::{ + error::CreatePsbtError, psbt, KeychainKind, PsbtParams, SelectionStrategy, SignOptions, Wallet, +}; use bitcoin::{ absolute, hashes::Hash, Amount, FeeRate, Network, OutPoint, Psbt, ScriptBuf, Sequence, Transaction, TxIn, TxOut, @@ -905,7 +907,7 @@ fn test_create_psbt_utxo_filter() { .unwrap(), ); params.change_script(change_script); - params.drain_wallet(); + params.coin_selection(psbt::SelectionStrategy::All); let (psbt, _) = wallet.create_psbt(params).unwrap(); assert_eq!(psbt.unsigned_tx.input.len(), 2); assert_eq!(psbt.unsigned_tx.output.len(), 1); @@ -951,7 +953,7 @@ fn test_create_psbt_no_recipients_error() { .at_derivation_index(0) .unwrap(); params - .drain_wallet() + .coin_selection(SelectionStrategy::All) .change_script(ChangeScript::from_descriptor(change_descriptor)); wallet .create_psbt(params) @@ -1205,7 +1207,7 @@ fn test_create_psbt_drain_wallet_change_below_dust_error() { .unwrap(); let mut params = PsbtParams::default(); params - .drain_wallet() + .coin_selection(SelectionStrategy::All) .change_script(ChangeScript::from_descriptor(change_descriptor)); let err = wallet.create_psbt(params).unwrap_err(); @@ -1268,7 +1270,7 @@ fn test_replace_by_fee_drain_wallet_change_below_dust_error() { .unwrap(); let mut params = PsbtParams::default().replace_txs([original_tx]); params - .drain_wallet() + .coin_selection(SelectionStrategy::All) .change_script(ChangeScript::from_descriptor(change_descriptor)); let err = wallet.replace_by_fee(params).unwrap_err(); assert!( From b9d16bcead4312065f307c8fa73305e56ece8b8b Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 16 Jul 2026 08:37:21 -0400 Subject: [PATCH 13/19] fix(wallet): Implement SingleRandomDraw as a coin select algo Shuffling the input candidates now occurs only in the case that SingleRandomDraw is the chosen selection strategy. As such, the extra calls to `shuffle_slice` can be removed. --- src/wallet/mod.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 4ebcbd7f..066178a6 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3202,8 +3202,6 @@ impl Wallet { } } - utils::shuffle_slice(&mut may_spend, rng); - let target_outputs = self.target_outputs(¶ms); let input_candidates = InputCandidates::new(must_spend, may_spend); @@ -3270,12 +3268,18 @@ impl Wallet { .map_err(CreatePsbtError::Bnb)?; } SelectionStrategy::SingleRandomDraw => { - // We should have shuffled candidates earlier, so just select - // until the target is met. - // TODO: Can we shuffle candidates here instead using select_with_algo? - selector - .select_until_target_met() - .map_err(CreatePsbtError::InsufficientFunds)?; + // Implement a shuffle algorithm by associating every candidate with + // a random sort key. + selector.select_with_algorithm(|selector| -> Result<_, CreatePsbtError> { + let n = selector.inner().candidates().count(); + let keys: Vec = (0..n).map(|_| rng.next_u32()).collect(); + selector + .inner_mut() + .sort_candidates_by(|(a, _), (b, _)| keys[a].cmp(&keys[b])); + selector + .select_until_target_met() + .map_err(CreatePsbtError::InsufficientFunds) + })? } }; let mut selection = selector.try_finalize().ok_or({ @@ -3511,8 +3515,6 @@ impl Wallet { } } - utils::shuffle_slice(&mut may_spend, rng); - let target_outputs = self.target_outputs(¶ms); let input_candidates = InputCandidates::new(must_spend, may_spend); From c9b8b7939958b1a3b4e693900aadffb6553f2d22 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Fri, 17 Jul 2026 14:30:39 -0400 Subject: [PATCH 14/19] build: Add cfg bdk_wallet_unstable and feature-gate create_psbt API `bdk_tx` is now an optional dependency controlled by the new `bdk-tx` feature. All `create_psbt`/`replace_by_fee` surface area is gated behind `cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))`. Enabling the `bdk-tx` feature without also setting `--cfg bdk_wallet_unstable` in RUSTFLAGS is a compile error with a useful error message. The justfile and CI are updated to pass the `bdk_wallet_unstable` flag in order to test all features. All `create_psbt` and `replace_by_fee` tests are consolidated into a new `tests/create_psbt.rs` gated by `bdk_wallet_unstable`. `.gitignore` is updated to exclude a local `.cargo/config.toml`, and an Unreleased CHANGELOG entry documents the opt-in requirement for users. --- .github/workflows/code_coverage.yml | 4 +- .github/workflows/cont_integration.yml | 36 +- .gitignore | 1 + CHANGELOG.md | 15 + Cargo.toml | 13 +- justfile | 21 +- src/lib.rs | 5 +- src/psbt/mod.rs | 3 +- src/wallet/error.rs | 8 + src/wallet/mod.rs | 47 +- tests/add_foreign_utxo.rs | 55 +- tests/create_psbt.rs | 1208 ++++++++++++++++++++++++ tests/psbt.rs | 1153 +--------------------- 13 files changed, 1321 insertions(+), 1248 deletions(-) create mode 100644 tests/create_psbt.rs diff --git a/.github/workflows/code_coverage.yml b/.github/workflows/code_coverage.yml index 9c606f66..063470fd 100644 --- a/.github/workflows/code_coverage.yml +++ b/.github/workflows/code_coverage.yml @@ -31,11 +31,11 @@ jobs: - name: Generate coverage data run: cargo llvm-cov --all-features --branch --quiet --ignore-filename-regex "test_utils" --lcov --output-path lcov.info env: - RUSTFLAGS: "--cfg coverage_nightly" + RUSTFLAGS: "--cfg coverage_nightly --cfg bdk_wallet_unstable" - name: Generate HTML coverage report run: cargo llvm-cov --all-features --branch --quiet --ignore-filename-regex "test_utils" --html env: - RUSTFLAGS: "--cfg coverage_nightly" + RUSTFLAGS: "--cfg coverage_nightly --cfg bdk_wallet_unstable" - name: Codecov upload uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index 1ebe157c..af5e78bf 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -20,9 +20,6 @@ jobs: os: - ubuntu-latest - ubuntu-24.04-arm - features: - - --no-default-features --features miniscript/no-std,bdk_chain/hashbrown - - --all-features steps: - name: Checkout uses: actions/checkout@v6 @@ -37,10 +34,15 @@ jobs: cache: true - name: Pin dependencies for MSRV run: ./ci/pin-msrv.sh - - name: Build + Test + - name: Build (no default features) run: | - cargo build --workspace --all-targets ${{ matrix.features }} - cargo test --workspace ${{ matrix.features }} + cargo build --workspace --no-default-features --features miniscript/no-std,bdk_chain/hashbrown + - name: Test all features + env: + RUSTFLAGS: "--cfg bdk_wallet_unstable" + RUSTDOCFLAGS: "--cfg bdk_wallet_unstable" + run: | + cargo test --workspace --all-features build-test-stable: name: Build & Test Rust Stable @@ -50,9 +52,6 @@ jobs: os: - ubuntu-latest - ubuntu-24.04-arm - features: - - --no-default-features --features miniscript/no-std,bdk_chain/hashbrown - - --all-features steps: - name: Checkout uses: actions/checkout@v6 @@ -64,10 +63,15 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1 with: cache: true - - name: Build + Test + - name: Build (no default features) + run: | + cargo build --workspace --no-default-features --features miniscript/no-std,bdk_chain/hashbrown + - name: Test all features + env: + RUSTFLAGS: "--cfg bdk_wallet_unstable" + RUSTDOCFLAGS: "--cfg bdk_wallet_unstable" run: | - cargo build --workspace --all-targets ${{ matrix.features }} - cargo test --workspace ${{ matrix.features }} + cargo test --workspace --all-features check-no-std: name: Check no_std @@ -144,7 +148,9 @@ jobs: with: cache: true - name: Clippy - run: cargo clippy --all-features --all-targets -- -D warnings + env: + RUSTFLAGS: "-D warnings --cfg bdk_wallet_unstable" + run: cargo clippy --all-features --all-targets docs_check: name: Check cargo doc @@ -160,4 +166,6 @@ jobs: with: cache: true - name: Check docs - run: RUSTDOCFLAGS='-D warnings' cargo doc --workspace --all-features --no-deps + env: + RUSTDOCFLAGS: "-D warnings --cfg bdk_wallet_unstable" + run: cargo doc --workspace --all-features --no-deps diff --git a/.gitignore b/.gitignore index d3854bca..2c2430bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /target Cargo.lock /.vscode +/.cargo/config.toml *.swp .idea diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a27fa13..a17240ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ Contributors do not need to change this file but do need to add changelog detail The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- feat(wallet): add unstable `Wallet::create_psbt` and `Wallet::replace_by_fee` behind the + `bdk-tx` cargo feature and the `--cfg bdk_wallet_unstable` rustc flag. These APIs are + explicitly **unstable**: breaking changes may land in minor releases without a semver bump. + To opt in, enable the `bdk-tx` feature **and** pass `--cfg bdk_wallet_unstable` to rustc, + for example via `RUSTFLAGS="--cfg bdk_wallet_unstable"` or by adding the following to your + workspace's `.cargo/config.toml`: + ```toml + [build] + rustflags = ["--cfg", "bdk_wallet_unstable"] + ``` + ## [v3.1.0] ### Added diff --git a/Cargo.toml b/Cargo.toml index e63593d1..4fff43be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,14 +14,14 @@ rust-version = "1.85.0" [package.metadata.docs.rs] all-features = true -rustdoc-args = ["--cfg", "docsrs"] +rustdoc-args = ["--cfg", "docsrs", "--cfg", "bdk_wallet_unstable"] [lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)', 'cfg(bdk_wallet_unstable)'] } [dependencies] bdk_chain = { version = "0.23.3", features = ["miniscript", "serde"], default-features = false } -bdk_tx = { version = "0.2.0", default-features = false } +bdk_tx = { version = "0.2.0", default-features = false, optional = true } bitcoin = { version = "0.32.8", features = ["serde", "base64"], default-features = false } miniscript = { version = "12.3.5", features = ["serde"], default-features = false } rand_core = { version = "0.6.4" } @@ -37,12 +37,13 @@ tempfile = { version = "3.26.0", optional = true } [features] default = ["std"] -std = ["bitcoin/std", "bitcoin/rand-std", "miniscript/std", "bdk_chain/std", "bdk_tx/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"] rusqlite = ["bdk_chain/rusqlite"] file_store = ["bdk_file_store"] +bdk-tx = ["dep:bdk_tx"] test-utils = ["std", "anyhow", "tempfile"] [dev-dependencies] @@ -51,6 +52,7 @@ assert_matches = "1.5.0" bdk_bitcoind_rpc = { version = "0.22.0" } bdk_electrum = { version = "0.23.2" } bdk_esplora = { version = "0.22.1", features = ["async-https", "blocking-https", "tokio"] } +bdk_tx = { version = "0.2.0" } bdk_wallet = { path = ".", features = ["rusqlite", "file_store", "test-utils"] } clap = { version = "4.5.60", features = ["derive", "env"] } ctrlc = "3.5.2" @@ -82,6 +84,9 @@ name = "bitcoind_rpc" [[example]] name = "psbt" +required-features = ["bdk-tx"] [[example]] name = "replace_by_fee" +required-features = ["bdk-tx"] + diff --git a/justfile b/justfile index 473f6636..b4246dc0 100644 --- a/justfile +++ b/justfile @@ -7,6 +7,9 @@ alias t := test alias p := pre-push alias d := doc +# All features not including the unstable ones +FEATURES := "std,compiler,all-keys,rusqlite,file_store,test-utils" + _default: @just --list @@ -17,8 +20,9 @@ build: # Check code: formatting, compilation, linting, and commit signature check: cargo +nightly fmt --all -- --check - cargo check --all-features --all-targets - cargo clippy --all-features --all-targets -- -D warnings + RUSTFLAGS="" cargo check --no-default-features --features miniscript/no-std,bdk_chain/hashbrown --all-targets + RUSTFLAGS="" cargo check --features {{FEATURES}} --all-targets + RUSTFLAGS="--cfg bdk_wallet_unstable" cargo clippy --all-features --all-targets -- -D warnings @[ "$(git log --pretty='format:%G?' -1 HEAD)" = "N" ] && \ echo "\n⚠️ Unsigned commit: BDK requires that commits be signed." || \ true @@ -27,13 +31,20 @@ check: fmt: cargo +nightly fmt +# Run tests on the stable API surface +_test: + RUSTFLAGS="" cargo test --workspace --features {{FEATURES}} + +# Run tests on the unstable API surface (with --cfg bdk_wallet_unstable) +_test-unstable: + RUSTDOCFLAGS="--cfg bdk_wallet_unstable" RUSTFLAGS="--cfg bdk_wallet_unstable" cargo test --workspace --all-features + # Run all tests on the workspace with all features -test: - cargo test --all-features +test: _test _test-unstable # Check docs on the workspace doc: - RUSTDOCFLAGS='-D warnings' cargo doc --workspace --all-features --no-deps + RUSTDOCFLAGS="-D warnings --cfg bdk_wallet_unstable" cargo doc --workspace --all-features --no-deps # Run pre-push suite: format, check, and test pre-push: fmt check test doc diff --git a/src/lib.rs b/src/lib.rs index a8b45ce9..905267e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,9 @@ pub extern crate alloc; pub extern crate bdk_chain as chain; #[cfg(feature = "file_store")] pub extern crate bdk_file_store as file_store; +#[cfg(all(feature = "bdk-tx", not(bdk_wallet_unstable)))] +compile_error!("The `bdk-tx` feature requires `--cfg bdk_wallet_unstable`."); +#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] pub extern crate bdk_tx; #[cfg(feature = "keys-bip39")] pub extern crate bip39; @@ -56,5 +59,5 @@ pub use wallet::*; /// Get the version of [`bdk_wallet`](crate) at runtime. pub fn version() -> &'static str { - env!("CARGO_PKG_VERSION", "unknown") + env!("CARGO_PKG_VERSION") } diff --git a/src/psbt/mod.rs b/src/psbt/mod.rs index dfc0b016..72d889cd 100644 --- a/src/psbt/mod.rs +++ b/src/psbt/mod.rs @@ -17,8 +17,9 @@ use bitcoin::FeeRate; use bitcoin::Psbt; use bitcoin::TxOut; +#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] mod params; - +#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] pub use params::*; // TODO upstream the functions here to `rust-bitcoin`? diff --git a/src/wallet/error.rs b/src/wallet/error.rs index c4b4ee09..2ef1cc28 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -19,6 +19,7 @@ use alloc::{ boxed::Box, string::{String, ToString}, }; +#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] use bdk_tx::bdk_coin_select; use bitcoin::{absolute, psbt, Amount, BlockHash, Network, OutPoint, Sequence, Txid}; use core::fmt; @@ -368,6 +369,7 @@ impl fmt::Display for BuildFeeBumpError { impl core::error::Error for BuildFeeBumpError {} /// Error when creating a PSBT. +#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] #[derive(Debug)] #[non_exhaustive] pub enum CreatePsbtError { @@ -401,6 +403,7 @@ pub enum CreatePsbtError { Sequence(bdk_tx::SetSequenceError), } +#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] impl fmt::Display for CreatePsbtError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -418,9 +421,11 @@ impl fmt::Display for CreatePsbtError { } } +#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] impl core::error::Error for CreatePsbtError {} /// Error when creating a Replace-By-Fee transaction. +#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] #[derive(Debug)] #[non_exhaustive] pub enum ReplaceByFeeError { @@ -442,6 +447,7 @@ pub enum ReplaceByFeeError { ConflictingInput(OutPoint), } +#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] impl fmt::Display for ReplaceByFeeError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -468,8 +474,10 @@ impl fmt::Display for ReplaceByFeeError { } } +#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] impl core::error::Error for ReplaceByFeeError {} +#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] 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 066178a6..e860422c 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -31,21 +31,14 @@ use bdk_chain::{ SyncResponse, }, tx_graph::{CalculateFeeError, CanonicalTx, TxGraph, TxUpdate}, - Anchor, BlockId, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, + 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, relative, + psbt, secp256k1::Secp256k1, sighash::{EcdsaSighashType, TapSighashType}, transaction, Address, Amount, Block, FeeRate, Network, NetworkKind, OutPoint, Psbt, ScriptBuf, @@ -53,9 +46,7 @@ use bitcoin::{ }; use miniscript::{ descriptor::KeyMap, - plan::{Assets, Plan}, psbt::{PsbtExt, PsbtInputExt, PsbtInputSatisfier}, - ForEachKey, }; use rand_core::RngCore; @@ -79,17 +70,37 @@ use crate::descriptor::{ policy::BuildSatisfaction, DerivedDescriptor, DescriptorMeta, ExtendedDescriptor, ExtractPolicy, IntoWalletDescriptor, Policy, XKeyUtils, }; -use crate::psbt::{AssetsExt, CreateTx, PsbtParams, PsbtUtils, Rbf, SelectionStrategy}; +use crate::psbt::PsbtUtils; use crate::types::*; use crate::wallet::{ coin_selection::{DefaultCoinSelectionAlgorithm, Excess, InsufficientFunds}, - error::{ - BuildFeeBumpError, CreatePsbtError, CreateTxError, MiniscriptPsbtError, ReplaceByFeeError, - }, + error::{BuildFeeBumpError, CreateTxError, MiniscriptPsbtError}, signer::{SignOptions, SignerError, SignerOrdering, SignersContainer, TransactionSigner}, - tx_builder::{FeePolicy, TxBuilder, TxOrdering, TxParams}, + tx_builder::{FeePolicy, TxBuilder, TxParams}, utils::{check_nsequence_rbf, After, Older, SecpCtx}, }; +// Unstable bdk-tx imports — only active with --cfg bdk_wallet_unstable and feature = "bdk-tx". +#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx", feature = "std"))] +use bitcoin::secp256k1::rand; +#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] +use { + crate::psbt::{AssetsExt, CreateTx, PsbtParams, Rbf, SelectionStrategy}, + crate::wallet::{ + error::{CreatePsbtError, ReplaceByFeeError}, + tx_builder::TxOrdering, + }, + bdk_chain::Anchor, + bdk_tx::{ + bdk_coin_select, selection_algorithm_lowest_fee_bnb, ChangeScript, ConfirmationStatus, + Finalizer, Input, InputCandidates, OriginalTxStats, Output, RbfParams, Selector, + SelectorParams, + }, + bitcoin::relative, + miniscript::{ + plan::{Assets, Plan}, + ForEachKey, + }, +}; // re-exports pub use bdk_chain::Balance; @@ -101,6 +112,7 @@ pub use persisted::*; pub use utils::{IsDust, TxDetails}; /// Alias [`FullTxOut`] with associated keychain and derivation index. +#[allow(unused)] type IndexedTxOut = ((KeychainKind, u32), FullTxOut); /// A Bitcoin wallet @@ -803,6 +815,7 @@ impl Wallet { } /// List indexed [`FullTxOut`]s. + #[allow(unused)] fn list_indexed_txouts( &self, params: CanonicalizationParams, @@ -2929,6 +2942,7 @@ impl Wallet { /// /// [`Height`]: bitcoin::absolute::Height /// [`Time`]: bitcoin::absolute::Time +#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] fn status_from_position(pos: ChainPosition) -> Option { if let ChainPosition::Confirmed { anchor, .. } = pos { let conf_height = anchor.confirmation_height_upper_bound(); @@ -2945,6 +2959,7 @@ fn status_from_position(pos: ChainPosition) -> Option 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, - /* absolute_timelock: */ None, - )?; - - 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/create_psbt.rs b/tests/create_psbt.rs new file mode 100644 index 00000000..0233862d --- /dev/null +++ b/tests/create_psbt.rs @@ -0,0 +1,1208 @@ +//! Integration tests for the unstable `create_psbt` and `replace_by_fee` APIs. +#![cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] + +use bdk_chain::{BlockId, ConfirmationBlockTime}; +use bdk_tx::{bdk_coin_select, ChangeScript}; +use bdk_wallet::bitcoin; +use bdk_wallet::test_utils::*; +use bdk_wallet::{ + error::CreatePsbtError, psbt, KeychainKind, PsbtParams, SelectionStrategy, Wallet, +}; +use bitcoin::{ + absolute, hashes::Hash, Amount, FeeRate, Network, OutPoint, ScriptBuf, Sequence, Transaction, + TxIn, TxOut, +}; +use miniscript::plan::Assets; + +// 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) + .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(), 0); + assert_eq!(tx.input.len(), 1); + assert_eq!(tx.output.len(), 2); + + // global xpubs + assert_eq!( + psbt.xpub, + [(expected_xpub, ("f6a5cb8b".parse().unwrap(), vec![].into()))].into(), + ); + // witness utxo + let psbt_input = &psbt.inputs[0]; + assert_eq!( + psbt_input.witness_utxo.as_ref().map(|txo| txo.value), + Some(Amount::ONE_BTC), + ); + // input internal key + assert!(psbt_input.tap_internal_key.is_some()); + // input key origins + assert!(psbt_input + .tap_key_origins + .values() + .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b")); + // output internal key + assert!(psbt + .outputs + .iter() + .any(|output| output.tap_internal_key.is_some())); + // output key origins + assert!(psbt.outputs.iter().any(|output| output + .tap_key_origins + .values() + .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b"))); +} + +#[test] +fn test_create_psbt_insufficient_funds_error() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External); + + let mut params = 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)); + } +} + +/// Fallback sequence is applied to a coin-selected input that has no CSV +/// requirement. +#[test] +fn test_create_psbt_fallback_sequence_applied_to_coin_selected_input() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut params = PsbtParams::default(); + params + .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) + .fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); + let psbt = wallet.create_psbt(params).unwrap().0; + assert_eq!( + psbt.unsigned_tx.input[0].sequence, + Sequence::ENABLE_RBF_NO_LOCKTIME + ); +} + +/// Fallback sequence is NOT applied when the input already has a CSV-derived sequence +/// requirement — the CSV value wins. +#[test] +fn test_create_psbt_fallback_sequence_skipped_for_csv_input() { + use bitcoin::relative; + let mut wallet = Wallet::create_single(get_test_single_sig_csv()) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 10_000, + hash: Hash::hash(b"csv_fallback"), + }, + confirmation_time: 1_234_567_000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let op = receive_output( + &mut wallet, + Amount::from_sat(100_000), + ReceiveTo::Block(anchor), + ); + + let addr = wallet.next_unused_address(KeychainKind::External); + let rel_locktime = relative::LockTime::from_consensus(6).unwrap(); + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .add_assets(Assets::new().older(rel_locktime)) + .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) + .fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); + let psbt = wallet.create_psbt(params).unwrap().0; + // CSV descriptor requires older(6); fallback must not clobber the CSV-derived sequence. + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); +} + +/// A per-input sequence override is applied to a manually-selected UTXO. +#[test] +fn test_create_psbt_sequence_override_manually_selected_input() { + let (mut wallet, txid) = get_funded_wallet_wpkh(); + let utxo = OutPoint::new(txid, 0); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut params = PsbtParams::default(); + params + .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) + .add_utxos(&[utxo]) + .manually_selected_only() + .sequence_override(utxo, Sequence(42)); + let psbt = wallet.create_psbt(params).unwrap().0; + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(42)); +} + +/// A per-input sequence override takes precedence over a fallback sequence. +#[test] +fn test_create_psbt_sequence_override_takes_precedence_over_fallback() { + let (mut wallet, txid) = get_funded_wallet_wpkh(); + let utxo = OutPoint::new(txid, 0); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut params = PsbtParams::default(); + params + .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) + .add_utxos(&[utxo]) + .manually_selected_only() + .sequence_override(utxo, Sequence(42)) + .fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); + let psbt = wallet.create_psbt(params).unwrap().0; + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(42)); +} + +/// A sequence override that violates the CSV requirement returns a Sequence error. +#[test] +fn test_create_psbt_sequence_override_csv_conflict_returns_error() { + use bitcoin::relative; + let mut wallet = Wallet::create_single(get_test_single_sig_csv()) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 10_000, + hash: Hash::hash(b"csv_override"), + }, + confirmation_time: 1_234_567_000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let op = receive_output( + &mut wallet, + Amount::from_sat(100_000), + ReceiveTo::Block(anchor), + ); + + let addr = wallet.next_unused_address(KeychainKind::External); + let rel_locktime = relative::LockTime::from_consensus(6).unwrap(); + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .add_assets(Assets::new().older(rel_locktime)) + .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) + .manually_selected_only() + .sequence_override(op, Sequence(3)); // CSV requires >= 6 + let result = wallet.create_psbt(params); + assert!(matches!(result, Err(CreatePsbtError::Sequence(_)))); +} + +// 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 mut params = PsbtParams::default(); + params.add_recipients([(external, Amount::from_sat(100_000))]); + params.fee_rate(FeeRate::from_sat_per_vb(4).unwrap()); + let params = params.replace_txs([tx_a]); + let (psbt, _) = wallet + .replace_by_fee(params) + .expect("failed to create RBF PSBT"); + + let replacement_fee = wallet + .calculate_fee(&psbt.unsigned_tx) + .expect("replacement tx fee should be calculable"); + + assert!( + replacement_fee >= total_original_fee, + "replacement fee ({replacement_fee}) must be >= sum of fees for A + B + C ({total_original_fee})", + ); +} + +// Test that `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.clone(), 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); + + // Attempting to replace the now-confirmed tx should return TransactionConfirmed. + let mut params = PsbtParams::default(); + params.add_recipients([(recip, Amount::from_sat(10_000))]); + params.fee_rate(FeeRate::from_sat_per_vb(10).unwrap()); + let params = params.replace_txs([unconfirmed_tx]); + let result = wallet.replace_by_fee(params); + + assert!( + matches!(result, Err(ReplaceByFeeError::TransactionConfirmed(txid)) if txid == confirmed_txid), + "expected TransactionConfirmed error, got: {result:?}", + ); +} + +// Test that `replace_by_fee` errors when all original inputs have been removed via +// `remove_utxo`, leaving the replacement with no inputs from the replaced transaction. +#[test] +fn test_replace_by_fee_no_inputs_from_original() { + 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 addr = wallet.reveal_next_address(External).address; + + // Fund the wallet with an unconfirmed output. + let funding_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(Hash::hash(b"funding_parent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(200_000), + script_pubkey: addr.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx(&mut wallet, funding_tx); + + // Create an unconfirmed tx spending the funded UTXO. + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + let mut params = PsbtParams::default(); + params + .add_utxos(&[funding_op]) + .add_recipients([(recip.clone(), Amount::from_sat(100_000))]); + let unconfirmed_tx = wallet.create_psbt(params).unwrap().0.unsigned_tx; + let unconfirmed_txid = unconfirmed_tx.compute_txid(); + insert_tx(&mut wallet, unconfirmed_tx.clone()); + + // Build replacement params with a recipient but remove the original inputs. + let mut params = PsbtParams::default().replace_txs([unconfirmed_tx]); + params + .remove_utxo(&funding_op) + .add_recipients([(recip, Amount::from_sat(50_000))]); + + let result = wallet.replace_by_fee(params); + assert!( + matches!(result, Err(ReplaceByFeeError::NoInputsFromOriginal(txid)) if txid == unconfirmed_txid), + "expected NoInputsFromOriginal error, got: {result:?}", + ); +} + +// Test that `replace_by_fee` returns `NoOriginalTransactions` when `replace_txs` is called +// with an empty list, i.e. no transactions were provided for replacement. +#[test] +fn test_replace_by_fee_no_original_transactions() { + use bdk_wallet::error::ReplaceByFeeError; + + 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(); + + // replace_txs with an empty iterator produces PsbtParams with an empty replace set. + let params = PsbtParams::default().replace_txs(core::iter::empty::()); + let result = wallet.replace_by_fee(params); + assert!( + matches!(result, Err(ReplaceByFeeError::NoOriginalTransactions)), + "expected NoOriginalTransactions, got: {result:?}", + ); +} + +// Test that `replace_by_fee` rejects a manually-selected input that spends +// from a descendant of the one being replaced. +#[test] +fn test_replace_by_fee_conflicting_input_descendant() { + use bdk_tx::Input as BdkInput; + use bdk_wallet::error::ReplaceByFeeError; + use bitcoin::{psbt as btc_psbt, Sequence}; + + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External).address; + + // Fund the wallet so there is a spendable UTXO. + let funding_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(Hash::hash(b"funding_parent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(500_000), + script_pubkey: addr.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx(&mut wallet, funding_tx); + + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + + // tx_parent: the transaction we will eventually replace. + let mut params = PsbtParams::default(); + params + .add_utxos(&[funding_op]) + .add_recipients([(recip.clone(), Amount::from_sat(100_000))]); + let tx_parent = wallet.create_psbt(params).unwrap().0.unsigned_tx; + let txid_parent = tx_parent.compute_txid(); + insert_tx(&mut wallet, tx_parent.clone()); + + // tx_child: spends one of tx_parent's outputs (a descendant of the tx being replaced). + let child_output = TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh( + &bitcoin::WPubkeyHash::from_slice(&[0u8; 20]).unwrap(), + ), + }; + let child_op = OutPoint::new(txid_parent, 0); + let tx_child = Transaction { + input: vec![TxIn { + previous_output: child_op, + ..Default::default() + }], + output: vec![child_output.clone()], + ..new_tx(1) + }; + let txid_child = tx_child.compute_txid(); + insert_tx(&mut wallet, tx_child.clone()); + + // A planned input that spends an output of tx_child (a descendant of the replaced tx). + // This is the indirect conflict that params-level stripping cannot catch. + let grandchild_op = OutPoint::new(txid_child, 0); + let grandchild_input = BdkInput::from_psbt_input( + grandchild_op, + Sequence::ENABLE_RBF_NO_LOCKTIME, + btc_psbt::Input { + witness_utxo: Some(child_output), + ..Default::default() + }, + /* satisfaction_weight */ 0, + /* status */ None, + /* is_coinbase */ false, + /* absolute_timelock */ None, + ) + .unwrap(); + + // Build replacement for tx_parent, adding the grandchild planned input. + let mut params = PsbtParams::default(); + params.add_planned_input(grandchild_input); + params.add_recipients([(recip, Amount::from_sat(50_000))]); + let params = params.replace_txs([tx_parent]); + + let result = wallet.replace_by_fee(params); + assert!( + matches!(result, Err(ReplaceByFeeError::ConflictingInput(op)) if op == grandchild_op), + "expected ConflictingInput({grandchild_op}), got: {result:?}", + ); +} + +#[test] +fn test_create_psbt_utxo_filter() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 1000, + hash: Hash::hash(b"1000"), + }, + confirmation_time: 1234567, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + + for value in [200, 300, 600, 1000] { + let _ = receive_output( + &mut wallet, + Amount::from_sat(value), + ReceiveTo::Block(anchor), + ); + } + assert_eq!(wallet.list_unspent().count(), 4); + assert_eq!(wallet.balance().total().to_sat(), 2100); + + let 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.coin_selection(psbt::SelectionStrategy::All); + 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" + ); +} + +// Verify that `create_psbt` returns `NoRecipients` when no recipients are provided and +// `drain_wallet` is not set, even when the wallet contains multiple UTXOs. +#[test] +fn test_create_psbt_no_recipients_error() { + use bdk_chain::{BlockId, ConfirmationBlockTime}; + use bdk_wallet::error::CreatePsbtError; + + let (mut wallet, _) = get_funded_wallet_wpkh(); + + // Add a second confirmed UTXO so we can confirm it's not "just draining one". + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 200, + hash: bitcoin::hashes::Hash::hash(b"200"), + }, + confirmation_time: 2000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + receive_output(&mut wallet, bitcoin::Amount::from_sat(25_000), anchor); + + // No recipients, no drain_wallet → should error. + let err = wallet.create_psbt(PsbtParams::default()).unwrap_err(); + assert!( + matches!(err, CreatePsbtError::NoRecipients), + "expected NoRecipients, got {err:?}" + ); + + // drain_wallet with an explicit change_script and no recipients should succeed (sweep to + // change). + let mut params = PsbtParams::default(); + let change_descriptor = wallet + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(); + params + .coin_selection(SelectionStrategy::All) + .change_script(ChangeScript::from_descriptor(change_descriptor)); + wallet + .create_psbt(params) + .expect("drain_wallet with explicit change_script should succeed"); +} + +// When `drain_wallet` is set but the only output (change) would fall below the dust threshold, +// verify that `create_psbt` surfaces this as an error rather than returning a zero-output +// PSBT. +#[test] +fn test_create_psbt_drain_wallet_change_below_dust_error() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 100, + hash: Hash::hash(b"100"), + }, + confirmation_time: 0, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + + // 200 sats: enough to meet the minimum fee for a P2TR spend, + // but after deducting fees for a tx that *includes* a change output the + // residual change falls below the dust threshold. + receive_output(&mut wallet, Amount::from_sat(200), ReceiveTo::Block(anchor)); + + let change_descriptor = wallet + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(); + let mut params = PsbtParams::default(); + params + .coin_selection(SelectionStrategy::All) + .change_script(ChangeScript::from_descriptor(change_descriptor)); + + let err = wallet.create_psbt(params).unwrap_err(); + assert!( + matches!(err, CreatePsbtError::AllOutputsBelowDust), + "expected AllOutputsBelowDust when change is below dust threshold, got {err:?}" + ); +} + +// Same dust-drop edge case but via `replace_by_fee`. When `drain_wallet` is set +// and the only output (change) falls below dust, the resulting transaction +// would have zero outputs. Verify that `replace_by_fee` returns the expected error. +#[test] +fn test_replace_by_fee_drain_wallet_change_below_dust_error() { + use bdk_wallet::error::ReplaceByFeeError; + use bitcoin::transaction; + + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 100, + hash: Hash::hash(b"100"), + }, + confirmation_time: 0, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + + // 400 sats: at 1 sat/vb, fees for a P2TR tx with 1 input + 1 change output ≈ 111 sat, + // leaving ~289 sat change — below the P2TR dust threshold (~303 sat). + let op = receive_output(&mut wallet, Amount::from_sat(400), ReceiveTo::Block(anchor)); + + // Build an original unconfirmed tx that spends `op` with RBF enabled. + let original_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: op, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(300), + script_pubkey: wallet + .peek_address(KeychainKind::External, 1) + .script_pubkey(), + }], + }; + insert_tx(&mut wallet, original_tx.clone()); + + // RBF with `drain_wallet`, no recipients, default fee rate (1 sat/vb). + // The only possible output (change) falls below dust. + let change_descriptor = wallet + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(); + let mut params = PsbtParams::default().replace_txs([original_tx]); + params + .coin_selection(SelectionStrategy::All) + .change_script(ChangeScript::from_descriptor(change_descriptor)); + let err = wallet.replace_by_fee(params).unwrap_err(); + assert!( + matches!( + err, + ReplaceByFeeError::CreatePsbt(CreatePsbtError::AllOutputsBelowDust) + ), + "expected AllOutputsBelowDust when RBF change is below dust threshold, got {err:?}" + ); +} + +#[test] +fn test_replace_tx_with_planned_input() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External).address; + + // Fund the wallet with an unconfirmed output. + let funding_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(Hash::hash(b"funding_parent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(200_000), + script_pubkey: addr.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx(&mut wallet, funding_tx); + + // Create an unconfirmed tx spending the funded UTXO. + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + let op2 = OutPoint::new(Hash::hash(b"txid"), 2); + let txout = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_p2a(), + }; + wallet.insert_txout(op2, txout.clone()); + let psbt_input = bitcoin::psbt::Input { + witness_utxo: Some(txout), + ..Default::default() + }; + let planned_input = bdk_tx::Input::from_psbt_input( + op2, + Sequence::ENABLE_LOCKTIME_NO_RBF, + psbt_input, + /* satisfaction_weight: */ 0, + /* status: */ None, + /* is_coinbase: */ false, + /* absolute_timelock: */ None, + ) + .unwrap(); + + let mut params = PsbtParams::default(); + params + .add_utxos(&[funding_op]) + .add_planned_input(planned_input.clone()) + .add_recipients([(recip.clone(), Amount::from_sat(100_000))]); + let unconfirmed_tx = wallet.create_psbt(params).unwrap().0.unsigned_tx; + insert_tx(&mut wallet, unconfirmed_tx.clone()); + + // Add the planned input *before* calling replace_txs. The replace() method + // should respect pre-registered planned inputs in the unique set. + let mut params = PsbtParams::default(); + params + .add_planned_input(planned_input.clone()) + .add_recipients([(recip, Amount::from_sat(99_000))]); + let params = params.replace_txs([unconfirmed_tx]); + + let (psbt, _) = wallet + .replace_by_fee(params) + .expect("replacement should succeed"); + assert_eq!( + psbt.unsigned_tx.input.len(), + 2, + "replacement tx must include both the wallet input and the planned input" + ); + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|txin| txin.previous_output == funding_op), + "replacement must include the wallet-controlled input" + ); + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|txin| txin.previous_output == op2), + "replacement must include the planned input" + ); +} + +#[test] +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 = bitcoin::psbt::Input { + witness_utxo: Some(txout), + ..Default::default() + }; + let input = bdk_tx::Input::from_psbt_input( + op2, + Sequence::ENABLE_LOCKTIME_NO_RBF, + psbt_input, + /* satisfaction_weight: */ 0, + /* status: */ None, + /* is_coinbase: */ false, + /* absolute_timelock: */ None, + )?; + + let send_to = wallet.reveal_next_address(KeychainKind::External).address; + + // Build tx: 2-in / 2-out + let mut params = 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 536a3bb4..f848473c 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -1,965 +1,12 @@ -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::{ - error::CreatePsbtError, psbt, KeychainKind, PsbtParams, SelectionStrategy, SignOptions, Wallet, -}; -use bitcoin::{ - absolute, hashes::Hash, Amount, FeeRate, Network, OutPoint, Psbt, ScriptBuf, Sequence, - Transaction, TxIn, TxOut, -}; +use bdk_wallet::{psbt, KeychainKind, SignOptions}; +use bitcoin::{Amount, FeeRate, Psbt, TxIn}; use core::str::FromStr; -use miniscript::plan::Assets; // from bip 174 const PSBT_STR: &str = "cHNidP8BAKACAAAAAqsJSaCMWvfEm4IS9Bfi8Vqz9cM9zxU4IagTn4d6W3vkAAAAAAD+////qwlJoIxa98SbghL0F+LxWrP1wz3PFTghqBOfh3pbe+QBAAAAAP7///8CYDvqCwAAAAAZdqkUdopAu9dAy+gdmI5x3ipNXHE5ax2IrI4kAAAAAAAAGXapFG9GILVT+glechue4O/p+gOcykWXiKwAAAAAAAEHakcwRAIgR1lmF5fAGwNrJZKJSGhiGDR9iYZLcZ4ff89X0eURZYcCIFMJ6r9Wqk2Ikf/REf3xM286KdqGbX+EhtdVRs7tr5MZASEDXNxh/HupccC1AaZGoqg7ECy0OIEhfKaC3Ibi1z+ogpIAAQEgAOH1BQAAAAAXqRQ1RebjO4MsRwUPJNPuuTycA5SLx4cBBBYAFIXRNTfy4mVAWjTbr6nj3aAfuCMIAAAA"; -// Test that `create_psbt` results in the expected PSBT. -#[test] -fn test_create_psbt() { - let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); - let mut wallet = Wallet::create(desc, change_desc) - .network(Network::Regtest) - .create_wallet_no_persist() - .unwrap(); - let expected_xpub = match wallet.public_descriptor(KeychainKind::External) { - miniscript::Descriptor::Tr(tr) => match tr.internal_key() { - miniscript::DescriptorPublicKey::XPub(desc) => desc.xkey, - _ => unreachable!(), - }, - _ => unreachable!(), - }; - - // Receive coins - let anchor = ConfirmationBlockTime { - block_id: BlockId { - height: 100, - hash: Hash::hash(b"100"), - }, - confirmation_time: 1234567000, - }; - insert_checkpoint(&mut wallet, anchor.block_id); - receive_output(&mut wallet, Amount::ONE_BTC, ReceiveTo::Block(anchor)); - - let change_descriptor = wallet - .public_descriptor(KeychainKind::Internal) - .at_derivation_index(0) - .unwrap(); - - let addr = wallet.reveal_next_address(KeychainKind::External); - let mut params = 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) - .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(), 0); - assert_eq!(tx.input.len(), 1); - assert_eq!(tx.output.len(), 2); - - // global xpubs - assert_eq!( - psbt.xpub, - [(expected_xpub, ("f6a5cb8b".parse().unwrap(), vec![].into()))].into(), - ); - // witness utxo - let psbt_input = &psbt.inputs[0]; - assert_eq!( - psbt_input.witness_utxo.as_ref().map(|txo| txo.value), - Some(Amount::ONE_BTC), - ); - // input internal key - assert!(psbt_input.tap_internal_key.is_some()); - // input key origins - assert!(psbt_input - .tap_key_origins - .values() - .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b")); - // output internal key - assert!(psbt - .outputs - .iter() - .any(|output| output.tap_internal_key.is_some())); - // output key origins - assert!(psbt.outputs.iter().any(|output| output - .tap_key_origins - .values() - .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b"))); -} - -#[test] -fn test_create_psbt_insufficient_funds_error() { - let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); - let mut wallet = Wallet::create(desc, change_desc) - .network(Network::Regtest) - .create_wallet_no_persist() - .unwrap(); - - let addr = wallet.reveal_next_address(KeychainKind::External); - - let mut params = 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)); - } -} - -/// Fallback sequence is applied to a coin-selected input that has no CSV -/// requirement. -#[test] -fn test_create_psbt_fallback_sequence_applied_to_coin_selected_input() { - let (mut wallet, _) = get_funded_wallet_wpkh(); - let addr = wallet.next_unused_address(KeychainKind::External); - let mut params = PsbtParams::default(); - params - .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) - .fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); - let psbt = wallet.create_psbt(params).unwrap().0; - assert_eq!( - psbt.unsigned_tx.input[0].sequence, - Sequence::ENABLE_RBF_NO_LOCKTIME - ); -} - -/// Fallback sequence is NOT applied when the input already has a CSV-derived sequence -/// requirement — the CSV value wins. -#[test] -fn test_create_psbt_fallback_sequence_skipped_for_csv_input() { - use bitcoin::relative; - let mut wallet = Wallet::create_single(get_test_single_sig_csv()) - .network(Network::Regtest) - .create_wallet_no_persist() - .unwrap(); - let anchor = ConfirmationBlockTime { - block_id: BlockId { - height: 10_000, - hash: Hash::hash(b"csv_fallback"), - }, - confirmation_time: 1_234_567_000, - }; - insert_checkpoint(&mut wallet, anchor.block_id); - let op = receive_output( - &mut wallet, - Amount::from_sat(100_000), - ReceiveTo::Block(anchor), - ); - - let addr = wallet.next_unused_address(KeychainKind::External); - let rel_locktime = relative::LockTime::from_consensus(6).unwrap(); - let mut params = PsbtParams::default(); - params - .add_utxos(&[op]) - .add_assets(Assets::new().older(rel_locktime)) - .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) - .fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); - let psbt = wallet.create_psbt(params).unwrap().0; - // CSV descriptor requires older(6); fallback must not clobber the CSV-derived sequence. - assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); -} - -/// A per-input sequence override is applied to a manually-selected UTXO. -#[test] -fn test_create_psbt_sequence_override_manually_selected_input() { - let (mut wallet, txid) = get_funded_wallet_wpkh(); - let utxo = OutPoint::new(txid, 0); - let addr = wallet.next_unused_address(KeychainKind::External); - let mut params = PsbtParams::default(); - params - .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) - .add_utxos(&[utxo]) - .manually_selected_only() - .sequence_override(utxo, Sequence(42)); - let psbt = wallet.create_psbt(params).unwrap().0; - assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(42)); -} - -/// A per-input sequence override takes precedence over a fallback sequence. -#[test] -fn test_create_psbt_sequence_override_takes_precedence_over_fallback() { - let (mut wallet, txid) = get_funded_wallet_wpkh(); - let utxo = OutPoint::new(txid, 0); - let addr = wallet.next_unused_address(KeychainKind::External); - let mut params = PsbtParams::default(); - params - .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) - .add_utxos(&[utxo]) - .manually_selected_only() - .sequence_override(utxo, Sequence(42)) - .fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); - let psbt = wallet.create_psbt(params).unwrap().0; - assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(42)); -} - -/// A sequence override that violates the CSV requirement returns a Sequence error. -#[test] -fn test_create_psbt_sequence_override_csv_conflict_returns_error() { - use bitcoin::relative; - let mut wallet = Wallet::create_single(get_test_single_sig_csv()) - .network(Network::Regtest) - .create_wallet_no_persist() - .unwrap(); - let anchor = ConfirmationBlockTime { - block_id: BlockId { - height: 10_000, - hash: Hash::hash(b"csv_override"), - }, - confirmation_time: 1_234_567_000, - }; - insert_checkpoint(&mut wallet, anchor.block_id); - let op = receive_output( - &mut wallet, - Amount::from_sat(100_000), - ReceiveTo::Block(anchor), - ); - - let addr = wallet.next_unused_address(KeychainKind::External); - let rel_locktime = relative::LockTime::from_consensus(6).unwrap(); - let mut params = PsbtParams::default(); - params - .add_utxos(&[op]) - .add_assets(Assets::new().older(rel_locktime)) - .add_recipients([(addr.script_pubkey(), Amount::from_sat(25_000))]) - .manually_selected_only() - .sequence_override(op, Sequence(3)); // CSV requires >= 6 - let result = wallet.create_psbt(params); - assert!(matches!(result, Err(CreatePsbtError::Sequence(_)))); -} - -// 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 mut params = PsbtParams::default(); - params.add_recipients([(external, Amount::from_sat(100_000))]); - params.fee_rate(FeeRate::from_sat_per_vb(4).unwrap()); - let params = params.replace_txs([tx_a]); - let (psbt, _) = wallet - .replace_by_fee(params) - .expect("failed to create RBF PSBT"); - - let replacement_fee = wallet - .calculate_fee(&psbt.unsigned_tx) - .expect("replacement tx fee should be calculable"); - - assert!( - replacement_fee >= total_original_fee, - "replacement fee ({replacement_fee}) must be >= sum of fees for A + B + C ({total_original_fee})", - ); -} - -// Test that `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.clone(), 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); - - // Attempting to replace the now-confirmed tx should return TransactionConfirmed. - let mut params = PsbtParams::default(); - params.add_recipients([(recip, Amount::from_sat(10_000))]); - params.fee_rate(FeeRate::from_sat_per_vb(10).unwrap()); - let params = params.replace_txs([unconfirmed_tx]); - let result = wallet.replace_by_fee(params); - - assert!( - matches!(result, Err(ReplaceByFeeError::TransactionConfirmed(txid)) if txid == confirmed_txid), - "expected TransactionConfirmed error, got: {result:?}", - ); -} - -// Test that `replace_by_fee` errors when all original inputs have been removed via -// `remove_utxo`, leaving the replacement with no inputs from the replaced transaction. -#[test] -fn test_replace_by_fee_no_inputs_from_original() { - 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 addr = wallet.reveal_next_address(External).address; - - // Fund the wallet with an unconfirmed output. - let funding_tx = Transaction { - input: vec![TxIn { - previous_output: OutPoint::new(Hash::hash(b"funding_parent"), 0), - ..Default::default() - }], - output: vec![TxOut { - value: Amount::from_sat(200_000), - script_pubkey: addr.script_pubkey(), - }], - ..new_tx(0) - }; - let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); - insert_tx(&mut wallet, funding_tx); - - // Create an unconfirmed tx spending the funded UTXO. - let recip = - ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") - .unwrap(); - let mut params = PsbtParams::default(); - params - .add_utxos(&[funding_op]) - .add_recipients([(recip.clone(), Amount::from_sat(100_000))]); - let unconfirmed_tx = wallet.create_psbt(params).unwrap().0.unsigned_tx; - let unconfirmed_txid = unconfirmed_tx.compute_txid(); - insert_tx(&mut wallet, unconfirmed_tx.clone()); - - // Build replacement params with a recipient but remove the original inputs. - let mut params = PsbtParams::default().replace_txs([unconfirmed_tx]); - params - .remove_utxo(&funding_op) - .add_recipients([(recip, Amount::from_sat(50_000))]); - - let result = wallet.replace_by_fee(params); - assert!( - matches!(result, Err(ReplaceByFeeError::NoInputsFromOriginal(txid)) if txid == unconfirmed_txid), - "expected NoInputsFromOriginal error, got: {result:?}", - ); -} - -// Test that `replace_by_fee` returns `NoOriginalTransactions` when `replace_txs` is called -// with an empty list, i.e. no transactions were provided for replacement. -#[test] -fn test_replace_by_fee_no_original_transactions() { - use bdk_wallet::error::ReplaceByFeeError; - - 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(); - - // replace_txs with an empty iterator produces PsbtParams with an empty replace set. - let params = PsbtParams::default().replace_txs(core::iter::empty::()); - let result = wallet.replace_by_fee(params); - assert!( - matches!(result, Err(ReplaceByFeeError::NoOriginalTransactions)), - "expected NoOriginalTransactions, got: {result:?}", - ); -} - -// Test that `replace_by_fee` rejects a manually-selected input that spends -// from a descendant of the one being replaced. -#[test] -fn test_replace_by_fee_conflicting_input_descendant() { - use bdk_tx::Input as BdkInput; - use bdk_wallet::error::ReplaceByFeeError; - use bitcoin::{psbt as btc_psbt, Sequence}; - - let (desc, change_desc) = get_test_wpkh_and_change_desc(); - let mut wallet = Wallet::create(desc, change_desc) - .network(Network::Regtest) - .create_wallet_no_persist() - .unwrap(); - - let addr = wallet.reveal_next_address(KeychainKind::External).address; - - // Fund the wallet so there is a spendable UTXO. - let funding_tx = Transaction { - input: vec![TxIn { - previous_output: OutPoint::new(Hash::hash(b"funding_parent"), 0), - ..Default::default() - }], - output: vec![TxOut { - value: Amount::from_sat(500_000), - script_pubkey: addr.script_pubkey(), - }], - ..new_tx(0) - }; - let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); - insert_tx(&mut wallet, funding_tx); - - let recip = - ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") - .unwrap(); - - // tx_parent: the transaction we will eventually replace. - let mut params = PsbtParams::default(); - params - .add_utxos(&[funding_op]) - .add_recipients([(recip.clone(), Amount::from_sat(100_000))]); - let tx_parent = wallet.create_psbt(params).unwrap().0.unsigned_tx; - let txid_parent = tx_parent.compute_txid(); - insert_tx(&mut wallet, tx_parent.clone()); - - // tx_child: spends one of tx_parent's outputs (a descendant of the tx being replaced). - let child_output = TxOut { - value: Amount::from_sat(10_000), - script_pubkey: ScriptBuf::new_p2wpkh( - &bitcoin::WPubkeyHash::from_slice(&[0u8; 20]).unwrap(), - ), - }; - let child_op = OutPoint::new(txid_parent, 0); - let tx_child = Transaction { - input: vec![TxIn { - previous_output: child_op, - ..Default::default() - }], - output: vec![child_output.clone()], - ..new_tx(1) - }; - let txid_child = tx_child.compute_txid(); - insert_tx(&mut wallet, tx_child.clone()); - - // A planned input that spends an output of tx_child (a descendant of the replaced tx). - // This is the indirect conflict that params-level stripping cannot catch. - let grandchild_op = OutPoint::new(txid_child, 0); - let grandchild_input = BdkInput::from_psbt_input( - grandchild_op, - Sequence::ENABLE_RBF_NO_LOCKTIME, - btc_psbt::Input { - witness_utxo: Some(child_output), - ..Default::default() - }, - /* satisfaction_weight */ 0, - /* status */ None, - /* is_coinbase */ false, - /* absolute_timelock */ None, - ) - .unwrap(); - - // Build replacement for tx_parent, adding the grandchild planned input. - let mut params = PsbtParams::default(); - params.add_planned_input(grandchild_input); - params.add_recipients([(recip, Amount::from_sat(50_000))]); - let params = params.replace_txs([tx_parent]); - - let result = wallet.replace_by_fee(params); - assert!( - matches!(result, Err(ReplaceByFeeError::ConflictingInput(op)) if op == grandchild_op), - "expected ConflictingInput({grandchild_op}), got: {result:?}", - ); -} - -#[test] -fn test_create_psbt_utxo_filter() { - let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); - let mut wallet = Wallet::create(desc, change_desc) - .network(Network::Regtest) - .create_wallet_no_persist() - .unwrap(); - - let anchor = ConfirmationBlockTime { - block_id: BlockId { - height: 1000, - hash: Hash::hash(b"1000"), - }, - confirmation_time: 1234567, - }; - insert_checkpoint(&mut wallet, anchor.block_id); - - for value in [200, 300, 600, 1000] { - let _ = receive_output( - &mut wallet, - Amount::from_sat(value), - ReceiveTo::Block(anchor), - ); - } - assert_eq!(wallet.list_unspent().count(), 4); - assert_eq!(wallet.balance().total().to_sat(), 2100); - - let 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.coin_selection(psbt::SelectionStrategy::All); - 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" - ); -} - -// Verify that `create_psbt` returns `NoRecipients` when no recipients are provided and -// `drain_wallet` is not set, even when the wallet contains multiple UTXOs. -#[test] -fn test_create_psbt_no_recipients_error() { - use bdk_chain::{BlockId, ConfirmationBlockTime}; - use bdk_wallet::error::CreatePsbtError; - - let (mut wallet, _) = get_funded_wallet_wpkh(); - - // Add a second confirmed UTXO so we can confirm it's not "just draining one". - let anchor = ConfirmationBlockTime { - block_id: BlockId { - height: 200, - hash: bitcoin::hashes::Hash::hash(b"200"), - }, - confirmation_time: 2000, - }; - insert_checkpoint(&mut wallet, anchor.block_id); - receive_output(&mut wallet, bitcoin::Amount::from_sat(25_000), anchor); - - // No recipients, no drain_wallet → should error. - let err = wallet.create_psbt(PsbtParams::default()).unwrap_err(); - assert!( - matches!(err, CreatePsbtError::NoRecipients), - "expected NoRecipients, got {err:?}" - ); - - // drain_wallet with an explicit change_script and no recipients should succeed (sweep to - // change). - let mut params = PsbtParams::default(); - let change_descriptor = wallet - .public_descriptor(KeychainKind::Internal) - .at_derivation_index(0) - .unwrap(); - params - .coin_selection(SelectionStrategy::All) - .change_script(ChangeScript::from_descriptor(change_descriptor)); - wallet - .create_psbt(params) - .expect("drain_wallet with explicit change_script should succeed"); -} - #[test] #[should_panic(expected = "InputIndexOutOfRange")] fn test_psbt_malformed_psbt_input_legacy() { @@ -1175,199 +222,3 @@ fn test_psbt_multiple_internalkey_signers() { let verify_res = secp.verify_schnorr(&signature, &message, &xonlykey); assert!(verify_res.is_ok(), "The wrong internal key was used"); } - -// When `drain_wallet` is set but the only output (change) would fall below the dust threshold, -// verify that `create_psbt` surfaces this as an error rather than returning a zero-output -// PSBT. -#[test] -fn test_create_psbt_drain_wallet_change_below_dust_error() { - let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); - let mut wallet = Wallet::create(desc, change_desc) - .network(Network::Regtest) - .create_wallet_no_persist() - .unwrap(); - - let anchor = ConfirmationBlockTime { - block_id: BlockId { - height: 100, - hash: Hash::hash(b"100"), - }, - confirmation_time: 0, - }; - insert_checkpoint(&mut wallet, anchor.block_id); - - // 200 sats: enough to meet the minimum fee for a P2TR spend, - // but after deducting fees for a tx that *includes* a change output the - // residual change falls below the dust threshold. - receive_output(&mut wallet, Amount::from_sat(200), ReceiveTo::Block(anchor)); - - let change_descriptor = wallet - .public_descriptor(KeychainKind::Internal) - .at_derivation_index(0) - .unwrap(); - let mut params = PsbtParams::default(); - params - .coin_selection(SelectionStrategy::All) - .change_script(ChangeScript::from_descriptor(change_descriptor)); - - let err = wallet.create_psbt(params).unwrap_err(); - assert!( - matches!(err, CreatePsbtError::AllOutputsBelowDust), - "expected AllOutputsBelowDust when change is below dust threshold, got {err:?}" - ); -} - -// Same dust-drop edge case but via `replace_by_fee`. When `drain_wallet` is set -// and the only output (change) falls below dust, the resulting transaction -// would have zero outputs. Verify that `replace_by_fee` returns the expected error. -#[test] -fn test_replace_by_fee_drain_wallet_change_below_dust_error() { - use bdk_wallet::error::ReplaceByFeeError; - use bitcoin::transaction; - - let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); - let mut wallet = Wallet::create(desc, change_desc) - .network(Network::Regtest) - .create_wallet_no_persist() - .unwrap(); - - let anchor = ConfirmationBlockTime { - block_id: BlockId { - height: 100, - hash: Hash::hash(b"100"), - }, - confirmation_time: 0, - }; - insert_checkpoint(&mut wallet, anchor.block_id); - - // 400 sats: at 1 sat/vb, fees for a P2TR tx with 1 input + 1 change output ≈ 111 sat, - // leaving ~289 sat change — below the P2TR dust threshold (~303 sat). - let op = receive_output(&mut wallet, Amount::from_sat(400), ReceiveTo::Block(anchor)); - - // Build an original unconfirmed tx that spends `op` with RBF enabled. - let original_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn { - previous_output: op, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - ..Default::default() - }], - output: vec![TxOut { - value: Amount::from_sat(300), - script_pubkey: wallet - .peek_address(KeychainKind::External, 1) - .script_pubkey(), - }], - }; - insert_tx(&mut wallet, original_tx.clone()); - - // RBF with `drain_wallet`, no recipients, default fee rate (1 sat/vb). - // The only possible output (change) falls below dust. - let change_descriptor = wallet - .public_descriptor(KeychainKind::Internal) - .at_derivation_index(0) - .unwrap(); - let mut params = PsbtParams::default().replace_txs([original_tx]); - params - .coin_selection(SelectionStrategy::All) - .change_script(ChangeScript::from_descriptor(change_descriptor)); - let err = wallet.replace_by_fee(params).unwrap_err(); - assert!( - matches!( - err, - ReplaceByFeeError::CreatePsbt(CreatePsbtError::AllOutputsBelowDust) - ), - "expected AllOutputsBelowDust when RBF change is below dust threshold, got {err:?}" - ); -} - -#[test] -fn test_replace_tx_with_planned_input() { - let (desc, change_desc) = get_test_wpkh_and_change_desc(); - let mut wallet = Wallet::create(desc, change_desc) - .network(Network::Regtest) - .create_wallet_no_persist() - .unwrap(); - - let addr = wallet.reveal_next_address(KeychainKind::External).address; - - // Fund the wallet with an unconfirmed output. - let funding_tx = Transaction { - input: vec![TxIn { - previous_output: OutPoint::new(Hash::hash(b"funding_parent"), 0), - ..Default::default() - }], - output: vec![TxOut { - value: Amount::from_sat(200_000), - script_pubkey: addr.script_pubkey(), - }], - ..new_tx(0) - }; - let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); - insert_tx(&mut wallet, funding_tx); - - // Create an unconfirmed tx spending the funded UTXO. - let recip = - ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") - .unwrap(); - let op2 = OutPoint::new(Hash::hash(b"txid"), 2); - let txout = TxOut { - value: Amount::ZERO, - script_pubkey: ScriptBuf::new_p2a(), - }; - wallet.insert_txout(op2, txout.clone()); - let psbt_input = bitcoin::psbt::Input { - witness_utxo: Some(txout), - ..Default::default() - }; - let planned_input = bdk_tx::Input::from_psbt_input( - op2, - Sequence::ENABLE_LOCKTIME_NO_RBF, - psbt_input, - /* satisfaction_weight: */ 0, - /* status: */ None, - /* is_coinbase: */ false, - /* absolute_timelock: */ None, - ) - .unwrap(); - - let mut params = PsbtParams::default(); - params - .add_utxos(&[funding_op]) - .add_planned_input(planned_input.clone()) - .add_recipients([(recip.clone(), Amount::from_sat(100_000))]); - let unconfirmed_tx = wallet.create_psbt(params).unwrap().0.unsigned_tx; - insert_tx(&mut wallet, unconfirmed_tx.clone()); - - // Add the planned input *before* calling replace_txs. The replace() method - // should respect pre-registered planned inputs in the unique set. - let mut params = PsbtParams::default(); - params - .add_planned_input(planned_input.clone()) - .add_recipients([(recip, Amount::from_sat(99_000))]); - let params = params.replace_txs([unconfirmed_tx]); - - let (psbt, _) = wallet - .replace_by_fee(params) - .expect("replacement should succeed"); - assert_eq!( - psbt.unsigned_tx.input.len(), - 2, - "replacement tx must include both the wallet input and the planned input" - ); - assert!( - psbt.unsigned_tx - .input - .iter() - .any(|txin| txin.previous_output == funding_op), - "replacement must include the wallet-controlled input" - ); - assert!( - psbt.unsigned_tx - .input - .iter() - .any(|txin| txin.previous_output == op2), - "replacement must include the planned input" - ); -} From 418c5ad3cf975780f532d005683b2dd981631ee5 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 21 Jul 2026 09:16:14 -0400 Subject: [PATCH 15/19] fix(psbt,wallet): Fix Untouched ordering bug Introduce MustSpend enum representing either a wallet-owned Utxo(OutPoint) or Planned(Input). The params maintains the must_spend inputs in the order they are added. Fix `build_must_spend_inputs` to iterate over params.must_spend mapping wallet UTXOs to Input together with their Planned counterparts. --- src/psbt/params.rs | 108 +++++++++++++++++++++++++++++---------------- src/wallet/mod.rs | 43 +++++++++--------- 2 files changed, 92 insertions(+), 59 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 4f6fba42..58122aa6 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -32,10 +32,8 @@ pub type Rbf = ReplaceTx; pub struct PsbtParams { /// Set of selected UTXO outpoints, `HashSet` ensures uniqueness pub(crate) set: HashSet, - /// List of UTXO outpoints to spend. - pub(crate) utxos: Vec, - /// List of planned transaction [`Input`]s. - pub(crate) inputs: Vec, + /// Ordered list of manually-selected spends. + pub(crate) must_spend: Vec, /// List of recipient script/amount pairs. pub(crate) recipients: Vec<(ScriptBuf, Amount)>, /// Optional script or descriptor designated for change. @@ -86,8 +84,7 @@ impl Default for PsbtParams { fn default() -> Self { Self { set: Default::default(), - utxos: Default::default(), - inputs: Default::default(), + must_spend: Default::default(), assets: Default::default(), recipients: Default::default(), change_script: Default::default(), @@ -132,8 +129,11 @@ impl PsbtParams { /// [`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))); + for outpoint in outpoints.iter().copied() { + if self.set.insert(outpoint) { + self.must_spend.push(MustSpend::Utxo(outpoint)); + } + } self } @@ -185,7 +185,7 @@ impl PsbtParams { /// [`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.must_spend.push(MustSpend::Planned(input)); } self } @@ -227,8 +227,7 @@ impl PsbtParams { fn into_replace_params(self) -> PsbtParams { PsbtParams { set: self.set, - utxos: self.utxos, - inputs: self.inputs, + must_spend: self.must_spend, assets: self.assets, recipients: self.recipients, change_script: self.change_script, @@ -261,9 +260,8 @@ impl PsbtParams { /// 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.inputs - .retain(|input| input.prev_outpoint() != *outpoint); + self.must_spend + .retain(|item| item.prev_outpoint() != *outpoint); } self } @@ -489,6 +487,25 @@ impl PsbtParams { } } +/// An element in the list of manually selected inputs. +#[derive(Debug, Clone)] +pub(crate) enum MustSpend { + /// A UTXO owned by the wallet + Utxo(OutPoint), + /// An already constructed [`Input`] with spending plan included + Planned(Input), +} + +impl MustSpend { + /// Returns the [`OutPoint`] of this [`MustSpend`] UTXO. + fn prev_outpoint(&self) -> OutPoint { + match self { + Self::Utxo(outpoint) => *outpoint, + Self::Planned(input) => input.prev_outpoint(), + } + } +} + /// Coin select strategy. #[derive(Clone, Default)] #[non_exhaustive] @@ -567,11 +584,15 @@ impl PsbtParams { where T: Into>, { - // We're resetting the inputs, so remove any existing utxos from - // the set. Pre existing planned inputs are retained. - for outpoint in self.utxos.drain(..) { - self.set.remove(&outpoint); - } + // We're resetting the wallet-managed inputs, so remove any existing `Utxo` entries from + // the dedup set. Pre-existing planned inputs are retained. + self.must_spend.retain(|item| { + let keep = matches!(item, MustSpend::Planned(..)); + if !keep { + self.set.remove(&item.prev_outpoint()); + } + keep + }); let mut utxos = vec![]; @@ -608,17 +629,22 @@ impl PsbtParams { // replaced. Such an input would spend an output that the replacement invalidates, // which is invalid. The complete descendant walk is deferred to `replace_by_fee_with_rng` // which has access to the TxGraph. - self.inputs.retain(|input| { - let prev_txid = input.prev_outpoint().txid; - let conflicts = self.replace.contains(&prev_txid); - if conflicts { - self.set.remove(&input.prev_outpoint()); + self.must_spend.retain(|item| match item { + MustSpend::Utxo(..) => true, + MustSpend::Planned(input) => { + let conflicts = self.replace.contains(&input.prev_outpoint().txid); + if conflicts { + self.set.remove(&input.prev_outpoint()); + } + !conflicts } - !conflicts }); - self.utxos - .extend(utxos.iter().copied().filter(|&op| self.set.insert(op))); + for outpoint in utxos { + if self.set.insert(outpoint) { + self.must_spend.push(MustSpend::Utxo(outpoint)); + } + } } } @@ -692,7 +718,7 @@ mod test { // Remove utxo params.remove_utxo(&outpoint_0); assert!(params.utxos().is_empty()); - assert!(params.utxos.is_empty()); + assert!(params.must_spend.is_empty()); } #[test] @@ -759,7 +785,8 @@ mod test { &[op].into(), "Failed to filter duplicate outpoints" ); - assert!(params.utxos.contains(&op)); + assert_eq!(params.must_spend.len(), 1); + assert!(matches!(params.must_spend[0], MustSpend::Utxo(outpoint) if outpoint == op)); params = PsbtParams::default(); @@ -770,7 +797,8 @@ mod test { &[op].into(), "Failed to filter duplicate outpoints" ); - assert!(params.utxos.contains(&op)); + assert_eq!(params.must_spend.len(), 1); + assert!(matches!(params.must_spend[0], MustSpend::Utxo(outpoint) if outpoint == op)); } // A pre-registered planned input whose `prev_txid` is in `txids_to_replace` must be @@ -842,13 +870,13 @@ mod test { .add_planned_input(safe_input); let params = params.replace_txs([tx_a]); - // The conflicting input must have been stripped from both `inputs` and `set`. + // The conflicting input must have been stripped from `must_spend` and `set`. assert!( !params - .inputs + .must_spend .iter() - .any(|i| i.prev_outpoint() == conflicted_op), - "conflicting planned input must be stripped from inputs" + .any(|must_spend| must_spend.prev_outpoint() == conflicted_op), + "conflicting planned input must be stripped from must_spend" ); assert!( !params.set.contains(&conflicted_op), @@ -857,14 +885,20 @@ mod test { // The safe input must be preserved. assert!( - params.inputs.iter().any(|i| i.prev_outpoint() == safe_op), + params + .must_spend + .iter() + .any(|must_spend| must_spend.prev_outpoint() == safe_op), "unrelated planned input must be retained" ); assert!(params.set.contains(&safe_op)); - // tx_a's own spend must still appear in utxos (the replacement input). + // tx_a's own spend must still appear in must_spend (the replacement input). assert!( - params.utxos.contains(&parent_op), + params + .must_spend + .iter() + .any(|must_spend| matches!(must_spend, MustSpend::Utxo(op) if *op == parent_op)), "tx_a's input must be present in replacement utxos" ); } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index e860422c..798e1bc6 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -84,7 +84,7 @@ use crate::wallet::{ use bitcoin::secp256k1::rand; #[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] use { - crate::psbt::{AssetsExt, CreateTx, PsbtParams, Rbf, SelectionStrategy}, + crate::psbt::{AssetsExt, CreateTx, MustSpend, PsbtParams, Rbf, SelectionStrategy}, crate::wallet::{ error::{CreatePsbtError, ReplaceByFeeError}, tx_builder::TxOrdering, @@ -3608,14 +3608,11 @@ impl Wallet { Ok((psbt, finalizer)) } - /// Builds the required inputs from user-added UTXOS and pre-built [`Input`]s in `params`. + /// Builds the required inputs from the manually-selected spends in `params`. /// - /// Processes [`params.utxos`] by planning each outpoint as an [`Input`], then applies any - /// per-input sequence override or the fallback sequence. Pre-built inputs from - /// [`params.inputs`] are appended unchanged. - /// - /// [`params.utxos`]: PsbtParams::add_utxos - /// [`params.inputs`]: PsbtParams::add_planned_input + /// Wallet outpoints are planned into [`Input`]s in insertion order, then any per-input + /// sequence override or the fallback sequence is applied. Pre-built planned inputs are kept + /// in that same insertion order. fn build_must_spend_inputs( &self, params: &PsbtParams, @@ -3623,25 +3620,27 @@ impl Wallet { assets: &Assets, ) -> Result, CreatePsbtError> { params - .utxos + .must_spend .iter() - .map(|&outpoint| { - let txo = txouts - .get(&outpoint) - .ok_or(CreatePsbtError::UnknownUtxo(outpoint))?; - let mut input = self - .plan_input(txo, assets) - .ok_or(CreatePsbtError::Plan(outpoint))?; - if let Some(&seq) = params.sequence_overrides.get(&outpoint) { - input.set_sequence(seq).map_err(CreatePsbtError::Sequence)?; - } else if let Some(seq) = params.fallback_sequence { - if input.sequence().is_none() { + .map(|item| match item { + MustSpend::Utxo(outpoint) => { + let txo = txouts + .get(outpoint) + .ok_or(CreatePsbtError::UnknownUtxo(*outpoint))?; + let mut input = self + .plan_input(txo, assets) + .ok_or(CreatePsbtError::Plan(*outpoint))?; + if let Some(&seq) = params.sequence_overrides.get(outpoint) { input.set_sequence(seq).map_err(CreatePsbtError::Sequence)?; + } else if let Some(seq) = params.fallback_sequence { + if input.sequence().is_none() { + input.set_sequence(seq).map_err(CreatePsbtError::Sequence)?; + } } + Ok(input) } - Ok(input) + MustSpend::Planned(input) => Ok(input.clone()), }) - .chain(params.inputs.iter().cloned().map(Result::Ok)) .collect() } From 5eae1fb2423ba0f577d13e7cae22f5638549028c Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 21 Jul 2026 09:38:36 -0400 Subject: [PATCH 16/19] refactor(wallet): Simplify peek_change_info --- src/wallet/mod.rs | 81 +++++++++++++++++++++++++---------------------- 1 file changed, 43 insertions(+), 38 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 798e1bc6..1f24f474 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2978,44 +2978,35 @@ impl Wallet { /// 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 + /// The next change address is the next unused address of the change keychain, or the + /// next-to-be-revealed address **without** mutating wallet state. Revelation is deferred + /// until after all error paths have been cleared by the caller. + fn peek_change_info(&self) -> ((KeychainKind, u32, ScriptBuf), ChangeScript) { + 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 - .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) - } - } + .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) + .at_derivation_index(index) + .expect("should be valid derivation index"); + ( + (change_keychain, index, spk), + ChangeScript::from_descriptor(descriptor), + ) } /// Parses the common parameters used during PSBT creation and returns the spend assets @@ -3191,7 +3182,14 @@ impl Wallet { { return Err(CreatePsbtError::NoRecipients); } - let (change_info, change_script) = self.peek_change_info(params.change_script.take()); + let (change_info, change_script) = params + .change_script + .take() + .map(|change_script| (None, change_script)) + .unwrap_or_else(|| { + let (change_info, change_script) = self.peek_change_info(); + (Some(change_info), change_script) + }); let (assets, txouts) = self.parse_params(¶ms); @@ -3427,7 +3425,14 @@ impl Wallet { { return Err(ReplaceByFeeError::CreatePsbt(CreatePsbtError::NoRecipients)); } - let (change_info, change_script) = self.peek_change_info(params.change_script.take()); + let (change_info, change_script) = params + .change_script + .take() + .map(|change_script| (None, change_script)) + .unwrap_or_else(|| { + let (change_info, change_script) = self.peek_change_info(); + (Some(change_info), change_script) + }); let (assets, txouts) = self.parse_params(¶ms); From 05bc9db47dc7d0edcc7701cff11a5e9141f2f4df Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 21 Jul 2026 09:45:16 -0400 Subject: [PATCH 17/19] refactor(psbt): Rename PsbtParams method to utxo_filter --- src/psbt/params.rs | 2 +- tests/create_psbt.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 58122aa6..91d15b70 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -391,7 +391,7 @@ impl PsbtParams { /// 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 + pub fn utxo_filter(&mut self, predicate: F) -> &mut Self where F: Fn(&FullTxOut) -> bool + Send + Sync + 'static, { diff --git a/tests/create_psbt.rs b/tests/create_psbt.rs index 0233862d..391f3ab2 100644 --- a/tests/create_psbt.rs +++ b/tests/create_psbt.rs @@ -894,7 +894,7 @@ fn test_create_psbt_utxo_filter() { let mut params = PsbtParams::default(); params.fee_rate(FeeRate::ZERO); // Avoid selection of dust utxos - params.filter_utxos(|txo| { + params.utxo_filter(|txo| { let min_non_dust = txo.txout.script_pubkey.minimal_non_dust(); // 330 txo.txout.value >= min_non_dust }); From 6f6c82c2a79d0eb31ac10543dbed29279d2b98e1 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 21 Jul 2026 09:51:39 -0400 Subject: [PATCH 18/19] refactor(psbt): Remove Rbf type --- src/psbt/params.rs | 11 ++++------- src/wallet/mod.rs | 6 +++--- tests/create_psbt.rs | 2 +- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 91d15b70..792dbd63 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -23,9 +23,6 @@ pub struct CreateTx; #[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)] @@ -214,7 +211,7 @@ impl PsbtParams { /// [`add_planned_input`]: PsbtParams::add_planned_input /// [`Input`]: bdk_tx::Input /// [`NoOriginalTransactions`]: crate::error::ReplaceByFeeError::NoOriginalTransactions - pub fn replace_txs(self, txs: impl IntoIterator) -> PsbtParams + pub fn replace_txs(self, txs: impl IntoIterator) -> PsbtParams where T: Into>, { @@ -223,8 +220,8 @@ impl PsbtParams { params } - /// Transition this [`PsbtParams`] to the [`Rbf`] state. - fn into_replace_params(self) -> PsbtParams { + /// Transition this [`PsbtParams`] to the [`ReplaceTx`] state. + fn into_replace_params(self) -> PsbtParams { PsbtParams { set: self.set, must_spend: self.must_spend, @@ -577,7 +574,7 @@ impl fmt::Debug for UtxoFilter { } } -impl PsbtParams { +impl PsbtParams { /// Replace spends of the provided `txs`. This will internally set the list of UTXOs /// to be spent. fn replace(&mut self, txs: impl IntoIterator) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1f24f474..1f0ed24e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -84,7 +84,7 @@ use crate::wallet::{ use bitcoin::secp256k1::rand; #[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))] use { - crate::psbt::{AssetsExt, CreateTx, MustSpend, PsbtParams, Rbf, SelectionStrategy}, + crate::psbt::{AssetsExt, CreateTx, MustSpend, PsbtParams, ReplaceTx, SelectionStrategy}, crate::wallet::{ error::{CreatePsbtError, ReplaceByFeeError}, tx_builder::TxOrdering, @@ -3391,7 +3391,7 @@ impl Wallet { #[cfg_attr(docsrs, doc(cfg(feature = "std")))] pub fn replace_by_fee( &mut self, - params: PsbtParams, + params: PsbtParams, ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { self.replace_by_fee_with_rng(params, &mut rand::thread_rng()) } @@ -3411,7 +3411,7 @@ impl Wallet { /// See [`Wallet::take_staged`]. pub fn replace_by_fee_with_rng( &mut self, - mut params: PsbtParams, + mut params: PsbtParams, rng: &mut impl RngCore, ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { if params.replace.is_empty() { diff --git a/tests/create_psbt.rs b/tests/create_psbt.rs index 391f3ab2..1b9c0f12 100644 --- a/tests/create_psbt.rs +++ b/tests/create_psbt.rs @@ -762,7 +762,7 @@ fn test_replace_by_fee_no_original_transactions() { .create_wallet_no_persist() .unwrap(); - // replace_txs with an empty iterator produces PsbtParams with an empty replace set. + // replace_txs with an empty iterator produces PsbtParams with an empty replace set. let params = PsbtParams::default().replace_txs(core::iter::empty::()); let result = wallet.replace_by_fee(params); assert!( From 319df5e3e634ffbac98a1060c6e1240e5998d165 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 21 Jul 2026 10:14:36 -0400 Subject: [PATCH 19/19] fix(psbt): SelectionStrategy::Custom is now Send + Sync --- src/psbt/params.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index 792dbd63..8ec7aa1a 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -517,7 +517,7 @@ pub enum SelectionStrategy { Custom { /// Selection algorithm #[allow(clippy::type_complexity)] - algorithm: Arc Result<(), SelectorError>>, + algorithm: Arc Result<(), SelectorError> + Send + Sync>, }, /// Lowest fee, a variation of Branch 'n Bound that allows for change /// while minimizing transaction fees. Refer to