From ad077bd3fae50efb618f5437eb8dc49c59002808 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 28 Apr 2026 01:15:44 -0400 Subject: [PATCH 01/11] 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 ec93b54797c0a1f87b09ff5dbb24b5496505e55a Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 28 Apr 2026 01:14:29 -0400 Subject: [PATCH 02/11] 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 120fc6a6..bb222c3e 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 7f9485d8bb8cd2c7d689082703898aaa7dfa3ab7 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 28 Apr 2026 01:16:33 -0400 Subject: [PATCH 03/11] 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 73d71f1e..c97758e6 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)) } @@ -2491,6 +2527,36 @@ impl Wallet { self.events_helper(|wallet| wallet.apply_block_connected_to(block, height, connected_to)) } + /// Inserts a transaction into the inner transaction graph, scanning for relevant outputs. + /// + /// This can be used to inform the wallet of created transactions before they are known to exist + /// on chain or in the mempool. Inserting a transaction on its own doesn't affect the balance of + /// the wallet until the transaction is seen by the network and the wallet is synced. + /// + /// The effect of insertion depends on the [relevance] of `tx` as determined by the [indexer]. + /// If the transaction was newly inserted and an output matches a derived script pubkey, then + /// the index is updated with the relevant outpoints. If no outputs are relevant, the + /// transaction is kept and the index remains unchanged. If `tx` already exists in the wallet + /// under the same txid, then the effect is a no-op. + /// + /// **You must persist the change set staged as a result of this call.** + /// + /// [relevance]: Indexer::is_tx_relevant + /// [indexer]: Self::spk_index + pub fn insert_tx(&mut self, tx: T) + where + T: Into>, + { + let mut tx_update = TxUpdate::default(); + tx_update.txs = vec![tx.into()]; + let update = Update { + tx_update, + ..Default::default() + }; + self.apply_update(update) + .expect("Applying a `TxUpdate` should not fail"); + } + /// Apply relevant unconfirmed transactions to the wallet. /// /// Transactions that are not relevant are filtered out. @@ -2778,6 +2844,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() @@ -2907,7 +3746,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 a7f14acd44bfac108c64f77f5bdd10ff599bd8b1 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 28 Apr 2026 01:20:32 -0400 Subject: [PATCH 04/11] 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 268c66f8..6aea7bfc 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -6,7 +6,7 @@ use bdk_chain::{BlockId, CanonicalizationParams, ConfirmationBlockTime}; use bdk_wallet::coin_selection; use bdk_wallet::descriptor::{calc_checksum, DescriptorError}; use bdk_wallet::error::CreateTxError; -use bdk_wallet::psbt::PsbtUtils; +use bdk_wallet::psbt::{self, PsbtUtils}; use bdk_wallet::signer::{SignOptions, SignerError}; use bdk_wallet::test_utils::*; use bdk_wallet::KeychainKind; @@ -25,6 +25,89 @@ use rand::SeedableRng; mod common; +// Test we can select and spend an indexed but not-yet-canonical utxo +#[test] +fn test_spend_non_canonical_txout() -> anyhow::Result<()> { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let recip = ScriptBuf::from_hex("0014446906a6560d8ad760db3156706e72e171f3a2aa").unwrap(); + + // Receive tx0 (coinbase) + let tx = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::ONE_BTC, + script_pubkey: wallet + .reveal_next_address(KeychainKind::External) + .script_pubkey(), + }], + ..new_tx(1) + }; + let block = BlockId { + height: 100, + hash: Hash::hash(b"100"), + }; + insert_tx_anchor(&mut wallet, tx, block); + let block = BlockId { + height: 1000, + hash: Hash::hash(b"1000"), + }; + insert_checkpoint(&mut wallet, block); + + // Create tx1 + let mut params = psbt::PsbtParams::default(); + params.add_recipients([(recip.clone(), Amount::from_btc(0.01)?)]); + let psbt = wallet.create_psbt(params)?.0; + let txid = psbt.unsigned_tx.compute_txid(); + let (vout, _) = psbt + .unsigned_tx + .output + .iter() + .enumerate() + .find(|(_, txo)| wallet.is_mine(txo.script_pubkey.clone())) + .unwrap(); + let to_select_op = OutPoint::new(txid, vout as u32); + + let txid1 = psbt.unsigned_tx.compute_txid(); + wallet.insert_tx(psbt.unsigned_tx); + + // Create tx2, spending the change of tx1 + let mut params = psbt::PsbtParams::default(); + let canonical_params = bdk_chain::CanonicalizationParams { + assume_canonical: vec![to_select_op.txid], + }; + params + .canonicalization_params(canonical_params) + .add_recipients([(recip, Amount::from_btc(0.01)?)]); + + let psbt = wallet.create_psbt(params)?.0; + + assert_eq!(psbt.unsigned_tx.input.len(), 1); + assert_eq!(psbt.unsigned_tx.input[0].previous_output, to_select_op); + + let txid2 = psbt.unsigned_tx.compute_txid(); + wallet.insert_tx(psbt.unsigned_tx); + + // Check we can retrieve the unsigned txs. + let txs = wallet + .transactions_with_params(CanonicalizationParams { + assume_canonical: vec![txid2], + }) + .filter(|c| c.chain_position.is_unconfirmed()) + .collect::>(); + + assert_eq!(txs.len(), 2); + + assert!(txs.iter().any(|c| c.tx_node.txid == txid1)); + assert!(txs.iter().any(|c| c.tx_node.txid == txid2)); + + Ok(()) +} + #[test] fn test_error_external_and_internal_are_the_same() { // identical descriptors should fail to create wallet From 43adb6ea46ebf87fdfc9e5ff078815e8a5d5edc5 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 28 Apr 2026 01:20:37 -0400 Subject: [PATCH 05/11] 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 bb222c3e..1f86fb3c 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 a4943c83752a47800982acf153583392e9a9da06 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Sun, 7 Jun 2026 22:54:41 -0400 Subject: [PATCH 06/11] 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 c97758e6..53de5f5a 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3272,7 +3272,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)], /// )?; @@ -3280,9 +3280,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> { @@ -3349,6 +3349,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 a99fa41d14da82ba5580eedd636175d31b5278f8 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Mon, 8 Jun 2026 21:44:02 -0400 Subject: [PATCH 07/11] 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 37bd3f80048b3fe9083bb31574f1a87fc1a68191 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Mon, 8 Jun 2026 23:11:28 -0400 Subject: [PATCH 08/11] 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 53de5f5a..02c0354e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3091,6 +3091,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); @@ -3197,6 +3203,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 => { @@ -3352,6 +3364,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 ee175c74b91d2344a689f7bf18f5e9548507d116 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Thu, 11 Jun 2026 09:43:00 -0400 Subject: [PATCH 09/11] fix: Remove unused methods --- src/wallet/mod.rs | 50 +++------------------------- tests/wallet.rs | 85 +---------------------------------------------- 2 files changed, 6 insertions(+), 129 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 02c0354e..03029c98 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1102,24 +1102,14 @@ 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 { 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)) } @@ -2527,36 +2517,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. diff --git a/tests/wallet.rs b/tests/wallet.rs index 6aea7bfc..268c66f8 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -6,7 +6,7 @@ use bdk_chain::{BlockId, CanonicalizationParams, ConfirmationBlockTime}; use bdk_wallet::coin_selection; use bdk_wallet::descriptor::{calc_checksum, DescriptorError}; use bdk_wallet::error::CreateTxError; -use bdk_wallet::psbt::{self, PsbtUtils}; +use bdk_wallet::psbt::PsbtUtils; use bdk_wallet::signer::{SignOptions, SignerError}; use bdk_wallet::test_utils::*; use bdk_wallet::KeychainKind; @@ -25,89 +25,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 e3c649a3384be83e4760dce4195d5f289c1bfa61 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 9 Jun 2026 23:43:59 -0400 Subject: [PATCH 10/11] 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 03029c98..078b0221 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3398,6 +3398,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 573e6086..c7fae070 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -873,6 +873,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(); @@ -1288,3 +1381,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 e57a53667aed0e65507790738c51640c4ce54476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 16 Jun 2026 13:44:53 +0000 Subject: [PATCH 11/11] refactor(psbt)!: Redesign PSBT creation as a 3-stage API Replace `create_psbt`/`sweep` with an explicit three-stage flow built on bdk_tx's `TxTemplate`, keeping the wallet a thin layer over `bdk_tx`: 1. `candidates()` / `rbf_candidates()` / `candidates_with(&CandidateParams)` resolve a `CandidateSet` (deterministic, rng-free). 2. `select(coins, SelectParams)` runs coin selection and returns a `TxTemplate`; single-random-draw randomness lives here (via `Selector::select_with_algorithm`). 3. `finish(template, FinishParams)` emits the `Psbt` and `Finalizer`. The caller shapes the returned `TxTemplate` (version, locktime, sequence, anti-fee-sniping, ordering, shuffling) with `bdk_tx`'s own methods rather than through wallet options. It is returned unshuffled with no anti-fee-sniping, so change-output privacy is opted into explicitly. Params are plain public-field structs (no builders): - `CandidateParams` is purely wallet-derivation config: `must_spend` (`BTreeSet`), `assets` (plain `Assets`, empty == none), canonicalization, maturity, `manually_selected_only`, and `replace` (RBF). - `SelectParams` (recipients, change, coin-selection strategy, fee rate) and `FinishParams` (emission options). `CandidateSet`: - Foreign (non-wallet) inputs are pushed on with `push_must_select` / `push_can_select` (forwarding to upstream `InputCandidates::push_*`), so they aren't wallet-derivation config. - `filter` / `regroup` / `into_parts` expose `bdk_tx` for anything not wrapped. - For RBF it carries `bdk_tx::RbfParams` directly, rejects a pushed input that spends a replaced output (`ConflictingInput`), and exposes the wallet outputs the replacement strips (`replaced_unspent`) for batching. Other: - Sweep folds into `select` via `SelectionStrategy::DrainAll`; a `NoRecipients` guard prevents accidental full-wallet drains; a no-change drain auto-derives and reveals an internal change address. - A replaced tx the wallet controls no input of is rejected (`CandidatesError::CannotReplace`). - Errors split into `CandidatesError` (stage 1) and `CreatePsbtError` (2/3). - Point `bdk_tx` at the `feature/tx-template` branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.toml | 2 +- examples/psbt.rs | 42 +- examples/replace_by_fee.rs | 50 +- src/psbt/params.rs | 1032 ++++++++++-------------------------- src/wallet/error.rs | 135 +++-- src/wallet/mod.rs | 917 +++++++++++++++----------------- tests/add_foreign_utxo.rs | 15 +- tests/psbt.rs | 885 ++++++++++++++++++++----------- 8 files changed, 1382 insertions(+), 1696 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1f86fb3c..2022af65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +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 } +bdk_tx = { git = "https://github.com/evanlinjin/bdk-tx", branch = "feature/tx-template", default-features = false } bitcoin = { version = "0.32.8", features = ["serde", "base64"], default-features = false } miniscript = { version = "12.3.5", features = ["serde"], default-features = false } rand_core = { version = "0.6.4" } diff --git a/examples/psbt.rs b/examples/psbt.rs index ccf26c98..dba8f1bc 100644 --- a/examples/psbt.rs +++ b/examples/psbt.rs @@ -5,10 +5,10 @@ use std::str::FromStr; use bdk_chain::BlockId; use bdk_chain::ConfirmationBlockTime; -use bdk_wallet::psbt::{PsbtParams, SelectionStrategy::*}; +use bdk_wallet::psbt::{FinishParams, SelectParams, SelectionStrategy::*}; use bdk_wallet::test_utils::*; use bdk_wallet::{KeychainKind::External, Wallet}; -use bitcoin::{consensus, secp256k1::rand, Address, Amount, TxIn, TxOut}; +use bitcoin::{consensus, secp256k1::rand, transaction::Version, Address, Amount, TxIn, TxOut}; use rand::Rng; // This example shows how to create a PSBT using BDK Wallet. @@ -41,17 +41,35 @@ fn main() -> anyhow::Result<()> { .map(|output| (output.outpoint, output)) .collect::>(); - // Build params. - let mut params = PsbtParams::default(); + // Build address. 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)?; + + // Stage 1: resolve the spendable candidates. + let coins = wallet.candidates()?; + + // Stage 2: run coin selection, yielding a `TxTemplate`. + let template = wallet.select( + coins, + SelectParams { + recipients: vec![(addr.script_pubkey(), AMOUNT)], + coin_selection: LowestFee { + longterm_feerate: feerate_unchecked(3.0), + max_rounds: 500_000, + }, + fee_rate: feerate_unchecked(FEERATE), + ..Default::default() + }, + )?; + + // Shape the template before emitting. We pin the tx version, and — importantly — shuffle the + // outputs so the change output is not left in its default, trivially-identifiable position + // (`select` returns an *unshuffled* template). Other shaping (locktime, anti-fee-sniping, + // input ordering) is likewise done on the template. + let mut rng = rand::thread_rng(); + let template = template.set_version(Version(3))?.shuffle_outputs(&mut rng); + + // Stage 3: emit the PSBT (which also returns the Finalizer). + let (mut psbt, finalizer) = wallet.finish(template, FinishParams::default())?; let tx = &psbt.unsigned_tx; for txin in &tx.input { diff --git a/examples/replace_by_fee.rs b/examples/replace_by_fee.rs index feb4d156..dff05f83 100644 --- a/examples/replace_by_fee.rs +++ b/examples/replace_by_fee.rs @@ -3,14 +3,13 @@ use std::sync::Arc; use bdk_chain::BlockId; -use bdk_tx::ChangeScript; -use bdk_wallet::psbt::PsbtParams; +use bdk_wallet::psbt::{FinishParams, SelectParams}; use bdk_wallet::test_utils::*; use bdk_wallet::{KeychainKind, Wallet}; use bitcoin::{Amount, FeeRate, TxIn, TxOut}; use miniscript::{DefiniteDescriptorKey, Descriptor}; -// This example demonstrates creating a sweep transaction using PsbtParams and replacing it with a +// This example demonstrates creating a transaction with `SelectParams` and replacing it with a // higher feerate. const NETWORK: bitcoin::Network = bitcoin::Network::Regtest; @@ -42,16 +41,19 @@ fn main() -> anyhow::Result<()> { "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)?; + println!("Creating first transaction (tx1)..."); + + // Create tx1: pay an amount to our own derived address at a low feerate. + let coins = wallet.candidates()?; + let template1 = wallet.select( + coins, + SelectParams { + recipients: vec![(derived_descriptor.script_pubkey(), Amount::from_sat(10_000))], + fee_rate: FeeRate::from_sat_per_vb(2).expect("valid feerate"), + ..Default::default() + }, + )?; + let (mut psbt1, finalizer1) = wallet.finish(template1, FinishParams::default())?; // Sign and finalize tx1 let _ = psbt1 @@ -78,16 +80,18 @@ 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)]); - - // 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)?; + // Create tx2: Replace tx1 at a higher feerate, paying the same recipient. Seed a candidate set + // with the tx to replace, then shape the output with bumped fee rate. + let coins = wallet.rbf_candidates(&[tx1.compute_txid()])?; + let template2 = wallet.select( + coins, + SelectParams { + recipients: vec![(derived_descriptor.script_pubkey(), Amount::from_sat(10_000))], + fee_rate: FeeRate::from_sat_per_vb(5).expect("valid feerate"), + ..Default::default() + }, + )?; + let (mut psbt2, finalizer2) = wallet.finish(template2, FinishParams::default())?; // Sign and finalize tx2 let _ = psbt2 diff --git a/src/psbt/params.rs b/src/psbt/params.rs index fbfa6171..2b8dd7ae 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -1,494 +1,302 @@ //! Parameters for creating a PSBT. +//! +//! PSBT building is split into three stages: +//! +//! 1. **Candidate construction** — [`CandidateParams`] configures which coins may fund the +//! transaction; [`Wallet::candidates_with`] resolves them into a [`CandidateSet`]. A replacement +//! (RBF) is just a candidate set built from options whose [`replace`] list is non-empty (or via +//! the [`Wallet::rbf_candidates`] shortcut). +//! 2. **Selection** — [`SelectParams`] describes the recipients, fee rate and coin-selection +//! strategy; it is passed alongside a [`CandidateSet`] to [`Wallet::select`], which runs coin +//! selection and returns a [`bdk_tx::TxTemplate`]. To sweep (drain), use no recipients with +//! [`SelectionStrategy::DrainAll`]. +//! 3. **Emission** — the caller shapes the [`bdk_tx::TxTemplate`] (version, locktime, ordering, +//! anti-fee-sniping) using its own methods, then emits the final [`Psbt`](bitcoin::Psbt) via +//! [`Wallet::finish`] with [`FinishParams`]. +//! +//! [`replace`]: CandidateParams::replace +//! [`Wallet::candidates_with`]: crate::Wallet::candidates_with +//! [`Wallet::rbf_candidates`]: crate::Wallet::rbf_candidates +//! [`Wallet::select`]: crate::Wallet::select +//! [`Wallet::finish`]: crate::Wallet::finish -use alloc::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 bdk_chain::CanonicalizationParams; +use bdk_tx::{ChangeScript, Input, InputCandidates, RbfParams}; +use bitcoin::{absolute, Amount, FeeRate, OutPoint, ScriptBuf, Txid}; use miniscript::plan::Assets; -use crate::collections::{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, `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, - /// 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. +use crate::collections::{BTreeSet, HashSet}; +use crate::types::LocalOutput; +use crate::wallet::error::CandidatesError; + +/// Parameters for building the set of spendable input candidates (PSBT-building stage 1). +/// +/// Configures how candidates are derived **from the wallet** — manually selected ("must spend") +/// UTXOs, the spend [`Assets`], canonicalization, and Replace-By-Fee. Pass it to +/// [`Wallet::candidates_with`] to resolve a [`CandidateSet`]. +/// +/// All fields are public; construct with [`new`](Self::new) (or [`Default`]) and set what you +/// need. Manually-selected [`must_spend`](Self::must_spend) outpoints are de-duplicated when the +/// [`CandidateSet`] is resolved. +/// +/// To spend a UTXO that did not originate from this wallet (a pre-built foreign [`Input`]), don't +/// configure it here — push it onto the resolved [`CandidateSet`] with +/// [`push_must_select`](CandidateSet::push_must_select) / +/// [`push_can_select`](CandidateSet::push_can_select). +/// +/// To build a replacement transaction (RBF), list the txids to replace in +/// [`replace`](Self::replace); the resulting [`CandidateSet`] carries the replacement context +/// forward to stage 2, so any output shape (pay or sweep) can replace. +/// +/// [`Wallet::candidates_with`]: crate::Wallet::candidates_with +#[derive(Debug, Default)] +pub struct CandidateParams { + /// Manually-selected UTXO outpoints that must be spent. + /// + /// Each outpoint must correspond to an output of a transaction tracked by the wallet and be + /// currently unspent, otherwise resolving the [`CandidateSet`] yields [`UnknownUtxo`]. To spend + /// a UTXO that did not originate from this wallet, push a foreign [`Input`] onto the resolved + /// [`CandidateSet`] instead (see [`CandidateSet::push_must_select`]). + /// + /// [`UnknownUtxo`]: crate::wallet::error::CandidatesError::UnknownUtxo + pub must_spend: BTreeSet, + /// Spend [`Assets`] used to create spending plans for the wallet's own outputs. + /// + /// An empty value (the default) means no assets are provided, in which case all keys are + /// assumed equally likely to sign. + pub assets: Assets, + /// Parameters for modifying the wallet's view of canonical transactions. /// - /// 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, - } - } + /// Refer to [`CanonicalizationParams`] for more. + pub canonical_params: CanonicalizationParams, + /// Height used when evaluating the maturity of coinbase outputs during coin selection. + /// + /// Defaults to the chain tip height when `None`. + pub maturity_height: Option, + /// Only include inputs selected manually via [`must_spend`](Self::must_spend) (plus any foreign + /// inputs pushed onto the resolved [`CandidateSet`]); skip coin selection for additional + /// candidates. + /// + /// The manually-selected inputs must then be enough to fund the transaction. + pub manually_selected_only: bool, + /// Txids to replace (Replace-By-Fee). + /// + /// The must-spend inputs of the resulting [`CandidateSet`] are derived from the inputs of the + /// replaced transactions (resolved against the wallet's transaction graph). There should be no + /// ancestry linking these txids — replacing an ancestor invalidates the descendant — and such + /// ancestry is sanitized away during resolution. + pub replace: Vec, } -impl PsbtParams { - /// Create a new [`PsbtParams`]. +impl CandidateParams { + /// Create new, empty [`CandidateParams`]. 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, 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 - /// [`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 - } - - /// 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`. 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 - /// - /// There should be no ancestry linking the elements of `txs`, since replacing an - /// ancestor necessarily invalidates the descendant. - /// - /// `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 - T: Into>, - { - 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, - } - } +/// A resolved set of spendable input candidates (output of PSBT-building stage 1). +/// +/// Produced by [`Wallet::candidates_with`] from [`CandidateParams`]: every owned UTXO has been +/// planned against the wallet's descriptors and spendability filters applied. It owns its inputs +/// (no wallet borrow), so it can be held as a snapshot and used to build one or more PSBTs via +/// [`Wallet::select`]. +/// +/// Add foreign (non-wallet) inputs with [`push_must_select`](Self::push_must_select) / +/// [`push_can_select`](Self::push_can_select), and apply your own post-resolution filters with +/// [`filter`](Self::filter) / [`regroup`](Self::regroup). +/// +/// If the [`CandidateParams`] had a non-empty [`replace`](CandidateParams::replace) list, the set +/// carries the [`bdk_tx::RbfParams`] (replaced-tx fee statistics) forward so stage 2 applies the +/// correct fee floor, and exposes the wallet-owned outputs being stripped by the replacement via +/// [`replaced_unspent`](Self::replaced_unspent) (handy for batching the replaced txs' payments). +/// +/// [`Wallet::candidates_with`]: crate::Wallet::candidates_with +/// [`Wallet::select`]: crate::Wallet::select +#[derive(Debug, Clone)] +pub struct CandidateSet { + pub(crate) candidates: InputCandidates, + pub(crate) rbf: Option, + /// Txids being replaced/evicted (direct conflicts + descendants). A pushed input may not spend + /// an output of any of these. + pub(crate) replaced: HashSet, + /// Wallet-owned UTXOs stripped from the canonical view by the replacement. + pub(crate) replaced_unspent: Vec, } -impl PsbtParams { - /// Get the currently selected spends. - pub fn utxos(&self) -> &HashSet { - &self.set +impl CandidateSet { + /// Iterate over all resolved input candidates (both must-select and optional). + pub fn inputs(&self) -> impl Iterator + '_ { + self.candidates.inputs() } - /// 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 + /// Whether the set contains no candidates at all. + pub fn is_empty(&self) -> bool { + self.candidates.inputs().next().is_none() } - /// 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 + /// Whether this set is a Replace-By-Fee set (built from a non-empty + /// [`CandidateParams::replace`] list). + pub fn is_rbf(&self) -> bool { + self.rbf.is_some() } - /// Add the spend [`Assets`]. + /// Wallet-owned UTXOs that the replacement strips out of the canonical view — the outputs of + /// the replaced (and descendant) txs that were unspent in the wallet's view before the replace. /// - /// 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 + /// These are the still-live payments of the txs being replaced; a caller batching several txs + /// into one replacement can use them to decide which payments to re-create. Empty for a + /// non-Replace-By-Fee set. + pub fn replaced_unspent(&self) -> &[LocalOutput] { + &self.replaced_unspent } - /// Add outgoing recipients to the transaction. + /// Add a foreign [`Input`] to the must-select group (always spent). /// - /// - `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`. + /// Use this for a UTXO that did not originate from the wallet, supplied with a pre-built + /// [`Plan`]/[`psbt::Input`] — its validity (UTXO existence, satisfaction weight, ...) relies on + /// the caller-supplied values, so only push inputs you trust. /// - /// 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`]. + /// # Errors /// - /// [`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`]. + /// Returns [`ConflictingInput`] if the input spends an output of a transaction being replaced + /// (RBF) — that output won't exist after the replacement. /// - /// 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 + /// [`ConflictingInput`]: CandidatesError::ConflictingInput + /// [`Plan`]: miniscript::plan::Plan + /// [`psbt::Input`]: bitcoin::psbt::Input + pub fn push_must_select(mut self, input: Input) -> Result { + self.ensure_not_replaced(&input)?; + self.candidates = self.candidates.push_must_select(input); + Ok(self) } - /// Set the parameters for modifying the wallet's view of canonical transactions. + /// Add a foreign [`Input`] as an optional (can-select) candidate. /// - /// 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 + /// Like [`push_must_select`](Self::push_must_select), but the input is offered to coin + /// selection rather than always spent. + /// + /// # Errors + /// + /// Returns [`ConflictingInput`](CandidatesError::ConflictingInput) if the input spends an + /// output of a transaction being replaced (RBF). + pub fn push_can_select(mut self, input: Input) -> Result { + self.ensure_not_replaced(&input)?; + self.candidates = self.candidates.push_can_select(input); + Ok(self) } - /// 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 + /// Reject an input that spends an output of a replaced (evicted) transaction. + fn ensure_not_replaced(&self, input: &Input) -> Result<(), CandidatesError> { + let op = input.prev_outpoint(); + if self.replaced.contains(&op.txid) { + return Err(CandidatesError::ConflictingInput(op)); + } + Ok(()) } - /// Filter [`FullTxOut`]s by the provided closure. + /// Keep only the candidates for which `policy` returns `true`. /// - /// 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 + /// Forwards to [`bdk_tx::InputCandidates::filter`]. The closure receives each + /// [`bdk_tx::Input`], which exposes enough to filter by value, script, or confirmation: e.g. + /// [`prev_txout`](Input::prev_txout) (amount/script), [`status`](Input::status) and + /// [`confirmations`](Input::confirmations) (confirmed-only: `|i| i.status().is_some()`), + /// [`is_coinbase`](Input::is_coinbase), and [`is_immature`](Input::is_immature). + pub fn filter

