use bitcoin::amount::CheckedSum;
use bitcoin::{Amount, FeeRate, SignedAmount};
use crate::{Return, WeightedUtxo};
pub fn select_coins_bnb<Utxo: WeightedUtxo>(
target: Amount,
cost_of_change: Amount,
fee_rate: FeeRate,
long_term_fee_rate: FeeRate,
weighted_utxos: &[Utxo],
) -> Return<'_, Utxo> {
const ITERATION_LIMIT: u32 = 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<usize> = vec![];
let mut best_selection: Vec<usize> = vec![];
let upper_bound = target.checked_add(cost_of_change)?;
let mut w_utxos: Vec<(Amount, SignedAmount, &Utxo)> = weighted_utxos
.iter()
.map(|wu| (wu.effective_value(fee_rate), wu.waste(fee_rate, long_term_fee_rate), wu))
.filter(|(eff_val, waste, _)| eff_val.is_some() && waste.is_some())
.map(|(eff_val, waste, wu)| (eff_val.unwrap(), waste.unwrap(), wu))
.filter(|(eff_val, _, _)| eff_val.is_positive())
.map(|(eff_val, waste, wu)| (eff_val.to_unsigned().unwrap(), waste, wu))
.collect();
w_utxos.sort_by(|a, b| {
b.0.cmp(&a.0).then(
a.2.waste(fee_rate, long_term_fee_rate).cmp(&b.2.waste(fee_rate, long_term_fee_rate)),
)
});
let mut available_value = w_utxos.clone().into_iter().map(|(ev, _, _)| ev).checked_sum()?;
if available_value < target || target == Amount::ZERO {
return None;
}
while iteration < ITERATION_LIMIT {
backtrack = false;
if available_value.unchecked_add(value) < target
|| value > upper_bound
|| current_waste > best_waste && fee_rate > long_term_fee_rate
{
backtrack = true;
}
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)?;
if current_waste <= best_waste {
best_selection = index_selection.clone();
best_waste = current_waste;
}
current_waste = current_waste.checked_sub(waste)?;
}
if backtrack {
if index_selection.is_empty() {
return index_to_utxo_list(iteration, best_selection, w_utxos);
}
loop {
index -= 1;
if index <= *index_selection.last().unwrap() {
break;
}
let (eff_value, _, _) = w_utxos[index];
available_value += eff_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();
}
else {
let (eff_value, utxo_waste, _) = w_utxos[index];
available_value = available_value.unchecked_sub(eff_value);
if index_selection.is_empty()
|| index - 1 == *index_selection.last().unwrap()
|| w_utxos[index].0 != w_utxos[index - 1].0
{
index_selection.push(index);
current_waste = current_waste.checked_add(utxo_waste)?;
value = value.unchecked_add(eff_value);
}
}
index += 1;
iteration += 1;
}
index_to_utxo_list(iteration, best_selection, w_utxos)
}
fn index_to_utxo_list<Utxo: WeightedUtxo>(
iterations: u32,
index_list: Vec<usize>,
wu: Vec<(Amount, SignedAmount, &Utxo)>,
) -> Return<'_, Utxo> {
let mut result: Vec<_> = Vec::new();
let list = index_list;
for i in list {
let wu = wu[i].2;
result.push(wu);
}
if result.is_empty() {
None
} else {
Some((iterations, result))
}
}
#[cfg(test)]
mod tests {
use core::str::FromStr;
use std::iter::{once, zip};
use arbitrary::{Arbitrary, Unstructured};
use arbtest::arbtest;
use bitcoin::transaction::effective_value;
use bitcoin::{Amount, Weight};
use super::*;
use crate::tests::{assert_proptest_bnb, assert_ref_eq, parse_fee_rate, Utxo, UtxoPool};
use crate::WeightedUtxo;
const TX_IN_BASE_WEIGHT: u64 = 160;
#[derive(Debug)]
pub struct TestBnB<'a> {
target: &'a str,
cost_of_change: &'a str,
fee_rate: &'a str,
lt_fee_rate: &'a str,
weighted_utxos: &'a [&'a str],
expected_utxos: Option<&'a [&'a str]>,
expected_iterations: u32,
}
impl TestBnB<'_> {
fn assert(&self) {
let target = Amount::from_str(self.target).unwrap();
let cost_of_change = Amount::from_str(self.cost_of_change).unwrap();
let fee_rate = parse_fee_rate(self.fee_rate);
let lt_fee_rate = parse_fee_rate(self.lt_fee_rate);
let pool: UtxoPool = UtxoPool::new(self.weighted_utxos, fee_rate);
let result =
select_coins_bnb(target, cost_of_change, fee_rate, lt_fee_rate, &pool.utxos);
if let Some((iterations, inputs)) = result {
assert_eq!(iterations, self.expected_iterations);
let expected: UtxoPool = UtxoPool::new(self.expected_utxos.unwrap(), fee_rate);
assert_ref_eq(inputs, expected.utxos);
} else {
assert!(self.expected_utxos.is_none());
assert_eq!(self.expected_iterations, 0);
}
}
}
fn assert_coin_select(target_str: &str, expected_iterations: u32, expected_utxos: &[&str]) {
TestBnB {
target: target_str,
cost_of_change: "0 sats",
fee_rate: "0",
lt_fee_rate: "0",
weighted_utxos: &["1 cBTC/68 vB", "2 cBTC/68 vB", "3 cBTC/68 vB", "4 cBTC/68 vB"],
expected_utxos: Some(expected_utxos),
expected_iterations,
}
.assert();
}
fn arb_amount_in_range(u: &mut Unstructured, r: std::ops::RangeInclusive<u64>) -> Amount {
let u = u.int_in_range::<u64>(r).unwrap();
Amount::from_sat(u)
}
fn arb_fee_rate_in_range(u: &mut Unstructured, r: std::ops::RangeInclusive<u64>) -> FeeRate {
let u = u.int_in_range::<u64>(r).unwrap();
FeeRate::from_sat_per_kwu(u)
}
fn calculate_max_fee_rate(amount: Amount, weight: Weight) -> Option<FeeRate> {
let weight = weight + Weight::from_wu(TX_IN_BASE_WEIGHT);
let mut result = None;
if let Some(fee_rate) = amount.div_by_weight_floor(weight) {
if fee_rate > FeeRate::ZERO {
result = Some(fee_rate)
}
};
result
}
#[test]
fn select_coins_bnb_one() {
assert_coin_select("1 cBTC", 8, &["1 cBTC/68 vB"]);
}
#[test]
fn select_coins_bnb_two() {
assert_coin_select("2 cBTC", 6, &["2 cBTC/68 vB"]);
}
#[test]
fn select_coins_bnb_three() {
assert_coin_select("3 cBTC", 8, &["2 cBTC/68 vB", "1 cBTC/68 vB"]);
}
#[test]
fn select_coins_bnb_four() {
assert_coin_select("4 cBTC", 8, &["3 cBTC/68 vB", "1 cBTC/68 vB"]);
}
#[test]
fn select_coins_bnb_five() {
assert_coin_select("5 cBTC", 12, &["3 cBTC/68 vB", "2 cBTC/68 vB"]);
}
#[test]
fn select_coins_bnb_six() {
assert_coin_select("6 cBTC", 12, &["3 cBTC/68 vB", "2 cBTC/68 vB", "1 cBTC/68 vB"]);
}
#[test]
fn select_coins_bnb_seven() {
assert_coin_select("7 cBTC", 8, &["4 cBTC/68 vB", "2 cBTC/68 vB", "1 cBTC/68 vB"]);
}
#[test]
fn select_coins_bnb_eight() {
assert_coin_select("8 cBTC", 8, &["4 cBTC/68 vB", "3 cBTC/68 vB", "1 cBTC/68 vB"]);
}
#[test]
fn select_coins_bnb_nine() {
assert_coin_select("9 cBTC", 6, &["4 cBTC/68 vB", "3 cBTC/68 vB", "2 cBTC/68 vB"]);
}
#[test]
fn select_coins_bnb_ten() {
assert_coin_select(
"10 cBTC",
8,
&["4 cBTC/68 vB", "3 cBTC/68 vB", "2 cBTC/68 vB", "1 cBTC/68 vB"],
);
}
#[test]
#[should_panic]
fn select_coins_bnb_eleven_invalid_target_should_panic() {
assert_coin_select("11 cBTC", 8, &["1 cBTC/68 vB"]);
}
#[test]
#[should_panic]
fn select_coins_bnb_params_invalid_target_should_panic() {
TestBnB {
target: "11 cBTC",
cost_of_change: "1 cBTC",
fee_rate: "0",
lt_fee_rate: "0",
weighted_utxos: &["1.5 cBTC/68 vB"],
expected_utxos: Some(&["1.5 cBTC/68 vB"]),
expected_iterations: 2,
}
.assert();
}
#[test]
fn select_coins_bnb_zero() {
TestBnB {
target: "0 sats",
cost_of_change: "0 sats",
fee_rate: "0",
lt_fee_rate: "0",
weighted_utxos: &["1 cBTC/68 vB"],
expected_utxos: None,
expected_iterations: 0,
}
.assert();
}
#[test]
fn select_coins_bnb_cost_of_change() {
let mut t = TestBnB {
target: "1 cBTC",
cost_of_change: "1 cBTC",
fee_rate: "0",
lt_fee_rate: "0",
weighted_utxos: &["1.5 cBTC/68 vB"],
expected_utxos: Some(&["1.5 cBTC/68 vB"]),
expected_iterations: 2,
};
t.assert();
t.cost_of_change = "0 sats";
t.expected_utxos = None;
t.expected_iterations = 0;
t.assert();
}
#[test]
fn select_coins_bnb_effective_value() {
TestBnB {
target: "1 cBTC",
cost_of_change: "0 sats",
fee_rate: "10 sat/kwu",
lt_fee_rate: "10 sat/kwu",
weighted_utxos: &["1 cBTC/68 vB"],
expected_utxos: None,
expected_iterations: 0,
}
.assert();
}
#[test]
fn select_coins_bnb_skip_effective_negative_effective_value() {
TestBnB {
target: "1 cBTC",
cost_of_change: "1 cBTC",
fee_rate: "10 sat/kwu",
lt_fee_rate: "10 sat/kwu",
weighted_utxos: &["1.5 cBTC/68 vB", "1 sat/68 vB"],
expected_utxos: Some(&["1.5 cBTC/68 vB"]),
expected_iterations: 2,
}
.assert();
}
#[test]
fn select_coins_bnb_target_greater_than_value() {
TestBnB {
target: "11 cBTC",
cost_of_change: "0 sats",
fee_rate: "0",
lt_fee_rate: "0",
weighted_utxos: &["1 cBTC/68 vB", "2 cBTC/68 vB", "3 cBTC/68 vB", "4 cBTC/68 vB"],
expected_utxos: None,
expected_iterations: 0,
}
.assert();
}
#[test]
fn select_coins_bnb_consume_more_inputs_when_cheap() {
TestBnB {
target: "6 sats",
cost_of_change: "0 sats",
fee_rate: "10 sat/kwu",
lt_fee_rate: "20 sat/kwu",
weighted_utxos: &[
"e(1 sats)/68 vB",
"e(2 sats)/68 vB",
"e(3 sats)/68 vB",
"e(4 sats)/68 vB",
],
expected_utxos: Some(&["e(3 sats)/68 vB", "e(2 sats)/68 vB", "e(1 sats)/68 vB"]),
expected_iterations: 12,
}
.assert();
}
#[test]
fn select_coins_bnb_consume_less_inputs_when_expensive() {
TestBnB {
target: "6 sats",
cost_of_change: "0 sats",
fee_rate: "20 sat/kwu",
lt_fee_rate: "10 sat/kwu",
weighted_utxos: &[
"e(1 sats)/68 vB",
"e(2 sats)/68 vB",
"e(3 sats)/68 vB",
"e(4 sats)/68 vB",
],
expected_utxos: Some(&["e(4 sats)/68 vB", "e(2 sats)/68 vB"]),
expected_iterations: 12,
}
.assert();
}
#[test]
fn select_coins_bnb_consume_less_inputs_with_excess_when_expensive() {
TestBnB {
target: "6 sats",
cost_of_change: "1 sats",
fee_rate: "20 sat/kwu",
lt_fee_rate: "10 sat/kwu",
weighted_utxos: &[
"e(1 sats)/68 vB",
"e(2 sats)/68 vB",
"e(3 sats)/68 vB",
"e(4 sats)/68 vB",
],
expected_utxos: Some(&["e(4 sats)/68 vB", "e(2 sats)/68 vB"]),
expected_iterations: 12,
}
.assert();
}
#[test]
fn select_coins_bnb_utxo_pool_sum_overflow() {
TestBnB {
target: "1 cBTC",
cost_of_change: "0 sats",
fee_rate: "0",
lt_fee_rate: "0",
weighted_utxos: &["18446744073709551615 sats/68 vB", "1 sats/68 vB"], expected_utxos: None,
expected_iterations: 0,
}
.assert();
}
#[test]
fn select_coins_bnb_upper_bound_overflow() {
TestBnB {
target: "1 sats",
cost_of_change: "18446744073709551615 sats", fee_rate: "0",
lt_fee_rate: "0",
weighted_utxos: &["1 sats/68 vB"],
expected_utxos: None,
expected_iterations: 0,
}
.assert();
}
#[test]
fn select_coins_bnb_utxo_greater_than_max_money() {
TestBnB {
target: "1 sats",
cost_of_change: "18141417255681066410 sats",
fee_rate: "1 sat/kwu",
lt_fee_rate: "0",
weighted_utxos: &["8740670712339394302 sats/68 vB"],
expected_utxos: None,
expected_iterations: 0,
}
.assert();
}
#[test]
fn select_coins_bnb_effective_value_tie_high_fee_rate() {
TestBnB {
target: "100 sats",
cost_of_change: "10 sats",
fee_rate: "20 sat/kwu",
lt_fee_rate: "10 sat/kwu",
weighted_utxos: &["e(50 sats)/230 wu", "e(50 sats)/272 wu", "e(50 sats)/230 wu"],
expected_utxos: Some(&["e(50 sats)/230 wu", "e(50 sats)/230 wu"]),
expected_iterations: 9,
}
.assert();
}
#[test]
fn select_coins_bnb_effective_value_tie_low_fee_rate() {
TestBnB {
target: "100 sats",
cost_of_change: "10 sats",
fee_rate: "10 sat/kwu",
lt_fee_rate: "20 sat/kwu",
weighted_utxos: &["e(50 sats)/272 wu", "e(50 sats)/230 wu", "e(50 sats)/272 wu"],
expected_utxos: Some(&["e(50 sats)/272 wu", "e(50 sats)/272 wu"]),
expected_iterations: 9,
}
.assert();
}
#[test]
fn select_coins_bnb_set_size_five() {
TestBnB {
target: "6 cBTC",
cost_of_change: "0 sats",
fee_rate: "0",
lt_fee_rate: "0",
weighted_utxos: &[
"3 cBTC/68 vB",
"2.9 cBTC/68 vB",
"2 cBTC/68 vB",
"1.0 cBTC/68 vB",
"1 cBTC/68 vB",
],
expected_utxos: Some(&["3 cBTC/68 vB", "2 cBTC/68 vB", "1 cBTC/68 vB"]),
expected_iterations: 22,
}
.assert();
}
#[test]
fn select_coins_bnb_set_size_seven() {
TestBnB {
target: "18 cBTC",
cost_of_change: "50 sats",
fee_rate: "0",
lt_fee_rate: "0",
weighted_utxos: &[
"10 cBTC/68 vB",
"7000005 sats/68 vB",
"6000005 sats/68 vB",
"6 cBTC/68 vB",
"3 cBTC/68 vB",
"2 cBTC/68 vB",
"1000005 cBTC/68 vB",
],
expected_utxos: Some(&["10 cBTC/68 vB", "6 cBTC/68 vB", "2 cBTC/68 vB"]),
expected_iterations: 44,
}
.assert();
}
#[test]
fn select_coins_bnb_early_bail_optimization() {
let mut utxos =
vec!["7 cBTC/68 vB", "7 cBTC/68 vB", "7 cBTC/68 vB", "7 cBTC/68 vB", "2 cBTC/68 vB"];
for _i in 0..50_000 {
utxos.push("5 cBTC/68 vB");
}
TestBnB {
target: "30 cBTC",
cost_of_change: "5000 sats",
fee_rate: "0",
lt_fee_rate: "0",
weighted_utxos: &utxos,
expected_utxos: Some(&[
"7 cBTC/68 vB",
"7 cBTC/68 vB",
"7 cBTC/68 vB",
"7 cBTC/68 vB",
"2 cBTC/68 vB",
]),
expected_iterations: 100_000,
}
.assert();
}
#[test]
fn select_coins_bnb_exhaust() {
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::<usize>() 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 amts: Vec<_> = zip(alpha, beta)
.flat_map(|tup| once(tup.0).chain(once(tup.1)))
.map(|a| Amount::from_sat(a as u64))
.collect();
let pool: Vec<_> = amts.into_iter().map(|a| Utxo::new(a, Weight::ZERO)).collect();
let list = select_coins_bnb(target, Amount::ONE_SAT, FeeRate::ZERO, FeeRate::ZERO, &pool);
assert!(list.is_none());
}
#[test]
fn select_coins_bnb_exhaust_v2() {
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 amts: Vec<_> = vals.map(Amount::from_sat).collect();
let pool: Vec<_> = amts.into_iter().map(|a| Utxo::new(a, Weight::ZERO)).collect();
let list = select_coins_bnb(
Amount::from_sat(target),
Amount::ONE_SAT,
FeeRate::ZERO,
FeeRate::ZERO,
&pool,
);
assert!(list.is_none());
}
#[test]
fn select_coins_bnb_exhaust_with_result() {
let base: usize = 2;
let mut target = 0;
let amts = (0..15).enumerate().flat_map(|(i, _)| {
let a = base.pow(15 + i as u32) as u64;
target += a;
vec![a, a + 2]
});
let mut amts: Vec<_> = amts.map(Amount::from_sat).collect();
amts.push(Amount::from_sat(target));
let pool: Vec<_> = amts.into_iter().map(|a| Utxo::new(a, Weight::ZERO)).collect();
let (iterations, utxos) = select_coins_bnb(
Amount::from_sat(target),
Amount::ONE_SAT,
FeeRate::ZERO,
FeeRate::ZERO,
&pool,
)
.unwrap();
assert_eq!(utxos.len(), 1);
assert_eq!(utxos[0].value(), Amount::from_sat(target));
assert_eq!(100000, iterations);
}
#[test]
fn select_one_of_one_idealized_proptest() {
let minimal_non_dust: u64 = 1;
let effective_value_max: u64 = SignedAmount::MAX.to_sat() as u64;
arbtest(|u| {
let amount = arb_amount_in_range(u, minimal_non_dust..=effective_value_max);
let utxo = Utxo::new(amount, Weight::ZERO);
let pool: Vec<Utxo> = vec![utxo.clone()];
let (_i, utxos) =
select_coins_bnb(utxo.value(), Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &pool)
.unwrap();
assert_ref_eq(utxos, pool.clone());
Ok(())
});
}
#[test]
fn select_one_of_many_proptest() {
arbtest(|u| {
let pool = UtxoPool::arbitrary(u)?;
let utxos = pool.utxos.clone();
let utxo = u.choose(&utxos)?;
let max_fee_rate = calculate_max_fee_rate(utxo.value(), utxo.satisfaction_weight());
if let Some(f) = max_fee_rate {
let fee_rate = arb_fee_rate_in_range(u, 1..=f.to_sat_per_kwu());
let target_effective_value =
effective_value(fee_rate, utxo.satisfaction_weight(), utxo.value()).unwrap();
if let Ok(target) = target_effective_value.to_unsigned() {
let result = select_coins_bnb(target, Amount::ZERO, fee_rate, fee_rate, &utxos);
if let Some((_i, utxos)) = result {
let sum: SignedAmount = utxos
.clone()
.into_iter()
.map(|u| {
effective_value(fee_rate, u.satisfaction_weight(), u.value())
.unwrap()
})
.sum();
let amount_sum = sum.to_unsigned().unwrap();
assert_eq!(amount_sum, target);
let weight_sum = utxos.iter().try_fold(Weight::ZERO, |acc, itm| {
acc.checked_add(itm.satisfaction_weight())
});
assert!(weight_sum.unwrap() <= utxo.satisfaction_weight());
} else {
let available_value = utxos.into_iter().map(|u| u.value()).checked_sum();
assert!(available_value.is_none());
}
}
}
Ok(())
});
}
#[test]
fn select_many_of_many_proptest() {
arbtest(|u| {
let pool = UtxoPool::arbitrary(u)?;
let utxos = pool.utxos.clone();
let mut gen = exhaustigen::Gen::new();
let mut subsets: Vec<Vec<&Utxo>> = Vec::new();
while !gen.done() {
let s = gen.gen_subset(&pool.utxos).collect::<Vec<_>>();
subsets.push(s);
}
let target_selection: &Vec<&Utxo> = u.choose(&subsets).unwrap();
let mut fee_rates: Vec<FeeRate> = target_selection
.iter()
.map(|u| {
calculate_max_fee_rate(u.value(), u.satisfaction_weight())
.unwrap_or(FeeRate::ZERO)
})
.collect();
fee_rates.sort();
let min_fee_rate = fee_rates.first().unwrap_or(&FeeRate::ZERO).to_sat_per_kwu();
let fee_rate = arb_fee_rate_in_range(u, 0..=min_fee_rate);
let effective_values: Vec<SignedAmount> = target_selection
.iter()
.map(|u| {
let e = effective_value(fee_rate, u.satisfaction_weight(), u.value());
e.unwrap_or(SignedAmount::ZERO)
})
.collect();
let eff_values_sum = effective_values.into_iter().checked_sum();
if let Some(s) = eff_values_sum {
if let Ok(target) = s.to_unsigned() {
let result = select_coins_bnb(target, Amount::ZERO, fee_rate, fee_rate, &utxos);
if let Some((_i, utxos)) = result {
let effective_value_sum: Amount = utxos
.clone()
.into_iter()
.map(|u| {
effective_value(fee_rate, u.satisfaction_weight(), u.value())
.unwrap()
.to_unsigned()
.unwrap()
})
.sum();
assert_eq!(effective_value_sum, target);
let result_sum = utxos.iter().try_fold(Weight::ZERO, |acc, item| {
acc.checked_add(item.satisfaction_weight())
});
let target_sum =
target_selection.iter().try_fold(Weight::ZERO, |acc, item| {
acc.checked_add(item.satisfaction_weight())
});
if let Some(s) = target_sum {
assert!(result_sum.unwrap() <= s);
}
} else {
let available_value = utxos.into_iter().map(|u| u.value()).checked_sum();
assert!(
available_value.is_none()
|| target_selection.is_empty()
|| target == Amount::ZERO
);
}
}
}
Ok(())
});
}
#[test]
fn select_bnb_proptest() {
arbtest(|u| {
let pool = UtxoPool::arbitrary(u)?;
let target = Amount::arbitrary(u)?;
let cost_of_change = Amount::arbitrary(u)?;
let fee_rate = FeeRate::arbitrary(u)?;
let lt_fee_rate = FeeRate::arbitrary(u)?;
let utxos = pool.utxos.clone();
let result = select_coins_bnb(target, cost_of_change, fee_rate, lt_fee_rate, &utxos);
assert_proptest_bnb(target, cost_of_change, fee_rate, pool, result);
Ok(())
});
}
}