rust_coinselect/algorithms/
bnb.rs

1use crate::{
2    types::{CoinSelectionOpt, OutputGroup, SelectionError, SelectionOutput, WasteMetric},
3    utils::{calculate_fee, calculate_waste},
4};
5
6/// Struct MatchParameters encapsulates target_for_match, match_range, and target_feerate, options, tries, best solution.
7#[derive(Debug)]
8struct BnbContext {
9    target_for_match: u64,
10    match_range: u64,
11    options: CoinSelectionOpt,
12    tries: u32,
13    best_solution: Option<(Vec<usize>, f32)>,
14    // Used as a solution to Clippy's `Too Many Arguments` Warn.
15    // https://rust-lang.github.io/rust-clippy/master/#too_many_arguments
16}
17
18/// Perform Coinselection via Branch And Bound algorithm, only returns a solution if least waste within target's `match_range` is found.
19pub fn select_coin_bnb(
20    inputs: &[OutputGroup],
21    options: &CoinSelectionOpt,
22) -> Result<SelectionOutput, SelectionError> {
23    let cost_per_input = calculate_fee(options.avg_input_weight, options.target_feerate)?;
24    let cost_per_output = calculate_fee(options.avg_output_weight, options.target_feerate)?;
25    let base_fee = calculate_fee(options.base_weight, options.target_feerate)?;
26
27    let mut sorted_inputs: Vec<(usize, &OutputGroup)> = inputs.iter().enumerate().collect();
28    sorted_inputs.sort_by_key(|(_, input)| input.value);
29
30    let mut ctx = BnbContext {
31        target_for_match: options.target_value
32            + options.min_change_value
33            + base_fee.max(options.min_absolute_fee),
34        match_range: cost_per_input + cost_per_output,
35        options: options.clone(),
36        tries: 1_000_000,
37        best_solution: None,
38    };
39
40    let mut selected_inputs = vec![];
41
42    bnb(&sorted_inputs, &mut selected_inputs, 0, 0, 0, &mut ctx);
43
44    match ctx.best_solution {
45        Some((selected, waste)) => Ok(SelectionOutput {
46            selected_inputs: selected,
47            waste: WasteMetric(waste),
48        }),
49        None => Err(SelectionError::NoSolutionFound),
50    }
51}
52
53fn bnb(
54    sorted: &[(usize, &OutputGroup)],
55    selected: &mut Vec<usize>,
56    acc_value: u64,
57    acc_weight: u64,
58    depth: usize,
59    ctx: &mut BnbContext,
60) {
61    if ctx.tries == 0 || depth >= sorted.len() {
62        return;
63    }
64    ctx.tries -= 1;
65
66    // Calculate current fee based on accumulated weight
67    let fee = calculate_fee(acc_weight, ctx.options.target_feerate)
68        .unwrap_or(ctx.options.min_absolute_fee);
69    // .max(ctx.options.min_absolute_fee);
70
71    // Calculate effective value after fees
72    let effective_value = acc_value.saturating_sub(fee);
73
74    // Prune if we're way over target (including change consideration)
75    if effective_value > ctx.target_for_match + ctx.match_range {
76        return;
77    }
78
79    // Check for valid solution (must cover target + min change)
80    if effective_value >= ctx.target_for_match {
81        let waste = calculate_waste(&ctx.options, acc_value, acc_weight, fee);
82        if ctx.best_solution.is_none() || waste < ctx.best_solution.as_ref().unwrap().1 {
83            ctx.best_solution = Some((selected.clone(), waste));
84        }
85        return;
86    }
87
88    let (index, input) = sorted[depth];
89    let input_effective_value = input.value.saturating_sub(
90        calculate_fee(input.weight, ctx.options.target_feerate)
91            .unwrap_or(ctx.options.min_absolute_fee),
92    );
93
94    // Branch 1: Include current input
95    selected.push(index);
96    bnb(
97        sorted,
98        selected,
99        acc_value + input_effective_value,
100        acc_weight + input.weight,
101        depth + 1,
102        ctx,
103    );
104    selected.pop();
105
106    // Branch 2: Exclude current input
107    bnb(sorted, selected, acc_value, acc_weight, depth + 1, ctx);
108}
109
110#[cfg(test)]
111mod test {
112    use crate::{
113        algorithms::bnb::select_coin_bnb,
114        types::{CoinSelectionOpt, ExcessStrategy, OutputGroup, SelectionError},
115    };
116
117    fn setup_basic_output_groups() -> Vec<OutputGroup> {
118        vec![
119            OutputGroup {
120                value: 1000,
121                weight: 100,
122                input_count: 1,
123                creation_sequence: None,
124            },
125            OutputGroup {
126                value: 2000,
127                weight: 200,
128                input_count: 1,
129                creation_sequence: None,
130            },
131            OutputGroup {
132                value: 3000,
133                weight: 300,
134                input_count: 1,
135                creation_sequence: None,
136            },
137        ]
138    }
139
140    fn bnb_setup_options(target_value: u64) -> CoinSelectionOpt {
141        CoinSelectionOpt {
142            target_value,
143            target_feerate: 0.5, // Simplified feerate
144            long_term_feerate: None,
145            min_absolute_fee: 500,
146            base_weight: 10,
147            change_weight: 50,
148            change_cost: 10,
149            avg_input_weight: 40,
150            avg_output_weight: 20,
151            min_change_value: 500,
152            excess_strategy: ExcessStrategy::ToChange,
153        }
154    }
155
156    fn test_bnb_solution() {
157        // Define the test values
158        let mut values = [
159            OutputGroup {
160                value: 55000,
161                weight: 500,
162                input_count: 1,
163                creation_sequence: None,
164            },
165            OutputGroup {
166                value: 400,
167                weight: 200,
168                input_count: 1,
169                creation_sequence: None,
170            },
171            OutputGroup {
172                value: 40000,
173                weight: 300,
174                input_count: 1,
175                creation_sequence: None,
176            },
177            OutputGroup {
178                value: 25000,
179                weight: 100,
180                input_count: 1,
181                creation_sequence: None,
182            },
183            OutputGroup {
184                value: 35000,
185                weight: 150,
186                input_count: 1,
187                creation_sequence: None,
188            },
189            OutputGroup {
190                value: 600,
191                weight: 250,
192                input_count: 1,
193                creation_sequence: None,
194            },
195            OutputGroup {
196                value: 30000,
197                weight: 120,
198                input_count: 1,
199                creation_sequence: None,
200            },
201            OutputGroup {
202                value: 94730,
203                weight: 50,
204                input_count: 1,
205                creation_sequence: None,
206            },
207            OutputGroup {
208                value: 29810,
209                weight: 500,
210                input_count: 1,
211                creation_sequence: None,
212            },
213            OutputGroup {
214                value: 78376,
215                weight: 200,
216                input_count: 1,
217                creation_sequence: None,
218            },
219            OutputGroup {
220                value: 17218,
221                weight: 300,
222                input_count: 1,
223                creation_sequence: None,
224            },
225            OutputGroup {
226                value: 13728,
227                weight: 100,
228                input_count: 1,
229                creation_sequence: None,
230            },
231        ];
232
233        // Adjust the target value to ensure it tests for multiple valid solutions
234        let opt = bnb_setup_options(195782);
235        let ans = select_coin_bnb(&values, &opt);
236        values.sort_by_key(|v| v.value);
237        if let Ok(selection_output) = ans {
238            let expected_solution = vec![1, 5, 11, 6, 4, 2, 9];
239            assert_eq!(
240                selection_output.selected_inputs, expected_solution,
241                "Expected solution {:?}, but got {:?}",
242                expected_solution, selection_output.selected_inputs
243            );
244        } else {
245            panic!("Failed to find a solution");
246        }
247    }
248
249    fn test_bnb_no_solution() {
250        let inputs = setup_basic_output_groups();
251        let total_input_value: u64 = inputs.iter().map(|input| input.value).sum();
252        let impossible_target = total_input_value + 1000;
253        let options = bnb_setup_options(impossible_target);
254        let result = select_coin_bnb(&inputs, &options);
255        assert!(
256            matches!(result, Err(SelectionError::NoSolutionFound)),
257            "Expected NoSolutionFound error, got {:?}",
258            result
259        );
260    }
261
262    #[test]
263    fn test_bnb() {
264        test_bnb_solution();
265        test_bnb_no_solution();
266    }
267}