From dbc77f34cdbeb368ae4c0f534f06586ffcfa0fa0 Mon Sep 17 00:00:00 2001 From: yancy Date: Fri, 29 Dec 2023 17:24:10 +0100 Subject: [PATCH 1/6] Move branch and bound to a seperate module --- README.md | 4 + src/branch_and_bound.rs | 268 ++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 268 +--------------------------------------- 3 files changed, 274 insertions(+), 266 deletions(-) create mode 100644 src/branch_and_bound.rs diff --git a/README.md b/README.md index 172866d..a97cbdb 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ The current interface is provided via `select_coins()` function. The required p As discussed in the literature above, ideally we want to choose a selection from the existing UTXO set available to the wallet. However, if there is no combination that efficiently matches the target spend amount, then creating a change output by splitting a UTXO is the next best option. Therefore, the algorithm takes into account the current cost of creating a new output (cost_of_change). +## Benchmarks + +To run the benchmarks use: `RUSTFLAGS='--cfg=bench' cargo +nightly bench`. + ## Minimum Supported Rust Version (MSRV) This library should always compile with any combination of features on **Rust 1.48**. diff --git a/src/branch_and_bound.rs b/src/branch_and_bound.rs new file mode 100644 index 0000000..db7750f --- /dev/null +++ b/src/branch_and_bound.rs @@ -0,0 +1,268 @@ +use crate::Utxo; +use std::cmp::Reverse; + +/// Select coins using BnB algorithm similar to what is done in bitcoin +/// core see: +/// Returns None if BnB doesn't find a solution. +pub fn select_coins_bnb( + target: u64, + cost_of_change: u64, + utxo_pool: &mut [T], +) -> Option> { + let solution = find_solution(target, cost_of_change, utxo_pool)?; + Some( + solution + .into_iter() + .zip(utxo_pool.iter()) + .filter_map(|(include, utxo)| if include { Some(utxo.clone()) } else { None }) + .collect::>(), + ) +} + +fn find_solution( + target: u64, + cost_of_change: u64, + utxo_pool: &mut [T], +) -> Option> { + let utxo_sum = utxo_pool.iter().fold(0u64, |mut s, u| { + s += u.get_value(); + s + }); + + let utxo_pool_length = utxo_pool.len(); + utxo_pool.sort_by_key(|u| Reverse(u.get_value())); + + let mut curr_selection: Vec = vec![false; utxo_pool_length]; + let mut best_selection = None; + let mut remainder = utxo_sum; + + let lower_bound = target; + let upper_bound = cost_of_change + lower_bound; + + if utxo_sum < lower_bound { + return None; + } + + for m in 0..utxo_pool_length { + let mut curr_sum = 0; + let mut slice_remainder = remainder; + + for n in m..utxo_pool_length { + if slice_remainder + curr_sum < lower_bound { + break; + } + + let utxo_value = utxo_pool[n].get_value(); + curr_sum += utxo_value; + curr_selection[n] = true; + + if curr_sum >= lower_bound { + if curr_sum <= upper_bound { + best_selection = Some(curr_selection.clone()); + } + + curr_selection[n] = false; + curr_sum -= utxo_value; + } + + slice_remainder -= utxo_value; + } + + remainder -= utxo_pool[m].get_value(); + curr_selection[m] = false; + } + + best_selection +} + +#[cfg(test)] +mod tests { + use crate::*; + use crate::branch_and_bound::find_solution; + + const ONE_BTC: u64 = 100000000; + const TWO_BTC: u64 = 2 * 100000000; + const THREE_BTC: u64 = 3 * 100000000; + const FOUR_BTC: u64 = 4 * 100000000; + + const UTXO_POOL: [MinimalUtxo; 4] = [ + MinimalUtxo { value: ONE_BTC }, + MinimalUtxo { value: TWO_BTC }, + MinimalUtxo { value: THREE_BTC }, + MinimalUtxo { value: FOUR_BTC }, + ]; + + const COST_OF_CHANGE: u64 = 50000000; + + #[derive(Clone, Debug, Eq, PartialEq)] + struct MinimalUtxo { + value: u64, + } + + impl Utxo for MinimalUtxo { + fn get_value(&self) -> u64 { + self.value + } + } + + #[test] + fn find_solution_1_btc() { + let utxo_match = find_solution(ONE_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); + let expected_bool_vec = vec![false, false, false, true]; + assert_eq!(expected_bool_vec, utxo_match); + } + + #[test] + fn find_solution_2_btc() { + let utxo_match = find_solution(TWO_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); + let expected_bool_vec = vec![false, false, true, false]; + assert_eq!(expected_bool_vec, utxo_match); + } + + #[test] + fn find_solution_3_btc() { + let utxo_match = find_solution(THREE_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); + let expected_bool_vec = vec![false, false, true, true]; + assert_eq!(expected_bool_vec, utxo_match); + } + + #[test] + fn find_solution_4_btc() { + let utxo_match = find_solution(FOUR_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); + let expected_bool_vec = vec![false, true, false, true]; + assert_eq!(expected_bool_vec, utxo_match); + } + + #[test] + fn find_solution_5_btc() { + let five_btc = FOUR_BTC + ONE_BTC; + let utxo_match = find_solution(five_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); + let expected_bool_vec = vec![false, true, true, false]; + assert_eq!(expected_bool_vec, utxo_match); + } + + #[test] + fn find_solution_6_btc() { + let six_btc = FOUR_BTC + TWO_BTC; + let utxo_match = find_solution(six_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); + let expected_bool_vec = vec![false, true, true, true]; + assert_eq!(expected_bool_vec, utxo_match); + } + + #[test] + fn find_solution_7_btc() { + let seven_btc = FOUR_BTC + THREE_BTC; + let utxo_match = find_solution(seven_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); + let expected_bool_vec = vec![true, false, true, true]; + assert_eq!(expected_bool_vec, utxo_match); + } + + #[test] + fn find_solution_8_btc() { + let seven_btc = FOUR_BTC + THREE_BTC + ONE_BTC; + let utxo_match = find_solution(seven_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); + let expected_bool_vec = vec![true, true, false, true]; + assert_eq!(expected_bool_vec, utxo_match); + } + + #[test] + fn find_solution_9_btc() { + let seven_btc = FOUR_BTC + THREE_BTC + TWO_BTC; + let utxo_match = find_solution(seven_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); + let expected_bool_vec = vec![true, true, true, false]; + assert_eq!(expected_bool_vec, utxo_match); + } + + #[test] + fn find_solution_10_btc() { + let ten_btc = ONE_BTC + TWO_BTC + THREE_BTC + FOUR_BTC; + let utxo_match = find_solution(ten_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); + let expected_bool_vec = vec![true, true, true, true]; + assert_eq!(expected_bool_vec, utxo_match); + } + + #[test] + fn find_solution_11_btc_not_possible() { + let ten_btc = ONE_BTC + TWO_BTC + THREE_BTC + FOUR_BTC; + let utxo_match = find_solution(ten_btc + ONE_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()); + assert_eq!(None, utxo_match); + } + + #[test] + fn find_solution_with_large_cost_of_change() { + let utxo_match = + find_solution(ONE_BTC * 9 / 10, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); + let expected_bool_vec = vec![false, false, false, true]; + assert_eq!(expected_bool_vec, utxo_match); + } + + #[test] + fn find_solution_with_no_cost_of_change() { + let utxo_match = find_solution(ONE_BTC * 9 / 10, 0, &mut UTXO_POOL.clone()); + assert_eq!(None, utxo_match); + } + + #[test] + fn find_solution_with_not_input_fee() { + let utxo_match = find_solution(ONE_BTC + 1, COST_OF_CHANGE, &mut UTXO_POOL.clone()); + assert_eq!(None, utxo_match); + } + + #[test] + fn select_coins_bnb_with_match() { + select_coins_bnb(ONE_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); + } + + #[test] + fn select_coins_bnb_with_no_match() { + let utxo_match = select_coins_bnb(1, COST_OF_CHANGE, &mut UTXO_POOL.clone()); + assert_eq!(None, utxo_match); + } +} + +#[cfg(bench)] +#[cfg(test)] +mod benches { + use crate::select_coins_bnb; + use crate::Utxo; + use test::Bencher; + + #[derive(Clone, Debug, Eq, PartialEq)] + struct MinimalUtxo { + value: u64, + } + + impl Utxo for MinimalUtxo { + fn get_value(&self) -> u64 { + self.value + } + } + + #[bench] + /// Creates a UTXO pool of 1,000 coins that do not match and one coin + /// that will be a match when combined with any of the other 1,000 coins. + /// + /// Matching benchmark of Bitcoin core coin-selection benchmark. + // https://github.com/bitcoin/bitcoin/blob/f3bc1a72825fe2b51f4bc20e004cef464f05b965/src/bench/coin_selection.cpp#L44 + fn bench_select_coins_bnb(bh: &mut Bencher) { + // https://github.com/bitcoin/bitcoin/blob/f3bc1a72825fe2b51f4bc20e004cef464f05b965/src/consensus/amount.h#L15 + /// The amount of satoshis in one BTC. + const COIN: u64 = 100_000_000; + + // https://github.com/bitcoin/bitcoin/blob/f3bc1a72825fe2b51f4bc20e004cef464f05b965/src/wallet/coinselection.h#L18 + /// lower bound for randomly-chosen target change amount + const CHANGE_LOWER: u64 = 50_000; + + let u = MinimalUtxo { value: 1000 * COIN }; + let mut utxo_pool = vec![u; 1000]; + utxo_pool.push(MinimalUtxo { value: 3 * COIN }); + + bh.iter(|| { + let result = + select_coins_bnb(1003 * COIN, CHANGE_LOWER, &mut utxo_pool.clone()).unwrap(); + assert_eq!(2, result.len()); + assert_eq!(1000 * COIN, result[0].value); + assert_eq!(3 * COIN, result[1].value); + }); + } +} diff --git a/src/lib.rs b/src/lib.rs index ee14c93..731ca92 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,8 +16,7 @@ #[cfg(bench)] extern crate test; -use std::cmp::Reverse; - +mod branch_and_bound; mod single_random_draw; use bitcoin::Amount; @@ -25,6 +24,7 @@ use bitcoin::FeeRate; use bitcoin::TxOut; use bitcoin::Weight; +use crate::branch_and_bound::select_coins_bnb; use crate::single_random_draw::select_coins_srd; use rand::thread_rng; @@ -69,267 +69,3 @@ pub fn select_coins( None => select_coins_srd(target, fee_rate, weighted_utxos, &mut thread_rng()), } } - -/// Select coins using BnB algorithm similar to what is done in bitcoin -/// core see: -/// Returns None if BnB doesn't find a solution. -pub fn select_coins_bnb( - target: u64, - cost_of_change: u64, - utxo_pool: &mut [T], -) -> Option> { - let solution = find_solution(target, cost_of_change, utxo_pool)?; - Some( - solution - .into_iter() - .zip(utxo_pool.iter()) - .filter_map(|(include, utxo)| if include { Some(utxo.clone()) } else { None }) - .collect::>(), - ) -} - -fn find_solution( - target: u64, - cost_of_change: u64, - utxo_pool: &mut [T], -) -> Option> { - let utxo_sum = utxo_pool.iter().fold(0u64, |mut s, u| { - s += u.get_value(); - s - }); - - let utxo_pool_length = utxo_pool.len(); - utxo_pool.sort_by_key(|u| Reverse(u.get_value())); - - let mut curr_selection: Vec = vec![false; utxo_pool_length]; - let mut best_selection = None; - let mut remainder = utxo_sum; - - let lower_bound = target; - let upper_bound = cost_of_change + lower_bound; - - if utxo_sum < lower_bound { - return None; - } - - for m in 0..utxo_pool_length { - let mut curr_sum = 0; - let mut slice_remainder = remainder; - - for n in m..utxo_pool_length { - if slice_remainder + curr_sum < lower_bound { - break; - } - - let utxo_value = utxo_pool[n].get_value(); - curr_sum += utxo_value; - curr_selection[n] = true; - - if curr_sum >= lower_bound { - if curr_sum <= upper_bound { - best_selection = Some(curr_selection.clone()); - } - - curr_selection[n] = false; - curr_sum -= utxo_value; - } - - slice_remainder -= utxo_value; - } - - remainder -= utxo_pool[m].get_value(); - curr_selection[m] = false; - } - - best_selection -} - -#[cfg(test)] -mod tests { - use crate::*; - - const ONE_BTC: u64 = 100000000; - const TWO_BTC: u64 = 2 * 100000000; - const THREE_BTC: u64 = 3 * 100000000; - const FOUR_BTC: u64 = 4 * 100000000; - - const UTXO_POOL: [MinimalUtxo; 4] = [ - MinimalUtxo { value: ONE_BTC }, - MinimalUtxo { value: TWO_BTC }, - MinimalUtxo { value: THREE_BTC }, - MinimalUtxo { value: FOUR_BTC }, - ]; - - const COST_OF_CHANGE: u64 = 50000000; - - #[derive(Clone, Debug, Eq, PartialEq)] - struct MinimalUtxo { - value: u64, - } - - impl Utxo for MinimalUtxo { - fn get_value(&self) -> u64 { - self.value - } - } - - #[test] - fn find_solution_1_btc() { - let utxo_match = find_solution(ONE_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![false, false, false, true]; - assert_eq!(expected_bool_vec, utxo_match); - } - - #[test] - fn find_solution_2_btc() { - let utxo_match = find_solution(TWO_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![false, false, true, false]; - assert_eq!(expected_bool_vec, utxo_match); - } - - #[test] - fn find_solution_3_btc() { - let utxo_match = find_solution(THREE_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![false, false, true, true]; - assert_eq!(expected_bool_vec, utxo_match); - } - - #[test] - fn find_solution_4_btc() { - let utxo_match = find_solution(FOUR_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![false, true, false, true]; - assert_eq!(expected_bool_vec, utxo_match); - } - - #[test] - fn find_solution_5_btc() { - let five_btc = FOUR_BTC + ONE_BTC; - let utxo_match = find_solution(five_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![false, true, true, false]; - assert_eq!(expected_bool_vec, utxo_match); - } - - #[test] - fn find_solution_6_btc() { - let six_btc = FOUR_BTC + TWO_BTC; - let utxo_match = find_solution(six_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![false, true, true, true]; - assert_eq!(expected_bool_vec, utxo_match); - } - - #[test] - fn find_solution_7_btc() { - let seven_btc = FOUR_BTC + THREE_BTC; - let utxo_match = find_solution(seven_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![true, false, true, true]; - assert_eq!(expected_bool_vec, utxo_match); - } - - #[test] - fn find_solution_8_btc() { - let seven_btc = FOUR_BTC + THREE_BTC + ONE_BTC; - let utxo_match = find_solution(seven_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![true, true, false, true]; - assert_eq!(expected_bool_vec, utxo_match); - } - - #[test] - fn find_solution_9_btc() { - let seven_btc = FOUR_BTC + THREE_BTC + TWO_BTC; - let utxo_match = find_solution(seven_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![true, true, true, false]; - assert_eq!(expected_bool_vec, utxo_match); - } - - #[test] - fn find_solution_10_btc() { - let ten_btc = ONE_BTC + TWO_BTC + THREE_BTC + FOUR_BTC; - let utxo_match = find_solution(ten_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![true, true, true, true]; - assert_eq!(expected_bool_vec, utxo_match); - } - - #[test] - fn find_solution_11_btc_not_possible() { - let ten_btc = ONE_BTC + TWO_BTC + THREE_BTC + FOUR_BTC; - let utxo_match = find_solution(ten_btc + ONE_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()); - assert_eq!(None, utxo_match); - } - - #[test] - fn find_solution_with_large_cost_of_change() { - let utxo_match = - find_solution(ONE_BTC * 9 / 10, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![false, false, false, true]; - assert_eq!(expected_bool_vec, utxo_match); - } - - #[test] - fn find_solution_with_no_cost_of_change() { - let utxo_match = find_solution(ONE_BTC * 9 / 10, 0, &mut UTXO_POOL.clone()); - assert_eq!(None, utxo_match); - } - - #[test] - fn find_solution_with_not_input_fee() { - let utxo_match = find_solution(ONE_BTC + 1, COST_OF_CHANGE, &mut UTXO_POOL.clone()); - assert_eq!(None, utxo_match); - } - - #[test] - fn select_coins_bnb_with_match() { - select_coins_bnb(ONE_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - } - - #[test] - fn select_coins_bnb_with_no_match() { - let utxo_match = select_coins_bnb(1, COST_OF_CHANGE, &mut UTXO_POOL.clone()); - assert_eq!(None, utxo_match); - } -} - -#[cfg(bench)] -mod benches { - use crate::select_coins_bnb; - use crate::Utxo; - use test::Bencher; - - #[derive(Clone, Debug, Eq, PartialEq)] - struct MinimalUtxo { - value: u64, - } - - impl Utxo for MinimalUtxo { - fn get_value(&self) -> u64 { - self.value - } - } - - #[bench] - /// Creates a UTXO pool of 1,000 coins that do not match and one coin - /// that will be a match when combined with any of the other 1,000 coins. - /// - /// Matching benchmark of Bitcoin core coin-selection benchmark. - // https://github.com/bitcoin/bitcoin/blob/f3bc1a72825fe2b51f4bc20e004cef464f05b965/src/bench/coin_selection.cpp#L44 - fn bench_select_coins_bnb(bh: &mut Bencher) { - // https://github.com/bitcoin/bitcoin/blob/f3bc1a72825fe2b51f4bc20e004cef464f05b965/src/consensus/amount.h#L15 - /// The amount of satoshis in one BTC. - const COIN: u64 = 100_000_000; - - // https://github.com/bitcoin/bitcoin/blob/f3bc1a72825fe2b51f4bc20e004cef464f05b965/src/wallet/coinselection.h#L18 - /// lower bound for randomly-chosen target change amount - const CHANGE_LOWER: u64 = 50_000; - - let u = MinimalUtxo { value: 1000 * COIN }; - let mut utxo_pool = vec![u; 1000]; - utxo_pool.push(MinimalUtxo { value: 3 * COIN }); - - bh.iter(|| { - let result = - select_coins_bnb(1003 * COIN, CHANGE_LOWER, &mut utxo_pool.clone()).unwrap(); - assert_eq!(2, result.len()); - assert_eq!(1000 * COIN, result[0].value); - assert_eq!(3 * COIN, result[1].value); - }); - } -} From ad57928cbd089396016a371b3410b56330736424 Mon Sep 17 00:00:00 2001 From: yancy Date: Wed, 6 Dec 2023 21:27:11 +0100 Subject: [PATCH 2/6] Implement BnB search algorithm --- Cargo.toml | 10 +- src/branch_and_bound.rs | 999 ++++++++++++++++++++++++++++++++++------ src/lib.rs | 32 +- 3 files changed, 883 insertions(+), 158 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e89b909..8fb6684 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", rev="2f109442e30d74fb7502e7fd1ce2075a67262cd5" } rand = {version = "0.8.5", default-features = false, optional = true} [dev-dependencies] @@ -23,7 +23,7 @@ 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", rev="2f109442e30d74fb7502e7fd1ce2075a67262cd5" } +bitcoin-io = { git = "https://github.com/yancyribbens/rust-bitcoin", rev="2f109442e30d74fb7502e7fd1ce2075a67262cd5" } +bitcoin-units = { git = "https://github.com/yancyribbens/rust-bitcoin", rev="2f109442e30d74fb7502e7fd1ce2075a67262cd5" } +bitcoin-internals = { git = "https://github.com/yancyribbens/rust-bitcoin", rev="2f109442e30d74fb7502e7fd1ce2075a67262cd5" } diff --git a/src/branch_and_bound.rs b/src/branch_and_bound.rs index db7750f..ae8e2ab 100644 --- a/src/branch_and_bound.rs +++ b/src/branch_and_bound.rs @@ -1,222 +1,929 @@ -use crate::Utxo; -use std::cmp::Reverse; - -/// Select coins using BnB algorithm similar to what is done in bitcoin -/// core see: -/// Returns None if BnB doesn't find a solution. -pub fn select_coins_bnb( - target: u64, - cost_of_change: u64, - utxo_pool: &mut [T], -) -> Option> { - let solution = find_solution(target, cost_of_change, utxo_pool)?; - Some( - solution - .into_iter() - .zip(utxo_pool.iter()) - .filter_map(|(include, utxo)| if include { Some(utxo.clone()) } else { None }) - .collect::>(), - ) -} - -fn find_solution( - target: u64, - cost_of_change: u64, - utxo_pool: &mut [T], -) -> Option> { - let utxo_sum = utxo_pool.iter().fold(0u64, |mut s, u| { - s += u.get_value(); - s - }); - - let utxo_pool_length = utxo_pool.len(); - utxo_pool.sort_by_key(|u| Reverse(u.get_value())); - - let mut curr_selection: Vec = vec![false; utxo_pool_length]; - let mut best_selection = None; - let mut remainder = utxo_sum; - - let lower_bound = target; - let upper_bound = cost_of_change + lower_bound; - - if utxo_sum < lower_bound { +// SPDX-License-Identifier: CC0-1.0 +// +//! Bitcoin Branch and Bound Coin Selection. +//! +//! This module introduces the Branch and Bound Coin Selection Algorithm. + +use crate::WeightedUtxo; +use bitcoin::amount::CheckedSum; +use bitcoin::Amount; +use bitcoin::FeeRate; +use bitcoin::SignedAmount; + +/// Select coins bnb performs a depth first branch and bound search. The search traverses a +/// binary tree with a maximum depth n where n is the size of the target UTXO pool. +/// +/// See also core: +/// +/// Returns a vector of `WeightedUtxo` that meet or exceed the target `Amount` when summed. +/// The `Amount` returned will not exceed the target by more than target + delta where delta is +/// the cost of producing a change output. +/// +/// The vector returned seeks to minimize the excess, which is the difference between the target +/// `Amount` and vector sum. If no match can be found, None is returned. +/// +/// This algorithem is designed to never panic or overflow. If a panic or overflow would occur, +/// None is returned. Also, if no match can be found, None is returned. The semantics may +/// change in the future to give more information about errors encountered. +/// +/// # Returns +/// * `Some(Vec)` where `Vec` is not empty on match. +/// * `None` No match found or un-expected results. +/// +/// # 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 explores a binary tree. The left branch of each node is the inclusion branch and +// the right branch is the exclusion branch. +// o +// / \ +// I E +// +// If the UTXO set consist of a list: [4,3,2,1], and the target is 5, the selection process works +// as follows: +// +// Add 4 to the inclusion branch. The current total is 4 which is less than our target of 5, +// therefore the search routine continues. The next UTXO 3 is added to the inclusion branch. +// o +// / +// 4 +// / +// 3 +// +// At this point, the total sums to 7 (4 + 3) exceeding the target of 5. 7 may be recorded as a +// solution with an excess of 2 (7 - 5). 3 is removed from the left branch and it becomes +// the right branch since 3 is now excluded. +// o +// / +// 4 +// \ +// 3 +// +// We next try add 2 to the inclusion branch. +// o +// / +// 4 +// \ +// 3 +// / +// 2 +// +// The sum of the left inclusion branch is now 6 (4 + 2). Once again, we find the total +// exceeds 5, so we record 6 as a solution with an excess of 1, our best solution so far. +// Once again, we add 2 to the exclusion branch. +// o +// / +// 4 +// \ +// 3 +// \ +// 2 +// +// Finally, we add 1 to the inclusion branch. This ends our depth first search by matching two +// conditions, it is both the leaf node (no more available value) and matches our search criteria of +// 5 with the smallest possible excess (0). Both 4 and 1 are on the left inclusion branch. +// +// o +// / \ +// 4 +// \ +// 3 +// \ +// 2 +// / +// 1 +// +// The search continues because it is possible to do better than 0 (more on that later). +// We next try excluding 4 by adding 4 to the exclusion branch, then we begin a new search +// tree by adding 3 to the inclusion branch. +// o +// \ +// 4 +// / +// 3 +// +// 3 is less than our target, so we next add 2 to our inclusion branch. +// o +// \ +// 4 +// / +// 3 +// / +// 2 +// +// We now stop our search again noticing that 3 and 2 equals our target as 5, and since this +// solution was found last, [3, 2] overwrites the previously found solution [4, 1]. We haven't +// tried combinations including 1 at this point, however adding 1 to [3, 2, 1] would be a worse +// solution since it overshoots the target of 5, so the combination is dismissed. Furthermore, +// removing 2 would not leave enough available value [3, 1] to make it to our target, therefore +// the search routine has exhausted all possibilities using 3 as the root. We next backtrack and +// exclude our root node of this 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]. +// +// * Addendum on Waste Calculation Optimization * +// Waste, like accumulated 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. +// Besides the difference between amount and target, current_waste stores the difference between +// utxo fee and utxo_long_term_fee. +// +// 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 no longer possible is: +// +// 1) We have already found a solution that matchs the target and the next solution has a +// higher waste score. +// +// 2) It's a high fee environment such that adding more utxos will increase current_waste. +// +// If either 1 or 2 is true, we consider the current search path no longer viable to continue. In +// such a case, backtrack to start a new search path. +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: + // https://github.com/bitcoin/bitcoin/blob/1d9da8da309d1dbf9aef15eb8dc43b4a2dc3d309/src/wallet/coinselection.cpp#L74 + const ITERATION_LIMIT: i32 = 100_000; + + let mut iteration = 0; + let mut index = 0; + let mut backtrack; + + let mut value = Amount::ZERO; + + 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; + + let upper_bound = target.checked_add(cost_of_change)?; + + // Creates a tuple of (effective_value, waste, weighted_utxo) + let mut w_utxos: Vec<(Amount, SignedAmount, &WeightedUtxo)> = weighted_utxos + .iter() + // calculate effective_value and waste for each w_utxo. + .map(|wu| (wu.effective_value(fee_rate), wu.waste(fee_rate, long_term_fee_rate), wu)) + // remove utxos that either had an error in the effective_value or waste calculation. + .filter(|(eff_val, waste, _)| eff_val.is_some() && waste.is_some()) + // unwrap the option type since we know they are not None (see previous step). + .map(|(eff_val, waste, wu)| (eff_val.unwrap(), waste.unwrap(), wu)) + // filter out all effective_values that are negative. + .filter(|(eff_val, _, _)| eff_val.is_positive()) + // all utxo effective_values are now positive (see previous step) - cast to unsigned. + .map(|(eff_val, waste, wu)| (eff_val.to_unsigned().unwrap(), waste, wu)) + .collect(); + + w_utxos.sort_by_key(|u| u.0); + w_utxos.reverse(); + + let mut available_value = w_utxos.clone().into_iter().map(|(ev, _, _)| ev).checked_sum()?; + + if available_value < target { return None; } - for m in 0..utxo_pool_length { - let mut curr_sum = 0; - let mut slice_remainder = remainder; + while iteration < ITERATION_LIMIT { + backtrack = false; + + // * If any of the conditions are met, backtrack. + // + // unchecked_add is used here for performance. Before entering the search loop, all + // utxos are summed and checked for overflow. Since there was no overflow then, any + // subset of addition will not overflow. + if available_value.unchecked_add(value) < target + // Provides an upper bound on the excess value that is permissible. + // Since value is lost when we create a change output due to increasing the size of the + // transaction by an output (the change output), we accept solutions that may be + // larger than the target. The excess is added to the solutions waste score. + // However, values greater than value + cost_of_change are not considered. + // + // This creates a range of possible solutions where; + // range = (target, target + cost_of_change] + // + // That is, the range includes solutions that exactly equal the target up to but not + // including values greater than target + cost_of_change. + || value > upper_bound + // 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. + || current_waste > best_waste && fee_rate > long_term_fee_rate + { + backtrack = true; + } + // * value meets or exceeds the target. + // Record the solution and the waste then continue. + else if value >= target { + backtrack = true; + + let v = value.to_signed().ok()?; + let t = target.to_signed().ok()?; + let waste: SignedAmount = v.checked_sub(t)?; + current_waste = current_waste.checked_add(waste)?; + + // Check if index_selection is better than the previous known best, and + // update best_selection accordingly. + if current_waste <= best_waste { + best_selection = Some(index_selection.clone()); + best_waste = current_waste; + } - for n in m..utxo_pool_length { - if slice_remainder + curr_sum < lower_bound { - break; + current_waste = current_waste.checked_sub(waste)?; + } + // * Backtrack + if backtrack { + if index_selection.is_empty() { + return index_to_utxo_list(best_selection, w_utxos); } - let utxo_value = utxo_pool[n].get_value(); - curr_sum += utxo_value; - curr_selection[n] = true; + loop { + index -= 1; - if curr_sum >= lower_bound { - if curr_sum <= upper_bound { - best_selection = Some(curr_selection.clone()); + if index <= *index_selection.last().unwrap() { + break; } - curr_selection[n] = false; - curr_sum -= utxo_value; + let (eff_value, _, _) = w_utxos[index]; + available_value += eff_value; } - slice_remainder -= utxo_value; + assert_eq!(index, *index_selection.last().unwrap()); + let (eff_value, utxo_waste, _) = w_utxos[index]; + current_waste = current_waste.checked_sub(utxo_waste)?; + value = value.checked_sub(eff_value)?; + index_selection.pop().unwrap(); + } + // * Add next node to the inclusion branch. + else { + let (eff_value, utxo_waste, _) = w_utxos[index]; + current_waste = current_waste.checked_add(utxo_waste)?; + + index_selection.push(index); + + // unchecked add is used here for performance. Since the sum of all utxo values + // did not overflow, then any positive subset of the sum will not overflow. + value = value.unchecked_add(eff_value); + + // unchecked sub is used her for performance. + // The bounds for available_value are at most the sum of utxos + // and at least zero. + available_value = available_value.unchecked_sub(eff_value); } - remainder -= utxo_pool[m].get_value(); - curr_selection[m] = false; + // no overflow is possible since the iteration count is bounded. + index += 1; + iteration += 1; } - best_selection + return index_to_utxo_list(best_selection, w_utxos); +} + +// Copy the index list into a list such that for each +// index, the corresponding w_utxo is copied. +fn index_to_utxo_list( + index_list: Option>, + wu: Vec<(Amount, SignedAmount, &WeightedUtxo)>, +) -> Option> { + // Doing this to satisfy the borrow checker such that the + // refs &WeightedUtxo in `wu` have the same lifetime as the + // returned &WeightedUtxo. + let origin: Vec<_> = wu.iter().map(|(_, _, u)| *u).collect(); + let mut result = origin.clone(); + result.clear(); + + // copy over the origin items into result that are present + // in the index_list. + if let Some(i_list) = index_list { + for i in i_list { + result.push(origin[i]) + } + Some(result.into_iter()) + } else { + None + } } #[cfg(test)] mod tests { - use crate::*; - use crate::branch_and_bound::find_solution; + use super::*; + use crate::WeightedUtxo; + use bitcoin::Amount; + use bitcoin::ScriptBuf; + use bitcoin::SignedAmount; + use bitcoin::TxOut; + use bitcoin::Weight; + use core::str::FromStr; + use std::iter::once; + use std::iter::zip; + + fn create_weighted_utxos(fee: Amount) -> Vec { + let amts = [ + 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() + .map(|amt| WeightedUtxo { + satisfaction_weight: Weight::ZERO, + utxo: TxOut { value: *amt, script_pubkey: ScriptBuf::new() }, + }) + .collect() + } - const ONE_BTC: u64 = 100000000; - const TWO_BTC: u64 = 2 * 100000000; - const THREE_BTC: u64 = 3 * 100000000; - const FOUR_BTC: u64 = 4 * 100000000; + fn create_weighted_utxos_from_values(values: Vec) -> Vec { + values + .iter() + .map(|amt| WeightedUtxo { + satisfaction_weight: Weight::ZERO, + utxo: TxOut { value: *amt, script_pubkey: ScriptBuf::new() }, + }) + .collect() + } - const UTXO_POOL: [MinimalUtxo; 4] = [ - MinimalUtxo { value: ONE_BTC }, - MinimalUtxo { value: TWO_BTC }, - MinimalUtxo { value: THREE_BTC }, - MinimalUtxo { value: FOUR_BTC }, - ]; + #[test] + fn select_coins_bnb_one() { + let target = Amount::from_str("1 cBTC").unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list: Vec<_> = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ) + .unwrap() + .collect(); + + assert_eq!(list.len(), 1); + assert_eq!(list[0].utxo.value, Amount::from_str("1 cBTC").unwrap()); + } - const COST_OF_CHANGE: u64 = 50000000; + #[test] + fn select_coins_bnb_two() { + let target = Amount::from_str("2 cBTC").unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list: Vec<_> = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ) + .unwrap() + .collect(); + + assert_eq!(list.len(), 1); + assert_eq!(list[0].utxo.value, Amount::from_str("2 cBTC").unwrap()); + } - #[derive(Clone, Debug, Eq, PartialEq)] - struct MinimalUtxo { - value: u64, + #[test] + fn select_coins_bnb_three() { + let target = Amount::from_str("3 cBTC").unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list: Vec<_> = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ) + .unwrap() + .collect(); + + 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()); } - impl Utxo for MinimalUtxo { - fn get_value(&self) -> u64 { - self.value - } + #[test] + fn select_coins_bnb_four() { + let target = Amount::from_str("4 cBTC").unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list: Vec<_> = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ) + .unwrap() + .collect(); + + 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 select_coins_bnb_five() { + let target = Amount::from_str("5 cBTC").unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list: Vec<_> = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ) + .unwrap() + .collect(); + + 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 select_coins_bnb_six() { + let target = Amount::from_str("6 cBTC").unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list: Vec<_> = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ) + .unwrap() + .collect(); + + 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 find_solution_1_btc() { - let utxo_match = find_solution(ONE_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![false, false, false, true]; - assert_eq!(expected_bool_vec, utxo_match); + fn select_coins_bnb_seven() { + let target = Amount::from_str("7 cBTC").unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list: Vec<_> = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ) + .unwrap() + .collect(); + + 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 find_solution_2_btc() { - let utxo_match = find_solution(TWO_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![false, false, true, false]; - assert_eq!(expected_bool_vec, utxo_match); + fn select_coins_bnb_eight() { + let target = Amount::from_str("8 cBTC").unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list: Vec<_> = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ) + .unwrap() + .collect(); + + 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 find_solution_3_btc() { - let utxo_match = find_solution(THREE_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![false, false, true, true]; - assert_eq!(expected_bool_vec, utxo_match); + fn select_coins_bnb_nine() { + let target = Amount::from_str("9 cBTC").unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list: Vec<_> = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ) + .unwrap() + .collect(); + + 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 find_solution_4_btc() { - let utxo_match = find_solution(FOUR_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![false, true, false, true]; - assert_eq!(expected_bool_vec, utxo_match); + fn select_coins_bnb_ten() { + let target = Amount::from_str("10 cBTC").unwrap(); + let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + + let list: Vec<_> = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ) + .unwrap() + .collect(); + + 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] - fn find_solution_5_btc() { - let five_btc = FOUR_BTC + ONE_BTC; - let utxo_match = find_solution(five_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![false, true, true, false]; - assert_eq!(expected_bool_vec, utxo_match); + fn select_coins_bnb_cost_of_change() { + let target = Amount::from_str("1 cBTC").unwrap(); + + // 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(), + }, + }]; + + let mut wu = weighted_utxos.clone(); + + let list: Vec<_> = + select_coins_bnb(target, cost_of_change, FeeRate::ZERO, FeeRate::ZERO, &mut wu) + .unwrap() + .collect(); + + 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, FeeRate::ZERO, &mut wu); + assert!(index_list.is_none()); } #[test] - fn find_solution_6_btc() { - let six_btc = FOUR_BTC + TWO_BTC; - let utxo_match = find_solution(six_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![false, true, true, true]; - assert_eq!(expected_bool_vec, utxo_match); + fn select_coins_bnb_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, fee_rate, &mut wu); + assert!(index_list.is_none()); } #[test] - fn find_solution_7_btc() { - let seven_btc = FOUR_BTC + THREE_BTC; - let utxo_match = find_solution(seven_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![true, false, true, true]; - assert_eq!(expected_bool_vec, utxo_match); + fn select_coins_bnb_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: Vec<_> = select_coins_bnb(target, cost_of_change, fee_rate, fee_rate, &mut wu) + .unwrap() + .collect(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].utxo.value, Amount::from_str("1.5 cBTC").unwrap()); } #[test] - fn find_solution_8_btc() { - let seven_btc = FOUR_BTC + THREE_BTC + ONE_BTC; - let utxo_match = find_solution(seven_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![true, true, false, true]; - assert_eq!(expected_bool_vec, utxo_match); + fn select_coins_bnb_target_greater_than_value() { + let target = Amount::from_str("11 cBTC").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, + ); + assert!(list.is_none()); } #[test] - fn find_solution_9_btc() { - let seven_btc = FOUR_BTC + THREE_BTC + TWO_BTC; - let utxo_match = find_solution(seven_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![true, true, true, false]; - assert_eq!(expected_bool_vec, utxo_match); + fn select_coins_bnb_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: Vec<_> = + select_coins_bnb(target, Amount::ZERO, fee_rate, lt_fee_rate, &mut weighted_utxos) + .unwrap() + .collect(); + + 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 find_solution_10_btc() { - let ten_btc = ONE_BTC + TWO_BTC + THREE_BTC + FOUR_BTC; - let utxo_match = find_solution(ten_btc, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![true, true, true, true]; - assert_eq!(expected_bool_vec, utxo_match); + fn select_coins_bnb_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: Vec<_> = + select_coins_bnb(target, Amount::ZERO, fee_rate, lt_fee_rate, &mut weighted_utxos) + .unwrap() + .collect(); + + 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); } #[test] - fn find_solution_11_btc_not_possible() { - let ten_btc = ONE_BTC + TWO_BTC + THREE_BTC + FOUR_BTC; - let utxo_match = find_solution(ten_btc + ONE_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()); - assert_eq!(None, utxo_match); + fn select_coins_bnb_consume_less_inputs_with_excess_when_expensive() { + // prefer using less inputs with excess vs more inputs with + // less excess when fees are expensive. + // + // In otherwords, the selection will choose 6 cBTC + 1 sat using two inputs + // instead of exactly 6 cBTC with three inputs during a high fee + // environment. + let target = Amount::from_str("6 cBTC").unwrap(); + let fee = Amount::from_str("4 sats").unwrap(); + + let values = vec![ + 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() + Amount::from_str("1 sats").unwrap() + fee, + ]; + + let weighted_utxos = create_weighted_utxos_from_values(values); + + let fee_rate = FeeRate::from_sat_per_kwu(20); + let lt_fee_rate = FeeRate::from_sat_per_kwu(10); + + let cost_of_change = Amount::from_str("1 sats").unwrap(); + let list: Vec<_> = + select_coins_bnb(target, cost_of_change, fee_rate, lt_fee_rate, &weighted_utxos) + .unwrap() + .collect(); + + assert_eq!(list.len(), 2); + assert_eq!( + list[0].utxo.value, + Amount::from_str("4 cBTC").unwrap() + Amount::from_str("1 sats").unwrap() + fee + ); + assert_eq!(list[1].utxo.value, Amount::from_str("2 cBTC").unwrap() + fee); } #[test] - fn find_solution_with_large_cost_of_change() { - let utxo_match = - find_solution(ONE_BTC * 9 / 10, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); - let expected_bool_vec = vec![false, false, false, true]; - assert_eq!(expected_bool_vec, utxo_match); + fn select_coins_bnb_utxo_pool_sum_overflow() { + let target = Amount::from_str("1 cBTC").unwrap(); + let satisfaction_weight = Weight::from_wu(204); + let value = SignedAmount::MAX.to_unsigned().unwrap(); + let mut weighted_utxos = vec![ + WeightedUtxo { + satisfaction_weight, + utxo: TxOut { value, script_pubkey: ScriptBuf::new() }, + }, + WeightedUtxo { + satisfaction_weight, + utxo: TxOut { value, script_pubkey: ScriptBuf::new() }, + }, + ]; + let list = select_coins_bnb( + target, + Amount::ZERO, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ); + assert!(list.is_none()); } #[test] - fn find_solution_with_no_cost_of_change() { - let utxo_match = find_solution(ONE_BTC * 9 / 10, 0, &mut UTXO_POOL.clone()); - assert_eq!(None, utxo_match); + fn select_coins_bnb_upper_bound_overflow() { + // the upper_bound is target + cost_of_change. + // adding these two together returns NONE on overflow. + let target = Amount::MAX; + let cost_of_change = Amount::MAX; + + let satisfaction_weight = Weight::from_wu(204); + let mut weighted_utxos = vec![WeightedUtxo { + satisfaction_weight, + utxo: TxOut { value: target, script_pubkey: ScriptBuf::new() }, + }]; + + let list = select_coins_bnb( + target, + cost_of_change, + FeeRate::ZERO, + FeeRate::ZERO, + &mut weighted_utxos, + ); + assert!(list.is_none()); } #[test] - fn find_solution_with_not_input_fee() { - let utxo_match = find_solution(ONE_BTC + 1, COST_OF_CHANGE, &mut UTXO_POOL.clone()); - assert_eq!(None, utxo_match); + fn select_coins_bnb_set_size_five() { + let target = Amount::from_str("6 cBTC").unwrap(); + let cost_of_change = Amount::ZERO; + let vals = vec![ + Amount::from_str("3 cBTC").unwrap(), + Amount::from_str("2.9 cBTC").unwrap(), + Amount::from_str("2 cBTC").unwrap(), + Amount::from_str("1.9 cBTC").unwrap(), + Amount::from_str("1 cBTC").unwrap(), + ]; + + let weighted_utxos = create_weighted_utxos_from_values(vals); + let list: Vec<_> = + select_coins_bnb(target, cost_of_change, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos) + .unwrap() + .collect(); + + 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 select_coins_bnb_with_match() { - select_coins_bnb(ONE_BTC, COST_OF_CHANGE, &mut UTXO_POOL.clone()).unwrap(); + fn select_coins_bnb_set_size_seven() { + let target = Amount::from_str("18 cBTC").unwrap(); + let cost_of_change = Amount::from_str("50 sats").unwrap(); + let vals = vec![ + Amount::from_str("10 cBTC").unwrap(), + Amount::from_str("7 cBTC").unwrap() + Amount::from_str("5 sats").unwrap(), + Amount::from_str("6 cBTC").unwrap() + Amount::from_str("5 sats").unwrap(), + Amount::from_str("6 cBTC").unwrap(), + Amount::from_str("3 cBTC").unwrap(), + Amount::from_str("2 cBTC").unwrap(), + Amount::from_str("1 cBTC").unwrap() + Amount::from_str("5 sats").unwrap(), + ]; + + let weighted_utxos = create_weighted_utxos_from_values(vals); + let list: Vec<_> = + select_coins_bnb(target, cost_of_change, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos) + .unwrap() + .collect(); + + assert_eq!(list.len(), 3); + assert_eq!(list[0].utxo.value, Amount::from_str("10 cBTC").unwrap()); + assert_eq!(list[1].utxo.value, Amount::from_str("6 cBTC").unwrap()); + assert_eq!(list[2].utxo.value, Amount::from_str("2 cBTC").unwrap()); } #[test] - fn select_coins_bnb_with_no_match() { - let utxo_match = select_coins_bnb(1, COST_OF_CHANGE, &mut UTXO_POOL.clone()); - assert_eq!(None, utxo_match); + fn select_coins_bnb_exhaust() { + // Recreate make_hard from bitcoin core test suit. + // Takes 327,661 iterations to find a solution. + let base: usize = 2; + let alpha = (0..17).enumerate().map(|(i, _)| base.pow(17 + i as u32)); + let target = Amount::from_sat(alpha.clone().sum::() as u64); + + let beta = (0..17).enumerate().map(|(i, _)| { + let a = base.pow(17 + i as u32); + let b = base.pow(16 - i as u32); + a + b + }); + + let vals: Vec<_> = zip(alpha, beta) + // flatten requires iterable types. + // use once() to make tuple iterable. + .flat_map(|tup| once(tup.0).chain(once(tup.1))) + .map(|a| Amount::from_sat(a as u64)) + .collect(); + + let weighted_utxos = create_weighted_utxos_from_values(vals); + let list = select_coins_bnb( + target, + Amount::ONE_SAT, + FeeRate::ZERO, + FeeRate::ZERO, + &weighted_utxos, + ); + + assert!(list.is_none()); + } + + #[test] + fn select_coins_bnb_exhaust_v2() { + // Takes 163,819 iterations to find a solution. + let base: usize = 2; + let mut target = 0; + let vals = (0..15).enumerate().flat_map(|(i, _)| { + let a = base.pow(15 + i as u32) as u64; + target += a; + vec![a, a + 2] + }); + + let vals: Vec<_> = vals.map(Amount::from_sat).collect(); + let weighted_utxos = create_weighted_utxos_from_values(vals); + let list = select_coins_bnb( + Amount::from_sat(target), + Amount::ONE_SAT, + FeeRate::ZERO, + FeeRate::ZERO, + &weighted_utxos, + ); + + assert!(list.is_none()); + } + + #[test] + fn select_coins_bnb_exhaust_with_result() { + // This returns a result AND hits the iteration exhaust limit. + + // Takes 163,819 iterations (hits the iteration limit). + let base: usize = 2; + let mut target = 0; + let vals = (0..15).enumerate().flat_map(|(i, _)| { + let a = base.pow(15 + i as u32) as u64; + target += a; + vec![a, a + 2] + }); + + let mut vals: Vec<_> = vals.map(Amount::from_sat).collect(); + + // Add a value that will match the target before iteration exhaustion occurs. + vals.push(Amount::from_sat(target)); + let weighted_utxos = create_weighted_utxos_from_values(vals); + let mut list = select_coins_bnb( + Amount::from_sat(target), + Amount::ONE_SAT, + FeeRate::ZERO, + FeeRate::ZERO, + &weighted_utxos, + ) + .unwrap(); + + assert_eq!(list.len(), 1); + assert_eq!(list.next().unwrap().utxo.value, Amount::from_sat(target)); } } diff --git a/src/lib.rs b/src/lib.rs index 731ca92..8d5c1e8 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; use crate::branch_and_bound::select_coins_bnb; use crate::single_random_draw::select_coins_srd; +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 @@ -43,6 +45,7 @@ const CHANGE_LOWER: Amount = Amount::from_sat(50_000); /// The idea of using a WeightUtxo type was inspired by the BDK implementation: /// #[derive(Clone, Debug, PartialEq)] +// note, change this to private? No good reason to be public. pub struct WeightedUtxo { /// The satisfaction_weight is the size of the required params to satisfy the UTXO. pub satisfaction_weight: Weight, @@ -50,6 +53,24 @@ pub struct WeightedUtxo { pub utxo: TxOut, } +impl WeightedUtxo { + fn effective_value(&self, fee_rate: FeeRate) -> Option { + 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)?; + fee_rate.checked_mul_by_weight(weight) + } + + 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) + } +} + /// 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 @@ -59,13 +80,10 @@ pub struct WeightedUtxo { #[cfg_attr(docsrs, doc(cfg(feature = "rand")))] pub fn select_coins( target: Amount, - cost_of_change: u64, + cost_of_change: Amount, fee_rate: FeeRate, + long_term_fee_rate: FeeRate, weighted_utxos: &mut [WeightedUtxo], - utxo_pool: &mut [T], -) -> Option> { - match select_coins_bnb(target.to_sat(), cost_of_change, utxo_pool) { - Some(_res) => Some(Vec::new()), - None => select_coins_srd(target, fee_rate, weighted_utxos, &mut thread_rng()), - } +) -> Option> { + select_coins_bnb(target, cost_of_change, fee_rate, long_term_fee_rate, weighted_utxos) } From 2f927e9d956693f24fe97e87bd44beb019352aae Mon Sep 17 00:00:00 2001 From: yancy Date: Thu, 11 Jan 2024 12:43:16 +0100 Subject: [PATCH 3/6] Replace cargo bench with criterion --- Cargo.toml | 5 ++++ README.md | 15 +++++++++++- benches/coin_selection.rs | 49 +++++++++++++++++++++++++++++++++++++++ src/branch_and_bound.rs | 47 ------------------------------------- src/lib.rs | 2 +- 5 files changed, 69 insertions(+), 49 deletions(-) create mode 100644 benches/coin_selection.rs diff --git a/Cargo.toml b/Cargo.toml index 8fb6684..ff63da2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ bitcoin = { git="https://github.com/yancyribbens/rust-bitcoin", rev="2f109442e30 rand = {version = "0.8.5", default-features = false, optional = true} [dev-dependencies] +criterion = "0.3" rust-bitcoin-coin-selection = {path = ".", features = ["rand"]} rand = "0.8.5" @@ -27,3 +28,7 @@ bitcoin_hashes = { git = "https://github.com/yancyribbens/rust-bitcoin", rev="2f bitcoin-io = { git = "https://github.com/yancyribbens/rust-bitcoin", rev="2f109442e30d74fb7502e7fd1ce2075a67262cd5" } bitcoin-units = { git = "https://github.com/yancyribbens/rust-bitcoin", rev="2f109442e30d74fb7502e7fd1ce2075a67262cd5" } bitcoin-internals = { git = "https://github.com/yancyribbens/rust-bitcoin", rev="2f109442e30d74fb7502e7fd1ce2075a67262cd5" } + +[[bench]] +name = "coin_selection" +harness = false diff --git a/README.md b/README.md index a97cbdb..f038306 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,20 @@ As discussed in the literature above, ideally we want to choose a selection from ## Benchmarks -To run the benchmarks use: `RUSTFLAGS='--cfg=bench' cargo +nightly bench`. +To run the benchmarks use: `cargo bench`. + +Note: criterion requires rustc version 1.65 to run the benchmarks. + +### performance comparison + +A basic performance comparison between this current [Rust BnB](https://github.com/p2pderivatives/rust-bitcoin-coin-selection/pull/28/files#diff-9098d62be93e83524a8371395c973d761a95000d1c295f600a8c808e917c16d9R122) implementation and the [Bitcoin Core](https://github.com/bitcoin/bitcoin/blob/4b1196a9855dcd188a24f393aa2fa21e2d61f061/src/wallet/coinselection.cpp#L76) version using commodity hardware (My rather old laptop). + +|implementation|pool size|ns/iter| +|-------------:|---------|-------| +| Rust BnB| 1,000|897,810| +| C++ Core BnB| 1,000|816,374| + +Note: The measurements where recorded using rustc 1.75. Expect worse performance with MSRV. ## Minimum Supported Rust Version (MSRV) diff --git a/benches/coin_selection.rs b/benches/coin_selection.rs new file mode 100644 index 0000000..40e3c30 --- /dev/null +++ b/benches/coin_selection.rs @@ -0,0 +1,49 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +use bitcoin::Amount; +use bitcoin::FeeRate; +use bitcoin::ScriptBuf; +use bitcoin::TxOut; +use bitcoin::Weight; +use rust_bitcoin_coin_selection::select_coins_bnb; +use rust_bitcoin_coin_selection::WeightedUtxo; + +pub fn criterion_benchmark(c: &mut Criterion) { + // https://github.com/bitcoin/bitcoin/blob/f3bc1a72825fe2b51f4bc20e004cef464f05b965/src/wallet/coinselection.h#L18 + let cost_of_change = Amount::from_sat(50_000); + + let one = WeightedUtxo { + satisfaction_weight: Weight::ZERO, + utxo: TxOut { value: Amount::from_sat(1_000), script_pubkey: ScriptBuf::new() }, + }; + + let two = WeightedUtxo { + satisfaction_weight: Weight::ZERO, + utxo: TxOut { value: Amount::from_sat(3), script_pubkey: ScriptBuf::new() }, + }; + + let target = Amount::from_sat(1_003); + let mut utxo_pool = vec![one; 1000]; + utxo_pool.push(two); + + c.bench_function("bnb 1000", |b| { + b.iter(|| { + let result: Vec<_> = select_coins_bnb( + black_box(target), + black_box(cost_of_change), + black_box(FeeRate::ZERO), + black_box(FeeRate::ZERO), + black_box(&mut utxo_pool), + ) + .unwrap() + .collect(); + + assert_eq!(2, result.len()); + assert_eq!(Amount::from_sat(1_000), result[0].utxo.value); + assert_eq!(Amount::from_sat(3), result[1].utxo.value); + }) + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/src/branch_and_bound.rs b/src/branch_and_bound.rs index ae8e2ab..c219095 100644 --- a/src/branch_and_bound.rs +++ b/src/branch_and_bound.rs @@ -926,50 +926,3 @@ mod tests { assert_eq!(list.next().unwrap().utxo.value, Amount::from_sat(target)); } } - -#[cfg(bench)] -#[cfg(test)] -mod benches { - use crate::select_coins_bnb; - use crate::Utxo; - use test::Bencher; - - #[derive(Clone, Debug, Eq, PartialEq)] - struct MinimalUtxo { - value: u64, - } - - impl Utxo for MinimalUtxo { - fn get_value(&self) -> u64 { - self.value - } - } - - #[bench] - /// Creates a UTXO pool of 1,000 coins that do not match and one coin - /// that will be a match when combined with any of the other 1,000 coins. - /// - /// Matching benchmark of Bitcoin core coin-selection benchmark. - // https://github.com/bitcoin/bitcoin/blob/f3bc1a72825fe2b51f4bc20e004cef464f05b965/src/bench/coin_selection.cpp#L44 - fn bench_select_coins_bnb(bh: &mut Bencher) { - // https://github.com/bitcoin/bitcoin/blob/f3bc1a72825fe2b51f4bc20e004cef464f05b965/src/consensus/amount.h#L15 - /// The amount of satoshis in one BTC. - const COIN: u64 = 100_000_000; - - // https://github.com/bitcoin/bitcoin/blob/f3bc1a72825fe2b51f4bc20e004cef464f05b965/src/wallet/coinselection.h#L18 - /// lower bound for randomly-chosen target change amount - const CHANGE_LOWER: u64 = 50_000; - - let u = MinimalUtxo { value: 1000 * COIN }; - let mut utxo_pool = vec![u; 1000]; - utxo_pool.push(MinimalUtxo { value: 3 * COIN }); - - bh.iter(|| { - let result = - select_coins_bnb(1003 * COIN, CHANGE_LOWER, &mut utxo_pool.clone()).unwrap(); - assert_eq!(2, result.len()); - assert_eq!(1000 * COIN, result[0].value); - assert_eq!(3 * COIN, result[1].value); - }); - } -} diff --git a/src/lib.rs b/src/lib.rs index 8d5c1e8..bfad26b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,7 +25,7 @@ use bitcoin::SignedAmount; use bitcoin::TxOut; use bitcoin::Weight; -use crate::branch_and_bound::select_coins_bnb; +pub use crate::branch_and_bound::select_coins_bnb; use crate::single_random_draw::select_coins_srd; use bitcoin::blockdata::transaction::TxIn; use rand::thread_rng; From 8b5c1b2ce61635841bdbf680c3a18997b966105d Mon Sep 17 00:00:00 2001 From: yancy Date: Tue, 13 Feb 2024 08:48:16 +0100 Subject: [PATCH 4/6] Return Iterator instead of Vector for SRD --- benches/coin_selection.rs | 2 +- src/branch_and_bound.rs | 211 +++++++++++++------------------------- src/lib.rs | 15 ++- src/single_random_draw.rs | 97 +++++++++++------- 4 files changed, 143 insertions(+), 182 deletions(-) diff --git a/benches/coin_selection.rs b/benches/coin_selection.rs index 40e3c30..7a5b741 100644 --- a/benches/coin_selection.rs +++ b/benches/coin_selection.rs @@ -33,7 +33,7 @@ pub fn criterion_benchmark(c: &mut Criterion) { black_box(cost_of_change), black_box(FeeRate::ZERO), black_box(FeeRate::ZERO), - black_box(&mut utxo_pool), + black_box(&utxo_pool), ) .unwrap() .collect(); diff --git a/src/branch_and_bound.rs b/src/branch_and_bound.rs index c219095..915e4af 100644 --- a/src/branch_and_bound.rs +++ b/src/branch_and_bound.rs @@ -147,7 +147,7 @@ pub fn select_coins_bnb( cost_of_change: Amount, fee_rate: FeeRate, long_term_fee_rate: FeeRate, - weighted_utxos: &mut [WeightedUtxo] + weighted_utxos: &[WeightedUtxo], ) -> Option> { // Total_Tries in Core: // https://github.com/bitcoin/bitcoin/blob/1d9da8da309d1dbf9aef15eb8dc43b4a2dc3d309/src/wallet/coinselection.cpp#L74 @@ -354,17 +354,12 @@ mod tests { #[test] fn select_coins_bnb_one() { let target = Amount::from_str("1 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + let weighted_utxos = create_weighted_utxos(Amount::ZERO); - let list: Vec<_> = select_coins_bnb( - target, - Amount::ZERO, - FeeRate::ZERO, - FeeRate::ZERO, - &mut weighted_utxos, - ) - .unwrap() - .collect(); + let list: Vec<_> = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos) + .unwrap() + .collect(); assert_eq!(list.len(), 1); assert_eq!(list[0].utxo.value, Amount::from_str("1 cBTC").unwrap()); @@ -373,17 +368,12 @@ mod tests { #[test] fn select_coins_bnb_two() { let target = Amount::from_str("2 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + let weighted_utxos = create_weighted_utxos(Amount::ZERO); - let list: Vec<_> = select_coins_bnb( - target, - Amount::ZERO, - FeeRate::ZERO, - FeeRate::ZERO, - &mut weighted_utxos, - ) - .unwrap() - .collect(); + let list: Vec<_> = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos) + .unwrap() + .collect(); assert_eq!(list.len(), 1); assert_eq!(list[0].utxo.value, Amount::from_str("2 cBTC").unwrap()); @@ -392,17 +382,12 @@ mod tests { #[test] fn select_coins_bnb_three() { let target = Amount::from_str("3 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + let weighted_utxos = create_weighted_utxos(Amount::ZERO); - let list: Vec<_> = select_coins_bnb( - target, - Amount::ZERO, - FeeRate::ZERO, - FeeRate::ZERO, - &mut weighted_utxos, - ) - .unwrap() - .collect(); + let list: Vec<_> = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos) + .unwrap() + .collect(); assert_eq!(list.len(), 2); assert_eq!(list[0].utxo.value, Amount::from_str("2 cBTC").unwrap()); @@ -412,17 +397,12 @@ mod tests { #[test] fn select_coins_bnb_four() { let target = Amount::from_str("4 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + let weighted_utxos = create_weighted_utxos(Amount::ZERO); - let list: Vec<_> = select_coins_bnb( - target, - Amount::ZERO, - FeeRate::ZERO, - FeeRate::ZERO, - &mut weighted_utxos, - ) - .unwrap() - .collect(); + let list: Vec<_> = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos) + .unwrap() + .collect(); assert_eq!(list.len(), 2); assert_eq!(list[0].utxo.value, Amount::from_str("3 cBTC").unwrap()); @@ -432,17 +412,12 @@ mod tests { #[test] fn select_coins_bnb_five() { let target = Amount::from_str("5 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + let weighted_utxos = create_weighted_utxos(Amount::ZERO); - let list: Vec<_> = select_coins_bnb( - target, - Amount::ZERO, - FeeRate::ZERO, - FeeRate::ZERO, - &mut weighted_utxos, - ) - .unwrap() - .collect(); + let list: Vec<_> = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos) + .unwrap() + .collect(); assert_eq!(list.len(), 2); assert_eq!(list[0].utxo.value, Amount::from_str("3 cBTC").unwrap()); @@ -452,17 +427,12 @@ mod tests { #[test] fn select_coins_bnb_six() { let target = Amount::from_str("6 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + let weighted_utxos = create_weighted_utxos(Amount::ZERO); - let list: Vec<_> = select_coins_bnb( - target, - Amount::ZERO, - FeeRate::ZERO, - FeeRate::ZERO, - &mut weighted_utxos, - ) - .unwrap() - .collect(); + let list: Vec<_> = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos) + .unwrap() + .collect(); assert_eq!(list.len(), 3); assert_eq!(list[0].utxo.value, Amount::from_str("3 cBTC").unwrap()); @@ -473,17 +443,12 @@ mod tests { #[test] fn select_coins_bnb_seven() { let target = Amount::from_str("7 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + let weighted_utxos = create_weighted_utxos(Amount::ZERO); - let list: Vec<_> = select_coins_bnb( - target, - Amount::ZERO, - FeeRate::ZERO, - FeeRate::ZERO, - &mut weighted_utxos, - ) - .unwrap() - .collect(); + let list: Vec<_> = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos) + .unwrap() + .collect(); assert_eq!(list.len(), 3); assert_eq!(list[0].utxo.value, Amount::from_str("4 cBTC").unwrap()); @@ -494,17 +459,12 @@ mod tests { #[test] fn select_coins_bnb_eight() { let target = Amount::from_str("8 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + let weighted_utxos = create_weighted_utxos(Amount::ZERO); - let list: Vec<_> = select_coins_bnb( - target, - Amount::ZERO, - FeeRate::ZERO, - FeeRate::ZERO, - &mut weighted_utxos, - ) - .unwrap() - .collect(); + let list: Vec<_> = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos) + .unwrap() + .collect(); assert_eq!(list.len(), 3); assert_eq!(list[0].utxo.value, Amount::from_str("4 cBTC").unwrap()); @@ -515,17 +475,12 @@ mod tests { #[test] fn select_coins_bnb_nine() { let target = Amount::from_str("9 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + let weighted_utxos = create_weighted_utxos(Amount::ZERO); - let list: Vec<_> = select_coins_bnb( - target, - Amount::ZERO, - FeeRate::ZERO, - FeeRate::ZERO, - &mut weighted_utxos, - ) - .unwrap() - .collect(); + let list: Vec<_> = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos) + .unwrap() + .collect(); assert_eq!(list.len(), 3); assert_eq!(list[0].utxo.value, Amount::from_str("4 cBTC").unwrap()); @@ -536,17 +491,12 @@ mod tests { #[test] fn select_coins_bnb_ten() { let target = Amount::from_str("10 cBTC").unwrap(); - let mut weighted_utxos = create_weighted_utxos(Amount::ZERO); + let weighted_utxos = create_weighted_utxos(Amount::ZERO); - let list: Vec<_> = select_coins_bnb( - target, - Amount::ZERO, - FeeRate::ZERO, - FeeRate::ZERO, - &mut weighted_utxos, - ) - .unwrap() - .collect(); + let list: Vec<_> = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos) + .unwrap() + .collect(); assert_eq!(list.len(), 4); assert_eq!(list[0].utxo.value, Amount::from_str("4 cBTC").unwrap()); @@ -571,18 +521,17 @@ mod tests { }, }]; - let mut wu = weighted_utxos.clone(); + let wu = weighted_utxos.clone(); let list: Vec<_> = - select_coins_bnb(target, cost_of_change, FeeRate::ZERO, FeeRate::ZERO, &mut wu) + select_coins_bnb(target, cost_of_change, FeeRate::ZERO, FeeRate::ZERO, &wu) .unwrap() .collect(); 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, FeeRate::ZERO, &mut wu); + let index_list = select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &wu); assert!(index_list.is_none()); } @@ -602,8 +551,8 @@ mod tests { }, }]; - let mut wu = weighted_utxos.clone(); - let index_list = select_coins_bnb(target, Amount::ZERO, fee_rate, fee_rate, &mut wu); + let wu = weighted_utxos.clone(); + let index_list = select_coins_bnb(target, Amount::ZERO, fee_rate, fee_rate, &wu); assert!(index_list.is_none()); } @@ -638,10 +587,9 @@ mod tests { }, ]; - let mut wu = weighted_utxos.clone(); - let list: Vec<_> = select_coins_bnb(target, cost_of_change, fee_rate, fee_rate, &mut wu) - .unwrap() - .collect(); + let wu = weighted_utxos.clone(); + let list: Vec<_> = + select_coins_bnb(target, cost_of_change, fee_rate, fee_rate, &wu).unwrap().collect(); assert_eq!(list.len(), 1); assert_eq!(list[0].utxo.value, Amount::from_str("1.5 cBTC").unwrap()); } @@ -649,14 +597,9 @@ mod tests { #[test] fn select_coins_bnb_target_greater_than_value() { let target = Amount::from_str("11 cBTC").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, - ); + let weighted_utxos = create_weighted_utxos(Amount::ZERO); + let list = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos); assert!(list.is_none()); } @@ -664,7 +607,7 @@ mod tests { fn select_coins_bnb_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 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); @@ -672,7 +615,7 @@ mod tests { // the possible combinations are 2,4 or 1,2,3 // fees are cheap, so use 1,2,3 let list: Vec<_> = - select_coins_bnb(target, Amount::ZERO, fee_rate, lt_fee_rate, &mut weighted_utxos) + select_coins_bnb(target, Amount::ZERO, fee_rate, lt_fee_rate, &weighted_utxos) .unwrap() .collect(); @@ -686,7 +629,7 @@ mod tests { fn select_coins_bnb_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 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); @@ -694,7 +637,7 @@ mod tests { // the possible combinations are 2,4 or 1,2,3 // fees are expensive, so use 2,4 let list: Vec<_> = - select_coins_bnb(target, Amount::ZERO, fee_rate, lt_fee_rate, &mut weighted_utxos) + select_coins_bnb(target, Amount::ZERO, fee_rate, lt_fee_rate, &weighted_utxos) .unwrap() .collect(); @@ -745,7 +688,7 @@ mod tests { let target = Amount::from_str("1 cBTC").unwrap(); let satisfaction_weight = Weight::from_wu(204); let value = SignedAmount::MAX.to_unsigned().unwrap(); - let mut weighted_utxos = vec![ + let weighted_utxos = vec![ WeightedUtxo { satisfaction_weight, utxo: TxOut { value, script_pubkey: ScriptBuf::new() }, @@ -755,13 +698,8 @@ mod tests { utxo: TxOut { value, script_pubkey: ScriptBuf::new() }, }, ]; - let list = select_coins_bnb( - target, - Amount::ZERO, - FeeRate::ZERO, - FeeRate::ZERO, - &mut weighted_utxos, - ); + let list = + select_coins_bnb(target, Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos); assert!(list.is_none()); } @@ -773,18 +711,13 @@ mod tests { let cost_of_change = Amount::MAX; let satisfaction_weight = Weight::from_wu(204); - let mut weighted_utxos = vec![WeightedUtxo { + let weighted_utxos = vec![WeightedUtxo { satisfaction_weight, utxo: TxOut { value: target, script_pubkey: ScriptBuf::new() }, }]; - let list = select_coins_bnb( - target, - cost_of_change, - FeeRate::ZERO, - FeeRate::ZERO, - &mut weighted_utxos, - ); + let list = + select_coins_bnb(target, cost_of_change, FeeRate::ZERO, FeeRate::ZERO, &weighted_utxos); assert!(list.is_none()); } diff --git a/src/lib.rs b/src/lib.rs index bfad26b..9e4afd7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -83,7 +83,16 @@ pub fn select_coins( cost_of_change: Amount, fee_rate: FeeRate, long_term_fee_rate: FeeRate, - weighted_utxos: &mut [WeightedUtxo], -) -> Option> { - select_coins_bnb(target, cost_of_change, fee_rate, long_term_fee_rate, weighted_utxos) + weighted_utxos: &[WeightedUtxo], +) -> Option> { + { + let bnb = + select_coins_bnb(target, cost_of_change, fee_rate, long_term_fee_rate, weighted_utxos); + + if bnb.is_some() { + bnb + } else { + select_coins_srd(target, fee_rate, weighted_utxos, &mut thread_rng()) + } + } } diff --git a/src/single_random_draw.rs b/src/single_random_draw.rs index 6347ba7..2d5bdb6 100644 --- a/src/single_random_draw.rs +++ b/src/single_random_draw.rs @@ -28,20 +28,22 @@ use rand::seq::SliceRandom; /// /// * `fee_rate` - ratio of transaction amount per size. /// /// * `weighted_utxos` - Weighted UTXOs from which to sum the target amount. /// /// * `rng` - used primarily by tests to make the selection deterministic. -pub fn select_coins_srd( +pub fn select_coins_srd<'a, R: rand::Rng + ?Sized>( target: Amount, fee_rate: FeeRate, - weighted_utxos: &mut [WeightedUtxo], + weighted_utxos: &'a [WeightedUtxo], rng: &mut R, -) -> Option> { - let mut result: Vec = Vec::new(); +) -> Option> { + let mut result: Vec<_> = weighted_utxos.iter().collect(); + let mut origin = result.to_owned(); + origin.shuffle(rng); - weighted_utxos.shuffle(rng); + result.clear(); let threshold = target + CHANGE_LOWER; let mut value = Amount::ZERO; - for w_utxo in weighted_utxos { + for w_utxo in origin { let utxo_value = w_utxo.utxo.value; let effective_value = effective_value(fee_rate, w_utxo.satisfaction_weight, utxo_value)?; @@ -50,14 +52,14 @@ pub fn select_coins_srd( Err(_) => continue, }; - result.push(w_utxo.clone()); + result.push(w_utxo); if value >= threshold { - return Some(result); + return Some(result.into_iter()); } } - Some(Vec::new()) + None } #[cfg(test)] @@ -113,34 +115,43 @@ mod tests { #[test] fn select_coins_srd_with_solution() { let target: Amount = Amount::from_str("1.5 cBTC").unwrap(); - let mut weighted_utxos: Vec = create_weighted_utxos(); + let weighted_utxos: Vec = create_weighted_utxos(); - let result = select_coins_srd(target, FEE_RATE, &mut weighted_utxos, &mut get_rng()) - .expect("unexpected error"); + let result: Vec<&WeightedUtxo> = + select_coins_srd(target, FEE_RATE, &weighted_utxos, &mut get_rng()) + .expect("unexpected error") + .collect(); - assert_eq!(vec![weighted_utxos[0].clone()], result); + let expected_result = Amount::from_str("2 cBTC").unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(expected_result, result[0].utxo.value); } #[test] fn select_coins_srd_no_solution() { let target: Amount = Amount::from_str("4 cBTC").unwrap(); - let mut weighted_utxos: Vec = create_weighted_utxos(); + let weighted_utxos: Vec = create_weighted_utxos(); - let result = select_coins_srd(target, FEE_RATE, &mut weighted_utxos, &mut get_rng()) - .expect("unexpected error"); - - assert!(result.is_empty()); + let result = select_coins_srd(target, FEE_RATE, &weighted_utxos, &mut get_rng()); + assert!(result.is_none()) } #[test] fn select_coins_srd_all_solution() { let target: Amount = Amount::from_str("2.5 cBTC").unwrap(); - let mut weighted_utxos: Vec = create_weighted_utxos(); + let weighted_utxos: Vec = create_weighted_utxos(); + + let result: Vec<&WeightedUtxo> = + select_coins_srd(target, FeeRate::ZERO, &weighted_utxos, &mut get_rng()) + .expect("unexpected error") + .collect(); - let result = select_coins_srd(target, FeeRate::ZERO, &mut weighted_utxos, &mut get_rng()) - .expect("unexpected error"); + let expected_second_element = Amount::from_str("1 cBTC").unwrap(); + let expected_first_element = Amount::from_str("2 cBTC").unwrap(); - assert_eq!(weighted_utxos.clone(), result); + assert_eq!(result.len(), 2); + assert_eq!(result[0].utxo.value, expected_first_element); + assert_eq!(result[1].utxo.value, expected_second_element); } #[test] @@ -157,32 +168,35 @@ mod tests { }); let mut rng = get_rng(); - let result = select_coins_srd(target, FEE_RATE, &mut weighted_utxos, &mut rng) - .expect("unexpected error"); + let result: Vec<_> = select_coins_srd(target, FEE_RATE, &weighted_utxos, &mut rng) + .expect("unexpected error") + .collect(); - let mut expected_utxos = create_weighted_utxos(); - expected_utxos.shuffle(&mut rng); - assert_eq!(result, expected_utxos); + let expected_second_element = Amount::from_str("1 cBTC").unwrap(); + let expected_first_element = Amount::from_str("2 cBTC").unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].utxo.value, expected_first_element); + assert_eq!(result[1].utxo.value, expected_second_element); } #[test] fn select_coins_srd_fee_rate_error() { let target: Amount = Amount::from_str("2 cBTC").unwrap(); - let mut weighted_utxos: Vec = create_weighted_utxos(); + let weighted_utxos: Vec = create_weighted_utxos(); - let result = select_coins_srd(target, FeeRate::MAX, &mut weighted_utxos, &mut get_rng()); + let result = select_coins_srd(target, FeeRate::MAX, &weighted_utxos, &mut get_rng()); assert!(result.is_none()); } #[test] fn select_coins_srd_change_output_too_small() { let target: Amount = Amount::from_str("3 cBTC").unwrap(); - let mut weighted_utxos: Vec = create_weighted_utxos(); + let weighted_utxos: Vec = create_weighted_utxos(); - let result = select_coins_srd(target, FEE_RATE, &mut weighted_utxos, &mut get_rng()) - .expect("unexpected error"); + let result = select_coins_srd(target, FEE_RATE, &weighted_utxos, &mut get_rng()); - assert!(result.is_empty()); + assert!(result.is_none()); } #[test] @@ -195,19 +209,24 @@ mod tests { // fee = 15 sats, since // 40 sat/kwu * (204 + BASE_WEIGHT) = 15 sats let fee_rate: FeeRate = FeeRate::from_sat_per_kwu(40); - let mut weighted_utxos: Vec = create_weighted_utxos(); + let weighted_utxos: Vec = create_weighted_utxos(); - let result = select_coins_srd(target, fee_rate, &mut weighted_utxos, &mut get_rng()) - .expect("unexpected error"); + let result: Vec<_> = select_coins_srd(target, fee_rate, &weighted_utxos, &mut get_rng()) + .expect("unexpected error") + .collect(); + let expected_second_element = Amount::from_str("1 cBTC").unwrap(); + let expected_first_element = Amount::from_str("2 cBTC").unwrap(); - assert_eq!(weighted_utxos.clone(), result); + assert_eq!(result.len(), 2); + assert_eq!(result[0].utxo.value, expected_first_element); + assert_eq!(result[1].utxo.value, expected_second_element); } #[test] fn select_coins_srd_addition_overflow() { let target: Amount = Amount::from_str("2 cBTC").unwrap(); - let mut weighted_utxos: Vec = vec![WeightedUtxo { + let weighted_utxos: Vec = vec![WeightedUtxo { satisfaction_weight: Weight::MAX, utxo: TxOut { value: Amount::from_str("1 cBTC").unwrap(), @@ -215,7 +234,7 @@ mod tests { }, }]; - let result = select_coins_srd(target, FEE_RATE, &mut weighted_utxos, &mut get_rng()); + let result = select_coins_srd(target, FEE_RATE, &weighted_utxos, &mut get_rng()); assert!(result.is_none()); } } From 1204ec53b309a55dfc6ab74df82235e4b4f0b0af Mon Sep 17 00:00:00 2001 From: yancy Date: Thu, 8 Feb 2024 11:00:30 +0100 Subject: [PATCH 5/6] Bump MSRV --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f038306..3d43e36 100644 --- a/README.md +++ b/README.md @@ -36,4 +36,4 @@ Note: The measurements where recorded using rustc 1.75. Expect worse performanc ## Minimum Supported Rust Version (MSRV) -This library should always compile with any combination of features on **Rust 1.48**. +This library should always compile with any combination of features on **Rust 1.56.1**. From f808f14fb24f7c7b1ce65499cfff51a3cb3113e1 Mon Sep 17 00:00:00 2001 From: yancy Date: Wed, 7 Feb 2024 12:40:47 +0100 Subject: [PATCH 6/6] Bump version --- CHANGELOG.md | 8 ++++++++ Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf1eedc..f19f41e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,3 +5,11 @@ # 0.2.0 - 2023-06-03 - Add Single Random Draw module and a basic error type. + +# 0.3.0 - 2024-02-07 + +- Move existing branch and bound to a new module. +- Re-implement branch and bound optimizing for waste score and performance. +- Change the return type of SRD to Iterator. +- Use Criterion instead of Cargo Bench for benchmarking. +- Bump MSRV to 1.56.1 diff --git a/Cargo.toml b/Cargo.toml index ff63da2..b9c30cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ homepage = "https://github.com/rust-bitcoin/rust-bitcoin-coin-selection/" license = "CC0-1.0" name = "rust-bitcoin-coin-selection" repository = "https://github.com/rust-bitcoin/rust-bitcoin-coin-selection/" -version = "0.2.0" +version = "0.3.0" # documentation = "https://docs.rs/bitcoin-coin-selection/" description = "Libary providing utility functions to efficiently select a set of UTXOs." keywords = ["crypto", "bitcoin"]