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