(mut self, policy: P) -> Self where - F: Fn(&FullTxOut) -> bool + Send + Sync + 'static, + P: FnMut(&Input) -> bool, { - self.utxo_filter = UtxoFilter(Arc::new(predicate)); + self.candidates = self.candidates.filter(policy); 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. + /// Regroup the candidates by the group key returned by `policy`. /// - /// [`Shuffle`]: TxOrdering::Shuffle - /// [`Untouched`]: TxOrdering::Untouched - pub fn ordering(&mut self, ordering: TxOrdering) -> &mut Self { - self.ordering = ordering; + /// Forwards to [`bdk_tx::InputCandidates::regroup`]. + pub fn regroup(mut self, policy: P) -> Self + where + P: FnMut(&Input) -> G, + G: Ord + Clone, + { + self.candidates = self.candidates.regroup(policy); self } - /// Only fill in the [`witness_utxo`] field of PSBT inputs which spends funds under segwit (v0). + /// Consume into the underlying `bdk_tx` parts: the [`InputCandidates`] and, if this is a + /// Replace-By-Fee set (see [`is_rbf`](Self::is_rbf)), the [`RbfParams`] carrying the + /// replaced-tx fee floor. /// - /// 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`. + /// Pass both on to `bdk_tx` (e.g. via [`SelectorParams::replace`]) to build a PSBT directly + /// while still enforcing the RBF minimum fee. The `RbfParams` is wallet-derived and cannot be + /// reconstructed without the wallet, so it is returned here rather than dropped. /// - /// [`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 + /// [`SelectorParams::replace`]: bdk_tx::SelectorParams::replace + pub fn into_parts(self) -> (InputCandidates, Option) { + (self.candidates, self.rbf) } +} - /// 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. +/// Parameters to create a PSBT that pays a set of recipients (PSBT-building stage 2). +/// +/// Built with [`SelectParams::new`], passed alongside a [`CandidateSet`] to +/// [`Wallet::select`], which runs coin selection and returns a [`bdk_tx::TxTemplate`]. The caller +/// then shapes the template (version, locktime, anti-fee-sniping, input/output ordering) using +/// the template's own methods before emitting the PSBT via [`Wallet::finish`]. +/// +/// [`Wallet::select`]: crate::Wallet::select +/// [`Wallet::finish`]: crate::Wallet::finish +#[derive(Debug)] +pub struct SelectParams { + /// List of recipient script/amount pairs. + pub recipients: Vec<(ScriptBuf, Amount)>, + /// Optional script or descriptor designated for change. + pub change_script: Option, + /// Coin selection strategy to use. /// - /// [`Psbt::xpub`]: bitcoin::Psbt::xpub - pub fn add_global_xpubs(&mut self) -> &mut Self { - self.add_global_xpubs = true; - self - } + /// Defaults to [`SelectionStrategy::SingleRandomDraw`]. Use [`SelectionStrategy::DrainAll`] + /// (with no recipients) to sweep the whole candidate set. + pub coin_selection: SelectionStrategy, + /// Target fee rate. + pub fee_rate: FeeRate, +} - /// 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 +impl Default for SelectParams { + fn default() -> Self { + Self::new() } +} - /// 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 +impl SelectParams { + /// Create `SelectParams` with no recipients, default coin selection, and the + /// `FeeRate::BROADCAST_MIN` fee rate. + pub fn new() -> Self { + Self { + recipients: Vec::new(), + change_script: None, + coin_selection: SelectionStrategy::default(), + fee_rate: FeeRate::BROADCAST_MIN, + } } +} - /// 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 - } +/// Parameters for emitting the final [`Psbt`] from a [`bdk_tx::TxTemplate`] (PSBT-building stage 3). +/// +/// Carries only PSBT-emission options. Transaction-shape decisions (version, locktime, sequence, +/// anti-fee-sniping, input/output ordering) live on the [`bdk_tx::TxTemplate`] returned by +/// [`Wallet::select`] and are applied with the template's own methods before being passed to +/// [`Wallet::finish`]. +/// +/// [`Psbt`]: bitcoin::Psbt +/// [`Wallet::select`]: crate::Wallet::select +/// [`Wallet::finish`]: crate::Wallet::finish +#[derive(Debug, Clone, Default)] +pub struct FinishParams { + /// Only set the [`witness_utxo`](bitcoin::psbt::Input::witness_utxo) in segwit-v0 PSBT inputs. + pub only_witness_utxo: bool, + /// Whether to try filling in the PSBT global xpubs from the wallet's descriptors. + pub add_global_xpubs: bool, } /// Coin select strategy. @@ -509,335 +317,29 @@ pub enum SelectionStrategy { /// How many times to run BnB before giving up. max_rounds: usize, }, + /// Select **all** available candidates (drain), ignoring any target amount. + /// + /// The remainder (everything minus fees) goes to the change output. With no recipients this + /// sweeps the whole candidate set to change (auto-derived if no `change_script` is set, or an + /// explicit destination); with recipients it pays them and sends the rest to change + /// ("drain while paying"). + DrainAll, } -/// [`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: impl IntoIterator) - 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); - } - - let mut utxos = vec![]; - - 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 - // 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; - - // 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))); - } -} - -/// 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 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([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: - // 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 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()); - } - - #[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)); - } - - // 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" - ); - } +/// Merge the available signing keys and hash preimages from `src` into `dst`. +/// +/// Only these additive (set-union) secrets are merged. The absolute/relative **timelocks are +/// deliberately left untouched** — they are single-valued ceilings with no unambiguous merge (and +/// [`absolute::LockTime`]/[`relative::LockTime`] are only partially ordered, so there is no +/// well-defined "stricter" across a height- and a time-based lock). Callers that care set the +/// timelocks explicitly after merging. +/// +/// [`relative::LockTime`]: bitcoin::relative::LockTime +pub(crate) fn merge_assets_secrets(dst: &mut Assets, src: &Assets) { + dst.keys.extend(src.keys.clone()); + dst.sha256_preimages.extend(src.sha256_preimages.clone()); + dst.hash256_preimages.extend(src.hash256_preimages.clone()); + dst.ripemd160_preimages + .extend(src.ripemd160_preimages.clone()); + dst.hash160_preimages.extend(src.hash160_preimages.clone()); } diff --git a/src/wallet/error.rs b/src/wallet/error.rs index e24a462c..124469c5 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -367,17 +367,71 @@ impl fmt::Display for BuildFeeBumpError { impl core::error::Error for BuildFeeBumpError {} +/// Error when resolving the spendable [`CandidateSet`] (PSBT-building stage 1). +/// +/// [`CandidateSet`]: crate::psbt::CandidateSet +#[derive(Debug)] +#[non_exhaustive] +pub enum CandidatesError { + /// The UTXO of outpoint could not be found. + UnknownUtxo(OutPoint), + /// Failed to create a spending plan for a manually selected output. + Plan(OutPoint), + /// A transaction to be replaced (RBF) is already confirmed. + TransactionConfirmed(Txid), + /// A transaction being replaced (RBF) could not be found. + MissingTransaction(Txid), + /// The wallet controls none of the inputs of a transaction to be replaced (RBF), so it cannot + /// build a replacement that conflicts with (and therefore evicts) it. + CannotReplace(Txid), + /// A manually-selected input spends an output of a transaction in the replaced (RBF) set + /// (a direct conflict or one of its descendants); including it would produce an invalid + /// transaction. + ConflictingInput(OutPoint), + /// Failed to compute the fee of a transaction being replaced (RBF). + PreviousFee(bdk_chain::tx_graph::CalculateFeeError), +} + +impl fmt::Display for CandidatesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownUtxo(op) => write!(f, "unknown UTXO: {op}"), + Self::Plan(op) => write!(f, "failed to create a plan for txout with outpoint {op}"), + Self::TransactionConfirmed(txid) => { + write!(f, "transaction already confirmed: {txid}") + } + Self::MissingTransaction(txid) => write!(f, "missing transaction: {txid}"), + Self::CannotReplace(txid) => { + write!( + f, + "cannot replace transaction {txid}: the wallet controls none of its inputs" + ) + } + Self::ConflictingInput(outpoint) => { + write!( + f, + "manually-selected input {outpoint} conflicts with the replacement set" + ) + } + Self::PreviousFee(e) => write!(f, "{e}"), + } + } +} + +impl core::error::Error for CandidatesError {} + /// Error when creating a PSBT. #[derive(Debug)] #[non_exhaustive] pub enum CreatePsbtError { /// No Bnb solution. Bnb(bdk_coin_select::NoBnbSolution), - /// No output destinations were configured. At least one recipient, or - /// [`drain_wallet`] with an explicit [`change_script`], is required. + /// No recipients were configured with a non-drain coin selection. A [`select`] requires at + /// least one recipient; to send all funds to a single destination, use + /// [`SelectionStrategy::DrainAll`] with no recipients. /// - /// [`drain_wallet`]: crate::PsbtParams::drain_wallet - /// [`change_script`]: crate::PsbtParams::change_script + /// [`select`]: crate::Wallet::select + /// [`SelectionStrategy::DrainAll`]: crate::psbt::SelectionStrategy::DrainAll NoRecipients, /// After coin selection, all outputs fell below the dust threshold and were /// dropped to fees. @@ -387,18 +441,12 @@ pub enum CreatePsbtError { /// 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 + /// [`add_global_xpubs`]: crate::psbt::FinishParams::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), + /// Failed to build the PSBT. + Build(bdk_tx::BuildPsbtError), /// 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 { @@ -409,69 +457,10 @@ impl fmt::Display for CreatePsbtError { 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}"), + Self::Build(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), - /// No original transactions were specified. - 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 { - 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::NoOriginalTransactions => write!(f, "no original transactions were specified"), - Self::NoInputsFromOriginal(txid) => { - write!( - f, - "replacement has no inputs from original transaction: {txid}" - ) - } - Self::ConflictingInput(outpoint) => { - write!( - f, - "manually-selected input {outpoint} conflicts with the replacement set" - ) - } - } - } -} - -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 078b0221..602f60fe 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -35,9 +35,9 @@ use bdk_chain::{ 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, + bdk_coin_select, selection_algorithm_lowest_fee_bnb, selection_algorithm_single_random_draw, + ChangeScript, ConfirmationStatus, Finalizer, Input, InputCandidates, OriginalTxStats, Output, + PsbtBuildParams, RbfParams, Selector, SelectorParams, TxTemplate, }; #[cfg(feature = "std")] use bitcoin::secp256k1::rand; @@ -73,21 +73,24 @@ pub mod signer; pub mod tx_builder; pub(crate) mod utils; -use crate::collections::{BTreeMap, HashMap, HashSet}; +use crate::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use crate::descriptor::{ check_wallet_descriptor, checksum::calc_checksum, error::Error as DescriptorError, policy::BuildSatisfaction, DerivedDescriptor, DescriptorMeta, ExtendedDescriptor, ExtractPolicy, IntoWalletDescriptor, Policy, XKeyUtils, }; -use crate::psbt::{AssetsExt, CreateTx, PsbtParams, PsbtUtils, Rbf, SelectionStrategy}; +use crate::psbt::{ + merge_assets_secrets, CandidateParams, CandidateSet, FinishParams, PsbtUtils, SelectParams, + SelectionStrategy, +}; use crate::types::*; use crate::wallet::{ coin_selection::{DefaultCoinSelectionAlgorithm, Excess, InsufficientFunds}, error::{ - BuildFeeBumpError, CreatePsbtError, CreateTxError, MiniscriptPsbtError, ReplaceByFeeError, + BuildFeeBumpError, CandidatesError, CreatePsbtError, 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}, }; @@ -2888,51 +2891,53 @@ impl Wallet { /// Parses the common parameters used during PSBT creation and returns the spend assets /// and a map of indexed tx outputs. - fn parse_params( + fn parse_params( &self, - params: &PsbtParams, + opts: &CandidateParams, ) -> (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 - } - }; + // The caller's `opts.assets` are authoritative. Copy in its keys/preimages and carry its + // timelocks over verbatim (no implicit merge). If the caller supplied no signing keys — the + // common case, where they only want to declare timelocks — assume all of the wallet's keys + // are available so wallet-controlled outputs can still be planned. + let mut assets = Assets::new(); + merge_assets_secrets(&mut assets, &opts.assets); + assets.absolute_timelock = opts.assets.absolute_timelock; + assets.relative_timelock = opts.assets.relative_timelock; + if assets.keys.is_empty() { + merge_assets_secrets(&mut assets, &self.assets()); + } // Get wallet txouts. let txouts = self - .list_indexed_txouts(params.canonical_params.clone()) + .list_indexed_txouts(opts.canonical_params.clone()) .map(|(_, txo)| (txo.outpoint, txo)) .collect(); (assets, txouts) } - /// Filters wallet `txos` by the spending criteria. + /// Filters wallet `txos` by the built-in spending criteria. /// /// - `policy`: Closure indicating whether the output should be kept, used by some callers to - /// apply additional filters as in the case of RBF. - fn filter_spendable<'a, I, C, F>( + /// apply additional filters as in the case of RBF. User-defined filters are applied + /// post-resolution via [`CandidateSet::filter`] instead. + fn filter_spendable<'a, I, F>( &'a self, txos: I, - params: &'a PsbtParams, + opts: &'a CandidateParams, 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()); + let current_height = opts + .maturity_height + .map(|h| h.to_consensus_u32()) + .unwrap_or_else(|| self.chain.tip().height()); txos.into_iter().filter(move |txo| { // Exclude outputs that are manually selected. - if params.set.contains(&txo.outpoint) { + if opts.must_spend.contains(&txo.outpoint) { return false; } // Filter outputs according to `policy` fn. @@ -2956,9 +2961,8 @@ impl Wallet { } /// Maps the recipients of the `params` to a collection of target [`Output`]s. - fn target_outputs(&self, params: &PsbtParams) -> Vec { - params - .recipients + fn target_outputs(&self, recipients: &[(ScriptBuf, Amount)]) -> Vec { + recipients .iter() .cloned() .map( @@ -2976,30 +2980,46 @@ impl Wallet { .collect() } - /// Creates a PSBT with the given `params` and returns the updated [`Psbt`] and - /// [`Finalizer`]. + /// **Stage 2.** Run coin selection over a resolved [`CandidateSet`], returning a + /// [`TxTemplate`] that pays the given recipients. + /// + /// The returned template is the workspace between coin selection and PSBT emission: shape it + /// (version, locktime, anti-fee-sniping, input/output ordering) using [`TxTemplate`]'s own + /// methods, then emit the final [`Psbt`] with [`Wallet::finish`]. + /// + /// # Privacy /// - /// 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`]. + /// **The returned [`TxTemplate`] is _unshuffled_ and carries no anti-fee-sniping by + /// default.** Outputs are emitted in `[recipients…, change]` order, so the change output sits + /// in a deterministic, trivially-identifiable position. If you care about change-output + /// privacy you **must** randomize it yourself before [`finish`](Self::finish): call + /// `template.shuffle_outputs(rng)` (and optionally `template.shuffle_inputs(rng)`). Apply + /// anti-fee-sniping with `template.apply_anti_fee_sniping(tip_height, rng)`. /// /// # Example /// /// ```rust,no_run /// # use std::str::FromStr; /// # use bitcoin::{Amount, Address, FeeRate, OutPoint}; - /// # use bdk_wallet::psbt::{PsbtParams, SelectionStrategy}; + /// # use bdk_wallet::psbt::{CandidateParams, SelectParams, FinishParams, SelectionStrategy}; /// # let mut wallet = bdk_wallet::doctest_wallet!(); /// # let outpoint = OutPoint::null(); /// # let address = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5").unwrap().assume_checked(); /// # let amount = Amount::ZERO; - /// 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)?; + /// // Stage 1: resolve the spendable candidates. + /// let mut opts = CandidateParams::new(); + /// opts.must_spend = [outpoint].into(); + /// let coins = wallet.candidates_with(&opts)?; + /// + /// // Stage 2: select coins to pay the recipients, yielding a `TxTemplate`. + /// let mut params = SelectParams::new(); + /// params.recipients = vec![(address.script_pubkey(), amount)]; + /// params.coin_selection = SelectionStrategy::SingleRandomDraw; + /// params.fee_rate = FeeRate::BROADCAST_MIN; + /// let template = wallet.select(coins, params)?; + /// + /// // Stage 3: emit the PSBT (optionally shaping the template first). + /// let (psbt, finalizer) = wallet.finish(template, FinishParams::default())?; /// # Ok::<_, anyhow::Error>(()) /// ``` /// @@ -3007,546 +3027,459 @@ impl Wallet { /// /// 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. + /// - No recipients were provided ([`NoRecipients`]) with a non-drain coin selection — to send + /// everything to one destination, use [`SelectionStrategy::DrainAll`] with no recipients + /// - The input value is insufficient to fund the outputs ([`InsufficientFunds`]) + /// - Coin selection failed to find a solution ([`Bnb`] for [`LowestFee`], or [`Selector`]) — + /// this also covers draining a wallet with no spendable value, which cannot even cover fees + /// - After coin selection, all outputs fell below the dust threshold + /// ([`AllOutputsBelowDust`]) + /// + /// Errors relating to resolving the inputs (unknown/un-plannable UTXOs, sequence conflicts, or + /// Replace-By-Fee validation) surface earlier, from [`Wallet::candidates_with`]. + /// + /// [`NoRecipients`]: CreatePsbtError::NoRecipients + /// [`InsufficientFunds`]: CreatePsbtError::InsufficientFunds + /// [`Bnb`]: CreatePsbtError::Bnb + /// [`LowestFee`]: SelectionStrategy::LowestFee + /// [`Selector`]: CreatePsbtError::Selector + /// [`AllOutputsBelowDust`]: CreatePsbtError::AllOutputsBelowDust /// /// # Change address /// - /// When no [`ChangeScript`] is supplied via [`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 + /// When no [`ChangeScript`] is supplied via [`SelectParams`], the wallet automatically + /// selects the next unused internal address and reveals it so that incoming change is tracked + /// on the next sync. The change address will not be marked used, so calling this function /// again before syncing will use the same change address. If you intend to build /// multiple transactions without syncing between them, either provide the change script in - /// the [`PsbtParams`], or do [`Wallet::mark_used`] after each call to prevent reuse. + /// the [`SelectParams`], or do [`Wallet::mark_used`] after each call to prevent reuse. /// /// **You must persist the change set staged as a result of this call.** /// See [`Wallet::take_staged`]. #[cfg(feature = "std")] #[cfg_attr(docsrs, doc(cfg(feature = "std")))] - pub fn create_psbt( + pub fn select( &mut self, - params: PsbtParams, - ) -> Result<(Psbt, Finalizer), CreatePsbtError> { - self.create_psbt_with_rng(params, &mut rand::thread_rng()) + coins: CandidateSet, + params: SelectParams, + ) -> Result { + self.select_with_rng(coins, params, &mut rand::thread_rng()) } - /// Creates a PSBT with the given `params` and random number generator (RNG). - /// - /// Return the updated [`Psbt`] and [`Finalizer`]. + /// **Stage 2.** [`select`](Self::select) with an explicit RNG. /// - /// ## 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( + /// The RNG is consumed only by [`SelectionStrategy::SingleRandomDraw`], which uses it to draw + /// candidates in a uniformly-random order; other strategies are deterministic and ignore it. + pub fn select_with_rng( &mut self, - mut params: PsbtParams, + coins: CandidateSet, + mut params: SelectParams, 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()) - { + ) -> Result { + // `DrainAll` selects every candidate and sends the remainder to change, so it may have no + // recipients (a sweep). Any other strategy requires at least one recipient — this guards + // against accidentally draining the whole wallet by passing empty recipients. + let drain = matches!(params.coin_selection, SelectionStrategy::DrainAll); + if params.recipients.is_empty() && !drain { return Err(CreatePsbtError::NoRecipients); } let (change_info, change_script) = self.peek_change_info(params.change_script.take()); + let target_outputs = self.target_outputs(¶ms.recipients); - let (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 CandidateSet { + candidates: input_candidates, + rbf, + .. + } = coins; + // With no candidates there is nothing to select. For a targeted selection the shortfall is + // the whole target; a drain has no meaningful "missing" amount, so let it fall through to + // `CannotMeetTarget` rather than report a misleading `missing: 0`. + if input_candidates.inputs().next().is_none() && !drain { let target_amount: Amount = target_outputs.iter().map(|output| output.value).sum(); - let err = bdk_coin_select::InsufficientFunds { - missing: target_amount.to_sat(), - }; - return Err(CreatePsbtError::InsufficientFunds(err)); + return Err(CreatePsbtError::InsufficientFunds( + bdk_coin_select::InsufficientFunds { + missing: target_amount.to_sat(), + }, + )); } let mut selector = Selector::new( &input_candidates, - SelectorParams::new(params.fee_rate, target_outputs, change_script), + SelectorParams { + replace: rbf, + ..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()); - } + // Run coin selection per the chosen strategy. `SingleRandomDraw` shuffles the candidates + // with `rng` and selects until the target is met. `DrainAll` ignores the target and + // selects everything (sweep / drain-while-paying). + match params.coin_selection { + SelectionStrategy::DrainAll => { + selector.select_all(); + } + SelectionStrategy::SingleRandomDraw => { + selector + .select_with_algorithm(selection_algorithm_single_random_draw(rng)) + .map_err(CreatePsbtError::InsufficientFunds)?; + } + SelectionStrategy::LowestFee { + longterm_feerate, + max_rounds, + } => { + selector + .select_with_algorithm(selection_algorithm_lowest_fee_bnb( + longterm_feerate, + max_rounds, + )) + .map_err(CreatePsbtError::Bnb)?; } } - 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 template = selector.try_finalize().ok_or({ let e = bdk_tx::CannotMeetTarget; CreatePsbtError::Selector(bdk_tx::SelectorError::CannotMeetTarget(e)) })?; - // Change fell below the dust threshold and was dropped to fees, leaving - // the transaction with no outputs. - if selection.outputs().is_empty() { + // Change fell below the dust threshold and was dropped to fees, leaving the transaction + // with no outputs. + if template.outputs().is_empty() { return Err(CreatePsbtError::AllOutputsBelowDust); } - 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()) + // Reveal the auto-selected change address if it ended up in the template's outputs. + if let Some((keychain, index, spk)) = change_info { + if template + .outputs() + .iter() + .any(|output| output.script_pubkey() == spk) { - 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); + if let Some((_, index_changeset)) = + self.tx_graph.index.reveal_to_target(keychain, index) + { + self.stage.merge(index_changeset.into()); + } } } - let finalizer = selection.into_finalizer(); - - Ok((psbt, finalizer)) + Ok(template) } - /// 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. + /// **Stage 1.** Resolve the wallet's spendable [`CandidateSet`] with default options. /// - /// # 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()) + /// Equivalent to [`candidates_with`](Self::candidates_with) called with + /// [`CandidateParams::default`]. + pub fn candidates(&self) -> Result { + self.build_candidates(&CandidateParams::default()) } - /// Creates a Replace-By-Fee transaction (RBF) and returns the updated [`Psbt`] and - /// [`Finalizer`]. + /// **Stage 1.** Resolve a Replace-By-Fee [`CandidateSet`] replacing the given `txids`. /// - /// 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()) + /// Shortcut for [`candidates_with`](Self::candidates_with) with [`CandidateParams`] whose + /// [`replace`](CandidateParams::replace) list is `txids`. The replaced transactions must be + /// present in the wallet's transaction graph. + pub fn rbf_candidates(&self, txids: &[Txid]) -> Result { + let opts = CandidateParams { + replace: txids.to_vec(), + ..Default::default() + }; + self.build_candidates(&opts) } - /// Creates a Replace-By-Fee transaction (RBF) and returns the updated [`Psbt`] and - /// [`Finalizer`]. + /// **Stage 1.** Resolve the wallet's spendable [`CandidateSet`] for `opts`. /// - /// ## Parameters: + /// Plans manually-selected UTXOs, gathers and filters the wallet's + /// spendable coins, and — when [`opts.replace`] is non-empty — sets up the Replace-By-Fee + /// context. The returned [`CandidateSet`] is an owned snapshot suitable for + /// [`select`](Self::select). /// - /// - `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. + /// # Errors /// - /// **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> { - 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); - - 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)); + /// A [`CandidatesError`] will be thrown if any of the following occurs: + /// + /// - A manually selected input is missing from the wallet ([`UnknownUtxo`]) or could not be + /// planned ([`Plan`]) + /// - For a Replace-By-Fee set: a replaced tx is already confirmed + /// ([`TransactionConfirmed`]), missing ([`MissingTransaction`]), one whose inputs the wallet + /// doesn't control ([`CannotReplace`]), its fee can't be computed ([`PreviousFee`]), or a + /// manually-selected input conflicts with the replaced set ([`ConflictingInput`]) + /// + /// [`opts.replace`]: CandidateParams::replace + /// [`UnknownUtxo`]: CandidatesError::UnknownUtxo + /// [`Plan`]: CandidatesError::Plan + /// [`TransactionConfirmed`]: CandidatesError::TransactionConfirmed + /// [`MissingTransaction`]: CandidatesError::MissingTransaction + /// [`CannotReplace`]: CandidatesError::CannotReplace + /// [`PreviousFee`]: CandidatesError::PreviousFee + /// [`ConflictingInput`]: CandidatesError::ConflictingInput + pub fn candidates_with(&self, opts: &CandidateParams) -> Result { + self.build_candidates(opts) + } + + /// Resolve a [`CandidateSet`] from `opts`, handling both the normal and Replace-By-Fee paths. + /// + /// Candidate construction is deterministic; any randomization (e.g. single random draw) happens + /// later, at [`select`](Self::select). + fn build_candidates(&self, opts: &CandidateParams) -> Result { + let (assets, txouts) = self.parse_params(opts); + + let is_rbf = !opts.replace.is_empty(); + + // Replace-By-Fee setup. We derive the set of txids being replaced (sanitizing ancestry), + // the must-spend inputs from those replaced txs, the descendant set to exclude from coin + // selection, and the fee floor. + let mut rbf_must_spend: Vec = vec![]; + let mut to_replace: HashSet = HashSet::new(); + let mut rbf_params: Option = None; + if is_rbf { + // Build the set of replaced txids, dropping any tx that is a coinbase or whose + // ancestors are also being replaced (replacing an ancestor invalidates the + // descendant). + let candidate_replace: HashSet = opts.replace.iter().copied().collect(); + let mut direct_conflicts: HashSet = HashSet::new(); + let mut replace_outpoints: Vec = vec![]; + for &txid in opts.replace.iter() { + let tx = self + .tx_graph + .graph() + .get_tx(txid) + .ok_or(CandidatesError::MissingTransaction(txid))?; + let has_replaced_ancestor = self + .tx_graph + .graph() + .walk_ancestors(Arc::clone(&tx), |_, ancestor| Some(ancestor.compute_txid())) + .any(|ancestor_txid| candidate_replace.contains(&ancestor_txid)); + if tx.is_coinbase() || has_replaced_ancestor { + continue; + } + if direct_conflicts.insert(txid) { + // The wallet must control at least one of the tx's inputs, otherwise it can't + // build a replacement that double-spends (and therefore evicts) it. + if !tx + .input + .iter() + .any(|txin| txouts.contains_key(&txin.previous_output)) + { + return Err(CandidatesError::CannotReplace(txid)); + } + replace_outpoints.extend(tx.input.iter().map(|txin| txin.previous_output)); + } } - } - // 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)) + // None of the (sanitized) txids-to-replace may already be confirmed. + let chain_tip = self.chain.tip().block_id(); + let chain_positions: HashMap> = self + .tx_graph + .graph() + .list_canonical_txs(&self.chain, chain_tip, opts.canonical_params.clone()) + .map(|canonical_tx| (canonical_tx.tx_node.txid, canonical_tx.chain_position)) + .collect(); + for &txid in direct_conflicts.iter() { + if chain_positions + .get(&txid) + .is_some_and(|chain_position| chain_position.is_confirmed()) { - return Err(ReplaceByFeeError::NoInputsFromOriginal(txid)); + return Err(CandidatesError::TransactionConfirmed(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(); + // The must-spend inputs are the wallet-owned inputs of the (sanitized) replaced txs. + // They appear "spent" so the normal spendable scan excludes them; plan them directly + // from their txouts (like `build_must_spend_inputs` does for the manual must-spend + // coins). Outpoints the caller supplies via `must_spend` are skipped to avoid duplicates + // (added by `build_must_spend_inputs` below). A replaced input the wallet doesn't own + // (a foreign input) can't be planned here and is skipped too — the caller re-adds it + // with [`CandidateSet::push_must_select`]. + for outpoint in &replace_outpoints { + if opts.must_spend.contains(outpoint) { + continue; + } + let Some(txo) = txouts.get(outpoint) else { + continue; + }; + let input = self + .plan_input(txo, &assets) + .ok_or(CandidatesError::Plan(*outpoint))?; + rbf_must_spend.push(input); + } - 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(); + // Replaced txs and their descendants are excluded from coin selection; the direct + // conflicts feed the RBF fee floor, the descendants accumulate into `descendant_fee`. + let descendants: HashSet = direct_conflicts + .iter() + .flat_map(|&txid| { + self.tx_graph + .graph() + .walk_descendants(txid, |_, txid| Some(txid)) + }) + .filter(|txid| !direct_conflicts.contains(txid)) + .collect(); + to_replace = direct_conflicts + .iter() + .chain(descendants.iter()) + .copied() + .collect(); - let to_replace: HashSet = direct_conflicts - .iter() - .chain(descendants.iter()) - .copied() - .collect(); + let original_txs: Vec = direct_conflicts + .iter() + .map(|&txid| -> Result<_, CandidatesError> { + let tx = self + .tx_graph + .graph() + .get_tx(txid) + .ok_or(CandidatesError::MissingTransaction(txid))?; + let fee = self + .calculate_fee(&tx) + .map_err(CandidatesError::PreviousFee)?; + Ok(OriginalTxStats { + weight: tx.weight(), + fee, + }) + }) + .collect::>()?; + + // Sum fees from all descendants (assumes each is currently in the mempool, so it must + // be evicted too). These came from a graph walk, so a missing tx or uncomputable fee + // is unexpected and surfaced as an error rather than silently under-counting the fee + // floor (which would produce a replacement too cheap to evict the package). + let mut descendant_fee = Amount::ZERO; + for &txid in descendants.iter() { + let tx = self + .tx_graph + .graph() + .get_tx(txid) + .ok_or(CandidatesError::MissingTransaction(txid))?; + descendant_fee += self + .calculate_fee(&tx) + .map_err(CandidatesError::PreviousFee)?; + } - let must_spend = self.build_must_spend_inputs(¶ms, &txouts, &assets)?; + rbf_params = Some(RbfParams { + original_txs, + descendant_fee, + incremental_relay_feerate: FeeRate::BROADCAST_MIN, + }); + } - // 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)); + // Combine the RBF-derived must-spend inputs with the user-supplied must-spend inputs. + let mut must_spend = rbf_must_spend; + must_spend.extend(self.build_must_spend_inputs(&opts.must_spend, &txouts, &assets)?); + + // A user-supplied must-spend input must not conflict with the replaced set. (Foreign inputs + // pushed later onto the `CandidateSet` are checked at push time.) + if is_rbf { + for input in &must_spend { + let op = input.prev_outpoint(); + if to_replace.contains(&op.txid) { + return Err(CandidatesError::ConflictingInput(op)); + } } } - // Get input candidates - let mut may_spend: Vec = if params.manually_selected_only { + let may_spend: Vec = if opts.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 + self.filter_spendable(txouts.into_values(), opts, |txo| { + // In the RBF case the UTXO must not be in `to_replace` and must be confirmed. + // TODO: BIP125 Rule 2 was removed in Core v31; make this an explicit RBF policy + // knob. !to_replace.contains(&txo.outpoint.txid) - && txo.chain_position.is_confirmed() - && (params.utxo_filter.0)(txo) + && (!is_rbf || txo.chain_position.is_confirmed()) }) .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, + // Wallet-owned UTXOs the replacement strips from the canonical view: outputs of the + // replaced/evicted txs that are unspent in the wallet's current view. Exposed so a caller + // batching txs can re-create their still-live payments. + let replaced_unspent: Vec = if is_rbf { + self.list_unspent() + .filter(|utxo| to_replace.contains(&utxo.outpoint.txid)) + .collect() + } else { + Vec::new() }; - 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)?; + Ok(CandidateSet { + candidates: InputCandidates::new(must_spend, may_spend), + rbf: rbf_params, + replaced: to_replace, + replaced_unspent, + }) + } - let (psbt, finalizer) = self - .create_psbt_from_selector(&mut selector, ¶ms, rng) - .map_err(ReplaceByFeeError::CreatePsbt)?; + /// **Stage 3.** Emit the final [`Psbt`] and its [`Finalizer`] from a shaped [`TxTemplate`]. + /// + /// The `template` is produced by [`select`](Self::select) and may + /// have been shaped (version, locktime, anti-fee-sniping, input/output ordering) using + /// [`TxTemplate`]'s own methods before being passed here. + /// + /// [`FinishParams`] carries the PSBT-emission options: whether to only set the `witness_utxo` + /// for segwit-v0 inputs, and whether to fill in the global xpubs from the wallet's descriptors. + /// + /// # Errors + /// + /// - [`CreatePsbtError::Build`] if the PSBT can't be built from the template. + /// - [`CreatePsbtError::MissingKeyOrigin`] if [`add_global_xpubs`] is set but an extended key + /// in a descriptor is neither a master key (depth 0) nor has an explicit origin. + /// + /// [`add_global_xpubs`]: FinishParams::add_global_xpubs + pub fn finish( + &self, + template: TxTemplate, + params: FinishParams, + ) -> Result<(Psbt, Finalizer), CreatePsbtError> { + let (mut psbt, finalizer) = template + .create_psbt(PsbtBuildParams { + mandate_full_tx_for_segwit_v0: !params.only_witness_utxo, + }) + .map_err(CreatePsbtError::Build)?; - // 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) + // Add global xpubs. + if params.add_global_xpubs { + for xpub in self + .keychains() + .flat_map(|(_, desc)| desc.get_extended_keys()) { - if let Some((_, index_changeset)) = - self.tx_graph.index.reveal_to_target(keychain, index) - { - self.stage.merge(index_changeset.into()); - } + let origin = match xpub.origin { + Some(origin) => origin, + None if xpub.xkey.depth == 0 => { + (xpub.root_fingerprint(&self.secp), vec![].into()) + } + _ => return Err(CreatePsbtError::MissingKeyOrigin(xpub.xkey)), + }; + + psbt.xpub.insert(xpub.xkey, origin); } } Ok((psbt, finalizer)) } - /// Builds the required inputs from user-added UTXOS and pre-built [`Input`]s in `params`. + /// Builds the required inputs from the caller's de-duplicated must-spend outpoints. /// - /// 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( + /// Plans each `must_spend` outpoint as an [`Input`]. Sequence values are left at their + /// plan-derived defaults — callers tune them on the returned [`TxTemplate`] + /// (`set_fallback_sequence` / `input_mut`). + fn build_must_spend_inputs( &self, - params: &PsbtParams, + must_spend: &BTreeSet, txouts: &HashMap>, assets: &Assets, - ) -> Result, CreatePsbtError> { - params - .utxos + ) -> Result, CandidatesError> { + must_spend .iter() .map(|&outpoint| { let txo = txouts .get(&outpoint) - .ok_or(CreatePsbtError::UnknownUtxo(outpoint))?; - let mut input = self + .ok_or(CandidatesError::UnknownUtxo(outpoint))?; + let 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_or(CandidatesError::Plan(outpoint))?; Ok(input) }) - .chain(params.inputs.iter().cloned().map(Result::Ok)) .collect() } @@ -3579,10 +3512,10 @@ impl Wallet { relative::LockTime::from_height(age) }); + // Keep the caller's keys/preimages, but pin the timelocks to the values derived above. let mut assets = Assets::new(); - assets.extend(spend_assets); - assets = assets.after(abs_locktime); - assets = assets.older(rel_locktime); + merge_assets_secrets(&mut assets, spend_assets); + let assets = assets.after(abs_locktime).older(rel_locktime); let plan = self.try_plan(op, &assets)?; let tx = self.tx_graph.graph().get_tx(txid)?; diff --git a/tests/add_foreign_utxo.rs b/tests/add_foreign_utxo.rs index 981c9924..cf734396 100644 --- a/tests/add_foreign_utxo.rs +++ b/tests/add_foreign_utxo.rs @@ -329,7 +329,7 @@ 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. + // We'll add a foreign anchor output as a planned input. let op2 = OutPoint::new(Hash::hash(b"txid"), 2); let txout = TxOut { value: Amount::ZERO, @@ -352,12 +352,15 @@ fn test_add_planned_psbt_input() -> anyhow::Result<()> { 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 mut opts = bdk_wallet::CandidateParams::new(); + opts.must_spend = [op1].into(); + let coins = wallet.candidates_with(&opts)?.push_must_select(input)?; - let (psbt, _) = wallet.create_psbt(params)?; + let mut params = bdk_wallet::SelectParams::new(); + params.recipients = vec![(send_to.script_pubkey(), Amount::from_sat(20_000))]; + + let template = wallet.select(coins, params)?; + let (psbt, _) = wallet.finish(template, bdk_wallet::psbt::FinishParams::default())?; assert!( psbt.unsigned_tx diff --git a/tests/psbt.rs b/tests/psbt.rs index c7fae070..bcd8e6e9 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -3,7 +3,12 @@ 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::{CandidatesError, CreatePsbtError}, + psbt, + psbt::FinishParams, + CandidateParams, KeychainKind, SelectParams, SignOptions, Wallet, +}; use bitcoin::{ absolute, hashes::Hash, Address, Amount, FeeRate, Network, OutPoint, Psbt, ScriptBuf, Sequence, Transaction, TxIn, TxOut, @@ -47,21 +52,28 @@ fn test_create_psbt() { .unwrap(); let addr = wallet.reveal_next_address(KeychainKind::External); - let mut params = PsbtParams::default(); + let mut params = SelectParams::new(); let feerate = FeeRate::from_sat_per_vb(4).unwrap(); let selection_strategy = psbt::SelectionStrategy::LowestFee { longterm_feerate: FeeRate::from_sat_per_vb(2).unwrap(), max_rounds: 1000, }; - params - .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(); + params.coin_selection = selection_strategy; + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + params.change_script = Some(ChangeScript::from_descriptor(change_descriptor)); + params.fee_rate = feerate; + + let coins = wallet.candidates().unwrap(); + let template = wallet + .select(coins, params) + .unwrap() + .set_version(bitcoin::transaction::Version(3)) + .unwrap(); + let finish_params = FinishParams { + add_global_xpubs: true, + ..Default::default() + }; + let (psbt, _) = wallet.finish(template, finish_params).unwrap(); let tx = &psbt.unsigned_tx; assert_eq!(tx.version.0, 3); assert_eq!(tx.lock_time.to_consensus_u32(), 0); @@ -108,10 +120,11 @@ fn test_create_psbt_insufficient_funds_error() { 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 mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_sat(10_000))]; - let result = wallet.create_psbt(params); + let coins = wallet.candidates().unwrap(); + let result = wallet.select(coins, params); assert!(matches!( result, Err(CreatePsbtError::InsufficientFunds( @@ -149,21 +162,25 @@ fn test_create_psbt_maturity_height() { 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 mut cp = CandidateParams::new(); + cp.maturity_height = Some(bitcoin::absolute::Height::from_consensus(99).unwrap()); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut p = SelectParams::new(); + p.recipients = vec![(send_to_address.script_pubkey(), Amount::from_sat(58_000))]; let _ = wallet - .create_psbt(p) + .select(coins, 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 mut cp = CandidateParams::new(); + cp.maturity_height = Some(bitcoin::absolute::Height::from_consensus(100).unwrap()); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut p = SelectParams::new(); + p.recipients = vec![(send_to_address.script_pubkey(), Amount::from_sat(58_000))]; let _ = wallet - .create_psbt(p) + .select(coins, p) .expect("`maturity_height` should enable selection"); // The output is eligible for selection once the wallet tip reaches maturity height minus 1 @@ -173,11 +190,12 @@ fn test_create_psbt_maturity_height() { 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 coins = wallet.candidates().unwrap(); + let mut p = SelectParams::new(); + p.recipients = vec![(send_to_address.script_pubkey(), Amount::from_sat(58_000))]; let _ = wallet - .create_psbt(p) + .select(coins, p) .expect("mature coinbase should be selected"); } @@ -206,25 +224,25 @@ fn test_create_psbt_cltv() { // 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); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + let res = wallet.candidates_with(&cp); assert!( - matches!(res, Err(CreatePsbtError::Plan(err)) if err == op), + matches!(res, Err(CandidatesError::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(); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + cp.assets = Assets::new().after(LockTime::from_consensus(100_000)); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + let template = wallet.select(coins, params).unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), 100_000); } @@ -236,23 +254,30 @@ fn test_create_psbt_cltv() { }; 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(); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + let template = wallet.select(coins, params).unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), 100_000); } // Locktime greater than required { - let mut 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(); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + + let template = wallet + .select(coins, params) + .unwrap() + .set_locktime(LockTime::from_consensus(200_000)) + .unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), 200_000); } } @@ -275,25 +300,25 @@ fn test_create_psbt_cltv_timestamp() { // 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); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + let res = wallet.candidates_with(&cp); assert!( - matches!(res, Err(CreatePsbtError::Plan(err)) if err == op), + matches!(res, Err(CandidatesError::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(); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + cp.assets = Assets::new().after(lock_time); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + let template = wallet.select(coins, params).unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); assert_eq!(psbt.unsigned_tx.lock_time, lock_time); } @@ -301,14 +326,19 @@ fn test_create_psbt_cltv_timestamp() { { 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(); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + cp.assets = Assets::new().after(lock_time); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + + let template = wallet + .select(coins, params) + .unwrap() + .set_locktime(LockTime::from_consensus(new_lock_time)) + .unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), new_lock_time); } } @@ -339,26 +369,26 @@ fn test_create_psbt_csv() { // 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); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + let res = wallet.candidates_with(&cp); assert!( - matches!(res, Err(CreatePsbtError::Plan(err)) if err == op), + matches!(res, Err(CandidatesError::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(); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + cp.assets = Assets::new().older(rel_locktime); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + let template = wallet.select(coins, params).unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); } @@ -372,11 +402,13 @@ fn test_create_psbt_csv() { 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(); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]; + let template = wallet.select(coins, params).unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); } } @@ -387,11 +419,14 @@ fn test_create_psbt_csv() { 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; + let coins = wallet.candidates().unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_sat(25_000))]; + let template = wallet + .select(coins, params) + .unwrap() + .set_fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); + let psbt = wallet.finish(template, FinishParams::default()).unwrap().0; assert_eq!( psbt.unsigned_tx.input[0].sequence, Sequence::ENABLE_RBF_NO_LOCKTIME @@ -423,13 +458,17 @@ fn test_create_psbt_fallback_sequence_skipped_for_csv_input() { 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; + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + cp.assets = Assets::new().older(rel_locktime); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_sat(25_000))]; + let template = wallet + .select(coins, params) + .unwrap() + .set_fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); + let psbt = wallet.finish(template, FinishParams::default()).unwrap().0; // CSV descriptor requires older(6); fallback must not clobber the CSV-derived sequence. assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); } @@ -440,13 +479,19 @@ 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; + let mut cp = CandidateParams::new(); + cp.must_spend = [utxo].into(); + cp.manually_selected_only = true; + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_sat(25_000))]; + let mut template = wallet.select(coins, params).unwrap(); + template + .input_mut(utxo) + .unwrap() + .set_sequence(Sequence(42)) + .unwrap(); + let psbt = wallet.finish(template, FinishParams::default()).unwrap().0; assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(42)); } @@ -456,18 +501,26 @@ 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; + let mut cp = CandidateParams::new(); + cp.must_spend = [utxo].into(); + cp.manually_selected_only = true; + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_sat(25_000))]; + let mut template = wallet + .select(coins, params) + .unwrap() + .set_fallback_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME); + template + .input_mut(utxo) + .unwrap() + .set_sequence(Sequence(42)) + .unwrap(); + let psbt = wallet.finish(template, FinishParams::default()).unwrap().0; assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(42)); } -/// A sequence override that violates the CSV requirement returns a Sequence error. +/// Setting a template input sequence that violates the CSV requirement is rejected. #[test] fn test_create_psbt_sequence_override_csv_conflict_returns_error() { use bitcoin::relative; @@ -491,15 +544,20 @@ fn test_create_psbt_sequence_override_csv_conflict_returns_error() { 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(_)))); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + cp.assets = Assets::new().older(rel_locktime); + cp.manually_selected_only = true; + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(addr.script_pubkey(), Amount::from_sat(25_000))]; + let mut template = wallet.select(coins, params).unwrap(); + // CSV requires older(6); setting a lower sequence on the template input is rejected. + let res = template.input_mut(op).unwrap().set_sequence(Sequence(3)); + assert!(matches!( + res, + Err(bdk_tx::SetSequenceError::RelativeTimelockNotSatisfied { .. }) + )); } // Test that replacing two unconfirmed txs A, B results in a transaction @@ -562,30 +620,42 @@ fn test_replace_by_fee_and_recipients() { 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; + let mut cp = CandidateParams::new(); + cp.must_spend = [op0].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip.clone(), Amount::from_sat(16_000))]; + let template = wallet.select(coins, params).unwrap(); + let txa = wallet + .finish(template, FinishParams::default()) + .unwrap() + .0 + .unsigned_tx; insert_tx(&mut wallet, txa.clone()); // Create tx B (unconfirmed) - let mut 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; + let mut cp = CandidateParams::new(); + cp.must_spend = [op1].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip.clone(), Amount::from_sat(42_000))]; + let template = wallet.select(coins, params).unwrap(); + let txb = wallet + .finish(template, FinishParams::default()) + .unwrap() + .0 + .unsigned_tx; insert_tx(&mut wallet, txb.clone()); // Now create RBF tx - let 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; + let coins = wallet + .rbf_candidates(&[txa.compute_txid(), txb.compute_txid()]) + .unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip, Amount::from_btc(1.99).unwrap())]; + params.fee_rate = FeeRate::from_sat_per_vb(4).unwrap(); + let template = wallet.select(coins, params).unwrap(); + let psbt = wallet.finish(template, FinishParams::default()).unwrap().0; // Expect replace inputs of A, B assert_eq!( @@ -717,12 +787,15 @@ 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 coins = wallet.rbf_candidates(&[a_txid]).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(external, Amount::from_sat(100_000))]; + params.fee_rate = FeeRate::from_sat_per_vb(4).unwrap(); + let template = wallet + .select(coins, params) + .expect("should select for replacement psbt"); let (psbt, _) = wallet - .replace_by_fee_and_recipients( - [tx_a], - FeeRate::from_sat_per_vb(4).unwrap(), - vec![(external, Amount::from_sat(100_000))], - ) + .finish(template, FinishParams::default()) .expect("should create replacement psbt"); let replacement_fee = wallet @@ -735,10 +808,9 @@ fn test_replace_by_fee_replaces_descendant_fees() { ); } -// Test that `replace_by_fee`` rejects a confirmed original tx +// Test that RBF 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(); @@ -769,11 +841,17 @@ fn test_replace_by_fee_confirmed_tx_error() { 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 mut cp = CandidateParams::new(); + cp.must_spend = [funding_op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip.clone(), Amount::from_sat(100_000))]; + let template = wallet.select(coins, params).unwrap(); + let unconfirmed_tx = wallet + .finish(template, FinishParams::default()) + .unwrap() + .0 + .unsigned_tx; insert_tx(&mut wallet, unconfirmed_tx.clone()); // Now confirm that tx. @@ -785,23 +863,19 @@ 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 result = wallet.rbf_candidates(&[confirmed_txid]); assert!( - matches!(result, Err(ReplaceByFeeError::TransactionConfirmed(txid)) if txid == confirmed_txid), + matches!(result, Err(CandidatesError::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 that a replacement derived from the wallet graph keeps the original transaction's inputs +// as the must-spend set. (In the reshaped API the replaced txs' inputs are re-derived from the +// wallet graph, so the replacement always retains at least one input from each replaced tx.) #[test] -fn test_replace_by_fee_no_inputs_from_original() { - use bdk_wallet::error::ReplaceByFeeError; +fn test_replace_by_fee_keeps_original_inputs() { use KeychainKind::*; let (desc, change_desc) = get_test_wpkh_and_change_desc(); @@ -831,54 +905,36 @@ fn test_replace_by_fee_no_inputs_from_original() { 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 mut cp = CandidateParams::new(); + cp.must_spend = [funding_op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip, Amount::from_sat(100_000))]; + let template = wallet.select(coins, params).unwrap(); + let unconfirmed_tx = wallet + .finish(template, FinishParams::default()) + .unwrap() + .0 + .unsigned_tx; let unconfirmed_txid = unconfirmed_tx.compute_txid(); insert_tx(&mut wallet, unconfirmed_tx.clone()); - // 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); + // The replacement set re-derives the original tx's inputs as must-spend candidates. + let coins = wallet.rbf_candidates(&[unconfirmed_txid]).unwrap(); + assert!(coins.is_rbf()); assert!( - matches!(result, Err(ReplaceByFeeError::NoInputsFromOriginal(txid)) if txid == unconfirmed_txid), - "expected NoInputsFromOriginal error, got: {result:?}", + coins + .inputs() + .any(|input| input.prev_outpoint() == funding_op), + "the replacement must keep the original transaction's input", ); } -// 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 +// Test that RBF rejects a manually-selected input that spends // from a descendant of the one being replaced. #[test] fn test_replace_by_fee_conflicting_input_descendant() { use bdk_tx::Input as BdkInput; - use bdk_wallet::error::ReplaceByFeeError; use bitcoin::{psbt as btc_psbt, Sequence}; let (desc, change_desc) = get_test_wpkh_and_change_desc(); @@ -909,11 +965,17 @@ fn test_replace_by_fee_conflicting_input_descendant() { .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 mut cp = CandidateParams::new(); + cp.must_spend = [funding_op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip.clone(), Amount::from_sat(100_000))]; + let template = wallet.select(coins, params).unwrap(); + let tx_parent = wallet + .finish(template, FinishParams::default()) + .unwrap() + .0 + .unsigned_tx; let txid_parent = tx_parent.compute_txid(); insert_tx(&mut wallet, tx_parent.clone()); @@ -953,19 +1015,52 @@ fn test_replace_by_fee_conflicting_input_descendant() { ) .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); + // Build replacement for tx_parent, then try to add the grandchild foreign input — it spends + // an output of a replaced tx, so the push is rejected. + let mut cp = CandidateParams::new(); + cp.replace = vec![txid_parent]; + let coins = wallet.candidates_with(&cp).unwrap(); + let result = coins.push_must_select(grandchild_input); assert!( - matches!(result, Err(ReplaceByFeeError::ConflictingInput(op)) if op == grandchild_op), + matches!(&result, Err(CandidatesError::ConflictingInput(op)) if *op == grandchild_op), "expected ConflictingInput({grandchild_op}), got: {result:?}", ); } +// Replacing a tx whose inputs the wallet doesn't control is rejected — the wallet couldn't build a +// replacement that conflicts with (and evicts) it. +#[test] +fn test_replace_uncontrolled_tx_errors() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // A foreign, unconfirmed tx in the graph that spends no wallet-owned output. + let foreign_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(Hash::hash(b"not_ours"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new_p2a(), + }], + ..new_tx(0) + }; + let foreign_txid = foreign_tx.compute_txid(); + insert_tx(&mut wallet, foreign_tx); + + let mut cp = CandidateParams::new(); + cp.replace = vec![foreign_txid]; + let result = wallet.candidates_with(&cp); + assert!( + matches!(result, Err(CandidatesError::CannotReplace(txid)) if txid == foreign_txid), + "expected CannotReplace({foreign_txid}), got: {result:?}", + ); +} + #[test] fn test_create_psbt_utxo_filter() { let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); @@ -993,22 +1088,24 @@ fn test_create_psbt_utxo_filter() { 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(); + // Avoid selection of dust utxos + let coins = wallet.candidates().unwrap().filter(|input| { + let txout = input.prev_txout(); + let min_non_dust = txout.script_pubkey.minimal_non_dust(); // 330 + txout.value >= min_non_dust + }); + let mut params = SelectParams::new(); + params.coin_selection = psbt::SelectionStrategy::DrainAll; + params.change_script = Some(change_script); + params.fee_rate = FeeRate::ZERO; + let template = wallet.select(coins, params).unwrap(); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); assert_eq!(psbt.unsigned_tx.input.len(), 2); assert_eq!(psbt.unsigned_tx.output.len(), 1); assert_eq!( @@ -1038,26 +1135,137 @@ fn test_create_psbt_no_recipients_error() { 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(); + // No recipients on `select` → should error. + let coins = wallet.candidates().unwrap(); + let err = wallet.select(coins, SelectParams::new()).unwrap_err(); assert!( matches!(err, CreatePsbtError::NoRecipients), "expected NoRecipients, got {err:?}" ); - // drain_wallet with an explicit change_script and no recipients should succeed (sweep to - // change). - let mut params = PsbtParams::default(); + // Sending everything to a single destination is expressed via `DrainAll` with no recipients. + let change_descriptor = wallet + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(); + let coins = wallet.candidates().unwrap(); + let mut params = SelectParams::new(); + params.coin_selection = psbt::SelectionStrategy::DrainAll; + params.change_script = Some(ChangeScript::from_descriptor(change_descriptor)); + let _template = wallet + .select(coins, params) + .expect("drain to an explicit destination should succeed"); +} + +// A drain with no explicit change script makes the wallet auto-derive and *reveal* an internal +// change address (it becomes the sole sweep destination). A drain to an explicit change script +// must not reveal anything new on the internal keychain. +#[test] +fn test_drain_reveals_auto_change() { + // (1) Auto-change drain reveals an internal change address. + let (mut wallet, _) = get_funded_wallet_wpkh(); + let before = wallet.derivation_index(KeychainKind::Internal); + let coins = wallet.candidates().unwrap(); + let mut params = SelectParams::new(); + params.coin_selection = psbt::SelectionStrategy::DrainAll; + let _template = wallet + .select(coins, params) + .expect("auto-change drain should succeed"); + assert!( + wallet.derivation_index(KeychainKind::Internal) > before, + "auto-change drain must reveal an internal change address (before={before:?})" + ); + + // (2) Drain to an explicit change script reveals nothing new internally. + let (mut wallet, _) = get_funded_wallet_wpkh(); + let before = wallet.derivation_index(KeychainKind::Internal); let change_descriptor = wallet .public_descriptor(KeychainKind::Internal) .at_derivation_index(0) .unwrap(); - params - .drain_wallet() - .change_script(ChangeScript::from_descriptor(change_descriptor)); - wallet - .create_psbt(params) - .expect("drain_wallet with explicit change_script should succeed"); + let coins = wallet.candidates().unwrap(); + let mut params = SelectParams::new(); + params.coin_selection = psbt::SelectionStrategy::DrainAll; + params.change_script = Some(ChangeScript::from_descriptor(change_descriptor)); + let _template = wallet + .select(coins, params) + .expect("explicit-destination drain should succeed"); + assert_eq!( + wallet.derivation_index(KeychainKind::Internal), + before, + "drain to an explicit change script must not reveal a new internal address" + ); +} + +// Manually-selected coins are de-duplicated when the `CandidateSet` is resolved. +#[test] +fn test_candidates_dedup_manual_inputs() { + let (wallet, txid) = get_funded_wallet_wpkh(); + let op = OutPoint::new(txid, 0); + + // (1) An outpoint listed more than once in `must_spend` resolves to a single input. + let mut cp = CandidateParams::new(); + cp.must_spend = [op, op, op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + assert_eq!( + coins.inputs().filter(|i| i.prev_outpoint() == op).count(), + 1, + "duplicate must-spend outpoints must resolve to a single input" + ); + + // (2) Pushing a foreign input whose outpoint is already a must-spend candidate is de-duplicated + // (the existing candidate is kept; its contents are irrelevant here). + let psbt_input = bitcoin::psbt::Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new_p2a(), + }), + ..Default::default() + }; + let foreign = bdk_tx::Input::from_psbt_input( + op, + Sequence::ENABLE_LOCKTIME_NO_RBF, + psbt_input, + /* satisfaction_weight: */ 0, + /* status: */ None, + /* is_coinbase: */ false, + /* absolute_timelock: */ None, + ) + .unwrap(); + let mut cp = CandidateParams::new(); + cp.must_spend = [op].into(); + let coins = wallet + .candidates_with(&cp) + .unwrap() + .push_must_select(foreign) + .unwrap(); + assert_eq!( + coins.inputs().filter(|i| i.prev_outpoint() == op).count(), + 1, + "an outpoint that is both a must-spend and a pushed foreign input resolves once" + ); +} + +// Draining a wallet with no spendable value cannot even cover fees, so selection fails rather than +// producing an empty/invalid transaction. +#[test] +fn test_drain_empty_wallet_errors() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let coins = wallet.candidates().unwrap(); + assert!(coins.is_empty(), "fresh wallet has no candidates"); + + let mut params = SelectParams::new(); + params.coin_selection = psbt::SelectionStrategy::DrainAll; + let err = wallet.select(coins, params).unwrap_err(); + assert!( + matches!(err, CreatePsbtError::Selector(_)), + "empty-wallet drain should fail to meet target, got {err:?}" + ); } #[test] @@ -1276,11 +1484,10 @@ fn test_psbt_multiple_internalkey_signers() { 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. +// When a sweep's only output would fall below the dust threshold, verify that `sweep` +// surfaces this as an error rather than returning a zero-output PSBT. #[test] -fn test_create_psbt_drain_wallet_change_below_dust_error() { +fn test_sweep_change_below_dust_error() { let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); let mut wallet = Wallet::create(desc, change_desc) .network(Network::Regtest) @@ -1305,80 +1512,15 @@ fn test_create_psbt_drain_wallet_change_below_dust_error() { .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 coins = wallet.candidates().unwrap(); + let mut params = SelectParams::new(); + params.coin_selection = psbt::SelectionStrategy::DrainAll; + params.change_script = Some(ChangeScript::from_descriptor(change_descriptor)); - let err = wallet.create_psbt(params).unwrap_err(); + let err = wallet.select(coins, 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:?}" + "expected AllOutputsBelowDust when swept output is below dust threshold, got {err:?}" ); } @@ -1432,25 +1574,40 @@ fn test_replace_tx_with_planned_input() { ) .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; + let mut cp = CandidateParams::new(); + cp.must_spend = [funding_op].into(); + let coins = wallet + .candidates_with(&cp) + .unwrap() + .push_must_select(planned_input.clone()) + .unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip.clone(), Amount::from_sat(100_000))]; + let template = wallet.select(coins, params).unwrap(); + let unconfirmed_tx = wallet + .finish(template, FinishParams::default()) + .unwrap() + .0 + .unsigned_tx; + let unconfirmed_txid = unconfirmed_tx.compute_txid(); insert_tx(&mut wallet, unconfirmed_tx.clone()); - // Add the 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]); + // Add the foreign input alongside the replacement. The pushed foreign input must be respected + // (and de-duplicated) in the replacement's candidate set. + let mut cp = CandidateParams::new(); + cp.replace = vec![unconfirmed_txid]; + let coins = wallet + .candidates_with(&cp) + .unwrap() + .push_must_select(planned_input.clone()) + .unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip, Amount::from_sat(99_000))]; - let (psbt, _) = wallet - .replace_by_fee(params) + let template = wallet + .select(coins, params) .expect("replacement should succeed"); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); assert_eq!( psbt.unsigned_tx.input.len(), 2, @@ -1471,3 +1628,83 @@ fn test_replace_tx_with_planned_input() { "replacement must include the planned input" ); } + +// Test that a Replace-By-Fee candidate set can be fed to `sweep` (now newly possible): +// create + broadcast an unconfirmed tx, then sweep-replace it at a higher feerate and +// assert the replacement spends the same input. +#[test] +fn test_sweep_replace_by_fee() { + use KeychainKind::*; + + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let block = BlockId { + height: 100, + hash: Hash::hash(b"100"), + }; + let addr = wallet.reveal_next_address(External).address; + + // Fund the wallet with a confirmed output. + let funding_tx = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(1_000_000), + script_pubkey: addr.script_pubkey(), + }], + ..new_tx(0) + }; + let funding_op = OutPoint::new(funding_tx.compute_txid(), 0); + insert_tx_anchor(&mut wallet, funding_tx, block); + + // Create + "broadcast" (insert) an unconfirmed tx paying an external recipient at a low + // feerate. + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + let mut cp = CandidateParams::new(); + cp.must_spend = [funding_op].into(); + let coins = wallet.candidates_with(&cp).unwrap(); + let mut params = SelectParams::new(); + params.recipients = vec![(recip.clone(), Amount::from_sat(100_000))]; + params.fee_rate = FeeRate::from_sat_per_vb(1).unwrap(); + let template = wallet.select(coins, params).unwrap(); + let original_tx = wallet + .finish(template, FinishParams::default()) + .unwrap() + .0 + .unsigned_tx; + let original_txid = original_tx.compute_txid(); + insert_tx(&mut wallet, original_tx.clone()); + + // Sweep-replace the original tx at a higher feerate, draining everything to a single + // destination. + let dest = ChangeScript::from_descriptor( + wallet + .public_descriptor(Internal) + .at_derivation_index(0) + .unwrap(), + ); + let coins = wallet.rbf_candidates(&[original_txid]).unwrap(); + assert!(coins.is_rbf(), "candidate set should carry RBF context"); + let mut sweep_params = SelectParams::new(); + sweep_params.coin_selection = psbt::SelectionStrategy::DrainAll; + sweep_params.change_script = Some(dest); + sweep_params.fee_rate = FeeRate::from_sat_per_vb(10).unwrap(); + let template = wallet + .select(coins, sweep_params) + .expect("sweep replacement should succeed"); + let (psbt, _) = wallet.finish(template, FinishParams::default()).unwrap(); + + // The replacement must spend the same input as the original tx. + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|txin| txin.previous_output == funding_op), + "sweep replacement must spend the original input" + ); +}