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}