diff --git a/README.md b/README.md index dd1493f..011af15 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ A basic performance comparison between this current [Rust BnB](https://github.co |implementation|pool size|ns/iter| |-------------:|---------|-------| -| Rust BnB| 1,000|936,560| +| Rust BnB| 1,000|819,320| | C++ Core BnB| 1,000|816,374| ## Minimum Supported Rust Version (MSRV) diff --git a/benches/coin_selection.rs b/benches/coin_selection.rs index 48c9895..286f3ec 100644 --- a/benches/coin_selection.rs +++ b/benches/coin_selection.rs @@ -1,6 +1,7 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion}; use bitcoin::Amount; +use bitcoin::FeeRate; use bitcoin::ScriptBuf; use bitcoin::TxOut; use bitcoin::Weight; @@ -30,7 +31,8 @@ pub fn criterion_benchmark(c: &mut Criterion) { let result = select_coins_bnb( black_box(target), black_box(cost_of_change), - black_box(&mut utxo_pool.clone()), + black_box(FeeRate::ZERO), + black_box(&mut utxo_pool), ) .unwrap(); assert_eq!(2, result.len()); diff --git a/src/branch_and_bound.rs b/src/branch_and_bound.rs index 3072d97..5fa164a 100644 --- a/src/branch_and_bound.rs +++ b/src/branch_and_bound.rs @@ -6,6 +6,7 @@ use crate::WeightedUtxo; use bitcoin::Amount; +use bitcoin::FeeRate; /// Select coins bnb performs a depth first branch and bound search on binary tree. /// @@ -25,6 +26,7 @@ use bitcoin::Amount; /// # Arguments /// * target: Target spend `Amount` /// * cost_of_change: The `Amount` needed to produce a change output +/// * fee_rate: `FeeRate` used to calculate each effective_value output value /// * weighted_utxos: The candidate Weighted UTXOs from which to choose a selection from // This search can be thought of as exploring a binary tree where the left branch is the inclusion @@ -105,8 +107,9 @@ use bitcoin::Amount; pub fn select_coins_bnb( target: Amount, cost_of_change: Amount, + fee_rate: FeeRate, weighted_utxos: &mut [WeightedUtxo], -) -> Option> { +) -> Option> { // Total_Tries in Core: // https://github.com/bitcoin/bitcoin/blob/1d9da8da309d1dbf9aef15eb8dc43b4a2dc3d309/src/wallet/coinselection.cpp#L74 const ITERATION_LIMIT: i32 = 100_000; @@ -121,14 +124,32 @@ pub fn select_coins_bnb( let mut index_selection: Vec = vec![]; let mut best_selection: Option> = None; - let mut available_value: Amount = weighted_utxos.iter().map(|u| u.utxo.value).sum(); + + // Create a tuple of (effective_value, weighted_utxo) and filter out negative values + // and errors (effective_values that are None). The effective_value will be used + // during the coin-selection process, and the weighted_utxo is index into on return. + let mut effective_values: Vec<(Amount, &WeightedUtxo)> = weighted_utxos + .iter() + .map(|wu| (wu.effective_value(fee_rate), wu)) + .filter(|(eff_val, _)| !eff_val.is_none()) + .map(|(eff_val, wu)| (eff_val.unwrap(), wu)) + .filter(|(eff_val, _)| eff_val.is_positive()) + .map(|(eff_val, wu)| (eff_val.to_unsigned().unwrap(), wu)) + .collect(); + + effective_values.sort_by_key(|u| u.0); + effective_values.reverse(); + + // TODO check for overflow + let mut available_value = effective_values.iter().fold(Amount::ZERO, |mut s, &(v, _)| { + s += v; + s + }); if available_value < target { return Some(Vec::new()); } - weighted_utxos.sort_by(|a, b| b.utxo.value.cmp(&a.utxo.value)); - while iteration < ITERATION_LIMIT { // There are two conditions for backtracking: // @@ -216,7 +237,8 @@ pub fn select_coins_bnb( // * Backtrack if backtrack { let last_index = index_selection.pop().unwrap(); - value -= weighted_utxos[last_index].utxo.value; + let (eff_value, _) = effective_values[last_index]; + value -= eff_value; index -= 1; assert_eq!(index, last_index); } @@ -224,23 +246,24 @@ pub fn select_coins_bnb( else if backtrack_subtree { // No new subtree left to explore. if index_selection.is_empty() { - return index_to_utxo_list(best_selection, weighted_utxos); + return index_to_utxo_list(best_selection, effective_values); } // Anchor the new subtree at the next available index. // The next iteration, the index will be incremented by one. index = index_selection[0]; - // The available value of the next iteration. + // The available value of the next iteration. This should never overflow + // since the value is always less than the last available_value calculation. available_value = - Amount::from_sat(weighted_utxos[index + 1..].iter().fold(0u64, |mut s, u| { - s += u.utxo.value.to_sat(); + effective_values[index + 1..].iter().fold(Amount::ZERO, |mut s, &(v, _)| { + s += v; s - })); + }); // If the new subtree does not have enough value, we are done searching. if available_value < target { - return index_to_utxo_list(best_selection, weighted_utxos); + return index_to_utxo_list(best_selection, effective_values); } // Start a new selection and add the root of the new subtree to the index selection. @@ -249,25 +272,25 @@ pub fn select_coins_bnb( } // * Add next node to the inclusion branch. else { - let utxo_value = weighted_utxos[index].utxo.value; + let (eff_value, _) = effective_values[index]; index_selection.push(index); - value += utxo_value; - available_value -= utxo_value; + value += eff_value; + available_value -= eff_value; } index += 1; iteration += 1; } - index_to_utxo_list(best_selection, weighted_utxos) + index_to_utxo_list(best_selection, effective_values) } fn index_to_utxo_list( index_list: Option>, - weighted_utxos: &mut [WeightedUtxo], -) -> Option> { - index_list.map(|i_list| i_list.iter().map(|i: &usize| weighted_utxos[*i].clone()).collect()) + effective_values: Vec<(Amount, &WeightedUtxo)>, +) -> Option> { + index_list.map(|i_list| i_list.iter().map(|i: &usize| effective_values[*i].1).collect()) } #[cfg(test)] @@ -296,111 +319,128 @@ mod tests { .collect() } - fn expected_list( - index_list: Vec, - weighted_utxos: &mut [WeightedUtxo], - ) -> Vec { - index_to_utxo_list(Some(index_list), weighted_utxos).unwrap() - } - #[test] fn one() { let target = Amount::from_str("1 cBTC").unwrap(); let mut weighted_utxos = create_weighted_utxos(); - let expected_i_list = vec![3]; - let list = select_coins_bnb(target, Amount::ZERO, &mut weighted_utxos).unwrap(); - assert_eq!(list, expected_list(expected_i_list, &mut weighted_utxos)); + let list = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].utxo.value, Amount::from_str("1 cBTC").unwrap()); } #[test] fn two() { let target = Amount::from_str("2 cBTC").unwrap(); let mut weighted_utxos = create_weighted_utxos(); - let expected_i_list = vec![2]; - let list = select_coins_bnb(target, Amount::ZERO, &mut weighted_utxos).unwrap(); - assert_eq!(list, expected_list(expected_i_list, &mut weighted_utxos)); + let list = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].utxo.value, Amount::from_str("2 cBTC").unwrap()); } #[test] fn three() { let target = Amount::from_str("3 cBTC").unwrap(); let mut weighted_utxos = create_weighted_utxos(); - let expected_i_list = vec![2, 3]; - let list = select_coins_bnb(target, Amount::ZERO, &mut weighted_utxos).unwrap(); - assert_eq!(list, expected_list(expected_i_list, &mut weighted_utxos)); + let list = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list[0].utxo.value, Amount::from_str("2 cBTC").unwrap()); + assert_eq!(list[1].utxo.value, Amount::from_str("1 cBTC").unwrap()); } #[test] fn four() { let target = Amount::from_str("4 cBTC").unwrap(); let mut weighted_utxos = create_weighted_utxos(); - let expected_i_list = vec![1, 3]; - let list = select_coins_bnb(target, Amount::ZERO, &mut weighted_utxos).unwrap(); - assert_eq!(list, expected_list(expected_i_list, &mut weighted_utxos)); + let list = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list[0].utxo.value, Amount::from_str("3 cBTC").unwrap()); + assert_eq!(list[1].utxo.value, Amount::from_str("1 cBTC").unwrap()); } #[test] fn five() { let target = Amount::from_str("5 cBTC").unwrap(); let mut weighted_utxos = create_weighted_utxos(); - let expected_i_list = vec![1, 2]; - let list = select_coins_bnb(target, Amount::ZERO, &mut weighted_utxos).unwrap(); - assert_eq!(list, expected_list(expected_i_list, &mut weighted_utxos)); + let list = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list[0].utxo.value, Amount::from_str("3 cBTC").unwrap()); + assert_eq!(list[1].utxo.value, Amount::from_str("2 cBTC").unwrap()); } #[test] fn six() { let target = Amount::from_str("6 cBTC").unwrap(); let mut weighted_utxos = create_weighted_utxos(); - let expected_i_list = vec![1, 2, 3]; - let list = select_coins_bnb(target, Amount::ZERO, &mut weighted_utxos).unwrap(); - assert_eq!(list, expected_list(expected_i_list, &mut weighted_utxos)); + let list = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + assert_eq!(list.len(), 3); + assert_eq!(list[0].utxo.value, Amount::from_str("3 cBTC").unwrap()); + assert_eq!(list[1].utxo.value, Amount::from_str("2 cBTC").unwrap()); + assert_eq!(list[2].utxo.value, Amount::from_str("1 cBTC").unwrap()); } #[test] fn seven() { let target = Amount::from_str("7 cBTC").unwrap(); let mut weighted_utxos = create_weighted_utxos(); - let expected_i_list = vec![0, 2, 3]; - let list = select_coins_bnb(target, Amount::ZERO, &mut weighted_utxos).unwrap(); - assert_eq!(list, expected_list(expected_i_list, &mut weighted_utxos)); + let list = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + assert_eq!(list.len(), 3); + assert_eq!(list[0].utxo.value, Amount::from_str("4 cBTC").unwrap()); + assert_eq!(list[1].utxo.value, Amount::from_str("2 cBTC").unwrap()); + assert_eq!(list[2].utxo.value, Amount::from_str("1 cBTC").unwrap()); } #[test] fn eight() { let target = Amount::from_str("8 cBTC").unwrap(); let mut weighted_utxos = create_weighted_utxos(); - let expected_i_list = vec![0, 1, 3]; - let list = select_coins_bnb(target, Amount::ZERO, &mut weighted_utxos).unwrap(); - assert_eq!(list, expected_list(expected_i_list, &mut weighted_utxos)); + let list = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + assert_eq!(list.len(), 3); + assert_eq!(list[0].utxo.value, Amount::from_str("4 cBTC").unwrap()); + assert_eq!(list[1].utxo.value, Amount::from_str("3 cBTC").unwrap()); + assert_eq!(list[2].utxo.value, Amount::from_str("1 cBTC").unwrap()); } #[test] fn nine() { let target = Amount::from_str("9 cBTC").unwrap(); let mut weighted_utxos = create_weighted_utxos(); - let expected_i_list = vec![0, 1, 2]; - let list = select_coins_bnb(target, Amount::ZERO, &mut weighted_utxos).unwrap(); - assert_eq!(list, expected_list(expected_i_list, &mut weighted_utxos)); + let list = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + assert_eq!(list.len(), 3); + assert_eq!(list[0].utxo.value, Amount::from_str("4 cBTC").unwrap()); + assert_eq!(list[1].utxo.value, Amount::from_str("3 cBTC").unwrap()); + assert_eq!(list[2].utxo.value, Amount::from_str("2 cBTC").unwrap()); } #[test] fn ten() { let target = Amount::from_str("10 cBTC").unwrap(); let mut weighted_utxos = create_weighted_utxos(); - let expected_i_list = vec![0, 1, 2, 3]; - let list = select_coins_bnb(target, Amount::ZERO, &mut weighted_utxos).unwrap(); - assert_eq!(list, expected_list(expected_i_list, &mut weighted_utxos)); + let list = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + assert_eq!(list.len(), 4); + assert_eq!(list[0].utxo.value, Amount::from_str("4 cBTC").unwrap()); + assert_eq!(list[1].utxo.value, Amount::from_str("3 cBTC").unwrap()); + assert_eq!(list[2].utxo.value, Amount::from_str("2 cBTC").unwrap()); + assert_eq!(list[3].utxo.value, Amount::from_str("1 cBTC").unwrap()); } #[test] @@ -410,9 +450,8 @@ mod tests { // Since cost of change here is one, we accept any solution // between 1 and 2. Range = (1, 2] let cost_of_change = target; - let expected_i_list = vec![0]; - let mut weighted_utxos = vec![WeightedUtxo { + let weighted_utxos = vec![WeightedUtxo { satisfaction_weight: Weight::ZERO, utxo: TxOut { value: Amount::from_str("1.5 cBTC").unwrap(), @@ -420,18 +459,101 @@ mod tests { }, }]; - let list = select_coins_bnb(target, cost_of_change, &mut weighted_utxos.clone()).unwrap(); - assert_eq!(list, expected_list(expected_i_list, &mut weighted_utxos)); + let mut wu = weighted_utxos.clone(); + + let list = select_coins_bnb(target, cost_of_change, FeeRate::ZERO, &mut wu).unwrap(); - let index_list = select_coins_bnb(target, Amount::ZERO, &mut weighted_utxos); + assert_eq!(list.len(), 1); + assert_eq!(list[0].utxo.value, Amount::from_str("1.5 cBTC").unwrap()); + + let index_list = select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut wu); assert_eq!(index_list, None); } + #[test] + fn effective_value() { + let target = Amount::from_str("1 cBTC").unwrap(); + let fee_rate = FeeRate::from_sat_per_kwu(10); + let satisfaction_weight = Weight::from_wu(204); + + let weighted_utxos = vec![WeightedUtxo { + satisfaction_weight, + utxo: TxOut { + // This would be a match using value, however since effective_value is used + // the effective_value is calculated, this will fall short of the target. + value: Amount::from_str("1 cBTC").unwrap(), + script_pubkey: ScriptBuf::new(), + }, + }]; + + let mut wu = weighted_utxos.clone(); + let index_list = select_coins_bnb(target, Amount::ZERO, fee_rate, &mut wu).unwrap(); + assert!(index_list.is_empty()); + } + + #[test] + fn skip_effective_negative_effective_value() { + let target = Amount::from_str("1 cBTC").unwrap(); + let fee_rate = FeeRate::from_sat_per_kwu(10); + let satisfaction_weight = Weight::from_wu(204); + + // Since cost of change here is one, we accept any solution + // between 1 and 2. Range = (1, 2] + let cost_of_change = target; + + let weighted_utxos = vec![ + WeightedUtxo { + satisfaction_weight: Weight::ZERO, + utxo: TxOut { + value: Amount::from_str("1.5 cBTC").unwrap(), + script_pubkey: ScriptBuf::new(), + }, + }, + WeightedUtxo { + satisfaction_weight, + utxo: TxOut { + // If this had no fee, a 1 sat utxo would be included since + // there would be less waste. However, since there is a weight + // and fee to spend it, the effective value is negative, so + // it will not be included. + value: Amount::from_str("1 sat").unwrap(), + script_pubkey: ScriptBuf::new(), + }, + }, + ]; + + let mut wu = weighted_utxos.clone(); + let list = select_coins_bnb(target, cost_of_change, fee_rate, &mut wu).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].utxo.value, Amount::from_str("1.5 cBTC").unwrap()); + } + #[test] fn target_greater_than_value() { let target = Amount::from_str("11 cBTC").unwrap(); let mut weighted_utxos = create_weighted_utxos(); - let list = select_coins_bnb(target, Amount::ZERO, &mut weighted_utxos).unwrap(); - assert_eq!(list, Vec::new()); + let list = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + assert!(list.is_empty()); } + + // TODO - rework this test. + //#[test] + //fn utxo_pool_sum_overflow() { + //let target = Amount::from_str("1 cBTC").unwrap(); + //let satisfaction_weight = Weight::from_wu(204); + //let mut weighted_utxos = vec![ + //WeightedUtxo { + //satisfaction_weight, + //utxo: TxOut { value: Amount::MAX, script_pubkey: ScriptBuf::new() }, + //}, + //WeightedUtxo { + //satisfaction_weight, + //utxo: TxOut { value: Amount::MAX, script_pubkey: ScriptBuf::new() }, + //}, + //]; + + //let list = select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos); + //assert!(list.is_none()); + //} } diff --git a/src/lib.rs b/src/lib.rs index 9834003..0a9445f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,11 +21,13 @@ mod single_random_draw; use bitcoin::Amount; use bitcoin::FeeRate; +use bitcoin::SignedAmount; use bitcoin::TxOut; use bitcoin::Weight; pub use crate::branch_and_bound::select_coins_bnb; use crate::single_random_draw::select_coins_srd; +use bitcoin::blockdata::transaction::effective_value; use rand::thread_rng; /// Trait that a UTXO struct must implement to be used as part of the coin selection @@ -51,6 +53,12 @@ pub struct WeightedUtxo { pub utxo: TxOut, } +impl WeightedUtxo { + fn effective_value(&self, fee_rate: FeeRate) -> Option { + effective_value(fee_rate, self.satisfaction_weight, self.utxo.value) + } +} + /// Select coins first using BnB algorithm similar to what is done in bitcoin /// core see: , /// and falls back on a random UTXO selection. Returns none if the target cannot @@ -64,11 +72,9 @@ pub fn select_coins( fee_rate: FeeRate, weighted_utxos: &mut [WeightedUtxo], ) -> Option> { - let coins = select_coins_bnb(target, cost_of_change, weighted_utxos); - - if coins.is_none() { - select_coins_srd(target, fee_rate, weighted_utxos, &mut thread_rng()) + if let Some(coins) = select_coins_bnb(target, cost_of_change, fee_rate, weighted_utxos) { + Some(coins.into_iter().cloned().collect()) } else { - coins + select_coins_srd(target, fee_rate, weighted_utxos, &mut thread_rng()) } }