rust_coinselect/
utils.rs

1use crate::types::{
2    CoinSelectionOpt, EffectiveValue, ExcessStrategy, OutputGroup, SelectionError, Weight,
3};
4use std::{collections::HashSet, fmt};
5
6#[inline]
7pub fn calculate_waste(
8    options: &CoinSelectionOpt,
9    accumulated_value: u64,
10    accumulated_weight: u64,
11    estimated_fee: u64,
12) -> f32 {
13    // waste =  weight*(target feerate - long term fee rate) + cost of change + excess
14    // weight - total weight of selected inputs
15    // cost of change - includes the fees paid on this transaction's change output plus the fees that will need to be paid to spend it later. If there is no change output, the cost is 0.
16    // excess - refers to the difference between the sum of selected inputs and the amount we need to pay (the sum of output values and fees). There shouldn’t be any excess if there is a change output.
17
18    let mut waste = accumulated_weight as f32
19        * (options.target_feerate - options.long_term_feerate.unwrap_or(0.0));
20    if options.excess_strategy == ExcessStrategy::ToChange {
21        // Change is created if excess strategy is set to ToChange. Hence cost of change is added.
22        waste += options.change_cost as f32;
23    } else {
24        // Change is not created if excess strategy is ToFee or ToRecipient. Hence check 'excess' that's been passed ahead.
25        waste += (accumulated_value.saturating_sub(options.target_value + estimated_fee)) as f32;
26    }
27    waste
28}
29
30/// `adjusted_target` is the target value plus the estimated fee.
31///
32/// `smaller_coins` is a slice of pairs where the `usize` refers to the index of the `OutputGroup` in the provided inputs.
33/// This slice should be sorted in descending order by the value of each `OutputGroup`, with each value being less than `adjusted_target`.
34pub fn calculate_accumulated_weight(
35    smaller_coins: &[(usize, EffectiveValue, Weight)],
36    selected_inputs: &HashSet<usize>,
37) -> u64 {
38    let mut accumulated_weight: u64 = 0;
39    for &(index, _value, weight) in smaller_coins {
40        if selected_inputs.contains(&index) {
41            accumulated_weight += weight;
42        }
43    }
44    accumulated_weight
45}
46
47impl fmt::Display for SelectionError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            SelectionError::NonPositiveFeeRate => write!(f, "Negative fee rate"),
51            SelectionError::NonPositiveTarget => write!(f, "Target value must be positive"),
52            SelectionError::AbnormallyHighFeeRate => write!(f, "Abnormally high fee rate"),
53            SelectionError::InsufficientFunds => write!(f, "The Inputs funds are insufficient"),
54            SelectionError::NoSolutionFound => write!(f, "No solution could be derived"),
55        }
56    }
57}
58
59impl std::error::Error for SelectionError {}
60
61type Result<T> = std::result::Result<T, SelectionError>;
62
63#[inline]
64pub fn calculate_fee(weight: u64, rate: f32) -> Result<u64> {
65    if rate <= 0.0 {
66        Err(SelectionError::NonPositiveFeeRate)
67    } else if rate > 1000.0 {
68        Err(SelectionError::AbnormallyHighFeeRate)
69    } else {
70        Ok((weight as f32 * rate).ceil() as u64)
71    }
72}
73
74/// Returns the effective value of the `OutputGroup`, which is the actual value minus the estimated fee.
75#[inline]
76pub fn effective_value(output: &OutputGroup, feerate: f32) -> Result<u64> {
77    Ok(output
78        .value
79        .saturating_sub(calculate_fee(output.weight, feerate)?))
80}
81
82/// Returns the weights of data in transaction other than the list of inputs that would be selected.
83pub fn calculate_base_weight_btc(output_weight: u64) -> u64 {
84    // VERSION_SIZE: 4 bytes - 16 WU
85    // SEGWIT_MARKER_SIZE: 2 bytes - 2 WU
86    // NUM_INPUTS_SIZE: 1 byte - 4 WU
87    // NUM_OUTPUTS_SIZE: 1 byte - 4 WU
88    // NUM_WITNESS_SIZE: 1 byte - 1 WU
89    // LOCK_TIME_SIZE: 4 bytes - 16 WU
90    // OUTPUT_VALUE_SIZE: variable
91
92    // Total default: (16 + 2 + 4 + 4 + 1 + 16 = 43 WU + variable) WU
93    // Source - https://docs.rs/bitcoin/latest/src/bitcoin/blockdata/transaction.rs.html#599-602
94    output_weight + 43
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::types::{CoinSelectionOpt, ExcessStrategy, OutputGroup, SelectionError};
101
102    fn setup_options(target_value: u64) -> CoinSelectionOpt {
103        CoinSelectionOpt {
104            target_value,
105            target_feerate: 0.4, // Simplified feerate
106            long_term_feerate: Some(0.4),
107            min_absolute_fee: 0,
108            base_weight: 10,
109            change_weight: 50,
110            change_cost: 10,
111            avg_input_weight: 20,
112            avg_output_weight: 10,
113            min_change_value: 500,
114            excess_strategy: ExcessStrategy::ToChange,
115        }
116    }
117
118    /// Tests the fee calculation function with various input scenarios.
119    /// Fee calculation is critical for coin selection as it determines the effective value
120    /// of each UTXO after accounting for the cost to spend it.
121    ///
122    /// Test vectors cover:
123    /// - Normal fee calculation with positive rate
124    /// - Error case with negative fee rate
125    /// - Error case with abnormally high fee rate (>1000 sat/vB)
126    /// - Edge case with zero fee rate
127    #[test]
128    fn test_calculate_fee() {
129        struct TestVector {
130            weight: u64,
131            fee: f32,
132            output: Result<u64>,
133        }
134
135        let test_vectors = [
136            TestVector {
137                weight: 60,
138                fee: 5.0,
139                output: Ok(300),
140            },
141            TestVector {
142                weight: 60,
143                fee: -5.0,
144                output: Err(SelectionError::NonPositiveFeeRate),
145            },
146            TestVector {
147                weight: 60,
148                fee: 1001.0,
149                output: Err(SelectionError::AbnormallyHighFeeRate),
150            },
151            TestVector {
152                weight: 60,
153                fee: 0.0,
154                output: Err(SelectionError::NonPositiveFeeRate),
155            },
156        ];
157
158        for vector in test_vectors {
159            let result = calculate_fee(vector.weight, vector.fee);
160            match result {
161                Ok(val) => {
162                    assert_eq!(val, vector.output.unwrap())
163                }
164                Err(err) => {
165                    let output = vector.output.err();
166                    assert_eq!(err, output.unwrap());
167                }
168            }
169        }
170    }
171
172    /// Tests the effective value calculation which determines the actual spendable amount
173    /// of a UTXO after subtracting the fee required to spend it.
174    ///
175    /// Effective value is crucial for coin selection as it helps:
176    /// - Avoid selecting UTXOs that cost more in fees than their value
177    /// - Compare UTXOs based on their true spendable amount
178    /// - Calculate the actual amount available for spending
179    ///
180    /// Test vectors cover:
181    /// - Edge case where fees exceed UTXO value
182    /// - Normal case with positive effective value
183    /// - Error cases with invalid fee rates
184    /// - Large value UTXO calculations
185    #[test]
186    fn test_effective_value() {
187        struct TestVector {
188            output: OutputGroup,
189            feerate: f32,
190            result: Result<u64>,
191        }
192
193        let test_vectors = [
194            // Value minus weight would be less Than Zero but will return zero because of saturating_subtraction for u64
195            TestVector {
196                output: OutputGroup {
197                    value: 100,
198                    weight: 101,
199                    input_count: 1,
200                    creation_sequence: None,
201                },
202                feerate: 1.0,
203                result: Ok(0),
204            },
205            // Value greater than zero
206            TestVector {
207                output: OutputGroup {
208                    value: 100,
209                    weight: 99,
210                    input_count: 1,
211                    creation_sequence: None,
212                },
213                feerate: 1.0,
214                result: Ok(1),
215            },
216            // Test negative fee rate return appropriate error
217            TestVector {
218                output: OutputGroup {
219                    value: 100,
220                    weight: 99,
221                    input_count: 1,
222                    creation_sequence: None,
223                },
224                feerate: -1.0,
225                result: Err(SelectionError::NonPositiveFeeRate),
226            },
227            // Test very high fee rate
228            TestVector {
229                output: OutputGroup {
230                    value: 100,
231                    weight: 99,
232                    input_count: 1,
233                    creation_sequence: None,
234                },
235                feerate: 2000.0,
236                result: Err(SelectionError::AbnormallyHighFeeRate),
237            },
238            // Test high value
239            TestVector {
240                output: OutputGroup {
241                    value: 100_000_000_000,
242                    weight: 10,
243                    input_count: 1,
244                    creation_sequence: None,
245                },
246                feerate: 1.0,
247                result: Ok(99_999_999_990),
248            },
249        ];
250
251        for vector in test_vectors {
252            let effective_value = effective_value(&vector.output, vector.feerate);
253
254            match effective_value {
255                Ok(val) => {
256                    assert_eq!(Ok(val), vector.result)
257                }
258                Err(err) => {
259                    assert_eq!(err, vector.result.unwrap_err());
260                }
261            }
262        }
263    }
264
265    /// Tests the waste metric calculation which helps optimize coin selection.
266    /// Waste represents the cost of creating a change output plus any excess amount
267    /// that goes to fees or is added to recipient outputs.
268    ///
269    /// The waste metric considers:
270    /// - Long-term vs current fee rates
271    /// - Cost of creating change outputs
272    /// - Excess amounts based on selected strategy (fee/change/recipient)
273    ///
274    /// Test vectors cover:
275    /// - Change output creation (ToChange strategy)
276    /// - Fee payment (ToFee strategy)
277    /// - Insufficient funds scenario
278    #[test]
279    fn test_calculate_waste() {
280        struct TestVector {
281            options: CoinSelectionOpt,
282            accumulated_value: u64,
283            accumulated_weight: u64,
284            estimated_fee: u64,
285            result: f32,
286        }
287
288        let options = setup_options(100).clone();
289        let test_vectors = [
290            // Test for excess strategy to drain(change output)
291            TestVector {
292                options: options.clone(),
293                accumulated_value: 1000,
294                accumulated_weight: 50,
295                estimated_fee: 20,
296                result: options.change_cost as f32,
297            },
298            // Test for excess strategy to miners
299            TestVector {
300                options: CoinSelectionOpt {
301                    excess_strategy: ExcessStrategy::ToFee,
302                    ..options
303                },
304                accumulated_value: 1000,
305                accumulated_weight: 50,
306                estimated_fee: 20,
307                result: 880.0,
308            },
309            // Test accumulated_value minus target_value < 0
310            TestVector {
311                options: CoinSelectionOpt {
312                    target_value: 1000,
313                    excess_strategy: ExcessStrategy::ToFee,
314                    ..options
315                },
316                accumulated_value: 200,
317                accumulated_weight: 50,
318                estimated_fee: 20,
319                result: 0.0,
320            },
321        ];
322
323        for vector in test_vectors {
324            let waste = calculate_waste(
325                &vector.options,
326                vector.accumulated_value,
327                vector.accumulated_weight,
328                vector.estimated_fee,
329            );
330
331            assert_eq!(waste, vector.result)
332        }
333    }
334}