rust_coinselect/algorithms/
bnb.rs1use crate::{
2 types::{
3 CoinSelectionOpt, OutputGroup, SelectionError, SelectionOutput, WasteMetric, TOTAL_TRIES,
4 },
5 utils::{
6 calculate_fee, calculate_fee_and_waste, insufficient_funds, prepare_output_groups,
7 PreparedOutputGroup,
8 },
9};
10
11pub fn select_coin_bnb(
28 inputs: &[OutputGroup],
29 options: &CoinSelectionOpt,
30) -> Result<SelectionOutput, SelectionError> {
31 let insufficient_funds_error = insufficient_funds(inputs, options);
32 let mut inputs = prepare_output_groups(inputs, options)?;
33 let base_fee =
34 calculate_fee(options.base_weight, options.target_feerate).max(options.min_absolute_fee);
35 let actual_target = options.target_value + base_fee;
36 let cost_of_change = options.change_cost;
37
38 inputs.sort_by(|a, b| b.value.cmp(&a.value));
40
41 let mut lookahead = vec![0u64; inputs.len()];
44 let mut total_available: u64 = 0;
45 for index in (0..inputs.len()).rev() {
46 lookahead[index] = total_available;
47 total_available += inputs[index].value;
48 }
49
50 if total_available < actual_target {
51 return Err(insufficient_funds_error);
52 }
53
54 let is_feerate_high = options
57 .long_term_feerate
58 .is_some_and(|long_term_feerate| options.target_feerate > long_term_feerate);
59
60 let mut current_selection: Vec<usize> = Vec::with_capacity(inputs.len());
61 let mut current_amount: u64 = 0;
62 let mut current_waste: i64 = 0;
63
64 let mut best_selection: Option<Vec<usize>> = None;
65 let mut best_waste: i64 = i64::MAX;
66
67 let mut next_utxo: usize = 0;
68 let mut tries = TOTAL_TRIES;
69 let mut is_done = false;
70
71 while !is_done {
72 let candidate = &inputs[next_utxo];
74 current_amount += candidate.value;
75 current_waste += calculate_fee(candidate.weight, options.target_feerate) as i64
76 - calculate_fee(
77 candidate.weight,
78 options.long_term_feerate.unwrap_or(options.target_feerate),
79 ) as i64;
80 current_selection.push(next_utxo);
81 next_utxo += 1;
82
83 tries -= 1;
84 if tries == 0 {
85 break;
86 }
87
88 let last = *current_selection.last().unwrap();
90 let mut should_shift = false;
91 let mut should_cut = false;
92
93 if current_amount + lookahead[last] < actual_target {
94 should_cut = true;
96 } else if current_amount > actual_target + cost_of_change {
97 should_shift = true;
99 } else if is_feerate_high && current_waste > best_waste {
100 should_shift = true;
102 } else if current_amount >= actual_target {
103 should_shift = true;
105 let excess = current_amount - actual_target;
106 let waste = current_waste + excess as i64;
107 if waste <= best_waste {
108 best_waste = waste;
109 best_selection = Some(current_selection.clone());
110 }
111 }
112 if should_cut {
116 deselect_last(
117 &inputs,
118 options,
119 &mut current_selection,
120 &mut current_amount,
121 &mut current_waste,
122 )?;
123 should_shift = true;
124 }
125
126 while should_shift {
127 if current_selection.is_empty() {
129 is_done = true;
130 break;
131 }
132 next_utxo = current_selection.last().unwrap() + 1;
135 deselect_last(
136 &inputs,
137 options,
138 &mut current_selection,
139 &mut current_amount,
140 &mut current_waste,
141 )?;
142 should_shift = false;
143
144 loop {
148 if next_utxo >= inputs.len() {
149 should_shift = true;
150 break;
151 }
152 if inputs[next_utxo - 1].value == inputs[next_utxo].value {
153 next_utxo += 1;
154 continue;
155 }
156 break;
157 }
158 }
159 }
160
161 let selected_pool_indices = match best_selection {
162 Some(s) => s,
163 None => return Err(SelectionError::NoSolutionFound),
164 };
165
166 let selected_inputs: Vec<usize> = selected_pool_indices
167 .iter()
168 .map(|&i| inputs[i].index)
169 .collect();
170
171 let accumulated_value: u64 = selected_pool_indices.iter().map(|&i| inputs[i].value).sum();
174 let accumulated_weight: u64 = selected_pool_indices
175 .iter()
176 .map(|&i| inputs[i].weight)
177 .sum();
178 let (fee, waste) = calculate_fee_and_waste(options, accumulated_value, accumulated_weight)?;
179 let fee_bnb = fee.saturating_sub(calculate_fee(options.change_weight, options.target_feerate));
180
181 Ok(SelectionOutput {
182 selected_inputs,
183 waste: WasteMetric(waste),
184 fee: fee_bnb,
185 })
186}
187
188fn deselect_last(
190 inputs: &[PreparedOutputGroup],
191 options: &CoinSelectionOpt,
192 current_selection: &mut Vec<usize>,
193 current_amount: &mut u64,
194 current_waste: &mut i64,
195) -> Result<(), SelectionError> {
196 let last = current_selection
197 .pop()
198 .expect("deselect_last on empty selection");
199 let candidate = &inputs[last];
200 *current_amount -= candidate.value;
201 *current_waste -= calculate_fee(candidate.weight, options.target_feerate) as i64
202 - calculate_fee(
203 candidate.weight,
204 options.long_term_feerate.unwrap_or(options.target_feerate),
205 ) as i64;
206 Ok(())
207}
208
209#[cfg(test)]
210mod test {
211 use crate::{
212 algorithms::bnb::select_coin_bnb,
213 types::{
214 basic_output_group, CoinSelectionOpt, ExcessStrategy, OutputGroup, SelectionError,
215 },
216 };
217
218 fn setup_output_groups() -> Vec<OutputGroup> {
221 [80_000u64, 40_000, 20_000, 10_000, 5_000]
222 .into_iter()
223 .map(|value| basic_output_group(value, 0))
224 .collect()
225 }
226
227 fn setup_options(target_value: u64) -> CoinSelectionOpt {
228 CoinSelectionOpt {
229 target_value,
230 target_feerate: 1.0,
231 long_term_feerate: Some(1.0),
232 min_absolute_fee: 0,
233 base_weight: 0,
234 change_weight: 50,
235 change_cost: 20,
236 min_change_value: 500,
237 excess_strategy: ExcessStrategy::ToChange,
238 }
239 }
240
241 #[test]
242 fn test_bnb_finds_exact_changeless_solution() {
243 let inputs = setup_output_groups();
245 let options = setup_options(65_000);
246 let result = select_coin_bnb(&inputs, &options).expect("a solution should exist");
247
248 let mut selected = result.selected_inputs;
249 selected.sort();
250 assert_eq!(selected, vec![1, 2, 4]);
251 }
252
253 #[test]
254 fn test_bnb_no_changeless_solution() {
255 let inputs = setup_output_groups();
258 let options = setup_options(63_000);
259 let result = select_coin_bnb(&inputs, &options);
260 assert!(
261 matches!(result, Err(SelectionError::NoSolutionFound)),
262 "expected NoSolutionFound, got {:?}",
263 result
264 );
265 }
266
267 #[test]
268 fn test_bnb_insufficient_funds() {
269 let inputs = setup_output_groups();
270 let total: u64 = inputs.iter().map(|i| i.value).sum();
271 let options = setup_options(total + 1_000);
272 let result = select_coin_bnb(&inputs, &options);
273 assert!(matches!(
274 result,
275 Err(SelectionError::InsufficientFunds { .. })
276 ));
277 }
278
279 #[test]
280 fn test_bnb_prefers_single_exact_input() {
281 let inputs = setup_output_groups();
283 let options = setup_options(80_000);
284 let result = select_coin_bnb(&inputs, &options).expect("a solution should exist");
285 assert_eq!(result.selected_inputs, vec![0]);
286 }
287
288 #[test]
291 fn test_bnb_handles_clones() {
292 let inputs: Vec<OutputGroup> = [10_000u64, 10_000, 10_000, 10_000, 7_000]
293 .into_iter()
294 .map(|value| basic_output_group(value, 0))
295 .collect();
296 let options = setup_options(30_000); let result = select_coin_bnb(&inputs, &options).expect("a solution should exist");
298 assert_eq!(result.selected_inputs.len(), 3);
299 let value: u64 = result
300 .selected_inputs
301 .iter()
302 .map(|&i| inputs[i].value)
303 .sum();
304 assert_eq!(value, 30_000);
305 }
306
307 #[test]
311 fn test_bnb_matches_brute_force() {
312 let value_sets: [&[u64]; 4] = [
315 &[100, 200, 300, 400, 500, 600],
316 &[1, 2, 4, 8, 16, 32, 64],
317 &[55, 55, 55, 70, 13, 200, 9],
318 &[1000, 999, 998, 5, 7, 3, 2],
319 ];
320
321 for values in value_sets {
322 let inputs: Vec<OutputGroup> = values
323 .iter()
324 .map(|&value| basic_output_group(value, 0))
325 .collect();
326
327 for target in 1u64..=120 {
328 let mut options = setup_options(target);
329 options.change_cost = 5; options.min_change_value = 0;
331
332 let n = inputs.len();
334 let mut brute_best: Option<u64> = None;
335 for mask in 1u64..(1u64 << n) {
336 let sum: u64 = (0..n)
337 .filter(|&i| mask & (1 << i) != 0)
338 .map(|i| inputs[i].value)
339 .sum();
340 if sum >= target && sum <= target + options.change_cost {
341 let excess = sum - target;
342 brute_best = Some(brute_best.map_or(excess, |b| b.min(excess)));
343 }
344 }
345
346 match select_coin_bnb(&inputs, &options) {
347 Ok(result) => {
348 let sum: u64 = result
349 .selected_inputs
350 .iter()
351 .map(|&i| inputs[i].value)
352 .sum();
353 assert!(
354 sum >= target && sum <= target + options.change_cost,
355 "target {target}: selection sum {sum} out of window"
356 );
357 let excess = sum - target;
358 assert_eq!(
359 Some(excess),
360 brute_best,
361 "target {target}, values {values:?}: BnB excess {excess} != brute force {brute_best:?}"
362 );
363 }
364 Err(SelectionError::NoSolutionFound) => {
365 assert!(
366 brute_best.is_none(),
367 "target {target}, values {values:?}: BnB found nothing but brute force did"
368 );
369 }
370 Err(SelectionError::InsufficientFunds { .. }) => {
371 let total: u64 = values.iter().sum();
372 assert!(
373 total < target,
374 "target {target}: spurious InsufficientFunds"
375 );
376 }
377 Err(e) => panic!("unexpected error {e:?}"),
378 }
379 }
380 }
381 }
382}