Skip to main content

rust_coinselect/
selectcoin.rs

1use std::thread;
2
3use crate::{
4    algorithms::{
5        bnb::select_coin_bnb, coingrinder::select_coin_coingrinder, fifo::select_coin_fifo,
6        lowestlarger::select_coin_lowestlarger,
7    },
8    types::{CoinSelectionOpt, OutputGroup, SelectionAlgorithm, SelectionError, SelectionOutput},
9    utils::insufficient_funds,
10};
11
12/// Signature shared by every individual coin selection algorithm.
13type CoinSelectionFn =
14    fn(&[OutputGroup], &CoinSelectionOpt) -> Result<SelectionOutput, SelectionError>;
15
16/// The algorithms run by [`select_coin`], tagged with their identity.
17const ALGORITHMS: [(SelectionAlgorithm, CoinSelectionFn); 4] = [
18    (SelectionAlgorithm::BranchAndBound, select_coin_bnb),
19    (SelectionAlgorithm::CoinGrinder, select_coin_coingrinder),
20    (SelectionAlgorithm::Fifo, select_coin_fifo),
21    (SelectionAlgorithm::LowestLarger, select_coin_lowestlarger),
22];
23
24/// The global coin selection API. Runs every algorithm and returns *all* successful results, each
25/// tagged with the [`SelectionAlgorithm`] that produced it, ordered best-first.
26///
27/// The best-result policy is: fewest real UTXOs, then fewest groups, then least waste. So the first
28/// element is the overall best selection. (`selected_inputs.len()` counts the chosen
29/// [`OutputGroup`]s, whereas the `input_count` sum counts the actual UTXOs they bundle; the two
30/// differ only when a group holds more than one UTXO.)
31pub fn select_coin(
32    inputs: &[OutputGroup],
33    options: &CoinSelectionOpt,
34) -> Result<Vec<(SelectionAlgorithm, SelectionOutput)>, SelectionError> {
35    // Run all algorithms concurrently. Checks only after all threads return and join.
36    let outcomes: Vec<(SelectionAlgorithm, Result<SelectionOutput, SelectionError>)> =
37        thread::scope(|scope| {
38            let handles: Vec<_> = ALGORITHMS
39                .into_iter()
40                .map(|(name, algo)| scope.spawn(move || (name, algo(inputs, options))))
41                .collect();
42            handles
43                .into_iter()
44                // A panicking algorithm is treated as "no solution" rather than poisoning the API.
45                .map(|handle| {
46                    handle.join().unwrap_or((
47                        SelectionAlgorithm::BranchAndBound,
48                        Err(SelectionError::NoSolutionFound),
49                    ))
50                })
51                .collect()
52        });
53
54    let mut results = Vec::new();
55    for (name, outcome) in outcomes {
56        match outcome {
57            Ok(result) => results.push((name, result)),
58            Err(
59                error @ (SelectionError::NonPositiveTarget
60                | SelectionError::NonPositiveFeeRate
61                | SelectionError::AbnormallyHighFeeRate),
62            ) => return Err(error),
63            Err(SelectionError::InsufficientFunds { .. } | SelectionError::NoSolutionFound) => {
64                continue
65            }
66        }
67    }
68
69    if results.is_empty() {
70        return Err(insufficient_funds(inputs, options));
71    }
72
73    // Order best-first: fewest real UTXOs, then fewest groups, then waste.
74    results.sort_by_key(|(_, output)| {
75        let total_input_count = output
76            .selected_inputs
77            .iter()
78            .map(|&idx| inputs[idx].input_count)
79            .sum::<usize>();
80        (
81            total_input_count,
82            output.selected_inputs.len(),
83            output.waste.0,
84        )
85    });
86    Ok(results)
87}
88
89#[cfg(test)]
90mod test {
91
92    use crate::{
93        algorithms::{
94            bnb::select_coin_bnb, coingrinder::select_coin_coingrinder, fifo::select_coin_fifo,
95            lowestlarger::select_coin_lowestlarger,
96        },
97        selectcoin::select_coin,
98        types::{
99            basic_output_group, CoinSelectionOpt, ExcessStrategy, OutputGroup, SelectionError,
100            SelectionOutput,
101        },
102        utils::calculate_fee,
103    };
104
105    fn setup_basic_output_groups() -> Vec<OutputGroup> {
106        vec![
107            basic_output_group(1_500_000, 50),
108            basic_output_group(2_000_000, 200),
109            basic_output_group(3_000_000, 300),
110            basic_output_group(2_500_000, 100),
111            basic_output_group(4_000_000, 150),
112            basic_output_group(500_000, 250),
113            basic_output_group(6_000_000, 120),
114            basic_output_group(70_000, 50),
115            basic_output_group(800_000, 60),
116            basic_output_group(900_000, 70),
117            basic_output_group(100_000, 80),
118            basic_output_group(1_000_000, 90),
119        ]
120    }
121
122    fn setup_options(target_value: u64) -> CoinSelectionOpt {
123        CoinSelectionOpt {
124            target_value,
125            target_feerate: 2.0, // Simplified feerate
126            long_term_feerate: Some(0.4),
127            min_absolute_fee: 0,
128            base_weight: 10,
129            change_weight: 50,
130            change_cost: 10,
131            min_change_value: 500,
132            excess_strategy: ExcessStrategy::ToChange,
133        }
134    }
135
136    /// Asserts that a set of selected inputs actually covers the target plus the total fee.
137    fn assert_covers_target(
138        inputs: &[OutputGroup],
139        options: &CoinSelectionOpt,
140        selected: &[usize],
141    ) {
142        let value: u64 = selected.iter().map(|&i| inputs[i].value).sum();
143        let selected_weight: u64 = selected.iter().map(|&i| inputs[i].weight).sum();
144        let total_fee = calculate_fee(
145            options.base_weight + selected_weight,
146            options.target_feerate,
147        )
148        .max(options.min_absolute_fee);
149        assert!(
150            value >= options.target_value + total_fee,
151            "selection {:?} (value {}) does not cover target {} + fee {}",
152            selected,
153            value,
154            options.target_value,
155            total_fee
156        );
157    }
158
159    #[test]
160    fn test_select_coin_rejects_zero_target() {
161        let inputs = setup_basic_output_groups();
162        let options = setup_options(0);
163        let result = select_coin(&inputs, &options);
164        assert!(matches!(result, Err(SelectionError::NonPositiveTarget)));
165    }
166
167    #[test]
168    fn test_select_coin_successful() {
169        let inputs = setup_basic_output_groups();
170        let options = setup_options(654321);
171        let result = select_coin(&inputs, &options);
172        assert!(result.is_ok());
173        let ranked = result.unwrap();
174        let best = &ranked[0].1;
175        assert!(!best.selected_inputs.is_empty());
176        assert_covers_target(&inputs, &options, &best.selected_inputs);
177    }
178
179    #[test]
180    fn test_select_coin_insufficient_funds() {
181        let inputs = setup_basic_output_groups();
182        let options = setup_options(999_999_999); // Set a target value higher than the sum of all inputs
183        let result = select_coin(&inputs, &options);
184        assert!(matches!(
185            result,
186            Err(SelectionError::InsufficientFunds {
187                available: 22_370_000,
188                required: 1_000_003_059,
189            })
190        ));
191    }
192
193    #[test]
194    fn test_select_coin_keeps_success_when_another_algorithm_is_insufficient() {
195        let inputs = vec![basic_output_group(1_000, 0)];
196        let mut options = setup_options(1_000);
197        options.target_feerate = 1.0;
198        options.long_term_feerate = Some(1.0);
199        options.base_weight = 0;
200        options.change_cost = 20;
201        options.min_change_value = 100;
202
203        let ranked = select_coin(&inputs, &options).expect("BnB should find the exact match");
204        assert_eq!(ranked[0].1.selected_inputs, vec![0]);
205    }
206
207    /// The core contract of the wrapper: it must return the fewest inputs achievable by any of the
208    /// individual algorithms (and a selection that actually covers the target).
209    #[test]
210    fn test_select_coin_returns_minimum_inputs() {
211        let inputs = setup_basic_output_groups();
212        let options = setup_options(654321);
213
214        let individual_min_inputs = [
215            select_coin_bnb(&inputs, &options),
216            select_coin_coingrinder(&inputs, &options),
217            select_coin_fifo(&inputs, &options),
218            select_coin_lowestlarger(&inputs, &options),
219        ]
220        .into_iter()
221        .filter_map(|r| r.ok())
222        .map(|r| r.selected_inputs.len())
223        .min()
224        .expect("at least one algorithm should succeed");
225
226        let ranked = select_coin(&inputs, &options).expect("selection should succeed");
227        let chosen = &ranked[0].1;
228        assert_eq!(
229            chosen.selected_inputs.len(),
230            individual_min_inputs,
231            "wrapper did not return the minimum-input selection"
232        );
233        assert_covers_target(&inputs, &options, &chosen.selected_inputs);
234    }
235
236    /// `select_coin` must return every successful algorithm's result, ordered best-first by the
237    /// policy: fewest real UTXOs, then fewest groups, then waste.
238    #[test]
239    fn test_select_coin_orders_best_first() {
240        let inputs = setup_basic_output_groups();
241        let options = setup_options(654321);
242
243        let ranked = select_coin(&inputs, &options).expect("selection should succeed");
244        assert!(!ranked.is_empty());
245
246        // The list is sorted best-first by the rank key (non-decreasing keys).
247        let key = |output: &SelectionOutput| {
248            let total_input_count: usize = output
249                .selected_inputs
250                .iter()
251                .map(|&idx| inputs[idx].input_count)
252                .sum();
253            (
254                total_input_count,
255                output.selected_inputs.len(),
256                output.waste.0,
257            )
258        };
259        let keys: Vec<_> = ranked.iter().map(|(_, output)| key(output)).collect();
260        assert!(
261            keys.windows(2).all(|w| w[0] <= w[1]),
262            "ranked results are not ordered best-first: {keys:?}"
263        );
264    }
265}