rust_coinselect/algorithms/
leastchange.rs

1use std::vec;
2
3use crate::{
4    types::{CoinSelectionOpt, OutputGroup, SelectionError, SelectionOutput, WasteMetric},
5    utils::{calculate_fee, calculate_waste, effective_value},
6};
7
8/// A Branch and Bound state for Least Change selection which stores the state while traversing the tree.
9struct BnBState {
10    index: usize,
11    current_eff_value: u64,
12    current_selection: Vec<usize>,
13    current_count: usize,
14    current_weight: u64,
15}
16
17/// Selects inputs using BnB to first minimize change and then the input count.
18pub fn select_coin_bnb_leastchange(
19    inputs: &[OutputGroup],
20    options: &CoinSelectionOpt,
21) -> Result<SelectionOutput, SelectionError> {
22    let mut best: Option<(Vec<usize>, u64, usize)> = None; // (selection, change, count)
23    let base_fees = calculate_fee(options.base_weight, options.target_feerate).unwrap_or_default();
24    let target =
25        options.target_value + options.min_change_value + base_fees.max(options.min_absolute_fee);
26
27    // Precompute net values and filter beneficial inputs
28    let mut filtered = inputs
29        .iter()
30        .enumerate()
31        .filter_map(
32            |(i, inp)| match effective_value(inp, options.target_feerate) {
33                Ok(net_value) if net_value > 0 => Some((i, inp.value, inp.weight)),
34                _ => None,
35            },
36        )
37        .collect::<Vec<_>>();
38
39    // Sort by net value descending
40    filtered.sort_by(|(_, a, _), (_, b, _)| b.cmp(a));
41
42    // Precompute remaining net values for pruning
43    let n = filtered.len();
44    let mut remaining_net = vec![0; n + 1];
45    for i in (0..n).rev() {
46        remaining_net[i] = remaining_net[i + 1] + filtered[i].1;
47    }
48
49    // DFS with BnB pruning
50    let mut stack = vec![BnBState {
51        index: 0,
52        current_eff_value: 0,
53        current_selection: Vec::new(),
54        current_count: 0,
55        current_weight: 0,
56    }];
57
58    while let Some(state) = stack.pop() {
59        if state.index >= n {
60            continue;
61        }
62
63        // Prune if impossible to reach target
64        if state.current_eff_value + remaining_net[state.index] < target {
65            continue;
66        }
67
68        stack.push(BnBState {
69            index: state.index + 1,
70            current_eff_value: state.current_eff_value,
71            current_selection: state.current_selection.clone(),
72            current_count: state.current_count,
73            current_weight: state.current_weight,
74        });
75
76        let (orig_idx, net_value, weight) = filtered[state.index];
77        let new_eff_value = state.current_eff_value + net_value;
78        let mut new_selection = state.current_selection.clone();
79        new_selection.push(orig_idx);
80        let new_count = state.current_count + 1;
81        let new_weight = state.current_weight + weight;
82
83        // Calculate fees based on current selection
84        let estimated_fees = calculate_fee(new_weight, options.target_feerate).unwrap_or(0);
85        let required_value = target + estimated_fees;
86        if new_eff_value >= required_value {
87            let change = new_eff_value - required_value;
88            let update = match best {
89                None => true,
90                Some((_, best_change, best_count)) => {
91                    change < best_change || (change == best_change && new_count < best_count)
92                }
93            };
94            if update {
95                best = Some((new_selection, change, new_count));
96            }
97        } else {
98            stack.push(BnBState {
99                index: state.index + 1,
100                current_eff_value: new_eff_value,
101                current_selection: new_selection,
102                current_count: new_count,
103                current_weight: new_weight,
104            });
105        }
106    }
107
108    if let Some((selected_inputs, _change, _count)) = best {
109        let total_value: u64 = selected_inputs.iter().map(|&i| inputs[i].value).sum();
110        let total_weight: u64 = selected_inputs.iter().map(|&i| inputs[i].weight).sum();
111        let estimated_fees = calculate_fee(total_weight, options.target_feerate).unwrap_or(0);
112        let waste = calculate_waste(options, total_value, total_weight, estimated_fees);
113
114        Ok(SelectionOutput {
115            selected_inputs,
116            waste: WasteMetric(waste),
117        })
118    } else {
119        Err(SelectionError::InsufficientFunds)
120    }
121}
122
123#[cfg(test)]
124mod test {
125
126    use crate::{
127        algorithms::leastchange::select_coin_bnb_leastchange,
128        types::{CoinSelectionOpt, ExcessStrategy, OutputGroup, SelectionError},
129    };
130
131    fn setup_leastchange_output_groups() -> Vec<OutputGroup> {
132        vec![
133            OutputGroup {
134                value: 100,
135                weight: 100,
136                input_count: 1,
137                creation_sequence: None,
138            },
139            OutputGroup {
140                value: 1500,
141                weight: 200,
142                input_count: 1,
143                creation_sequence: None,
144            },
145            OutputGroup {
146                value: 3400,
147                weight: 300,
148                input_count: 1,
149                creation_sequence: None,
150            },
151            OutputGroup {
152                value: 2200,
153                weight: 150,
154                input_count: 1,
155                creation_sequence: None,
156            },
157            OutputGroup {
158                value: 1190,
159                weight: 200,
160                input_count: 1,
161                creation_sequence: None,
162            },
163            OutputGroup {
164                value: 1200,
165                weight: 200,
166                input_count: 1,
167                creation_sequence: None,
168            },
169            OutputGroup {
170                value: 3300,
171                weight: 100,
172                input_count: 1,
173                creation_sequence: None,
174            },
175            OutputGroup {
176                value: 1000,
177                weight: 190,
178                input_count: 1,
179                creation_sequence: None,
180            },
181            OutputGroup {
182                value: 2000,
183                weight: 210,
184                input_count: 1,
185                creation_sequence: None,
186            },
187            OutputGroup {
188                value: 3000,
189                weight: 300,
190                input_count: 1,
191                creation_sequence: None,
192            },
193            OutputGroup {
194                value: 2250,
195                weight: 250,
196                input_count: 1,
197                creation_sequence: None,
198            },
199            OutputGroup {
200                value: 190,
201                weight: 220,
202                input_count: 1,
203                creation_sequence: None,
204            },
205            OutputGroup {
206                value: 1750,
207                weight: 170,
208                input_count: 1,
209                creation_sequence: None,
210            },
211        ]
212    }
213
214    fn setup_options(target_value: u64) -> CoinSelectionOpt {
215        CoinSelectionOpt {
216            target_value,
217            target_feerate: 5.0, // Simplified feerate
218            long_term_feerate: Some(1.0),
219            min_absolute_fee: 100,
220            base_weight: 10,
221            change_weight: 50,
222            change_cost: 10,
223            avg_input_weight: 20,
224            avg_output_weight: 10,
225            min_change_value: 100,
226            excess_strategy: ExcessStrategy::ToRecipient,
227        }
228    }
229
230    #[test]
231    fn test_leastchange_successful() {
232        let inputs = setup_leastchange_output_groups();
233        let options = setup_options(5432);
234        let result = select_coin_bnb_leastchange(&inputs, &options);
235        // assert!(result.is_ok());
236        let selection_output = result.unwrap();
237        assert!(!selection_output.selected_inputs.is_empty());
238        let mut selected = selection_output.selected_inputs.clone();
239        selected.sort();
240        assert_eq!(selected, vec![4, 5, 6, 8, 9]);
241    }
242
243    #[test]
244    fn test_leastchange_insufficient() {
245        let inputs = setup_leastchange_output_groups();
246        let options = setup_options(40000);
247        let result = select_coin_bnb_leastchange(&inputs, &options);
248        assert!(matches!(result, Err(SelectionError::InsufficientFunds)));
249    }
250}