diff --git a/Cargo.toml b/Cargo.toml index eed208f9..cb71fc45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] bdk_chain = { version = "0.23.1", features = ["miniscript", "serde"], default-features = false } +bdk_coin_select = { version = "0.4.1" } bitcoin = { version = "0.32.7", features = ["serde", "base64"], default-features = false } miniscript = { version = "12.3.1", features = ["serde"], default-features = false } rand_core = { version = "0.6.0" } @@ -30,6 +31,11 @@ bdk_file_store = { version = "0.21.1", optional = true } bip39 = { version = "2.0", optional = true } tempfile = { version = "3.20.0", optional = true } +[dependencies.bdk_tx] +version = "0.2.0" +git = "https://github.com/valuedmammal/bdk-tx" +branch = "release/0_2_0" + [features] default = ["std"] std = ["bitcoin/std", "bitcoin/rand-std", "miniscript/std", "bdk_chain/std"] diff --git a/examples/cpfp.rs b/examples/cpfp.rs new file mode 100644 index 00000000..8acc506c --- /dev/null +++ b/examples/cpfp.rs @@ -0,0 +1,112 @@ +use bdk_tx::Signer; +use bdk_wallet::{KeychainKind, Wallet}; +use bitcoin::{ + consensus::encode::deserialize_hex, secp256k1::Secp256k1, Amount, Network, OutPoint, ScriptBuf, + Transaction, TxOut, +}; +use miniscript::Descriptor; +use std::sync::Arc; + +const EXTERNAL: &str = "tr(tprv8ZgxMBicQKsPd3krDUsBAmtnRsK3rb8u5yi1zhQgMhF1tR8MW7xfE4rnrbbsrbPR52e7rKapu6ztw1jXveJSCGHEriUGZV7mCe88duLp5pj/86'/1'/0'/0/*)"; +const INTERNAL: &str = "tr(tprv8ZgxMBicQKsPd3krDUsBAmtnRsK3rb8u5yi1zhQgMhF1tR8MW7xfE4rnrbbsrbPR52e7rKapu6ztw1jXveJSCGHEriUGZV7mCe88duLp5pj/86'/1'/0'/1/*)"; + +const FEERATE: f32 = 10.0; + +fn main() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + let (external_desc, mut keymap) = Descriptor::parse_descriptor(&secp, EXTERNAL)?; + let (internal_desc, internal_keymap) = Descriptor::parse_descriptor(&secp, INTERNAL)?; + keymap.extend(internal_keymap); + + let mut wallet = Wallet::create(external_desc, internal_desc) + .network(Network::Regtest) + .create_wallet_no_persist()?; + + // Track balances for sanity checking + let initial_balance = wallet.balance().total(); + println!("Initial balance: {}", initial_balance); + + let tx0: Transaction = deserialize_hex( + "02000000000101401087cb611c1173462be69d8abb501edaf0e89cf086d0c88e377043fc7f6bde0000000000fdffffff02db1285270100000022512049c3c5eac192a9ee551f1a3a45bbb47c68c7c01e8d007847a44cdca20080a55f80de80020000000022512005472086085253543288c12a67aa2975f1e8e698b1f026d625238ef84abbfe2b024730440220787949255eb0af8e9f69b6e4f112a3a157c02a4498b87f5dede45eafd46405390220435c5562e86d1a2ad3f752d90d1fb877d8a207b09b5688c5d3371c201c534f9e012102cb066247461fb43246467b94f72497be4f5fa863baeca191c431648559e7efd365000000", + )?; + let tx0 = Arc::new(tx0); + let outpoint = fund_wallet(&mut wallet, tx0.clone())?; + + let funded_balance = wallet.balance().total(); + println!("Balance after funding: {} sat", funded_balance); + + let next_index = wallet.next_derivation_index(KeychainKind::External); + let definite_descriptor: Descriptor = wallet + .public_descriptor(KeychainKind::External) + .at_derivation_index(next_index)?; + + let target_feerate = bdk_coin_select::FeeRate::from_sat_per_vb(FEERATE); + + let (mut psbt, finalizer) = + wallet.create_sweep(outpoint, definite_descriptor, target_feerate)?; + + let _ = psbt.sign(&Signer(keymap), &secp).unwrap(); + let res = finalizer.finalize(&mut psbt); + assert!(res.is_finalized()); + + let tx1 = psbt.extract_tx().expect("Must be finalized!"); + assert_eq!(tx1.input.len(), 1, "Child should have 1 input"); + assert_eq!( + tx1.input[0].previous_output, outpoint, + "Should spend from parent" + ); + + wallet.apply_unconfirmed_txs([(Arc::new(tx1.clone()), 110)]); + let tx1 = Arc::new(tx1); + + compute_feerate(&wallet, &[tx0, tx1]); + + let final_balance = wallet.balance().total(); + println!("Final balance: {} sat", final_balance); + + Ok(()) +} + +/// Compute the package feerate and print it to stdout. +fn compute_feerate(wallet: &Wallet, txs: &[Arc]) { + let mut acc_fee = 0; + let mut acc_vsize = 0; + + for tx in txs { + let fee = wallet.calculate_fee(tx).unwrap().to_sat(); + let vsize = tx.vsize() as u64; + let feerate = fee as f32 / vsize as f32; + println!("Fee {fee} Vsize {vsize} FeeRate {}", feerate); + acc_fee += fee; + acc_vsize += vsize; + } + + println!("Target feerate {}", FEERATE); + println!("Package feerate {}", acc_fee as f32 / acc_vsize as f32); +} + +fn fund_wallet(wallet: &mut Wallet, tx0: Arc) -> anyhow::Result { + let txid0 = tx0.compute_txid(); + + // Previous output of tx0. This is needed for fee calculation. + let prevout = OutPoint::new( + "de6b7ffc4370378ec8d086f09ce8f0da1e50bb8a9de62b4673111c61cb871040".parse()?, + 0, + ); + let txout = TxOut { + script_pubkey: ScriptBuf::from_hex("0014ca5688311d4d0637f1c66bfd495eee02c5fe1755")?, + value: Amount::from_btc(50.0)?, + }; + wallet.insert_txout(prevout, txout); + wallet.apply_unconfirmed_txs([(tx0.clone(), 100)]); + + let outpoint = tx0 + .output + .iter() + .enumerate() + .find(|(_vout, txo)| txo.value == Amount::from_btc(0.42).unwrap()) + .map(|(vout, _)| OutPoint::new(txid0, vout as u32)) + .unwrap(); + + Ok(outpoint) +} diff --git a/examples/psbt.rs b/examples/psbt.rs new file mode 100644 index 00000000..662e997f --- /dev/null +++ b/examples/psbt.rs @@ -0,0 +1,117 @@ +#![allow(clippy::print_stdout)] + +use std::collections::HashMap; +use std::str::FromStr; + +use bdk_chain::BlockId; +use bdk_chain::ConfirmationBlockTime; +use bdk_wallet::psbt::{PsbtParams, SelectionStrategy::*}; +use bdk_wallet::test_utils::*; +use bdk_wallet::{KeychainKind::External, Wallet}; +use bitcoin::{ + bip32, consensus, + secp256k1::{self, rand}, + Address, Amount, TxIn, TxOut, +}; +use rand::Rng; + +// This example shows how to create a PSBT using BDK Wallet. + +const NETWORK: bitcoin::Network = bitcoin::Network::Signet; +const SEND_TO: &str = "tb1pw3g5qvnkryghme7pyal228ekj6vq48zc5k983lqtlr2a96n4xw0q5ejknw"; +const AMOUNT: Amount = Amount::from_sat(42_000); +const FEERATE: f64 = 2.0; // sat/vb + +fn main() -> anyhow::Result<()> { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let secp = secp256k1::Secp256k1::new(); + + // Xpriv to be used for signing the PSBT + let xprv = bip32::Xpriv::from_str("tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L")?; + + // Create wallet and fund it. + let mut wallet = Wallet::create(desc, change_desc) + .network(NETWORK) + .create_wallet_no_persist()?; + + fund_wallet(&mut wallet)?; + + let utxos = wallet + .list_unspent() + .map(|output| (output.outpoint, output)) + .collect::>(); + + // Build params. + let mut params = PsbtParams::default(); + let addr = Address::from_str(SEND_TO)?.require_network(NETWORK)?; + let feerate = feerate_unchecked(FEERATE); + params + .add_recipients([(addr, AMOUNT)]) + .fee(bdk_tx::FeeStrategy::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..d3208f42 --- /dev/null +++ b/src/psbt/params.rs @@ -0,0 +1,674 @@ +//! 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_coin_select::{ChangePolicy, DrainWeights}; +use bdk_tx::{FeeStrategy, Input, Output, ScriptSource}; +use bitcoin::{ + absolute, psbt::PsbtSighashType, transaction::Version, Amount, FeeRate, OutPoint, ScriptBuf, + Sequence, Transaction, Txid, +}; +use miniscript::plan::Assets; + +use crate::collections::HashSet; +use crate::TxOrdering; + +/// Marker type representing the PSBT creation state. +#[derive(Debug)] +pub struct CreateTx; + +/// Marker type representing the Replace-By-Fee (RBF) state. +#[derive(Debug)] +pub struct ReplaceTx; + +/// Alias for [`ReplaceTx`] context marker. +pub type Rbf = ReplaceTx; + +/// Parameters to create a PSBT. +// TODO: Can we derive `Clone` for this? +#[derive(Debug)] +pub struct PsbtParams { + /// Set of selected UTXO outpoints. + pub(crate) set: HashSet, + /// List of UTXO outpoints to spend. + pub(crate) utxos: Vec, + /// List of planned transaction [`Input`]s. + pub(crate) inputs: Vec, + /// List of recipient script/amount pairs. + pub(crate) recipients: Vec<(ScriptBuf, Amount)>, + /// Optional script or descriptor designated for change. + pub(crate) change_script: Option, + /// Optional assets for creating a spend plan. + pub(crate) assets: Option, + /// Fee targeting strategy. + pub(crate) fee_strategy: FeeStrategy, + /// Policy for creating change outputs. + pub(crate) change_policy: ChangePolicy, + /// Whether to spend all available coins. + pub(crate) drain_wallet: bool, + /// Coin selection strategy to use. + pub(crate) coin_selection: SelectionStrategy, + /// Parameters for transaction canonicalization. + pub(crate) canonical_params: CanonicalizationParams, + /// UTXO filtering function. + pub(crate) utxo_filter: UtxoFilter, + /// Optional height for evaluating coinbase maturity. + pub(crate) maturity_height: Option, + /// Only allow spending UTXOs which are selected manually. + pub(crate) manually_selected_only: bool, + /// Optional transaction [`Version`]. + pub(crate) version: Option, + /// Optional transaction [`LockTime`](absolute::LockTime). + pub(crate) locktime: Option, + /// Optional fallback [`Sequence`] for inputs. + pub(crate) fallback_sequence: Option, + /// Ordering of the transaction's inputs and outputs. + pub(crate) ordering: TxOrdering, + /// Only set the [`witness_utxo`](bitcoin::psbt::Input::witness_utxo) in PSBT inputs. This + /// allows opting out of setting the + /// [`non_witness_utxo`](bitcoin::psbt::Input::non_witness_utxo). + pub(crate) only_witness_utxo: bool, + /// Optional PSBT sighash type. + pub(crate) sighash_type: Option, + /// Whether to try filling in the PSBT global xpubs from the wallet's descriptors. + pub(crate) add_global_xpubs: bool, + /// Set of txids being replaced if this is a RBF transaction. + pub(crate) replace: HashSet, + /// The context in which the params are used. + pub(crate) marker: core::marker::PhantomData, +} + +impl Default for PsbtParams { + fn default() -> Self { + Self { + set: Default::default(), + utxos: Default::default(), + inputs: Default::default(), + assets: Default::default(), + recipients: Default::default(), + change_script: Default::default(), + fee_strategy: FeeStrategy::FeeRate(FeeRate::BROADCAST_MIN), + change_policy: ChangePolicy { + min_value: 330, + drain_weights: DrainWeights::TR_KEYSPEND, + }, + drain_wallet: Default::default(), + coin_selection: Default::default(), + canonical_params: Default::default(), + utxo_filter: Default::default(), + maturity_height: Default::default(), + manually_selected_only: Default::default(), + version: Default::default(), + locktime: Default::default(), + fallback_sequence: Default::default(), + ordering: Default::default(), + only_witness_utxo: Default::default(), + sighash_type: Default::default(), + add_global_xpubs: Default::default(), + replace: Default::default(), + marker: core::marker::PhantomData, + } + } +} + +impl PsbtParams { + /// Create a new [`PsbtParams`]. + pub fn new() -> Self { + Self::default() + } + + /// Add UTXOs by outpoint to fund the transaction. + /// + /// A single outpoint may appear at most once in the list of UTXOs to spend. The caller is + /// responsible for ensuring that items of `outpoints` correspond to outputs of previous + /// transactions and are currently unspent. + /// + /// If an outpoint doesn't correspond to an indexed script pubkey, a [`UnknownUtxo`] + /// error will occur. See [`Wallet::create_psbt`] for more. + /// + /// To add a UTXO that did not originate from this wallet (i.e. a "foreign" UTXO), see + /// [`PsbtParams::add_planned_input`]. + /// + /// [`UnknownUtxo`]: crate::wallet::error::CreatePsbtError::UnknownUtxo + /// [`Wallet::create_psbt`]: crate::Wallet::create_psbt + pub fn add_utxos(&mut self, outpoints: &[OutPoint]) -> &mut Self { + self.utxos + .extend(outpoints.iter().copied().filter(|&op| self.set.insert(op))); + self + } + + /// Replace spends of the given `txs` and return a [`PsbtParams`] 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_rng`](crate::Wallet::replace_by_fee_with_rng) to finish + /// building the PSBT. + /// + /// ## Note + /// + /// There should be no ancestry linking the elements of `txs`, since replacing an + /// ancestor necessarily invalidates the descendant. + pub fn replace_txs(self, txs: &[Arc]) -> PsbtParams { + let mut params = self.into_replace_params(); + params.replace(txs); + params + } + + /// Transition this [`PsbtParams`] to the [`Rbf`] state. + fn into_replace_params(self) -> PsbtParams { + PsbtParams { + set: self.set, + utxos: self.utxos, + inputs: self.inputs, + assets: self.assets, + recipients: self.recipients, + change_script: self.change_script, + fee_strategy: self.fee_strategy, + change_policy: self.change_policy, + drain_wallet: self.drain_wallet, + coin_selection: self.coin_selection, + canonical_params: self.canonical_params, + utxo_filter: self.utxo_filter, + maturity_height: self.maturity_height, + manually_selected_only: self.manually_selected_only, + version: self.version, + locktime: self.locktime, + fallback_sequence: self.fallback_sequence, + ordering: self.ordering, + only_witness_utxo: self.only_witness_utxo, + sighash_type: self.sighash_type, + add_global_xpubs: self.add_global_xpubs, + replace: self.replace, + marker: core::marker::PhantomData, + } + } +} + +impl PsbtParams { + /// Get the currently selected spends. + pub fn utxos(&self) -> &HashSet { + &self.set + } + + /// Remove a UTXO from the currently selected inputs. + pub fn remove_utxo(&mut self, outpoint: &OutPoint) -> &mut Self { + if self.set.remove(outpoint) { + self.utxos.retain(|op| op != outpoint); + } + self + } + + /// Only include inputs that are selected manually using [`add_utxos`] or [`add_planned_input`]. + /// + /// Since the wallet will skip coin selection for additional candidates, the manually selected + /// inputs must be enough to fund the transaction or else an error will be thrown due to + /// insufficient funds. + /// + /// [`add_utxos`]: PsbtParams::add_utxos + /// [`add_planned_input`]: PsbtParams::add_planned_input + pub fn manually_selected_only(&mut self) -> &mut Self { + self.manually_selected_only = true; + self + } + + /// Add the spend [`Assets`]. + /// + /// Assets are required to create a spending plan for an output controlled by the wallet's + /// descriptors. If none are provided here, then we assume all of the keys are equally likely + /// to sign. + /// + /// This may be called multiple times to add additional assets, however only the last + /// absolute or relative timelock is retained. 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 outgoing recipients to the transaction. + /// + /// - `recipients`: An iterator of `(S, Amount)` tuples where `S` can be a [`bitcoin::Address`], + /// a script pubkey, or anything that can be converted straight into a [`ScriptBuf`]. + pub fn add_recipients(&mut self, recipients: I) -> &mut Self + where + I: IntoIterator, + S: Into, + { + self.recipients + .extend(recipients.into_iter().map(|(s, amt)| (s.into(), amt))); + self + } + + /// Set the transaction `nLockTime`. + /// + /// This can be used as a fallback in case none of the inputs to the transaction require an + /// absolute locktime. If no locktime is required and nothing is specified here, then the + /// locktime is set to the last known chain tip. + pub fn locktime(&mut self, locktime: absolute::LockTime) -> &mut Self { + self.locktime = Some(locktime); + self + } + + /// Set the height to be used when evaluating the maturity of coinbase outputs during coin + /// selection. + pub fn maturity_height(&mut self, height: absolute::Height) -> &mut Self { + self.maturity_height = Some(height.to_consensus_u32()); + self + } + + /// Set the fee targeting strategy. See [`FeeStrategy`] for more. + /// + /// If not set, defaults to [`FeeRate::BROADCAST_MIN`]. + pub fn fee(&mut self, fee: FeeStrategy) -> &mut Self { + self.fee_strategy = fee; + 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`]: ScriptSource::Descriptor + /// [`Script`]: ScriptSource::Script + pub fn change_script(&mut self, script_source: ScriptSource) -> &mut Self { + self.change_script = Some(script_source); + self + } + + /// Set the policy to be used when considering whether a change output should be added to the + /// transaction. + /// + /// If not set, the wallet will use a change policy that makes sense for most single-signature + /// wallets. It's highly recommended to set your own [`ChangePolicy`] if you expect to build + /// transactions with change characteristics that signifcantly differ from the default. + /// + /// Things to consider when specifying a change policy include: + /// + /// - The minimum allowable value of a change output + /// - The [`Weight`] of a change output + /// - The [`Weight`] needed to spend the change in the future + /// + /// [`Weight`]: bitcoin::Weight + pub fn change_policy(&mut self, change_policy: ChangePolicy) -> &mut Self { + self.change_policy = change_policy; + self + } + + /// Filter [`FullTxOut`]s by the provided closure. + /// + /// This option can be used to mark specific outputs unspendable or apply custom UTXO + /// filtering logic. + /// + /// Any txouts for which the `predicate` returns `false` will be excluded from coin selection, + /// otherwise any coin in the wallet that is mature and spendable will be eligible for + /// selection. + pub fn filter_utxos(&mut self, predicate: F) -> &mut Self + where + F: Fn(&FullTxOut) -> bool + Send + Sync + 'static, + { + self.utxo_filter = UtxoFilter(Arc::new(predicate)); + self + } + + /// Set the [`TxOrdering`] for inputs and outputs of the PSBT. + /// + /// If not set here, the default ordering is to [`Shuffle`] all inputs and outputs. + /// + /// Set to [`Untouched`] to preserve the order of UTXOs and recipients in the manner in which + /// they are added to the params. If additional inputs are required that aren't manually + /// selected, their order will be determined by the [`SelectionStrategy`]. Refer to + /// [`TxOrdering`] for more. + /// + /// [`Shuffle`]: TxOrdering::Shuffle + /// [`Untouched`]: TxOrdering::Untouched + pub fn ordering(&mut self, ordering: TxOrdering) -> &mut Self { + self.ordering = ordering; + self + } + + /// Add a planned input. + /// + /// This can be used to add inputs that come with a [`Plan`] or [`psbt::Input`] provided. + /// See [`Input`] for more on how to create inputs manually. Be aware that creating inputs + /// in this manner relies on certain assumptions, like the UTXO validity, the satisfaction + /// weight, and so on. As such you should only use this method to add inputs you definitely + /// trust the values for. + /// + /// # Example + /// + /// ```rust,no_run + /// use bdk_tx::Input; + /// # use bdk_wallet::psbt::PsbtParams; + /// # use bitcoin::{psbt, OutPoint, Sequence, TxOut}; + /// # let outpoint = OutPoint::null(); + /// # let sequence = Sequence::ENABLE_LOCKTIME_NO_RBF; + /// # let psbt_input = psbt::Input::default(); + /// # let satisfaction_weight = 0; + /// # let tx_status = None; + /// # let is_coinbase = false; + /// let mut params = PsbtParams::default(); + /// let input = Input::from_psbt_input( + /// outpoint, + /// sequence, + /// psbt_input, + /// satisfaction_weight, + /// tx_status, + /// is_coinbase, + /// )?; + /// params.add_planned_input(input); + /// # Ok::<_, anyhow::Error>(()) + /// ``` + /// + /// [`Plan`]: miniscript::plan::Plan + /// [`psbt::Input`]: bitcoin::psbt::Input + pub fn add_planned_input(&mut self, input: Input) -> &mut Self { + if self.set.insert(input.prev_outpoint()) { + self.inputs.push(input); + } + self + } + + /// Only fill in the [`witness_utxo`] field of PSBT inputs which spends funds under segwit (v0). + /// + /// This allows opting out of including the [`non_witness_utxo`] for segwit spends. This reduces + /// the size of the PSBT, however be aware that some signers might require the presence of the + /// `non_witness_utxo`. + /// + /// [`witness_utxo`]: bitcoin::psbt::Input::witness_utxo + /// [`non_witness_utxo`]: bitcoin::psbt::Input::non_witness_utxo + pub fn only_witness_utxo(&mut self) -> &mut Self { + self.only_witness_utxo = true; + self + } + + /// Drain wallet. + /// + /// This will force selection of the available input candidates. As such, the option is only + /// applied to inputs that meet the spending criteria. + pub fn drain_wallet(&mut self) -> &mut Self { + self.drain_wallet = true; + self + } + + /// Set the transaction [`Version`]. + pub fn version(&mut self, version: Version) -> &mut Self { + self.version = Some(version); + self + } + + /// Set the [`Sequence`] value to be used as a fallback if not specified by the input. + pub fn fallback_sequence(&mut self, sequence: Sequence) -> &mut Self { + self.fallback_sequence = Some(sequence); + self + } + + /// Set a specific [`PsbtSighashType`]. + pub fn sighash_type(&mut self, sighash_type: PsbtSighashType) -> &mut Self { + self.sighash_type = Some(sighash_type); + self + } + + /// Fill in the global [`Psbt::xpub`]s field with the extended keys of the wallet's + /// descriptors. + /// + /// Some offline signers and/or multisig wallets may require this. + /// + /// [`Psbt::xpub`]: bitcoin::Psbt::xpub + pub fn add_global_xpubs(&mut self) -> &mut Self { + self.add_global_xpubs = true; + self + } +} + +/// 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 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 that `replace_txs` maintains the expected params. + #[test] + fn test_replace_params() { + use crate::KeychainKind::Internal; + let (wallet, txid0) = crate::test_utils::get_funded_wallet_wpkh(); + let outpoint_0 = OutPoint::new(txid0, 0); + let change_script = wallet.spk_index().spk_at_index(Internal, 0).unwrap(); + + // Create psbt + let mut params = PsbtParams::default(); + params.change_script(ScriptSource::Script(change_script)); + 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_unchecked(8); + params.fee(FeeStrategy::FeeRate(feerate)); + + // Get utxos + assert_eq!(params.utxos(), &[outpoint_0].into()); + + assert_eq!(params.replace, [txid1].into()); + assert!(matches!( + params.fee_strategy, + FeeStrategy::FeeRate(r) if r == 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 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)); + } +} diff --git a/src/test_utils.rs b/src/test_utils.rs index 11fd13b1..eb66ae48 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -317,6 +317,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/src/wallet/error.rs b/src/wallet/error.rs index 47be69df..dfe1a893 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -15,9 +15,11 @@ use crate::descriptor::policy::PolicyError; use crate::descriptor::DescriptorError; use crate::wallet::coin_selection; use crate::{descriptor, KeychainKind}; -use alloc::string::String; +use alloc::string::{String, ToString}; use bitcoin::{absolute, psbt, Amount, OutPoint, Sequence, Txid}; +use chain::tx_graph::CalculateFeeError; use core::fmt; +use core::num::TryFromIntError; /// Errors returned by miniscript when updating inconsistent PSBTs #[derive(Debug, Clone)] @@ -256,3 +258,152 @@ 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 spending plan for a manually selected output. + Plan(OutPoint), + /// Failed to create PSBT. + // TODO(@valuedmammal): `Box` shouldn't be needed once we can depend on `bdk_tx` 0.2.0. + Psbt(alloc::boxed::Box), + /// 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 CPFP sweep transaction. +#[derive(Debug)] +#[non_exhaustive] +pub enum CreateSweepError { + /// The UTXO of the outpoint could not be found. + UnknownUtxo(OutPoint), + /// The parent transaction is already confirmed and cannot be accelerated with CPFP. + ParentAlreadyConfirmed(OutPoint), + /// The output value is insufficient to pay the required child fee. + InsufficientValue { + /// Amount still needed (required fee - available value). + missing_amount: u64, + }, + /// Failed to create PSBT. + Psbt(CreatePsbtError), + /// Descriptor key conversion error + Conversion(miniscript::descriptor::ConversionError), + /// Descriptor key conversion error + Miniscript(miniscript::Error), + /// Failed to calculate the fee + FeeCalculationError(String), +} + +impl fmt::Display for CreateSweepError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownUtxo(op) => write!(f, "unknown UTXO: {op}"), + Self::ParentAlreadyConfirmed(op) => { + write!(f, "parent transaction output {op} is already confirmed") + } + Self::InsufficientValue { missing_amount } => { + write!( + f, + "insufficient value: need additional {missing_amount} sats" + ) + } + Self::Psbt(e) => write!(f, "Failed to create PSBT: {e}"), + Self::FeeCalculationError(e) => write!(f, "Fee calculation failed: {e}"), + Self::Conversion(err) => write!(f, "Conversion error: {err}"), + Self::Miniscript(err) => write!(f, "Miniscript error: {err}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for CreateSweepError {} + +impl From for CreateSweepError { + fn from(e: CreatePsbtError) -> Self { + Self::Psbt(e) + } +} + +impl From for CreateSweepError { + fn from(e: miniscript::descriptor::ConversionError) -> Self { + CreateSweepError::Conversion(e) + } +} + +impl From for CreateSweepError { + fn from(e: CalculateFeeError) -> Self { + CreateSweepError::FeeCalculationError(e.to_string()) + } +} + +impl From for CreateSweepError { + fn from(e: TryFromIntError) -> Self { + CreateSweepError::FeeCalculationError(e.to_string()) + } +} + +impl From for CreateSweepError { + fn from(e: miniscript::Error) -> Self { + CreateSweepError::Miniscript(e) + } +} + +/// Error when creating a Replace-By-Fee transaction. +#[derive(Debug)] +#[non_exhaustive] +pub enum ReplaceByFeeError { + /// There was a problem creating the PSBT + CreatePsbt(CreatePsbtError), + /// Failed to compute the fee of an original transaction + PreviousFee(bdk_chain::tx_graph::CalculateFeeError), + /// Original transaction could not be found + MissingTransaction(Txid), +} + +impl fmt::Display for ReplaceByFeeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CreatePsbt(e) => write!(f, "{e}"), + Self::PreviousFee(e) => write!(f, "{e}"), + Self::MissingTransaction(txid) => write!(f, "missing transaction: {txid}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for ReplaceByFeeError {} + +impl From for ReplaceByFeeError { + fn from(e: CreatePsbtError) -> Self { + Self::CreatePsbt(e) + } +} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index ab0a8ff6..2264f91b 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -19,6 +19,10 @@ use alloc::{ sync::Arc, vec::Vec, }; +use bdk_coin_select::{ + Candidate, ChangePolicy, CoinSelector, DrainWeights, Target, TargetFee, TargetOutputs, + TXIN_BASE_WEIGHT, +}; use core::{cmp::Ordering, fmt, mem, ops::Deref}; use bdk_chain::{ @@ -30,14 +34,21 @@ 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, DefiniteDescriptor, FeeStrategy, Finalizer, Input, + InputCandidates, OriginalTxStats, Output, RbfParams, ScriptSource, 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, NetworkKind, OutPoint, Psbt, @@ -45,7 +56,9 @@ use bitcoin::{ }; use miniscript::{ descriptor::KeyMap, + plan::{Assets, Plan}, psbt::{PsbtExt, PsbtInputExt, PsbtInputSatisfier}, + ForEachKey, }; use rand_core::RngCore; @@ -60,29 +73,36 @@ pub mod signer; pub mod tx_builder; pub(crate) mod utils; -use crate::collections::{BTreeMap, HashMap, HashSet}; use crate::descriptor::{ check_wallet_descriptor, error::Error as DescriptorError, 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}, utils::{check_nsequence_rbf, After, Older, SecpCtx}, }; +use crate::{ + collections::{BTreeMap, HashMap, HashSet}, + error::CreateSweepError, +}; // re-exports 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`] with associated keychain and derivation index. +type IndexedTxOut = KeychainIndexed>; /// A Bitcoin wallet /// @@ -920,6 +940,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` @@ -1207,14 +1240,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)) } @@ -2519,6 +2562,29 @@ impl Wallet { Ok(()) } + /// 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 won'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 (SPK), + /// 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>, + { + 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. @@ -2612,6 +2678,286 @@ impl Wallet { keychain } } + + /// Creates a Child-Pays-For-Parent (CPFP) transaction to accelerate an unconfirmed parent + /// transaction. + /// + /// This function constructs a transaction that spends from an output of an unconfirmed parent + /// transaction, paying a higher fee rate to incentivize miners to include both the parent and + /// child transactions in a block together. + /// + /// # Arguments + /// + /// * `outpoint` - The outpoint of the parent transaction output to spend + /// * `drain_script` - The descriptor for the change/drain output + /// * `target_feerate` - The desired fee rate for the combined parent+child package + /// + /// # Returns + /// + /// Returns a tuple of `(Psbt, Finalizer)` representing the unsigned child transaction and + /// its finalizer, or a [`CreateSweepError`] if the sweep cannot be created. + /// + /// # Errors + /// + /// This function will return an error if: + /// - The parent transaction cannot be found + /// - The specified output doesn't exist or is already confirmed + /// - The parent transaction fee cannot be calculated + /// - The child would need to pay more in fees than the output value + /// - PSBT creation fails + /// + /// # Example + /// + /// ```rust,no_run + /// # use bdk_wallet::*; + /// # use bitcoin::OutPoint; + /// # fn example(wallet: &Wallet, outpoint: OutPoint) -> Result<(), Box> { + /// let next_index = wallet.next_derivation_index(KeychainKind::External); + /// let drain_descriptor = wallet.public_descriptor(KeychainKind::External).at_derivation_index(next_index)?; + /// let target_feerate = bdk_coin_select::FeeRate::from_sat_per_vb(10.0); + /// + /// let (psbt, finalizer) = wallet.create_sweep( + /// outpoint, + /// drain_descriptor.clone(), + /// target_feerate + /// )?; + /// # Ok(()) + /// # } + /// ``` + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn create_sweep( + &self, + outpoint: OutPoint, + drain_script: DefiniteDescriptor, + target_feerate: bdk_coin_select::FeeRate, + ) -> Result<(Psbt, Finalizer), CreateSweepError> { + let params = self.create_sweep_internal(outpoint, drain_script, target_feerate)?; + + self.create_psbt(params).map_err(CreateSweepError::Psbt) + } + + /// Creates a Child-Pays-For-Parent (CPFP) transaction with a custom RNG. + /// + /// This is the `no_std` compatible version of [`create_sweep`](Self::create_sweep). + /// It allows you to provide your own random number generator. + /// + /// # Arguments + /// + /// * `outpoint` - The outpoint of the parent transaction output to spend + /// * `drain_script` - The descriptor for the change/drain output + /// * `target_feerate` - The desired fee rate for the combined parent+child package + /// * `rng` - Random number generator for input/output shuffling + /// + /// # Returns + /// + /// Returns a tuple of `(Psbt, Finalizer)` representing the unsigned child transaction. + /// + /// # Errors + /// + /// Returns [`CreateSweepError`] if the sweep cannot be created (see + /// [`create_sweep`](Self::create_sweep)). + /// + /// # Example + /// + /// ```rust,no_run + /// # use bdk_wallet::*; + /// # use bitcoin::OutPoint; + /// # use rand_core::OsRng; + /// # fn example(wallet: &Wallet, outpoint: OutPoint) -> Result<(), Box> { + /// let next_index = wallet.next_derivation_index(KeychainKind::External); + /// let drain_descriptor = wallet + /// .public_descriptor(KeychainKind::External) + /// .at_derivation_index(next_index)?; + /// let target_feerate = bdk_coin_select::FeeRate::from_sat_per_vb(10.0); + /// + /// let (psbt, finalizer) = wallet.create_sweep_with_rng( + /// outpoint, + /// drain_descriptor, + /// target_feerate, + /// &mut OsRng + /// )?; + /// # Ok(()) + /// # } + /// ``` + pub fn create_sweep_with_rng( + &self, + outpoint: OutPoint, + drain_script: DefiniteDescriptor, + target_feerate: bdk_coin_select::FeeRate, + rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), CreateSweepError> { + let params = self.create_sweep_internal(outpoint, drain_script, target_feerate)?; + self.create_psbt_with_rng(params, rng) + .map_err(CreateSweepError::Psbt) + } + + /// Internal helper that calculates CPFP parameters and returns PSBT parameters. + /// + /// This function contains the core CPFP logic: + /// 1. Validates the parent transaction is unconfirmed + /// 2. Calculates parent and child candidates for coin selection + /// 3. Determines the required child fee to achieve target package feerate + /// 4. Returns configured PSBT parameters + /// + /// Used by both [`create_sweep`](Self::create_sweep) and + /// [`create_sweep_with_rng`](Self::create_sweep_with_rng) + fn create_sweep_internal( + &self, + outpoint: OutPoint, + drain_script: DefiniteDescriptor, + target_feerate: bdk_coin_select::FeeRate, + ) -> Result, CreateSweepError> { + // Retrieve the parent transaction + let tx = self + .get_tx(outpoint.txid) + .ok_or(CreateSweepError::UnknownUtxo(outpoint))? + .tx_node + .tx; + + let utxo = self + .get_utxo(outpoint) + .ok_or(CreateSweepError::UnknownUtxo(outpoint))?; + + // Verify parent is unconfirmed + if utxo.chain_position.is_confirmed() { + return Err(CreateSweepError::ParentAlreadyConfirmed(outpoint)); + } + + let parent_fee = self + .calculate_fee(&tx) + .map_err(|e| CreateSweepError::FeeCalculationError(e.to_string()))? + .to_sat(); + + let input_count = tx.input.len(); + let output_count = tx.output.len(); + + let input_weight = tx + .input + .iter() + .map(|txin| txin.segwit_weight().to_wu()) + .sum::(); + + let output_value = tx + .output + .iter() + .map(|txout| txout.value.to_sat()) + .sum::(); + + let parent_input_value = output_value + parent_fee; + let parent_is_segwit = tx.input.iter().any(|txin| !txin.witness.is_empty()); + + // Create a candidate representing the parent transaction + let parent_candidate = Candidate { + value: parent_input_value, + weight: input_weight, + input_count, + is_segwit: parent_is_segwit, + }; + + let (keychain, index) = self + .indexed_graph + .index + .txout(outpoint) + .ok_or(CreateSweepError::UnknownUtxo(outpoint))? + .0; + let satisfaction_weight = self + .public_descriptor(keychain) + .at_derivation_index(index)? + .max_weight_to_satisfy()? + .to_wu(); + + let child_spend_weight: u64 = TXIN_BASE_WEIGHT + satisfaction_weight; + let child_is_segwit = utxo.txout.script_pubkey.is_witness_program(); + + // Create a candidate representing the child input + let child_candidate = Candidate { + weight: child_spend_weight, + value: utxo.txout.value.to_sat(), + input_count: 1, + is_segwit: child_is_segwit, + }; + + // Combine both candidates for the coin selector + let candidates = vec![parent_candidate, child_candidate]; + let mut selector = CoinSelector::new(&candidates); + selector.select_all(); + + let parent_output_value: u64 = tx.output.iter().map(|txout| txout.value.to_sat()).sum(); + let parent_output_weight: u64 = tx.output.iter().map(|txout| txout.weight().to_wu()).sum(); + + let target = Target { + fee: TargetFee { + rate: target_feerate, + replace: None, + }, + outputs: TargetOutputs { + value_sum: parent_output_value, + weight_sum: parent_output_weight, + n_outputs: output_count, + }, + }; + + // Calculate the drain (change output) + let drain = selector.drain(target, self.change_policy()); + + let total_fee = selector.fee(target.outputs.value_sum, drain.value); + + let child_fee = u64::try_from(total_fee) + .map_err(|e| CreateSweepError::FeeCalculationError(e.to_string()))? + .saturating_sub(parent_fee); + let available_value = utxo.txout.value.to_sat(); + if child_fee >= available_value { + let missing_amount = child_fee - available_value; + + return Err(CreateSweepError::InsufficientValue { missing_amount }); + } + + // Build the PSBT parameters + let mut params: PsbtParams = PsbtParams::default(); + params + .add_utxos(&[outpoint]) + .manually_selected_only() + .change_script(ScriptSource::from_script(drain_script.script_pubkey())) + .fee(FeeStrategy::AbsoluteFee(Amount::from_sat(child_fee))); + + Ok(params) + } + + /// Returns the default [`ChangePolicy`] used when creating change outputs + pub fn change_policy(&self) -> ChangePolicy { + let spk_0 = self + .indexed_graph + .index + .spk_at_index(KeychainKind::Internal, 0) + .expect("spk should exist in wallet"); + + ChangePolicy { + min_value: spk_0.minimal_non_dust().to_sat(), + drain_weights: self.change_weight(), + } + } + + /// Return the [`DrainWeights`] of an output controlled by this wallet. + fn change_weight(&self) -> DrainWeights { + let desc = self + .public_descriptor(KeychainKind::Internal) + .at_derivation_index(0) + .unwrap(); + let output_weight = bitcoin::TxOut { + script_pubkey: desc.script_pubkey(), + value: Amount::ZERO, + } + .weight() + .to_wu(); + let spend_weight = desc.max_weight_to_satisfy().unwrap().to_wu(); + + DrainWeights { + output_weight, + spend_weight, + n_outputs: 1, + } + } } /// Methods to construct sync/full-scan requests for spk-based chain sources. @@ -2689,6 +3035,582 @@ 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, + ScriptSource, + 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_script.clone().unwrap_or_else(|| { + let change_keychain = self.map_keychain(KeychainKind::Internal); + let descriptor = self.public_descriptor(change_keychain); + let next_index = self.next_derivation_index(change_keychain); + let definite_descriptor = descriptor + .at_derivation_index(next_index) + .expect("should be valid derivation index"); + ScriptSource::from_descriptor(definite_descriptor) + }); + + // 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. + /// + /// - `policy`: Closure indicating whether the output should be kept, used by some callers to + /// apply additional filters as in the case of RBF. + fn filter_spendable<'a, I, C, F>( + &'a self, + txos: I, + params: &'a PsbtParams, + policy: F, + ) -> impl Iterator> + 'a + where + I: IntoIterator> + 'a, + F: Fn(&FullTxOut) -> bool + 'a, + { + let current_height = params.maturity_height.unwrap_or(self.chain.tip().height()); + txos.into_iter().filter(move |txo| { + // Exclude outputs that are manually selected. + if params.utxos.contains(&txo.outpoint) { + return false; + } + // Filter outputs according to `policy` fn. + if !policy(txo) { + return false; + } + // Exclude 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`]. + /// + /// 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_tx::FeeStrategy; + /// # 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) + /// .fee(FeeStrategy::FeeRate(FeeRate::BROADCAST_MIN)); + /// + /// let (psbt, finalizer) = wallet.create_psbt(params)?; + /// # Ok::<_, anyhow::Error>(()) + /// ``` + /// + /// # Errors + /// + /// A [`CreatePsbtError`] will be thrown if any of the following occurs + /// + /// - A manually selected input is missing from the wallet, or could not be planned + /// - The input value is insufficient to fund the outputs + /// - Failure to complete coin selection + /// - Failure to create or update the PSBT. + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn create_psbt( + &self, + params: PsbtParams, + ) -> Result<(Psbt, Finalizer), CreatePsbtError> { + self.create_psbt_with_rng(params, &mut rand::thread_rng()) + } + + /// Creates a PSBT with the given `params` and random number generator (RNG). + /// + /// Return the updated [`Psbt`] and [`Finalizer`]. + /// + /// ## Parameters: + /// + /// - `params`: [`PsbtParams`] + /// - `rng`: Source of entropy, may be used during coin selection and to sort inputs and outputs + /// by the [`TxOrdering`](crate::wallet::tx_builder::TxOrdering). + pub fn create_psbt_with_rng( + &self, + 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 { + fee_strategy: params.fee_strategy.clone(), + target_outputs, + change_script, + change_policy: params.change_policy, + 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; + /// Longterm feerate + const LONGTERM_FEERATE: FeeRate = FeeRate::from_sat_per_vb_unchecked(2); + + // 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( + 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_rng(&mut selection.inputs, &mut selection.outputs, rng); + + let version = params.version.unwrap_or(transaction::Version::TWO); + let fallback_locktime = params + .locktime + .unwrap_or(absolute::LockTime::from_consensus( + self.chain.tip().height(), + )); + let fallback_sequence = params + .fallback_sequence + .unwrap_or(Sequence::ENABLE_LOCKTIME_NO_RBF); + + // Create psbt + let mut psbt = selection + .create_psbt(bdk_tx::PsbtParams { + version, + fallback_locktime, + fallback_sequence, + mandate_full_tx_for_segwit_v0: !params.only_witness_utxo, + sighash_type: params.sighash_type, + }) + .map_err(|e| CreatePsbtError::Psbt(Box::new(e)))?; + + // 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 [`Wallet::replace_by_fee_with_rng`]. 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> { + let params = PsbtParams { + fee_strategy: bdk_tx::FeeStrategy::FeeRate(feerate), + recipients, + ..Default::default() + } + .replace_txs(txs); + self.replace_by_fee_with_rng(params, &mut rand::thread_rng()) + } + + /// Creates a Replace-By-Fee transaction (RBF) and returns the updated [`Psbt`] and + /// [`Finalizer`]. + /// + /// This function uses the thread-local random number generator (RNG) to generate + /// randomness. To supply your own source of entropy see [`Wallet::replace_by_fee_with_rng`]. + /// + /// # Errors + /// + /// A [`ReplaceByFeeError`] will be thrown if any of the following occurs + /// + /// - An original transaction is missing from the wallet + /// - Failure to calculate the [fee](Wallet::calculate_fee) of an original transaction + /// - Failure to complete coin selection + /// - Failure to create or update the PSBT. + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn replace_by_fee( + &self, + params: PsbtParams, + ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { + self.replace_by_fee_with_rng(params, &mut rand::thread_rng()) + } + + /// Creates a Replace-By-Fee transaction (RBF) and returns the updated [`Psbt`] and + /// [`Finalizer`]. + /// + /// ## Parameters: + /// + /// - `params`: [`ReplaceParams`] + /// - `rng`: Source of entropy, may be used during coin selection and to sort inputs and outputs + /// by the [`TxOrdering`](crate::wallet::tx_builder::TxOrdering). + pub fn replace_by_fee_with_rng( + &self, + params: PsbtParams, + rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), ReplaceByFeeError> { + let PsbtParams { + replace: txids_to_replace, + .. + } = ¶ms; + // Txs and their descendants to be replaced. This is used to filter outputs that can't + // be selected. + let mut to_replace = txids_to_replace.clone(); + for txid in txids_to_replace.iter().copied() { + to_replace.extend( + self.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| { + // To be included for coin selection the UTXO + // - must not exist in `to_replace` + // - must be confirmed (per replacement policy Rule 2) + // - must pass a user-defined filter + !to_replace.contains(&txo.outpoint.txid) + && txo.chain_position.is_confirmed() + && (params.utxo_filter.0)(txo) + }) + .flat_map(|txo| self.plan_input(&txo, &assets)) + .collect() + }; + + utils::shuffle_slice(&mut may_spend, rng); + + let target_outputs = self.target_outputs(¶ms); + + let input_candidates = InputCandidates::new(must_spend, may_spend); + if input_candidates.inputs().next().is_none() { + let target_amount: Amount = target_outputs.iter().map(|output| output.value).sum(); + let err = bdk_coin_select::InsufficientFunds { + missing: target_amount.to_sat(), + }; + return Err(CreatePsbtError::InsufficientFunds(err))?; + } + + let original_txs: Vec = txids_to_replace + .iter() + .map(|&txid| -> Result<_, ReplaceByFeeError> { + let tx = self + .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 { + fee_strategy: params.fee_strategy.clone(), + target_outputs, + change_script, + change_policy: params.change_policy, + 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() @@ -2812,7 +3734,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::*; @@ -2853,6 +3775,88 @@ mod test { use crate::miniscript::Error::Unexpected; use crate::test_utils::get_test_tr_single_sig_xprv_and_change_desc; use crate::test_utils::insert_tx; + use bitcoin::hashes::Hash; + + //////////////////// + /// TEST HELPERS /// + /// //////////////// + fn setup_wallet() -> Wallet { + let descriptor = "wpkh(tprv8ZgxMBicQKsPe73PBRSmNbTfbcsZnwWhz5eVmhHpi31HW29Z7mc9B4cWGRQzopNUzZUT391DeDJxL2PefNunWyLgqCKRMDkU1s2s8bAfoSk/84'/1'/0'/0/*)"; + let change_descriptor = "wpkh(tprv8ZgxMBicQKsPe73PBRSmNbTfbcsZnwWhz5eVmhHpi31HW29Z7mc9B4cWGRQzopNUzZUT391DeDJxL2PefNunWyLgqCKRMDkU1s2s8bAfoSk/84'/1'/0'/1/*)"; + + Wallet::create(descriptor, change_descriptor) + .network(Network::Testnet) + .create_wallet_no_persist() + .unwrap() + } + + fn setup_drain_script(wallet: &Wallet) -> DefiniteDescriptor { + let next_index = wallet.next_derivation_index(KeychainKind::External); + wallet + .public_descriptor(KeychainKind::External) + .at_derivation_index(next_index) + .unwrap() + } + + fn create_test_block( + prev_hash: bitcoin::BlockHash, + txdata: Vec, + time: u32, + ) -> bitcoin::Block { + bitcoin::Block { + header: bitcoin::block::Header { + version: bitcoin::block::Version::ONE, + prev_blockhash: prev_hash, + merkle_root: bitcoin::TxMerkleNode::all_zeros(), + time, + bits: bitcoin::CompactTarget::from_consensus(0x1d00ffff), + nonce: 0, + }, + txdata, + } + } + + fn create_unconfirmed_parent_tx( + wallet: &mut Wallet, + parent_input_value: u64, + parent_output_value: u64, + ) -> (Transaction, OutPoint) { + let address = wallet.reveal_next_address(KeychainKind::External); + + let parent_input = OutPoint { + txid: bitcoin::Txid::from_slice(&[0xab; 32]).unwrap(), + vout: 0, + }; + + let parent_input_txout = TxOut { + script_pubkey: ScriptBuf::from_hex("0014ca5688311d4d0637f1c66bfd495eee02c5fe1755") + .unwrap(), + value: Amount::from_sat(parent_input_value), + }; + wallet.insert_txout(parent_input, parent_input_txout); + + let parent_tx = Transaction { + version: transaction::Version::TWO, + lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: parent_input, + script_sig: ScriptBuf::new(), + sequence: bitcoin::Sequence::MAX, + witness: Witness::from_slice(&[&[0u8; 72][..], &[0u8; 33][..]]), + }], + output: vec![TxOut { + value: Amount::from_sat(parent_output_value), + script_pubkey: address.script_pubkey(), + }], + }; + + let parent_outpoint = OutPoint { + txid: parent_tx.compute_txid(), + vout: 0, + }; + + (parent_tx, parent_outpoint) + } #[test] fn not_duplicated_utxos_across_optional_and_required() { @@ -2974,4 +3978,78 @@ mod test { let wallet = params.network(Network::Testnet).create_wallet_no_persist(); assert!(wallet.is_err()); } + + #[test] + fn test_create_sweep_with_unknown_utxo() { + let wallet = setup_wallet(); + let drain_script = setup_drain_script(&wallet); + let target_feerate = bdk_coin_select::FeeRate::from_sat_per_vb(5.0); + + let nonexistent_outpoint = OutPoint { + txid: bitcoin::Txid::from_slice(&[3u8; 32]).unwrap(), + vout: 0, + }; + + let result = wallet.create_sweep(nonexistent_outpoint, drain_script, target_feerate); + assert!(matches!(result, Err(CreateSweepError::UnknownUtxo(_)))); + } + + #[test] + fn test_create_sweep_successful() { + let mut wallet = setup_wallet(); + let drain_script = setup_drain_script(&wallet); + let target_feerate = bdk_coin_select::FeeRate::from_sat_per_vb(5.0); + + let (parent_tx, parent_outpoint) = + create_unconfirmed_parent_tx(&mut wallet, 100_500, 100_000); + wallet.apply_unconfirmed_txs([(parent_tx, 100)]); + + let result = wallet.create_sweep(parent_outpoint, drain_script, target_feerate); + assert!(result.is_ok()); + + let (psbt, _finalizer) = result.unwrap(); + assert_eq!(psbt.unsigned_tx.input.len(), 1); + assert_eq!(psbt.unsigned_tx.input[0].previous_output, parent_outpoint); + assert_eq!(psbt.unsigned_tx.output.len(), 1,); + } + + #[test] + fn test_create_sweep_with_parent_already_confirmed() { + let mut wallet = setup_wallet(); + let drain_script = setup_drain_script(&wallet); + let target_feerate = bdk_coin_select::FeeRate::from_sat_per_vb(5.0); + + let (parent_tx, parent_outpoint) = + create_unconfirmed_parent_tx(&mut wallet, 100_500, 100_000); + + let genesis_block = create_test_block(bitcoin::BlockHash::all_zeros(), vec![], 1234567890); + // Apply genesis at height 0 + wallet.apply_block(&genesis_block, 0).unwrap(); + let block = create_test_block(genesis_block.block_hash(), vec![parent_tx], 1234567891); + // Apply block at height 1 (makes transaction confirmed) + wallet.apply_block(&block, 1).unwrap(); + + let result = wallet.create_sweep(parent_outpoint, drain_script, target_feerate); + + assert!(matches!( + result, + Err(CreateSweepError::ParentAlreadyConfirmed(_)) + ),); + } + + #[test] + fn test_create_sweep_with_insufficient_value() { + let mut wallet = setup_wallet(); + let drain_script = setup_drain_script(&wallet); + let target_feerate = bdk_coin_select::FeeRate::from_sat_per_vb(10.0); + + let (parent_tx, parent_outpoint) = create_unconfirmed_parent_tx(&mut wallet, 1_000, 500); + wallet.apply_unconfirmed_txs([(parent_tx, 100)]); + + let result = wallet.create_sweep(parent_outpoint, drain_script, target_feerate); + assert!(matches!( + result, + Err(CreateSweepError::InsufficientValue { .. }) + )); + } } diff --git a/src/wallet/tx_builder.rs b/src/wallet/tx_builder.rs index f3bfe3f9..831a9d63 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_rng(&self, input: &mut [I], output: &mut [O], rng: &mut impl RngCore) { + match self { + TxOrdering::Untouched => {} + TxOrdering::Shuffle => { + shuffle_slice(input, rng); + shuffle_slice(output, rng); + } + TxOrdering::Custom { + input_sort, + output_sort, + } => { + input.sort_unstable_by(|a, b| input_sort(a, b)); + output.sort_unstable_by(|a, b| output_sort(a, b)); + } + } + } +} + /// Policy regarding the use of change outputs when creating a transaction #[derive(Default, Debug, Ord, PartialOrd, Eq, PartialEq, Hash, Clone, Copy)] pub enum ChangeSpendPolicy { 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..8baf2426 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -1,11 +1,480 @@ -use bdk_wallet::bitcoin::{Amount, FeeRate, Psbt, TxIn}; +use bdk_chain::{BlockId, ConfirmationBlockTime}; +use bdk_tx::FeeStrategy; +use bdk_wallet::bitcoin; use bdk_wallet::test_utils::*; -use bdk_wallet::{psbt, KeychainKind, SignOptions}; +use bdk_wallet::{error::CreatePsbtError, psbt, KeychainKind, PsbtParams, SignOptions, Wallet}; +use bitcoin::{ + absolute, hashes::Hash, Address, Amount, FeeRate, Network, OutPoint, Psbt, ScriptBuf, + Transaction, TxIn, TxOut, +}; use core::str::FromStr; +use miniscript::plan::Assets; +use std::sync::Arc; // from bip 174 const PSBT_STR: &str = "cHNidP8BAKACAAAAAqsJSaCMWvfEm4IS9Bfi8Vqz9cM9zxU4IagTn4d6W3vkAAAAAAD+////qwlJoIxa98SbghL0F+LxWrP1wz3PFTghqBOfh3pbe+QBAAAAAP7///8CYDvqCwAAAAAZdqkUdopAu9dAy+gdmI5x3ipNXHE5ax2IrI4kAAAAAAAAGXapFG9GILVT+glechue4O/p+gOcykWXiKwAAAAAAAEHakcwRAIgR1lmF5fAGwNrJZKJSGhiGDR9iYZLcZ4ff89X0eURZYcCIFMJ6r9Wqk2Ikf/REf3xM286KdqGbX+EhtdVRs7tr5MZASEDXNxh/HupccC1AaZGoqg7ECy0OIEhfKaC3Ibi1z+ogpIAAQEgAOH1BQAAAAAXqRQ1RebjO4MsRwUPJNPuuTycA5SLx4cBBBYAFIXRNTfy4mVAWjTbr6nj3aAfuCMIAAAA"; +// Test that `create_psbt` results in the expected PSBT. +#[test] +fn test_create_psbt() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let expected_xpub = match wallet.public_descriptor(KeychainKind::External) { + miniscript::Descriptor::Tr(tr) => match tr.internal_key() { + miniscript::DescriptorPublicKey::XPub(desc) => desc.xkey, + _ => unreachable!(), + }, + _ => unreachable!(), + }; + + // Receive coins + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 100, + hash: Hash::hash(b"100"), + }, + confirmation_time: 1234567000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + receive_output(&mut wallet, Amount::ONE_BTC, ReceiveTo::Block(anchor)); + + let change_spk = wallet + .peek_address(KeychainKind::Internal, 0) + .script_pubkey(); + + let addr = wallet.reveal_next_address(KeychainKind::External); + let mut params = PsbtParams::default(); + let feerate = FeeRate::from_sat_per_vb_unchecked(4); + params + .version(bitcoin::transaction::Version(3)) + .coin_selection(psbt::SelectionStrategy::LowestFee) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]) + .change_script(change_spk.into()) + .fee(FeeStrategy::FeeRate(feerate)) + .fallback_sequence(bitcoin::Sequence::MAX) + .ordering(bdk_wallet::TxOrdering::Shuffle) + .add_global_xpubs(); + + let (psbt, _) = wallet.create_psbt(params).unwrap(); + let tx = &psbt.unsigned_tx; + assert_eq!(tx.version.0, 3); + assert_eq!(tx.lock_time.to_consensus_u32(), anchor.block_id.height); + assert_eq!(tx.input.len(), 1); + assert_eq!(tx.output.len(), 2); + + // global xpubs + assert_eq!( + psbt.xpub, + [(expected_xpub, ("f6a5cb8b".parse().unwrap(), vec![].into()))].into(), + ); + // witness utxo + let psbt_input = &psbt.inputs[0]; + assert_eq!( + psbt_input.witness_utxo.as_ref().map(|txo| txo.value), + Some(Amount::ONE_BTC), + ); + // input internal key + assert!(psbt_input.tap_internal_key.is_some()); + // input key origins + assert!(psbt_input + .tap_key_origins + .values() + .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b")); + // output internal key + assert!(psbt + .outputs + .iter() + .any(|output| output.tap_internal_key.is_some())); + // output key origins + assert!(psbt.outputs.iter().any(|output| output + .tap_key_origins + .values() + .any(|(_, (fp, _))| fp.to_string() == "f6a5cb8b"))); +} + +#[test] +fn test_create_psbt_insufficient_funds_error() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External); + + let mut params = PsbtParams::default(); + params.add_recipients([(addr.script_pubkey(), Amount::from_sat(10_000))]); + + let result = wallet.create_psbt(params); + assert!(matches!( + result, + Err(CreatePsbtError::InsufficientFunds( + bdk_coin_select::InsufficientFunds { missing: 10_000 } + )), + )); +} + +#[test] +fn test_create_psbt_maturity_height() { + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let receive_address = wallet.reveal_next_address(KeychainKind::External); + let send_to_address = wallet.reveal_next_address(KeychainKind::External).address; + + let block_1 = BlockId { + height: 1, + hash: Hash::hash(b"1"), + }; + insert_checkpoint(&mut wallet, block_1); + + // Receive coinbase output at height = 1. + // maturity height = (1 + 100) = 101 + let tx = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::ONE_BTC, + script_pubkey: receive_address.script_pubkey(), + }], + ..new_tx(0) + }; + insert_tx_anchor(&mut wallet, tx, block_1); + + // The output is still immature at height = 99. + let mut p = PsbtParams::default(); + p.add_recipients([(send_to_address.clone(), Amount::from_sat(58_000))]) + .maturity_height(bitcoin::absolute::Height::from_consensus(99).unwrap()); + + let _ = wallet + .create_psbt(p) + .expect_err("immature output must not be selected"); + + // We can use the params to coerce the coinbase maturity. + let mut p = PsbtParams::default(); + p.add_recipients([(send_to_address.clone(), Amount::from_sat(58_000))]) + .maturity_height(bitcoin::absolute::Height::from_consensus(100).unwrap()); + + let _ = wallet + .create_psbt(p) + .expect("`maturity_height` should enable selection"); + + // The output is eligible for selection once the wallet tip reaches maturity height minus 1 + // (100), as it can be confirmed in the next block (101). + let block_100 = BlockId { + height: 100, + hash: Hash::hash(b"100"), + }; + insert_checkpoint(&mut wallet, block_100); + let mut p = PsbtParams::default(); + p.add_recipients([(send_to_address.clone(), Amount::from_sat(58_000))]); + + let _ = wallet + .create_psbt(p) + .expect("mature coinbase should be selected"); +} + +#[test] +fn test_create_psbt_cltv() { + use absolute::LockTime; + + let desc = get_test_single_sig_cltv(); + let mut wallet = Wallet::create_single(desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // Receive coins + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 99_999, + hash: Hash::hash(b"abc"), + }, + confirmation_time: 1234567000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let op = receive_output(&mut wallet, Amount::ONE_BTC, ReceiveTo::Block(anchor)); + + let addr = wallet.reveal_next_address(KeychainKind::External); + + // No assets fail + { + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let res = wallet.create_psbt(params); + assert!( + matches!(res, Err(CreatePsbtError::Plan(err)) if err == op), + "UTXO requires CLTV but the assets are insufficient", + ); + } + + // Add assets ok + { + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .add_assets(Assets::new().after(LockTime::from_consensus(100_000))) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let (psbt, _) = wallet.create_psbt(params).unwrap(); + assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), 100_000); + } + + // New chain tip (no assets) ok + { + let block_id = BlockId { + height: 100_000, + hash: Hash::hash(b"123"), + }; + insert_checkpoint(&mut wallet, block_id); + + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let (psbt, _) = wallet.create_psbt(params).unwrap(); + assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), 100_000); + } + + // Locktime greater than required + { + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .locktime(LockTime::from_consensus(200_000)) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + + let (psbt, _) = wallet.create_psbt(params).unwrap(); + assert_eq!(psbt.unsigned_tx.lock_time.to_consensus_u32(), 200_000); + } +} + +#[test] +fn test_create_psbt_csv() { + use bitcoin::relative; + use bitcoin::Sequence; + + let desc = get_test_single_sig_csv(); + let mut wallet = Wallet::create_single(desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // Receive coins + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 10_000, + hash: Hash::hash(b"abc"), + }, + confirmation_time: 1234567000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let op = receive_output(&mut wallet, Amount::ONE_BTC, ReceiveTo::Block(anchor)); + + let addr = wallet.reveal_next_address(KeychainKind::External); + + // No assets fail + { + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let res = wallet.create_psbt(params); + assert!( + matches!(res, Err(CreatePsbtError::Plan(err)) if err == op), + "UTXO requires CSV but the assets are insufficient", + ); + } + + // Add assets ok + { + let mut params = PsbtParams::default(); + let rel_locktime = relative::LockTime::from_consensus(6).unwrap(); + params + .add_utxos(&[op]) + .add_assets(Assets::new().older(rel_locktime)) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let (psbt, _) = wallet.create_psbt(params).unwrap(); + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); + } + + // Add 6 confirmations (no assets) + { + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 10_005, + hash: Hash::hash(b"xyz"), + }, + confirmation_time: 1234567000, + }; + insert_checkpoint(&mut wallet, anchor.block_id); + let mut params = PsbtParams::default(); + params + .add_utxos(&[op]) + .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); + let (psbt, _) = wallet.create_psbt(params).unwrap(); + assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); + } +} + +// Test that replacing two unconfirmed txs A, B results in a transaction +// that spends the inputs of both A and B. +#[test] +fn test_replace_by_fee_and_recpients() { + use KeychainKind::*; + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // The anchor block + let block = BlockId { + height: 100, + hash: Hash::hash(b"100"), + }; + + let mut addrs: Vec
= vec![]; + for _ in 0..3 { + let addr = wallet.reveal_next_address(External); + addrs.push(addr.address); + } + + // Insert parent 0 (coinbase) + let p0 = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::ONE_BTC, + script_pubkey: addrs[0].script_pubkey(), + }], + ..new_tx(1) + }; + let op0 = OutPoint::new(p0.compute_txid(), 0); + + insert_tx_anchor(&mut wallet, p0.clone(), block); + + // Insert parent 1 (coinbase) + let p1 = Transaction { + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::ONE_BTC, + script_pubkey: addrs[1].script_pubkey(), + }], + ..new_tx(1) + }; + let op1 = OutPoint::new(p1.compute_txid(), 0); + + insert_tx_anchor(&mut wallet, p1.clone(), block); + + // Add new tip, for maturity + let block = BlockId { + height: 1000, + hash: Hash::hash(b"1000"), + }; + insert_checkpoint(&mut wallet, block); + + // Create tx A (unconfirmed) + let recip = + ScriptBuf::from_hex("5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5") + .unwrap(); + let mut params = PsbtParams::default(); + params + .add_utxos(&[op0]) + .add_recipients([(recip.clone(), Amount::from_sat(16_000))]); + let txa = wallet.create_psbt(params).unwrap().0.unsigned_tx; + insert_tx(&mut wallet, txa.clone()); + + // Create tx B (unconfirmed) + let mut params = PsbtParams::default(); + params + .add_utxos(&[op1]) + .add_recipients([(recip.clone(), Amount::from_sat(42_000))]); + let txb = wallet.create_psbt(params).unwrap().0.unsigned_tx; + insert_tx(&mut wallet, txb.clone()); + + // Now create RBF tx + let psbt = wallet + .replace_by_fee_and_recipients( + &[Arc::new(txa), Arc::new(txb)], + FeeRate::from_sat_per_vb_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] +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(FeeStrategy::FeeRate(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 + }); + params.change_script( + wallet + .peek_address(KeychainKind::Internal, 0) + .script_pubkey() + .into(), + ); + 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 c779c0a4..29ac6446 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