use crate::{
types::{
CoinSelectionOpt, OutputGroup, SelectionError, SelectionOutput, WasteMetric, TOTAL_TRIES,
},
utils::{
calculate_fee, calculate_fee_and_waste, insufficient_funds, prepare_output_groups,
PreparedOutputGroup,
},
};
pub fn select_coin_bnb(
inputs: &[OutputGroup],
options: &CoinSelectionOpt,
) -> Result<SelectionOutput, SelectionError> {
let insufficient_funds_error = insufficient_funds(inputs, options);
let mut inputs = prepare_output_groups(inputs, options)?;
let base_fee =
calculate_fee(options.base_weight, options.target_feerate).max(options.min_absolute_fee);
let actual_target = options.target_value + base_fee;
let cost_of_change = options.change_cost;
inputs.sort_by(|a, b| b.value.cmp(&a.value));
let mut lookahead = vec![0u64; inputs.len()];
let mut total_available: u64 = 0;
for index in (0..inputs.len()).rev() {
lookahead[index] = total_available;
total_available += inputs[index].value;
}
if total_available < actual_target {
return Err(insufficient_funds_error);
}
let is_feerate_high = options
.long_term_feerate
.is_some_and(|long_term_feerate| options.target_feerate > long_term_feerate);
let mut current_selection: Vec<usize> = Vec::with_capacity(inputs.len());
let mut current_amount: u64 = 0;
let mut current_waste: i64 = 0;
let mut best_selection: Option<Vec<usize>> = None;
let mut best_waste: i64 = i64::MAX;
let mut next_utxo: usize = 0;
let mut tries = TOTAL_TRIES;
let mut is_done = false;
while !is_done {
let candidate = &inputs[next_utxo];
current_amount += candidate.value;
current_waste += calculate_fee(candidate.weight, options.target_feerate) as i64
- calculate_fee(
candidate.weight,
options.long_term_feerate.unwrap_or(options.target_feerate),
) as i64;
current_selection.push(next_utxo);
next_utxo += 1;
tries -= 1;
if tries == 0 {
break;
}
let last = *current_selection.last().unwrap();
let mut should_shift = false;
let mut should_cut = false;
if current_amount + lookahead[last] < actual_target {
should_cut = true;
} else if current_amount > actual_target + cost_of_change {
should_shift = true;
} else if is_feerate_high && current_waste > best_waste {
should_shift = true;
} else if current_amount >= actual_target {
should_shift = true;
let excess = current_amount - actual_target;
let waste = current_waste + excess as i64;
if waste <= best_waste {
best_waste = waste;
best_selection = Some(current_selection.clone());
}
}
if should_cut {
deselect_last(
&inputs,
options,
&mut current_selection,
&mut current_amount,
&mut current_waste,
)?;
should_shift = true;
}
while should_shift {
if current_selection.is_empty() {
is_done = true;
break;
}
next_utxo = current_selection.last().unwrap() + 1;
deselect_last(
&inputs,
options,
&mut current_selection,
&mut current_amount,
&mut current_waste,
)?;
should_shift = false;
loop {
if next_utxo >= inputs.len() {
should_shift = true;
break;
}
if inputs[next_utxo - 1].value == inputs[next_utxo].value {
next_utxo += 1;
continue;
}
break;
}
}
}
let selected_pool_indices = match best_selection {
Some(s) => s,
None => return Err(SelectionError::NoSolutionFound),
};
let selected_inputs: Vec<usize> = selected_pool_indices
.iter()
.map(|&i| inputs[i].index)
.collect();
let accumulated_value: u64 = selected_pool_indices.iter().map(|&i| inputs[i].value).sum();
let accumulated_weight: u64 = selected_pool_indices
.iter()
.map(|&i| inputs[i].weight)
.sum();
let (fee, waste) = calculate_fee_and_waste(options, accumulated_value, accumulated_weight)?;
let fee_bnb = fee.saturating_sub(calculate_fee(options.change_weight, options.target_feerate));
Ok(SelectionOutput {
selected_inputs,
waste: WasteMetric(waste),
fee: fee_bnb,
})
}
fn deselect_last(
inputs: &[PreparedOutputGroup],
options: &CoinSelectionOpt,
current_selection: &mut Vec<usize>,
current_amount: &mut u64,
current_waste: &mut i64,
) -> Result<(), SelectionError> {
let last = current_selection
.pop()
.expect("deselect_last on empty selection");
let candidate = &inputs[last];
*current_amount -= candidate.value;
*current_waste -= calculate_fee(candidate.weight, options.target_feerate) as i64
- calculate_fee(
candidate.weight,
options.long_term_feerate.unwrap_or(options.target_feerate),
) as i64;
Ok(())
}
#[cfg(test)]
mod test {
use crate::{
algorithms::bnb::select_coin_bnb,
types::{
basic_output_group, CoinSelectionOpt, ExcessStrategy, OutputGroup, SelectionError,
},
};
fn setup_output_groups() -> Vec<OutputGroup> {
[80_000u64, 40_000, 20_000, 10_000, 5_000]
.into_iter()
.map(|value| basic_output_group(value, 0))
.collect()
}
fn setup_options(target_value: u64) -> CoinSelectionOpt {
CoinSelectionOpt {
target_value,
target_feerate: 1.0,
long_term_feerate: Some(1.0),
min_absolute_fee: 0,
base_weight: 0,
change_weight: 50,
change_cost: 20,
min_change_value: 500,
excess_strategy: ExcessStrategy::ToChange,
}
}
#[test]
fn test_bnb_finds_exact_changeless_solution() {
let inputs = setup_output_groups();
let options = setup_options(65_000);
let result = select_coin_bnb(&inputs, &options).expect("a solution should exist");
let mut selected = result.selected_inputs;
selected.sort();
assert_eq!(selected, vec![1, 2, 4]);
}
#[test]
fn test_bnb_no_changeless_solution() {
let inputs = setup_output_groups();
let options = setup_options(63_000);
let result = select_coin_bnb(&inputs, &options);
assert!(
matches!(result, Err(SelectionError::NoSolutionFound)),
"expected NoSolutionFound, got {:?}",
result
);
}
#[test]
fn test_bnb_insufficient_funds() {
let inputs = setup_output_groups();
let total: u64 = inputs.iter().map(|i| i.value).sum();
let options = setup_options(total + 1_000);
let result = select_coin_bnb(&inputs, &options);
assert!(matches!(
result,
Err(SelectionError::InsufficientFunds { .. })
));
}
#[test]
fn test_bnb_prefers_single_exact_input() {
let inputs = setup_output_groups();
let options = setup_options(80_000);
let result = select_coin_bnb(&inputs, &options).expect("a solution should exist");
assert_eq!(result.selected_inputs, vec![0]);
}
#[test]
fn test_bnb_handles_clones() {
let inputs: Vec<OutputGroup> = [10_000u64, 10_000, 10_000, 10_000, 7_000]
.into_iter()
.map(|value| basic_output_group(value, 0))
.collect();
let options = setup_options(30_000); let result = select_coin_bnb(&inputs, &options).expect("a solution should exist");
assert_eq!(result.selected_inputs.len(), 3);
let value: u64 = result
.selected_inputs
.iter()
.map(|&i| inputs[i].value)
.sum();
assert_eq!(value, 30_000);
}
#[test]
fn test_bnb_matches_brute_force() {
let value_sets: [&[u64]; 4] = [
&[100, 200, 300, 400, 500, 600],
&[1, 2, 4, 8, 16, 32, 64],
&[55, 55, 55, 70, 13, 200, 9],
&[1000, 999, 998, 5, 7, 3, 2],
];
for values in value_sets {
let inputs: Vec<OutputGroup> = values
.iter()
.map(|&value| basic_output_group(value, 0))
.collect();
for target in 1u64..=120 {
let mut options = setup_options(target);
options.change_cost = 5; options.min_change_value = 0;
let n = inputs.len();
let mut brute_best: Option<u64> = None;
for mask in 1u64..(1u64 << n) {
let sum: u64 = (0..n)
.filter(|&i| mask & (1 << i) != 0)
.map(|i| inputs[i].value)
.sum();
if sum >= target && sum <= target + options.change_cost {
let excess = sum - target;
brute_best = Some(brute_best.map_or(excess, |b| b.min(excess)));
}
}
match select_coin_bnb(&inputs, &options) {
Ok(result) => {
let sum: u64 = result
.selected_inputs
.iter()
.map(|&i| inputs[i].value)
.sum();
assert!(
sum >= target && sum <= target + options.change_cost,
"target {target}: selection sum {sum} out of window"
);
let excess = sum - target;
assert_eq!(
Some(excess),
brute_best,
"target {target}, values {values:?}: BnB excess {excess} != brute force {brute_best:?}"
);
}
Err(SelectionError::NoSolutionFound) => {
assert!(
brute_best.is_none(),
"target {target}, values {values:?}: BnB found nothing but brute force did"
);
}
Err(SelectionError::InsufficientFunds { .. }) => {
let total: u64 = values.iter().sum();
assert!(
total < target,
"target {target}: spurious InsufficientFunds"
);
}
Err(e) => panic!("unexpected error {e:?}"),
}
}
}
}
}