Skip to main content

rust_coinselect/algorithms/
bnb.rs

1use 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
11/// Performs coin selection via the Branch and Bound algorithm.
12///
13/// Update from Bitcoin Core's BNB: Instead of explicitly backtracking and re-testing omission branches, the search tracks the next candidate to explore
14/// and *shifts* directly to it, and it uses a precomputed `lookahead` of the remaining effective
15/// value at each depth to prune dead branches early. Successive candidates with identical effective
16/// value to a just-omitted one are skipped, since they would only re-derive an already-seen set.
17///
18/// The search looks for a combination whose summed effective value lands in the window
19/// `[target, target + cost_of_change]`. Because it searches for a *changeless* solution (the
20/// leftover is small enough to drop to fees rather than create a change output), the target here is
21/// deliberately *not* padded by `min_change_value` : unlike the accumulative algorithms. Effective
22/// values are used throughout, so an input's spend fee is accounted for exactly once. Among all
23/// solutions in range, the one with the least waste is returned.
24///
25/// Returns [`SelectionError::InsufficientFunds`] when the inputs cannot reach the target at all, and
26/// [`SelectionError::NoSolutionFound`] when no in-range (changeless) combination exists.
27pub 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    // Sort by descending effective value (largest first exploration).
39    inputs.sort_by(|a, b| b.value.cmp(&a.value));
40
41    // `lookahead[i]` is the total effective value of all candidates *after* index `i` : i.e. the
42    // value still reachable from depth `i`. Used to cut branches that can no longer hit the target.
43    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    // At high feerates, including more inputs only increases waste, which enables an extra pruning
55    // branch. Mirrors Core's `is_feerate_high`.
56    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        // EXPLORE: add `next_utxo` to the current selection.
73        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        // EVALUATE: decide whether to keep exploring, SHIFT to the omission branch, or CUT.
89        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            // Even adding every remaining candidate cannot reach the target: CUT this subtree.
95            should_cut = true;
96        } else if current_amount > actual_target + cost_of_change {
97            // Overshot the window: no deeper selection helps, SHIFT to the next branch.
98            should_shift = true;
99        } else if is_feerate_high && current_waste > best_waste {
100            // Already wasteful and adding inputs only makes it worse: SHIFT.
101            should_shift = true;
102        } else if current_amount >= actual_target {
103            // In range: a valid changeless solution. Record it if it improves on the best.
104            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        // Otherwise: keep exploring deeper (the loop adds `next_utxo` next iteration).
113
114        // A CUT is a SHIFT preceded by also dropping the last candidate (it leads nowhere).
115        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            // No selected candidate left to omit: the whole search space is exhausted.
128            if current_selection.is_empty() {
129                is_done = true;
130                break;
131            }
132            // Move to the omission branch: explore the candidate after the last selected one, and
133            // drop that last selected candidate from the running totals.
134            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            // Skip candidates identical in effective value to the just-omitted one (clones): trying
145            // them would only re-derive a set whose waste we have already considered. If we run off
146            // the end of the inputs, there is no fresh branch here, so SHIFT again (backtrack further).
147            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    // Recompute the reported waste from the concrete selection using the shared waste function so
172    // the metric is comparable with the other algorithms.
173    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
188/// Removes the most recently selected candidate, undoing its contribution to the running totals.
189fn 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    /// Inputs whose effective values (weight 0 => fee 0 at feerate 1.0) are exactly their values,
219    /// so subset sums are easy to reason about: 80000, 40000, 20000, 10000, 5000.
220    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        // Target 65000 is uniquely hit by 40000 + 20000 + 5000 (indices 1, 2, 4) among these coins.
244        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        // All coins are multiples of 5000, so no subset lands in [63000, 63020]. There are
256        // sufficient funds overall, so this is NoSolutionFound rather than InsufficientFunds.
257        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        // 80000 alone exactly matches the target; BnB should pick it rather than a larger combo.
282        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    /// Skipping clones must not cause an out-of-bounds or miss a solution. Several coins share the
289    /// same effective value, and the target is only reachable by combining some of them.
290    #[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); // 10000 * 3
297        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    /// Brute-force cross-check: for many small input sets, BnB must return a selection whose summed
308    /// effective value is inside `[target, target + cost_of_change]` with the minimum BnB-internal
309    /// waste over *all* such subsets, and must report NoSolutionFound exactly when none exist.
310    #[test]
311    fn test_bnb_matches_brute_force() {
312        // Effective value == value here (weight 0 => zero fee), and fee - long_term_fee == 0, so the
313        // BnB-internal waste of any in-window subset is just its excess over the target.
314        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; // cost_of_change window width
330                options.min_change_value = 0;
331
332                // Brute force: minimum excess over all subsets summing into the window.
333                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}