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}