rust_coinselect/algorithms/
coingrinder.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
11#[derive(Debug, Clone)]
12struct BestSelection {
13 selected: Vec<usize>,
14 value: u64,
15 weight: u64,
16 input_count: usize,
17}
18
19impl BestSelection {
20 fn is_better_than(&self, other: &Self) -> bool {
21 self.weight < other.weight
22 || (self.weight == other.weight && self.value < other.value)
23 || (self.weight == other.weight
24 && self.value == other.value
25 && self.input_count < other.input_count)
26 || (self.weight == other.weight
27 && self.value == other.value
28 && self.input_count == other.input_count
29 && self.selected < other.selected)
30 }
31}
32
33pub fn select_coin_coingrinder(
40 inputs: &[OutputGroup],
41 options: &CoinSelectionOpt,
42) -> Result<SelectionOutput, SelectionError> {
43 let insufficient_funds_error = insufficient_funds(inputs, options);
44 let mut inputs = prepare_output_groups(inputs, options)?;
45
46 inputs.sort_by(|a, b| {
47 b.value
48 .cmp(&a.value)
49 .then_with(|| a.weight.cmp(&b.weight))
50 .then_with(|| a.index.cmp(&b.index))
51 });
52
53 let mut remaining_value = vec![0u64; inputs.len() + 1];
54 for index in (0..inputs.len()).rev() {
55 remaining_value[index] = remaining_value[index + 1] + inputs[index].value;
56 }
57
58 let mut best = None;
59 let mut selected = Vec::new();
60 let mut tries = TOTAL_TRIES;
61 let base_fee = calculate_fee(
62 options.base_weight + options.change_weight,
63 options.target_feerate,
64 )
65 .max(options.min_absolute_fee);
66
67 search(
68 &inputs,
69 &remaining_value,
70 0,
71 0,
72 0,
73 0,
74 &mut selected,
75 options,
76 base_fee,
77 &mut best,
78 &mut tries,
79 )?;
80
81 let best = best.ok_or(insufficient_funds_error)?;
82 let (fee, waste) = calculate_fee_and_waste(options, best.value, best.weight)?;
83
84 Ok(SelectionOutput {
85 selected_inputs: best.selected,
86 waste: WasteMetric(waste),
87 fee,
88 })
89}
90
91#[allow(clippy::too_many_arguments)]
92fn search(
93 inputs: &[PreparedOutputGroup],
94 remaining_value: &[u64],
95 index: usize,
96 value: u64,
97 weight: u64,
98 input_count: usize,
99 selected: &mut Vec<usize>,
100 options: &CoinSelectionOpt,
101 base_fee: u64,
102 best: &mut Option<BestSelection>,
103 tries: &mut u32,
104) -> Result<(), SelectionError> {
105 if *tries == 0 || index >= inputs.len() {
106 return Ok(());
107 }
108 if value + remaining_value[index] < options.target_value + base_fee {
109 return Ok(());
110 }
111 if best.as_ref().is_some_and(|best| weight > best.weight) {
112 return Ok(());
113 }
114
115 *tries -= 1;
116
117 let candidate = &inputs[index];
118 let new_value = value + candidate.value;
119 let new_weight = weight + candidate.weight;
120 let new_input_count = input_count + candidate.input_count;
121 selected.push(candidate.index);
122
123 let required_value = options.target_value + base_fee;
124 if new_value >= required_value {
125 let candidate_best = BestSelection {
126 selected: selected.clone(),
127 value: new_value,
128 weight: new_weight,
129 input_count: new_input_count,
130 };
131 if best
132 .as_ref()
133 .is_none_or(|current| candidate_best.is_better_than(current))
134 {
135 *best = Some(candidate_best);
136 }
137 } else {
138 search(
139 inputs,
140 remaining_value,
141 index + 1,
142 new_value,
143 new_weight,
144 new_input_count,
145 selected,
146 options,
147 base_fee,
148 best,
149 tries,
150 )?;
151 }
152 selected.pop();
153
154 search(
155 inputs,
156 remaining_value,
157 index + 1,
158 value,
159 weight,
160 input_count,
161 selected,
162 options,
163 base_fee,
164 best,
165 tries,
166 )
167}
168
169#[cfg(test)]
170mod test {
171 use crate::{
172 algorithms::coingrinder::select_coin_coingrinder,
173 types::{basic_output_group, CoinSelectionOpt, ExcessStrategy, SelectionError},
174 };
175
176 fn setup_options(target_value: u64) -> CoinSelectionOpt {
177 CoinSelectionOpt {
178 target_value,
179 target_feerate: 1.0,
180 long_term_feerate: Some(1.0),
181 min_absolute_fee: 0,
182 base_weight: 0,
183 change_weight: 50,
184 change_cost: 20,
185 min_change_value: 100,
186 excess_strategy: ExcessStrategy::ToChange,
187 }
188 }
189
190 #[test]
191 fn test_coingrinder_prefers_lower_weight_over_lower_change() {
192 let inputs = vec![
193 basic_output_group(10_500, 100),
194 basic_output_group(4_000, 80),
195 basic_output_group(3_500, 80),
196 basic_output_group(3_000, 80),
197 ];
198
199 let result = select_coin_coingrinder(&inputs, &setup_options(10_000)).unwrap();
200 assert_eq!(result.selected_inputs, vec![0]);
201 }
202
203 #[test]
204 fn test_coingrinder_uses_multiple_inputs_when_needed() {
205 let inputs = vec![
206 basic_output_group(6_000, 90),
207 basic_output_group(5_000, 90),
208 basic_output_group(2_000, 50),
209 ];
210
211 let result = select_coin_coingrinder(&inputs, &setup_options(10_000)).unwrap();
212 let mut selected = result.selected_inputs;
213 selected.sort();
214 assert_eq!(selected, vec![0, 1]);
215 }
216
217 #[test]
218 fn test_coingrinder_insufficient_funds() {
219 let inputs = vec![basic_output_group(1_000, 100)];
220
221 let result = select_coin_coingrinder(&inputs, &setup_options(10_000));
222 assert!(matches!(
223 result,
224 Err(SelectionError::InsufficientFunds { .. })
225 ));
226 }
227}