Skip to main content

rust_coinselect/
utils.rs

1use crate::types::{
2    CoinSelectionOpt, EffectiveValue, ExcessStrategy, OutputGroup, SelectionError, Weight,
3};
4use std::{collections::HashSet, fmt, ops::Deref};
5
6#[derive(Debug, Clone)]
7pub(crate) struct PreparedOutputGroup {
8    output_group: OutputGroup,
9    pub index: usize,
10}
11
12impl Deref for PreparedOutputGroup {
13    type Target = OutputGroup;
14
15    fn deref(&self) -> &Self::Target {
16        &self.output_group
17    }
18}
19
20/// Builds the internal effective-value working set used by every selection algorithm.
21pub(crate) fn prepare_output_groups(
22    inputs: &[OutputGroup],
23    options: &CoinSelectionOpt,
24) -> Result<Vec<PreparedOutputGroup>> {
25    if options.target_value == 0 {
26        return Err(SelectionError::NonPositiveTarget);
27    }
28    if options.target_feerate <= 0.0
29        || options
30            .long_term_feerate
31            .is_some_and(|feerate| feerate <= 0.0)
32    {
33        return Err(SelectionError::NonPositiveFeeRate);
34    }
35    if options.target_feerate > 1000.0
36        || options
37            .long_term_feerate
38            .is_some_and(|feerate| feerate > 1000.0)
39    {
40        return Err(SelectionError::AbnormallyHighFeeRate);
41    }
42
43    let mut prepared = Vec::with_capacity(inputs.len());
44    for (index, input) in inputs.iter().enumerate() {
45        let effective_value = input
46            .value
47            .saturating_sub(calculate_fee(input.weight, options.target_feerate));
48        if effective_value >= options.min_change_value {
49            let mut output_group = input.clone();
50            output_group.value = effective_value;
51            prepared.push(PreparedOutputGroup {
52                output_group,
53                index,
54            });
55        }
56    }
57    if prepared.is_empty() {
58        return Err(insufficient_funds(inputs, options));
59    }
60    Ok(prepared)
61}
62
63/// Reports the raw available value and the amount required when spending every supplied input.
64pub(crate) fn insufficient_funds(
65    inputs: &[OutputGroup],
66    options: &CoinSelectionOpt,
67) -> SelectionError {
68    let available = inputs
69        .iter()
70        .fold(0u64, |total, input| total.saturating_add(input.value));
71    let base_fee =
72        calculate_fee(options.base_weight, options.target_feerate).max(options.min_absolute_fee);
73    let total_input_fee = inputs
74        .iter()
75        .map(|input| calculate_fee(input.weight, options.target_feerate))
76        .sum::<u64>();
77    let required = options
78        .target_value
79        .saturating_add(base_fee)
80        .saturating_add(total_input_fee);
81
82    SelectionError::InsufficientFunds {
83        available,
84        required,
85    }
86}
87
88/// Computes the total fee and waste metric (in satoshis) for a selection.
89///
90/// waste = weight * (target_feerate - long_term_feerate) + (cost_of_change OR excess)
91#[inline]
92pub fn calculate_fee_and_waste(
93    options: &CoinSelectionOpt,
94    accumulated_effective_value: u64,
95    accumulated_weight: u64,
96) -> Result<(u64, i64)> {
97    let base_fee = calculate_fee(
98        options.base_weight + options.change_weight,
99        options.target_feerate,
100    )
101    .max(options.min_absolute_fee);
102    let input_fee = calculate_fee(accumulated_weight, options.target_feerate);
103    let long_term_feerate = options.long_term_feerate.unwrap_or(options.target_feerate);
104    let fee_difference = (options.target_feerate - long_term_feerate) as f64;
105    let mut waste = (accumulated_weight as f64 * fee_difference).round() as i64;
106    let excess = accumulated_effective_value.saturating_sub(options.target_value + base_fee);
107    if options.excess_strategy == ExcessStrategy::ToChange && excess >= options.min_change_value {
108        // A change output is actually created, so we pay its cost (now and when spent later).
109        waste += options.change_cost as i64;
110    } else {
111        // No change output is created; whatever is left over is wasted to fees/recipient.
112        waste += excess as i64;
113    }
114    Ok((base_fee + input_fee, waste))
115}
116
117/// `adjusted_target` is the target value plus the estimated fee.
118///
119/// `smaller_coins` is a slice of pairs where the `usize` refers to the index of the `OutputGroup` in the provided inputs.
120/// This slice should be sorted in descending order by the value of each `OutputGroup`, with each value being less than `adjusted_target`.
121pub fn calculate_accumulated_weight(
122    smaller_coins: &[(usize, EffectiveValue, Weight)],
123    selected_inputs: &HashSet<usize>,
124) -> u64 {
125    let mut accumulated_weight: u64 = 0;
126    for &(index, _value, weight) in smaller_coins {
127        if selected_inputs.contains(&index) {
128            accumulated_weight += weight;
129        }
130    }
131    accumulated_weight
132}
133
134impl fmt::Display for SelectionError {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        match self {
137            SelectionError::NonPositiveFeeRate => write!(f, "Negative fee rate"),
138            SelectionError::NonPositiveTarget => write!(f, "Target value must be positive"),
139            SelectionError::AbnormallyHighFeeRate => write!(f, "Abnormally high fee rate"),
140            SelectionError::InsufficientFunds {
141                available,
142                required,
143            } => write!(
144                f,
145                "Insufficient funds: available {available} sats, required {required} sats"
146            ),
147            SelectionError::NoSolutionFound => write!(f, "No solution could be derived"),
148        }
149    }
150}
151
152impl std::error::Error for SelectionError {}
153
154type Result<T> = std::result::Result<T, SelectionError>;
155
156#[inline]
157pub fn calculate_fee(weight: u64, rate: f32) -> u64 {
158    (weight as f32 * rate).ceil() as u64
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::types::{CoinSelectionOpt, ExcessStrategy};
165
166    fn setup_options(target_value: u64) -> CoinSelectionOpt {
167        CoinSelectionOpt {
168            target_value,
169            target_feerate: 0.4, // Simplified feerate
170            long_term_feerate: Some(0.4),
171            min_absolute_fee: 0,
172            base_weight: 10,
173            change_weight: 50,
174            change_cost: 10,
175            min_change_value: 500,
176            excess_strategy: ExcessStrategy::ToChange,
177        }
178    }
179
180    /// The waste metric considers:
181    /// - Long-term vs current fee rates
182    /// - Cost of creating change outputs
183    /// - Excess amounts based on selected strategy (fee/change/recipient)
184    ///
185    /// Test vectors cover:
186    /// - Change output creation (ToChange strategy)
187    /// - Fee payment (ToFee strategy)
188    /// - Insufficient funds scenario
189    #[test]
190    fn test_calculate_fee_and_waste() {
191        struct TestVector {
192            options: CoinSelectionOpt,
193            accumulated_value: u64,
194            accumulated_weight: u64,
195            fee: u64,
196            result: i64,
197        }
198
199        let options = setup_options(100).clone();
200        let test_vectors = [
201            // Test for excess strategy to drain(change output)
202            TestVector {
203                options: options.clone(),
204                accumulated_value: 1000,
205                accumulated_weight: 50,
206                fee: 24,
207                result: options.change_cost as i64,
208            },
209            // Test for excess strategy to miners
210            TestVector {
211                options: CoinSelectionOpt {
212                    excess_strategy: ExcessStrategy::ToFee,
213                    ..options
214                },
215                accumulated_value: 1000,
216                accumulated_weight: 50,
217                fee: 24,
218                result: 896,
219            },
220            // Test accumulated_value minus target_value < 0
221            TestVector {
222                options: CoinSelectionOpt {
223                    target_value: 1000,
224                    excess_strategy: ExcessStrategy::ToFee,
225                    ..options
226                },
227                accumulated_value: 200,
228                accumulated_weight: 50,
229                fee: 24,
230                result: 0,
231            },
232        ];
233
234        for vector in test_vectors {
235            let (fee, waste) = calculate_fee_and_waste(
236                &vector.options,
237                vector.accumulated_value,
238                vector.accumulated_weight,
239            )
240            .unwrap();
241
242            assert_eq!(fee, vector.fee);
243            assert_eq!(waste, vector.result)
244        }
245    }
246}