diff --git a/Cargo.toml b/Cargo.toml index c05cc64..a551c9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ keywords = ["crypto", "bitcoin"] readme = "README.md" [dependencies] -bitcoin = { git="https://github.com/yancyribbens/rust-bitcoin", branch = "add-effective-value-calculation" } +bitcoin = { git="https://github.com/yancyribbens/rust-bitcoin", branch = "tmp/mark-txin-base-weight-public" } rand = {version = "0.8.5", default-features = false, optional = true} [dev-dependencies] @@ -24,10 +24,10 @@ rust-bitcoin-coin-selection = {path = ".", features = ["rand"]} rand = "0.8.5" [patch.crates-io] -bitcoin_hashes = { git = "https://github.com/yancyribbens/rust-bitcoin", branch = "add-effective-value-calculation" } -bitcoin-io = { git = "https://github.com/yancyribbens/rust-bitcoin", branch = "add-effective-value-calculation" } -bitcoin-units = { git = "https://github.com/yancyribbens/rust-bitcoin", branch = "add-effective-value-calculation" } -bitcoin-internals = { git = "https://github.com/yancyribbens/rust-bitcoin", branch = "add-effective-value-calculation" } +bitcoin_hashes = { git = "https://github.com/yancyribbens/rust-bitcoin", branch = "tmp/mark-txin-base-weight-public" } +bitcoin-io = { git = "https://github.com/yancyribbens/rust-bitcoin", branch = "tmp/mark-txin-base-weight-public" } +bitcoin-units = { git = "https://github.com/yancyribbens/rust-bitcoin", branch = "tmp/mark-txin-base-weight-public" } +bitcoin-internals = { git = "https://github.com/yancyribbens/rust-bitcoin", branch = "tmp/mark-txin-base-weight-public" } [[bench]] name = "coin_selection" diff --git a/benches/coin_selection.rs b/benches/coin_selection.rs index 286f3ec..fa5ff5c 100644 --- a/benches/coin_selection.rs +++ b/benches/coin_selection.rs @@ -32,6 +32,7 @@ pub fn criterion_benchmark(c: &mut Criterion) { black_box(target), black_box(cost_of_change), black_box(FeeRate::ZERO), + black_box(FeeRate::ZERO), black_box(&mut utxo_pool), ) .unwrap(); diff --git a/src/branch_and_bound.rs b/src/branch_and_bound.rs index 5fa164a..02ab989 100644 --- a/src/branch_and_bound.rs +++ b/src/branch_and_bound.rs @@ -7,6 +7,7 @@ use crate::WeightedUtxo; use bitcoin::Amount; use bitcoin::FeeRate; +use bitcoin::SignedAmount; /// Select coins bnb performs a depth first branch and bound search on binary tree. /// @@ -104,10 +105,23 @@ use bitcoin::FeeRate; // solution was found last, then [3, 2] overwrites the previously found solution [4, 1]. We next // backtrack and exclude our root node of this sub tree 3. Since our new sub tree starting at 2 // doesn't have enough value left to meet the target, we conclude our search at [3, 2]. +// +// * Waste Calculation * +// Waste, like value, is a bound used to track when a search path is no longer advantageous. +// The waste total is accumulated and stored in a variable called current_waste. If the +// iteration adds a new node to the inclusion branch, besides incrementing the accumulated +// value for the node, the waste is also added to the current_waste. Note that unlike value, +// waste can actually be negative. This happens if there is a low fee environment such that +// fee is less than long_term_fee. Therefore, the only case where a solution becomes more +// wasteful, and we may bound our search because a better waste score is not longer possible +// is a) if we have already found a target and recorded a best_value and b) if the it's a high +// fee environment such that adding more utxos will increase current_waste to be greater +// than best_waste. pub fn select_coins_bnb( target: Amount, cost_of_change: Amount, fee_rate: FeeRate, + long_term_fee_rate: FeeRate, weighted_utxos: &mut [WeightedUtxo], ) -> Option> { // Total_Tries in Core: @@ -120,7 +134,9 @@ pub fn select_coins_bnb( let mut backtrack_subtree; let mut value = Amount::ZERO; - let mut waste = Amount::MAX_MONEY; + + let mut current_waste: SignedAmount = SignedAmount::ZERO; + let mut best_waste = SignedAmount::MAX_MONEY; let mut index_selection: Vec = vec![]; let mut best_selection: Option> = None; @@ -128,7 +144,11 @@ pub fn select_coins_bnb( // 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 + + // TODO - it would be more efficient to create a Vec: + // Vec <(effective_value: Amount, waste: Amount)> + // That way, waste is calculated once for each UTXO. + let mut w_utxos: Vec<(Amount, &WeightedUtxo)> = weighted_utxos .iter() .map(|wu| (wu.effective_value(fee_rate), wu)) .filter(|(eff_val, _)| !eff_val.is_none()) @@ -137,11 +157,11 @@ pub fn select_coins_bnb( .map(|(eff_val, wu)| (eff_val.to_unsigned().unwrap(), wu)) .collect(); - effective_values.sort_by_key(|u| u.0); - effective_values.reverse(); + w_utxos.sort_by_key(|u| u.0); + w_utxos.reverse(); // TODO check for overflow - let mut available_value = effective_values.iter().fold(Amount::ZERO, |mut s, &(v, _)| { + let mut available_value = w_utxos.iter().fold(Amount::ZERO, |mut s, &(v, _)| { s += v; s }); @@ -215,7 +235,14 @@ pub fn select_coins_bnb( // // That is, the range includes solutions that exactly equal the target up to but not // including values greater than target + cost_of_change. - else if value > target + cost_of_change { + // + // if current_waste > best_waste, then backtrack. However, only backtrack if + // it's high fee_rate environment. During low fee environments, a utxo may + // have negative waste, therefore adding more utxos in such an environment + // may still result in reduced waste. + else if value > target + cost_of_change + || current_waste > best_waste && fee_rate > long_term_fee_rate + { backtrack = true; } // * value meets or exceeds the target. @@ -226,18 +253,25 @@ pub fn select_coins_bnb( else if value >= target { backtrack = true; - let current_waste = value - target; + let v = value.to_signed().ok()?; + let t = target.to_signed().ok()?; + let waste: SignedAmount = v.checked_sub(t)?; + current_waste += waste; - if current_waste <= waste { + if current_waste <= best_waste { best_selection = Some(index_selection.clone()); - waste = current_waste; + best_waste = current_waste; } + + current_waste -= waste; } // * Backtrack if backtrack { let last_index = index_selection.pop().unwrap(); - let (eff_value, _) = effective_values[last_index]; + let (eff_value, w_utxo) = w_utxos[last_index]; + + current_waste -= w_utxo.waste(fee_rate, long_term_fee_rate)?; value -= eff_value; index -= 1; assert_eq!(index, last_index); @@ -246,24 +280,26 @@ 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, effective_values); + return index_to_utxo_list(best_selection, w_utxos); } // Anchor the new subtree at the next available index. // The next iteration, the index will be incremented by one. index = index_selection[0]; + // Reset waste counter since we are starting a new search branch. + current_waste = SignedAmount::ZERO; + // 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 = - effective_values[index + 1..].iter().fold(Amount::ZERO, |mut s, &(v, _)| { - s += v; - s - }); + available_value = w_utxos[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, effective_values); + return index_to_utxo_list(best_selection, w_utxos); } // Start a new selection and add the root of the new subtree to the index selection. @@ -272,10 +308,13 @@ pub fn select_coins_bnb( } // * Add next node to the inclusion branch. else { - let (eff_value, _) = effective_values[index]; + let (eff_value, w_utxo) = w_utxos[index]; + let utxo_waste = w_utxo.waste(fee_rate, long_term_fee_rate)?; + current_waste += utxo_waste; index_selection.push(index); value += eff_value; + available_value -= eff_value; } @@ -283,7 +322,7 @@ pub fn select_coins_bnb( iteration += 1; } - index_to_utxo_list(best_selection, effective_values) + index_to_utxo_list(best_selection, w_utxos) } fn index_to_utxo_list( @@ -303,12 +342,12 @@ mod tests { use bitcoin::Weight; use core::str::FromStr; - fn create_weighted_utxos() -> Vec { + fn create_weighted_utxos(fee: Amount) -> Vec { let amts = [ - Amount::from_str("1 cBTC").unwrap(), - Amount::from_str("2 cBTC").unwrap(), - Amount::from_str("3 cBTC").unwrap(), - Amount::from_str("4 cBTC").unwrap(), + Amount::from_str("1 cBTC").unwrap() + fee, + Amount::from_str("2 cBTC").unwrap() + fee, + Amount::from_str("3 cBTC").unwrap() + fee, + Amount::from_str("4 cBTC").unwrap() + fee, ]; amts.iter() @@ -322,10 +361,16 @@ mod tests { #[test] fn one() { let target = Amount::from_str("1 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(); - - let list = - select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ) + .unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].utxo.value, Amount::from_str("1 cBTC").unwrap()); } @@ -333,10 +378,16 @@ mod tests { #[test] fn two() { let target = Amount::from_str("2 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(); - - let list = - select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ) + .unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].utxo.value, Amount::from_str("2 cBTC").unwrap()); } @@ -344,10 +395,16 @@ mod tests { #[test] fn three() { let target = Amount::from_str("3 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(); - - let list = - select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::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()); @@ -356,10 +413,16 @@ mod tests { #[test] fn four() { let target = Amount::from_str("4 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(); - - let list = - select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::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()); @@ -368,10 +431,16 @@ mod tests { #[test] fn five() { let target = Amount::from_str("5 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(); - - let list = - select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::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()); @@ -380,10 +449,16 @@ mod tests { #[test] fn six() { let target = Amount::from_str("6 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(); - - let list = - select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::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()); @@ -393,10 +468,16 @@ mod tests { #[test] fn seven() { let target = Amount::from_str("7 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(); - - let list = - select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::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()); @@ -406,10 +487,16 @@ mod tests { #[test] fn eight() { let target = Amount::from_str("8 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(); - - let list = - select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::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()); @@ -419,10 +506,16 @@ mod tests { #[test] fn nine() { let target = Amount::from_str("9 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(); - - let list = - select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::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()); @@ -432,10 +525,16 @@ mod tests { #[test] fn ten() { let target = Amount::from_str("10 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(); - - let list = - select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::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()); @@ -461,12 +560,14 @@ mod tests { let mut wu = weighted_utxos.clone(); - let list = select_coins_bnb(target, cost_of_change, FeeRate::ZERO, &mut wu).unwrap(); + let list = select_coins_bnb(target, cost_of_change, FeeRate::ZERO, FeeRate::ZERO, &mut wu) + .unwrap(); 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); + let index_list = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &mut wu); assert_eq!(index_list, None); } @@ -487,7 +588,8 @@ mod tests { }]; let mut wu = weighted_utxos.clone(); - let index_list = select_coins_bnb(target, Amount::ZERO, fee_rate, &mut wu).unwrap(); + let index_list = + select_coins_bnb(target, Amount::ZERO, fee_rate, fee_rate, &mut wu).unwrap(); assert!(index_list.is_empty()); } @@ -523,7 +625,7 @@ mod tests { ]; let mut wu = weighted_utxos.clone(); - let list = select_coins_bnb(target, cost_of_change, fee_rate, &mut wu).unwrap(); + let list = select_coins_bnb(target, cost_of_change, fee_rate, fee_rate, &mut wu).unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].utxo.value, Amount::from_str("1.5 cBTC").unwrap()); } @@ -531,12 +633,57 @@ mod tests { #[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, FeeRate::ZERO, &mut weighted_utxos).unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + let list = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ) + .unwrap(); assert!(list.is_empty()); } + #[test] + fn consume_more_inputs_when_cheap() { + let target = Amount::from_str("6 cBTC").unwrap(); + let fee = Amount::from_str("2 sats").unwrap(); + let mut weighted_utxos = create_weighted_utxos(fee); + + let fee_rate = FeeRate::from_sat_per_kwu(10); + let lt_fee_rate = FeeRate::from_sat_per_kwu(20); + + // the possible combinations are 2,4 or 1,2,3 + // fees are cheap, so use 1,2,3 + let list = + select_coins_bnb(target, Amount::ZERO, fee_rate, lt_fee_rate, &mut weighted_utxos) + .unwrap(); + assert_eq!(list.len(), 3); + assert_eq!(list[0].utxo.value, Amount::from_str("3 cBTC").unwrap() + fee); + assert_eq!(list[1].utxo.value, Amount::from_str("2 cBTC").unwrap() + fee); + assert_eq!(list[2].utxo.value, Amount::from_str("1 cBTC").unwrap() + fee); + } + + #[test] + fn consume_less_inputs_when_expensive() { + let target = Amount::from_str("6 cBTC").unwrap(); + let fee = Amount::from_str("4 sats").unwrap(); + let mut weighted_utxos = create_weighted_utxos(fee); + + let fee_rate = FeeRate::from_sat_per_kwu(20); + let lt_fee_rate = FeeRate::from_sat_per_kwu(10); + + // the possible combinations are 2,4 or 1,2,3 + // fees are expensive, so use 2,4 + let list = + select_coins_bnb(target, Amount::ZERO, fee_rate, lt_fee_rate, &mut weighted_utxos) + .unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list[0].utxo.value, Amount::from_str("4 cBTC").unwrap() + fee); + assert_eq!(list[1].utxo.value, Amount::from_str("2 cBTC").unwrap() + fee); + } + // TODO - rework this test. //#[test] //fn utxo_pool_sum_overflow() { diff --git a/src/lib.rs b/src/lib.rs index 0a9445f..510a590 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,7 +27,7 @@ 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 bitcoin::blockdata::transaction::TxIn; use rand::thread_rng; /// Trait that a UTXO struct must implement to be used as part of the coin selection @@ -55,7 +55,20 @@ pub struct WeightedUtxo { impl WeightedUtxo { fn effective_value(&self, fee_rate: FeeRate) -> Option { - effective_value(fee_rate, self.satisfaction_weight, self.utxo.value) + let signed_input_fee = self.calculate_fee(fee_rate)?.to_signed().ok()?; + self.utxo.value.to_signed().ok()?.checked_sub(signed_input_fee) + } + + fn calculate_fee(&self, fee_rate: FeeRate) -> Option { + let weight = self.satisfaction_weight.checked_add(TxIn::BASE_WEIGHT)?; + let f = fee_rate.checked_mul_by_weight(weight); + f + } + + fn waste(&self, fee_rate: FeeRate, long_term_fee_rate: FeeRate) -> Option { + let fee: SignedAmount = self.calculate_fee(fee_rate)?.to_signed().ok()?; + let lt_fee: SignedAmount = self.calculate_fee(long_term_fee_rate)?.to_signed().ok()?; + fee.checked_sub(lt_fee) } } @@ -70,9 +83,12 @@ pub fn select_coins( target: Amount, cost_of_change: Amount, fee_rate: FeeRate, + long_term_fee_rate: FeeRate, weighted_utxos: &mut [WeightedUtxo], ) -> Option> { - if let Some(coins) = select_coins_bnb(target, cost_of_change, fee_rate, weighted_utxos) { + if let Some(coins) = + select_coins_bnb(target, cost_of_change, fee_rate, long_term_fee_rate, weighted_utxos) + { Some(coins.into_iter().cloned().collect()) } else { select_coins_srd(target, fee_rate, weighted_utxos, &mut thread_rng())