diff --git a/CHANGELOG.md b/CHANGELOG.md index b2528d8c..cf9683b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 type to mark for a missing client. - Upgrade `tokio` to `1.0`. -#### Transaction Creation Overhaul +### Transaction Creation Overhaul The `TxBuilder` is now created from the `build_tx` or `build_fee_bump` functions on wallet and the final transaction is created by calling `finish` on the builder. @@ -61,6 +61,13 @@ final transaction is created by calling `finish` on the builder. - Added `Wallet::get_utxo` - Added `Wallet::get_descriptor_for_keychain` +### `add_foreign_utxo` + +- Renamed `UTXO` to `LocalUtxo` +- Added `WeightedUtxo` to replace floating `(UTXO, usize)`. +- Added `Utxo` enum to incorporate both local utxos and foreign utxos +- Added `TxBuilder::add_foreign_utxo` which allows adding a utxo external to the wallet. + ### CLI #### Changed - Remove `cli.rs` module, `cli-utils` feature and `repl.rs` example; moved to new [`bdk-cli`](https://github.com/bitcoindevkit/bdk-cli) repository diff --git a/src/types.rs b/src/types.rs index 5e20a7de..347325a8 100644 --- a/src/types.rs +++ b/src/types.rs @@ -25,7 +25,7 @@ use std::convert::AsRef; use bitcoin::blockdata::transaction::{OutPoint, Transaction, TxOut}; -use bitcoin::hash_types::Txid; +use bitcoin::{hash_types::Txid, util::psbt}; use serde::{Deserialize, Serialize}; @@ -90,7 +90,9 @@ impl std::default::Default for FeeRate { } } -/// A wallet unspent output +/// An unspent output owned by a [`Wallet`]. +/// +/// [`Wallet`]: crate::Wallet #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct LocalUtxo { /// Reference to a transaction output @@ -101,6 +103,62 @@ pub struct LocalUtxo { pub keychain: KeychainKind, } +/// A [`Utxo`] with its `satisfaction_weight`. +#[derive(Debug, Clone, PartialEq)] +pub struct WeightedUtxo { + /// The weight of the witness data or `scriptSig`. + /// This is used to properly maintain the feerate when doing coin selection. + pub satisfaction_weight: usize, + /// The UTXO + pub utxo: Utxo, +} + +#[derive(Debug, Clone, PartialEq)] +/// An unspent transaction output (UTXO). +pub enum Utxo { + /// A UTXO owned by the local wallet. + Local(LocalUtxo), + /// A UTXO owned by another wallet. + Foreign { + /// The location of the output. + outpoint: OutPoint, + /// The information about the input we require to add it to a PSBT. + // Box it to stop the type being too big. + psbt_input: Box, + }, +} + +impl Utxo { + /// Get the location of the UTXO + pub fn outpoint(&self) -> OutPoint { + match &self { + Utxo::Local(local) => local.outpoint, + Utxo::Foreign { outpoint, .. } => *outpoint, + } + } + + /// Get the `TxOut` of the UTXO + pub fn txout(&self) -> &TxOut { + match &self { + Utxo::Local(local) => &local.txout, + Utxo::Foreign { + outpoint, + psbt_input, + } => { + if let Some(prev_tx) = &psbt_input.non_witness_utxo { + return &prev_tx.output[outpoint.vout as usize]; + } + + if let Some(txout) = &psbt_input.witness_utxo { + return &txout; + } + + unreachable!("Foreign UTXOs will always have one of these set") + } + } + } +} + /// A wallet transaction #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] pub struct TransactionDetails { diff --git a/src/wallet/coin_selection.rs b/src/wallet/coin_selection.rs index 493a2bdf..f0b0bb7a 100644 --- a/src/wallet/coin_selection.rs +++ b/src/wallet/coin_selection.rs @@ -50,8 +50,8 @@ //! fn coin_select( //! &self, //! database: &D, -//! required_utxos: Vec<(LocalUtxo, usize)>, -//! optional_utxos: Vec<(LocalUtxo, usize)>, +//! required_utxos: Vec, +//! optional_utxos: Vec, //! fee_rate: FeeRate, //! amount_needed: u64, //! fee_amount: f32, @@ -60,11 +60,10 @@ //! let mut additional_weight = 0; //! let all_utxos_selected = required_utxos //! .into_iter().chain(optional_utxos) -//! .scan((&mut selected_amount, &mut additional_weight), |(selected_amount, additional_weight), (utxo, weight)| { -//! **selected_amount += utxo.txout.value; -//! **additional_weight += TXIN_BASE_WEIGHT + weight; -//! -//! Some(utxo) +//! .scan((&mut selected_amount, &mut additional_weight), |(selected_amount, additional_weight), weighted_utxo| { +//! **selected_amount += weighted_utxo.utxo.txout().value; +//! **additional_weight += TXIN_BASE_WEIGHT + weighted_utxo.satisfaction_weight; +//! Some(weighted_utxo.utxo) //! }) //! .collect::>(); //! let additional_fees = additional_weight as f32 * fee_rate.as_sat_vb() / 4.0; @@ -75,7 +74,6 @@ //! //! Ok(CoinSelectionResult { //! selected: all_utxos_selected, -//! selected_amount, //! fee_amount: fee_amount + additional_fees, //! }) //! } @@ -97,9 +95,9 @@ //! # Ok::<(), bdk::Error>(()) //! ``` -use crate::database::Database; -use crate::error::Error; -use crate::types::{FeeRate, LocalUtxo}; +use crate::types::FeeRate; +use crate::{database::Database, WeightedUtxo}; +use crate::{error::Error, Utxo}; use rand::seq::SliceRandom; #[cfg(not(test))] @@ -122,13 +120,29 @@ pub(crate) const TXIN_BASE_WEIGHT: usize = (32 + 4 + 4 + 1) * 4; #[derive(Debug)] pub struct CoinSelectionResult { /// List of outputs selected for use as inputs - pub selected: Vec, - /// Sum of the selected inputs' value - pub selected_amount: u64, + pub selected: Vec, /// Total fee amount in satoshi pub fee_amount: f32, } +impl CoinSelectionResult { + /// The total value of the inputs selected. + pub fn selected_amount(&self) -> u64 { + self.selected.iter().map(|u| u.txout().value).sum() + } + + /// The total value of the inputs selected from the local wallet. + pub fn local_selected_amount(&self) -> u64 { + self.selected + .iter() + .filter_map(|u| match u { + Utxo::Local(_) => Some(u.txout().value), + _ => None, + }) + .sum() + } +} + /// Trait for generalized coin selection algorithms /// /// This trait can be implemented to make the [`Wallet`](super::Wallet) use a customized coin @@ -151,8 +165,8 @@ pub trait CoinSelectionAlgorithm: std::fmt::Debug { fn coin_select( &self, database: &D, - required_utxos: Vec<(LocalUtxo, usize)>, - optional_utxos: Vec<(LocalUtxo, usize)>, + required_utxos: Vec, + optional_utxos: Vec, fee_rate: FeeRate, amount_needed: u64, fee_amount: f32, @@ -170,8 +184,8 @@ impl CoinSelectionAlgorithm for LargestFirstCoinSelection { fn coin_select( &self, _database: &D, - required_utxos: Vec<(LocalUtxo, usize)>, - mut optional_utxos: Vec<(LocalUtxo, usize)>, + required_utxos: Vec, + mut optional_utxos: Vec, fee_rate: FeeRate, amount_needed: u64, mut fee_amount: f32, @@ -188,7 +202,7 @@ impl CoinSelectionAlgorithm for LargestFirstCoinSelection { // We put the "required UTXOs" first and make sure the optional UTXOs are sorted, // initially smallest to largest, before being reversed with `.rev()`. let utxos = { - optional_utxos.sort_unstable_by_key(|(utxo, _)| utxo.txout.value); + optional_utxos.sort_unstable_by_key(|wu| wu.utxo.txout().value); required_utxos .into_iter() .map(|utxo| (true, utxo)) @@ -201,18 +215,19 @@ impl CoinSelectionAlgorithm for LargestFirstCoinSelection { let selected = utxos .scan( (&mut selected_amount, &mut fee_amount), - |(selected_amount, fee_amount), (must_use, (utxo, weight))| { + |(selected_amount, fee_amount), (must_use, weighted_utxo)| { if must_use || **selected_amount < amount_needed + (fee_amount.ceil() as u64) { - **fee_amount += calc_fee_bytes(TXIN_BASE_WEIGHT + weight); - **selected_amount += utxo.txout.value; + **fee_amount += + calc_fee_bytes(TXIN_BASE_WEIGHT + weighted_utxo.satisfaction_weight); + **selected_amount += weighted_utxo.utxo.txout().value; log::debug!( "Selected {}, updated fee_amount = `{}`", - utxo.outpoint, + weighted_utxo.utxo.outpoint(), fee_amount ); - Some(utxo) + Some(weighted_utxo.utxo) } else { None } @@ -231,7 +246,6 @@ impl CoinSelectionAlgorithm for LargestFirstCoinSelection { Ok(CoinSelectionResult { selected, fee_amount, - selected_amount, }) } } @@ -239,9 +253,7 @@ impl CoinSelectionAlgorithm for LargestFirstCoinSelection { #[derive(Debug, Clone)] // Adds fee information to an UTXO. struct OutputGroup { - utxo: LocalUtxo, - // weight needed to satisfy the UTXO, as described in `Descriptor::max_satisfaction_weight` - satisfaction_weight: usize, + weighted_utxo: WeightedUtxo, // Amount of fees for spending a certain utxo, calculated using a certain FeeRate fee: f32, // The effective value of the UTXO, i.e., the utxo value minus the fee for spending it @@ -249,12 +261,12 @@ struct OutputGroup { } impl OutputGroup { - fn new(utxo: LocalUtxo, satisfaction_weight: usize, fee_rate: FeeRate) -> Self { - let fee = (TXIN_BASE_WEIGHT + satisfaction_weight) as f32 / 4.0 * fee_rate.as_sat_vb(); - let effective_value = utxo.txout.value as i64 - fee.ceil() as i64; + fn new(weighted_utxo: WeightedUtxo, fee_rate: FeeRate) -> Self { + let fee = (TXIN_BASE_WEIGHT + weighted_utxo.satisfaction_weight) as f32 / 4.0 + * fee_rate.as_sat_vb(); + let effective_value = weighted_utxo.utxo.txout().value as i64 - fee.ceil() as i64; OutputGroup { - utxo, - satisfaction_weight, + weighted_utxo, effective_value, fee, } @@ -291,8 +303,8 @@ impl CoinSelectionAlgorithm for BranchAndBoundCoinSelection { fn coin_select( &self, _database: &D, - required_utxos: Vec<(LocalUtxo, usize)>, - optional_utxos: Vec<(LocalUtxo, usize)>, + required_utxos: Vec, + optional_utxos: Vec, fee_rate: FeeRate, amount_needed: u64, fee_amount: f32, @@ -300,7 +312,7 @@ impl CoinSelectionAlgorithm for BranchAndBoundCoinSelection { // Mapping every (UTXO, usize) to an output group let required_utxos: Vec = required_utxos .into_iter() - .map(|u| OutputGroup::new(u.0, u.1, fee_rate)) + .map(|u| OutputGroup::new(u, fee_rate)) .collect(); // Mapping every (UTXO, usize) to an output group. @@ -308,7 +320,7 @@ impl CoinSelectionAlgorithm for BranchAndBoundCoinSelection { // adding them is more than their value let optional_utxos: Vec = optional_utxos .into_iter() - .map(|u| OutputGroup::new(u.0, u.1, fee_rate)) + .map(|u| OutputGroup::new(u, fee_rate)) .filter(|u| u.effective_value > 0) .collect(); @@ -507,14 +519,12 @@ impl BranchAndBoundCoinSelection { fee_amount += selected_utxos.iter().map(|u| u.fee).sum::(); let selected = selected_utxos .into_iter() - .map(|u| u.utxo) + .map(|u| u.weighted_utxo.utxo) .collect::>(); - let selected_amount = selected.iter().map(|u| u.txout.value).sum(); CoinSelectionResult { selected, fee_amount, - selected_amount, } } } @@ -535,10 +545,11 @@ mod test { const P2WPKH_WITNESS_SIZE: usize = 73 + 33 + 2; - fn get_test_utxos() -> Vec<(LocalUtxo, usize)> { + fn get_test_utxos() -> Vec { vec![ - ( - LocalUtxo { + WeightedUtxo { + satisfaction_weight: P2WPKH_WITNESS_SIZE, + utxo: Utxo::Local(LocalUtxo { outpoint: OutPoint::from_str( "ebd9813ecebc57ff8f30797de7c205e3c7498ca950ea4341ee51a685ff2fa30a:0", ) @@ -548,11 +559,11 @@ mod test { script_pubkey: Script::new(), }, keychain: KeychainKind::External, - }, - P2WPKH_WITNESS_SIZE, - ), - ( - LocalUtxo { + }), + }, + WeightedUtxo { + satisfaction_weight: P2WPKH_WITNESS_SIZE, + utxo: Utxo::Local(LocalUtxo { outpoint: OutPoint::from_str( "65d92ddff6b6dc72c89624a6491997714b90f6004f928d875bc0fd53f264fa85:0", ) @@ -562,17 +573,17 @@ mod test { script_pubkey: Script::new(), }, keychain: KeychainKind::Internal, - }, - P2WPKH_WITNESS_SIZE, - ), + }), + }, ] } - fn generate_random_utxos(rng: &mut StdRng, utxos_number: usize) -> Vec<(LocalUtxo, usize)> { + fn generate_random_utxos(rng: &mut StdRng, utxos_number: usize) -> Vec { let mut res = Vec::new(); for _ in 0..utxos_number { - res.push(( - LocalUtxo { + res.push(WeightedUtxo { + satisfaction_weight: P2WPKH_WITNESS_SIZE, + utxo: Utxo::Local(LocalUtxo { outpoint: OutPoint::from_str( "ebd9813ecebc57ff8f30797de7c205e3c7498ca950ea4341ee51a685ff2fa30a:0", ) @@ -582,16 +593,16 @@ mod test { script_pubkey: Script::new(), }, keychain: KeychainKind::External, - }, - P2WPKH_WITNESS_SIZE, - )); + }), + }); } res } - fn generate_same_value_utxos(utxos_value: u64, utxos_number: usize) -> Vec<(LocalUtxo, usize)> { - let utxo = ( - LocalUtxo { + fn generate_same_value_utxos(utxos_value: u64, utxos_number: usize) -> Vec { + let utxo = WeightedUtxo { + satisfaction_weight: P2WPKH_WITNESS_SIZE, + utxo: Utxo::Local(LocalUtxo { outpoint: OutPoint::from_str( "ebd9813ecebc57ff8f30797de7c205e3c7498ca950ea4341ee51a685ff2fa30a:0", ) @@ -601,18 +612,18 @@ mod test { script_pubkey: Script::new(), }, keychain: KeychainKind::External, - }, - P2WPKH_WITNESS_SIZE, - ); + }), + }; vec![utxo; utxos_number] } - fn sum_random_utxos(mut rng: &mut StdRng, utxos: &mut Vec<(LocalUtxo, usize)>) -> u64 { + fn sum_random_utxos(mut rng: &mut StdRng, utxos: &mut Vec) -> u64 { let utxos_picked_len = rng.gen_range(2, utxos.len() / 2); utxos.shuffle(&mut rng); utxos[..utxos_picked_len] .iter() - .fold(0, |acc, x| acc + x.0.txout.value) + .map(|u| u.utxo.txout().value) + .sum() } #[test] @@ -632,7 +643,7 @@ mod test { .unwrap(); assert_eq!(result.selected.len(), 2); - assert_eq!(result.selected_amount, 300_000); + assert_eq!(result.selected_amount(), 300_000); assert_eq!(result.fee_amount, 186.0); } @@ -653,7 +664,7 @@ mod test { .unwrap(); assert_eq!(result.selected.len(), 2); - assert_eq!(result.selected_amount, 300_000); + assert_eq!(result.selected_amount(), 300_000); assert_eq!(result.fee_amount, 186.0); } @@ -674,7 +685,7 @@ mod test { .unwrap(); assert_eq!(result.selected.len(), 1); - assert_eq!(result.selected_amount, 200_000); + assert_eq!(result.selected_amount(), 200_000); assert_eq!(result.fee_amount, 118.0); } @@ -734,7 +745,7 @@ mod test { .unwrap(); assert_eq!(result.selected.len(), 3); - assert_eq!(result.selected_amount, 300_000); + assert_eq!(result.selected_amount(), 300_000); assert_eq!(result.fee_amount, 254.0); } @@ -755,7 +766,7 @@ mod test { .unwrap(); assert_eq!(result.selected.len(), 2); - assert_eq!(result.selected_amount, 300_000); + assert_eq!(result.selected_amount(), 300_000); assert_eq!(result.fee_amount, 186.0); } @@ -812,7 +823,7 @@ mod test { .unwrap(); assert_eq!(result.selected.len(), 1); - assert_eq!(result.selected_amount, 100_000); + assert_eq!(result.selected_amount(), 100_000); let input_size = (TXIN_BASE_WEIGHT as f32) / 4.0 + P2WPKH_WITNESS_SIZE as f32 / 4.0; let epsilon = 0.5; assert!((1.0 - (result.fee_amount / input_size)).abs() < epsilon); @@ -837,7 +848,7 @@ mod test { 0.0, ) .unwrap(); - assert_eq!(result.selected_amount, target_amount); + assert_eq!(result.selected_amount(), target_amount); } } @@ -847,7 +858,7 @@ mod test { let fee_rate = FeeRate::from_sat_per_vb(10.0); let utxos: Vec = get_test_utxos() .into_iter() - .map(|u| OutputGroup::new(u.0, u.1, fee_rate)) + .map(|u| OutputGroup::new(u, fee_rate)) .collect(); let curr_available_value = utxos @@ -875,7 +886,7 @@ mod test { let fee_rate = FeeRate::from_sat_per_vb(10.0); let utxos: Vec = generate_same_value_utxos(100_000, 100_000) .into_iter() - .map(|u| OutputGroup::new(u.0, u.1, fee_rate)) + .map(|u| OutputGroup::new(u, fee_rate)) .collect(); let curr_available_value = utxos @@ -908,7 +919,7 @@ mod test { let utxos: Vec<_> = generate_same_value_utxos(50_000, 10) .into_iter() - .map(|u| OutputGroup::new(u.0, u.1, fee_rate)) + .map(|u| OutputGroup::new(u, fee_rate)) .collect(); let curr_value = 0; @@ -933,7 +944,7 @@ mod test { ) .unwrap(); assert_eq!(result.fee_amount, 186.0); - assert_eq!(result.selected_amount, 100_000); + assert_eq!(result.selected_amount(), 100_000); } // TODO: bnb() function should be optimized, and this test should be done with more utxos @@ -946,7 +957,7 @@ mod test { for _ in 0..200 { let optional_utxos: Vec<_> = generate_random_utxos(&mut rng, 40) .into_iter() - .map(|u| OutputGroup::new(u.0, u.1, fee_rate)) + .map(|u| OutputGroup::new(u, fee_rate)) .collect(); let curr_value = 0; @@ -969,7 +980,7 @@ mod test { 0.0, ) .unwrap(); - assert_eq!(result.selected_amount, target_amount); + assert_eq!(result.selected_amount(), target_amount); } } @@ -983,7 +994,7 @@ mod test { let fee_rate = FeeRate::from_sat_per_vb(1.0); let utxos: Vec = utxos .into_iter() - .map(|u| OutputGroup::new(u.0, u.1, fee_rate)) + .map(|u| OutputGroup::new(u, fee_rate)) .collect(); let result = BranchAndBoundCoinSelection::default().single_random_draw( @@ -994,7 +1005,7 @@ mod test { 50.0, ); - assert!(result.selected_amount > target_amount); + assert!(result.selected_amount() > target_amount); assert_eq!( result.fee_amount, 50.0 + result.selected.len() as f32 * 68.0 diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 88b78769..d2b1e0c8 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -513,11 +513,7 @@ where params.bumping_fee.is_some(), // we mandate confirmed transactions if we're bumping the fee )?; - let coin_selection::CoinSelectionResult { - selected, - selected_amount, - mut fee_amount, - } = coin_selection.coin_select( + let coin_selection = coin_selection.coin_select( self.database.borrow().deref(), required_utxos, optional_utxos, @@ -525,10 +521,13 @@ where outgoing, fee_amount, )?; - tx.input = selected + let mut fee_amount = coin_selection.fee_amount; + + tx.input = coin_selection + .selected .iter() .map(|u| bitcoin::TxIn { - previous_output: u.outpoint, + previous_output: u.outpoint(), script_sig: Script::default(), sequence: n_sequence, witness: vec![], @@ -550,9 +549,8 @@ where Some(change_output) } }; - let mut fee_amount = fee_amount.ceil() as u64; - let change_val = (selected_amount - outgoing).saturating_sub(fee_amount); + let change_val = (coin_selection.selected_amount() - outgoing).saturating_sub(fee_amount); match change_output { None if change_val.is_dust() => { @@ -588,14 +586,15 @@ where params.ordering.sort_tx(&mut tx); let txid = tx.txid(); - let psbt = self.complete_transaction(tx, selected, params)?; + let sent = coin_selection.local_selected_amount(); + let psbt = self.complete_transaction(tx, coin_selection.selected, params)?; let transaction_details = TransactionDetails { transaction: None, txid, timestamp: time::get_timestamp(), received, - sent: selected_amount, + sent, fees: fee_amount, height: None, }; @@ -705,7 +704,10 @@ where keychain, }; - Ok((utxo, weight)) + Ok(WeightedUtxo { + satisfaction_weight: weight, + utxo: Utxo::Local(utxo), + }) }) .collect::, _>>()?; @@ -1039,18 +1041,18 @@ where &self, change_policy: tx_builder::ChangeSpendPolicy, unspendable: &HashSet, - manually_selected: Vec<(LocalUtxo, usize)>, + manually_selected: Vec, must_use_all_available: bool, manual_only: bool, must_only_use_confirmed_tx: bool, - ) -> Result<(Vec<(LocalUtxo, usize)>, Vec<(LocalUtxo, usize)>), Error> { + ) -> Result<(Vec, Vec), Error> { // must_spend <- manually selected utxos // may_spend <- all other available utxos let mut may_spend = self.get_available_utxos()?; may_spend.retain(|may_spend| { manually_selected .iter() - .find(|manually_selected| manually_selected.0.outpoint == may_spend.0.outpoint) + .find(|manually_selected| manually_selected.utxo.outpoint() == may_spend.0.outpoint) .is_none() }); let mut must_spend = manually_selected; @@ -1088,6 +1090,14 @@ where retain }); + let mut may_spend = may_spend + .into_iter() + .map(|(local_utxo, satisfaction_weight)| WeightedUtxo { + satisfaction_weight, + utxo: Utxo::Local(local_utxo), + }) + .collect(); + if must_use_all_available { must_spend.append(&mut may_spend); } @@ -1098,7 +1108,7 @@ where fn complete_transaction( &self, tx: Transaction, - selected: Vec, + selected: Vec, params: TxParams, ) -> Result { use bitcoin::util::psbt::serialize::Serialize; @@ -1131,9 +1141,9 @@ where } } - let lookup_output = selected + let mut lookup_output = selected .into_iter() - .map(|utxo| (utxo.outpoint, utxo)) + .map(|utxo| (utxo.outpoint(), utxo)) .collect::>(); // add metadata for the inputs @@ -1142,7 +1152,7 @@ where .iter_mut() .zip(psbt.global.unsigned_tx.input.iter()) { - let utxo = match lookup_output.get(&input.previous_output) { + let utxo = match lookup_output.remove(&input.previous_output) { Some(utxo) => utxo, None => continue, }; @@ -1153,32 +1163,50 @@ where psbt_input.sighash_type = Some(sighash_type); } - // Try to find the prev_script in our db to figure out if this is internal or external, - // and the derivation index - let (keychain, child) = match self - .database - .borrow() - .get_path_from_script_pubkey(&utxo.txout.script_pubkey)? - { - Some(x) => x, - None => continue, - }; + match utxo { + Utxo::Local(utxo) => { + // Try to find the prev_script in our db to figure out if this is internal or external, + // and the derivation index + let (keychain, child) = match self + .database + .borrow() + .get_path_from_script_pubkey(&utxo.txout.script_pubkey)? + { + Some(x) => x, + None => continue, + }; - let (desc, _) = self._get_descriptor_for_keychain(keychain); - let derived_descriptor = desc.as_derived(child, &self.secp); - psbt_input.bip32_derivation = derived_descriptor.get_hd_keypaths(&self.secp)?; + let desc = self.get_descriptor_for_keychain(keychain); + let derived_descriptor = desc.as_derived(child, &self.secp); + psbt_input.bip32_derivation = derived_descriptor.get_hd_keypaths(&self.secp)?; - psbt_input.redeem_script = derived_descriptor.psbt_redeem_script(); - psbt_input.witness_script = derived_descriptor.psbt_witness_script(); + psbt_input.redeem_script = derived_descriptor.psbt_redeem_script(); + psbt_input.witness_script = derived_descriptor.psbt_witness_script(); - let prev_output = input.previous_output; - if let Some(prev_tx) = self.database.borrow().get_raw_tx(&prev_output.txid)? { - if desc.is_witness() { - psbt_input.witness_utxo = - Some(prev_tx.output[prev_output.vout as usize].clone()); + let prev_output = input.previous_output; + if let Some(prev_tx) = self.database.borrow().get_raw_tx(&prev_output.txid)? { + if desc.is_witness() { + psbt_input.witness_utxo = + Some(prev_tx.output[prev_output.vout as usize].clone()); + } + if !desc.is_witness() || params.force_non_witness_utxo { + psbt_input.non_witness_utxo = Some(prev_tx); + } + } } - if !desc.is_witness() || params.force_non_witness_utxo { - psbt_input.non_witness_utxo = Some(prev_tx); + Utxo::Foreign { + psbt_input: foreign_psbt_input, + outpoint, + } => { + if params.force_non_witness_utxo + && foreign_psbt_input.non_witness_utxo.is_none() + { + return Err(Error::Generic(format!( + "Missing non_witness_utxo on foreign utxo {}", + outpoint + ))); + } + *psbt_input = *foreign_psbt_input; } } } @@ -1348,7 +1376,7 @@ where mod test { use std::str::FromStr; - use bitcoin::Network; + use bitcoin::{util::psbt, Network}; use crate::database::memory::MemoryDatabase; use crate::database::Database; @@ -2237,6 +2265,182 @@ mod test { assert_eq!(psbt.global.unknown.get(&psbt_key), Some(&value_bytes)); } + #[test] + fn test_add_foreign_utxo() { + let (wallet1, _, _) = get_funded_wallet(get_test_wpkh()); + let (wallet2, _, _) = + get_funded_wallet("wpkh(cVbZ8ovhye9AoAHFsqobCf7LxbXDAECy9Kb8TZdfsDYMZGBUyCnm)"); + let addr = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX").unwrap(); + let utxo = wallet2.list_unspent().unwrap().remove(0); + let foreign_utxo_satisfaction = wallet2 + .get_descriptor_for_keychain(KeychainKind::External) + .max_satisfaction_weight() + .unwrap(); + + let psbt_input = psbt::Input { + witness_utxo: Some(utxo.txout.clone()), + ..Default::default() + }; + + let mut builder = wallet1.build_tx(); + builder + .add_recipient(addr.script_pubkey(), 60_000) + .add_foreign_utxo(utxo.outpoint, psbt_input, foreign_utxo_satisfaction) + .unwrap(); + let (psbt, details) = builder.finish().unwrap(); + + assert_eq!( + details.sent - details.received, + 10_000 + details.fees, + "we should have only net spent ~10_000" + ); + + assert!( + psbt.global + .unsigned_tx + .input + .iter() + .find(|input| input.previous_output == utxo.outpoint) + .is_some(), + "foreign_utxo should be in there" + ); + + let (psbt, finished) = wallet1.sign(psbt, None).unwrap(); + + assert!( + !finished, + "only one of the inputs should have been signed so far" + ); + + let (_, finished) = wallet2.sign(psbt, None).unwrap(); + assert!(finished, "all the inputs should have been signed now"); + } + + #[test] + #[should_panic(expected = "Generic(\"Foreign utxo missing witness_utxo or non_witness_utxo\")")] + fn test_add_foreign_utxo_invalid_psbt_input() { + let (wallet, _, _) = get_funded_wallet(get_test_wpkh()); + let mut builder = wallet.build_tx(); + let outpoint = wallet.list_unspent().unwrap()[0].outpoint; + let foreign_utxo_satisfaction = wallet + .get_descriptor_for_keychain(KeychainKind::External) + .max_satisfaction_weight() + .unwrap(); + builder + .add_foreign_utxo(outpoint, psbt::Input::default(), foreign_utxo_satisfaction) + .unwrap(); + } + + #[test] + fn test_add_foreign_utxo_where_outpoint_doesnt_match_psbt_input() { + let (wallet1, _, txid1) = get_funded_wallet(get_test_wpkh()); + let (wallet2, _, txid2) = + get_funded_wallet("wpkh(cVbZ8ovhye9AoAHFsqobCf7LxbXDAECy9Kb8TZdfsDYMZGBUyCnm)"); + + let utxo2 = wallet2.list_unspent().unwrap().remove(0); + let tx1 = wallet1 + .database + .borrow() + .get_tx(&txid1, true) + .unwrap() + .unwrap() + .transaction + .unwrap(); + let tx2 = wallet2 + .database + .borrow() + .get_tx(&txid2, true) + .unwrap() + .unwrap() + .transaction + .unwrap(); + + let satisfaction_weight = wallet2 + .get_descriptor_for_keychain(KeychainKind::External) + .max_satisfaction_weight() + .unwrap(); + + let psbt_input1 = psbt::Input { + non_witness_utxo: Some(tx1), + ..Default::default() + }; + let psbt_input2 = psbt::Input { + non_witness_utxo: Some(tx2), + ..Default::default() + }; + + let mut builder = wallet1.build_tx(); + assert!( + builder + .add_foreign_utxo(utxo2.outpoint, psbt_input1, satisfaction_weight) + .is_err(), + "should fail when outpoint doesn't match psbt_input" + ); + assert!( + builder + .add_foreign_utxo(utxo2.outpoint, psbt_input2, satisfaction_weight) + .is_ok(), + "shoulld be ok when outpoing does match psbt_input" + ); + } + + #[test] + fn test_add_foreign_utxo_force_non_witness_utxo() { + let (wallet1, _, _) = get_funded_wallet(get_test_wpkh()); + let (wallet2, _, txid2) = + get_funded_wallet("wpkh(cVbZ8ovhye9AoAHFsqobCf7LxbXDAECy9Kb8TZdfsDYMZGBUyCnm)"); + let addr = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX").unwrap(); + let utxo2 = wallet2.list_unspent().unwrap().remove(0); + + let satisfaction_weight = wallet2 + .get_descriptor_for_keychain(KeychainKind::External) + .max_satisfaction_weight() + .unwrap(); + + let mut builder = wallet1.build_tx(); + builder + .add_recipient(addr.script_pubkey(), 60_000) + .force_non_witness_utxo(); + + { + let mut builder = builder.clone(); + let psbt_input = psbt::Input { + witness_utxo: Some(utxo2.txout.clone()), + ..Default::default() + }; + builder + .add_foreign_utxo(utxo2.outpoint, psbt_input, satisfaction_weight) + .unwrap(); + assert!( + builder.finish().is_err(), + "psbt_input with witness_utxo should succeed with witness_utxo" + ); + } + + { + let mut builder = builder.clone(); + let tx2 = wallet2 + .database + .borrow() + .get_tx(&txid2, true) + .unwrap() + .unwrap() + .transaction + .unwrap(); + let psbt_input = psbt::Input { + non_witness_utxo: Some(tx2), + ..Default::default() + }; + builder + .add_foreign_utxo(utxo2.outpoint, psbt_input, satisfaction_weight) + .unwrap(); + assert!( + builder.finish().is_ok(), + "psbt_input with non_witness_utxo should succeed with force_non_witness_utxo" + ); + } + } + #[test] #[should_panic( expected = "MissingKeyOrigin(\"tpubDCKxNyM3bLgbEX13Mcd8mYxbVg9ajDkWXMh29hMWBurKfVmBfWAM96QVP3zaUcN51HvkZ3ar4VwP82kC8JZhhux8vFQoJintSpVBwpFvyU3\")" diff --git a/src/wallet/tx_builder.rs b/src/wallet/tx_builder.rs index f1fdf82a..bd9181ee 100644 --- a/src/wallet/tx_builder.rs +++ b/src/wallet/tx_builder.rs @@ -54,15 +54,15 @@ use std::collections::HashSet; use std::default::Default; use std::marker::PhantomData; -use bitcoin::util::psbt::PartiallySignedTransaction as PSBT; +use bitcoin::util::psbt::{self, PartiallySignedTransaction as PSBT}; use bitcoin::{OutPoint, Script, SigHashType, Transaction}; use miniscript::descriptor::DescriptorTrait; use super::coin_selection::{CoinSelectionAlgorithm, DefaultCoinSelectionAlgorithm}; -use crate::{database::BatchDatabase, Error, Wallet}; +use crate::{database::BatchDatabase, Error, Utxo, Wallet}; use crate::{ - types::{FeeRate, KeychainKind, LocalUtxo}, + types::{FeeRate, KeychainKind, LocalUtxo, WeightedUtxo}, TransactionDetails, }; /// Context in which the [`TxBuilder`] is valid @@ -150,7 +150,7 @@ pub(crate) struct TxParams { pub(crate) fee_policy: Option, pub(crate) internal_policy_path: Option>>, pub(crate) external_policy_path: Option>>, - pub(crate) utxos: Vec<(LocalUtxo, usize)>, + pub(crate) utxos: Vec, pub(crate) unspendable: HashSet, pub(crate) manually_selected_only: bool, pub(crate) sighash: Option, @@ -297,7 +297,10 @@ impl<'a, B, D: BatchDatabase, Cs: CoinSelectionAlgorithm, Ctx: TxBuilderConte for utxo in utxos { let descriptor = self.wallet.get_descriptor_for_keychain(utxo.keychain); let satisfaction_weight = descriptor.max_satisfaction_weight().unwrap(); - self.params.utxos.push((utxo, satisfaction_weight)); + self.params.utxos.push(WeightedUtxo { + satisfaction_weight, + utxo: Utxo::Local(utxo), + }); } Ok(self) @@ -311,6 +314,84 @@ impl<'a, B, D: BatchDatabase, Cs: CoinSelectionAlgorithm, Ctx: TxBuilderConte self.add_utxos(&[outpoint]) } + /// Add a foreign UTXO i.e. A UTXO not owned by this wallet. + /// + /// At a minimum to add a foreign UTXO we need: + /// + /// 1. `outpoint`: To add it to the raw transaction. + /// 2. `psbt_input`: To know the value. + /// 3. `satisfaction_weight`: To know how much weight/vbytes the input will add to the transaction for fee calculation. + /// + /// There are several security concerns about adding foregin UTXOs that application + /// developers should consider. First, how do you know the value of the input is correct? If a + /// `non_witness_utxo` is provided in the `psbt_input` then this method implicitly verifies the + /// value by checking it against the transaction. If only a `wintess_utxo` is provided then this + /// method doesn't verify the value but just takes it as a given -- it is up to you to check + /// that whoever sent you the `input_psbt` was not lying! + /// + /// Secondly, you must somehow provide `satisfaction_weight` of the input. Depending on your + /// application it may be important that this be known precisely. If not, a malicious + /// counterparty may fool you into putting in a value that is too low, giving the transaction a + /// lower than expected feerate. They could also fool you into putting a value that is too high + /// causing you to pay a fee that is too high. The party who is broadcasting the transaction can + /// of course check the real input weight matches the expected weight prior to broadcasting. + /// + /// To guarantee the `satisfaction_weight` is correct, you can require the party providing the + /// `psbt_input` provide a miniscript descriptor for the input so you can check it against the + /// `script_pubkey` and then ask it for the [`max_satisfaction_weight`]. + /// + /// This is an **EXPERIMENTAL** feature, API and other major changes are expected. + /// + /// # Errors + /// + /// This method returns errors in the following circumstances: + /// + /// 1. The `psbt_input` does not contain a `witness_utxo` or `non_witness_utxo`. + /// 2. The data in `non_witness_utxo` does not match what is in `outpoint`. + /// + /// Note if you set [`force_non_witness_utxo`] any `psbt_input` you pass to this method must + /// have `non_witness_utxo` set otherwise you will get an error when [`finish`] is called. + /// + /// [`force_non_witness_utxo`]: Self::force_non_witness_utxo + /// [`finish`]: Self::finish + /// [`max_satisfaction_weight`]: miniscript::Descriptor::max_satisfaction_weight + pub fn add_foreign_utxo( + &mut self, + outpoint: OutPoint, + psbt_input: psbt::Input, + satisfaction_weight: usize, + ) -> Result<&mut Self, Error> { + if psbt_input.witness_utxo.is_none() { + match psbt_input.non_witness_utxo.as_ref() { + Some(tx) => { + if tx.txid() != outpoint.txid { + return Err(Error::Generic( + "Foreign utxo outpoint does not match PSBT input".into(), + )); + } + if tx.output.len() <= outpoint.vout as usize { + return Err(Error::InvalidOutpoint(outpoint)); + } + } + None => { + return Err(Error::Generic( + "Foreign utxo missing witness_utxo or non_witness_utxo".into(), + )) + } + } + } + + self.params.utxos.push(WeightedUtxo { + satisfaction_weight, + utxo: Utxo::Foreign { + outpoint, + psbt_input: Box::new(psbt_input), + }, + }); + + Ok(self) + } + /// Only spend utxos added by [`add_utxo`]. /// /// The wallet will **not** add additional utxos to the transaction even if they are needed to