diff --git a/CHANGELOG.md b/CHANGELOG.md index 73188e107..90e7c970a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- added `OldestFirstCoinSelection` impl to `CoinSelectionAlgorithm` +- Added `OldestFirstCoinSelection` impl to `CoinSelectionAlgorithm` - New MSRV set to `1.56` - Unpinned tokio to `1` - Add traits to reuse `Blockchain`s across multiple wallets (`BlockchainFactory` and `StatelessBlockchain`). @@ -19,6 +19,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Signing Taproot PSBTs (key spend and script spend) - Support for `tr()` descriptors in the `descriptor!()` macro - Add support for Bitcoin Core 23.0 when using the `rpc` blockchain +- Added `Waste` struct to `coinselection` module, with impl of + `Waste::calculate` to compute waste metric for coin selection algorithms. +- Added new type `WeightedTxOut`. It joins a TxOut with the satisfaction weight + needed to spend it in the future. +- Added new field `drain_output` to struct `CoinSelectionResult`. It'll hold + the created change output if needed. +- Added `weighted_drain_output` parameter for + `CoinSelectionAlgorithm::coin_select` to pass the TxOut to drain the change + joined with the satisfaction weight to spend it in the future. +- Changed `OutputGroup` owned `weighted_utxo` value to borrowed one. ## [v0.18.0] - [v0.17.0] diff --git a/src/types.rs b/src/types.rs index fc81bc278..66c1d80fc 100644 --- a/src/types.rs +++ b/src/types.rs @@ -147,6 +147,19 @@ pub struct WeightedUtxo { pub utxo: Utxo, } +/// A [`TxOut`] with its `satisfaction_weight`, if it were spend by the wallet owning the produced +/// UTXO from this TxOut. +#[derive(Debug, Clone, PartialEq)] +pub struct WeightedTxOut { + /// The weight of the witness data and `scriptSig` expressed in [weight units]. This is used to + /// properly compute the cost of change for waste metric. + /// + /// [weight units]: https://en.bitcoin.it/wiki/Weight_units + pub satisfaction_weight: usize, + /// The TxOut + pub txout: TxOut, +} + #[derive(Debug, Clone, PartialEq)] /// An unspent transaction output (UTXO). pub enum Utxo { diff --git a/src/wallet/coin_selection.rs b/src/wallet/coin_selection.rs index 7b4a48334..1c0006c7f 100644 --- a/src/wallet/coin_selection.rs +++ b/src/wallet/coin_selection.rs @@ -42,6 +42,7 @@ //! fee_rate: FeeRate, //! amount_needed: u64, //! fee_amount: u64, +//! weighted_drain_output: WeightedTxOut, //! ) -> Result { //! let mut selected_amount = 0; //! let mut additional_weight = 0; @@ -53,7 +54,7 @@ //! |(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) +//! Some(weighted_utxo) //! }, //! ) //! .collect::>(); @@ -66,9 +67,18 @@ //! }); //! } //! +//! let calculated_waste = Waste::calculate( +//! &all_utxos_selected, +//! weighted_drain_output, +//! amount_needed, +//! fee_rate, +//! )?; +//! //! Ok(CoinSelectionResult { -//! selected: all_utxos_selected, +//! selected: all_utxos_selected.into_iter().map(|u| u.utxo).collect(), //! fee_amount: fee_amount + additional_fees, +//! waste: calculated_waste.0, +//! drain_output: calculated_waste.1, //! }) //! } //! } @@ -88,10 +98,14 @@ //! # Ok::<(), bdk::Error>(()) //! ``` -use crate::types::FeeRate; +use crate::types::{FeeRate, WeightedTxOut}; +use crate::wallet::utils::IsDust; use crate::{database::Database, WeightedUtxo}; use crate::{error::Error, Utxo}; +use bitcoin::consensus::encode::serialize; +use bitcoin::TxOut; + use rand::seq::SliceRandom; #[cfg(not(test))] use rand::thread_rng; @@ -118,6 +132,10 @@ pub struct CoinSelectionResult { pub selected: Vec, /// Total fee amount in satoshi pub fee_amount: u64, + /// Waste value of current coin selection + pub waste: Waste, + /// Output to drain change amount + pub drain_output: Option, } impl CoinSelectionResult { @@ -138,6 +156,122 @@ impl CoinSelectionResult { } } +/// Metric introduced to measure the performance of different coin selection algorithms. +/// +/// This implementation considers "waste" the sum of two values: +/// * Timing cost +/// * Creation cost +/// > waste = timing_cost + creation_cost +/// +/// **Timing cost** is the cost associated with the current fee rate and some long term fee rate used +/// as a threshold to consolidate UTXOs. +/// > timing_cost = txin_size * current_fee_rate - txin_size * long_term_fee_rate +/// +/// Timing cost can be negative if the `current_fee_rate` is cheaper than the `long_term_fee_rate`, +/// or zero if they are equal. +/// +/// **Creation cost** is the cost associated with the surplus of coins beyond the transaction amount +/// and transaction fees. It can appear in the form of a change output or in the form of excess +/// fees paid to the miner. +/// +/// Change cost is derived from the cost of adding the extra output to the transaction and spending +/// that output in the future. +/// > cost_of_change = current_fee_rate * change_output_size + long_term_feerate * change_spend_size +/// +/// Excess happens when there is no change, and the surplus of coins is spend as part of the fees +/// to the miner: +/// > excess = tx_total_value - tx_fees - target +/// +/// Where _target_ is the amount needed to pay for the fees (minus input fees) and to fulfill the +/// output values of the transaction. +/// > target = sum(tx_outputs) + fee(tx_outputs) + fee(fixed_tx_parts) +/// +/// Creation cost can be zero if there is a perfect match as result of the coin selection +/// algorithm. +/// +/// So, waste can be zero if creation and timing cost are zero. Or can be negative, if timing cost +/// is negative and the creation cost is low enough (less than the absolute value of timing +/// cost). +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Waste(pub i64); +// REVIEW: Add change_output field inside Waste struct? + +const LONG_TERM_FEE_RATE: FeeRate = FeeRate::from_sat_per_vb(5.0); + +impl Waste { + /// Calculate the amount of waste for the given coin selection + /// + /// - `selected`: the selected transaction inputs + /// - `weighted_drain_output`: transaction output intended to drain change plus it's spending + /// satisfaction weight. If script_pubkey belongs to a foreign descriptor, it's + /// satisfaction weight is zero. + /// - `target`: threshold in satoshis used to select UTXOs. It includes the sum of recipient + /// outputs, the fees for creating the recipient outputs and the fees for fixed transaction + /// parts + /// - `fee_rate`: fee rate to use + pub fn calculate( + selected: &[WeightedUtxo], + weighted_drain_output: WeightedTxOut, + target: u64, + fee_rate: FeeRate, + ) -> Result<(Waste, Option), Error> { + // Always consider the cost of spending an input now vs in the future. + let utxo_groups: Vec<_> = selected + .iter() + .map(|u| OutputGroup::new(u, fee_rate)) + .collect(); + + // If fee_rate < LONG_TERM_FEE_RATE, timing cost can be negative + let timing_cost: i64 = utxo_groups.iter().fold(0, |acc, utxo| { + let fee: i64 = utxo.fee as i64; + let long_term_fee: i64 = LONG_TERM_FEE_RATE + .fee_wu(TXIN_BASE_WEIGHT + utxo.weighted_utxo.satisfaction_weight) + as i64; + + acc + fee - long_term_fee + }); + + // selected_effective_value: selected amount with fee discount for the selected utxos + let selected_effective_value: i64 = utxo_groups + .iter() + .fold(0, |acc, utxo| acc + utxo.effective_value); + + // target: sum(tx_outputs) + fees(tx_outputs) + fee(fixed_tx_parts) + // excess should always be greater or equal to zero + let excess = (selected_effective_value - target as i64) as u64; + + let mut drain_output = weighted_drain_output.txout; + + // change output fee + let change_output_cost = fee_rate.fee_vb(serialize(&drain_output).len()); + + let drain_val = excess.saturating_sub(change_output_cost); + + let mut change_output = None; + + // excess < change_output_size x fee_rate + dust_value + let creation_cost = if drain_val.is_dust(&drain_output.script_pubkey) { + // throw excess to fees + excess + } else { + // recover excess as change + drain_output.value = drain_val; + + let change_input_cost = LONG_TERM_FEE_RATE + .fee_wu(TXIN_BASE_WEIGHT + weighted_drain_output.satisfaction_weight); + + let cost_of_change = change_output_cost + change_input_cost; + + // return change output + change_output = Some(drain_output); + + cost_of_change + }; + + Ok((Waste(timing_cost + creation_cost as i64), change_output)) + } +} + /// Trait for generalized coin selection algorithms /// /// This trait can be implemented to make the [`Wallet`](super::Wallet) use a customized coin @@ -157,6 +291,10 @@ pub trait CoinSelectionAlgorithm: std::fmt::Debug { /// - `amount_needed`: the amount in satoshi to select /// - `fee_amount`: the amount of fees in satoshi already accumulated from adding outputs and /// the transaction's header + /// - `weighted_drain_output`: transaction output intended to drain change plus it's spending + /// satisfaction weight. If script_pubkey belongs to a foreign descriptor, it's + /// satisfaction weight is zero. + #[allow(clippy::too_many_arguments)] fn coin_select( &self, database: &D, @@ -165,6 +303,7 @@ pub trait CoinSelectionAlgorithm: std::fmt::Debug { fee_rate: FeeRate, amount_needed: u64, fee_amount: u64, + weighted_drain_output: WeightedTxOut, ) -> Result; } @@ -184,6 +323,7 @@ impl CoinSelectionAlgorithm for LargestFirstCoinSelection { fee_rate: FeeRate, amount_needed: u64, fee_amount: u64, + _weighted_drain_output: WeightedTxOut, ) -> Result { log::debug!( "amount_needed = `{}`, fee_amount = `{}`, fee_rate = `{:?}`", @@ -222,6 +362,7 @@ impl CoinSelectionAlgorithm for OldestFirstCoinSelection { fee_rate: FeeRate, amount_needed: u64, fee_amount: u64, + _weighted_drain_output: WeightedTxOut, ) -> Result { // query db and create a blockheight lookup table let blockheights = optional_utxos @@ -303,24 +444,27 @@ fn select_sorted_utxos( }); } + // TODO: Calculate waste metric for selected coins Ok(CoinSelectionResult { selected, fee_amount, + waste: Waste(0), + drain_output: None, }) } #[derive(Debug, Clone)] // Adds fee information to an UTXO. -struct OutputGroup { - weighted_utxo: WeightedUtxo, +struct OutputGroup<'u> { + weighted_utxo: &'u WeightedUtxo, // Amount of fees for spending a certain utxo, calculated using a certain FeeRate fee: u64, // The effective value of the UTXO, i.e., the utxo value minus the fee for spending it effective_value: i64, } -impl OutputGroup { - fn new(weighted_utxo: WeightedUtxo, fee_rate: FeeRate) -> Self { +impl<'u> OutputGroup<'u> { + fn new(weighted_utxo: &'u WeightedUtxo, fee_rate: FeeRate) -> Self { let fee = fee_rate.fee_wu(TXIN_BASE_WEIGHT + weighted_utxo.satisfaction_weight); let effective_value = weighted_utxo.utxo.txout().value as i64 - fee as i64; OutputGroup { @@ -366,16 +510,17 @@ impl CoinSelectionAlgorithm for BranchAndBoundCoinSelection { fee_rate: FeeRate, amount_needed: u64, fee_amount: u64, + _weighted_drain_output: WeightedTxOut, ) -> Result { // Mapping every (UTXO, usize) to an output group - let required_utxos: Vec = required_utxos - .into_iter() + let required_utxos: Vec> = required_utxos + .iter() .map(|u| OutputGroup::new(u, fee_rate)) .collect(); // Mapping every (UTXO, usize) to an output group. - let optional_utxos: Vec = optional_utxos - .into_iter() + let optional_utxos: Vec> = optional_utxos + .iter() .map(|u| OutputGroup::new(u, fee_rate)) .collect(); @@ -443,8 +588,8 @@ impl BranchAndBoundCoinSelection { #[allow(clippy::too_many_arguments)] fn bnb( &self, - required_utxos: Vec, - mut optional_utxos: Vec, + required_utxos: Vec>, + mut optional_utxos: Vec>, mut curr_value: i64, mut curr_available_value: i64, actual_target: i64, @@ -552,8 +697,8 @@ impl BranchAndBoundCoinSelection { fn single_random_draw( &self, - required_utxos: Vec, - mut optional_utxos: Vec, + required_utxos: Vec>, + mut optional_utxos: Vec>, curr_value: i64, actual_target: i64, fee_amount: u64, @@ -582,21 +727,24 @@ impl BranchAndBoundCoinSelection { BranchAndBoundCoinSelection::calculate_cs_result(selected_utxos, required_utxos, fee_amount) } - fn calculate_cs_result( - mut selected_utxos: Vec, - mut required_utxos: Vec, + fn calculate_cs_result<'u>( + mut selected_utxos: Vec>, + mut required_utxos: Vec>, mut fee_amount: u64, ) -> CoinSelectionResult { selected_utxos.append(&mut required_utxos); fee_amount += selected_utxos.iter().map(|u| u.fee).sum::(); let selected = selected_utxos .into_iter() - .map(|u| u.weighted_utxo.utxo) + .map(|u| u.weighted_utxo.utxo.clone()) .collect::>(); + // TODO: Calculate waste metric for selected coins CoinSelectionResult { selected, fee_amount, + waste: Waste(0), + drain_output: None, } } } @@ -649,6 +797,16 @@ mod test { ] } + fn get_test_weighted_txout() -> WeightedTxOut { + WeightedTxOut { + satisfaction_weight: P2WPKH_WITNESS_SIZE, + txout: TxOut { + value: 0, + script_pubkey: Script::new(), + }, + } + } + fn setup_database_and_get_oldest_first_test_utxos( database: &mut D, ) -> Vec { @@ -745,6 +903,27 @@ mod test { vec![utxo; utxos_number] } + fn generate_utxos_of_values(utxos_values: Vec) -> Vec { + utxos_values + .into_iter() + .map(|value| WeightedUtxo { + satisfaction_weight: P2WPKH_WITNESS_SIZE, + utxo: Utxo::Local(LocalUtxo { + outpoint: OutPoint::from_str( + "ebd9813ecebc57ff8f30797de7c205e3c7498ca950ea4341ee51a685ff2fa30a:0", + ) + .unwrap(), + txout: TxOut { + value, + script_pubkey: Script::new(), + }, + keychain: KeychainKind::External, + is_spent: false, + }), + }) + .collect() + } + 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); @@ -759,6 +938,8 @@ mod test { let utxos = get_test_utxos(); let database = MemoryDatabase::default(); + let weighted_drain_txout = get_test_weighted_txout(); + let result = LargestFirstCoinSelection::default() .coin_select( &database, @@ -767,6 +948,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 250_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); @@ -780,6 +962,14 @@ mod test { let utxos = get_test_utxos(); let database = MemoryDatabase::default(); + let weighted_drain_txout = WeightedTxOut { + txout: TxOut { + value: 0, + script_pubkey: Script::new(), + }, + satisfaction_weight: 0, + }; + let result = LargestFirstCoinSelection::default() .coin_select( &database, @@ -788,6 +978,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 20_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); @@ -801,6 +992,8 @@ mod test { let utxos = get_test_utxos(); let database = MemoryDatabase::default(); + let weighted_drain_txout = get_test_weighted_txout(); + let result = LargestFirstCoinSelection::default() .coin_select( &database, @@ -809,6 +1002,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 20_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); @@ -823,6 +1017,8 @@ mod test { let utxos = get_test_utxos(); let database = MemoryDatabase::default(); + let weighted_drain_txout = get_test_weighted_txout(); + LargestFirstCoinSelection::default() .coin_select( &database, @@ -831,6 +1027,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 500_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); } @@ -841,6 +1038,8 @@ mod test { let utxos = get_test_utxos(); let database = MemoryDatabase::default(); + let weighted_drain_txout = get_test_weighted_txout(); + LargestFirstCoinSelection::default() .coin_select( &database, @@ -849,6 +1048,7 @@ mod test { FeeRate::from_sat_per_vb(1000.0), 250_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); } @@ -858,6 +1058,8 @@ mod test { let mut database = MemoryDatabase::default(); let utxos = setup_database_and_get_oldest_first_test_utxos(&mut database); + let weighted_drain_txout = get_test_weighted_txout(); + let result = OldestFirstCoinSelection::default() .coin_select( &database, @@ -866,6 +1068,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 180_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); @@ -914,6 +1117,8 @@ mod test { database.set_tx(&utxo1_tx_details).unwrap(); database.set_tx(&utxo2_tx_details).unwrap(); + let weighted_drain_txout = get_test_weighted_txout(); + let result = OldestFirstCoinSelection::default() .coin_select( &database, @@ -922,6 +1127,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 180_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); @@ -935,6 +1141,8 @@ mod test { let mut database = MemoryDatabase::default(); let utxos = setup_database_and_get_oldest_first_test_utxos(&mut database); + let weighted_drain_txout = get_test_weighted_txout(); + let result = OldestFirstCoinSelection::default() .coin_select( &database, @@ -943,6 +1151,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 20_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); @@ -956,6 +1165,8 @@ mod test { let mut database = MemoryDatabase::default(); let utxos = setup_database_and_get_oldest_first_test_utxos(&mut database); + let weighted_drain_txout = get_test_weighted_txout(); + let result = OldestFirstCoinSelection::default() .coin_select( &database, @@ -964,6 +1175,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 20_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); @@ -978,6 +1190,8 @@ mod test { let mut database = MemoryDatabase::default(); let utxos = setup_database_and_get_oldest_first_test_utxos(&mut database); + let weighted_drain_txout = get_test_weighted_txout(); + OldestFirstCoinSelection::default() .coin_select( &database, @@ -986,6 +1200,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 600_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); } @@ -999,6 +1214,8 @@ mod test { let amount_needed: u64 = utxos.iter().map(|wu| wu.utxo.txout().value).sum::() - (FEE_AMOUNT + 50); + let weighted_drain_txout = get_test_weighted_txout(); + OldestFirstCoinSelection::default() .coin_select( &database, @@ -1007,6 +1224,7 @@ mod test { FeeRate::from_sat_per_vb(1000.0), amount_needed, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); } @@ -1019,6 +1237,8 @@ mod test { let database = MemoryDatabase::default(); + let weighted_drain_txout = get_test_weighted_txout(); + let result = BranchAndBoundCoinSelection::default() .coin_select( &database, @@ -1027,6 +1247,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 250_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); @@ -1040,6 +1261,8 @@ mod test { let utxos = get_test_utxos(); let database = MemoryDatabase::default(); + let weighted_drain_txout = get_test_weighted_txout(); + let result = BranchAndBoundCoinSelection::default() .coin_select( &database, @@ -1048,6 +1271,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 20_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); @@ -1061,6 +1285,8 @@ mod test { let utxos = get_test_utxos(); let database = MemoryDatabase::default(); + let weighted_drain_txout = get_test_weighted_txout(); + let result = BranchAndBoundCoinSelection::default() .coin_select( &database, @@ -1069,6 +1295,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 299756, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); @@ -1092,6 +1319,8 @@ mod test { let amount: u64 = optional.iter().map(|u| u.utxo.txout().value).sum(); assert!(amount > 150_000); + let weighted_drain_txout = get_test_weighted_txout(); + let result = BranchAndBoundCoinSelection::default() .coin_select( &database, @@ -1100,6 +1329,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 150_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); @@ -1114,6 +1344,8 @@ mod test { let utxos = get_test_utxos(); let database = MemoryDatabase::default(); + let weighted_drain_txout = get_test_weighted_txout(); + BranchAndBoundCoinSelection::default() .coin_select( &database, @@ -1122,6 +1354,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 500_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); } @@ -1132,6 +1365,8 @@ mod test { let utxos = get_test_utxos(); let database = MemoryDatabase::default(); + let weighted_drain_txout = get_test_weighted_txout(); + BranchAndBoundCoinSelection::default() .coin_select( &database, @@ -1140,6 +1375,7 @@ mod test { FeeRate::from_sat_per_vb(1000.0), 250_000, FEE_AMOUNT, + weighted_drain_txout, ) .unwrap(); } @@ -1149,6 +1385,8 @@ mod test { let utxos = get_test_utxos(); let database = MemoryDatabase::default(); + let weighted_drain_txout = get_test_weighted_txout(); + let result = BranchAndBoundCoinSelection::new(0) .coin_select( &database, @@ -1157,6 +1395,7 @@ mod test { FeeRate::from_sat_per_vb(1.0), 99932, // first utxo's effective value 0, + weighted_drain_txout, ) .unwrap(); @@ -1176,6 +1415,8 @@ mod test { for _i in 0..200 { let mut optional_utxos = generate_random_utxos(&mut rng, 16); let target_amount = sum_random_utxos(&mut rng, &mut optional_utxos); + let weighted_drain_txout = get_test_weighted_txout(); + let result = BranchAndBoundCoinSelection::new(0) .coin_select( &database, @@ -1184,6 +1425,7 @@ mod test { FeeRate::from_sat_per_vb(0.0), target_amount, 0, + weighted_drain_txout, ) .unwrap(); assert_eq!(result.selected_amount(), target_amount); @@ -1194,8 +1436,9 @@ mod test { #[should_panic(expected = "BnBNoExactMatch")] fn test_bnb_function_no_exact_match() { let fee_rate = FeeRate::from_sat_per_vb(10.0); - let utxos: Vec = get_test_utxos() - .into_iter() + let test_utxos = get_test_utxos(); + let utxos: Vec> = test_utxos + .iter() .map(|u| OutputGroup::new(u, fee_rate)) .collect(); @@ -1220,8 +1463,9 @@ mod test { #[should_panic(expected = "BnBTotalTriesExceeded")] fn test_bnb_function_tries_exceeded() { let fee_rate = FeeRate::from_sat_per_vb(10.0); - let utxos: Vec = generate_same_value_utxos(100_000, 100_000) - .into_iter() + let same_value_utxos = generate_same_value_utxos(100_000, 100_000); + let utxos: Vec = same_value_utxos + .iter() .map(|u| OutputGroup::new(u, fee_rate)) .collect(); @@ -1250,8 +1494,9 @@ mod test { let size_of_change = 31; let cost_of_change = size_of_change as f32 * fee_rate.as_sat_vb(); - let utxos: Vec<_> = generate_same_value_utxos(50_000, 10) - .into_iter() + let same_value_utxos = generate_same_value_utxos(50_000, 10); + let utxos: Vec<_> = same_value_utxos + .iter() .map(|u| OutputGroup::new(u, fee_rate)) .collect(); @@ -1286,8 +1531,9 @@ mod test { let fee_rate = FeeRate::from_sat_per_vb(0.0); for _ in 0..200 { - let optional_utxos: Vec<_> = generate_random_utxos(&mut rng, 40) - .into_iter() + let random_utxos = generate_random_utxos(&mut rng, 40); + let optional_utxos: Vec<_> = random_utxos + .iter() .map(|u| OutputGroup::new(u, fee_rate)) .collect(); @@ -1323,8 +1569,8 @@ mod test { let target_amount = sum_random_utxos(&mut rng, &mut utxos); let fee_rate = FeeRate::from_sat_per_vb(1.0); - let utxos: Vec = utxos - .into_iter() + let utxos: Vec> = utxos + .iter() .map(|u| OutputGroup::new(u, fee_rate)) .collect(); @@ -1339,4 +1585,154 @@ mod test { assert!(result.selected_amount() > target_amount); assert_eq!(result.fee_amount, (50 + result.selected.len() * 68) as u64); } + + #[test] + fn test_calculate_waste_with_change_output() { + let fee_rate = LONG_TERM_FEE_RATE; + let selected = generate_utxos_of_values(vec![100_000_000, 200_000_000]); + let weighted_drain_output = get_test_weighted_txout(); + let script_pubkey = weighted_drain_output.txout.script_pubkey.clone(); + + let utxos: Vec> = selected + .iter() + .map(|u| OutputGroup::new(u, fee_rate)) + .collect(); + + let change_output_cost = fee_rate.fee_vb(serialize(&weighted_drain_output.txout).len()); + let change_input_cost = + LONG_TERM_FEE_RATE.fee_wu(TXIN_BASE_WEIGHT + weighted_drain_output.satisfaction_weight); + + let cost_of_change = change_output_cost + change_input_cost; + + //selected_effective_value: selected amount with fee discount for the selected utxos + let selected_effective_value: i64 = + utxos.iter().fold(0, |acc, utxo| acc + utxo.effective_value); + + let excess = 2 * script_pubkey.dust_value().as_sat(); + + // change final value after deducing fees + let drain_val = excess.saturating_sub(change_output_cost); + + // choose target to create excess greater than dust + let target = (selected_effective_value - excess as i64) as u64; + + let timing_cost: i64 = utxos.iter().fold(0, |acc, utxo| { + let fee: i64 = utxo.fee as i64; + let long_term_fee: i64 = LONG_TERM_FEE_RATE + .fee_wu(TXIN_BASE_WEIGHT + utxo.weighted_utxo.satisfaction_weight) + as i64; + + acc + fee - long_term_fee + }); + + // Waste with change output + let waste_and_change = + Waste::calculate(&selected, weighted_drain_output, target, fee_rate).unwrap(); + + let change_output = waste_and_change.1.unwrap(); + + assert_eq!( + waste_and_change.0, + Waste(timing_cost + cost_of_change as i64) + ); + assert_eq!( + change_output, + TxOut { + script_pubkey, + value: drain_val + } + ); + assert!(!change_output.value.is_dust(&change_output.script_pubkey)); + } + + #[test] + fn test_calculate_waste_without_change() { + let fee_rate = FeeRate::from_sat_per_vb(11.0); + let selected = generate_utxos_of_values(vec![100_000_000]); + let target = 99_999_000; + let weighted_drain_output = get_test_weighted_txout(); + + let utxos: Vec> = selected + .iter() + .map(|u| OutputGroup::new(u, fee_rate)) + .collect(); + + let utxo_fee_diff: i64 = utxos.iter().fold(0, |acc, utxo| { + let fee: i64 = utxo.fee as i64; + let long_term_fee: i64 = LONG_TERM_FEE_RATE + .fee_wu(TXIN_BASE_WEIGHT + utxo.weighted_utxo.satisfaction_weight) + as i64; + + acc + fee - long_term_fee + }); + + //selected_effective_value: selected amount with fee discount for the selected utxos + let selected_effective_value: i64 = + utxos.iter().fold(0, |acc, utxo| acc + utxo.effective_value); + + // excess should always be greater or equal to zero + let excess = (selected_effective_value - target as i64) as u64; + + // Waste with change output + let waste_and_change = + Waste::calculate(&selected, weighted_drain_output, target, fee_rate).unwrap(); + + assert_eq!(waste_and_change.0, Waste(utxo_fee_diff + excess as i64)); + assert_eq!(waste_and_change.1, None); + } + + #[test] + fn test_calculate_waste_with_negative_timing_cost() { + let fee_rate = LONG_TERM_FEE_RATE - FeeRate::from_sat_per_vb(2.0); + let selected = generate_utxos_of_values(vec![200_000_000]); + let weighted_drain_output = get_test_weighted_txout(); + + let utxos: Vec> = selected + .iter() + .map(|u| OutputGroup::new(u, fee_rate)) + .collect(); + + let timing_cost: i64 = utxos.iter().fold(0, |acc, utxo| { + let fee: i64 = utxo.fee as i64; + let long_term_fee: i64 = LONG_TERM_FEE_RATE + .fee_wu(TXIN_BASE_WEIGHT + utxo.weighted_utxo.satisfaction_weight) + as i64; + + acc + fee - long_term_fee + }); + + //selected_effective_value: selected amount with fee discount for the selected utxos + let target = utxos.iter().fold(0, |acc, utxo| acc + utxo.effective_value) as u64; + + // Waste with change output + let waste_and_change = + Waste::calculate(&selected, weighted_drain_output, target, fee_rate).unwrap(); + + assert!(timing_cost < 0); + assert_eq!(waste_and_change.0, Waste(timing_cost)); + assert_eq!(waste_and_change.1, None); + } + + #[test] + fn test_calculate_waste_with_no_timing_cost_and_no_creation_cost() { + let fee_rate = LONG_TERM_FEE_RATE; + let utxo_values = vec![200_000_000]; + let selected = generate_utxos_of_values(utxo_values.clone()); + let weighted_drain_output = get_test_weighted_txout(); + + let utxos_fee: u64 = + LONG_TERM_FEE_RATE.fee_wu(TXIN_BASE_WEIGHT + selected[0].satisfaction_weight); + + // Build target to avoid any excess + let target = utxo_values[0] - utxos_fee; + + // Waste with change output + let waste_and_change = + Waste::calculate(&selected, weighted_drain_output, target, fee_rate).unwrap(); + + // There shouldn't be any waste or change output if there is no timing_cost nor + // creation_cost + assert_eq!(waste_and_change.0, Waste(0)); + assert_eq!(waste_and_change.1, None); + } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 6d2ff385e..b263b6fde 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -749,6 +749,14 @@ where params.bumping_fee.is_some(), // we mandate confirmed transactions if we're bumping the fee )?; + let weighted_drain_txout = WeightedTxOut { + txout: TxOut { + value: 0, + script_pubkey: Script::new(), + }, + satisfaction_weight: 0, + }; + let coin_selection = coin_selection.coin_select( self.database.borrow().deref(), required_utxos, @@ -756,6 +764,7 @@ where fee_rate, outgoing, fee_amount, + weighted_drain_txout, )?; let mut fee_amount = coin_selection.fee_amount;