diff --git a/Cargo.toml b/Cargo.toml index cb08925c..f26292eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] bdk_chain = { version = "0.23.1", features = ["miniscript", "serde"], default-features = false } +bdk_coin_select = { version = "0.4.1" } +bdk_tx = { version = "0.1.0" } bitcoin = { version = "0.32.6", features = ["serde", "base64"], default-features = false } miniscript = { version = "12.3.1", features = ["serde"], default-features = false } rand_core = { version = "0.6.0" } diff --git a/examples/psbt.rs b/examples/psbt.rs new file mode 100644 index 00000000..2d621b84 --- /dev/null +++ b/examples/psbt.rs @@ -0,0 +1,117 @@ +#![allow(clippy::print_stdout)] + +use std::collections::HashMap; +use std::str::FromStr; + +use bdk_chain::BlockId; +use bdk_chain::ConfirmationBlockTime; +use bdk_wallet::psbt::{PsbtParams, SelectionStrategy::*}; +use bdk_wallet::test_utils::*; +use bdk_wallet::{KeychainKind::External, Wallet}; +use bitcoin::{ + bip32, consensus, + secp256k1::{self, rand}, + Address, Amount, TxIn, TxOut, +}; +use rand::Rng; + +// This example shows how to create a PSBT using BDK Wallet. + +const NETWORK: bitcoin::Network = bitcoin::Network::Signet; +const SEND_TO: &str = "tb1pw3g5qvnkryghme7pyal228ekj6vq48zc5k983lqtlr2a96n4xw0q5ejknw"; +const AMOUNT: Amount = Amount::from_sat(42_000); +const FEERATE: f64 = 2.0; // sat/vb + +fn main() -> anyhow::Result<()> { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let secp = secp256k1::Secp256k1::new(); + + // Xpriv to be used for signing the PSBT + let xprv = bip32::Xpriv::from_str("tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L")?; + + // Create wallet and fund it. + let mut wallet = Wallet::create(desc, change_desc) + .network(NETWORK) + .create_wallet_no_persist()?; + + fund_wallet(&mut wallet)?; + + let utxos = wallet + .list_unspent() + .map(|output| (output.outpoint, output)) + .collect::>(); + + // Build params. + let mut params = PsbtParams::default(); + let addr = Address::from_str(SEND_TO)?.require_network(NETWORK)?; + let feerate = feerate_unchecked(FEERATE); + params + .add_recipients([(addr, AMOUNT)]) + .feerate(feerate) + .coin_selection(SingleRandomDraw); + + // Create PSBT (which also returns the Finalizer). + let (mut psbt, finalizer) = wallet.create_psbt(params)?; + + dbg!(&psbt); + + let tx = &psbt.unsigned_tx; + for txin in &tx.input { + let op = txin.previous_output; + let output = utxos.get(&op).unwrap(); + println!("TxIn: {}", output.txout.value); + } + for txout in &tx.output { + println!("TxOut: {}", txout.value); + } + + let _ = psbt.sign(&xprv, &secp); + println!("Signed: {}", !psbt.inputs[0].partial_sigs.is_empty()); + let finalize_res = finalizer.finalize(&mut psbt); + println!("Finalized: {}", finalize_res.is_finalized()); + + let tx = psbt.extract_tx()?; + let feerate = wallet.calculate_fee_rate(&tx)?; + println!("Fee rate: {} sat/vb", bdk_wallet::floating_rate!(feerate)); + + println!("{}", consensus::encode::serialize_hex(&tx)); + + Ok(()) +} + +fn fund_wallet(wallet: &mut Wallet) -> anyhow::Result<()> { + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 260071, + hash: "000000099f67ae6469d1ad0525d756e24d4b02fbf27d65b3f413d5feb367ec48".parse()?, + }, + confirmation_time: 1752184658, + }; + insert_checkpoint(wallet, anchor.block_id); + + let mut rng = rand::thread_rng(); + + // Fund wallet with several random utxos + for i in 0..21 { + let addr = wallet.reveal_next_address(External).address; + let value = 10_000 * (i + 1) + (100 * rng.gen_range(0..10)); + let tx = bitcoin::Transaction { + lock_time: bitcoin::absolute::LockTime::ZERO, + version: bitcoin::transaction::Version::TWO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: addr.script_pubkey(), + value: Amount::from_sat(value), + }], + }; + insert_tx_anchor(wallet, tx, anchor.block_id); + } + + let tip = BlockId { + height: 260171, + hash: "0000000b9efb77450e753ae9fd7be9f69219511c27b6e95c28f4126f3e1591c3".parse()?, + }; + insert_checkpoint(wallet, tip); + + Ok(()) +} diff --git a/examples/rbf.rs b/examples/rbf.rs new file mode 100644 index 00000000..108528d4 --- /dev/null +++ b/examples/rbf.rs @@ -0,0 +1,92 @@ +#![allow(clippy::print_stdout)] + +use std::str::FromStr; +use std::sync::Arc; + +use bdk_chain::BlockId; +use bdk_wallet::test_utils::*; +use bdk_wallet::Wallet; +use bitcoin::{bip32, consensus, secp256k1, Address, FeeRate, Transaction}; + +// This example shows how to create a Replace-By-Fee (RBF) transaction using BDK Wallet. + +const NETWORK: bitcoin::Network = bitcoin::Network::Regtest; +const SEND_TO: &str = "bcrt1q3yfqg2v9d605r45y5ddt5unz5n8v7jl5yk4a4f"; + +fn main() -> anyhow::Result<()> { + let desc = "wpkh(tprv8ZgxMBicQKsPe5tkv8BYJRupCNULhJYDv6qrtVAK9fNVheU6TbscSedVi8KQk8vVZqXMnsGomtVkR4nprbgsxTS5mAQPV4dpPXNvsmYcgZU/84h/1h/0h/0/*)"; + let change_desc = "wpkh(tprv8ZgxMBicQKsPe5tkv8BYJRupCNULhJYDv6qrtVAK9fNVheU6TbscSedVi8KQk8vVZqXMnsGomtVkR4nprbgsxTS5mAQPV4dpPXNvsmYcgZU/84h/1h/0h/1/*)"; + let secp = secp256k1::Secp256k1::new(); + + // Xpriv to be used for signing the PSBT + let xprv = bip32::Xpriv::from_str("tprv8ZgxMBicQKsPe5tkv8BYJRupCNULhJYDv6qrtVAK9fNVheU6TbscSedVi8KQk8vVZqXMnsGomtVkR4nprbgsxTS5mAQPV4dpPXNvsmYcgZU")?; + + // Create wallet and "fund" it. + let mut wallet = Wallet::create(desc, change_desc) + .network(NETWORK) + .create_wallet_no_persist()?; + + // `tx_1` is the unconfirmed wallet tx that we want to replace. + let tx_1 = fund_wallet(&mut wallet)?; + wallet.apply_unconfirmed_txs([(tx_1.clone(), 1234567000)]); + + // We'll need to fill in the original recipient details. + let addr = Address::from_str(SEND_TO)?.require_network(NETWORK)?; + let txo = tx_1 + .output + .iter() + .find(|txo| txo.script_pubkey == addr.script_pubkey()) + .expect("failed to find orginal recipient") + .clone(); + + // Now build fee bump. + let (mut psbt, finalizer) = wallet.replace_by_fee_and_recipients( + &[Arc::clone(&tx_1)], + FeeRate::from_sat_per_vb_unchecked(5), + vec![(txo.script_pubkey, txo.value)], + )?; + + let _ = psbt.sign(&xprv, &secp); + println!("Signed: {}", !psbt.inputs[0].partial_sigs.is_empty()); + let finalize_res = finalizer.finalize(&mut psbt); + println!("Finalized: {}", finalize_res.is_finalized()); + + let tx = psbt.extract_tx()?; + let feerate = wallet.calculate_fee_rate(&tx)?; + println!("Fee rate: {} sat/vb", bdk_wallet::floating_rate!(feerate)); + + println!("{}", consensus::encode::serialize_hex(&tx)); + + wallet.apply_unconfirmed_txs([(tx.clone(), 1234567001)]); + + let txid_2 = tx.compute_txid(); + + assert!( + wallet + .tx_graph() + .direct_conflicts(&tx_1) + .any(|(_, txid)| txid == txid_2), + "ERROR: RBF tx does not replace `tx_1`", + ); + + Ok(()) +} + +fn fund_wallet(wallet: &mut Wallet) -> anyhow::Result> { + // The parent of `tx`. This is needed to compute the original fee. + let tx0: Transaction = consensus::encode::deserialize_hex( + "020000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff025100ffffffff0200f2052a010000001600144d34238b9c4c59b9e2781e2426a142a75b8901ab0000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf90120000000000000000000000000000000000000000000000000000000000000000000000000", + )?; + + let anchor_block = BlockId { + height: 101, + hash: "3bcc1c447c6b3886f43e416b5c21cf5c139dc4829a71dc78609bc8f6235611c5".parse()?, + }; + insert_tx_anchor(wallet, tx0, anchor_block); + + let tx: Transaction = consensus::encode::deserialize_hex( + "020000000001014cb96536e94ba3f840cb5c2c965c8f9a306209de63fcd02060219aaf14f1d7b30000000000fdffffff0280de80020000000016001489120429856e9f41d684a35aba7262a4cecf4bf4f312852701000000160014757a57b3009c0e9b2b9aa548434dc295e21aeb05024730440220400c0a767ce42e0ea02b72faabb7f3433e607b475111285e0975bba1e6fd2e13022059453d83cbacb6652ba075f59ca0437036f3f94cae1959c7c5c0f96a8954707a012102c0851c2d2bddc1dd0b05caeac307703ec0c4b96ecad5a85af47f6420e2ef6c661b000000", + )?; + + Ok(Arc::new(tx)) +} diff --git a/src/lib.rs b/src/lib.rs index d54eac92..75e758ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,7 @@ pub use bdk_chain::rusqlite; pub use bdk_chain::rusqlite_impl; pub use descriptor::template; pub use descriptor::HdKeyPaths; +pub use psbt::*; pub use signer; pub use signer::SignOptions; pub use tx_builder::*; diff --git a/src/psbt/mod.rs b/src/psbt/mod.rs index 5f05b7b8..1f07fe6b 100644 --- a/src/psbt/mod.rs +++ b/src/psbt/mod.rs @@ -17,6 +17,10 @@ use bitcoin::FeeRate; use bitcoin::Psbt; use bitcoin::TxOut; +mod params; + +pub use params::*; + // TODO upstream the functions here to `rust-bitcoin`? /// Trait to add functions to extract utxos and calculate fees. diff --git a/src/psbt/params.rs b/src/psbt/params.rs new file mode 100644 index 00000000..6703b5f2 --- /dev/null +++ b/src/psbt/params.rs @@ -0,0 +1,511 @@ +//! Parameters for PSBT building. + +use alloc::sync::Arc; +use alloc::vec::Vec; +use core::fmt; + +use bdk_chain::{BlockId, CanonicalizationParams, ConfirmationBlockTime, FullTxOut, TxGraph}; +use bdk_tx::{DefiniteDescriptor, Input, Output}; +use bitcoin::{ + absolute, transaction::Version, Amount, FeeRate, OutPoint, ScriptBuf, Sequence, Transaction, + Txid, +}; +use miniscript::plan::Assets; + +use crate::collections::HashSet; +use crate::TxOrdering; + +/// Parameters to create a PSBT. +#[derive(Debug)] +pub struct PsbtParams { + // Inputs + pub(crate) set: HashSet, + pub(crate) utxos: Vec, + pub(crate) inputs: Vec, + + // Outputs + pub(crate) recipients: Vec<(ScriptBuf, Amount)>, + pub(crate) change_descriptor: Option, + + // Coin Selection + pub(crate) assets: Option, + pub(crate) feerate: FeeRate, + pub(crate) longterm_feerate: FeeRate, + pub(crate) drain_wallet: bool, + pub(crate) coin_selection: SelectionStrategy, + pub(crate) canonical_params: CanonicalizationParams, + pub(crate) utxo_filter: UtxoFilter, + pub(crate) maturity_height: Option, + pub(crate) manually_selected_only: bool, + + // PSBT + pub(crate) version: Option, + pub(crate) locktime: Option, + pub(crate) fallback_sequence: Option, + pub(crate) ordering: TxOrdering, + pub(crate) only_witness_utxo: bool, + pub(crate) add_global_xpubs: bool, +} + +impl Default for PsbtParams { + fn default() -> Self { + Self { + set: Default::default(), + utxos: Default::default(), + inputs: Default::default(), + assets: Default::default(), + recipients: Default::default(), + change_descriptor: Default::default(), + feerate: bitcoin::FeeRate::BROADCAST_MIN, + longterm_feerate: bitcoin::FeeRate::from_sat_per_vb_unchecked(10), + drain_wallet: Default::default(), + coin_selection: Default::default(), + canonical_params: Default::default(), + utxo_filter: Default::default(), + maturity_height: Default::default(), + manually_selected_only: Default::default(), + version: Default::default(), + locktime: Default::default(), + fallback_sequence: Default::default(), + ordering: Default::default(), + only_witness_utxo: Default::default(), + add_global_xpubs: Default::default(), + } + } +} + +impl PsbtParams { + /// Add UTXOs by outpoint to fund the transaction. + /// + /// A single outpoint may appear at most once in the list of UTXOs to spend. The caller is + /// responsible for ensuring that elements of `outpoints` correspond to outputs of previous + /// transactions and are currently unspent. + pub fn add_utxos(&mut self, outpoints: &[OutPoint]) -> &mut Self { + self.utxos + .extend(outpoints.iter().copied().filter(|&op| self.set.insert(op))); + self + } + + /// Get the currently selected spends. + pub fn utxos(&self) -> &HashSet { + &self.set + } + + /// Remove a UTXO from the currently selected inputs. + pub fn remove_utxo(&mut self, outpoint: &OutPoint) -> &mut Self { + if self.set.remove(outpoint) { + self.utxos.retain(|op| op != outpoint); + } + self + } + + /// Only include inputs that are selected manually using [`add_utxos`] or [`add_planned_input`]. + /// + /// Since the wallet will skip coin selection for additional candidates, the manually selected + /// inputs must be enough to fund the transaction or else an error will be thrown due to + /// insufficient funds. + /// + /// [`add_utxos`]: PsbtParams::add_utxos + /// [`add_planned_input`]: PsbtParams::add_planned_input + pub fn manually_selected_only(&mut self) -> &mut Self { + self.manually_selected_only = true; + self + } + + /// Add the spend [`Assets`]. + /// + /// Assets are required to create a spending plan for an output controlled by the wallet's + /// descriptors. If none are provided here, then we assume all of the keys are equally likely + /// to sign. + /// + /// This may be called multiple times to add additional assets, however only the last + /// absolute or relative timelock is retained. See also `AssetsExt`. + pub fn add_assets(&mut self, assets: Assets) -> &mut Self { + let mut new = match self.assets { + Some(ref existing) => { + let mut new = Assets::new(); + new.extend(existing); + new + } + None => Assets::new(), + }; + new.extend(&assets); + self.assets = Some(new); + self + } + + /// Add recipients. + /// + /// - `recipients`: An iterator of `(S, Amount)` tuples where `S` can be a bitcoin [`Address`], + /// a scriptPubKey, or anything that can be converted straight into a [`ScriptBuf`]. + pub fn add_recipients(&mut self, recipients: I) -> &mut Self + where + I: IntoIterator, + S: Into, + { + self.recipients + .extend(recipients.into_iter().map(|(s, amt)| (s.into(), amt))); + self + } + + /// Set the transaction `nLockTime`. + /// + /// This can be used as a fallback in case none of the inputs to the transaction require an + /// absolute locktime. If no locktime is required and nothing is specified here, then the + /// locktime is set to the last known chain tip. + pub fn locktime(&mut self, locktime: absolute::LockTime) -> &mut Self { + self.locktime = Some(locktime); + self + } + + /// Set the height to be used when evaluating the maturity of coinbase outputs during coin + /// selection. + pub fn maturity_height(&mut self, height: absolute::Height) -> &mut Self { + self.maturity_height = Some(height.to_consensus_u32()); + self + } + + /// Set the target fee rate. + pub fn feerate(&mut self, feerate: FeeRate) -> &mut Self { + self.feerate = feerate; + self + } + + /// Set the strategy to be used when selecting coins. + pub fn coin_selection(&mut self, strategy: SelectionStrategy) -> &mut Self { + self.coin_selection = strategy; + self + } + + /// Set the parameters for modifying the wallet's view of canonical transactions. + /// + /// The `params` can be used to resolve conflicts manually, or to assert that a particular + /// transaction should be treated as canonical for the purpose of building the current PSBT. + /// Refer to [`CanonicalizationParams`] for more. + pub fn canonicalization_params( + &mut self, + params: bdk_chain::CanonicalizationParams, + ) -> &mut Self { + self.canonical_params = params; + self + } + + /// Set the definite descriptor used for generating the change output. + pub fn change_descriptor(&mut self, desc: DefiniteDescriptor) -> &mut Self { + self.change_descriptor = Some(desc); + self + } + + /// Replace spends of the given `txs` and return a [`ReplaceParams`] populated with the + /// the inputs to spend. + /// + /// This merges all of the spends into a single transaction while retaining the parameters + /// of `self`. Note however that any previously added UTXOs are removed. Call + /// [`replace_by_fee_with_aux_rand`](crate::Wallet::replace_by_fee_with_aux_rand) to finish + /// building the PSBT. + /// + /// ## Note + /// + /// There should be no ancestry linking the elements of `txs`, since replacing an + /// ancestor necessarily invalidates the descendant. + pub fn replace(self, txs: &[Arc]) -> ReplaceParams { + ReplaceParams::new(txs, self) + } + + /// Filter [`FullTxOut`]s by the provided closure. + /// + /// This option can be used to mark specific outputs unspendable or apply any sort of custom + /// UTXO filter. + /// + /// Note that returning `true` from the `exclude` function will exclude the output from coin + /// selection, otherwise any coin in the wallet that is mature and spendable will be + /// eligible for selection. + pub fn filter_utxos(&mut self, exclude: F) -> &mut Self + where + F: Fn(&FullTxOut) -> bool + Send + Sync + 'static, + { + self.utxo_filter = UtxoFilter(Arc::new(exclude)); + 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. + /// + /// [`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 + } + + /// Fill in the global [`Psbt::xpub`]s field with the extended keys of the wallet's + /// descriptors. + /// + /// Some offline signers and/or multisig wallets may require this. + /// + /// [`Psbt::xpub`]: bitcoin::Psbt::xpub + pub fn add_global_xpubs(&mut self) -> &mut Self { + self.add_global_xpubs = true; + self + } +} + +/// Coin select strategy. +#[derive(Debug, Clone, Copy, Default)] +#[non_exhaustive] +pub enum SelectionStrategy { + /// Single random draw. + #[default] + SingleRandomDraw, + /// Lowest fee, a variation of Branch 'n Bound that allows for change + /// while minimizing transaction fees. Refer to + /// [`LowestFee`](bdk_coin_select::metrics::LowestFee) metric for more. + LowestFee, +} + +/// [`UtxoFilter`] is a user-defined `Fn` closure which decides whether to exclude a UTXO +/// from being selected. +// TODO: Consider having this also take a `&Wallet` in case the caller needs information +// not given by the FullTxOut. +#[allow(clippy::type_complexity)] +pub(crate) struct UtxoFilter( + pub Arc) -> bool + Send + Sync>, +); + +impl Default for UtxoFilter { + fn default() -> Self { + Self(Arc::new(|_| false)) + } +} + +impl fmt::Debug for UtxoFilter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "UtxoFilter") + } +} + +/// `ReplaceParams` provides a thin wrapper around [`PsbtParams`] and is intended for +/// crafting Replace-By-Fee transactions (RBF). +#[derive(Debug, Default)] +pub struct ReplaceParams { + /// Txids of txs to replace. + pub(crate) replace: HashSet, + /// The inner PSBT parameters. + pub(crate) inner: PsbtParams, +} + +impl ReplaceParams { + /// Construct from `inner` params and the `txs` to replace. + pub(crate) fn new(txs: &[Arc], inner: PsbtParams) -> Self { + let mut params = Self { + inner, + ..Default::default() + }; + params.replace(txs); + params + } + + /// Replace spends of the provided `txs`. This will internally set the internal list + /// of UTXOs to be spent. + pub fn replace(&mut self, txs: &[Arc]) { + self.inner.utxos.clear(); + let mut utxos = vec![]; + + let (mut txids_to_replace, txs): (HashSet, Vec) = txs + .iter() + .map(|tx| (tx.compute_txid(), tx.as_ref().clone())) + .unzip(); + let tx_graph = TxGraph::::new(txs); + + // Sanitize the RBF set by removing elements of `txs` which have ancestors + // in the same set. This is to avoid spending outputs of txs that are bound + // 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.inner.add_utxos(&utxos); + } + + /// Add recipients. + pub fn add_recipients(&mut self, recipients: I) -> &mut Self + where + I: IntoIterator, + S: Into, + { + self.inner.add_recipients(recipients); + self + } + + /// Set the target fee rate. + pub fn feerate(&mut self, feerate: FeeRate) -> &mut Self { + self.inner.feerate(feerate); + self + } + + /// Get the currently selected spends. + pub fn utxos(&self) -> &HashSet { + &self.inner.set + } + + /// Remove a UTXO from the currently selected inputs. + pub fn remove_utxo(&mut self, outpoint: &OutPoint) -> &mut Self { + self.inner.remove_utxo(outpoint); + self + } +} + +/// 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 = ReplaceParams::new(&txs, PsbtParams::default()); + assert_eq!(params.inner.set, expect_spends); + assert_eq!(params.replace, [txid_a, txid_c].into()); + } + + #[test] + fn test_selected_outpoints_are_unique() { + let mut params = PsbtParams::default(); + let op = OutPoint::null(); + + // Try adding the same outpoint repeatedly. + for _ in 0..3 { + params.add_utxos(&[op]); + } + assert_eq!( + params.utxos(), + &[op].into(), + "Failed to filter duplicate outpoints" + ); + assert!(params.utxos.contains(&op)); + + params = PsbtParams::default(); + + // Try adding duplicates in the same set. + params.add_utxos(&[op, op, op]); + assert_eq!( + params.utxos(), + &[op].into(), + "Failed to filter duplicate outpoints" + ); + assert!(params.utxos.contains(&op)); + } +} diff --git a/src/test_utils.rs b/src/test_utils.rs index 11fd13b1..14af2142 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -317,6 +317,30 @@ pub fn insert_checkpoint(wallet: &mut Wallet, block: BlockId) { .unwrap(); } +/// Inserts a transaction to be anchored by `block_id` (no last seen). This can be used to add +/// a coinbase tx to the wallet for testing. +/// +/// This will also insert the anchor `block_id`. See [`insert_anchor`] for more. +pub fn insert_tx_anchor(wallet: &mut Wallet, tx: Transaction, block_id: BlockId) { + insert_checkpoint(wallet, block_id); + let anchor = ConfirmationBlockTime { + block_id, + confirmation_time: 1234567000, + }; + let txid = tx.compute_txid(); + + let mut tx_update = TxUpdate::default(); + tx_update.txs = vec![Arc::new(tx)]; + tx_update.anchors = [(anchor, txid)].into(); + + wallet + .apply_update(Update { + tx_update, + ..Default::default() + }) + .expect("failed to apply update"); +} + /// Inserts a transaction into the local view, assuming it is currently present in the mempool. /// /// This can be used, for example, to track a transaction immediately after it is broadcast. diff --git a/src/wallet/error.rs b/src/wallet/error.rs index 47be69df..520bb80e 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -256,3 +256,74 @@ impl fmt::Display for BuildFeeBumpError { #[cfg(feature = "std")] impl std::error::Error for BuildFeeBumpError {} + +/// Error when creating a PSBT. +#[derive(Debug)] +#[non_exhaustive] +pub enum CreatePsbtError { + /// No Bnb solution. + Bnb(bdk_coin_select::NoBnbSolution), + /// Non-sufficient funds + InsufficientFunds(bdk_coin_select::InsufficientFunds), + /// In order to use the [`add_global_xpubs`] option, every extended key in the descriptor must + /// either be a master key itself, having a depth of 0, or have an explicit origin provided. + /// + /// [`add_global_xpubs`]: crate::psbt::PsbtParams::add_global_xpubs + MissingKeyOrigin(bitcoin::bip32::Xpub), + /// Failed to create a spend [`Plan`] for a manually selected output + Plan(OutPoint), + /// Failed to create PSBT + Psbt(bdk_tx::CreatePsbtError), + /// Selector error + Selector(bdk_tx::SelectorError), + /// The UTXO of outpoint could not be found + UnknownUtxo(OutPoint), +} + +impl fmt::Display for CreatePsbtError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bnb(e) => write!(f, "{e}"), + Self::InsufficientFunds(e) => write!(f, "{e}"), + Self::MissingKeyOrigin(e) => write!(f, "missing key origin: {e}"), + Self::Plan(op) => write!(f, "failed to create a plan for txout with outpoint {op}"), + Self::Psbt(e) => write!(f, "{e}"), + Self::Selector(e) => write!(f, "{e}"), + Self::UnknownUtxo(op) => write!(f, "unknown UTXO: {op}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for CreatePsbtError {} + +/// Error when creating a Replace-By-Fee transaction. +#[derive(Debug)] +#[non_exhaustive] +pub enum ReplaceByFeeError { + /// There was a problem creating the PSBT + CreatePsbt(CreatePsbtError), + /// Failed to compute the fee of an original transaction + PreviousFee(bdk_chain::tx_graph::CalculateFeeError), + /// Original transaction could not be found + MissingTransaction(Txid), +} + +impl fmt::Display for ReplaceByFeeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CreatePsbt(e) => write!(f, "{e}"), + Self::PreviousFee(e) => write!(f, "{e}"), + Self::MissingTransaction(txid) => write!(f, "missing transaction with txid: {txid}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for ReplaceByFeeError {} + +impl From for ReplaceByFeeError { + fn from(e: CreatePsbtError) -> Self { + Self::CreatePsbt(e) + } +} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 273b5f6f..2987522d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -30,14 +30,20 @@ use bdk_chain::{ SyncResponse, }, tx_graph::{CalculateFeeError, CanonicalTx, TxGraph, TxUpdate}, - BlockId, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, - FullTxOut, Indexed, IndexedTxGraph, Indexer, Merge, + Anchor, BlockId, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, + FullTxOut, Indexed, IndexedTxGraph, Indexer, KeychainIndexed, Merge, }; +use bdk_tx::{ + selection_algorithm_lowest_fee_bnb, ChangePolicyType, DefiniteDescriptor, Finalizer, Input, + InputCandidates, OriginalTxStats, Output, RbfParams, Selector, SelectorParams, TxStatus, +}; +#[cfg(feature = "std")] +use bitcoin::secp256k1::rand; use bitcoin::{ absolute, consensus::encode::serialize, constants::genesis_block, - psbt, + psbt, relative, secp256k1::Secp256k1, sighash::{EcdsaSighashType, TapSighashType}, transaction, Address, Amount, Block, BlockHash, FeeRate, Network, OutPoint, Psbt, ScriptBuf, @@ -45,7 +51,9 @@ use bitcoin::{ }; use miniscript::{ descriptor::KeyMap, + plan::{Assets, Plan}, psbt::{PsbtExt, PsbtInputExt, PsbtInputSatisfier}, + ForEachKey, }; use rand_core::RngCore; @@ -65,11 +73,13 @@ use crate::descriptor::{ DerivedDescriptor, DescriptorMeta, ExtendedDescriptor, ExtractPolicy, IntoWalletDescriptor, Policy, XKeyUtils, }; -use crate::psbt::PsbtUtils; +use crate::psbt::{AssetsExt, PsbtParams, PsbtUtils, ReplaceParams, SelectionStrategy}; use crate::types::*; use crate::wallet::{ coin_selection::{DefaultCoinSelectionAlgorithm, Excess, InsufficientFunds}, - error::{BuildFeeBumpError, CreateTxError, MiniscriptPsbtError}, + error::{ + BuildFeeBumpError, CreatePsbtError, CreateTxError, MiniscriptPsbtError, ReplaceByFeeError, + }, signer::{SignOptions, SignerError, SignerOrdering, SignersContainer, TransactionSigner}, tx_builder::{FeePolicy, TxBuilder, TxParams}, utils::{check_nsequence_rbf, After, Older, SecpCtx}, @@ -80,8 +90,10 @@ pub use bdk_chain::Balance; pub use changeset::ChangeSet; pub use params::*; pub use persisted::*; -pub use utils::IsDust; -pub use utils::TxDetails; +pub use utils::{IsDust, TxDetails}; + +/// Alias [`FullTxOut`] indexed by keychain, index. +type IndexedTxOut = KeychainIndexed>; /// A Bitcoin wallet /// @@ -903,6 +915,19 @@ impl Wallet { .map(|((k, i), full_txo)| new_local_utxo(k, i, full_txo)) } + /// List indexed full txouts. Note: the result can be modified by the canonicalization `params`. + fn list_indexed_txouts( + &self, + params: CanonicalizationParams, + ) -> impl Iterator + '_ { + self.indexed_graph.graph().filter_chain_txouts( + &self.chain, + self.chain.tip().block_id(), + params, + self.indexed_graph.index.outpoints().iter().cloned(), + ) + } + /// Get the [`TxDetails`] of a wallet transaction. /// /// If the transaction with txid [`Txid`] cannot be found in the wallet's transactions, `None` @@ -1190,14 +1215,24 @@ 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.indexed_graph.graph(); let tx_index = &self.indexed_graph.index; tx_graph - .list_canonical_txs( - &self.chain, - self.chain.tip().block_id(), - CanonicalizationParams::default(), - ) + .list_canonical_txs(&self.chain, self.chain.tip().block_id(), params) .filter(|c_tx| tx_index.is_tx_relevant(&c_tx.tx_node.tx)) } @@ -2466,6 +2501,31 @@ impl Wallet { Ok(()) } + /// Inserts a transaction into the inner transaction graph, scanning for relevant txouts. + /// + /// This is used to inform the wallet of newly created txs before they are known to exist + /// on chain or in mempool, which is useful for discovering wallet-owned outputs of + /// not-yet-canonical transactions. The inserted transaction carries no additional metadata, + /// like the time it was seen or the confirmation height. To later retrieve it, refer to + /// [`Wallet::transactions_with_params`]. + /// + /// The effect of insertion depends on the [relevance] of `tx` as determined by the indexer. + /// If the transaction was newly inserted and a txout matches a derived SPK, then the index + /// is updated with the relevant outpoint(s). If no outputs are relevant, the transaction is + /// kept but the index remains unchanged. There should be no change to the wallet balance until + /// the transaction is accepted by the network and the wallet is synced. If `tx` was already + /// present in-graph, then the effect is a no-op. + /// + /// **You must persist the change set staged as a result of this call.** + /// + /// [relevance]: Indexer::is_tx_relevant + pub fn insert_tx(&mut self, tx: T) + where + T: Into>, + { + self.stage.merge(self.indexed_graph.insert_tx(tx).into()); + } + /// Apply relevant unconfirmed transactions to the wallet. /// /// Transactions that are not relevant are filtered out. @@ -2636,6 +2696,556 @@ 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()?; + let time = + absolute::Time::from_consensus(anchor.confirmation_time.try_into().ok()?).ok()?; + Some(TxStatus { height, time }) + } else { + None + } +} + +impl Wallet { + /// Return the "keys" assets, i.e. the ones we can trivially infer by scanning + /// the pubkeys of the wallet's descriptors. + fn assets(&self) -> Assets { + let mut pks = vec![]; + for (_, desc) in self.keychains() { + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + } + + Assets::new().add(pks) + } + + /// Parses the common parameters used during PSBT creation. + /// + /// ## Returns + /// + /// - Assets + /// - Change script + /// - Indexed wallet txouts + fn parse_params( + &self, + params: &PsbtParams, + ) -> ( + Assets, + DefiniteDescriptor, + HashMap>, + ) { + // Get spend assets. + let assets = match params.assets { + None => self.assets(), + Some(ref params_assets) => { + let mut assets = Assets::new(); + assets.extend(params_assets); + // Fill in the "keys" assets if none are provided. + if assets.keys.is_empty() { + assets.extend(&self.assets()); + } + assets + } + }; + + // Get change script. + let change_script = params.change_descriptor.clone().unwrap_or_else(|| { + let change_keychain = self.map_keychain(KeychainKind::Internal); + let desc = self.public_descriptor(change_keychain); + let next_index = self.next_derivation_index(change_keychain); + desc.at_derivation_index(next_index) + .expect("should be valid derivation index") + }); + + // Get wallet txouts. + let txouts = self + .list_indexed_txouts(params.canonical_params.clone()) + .map(|(_, txo)| (txo.outpoint, txo)) + .collect(); + + (assets, change_script, txouts) + } + + /// Filters wallet `txos` by the spending criteria. + /// + /// - `exclude`: Closure indicating whether the output should be excluded, used by some callers + /// to apply additional filters as in the case of RBF. + fn filter_spendable<'a, I, F>( + &'a self, + txos: I, + params: &'a PsbtParams, + exclude: F, + ) -> impl Iterator> + 'a + where + I: IntoIterator> + 'a, + F: Fn(&FullTxOut) -> bool + 'a, + { + let current_height = params.maturity_height.unwrap_or(self.chain.tip().height()); + txos.into_iter().filter(move |txo| { + // Exclude outputs that are manually selected. + if params.utxos.contains(&txo.outpoint) { + return false; + } + // Exclude outputs according to `exclude` fn. + if exclude(txo) { + return false; + } + // Exclude outputs that are immature or already spent. + if !txo.is_mature(current_height) { + return false; + } + if txo.spent_by.is_some() { + return false; + } + true + }) + } + + /// Maps the recipients of the `params` to a collection of target [`Output`]s. + fn target_outputs(&self, params: &PsbtParams) -> Vec { + params + .recipients + .iter() + .cloned() + .map( + |(script, value)| match self.indexed_graph.index.index_of_spk(script.clone()) { + Some(&(keychain, index)) => { + let descriptor = self + .public_descriptor(keychain) + .at_derivation_index(index) + .expect("should be valid derivation index"); + Output::with_descriptor(descriptor, value) + } + None => Output::with_script(script, value), + }, + ) + .collect() + } + + /// Creates a PSBT with the given `params` and returns the updated [`Psbt`] and + /// [`Finalizer`]. + /// + /// # Example + /// + /// ```rust,no_run + /// # use std::str::FromStr; + /// # use bitcoin::{Amount, Address, FeeRate, OutPoint}; + /// # use bdk_wallet::psbt::{PsbtParams, SelectionStrategy}; + /// # let wallet = bdk_wallet::doctest_wallet!(); + /// # let outpoint = OutPoint::null(); + /// # let address = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5").unwrap().assume_checked(); + /// # let amount = Amount::ZERO; + /// let mut params = PsbtParams::default(); + /// params + /// .add_utxos(&[outpoint]) + /// .add_recipients([(address, amount)]) + /// .coin_selection(SelectionStrategy::LowestFee) + /// .feerate(FeeRate::BROADCAST_MIN); + /// + /// let (psbt, finalizer) = wallet.create_psbt(params)?; + /// # Ok::<_, anyhow::Error>(()) + /// ``` + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn create_psbt(&self, params: PsbtParams) -> Result<(Psbt, Finalizer), CreatePsbtError> { + self.create_psbt_with_aux_rand(params, &mut rand::thread_rng()) + } + + /// Creates a PSBT with the given `params` and auxiliary randomness. + /// + /// ### Parameters: + /// + /// - `params`: [`PsbtParams`] + /// - `rng`: Source of entropy, may be used during coin selection. + /// + /// Returns the updated [`Psbt`] and [`Finalizer`]. + pub fn create_psbt_with_aux_rand( + &self, + params: PsbtParams, + rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), CreatePsbtError> { + let (assets, change_script, txouts) = self.parse_params(¶ms); + + let must_spend: Vec = params + .utxos + .iter() + .map(|&op| -> Result<_, CreatePsbtError> { + let txo = txouts.get(&op).ok_or(CreatePsbtError::UnknownUtxo(op))?; + self.plan_input(txo, &assets) + .ok_or(CreatePsbtError::Plan(op)) + }) + .chain(params.inputs.iter().cloned().map(Result::Ok)) + .collect::>()?; + + // Get input candidates + let mut may_spend: Vec = if params.manually_selected_only { + vec![] + } else { + self.filter_spendable(txouts.into_values(), ¶ms, |txo| { + (params.utxo_filter.0)(txo) + }) + .flat_map(|txo| self.plan_input(&txo, &assets)) + .collect() + }; + + utils::shuffle_slice(&mut may_spend, rng); + + let target_outputs = self.target_outputs(¶ms); + + let input_candidates = InputCandidates::new(must_spend, may_spend); + if input_candidates.inputs().next().is_none() { + let target_amount: Amount = target_outputs.iter().map(|output| output.value).sum(); + let err = bdk_coin_select::InsufficientFunds { + missing: target_amount.to_sat(), + }; + return Err(CreatePsbtError::InsufficientFunds(err)); + } + + let mut selector = Selector::new( + &input_candidates, + SelectorParams { + target_feerate: params.feerate, + target_outputs, + change_descriptor: change_script, + change_policy: ChangePolicyType::NoDustAndLeastWaste { + longterm_feerate: params.longterm_feerate, + }, + replace: None, + }, + ) + .map_err(CreatePsbtError::Selector)?; + + self.create_psbt_from_selector(&mut selector, ¶ms, rng) + } + + /// Create the PSBT from [`Selector`] and `params`. + /// + /// Internal method for handling coin selection and building the + /// resulting PSBT. + fn create_psbt_from_selector( + &self, + selector: &mut Selector, + params: &PsbtParams, + rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), CreatePsbtError> { + // How many times to run bnb before giving up + const BNB_MAX_ROUNDS: usize = 10_000; + + // Select coins + if params.drain_wallet { + selector.select_all(); + } else { + match params.coin_selection { + SelectionStrategy::SingleRandomDraw => { + // We should have shuffled candidates earlier, so just select + // until the target is met. + selector + .select_until_target_met() + .map_err(CreatePsbtError::InsufficientFunds)?; + } + SelectionStrategy::LowestFee => { + selector + .select_with_algorithm(selection_algorithm_lowest_fee_bnb( + params.longterm_feerate, + BNB_MAX_ROUNDS, + )) + .map_err(CreatePsbtError::Bnb)?; + } + }; + } + let mut selection = selector.try_finalize().ok_or({ + let e = bdk_tx::CannotMeetTarget; + CreatePsbtError::Selector(bdk_tx::SelectorError::CannotMeetTarget(e)) + })?; + + let tx_ordering = ¶ms.ordering; + tx_ordering.sort_with_aux_rand(&mut selection.inputs, &mut selection.outputs, rng); + + let version = params.version.unwrap_or(transaction::Version::TWO); + let fallback_locktime = params + .locktime + .unwrap_or(absolute::LockTime::from_consensus( + self.chain.tip().height(), + )); + let fallback_sequence = params + .fallback_sequence + .unwrap_or(Sequence::ENABLE_LOCKTIME_NO_RBF); + + // Create psbt + let mut psbt = selection + .create_psbt(bdk_tx::PsbtParams { + version, + fallback_locktime, + fallback_sequence, + mandate_full_tx_for_segwit_v0: !params.only_witness_utxo, + }) + .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 [`ReplaceParams`], and updating the recipients + /// and feerate before calling [`replace_by_fee_with_aux_rand`]. If further configuration is + /// desired, consider using [`PsbtParams::replace`] instead. + /// + /// # Example + /// + /// ```rust,no_run + /// # use std::sync::Arc; + /// # use bitcoin::FeeRate; + /// # use bdk_wallet::psbt::{PsbtParams, SelectionStrategy}; + /// # use bdk_wallet::test_utils; + /// # let wallet = bdk_wallet::doctest_wallet!(); + /// # let to_replace = Arc::new(test_utils::new_tx(0)); + /// # let vout = 0; + /// // Retrieve the original recipient from tx `to_replace`. + /// let txout = to_replace.tx_out(vout)?.clone(); + /// + /// let (psbt, finalizer) = wallet.replace_by_fee_and_recipients( + /// &[to_replace], + /// FeeRate::from_sat_per_vb_unchecked(10), + /// vec![(txout.script_pubkey, txout.value)], + /// )?; + /// # Ok::<_, anyhow::Error>(()) + /// ``` + /// + /// [`replace_by_fee_with_aux_rand`]: Wallet::replace_by_fee_with_aux_rand + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn replace_by_fee_and_recipients( + &self, + txs: &[Arc], + feerate: FeeRate, + recipients: Vec<(ScriptBuf, Amount)>, + ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { + self.replace_by_fee_with_aux_rand( + ReplaceParams::new( + txs, + PsbtParams { + recipients, + feerate, + ..Default::default() + }, + ), + &mut rand::thread_rng(), + ) + } + + /// Creates a Replace-By-Fee transaction (RBF) and returns the updated [`Psbt`] and + /// [`Finalizer`]. + /// + /// ### Parameters: + /// + /// - `params`: [`ReplaceParams`] + #[cfg(feature = "std")] + pub fn replace_by_fee( + &self, + params: ReplaceParams, + ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { + self.replace_by_fee_with_aux_rand(params, &mut rand::thread_rng()) + } + + /// Creates a Replace-By-Fee transaction (RBF) and returns the updated [`Psbt`] and + /// [`Finalizer`]. + /// + /// ### Parameters: + /// + /// - `params`: [`ReplaceParams`] + /// - `rng`: Source of entropy, may be used during coin selection. + pub fn replace_by_fee_with_aux_rand( + &self, + params: ReplaceParams, + rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { + let ReplaceParams { + replace: txids_to_replace, + inner: params, + } = params; + // Txs and their descendants to be replaced. This is used to filter outputs that can't + // be selected. + let mut to_replace = txids_to_replace.clone(); + for txid in txids_to_replace.iter().copied() { + to_replace.extend( + self.indexed_graph + .graph() + .walk_descendants(txid, |_, txid| Some(txid)), + ); + } + + let (assets, change_script, txouts) = self.parse_params(¶ms); + + let must_spend: Vec = params + .utxos + .iter() + .map(|&op| -> Result<_, CreatePsbtError> { + let txo = txouts.get(&op).ok_or(CreatePsbtError::UnknownUtxo(op))?; + self.plan_input(txo, &assets) + .ok_or(CreatePsbtError::Plan(op)) + }) + .chain(params.inputs.iter().cloned().map(Result::Ok)) + .collect::>()?; + + // Get input candidates + let mut may_spend: Vec = if params.manually_selected_only { + vec![] + } else { + self.filter_spendable(txouts.into_values(), ¶ms, |txo| { + // Exlude outputs of txs to be replaced. Also exclude unconfirmed outputs + // per replacement policy Rule 2. + to_replace.contains(&txo.outpoint.txid) + || txo.chain_position.is_unconfirmed() + || (params.utxo_filter.0)(txo) + }) + .flat_map(|txo| self.plan_input(&txo, &assets)) + .collect() + }; + + utils::shuffle_slice(&mut may_spend, rng); + + let target_outputs = self.target_outputs(¶ms); + + let input_candidates = InputCandidates::new(must_spend, may_spend); + if input_candidates.inputs().next().is_none() { + let target_amount: Amount = target_outputs.iter().map(|output| output.value).sum(); + let err = bdk_coin_select::InsufficientFunds { + missing: target_amount.to_sat(), + }; + return Err(CreatePsbtError::InsufficientFunds(err))?; + } + + let original_txs: Vec = txids_to_replace + .iter() + .map(|&txid| -> Result<_, ReplaceByFeeError> { + let tx = self + .indexed_graph + .graph() + .get_tx(txid) + .ok_or(ReplaceByFeeError::MissingTransaction(txid))?; + let fee = self + .calculate_fee(&tx) + .map_err(ReplaceByFeeError::PreviousFee)?; + Ok(OriginalTxStats { + weight: tx.weight(), + fee, + }) + }) + .collect::>()?; + + let rbf_params = RbfParams { + original_txs, + incremental_relay_feerate: FeeRate::BROADCAST_MIN, + }; + + let mut selector = Selector::new( + &input_candidates, + SelectorParams { + target_feerate: params.feerate, + target_outputs, + change_descriptor: change_script, + change_policy: ChangePolicyType::NoDustAndLeastWaste { + longterm_feerate: params.longterm_feerate, + }, + replace: Some(rbf_params), + }, + ) + .map_err(CreatePsbtError::Selector)?; + + self.create_psbt_from_selector(&mut selector, ¶ms, rng) + .map_err(ReplaceByFeeError::CreatePsbt) + } + + /// Plan the output with the available assets and return a new [`Input`] if possible. See also + /// [`Self::try_plan`]. + fn plan_input( + &self, + txo: &FullTxOut, + spend_assets: &Assets, + ) -> Option { + let op = txo.outpoint; + let txid = op.txid; + + // We want to afford the output with as many assets as we can. The plan + // will use only the ones needed to produce the minimum satisfaction. + let cur_height = self.latest_checkpoint().height(); + let abs_locktime = spend_assets + .absolute_timelock + .unwrap_or(absolute::LockTime::from_consensus(cur_height)); + + let rel_locktime = spend_assets.relative_timelock.unwrap_or_else(|| { + let age = match txo.chain_position.confirmation_height_upper_bound() { + Some(conf_height) => cur_height + .saturating_add(1) + .saturating_sub(conf_height) + .try_into() + .unwrap_or(u16::MAX), + None => 0, + }; + relative::LockTime::from_height(age) + }); + + let mut assets = Assets::new(); + assets.extend(spend_assets); + assets = assets.after(abs_locktime); + assets = assets.older(rel_locktime); + + let plan = self.try_plan(op, &assets)?; + let tx = self.indexed_graph.graph().get_tx(txid)?; + let tx_status = status_from_position(txo.chain_position); + + Input::from_prev_tx(plan, tx, op.vout as usize, tx_status).ok() + } + + /// Attempt to create a spending plan for the UTXO of the given `outpoint` + /// with the provided `assets`. + /// + /// Return `None` if `outpoint` doesn't correspond to an indexed txout, or + /// if the assets are not sufficient to create a plan. + fn try_plan(&self, outpoint: OutPoint, assets: &Assets) -> Option { + let indexer = &self.indexed_graph.index; + let ((keychain, index), _) = indexer.txout(outpoint)?; + let def_desc = indexer + .get_descriptor(keychain)? + .at_derivation_index(index) + .expect("must be valid derivation index"); + def_desc.plan(assets).ok() + } +} + impl AsRef> for Wallet { fn as_ref(&self) -> &bdk_chain::tx_graph::TxGraph { self.indexed_graph.graph() @@ -2759,7 +3369,7 @@ macro_rules! floating_rate { /// Macro for getting a wallet for use in a doctest macro_rules! doctest_wallet { () => {{ - use $crate::bitcoin::{BlockHash, Transaction, absolute, TxOut, Network, hashes::Hash}; + use $crate::bitcoin::{transaction, absolute, Amount, BlockHash, Transaction, TxOut, Network, hashes::Hash}; use $crate::chain::{ConfirmationBlockTime, BlockId, TxGraph, tx_graph}; use $crate::{Update, KeychainKind, Wallet}; use $crate::test_utils::*; diff --git a/src/wallet/tx_builder.rs b/src/wallet/tx_builder.rs index f3bfe3f9..4808d4dd 100644 --- a/src/wallet/tx_builder.rs +++ b/src/wallet/tx_builder.rs @@ -844,7 +844,7 @@ type TxSort = dyn (Fn(&T, &T) -> core::cmp::Ordering) + Send + Sync; /// Ordering of the transaction's inputs and outputs #[derive(Clone, Default)] -pub enum TxOrdering { +pub enum TxOrdering { /// Randomized (default) #[default] Shuffle, @@ -859,13 +859,13 @@ pub enum TxOrdering { /// Provide custom comparison functions for sorting Custom { /// Transaction inputs sort function - input_sort: Arc>, + input_sort: Arc>, /// Transaction outputs sort function - output_sort: Arc>, + output_sort: Arc>, }, } -impl core::fmt::Debug for TxOrdering { +impl core::fmt::Debug for TxOrdering { fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { match self { TxOrdering::Shuffle => write!(f, "Shuffle"), @@ -905,6 +905,27 @@ impl TxOrdering { } } +impl TxOrdering { + /// Sort the provided `input` and `output` slices by this [`TxOrdering`] and auxiliary + /// randomness. + pub fn sort_with_aux_rand(&self, input: &mut [I], output: &mut [O], rng: &mut impl RngCore) { + match self { + TxOrdering::Untouched => {} + TxOrdering::Shuffle => { + shuffle_slice(input, rng); + shuffle_slice(output, rng); + } + TxOrdering::Custom { + input_sort, + output_sort, + } => { + input.sort_unstable_by(|a, b| input_sort(a, b)); + output.sort_unstable_by(|a, b| output_sort(a, b)); + } + } + } +} + /// Policy regarding the use of change outputs when creating a transaction #[derive(Default, Debug, Ord, PartialOrd, Eq, PartialEq, Hash, Clone, Copy)] pub enum ChangeSpendPolicy { diff --git a/tests/add_foreign_utxo.rs b/tests/add_foreign_utxo.rs index 1dd0a8c9..23b3c1a4 100644 --- a/tests/add_foreign_utxo.rs +++ b/tests/add_foreign_utxo.rs @@ -5,7 +5,7 @@ use bdk_wallet::signer::SignOptions; use bdk_wallet::test_utils::*; use bdk_wallet::tx_builder::AddForeignUtxoError; use bdk_wallet::KeychainKind; -use bitcoin::{psbt, Address, Amount}; +use bitcoin::{hashes::Hash, psbt, Address, Amount, OutPoint, ScriptBuf, Sequence, TxOut}; mod common; @@ -290,3 +290,55 @@ fn test_taproot_foreign_utxo() { "foreign_utxo should be in there" ); } + +#[test] +fn test_add_planned_psbt_input() -> anyhow::Result<()> { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let op1 = wallet.list_unspent().next().unwrap().outpoint; + + // We'll use `PsbtParams` to sweep a foreign anchor output. + let op2 = OutPoint::new(Hash::hash(b"txid"), 2); + let txout = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_p2a(), + }; + let psbt_input = psbt::Input { + witness_utxo: Some(txout), + ..Default::default() + }; + let input = bdk_tx::Input::from_psbt_input( + op2, + Sequence::ENABLE_LOCKTIME_NO_RBF, + psbt_input, + /* satisfaction_weight: */ 0, + /* status: */ None, + /* is_coinbase: */ false, + )?; + + let send_to = wallet.reveal_next_address(KeychainKind::External).address; + + // Build tx: 2-in / 2-out + let mut params = bdk_wallet::PsbtParams::default(); + params.add_utxos(&[op1]); + params.add_planned_input(input); + params.add_recipients([(send_to, Amount::from_sat(20_000))]); + + let (psbt, _) = wallet.create_psbt(params)?; + + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|input| input.previous_output == op1), + "Psbt should contain the wallet spend" + ); + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|input| input.previous_output == op2), + "Psbt should contain the planned input" + ); + + Ok(()) +} diff --git a/tests/psbt.rs b/tests/psbt.rs index 08c4acc9..799bc8c6 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -1,11 +1,319 @@ -use bdk_wallet::bitcoin::{Amount, FeeRate, Psbt, TxIn}; +use bdk_chain::{BlockId, ConfirmationBlockTime}; +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, secp256k1, Address, Amount, FeeRate, Network, OutPoint, Psbt, + ScriptBuf, Transaction, TxIn, TxOut, +}; use core::str::FromStr; +use miniscript::plan::Assets; +use std::sync::Arc; // from bip 174 const PSBT_STR: &str = "cHNidP8BAKACAAAAAqsJSaCMWvfEm4IS9Bfi8Vqz9cM9zxU4IagTn4d6W3vkAAAAAAD+////qwlJoIxa98SbghL0F+LxWrP1wz3PFTghqBOfh3pbe+QBAAAAAP7///8CYDvqCwAAAAAZdqkUdopAu9dAy+gdmI5x3ipNXHE5ax2IrI4kAAAAAAAAGXapFG9GILVT+glechue4O/p+gOcykWXiKwAAAAAAAEHakcwRAIgR1lmF5fAGwNrJZKJSGhiGDR9iYZLcZ4ff89X0eURZYcCIFMJ6r9Wqk2Ikf/REf3xM286KdqGbX+EhtdVRs7tr5MZASEDXNxh/HupccC1AaZGoqg7ECy0OIEhfKaC3Ibi1z+ogpIAAQEgAOH1BQAAAAAXqRQ1RebjO4MsRwUPJNPuuTycA5SLx4cBBBYAFIXRNTfy4mVAWjTbr6nj3aAfuCMIAAAA"; +#[test] +fn test_create_psbt() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // Receive coins + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 100, + hash: Hash::hash(b"100"), + }, + confirmation_time: 1234567000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + receive_output(&mut wallet, Amount::ONE_BTC, ReceiveTo::Block(anchor)); + + let change_desc = + miniscript::Descriptor::parse_descriptor(&secp256k1::Secp256k1::new(), change_desc) + .unwrap() + .0 + .at_derivation_index(0) + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External); + let mut params = PsbtParams::default(); + params + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]) + .change_descriptor(change_desc) + .feerate(FeeRate::from_sat_per_vb_unchecked(4)); + + let (psbt, _) = wallet.create_psbt(params).unwrap(); + assert!(psbt.fee().is_ok()); + let psbt_input = &psbt.inputs[0]; + assert_eq!( + psbt_input.witness_utxo.as_ref().map(|txo| txo.value), + Some(Amount::ONE_BTC), + ); + assert!(psbt_input.tap_internal_key.is_some()); + assert!(psbt_input + .tap_key_origins + .values() + .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b")); + assert!(psbt + .outputs + .iter() + .any(|output| output.tap_internal_key.is_some())); + assert!(psbt.outputs.iter().any(|output| output + .tap_key_origins + .values() + .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b"))); +} + +#[test] +fn test_create_psbt_cltv() { + use 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); + } + + // FIXME: Locktime greater than required + { + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .locktime(LockTime::from_consensus(200_000)) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + + // let (psbt, _) = wallet.create_psbt(params).unwrap(); + // assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), 200_000); + } +} + +#[test] +fn test_create_psbt_csv() { + use bitcoin::relative; + use bitcoin::Sequence; + + let desc = get_test_single_sig_csv(); + let mut wallet = Wallet::create_single(desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // Receive coins + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 10_000, + hash: Hash::hash(b"abc"), + }, + confirmation_time: 1234567000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let op = receive_output(&mut wallet, Amount::ONE_BTC, ReceiveTo::Block(anchor)); + + let addr = wallet.reveal_next_address(KeychainKind::External); + + // No assets fail + { + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let res = wallet.create_psbt(params); + assert!( + matches!(res, Err(CreatePsbtError::Plan(err)) if err == op), + "UTXO requires CSV but the assets are insufficient", + ); + } + + // Add assets ok + { + let mut params = PsbtParams::default(); + let rel_locktime = relative::LockTime::from_consensus(6).unwrap(); + params + .add_utxos(&[op]) + .add_assets(Assets::new().older(rel_locktime)) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let (psbt, _) = wallet.create_psbt(params).unwrap(); + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); + } + + // Add 6 confirmations (no assets) + { + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 10_005, + hash: Hash::hash(b"xyz"), + }, + confirmation_time: 1234567000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let (psbt, _) = wallet.create_psbt(params).unwrap(); + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); + } +} + +// Test that replacing two unconfirmed txs A, B results in a transaction +// that spends the inputs of both A and B. +#[test] +fn test_replace_by_fee() { + 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_unchecked(4), + 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] #[should_panic(expected = "InputIndexOutOfRange")] fn test_psbt_malformed_psbt_input_legacy() { diff --git a/tests/wallet.rs b/tests/wallet.rs index c779c0a4..6515f81d 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); + + // Query the set of unbroadcasted txs + let txs = wallet + .transactions_with_params(CanonicalizationParams { + assume_canonical: vec![txid2], + }) + .filter(|c| c.chain_position.is_unconfirmed()) + .collect::>(); + + assert_eq!(txs.len(), 2); + + assert!(txs.iter().any(|c| c.tx_node.txid == txid1)); + assert!(txs.iter().any(|c| c.tx_node.txid == txid2)); + + Ok(()) +} + #[test] fn test_error_external_and_internal_are_the_same() { // identical descriptors should fail to create wallet