@leather.io/bitcoin 0.16.7 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
 
2
- > @leather.io/bitcoin@0.16.7 build /home/runner/work/mono/mono/packages/bitcoin
2
+ > @leather.io/bitcoin@0.17.0 build /home/runner/work/mono/mono/packages/bitcoin
3
3
  > tsup
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -10,7 +10,7 @@ CLI Target: es2022
10
10
  ESM Build start
11
11
  ESM dist/index.js 25.37 KB
12
12
  ESM dist/index.js.map 49.31 KB
13
- ESM ⚡️ Build success in 57ms
13
+ ESM ⚡️ Build success in 39ms
14
14
  DTS Build start
15
- DTS ⚡️ Build success in 4044ms
15
+ DTS ⚡️ Build success in 4101ms
16
16
  DTS dist/index.d.ts 16.04 KB
package/CHANGELOG.md CHANGED
@@ -310,6 +310,13 @@
310
310
  * devDependencies
311
311
  * @leather.io/rpc bumped to 2.1.20
312
312
 
313
+ ## [0.17.0](https://github.com/leather-io/mono/compare/@leather.io/bitcoin-v0.16.7...@leather.io/bitcoin-v0.17.0) (2024-11-26)
314
+
315
+
316
+ ### Features
317
+
318
+ * migrate btc coin selection, closes LEA-1734 ([0cad7aa](https://github.com/leather-io/mono/commit/0cad7aaa35fbc8704d959a16d12965502757fe89))
319
+
313
320
  ## [0.16.4](https://github.com/leather-io/mono/compare/@leather.io/bitcoin-v0.16.3...@leather.io/bitcoin-v0.16.4) (2024-11-19)
314
321
 
315
322
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@leather.io/bitcoin",
3
3
  "author": "Leather.io contact@leather.io",
4
4
  "description": "Shared bitcoin utilities",
5
- "version": "0.16.7",
5
+ "version": "0.17.0",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/leather.io/mono/tree/dev/packages/bitcoin",
8
8
  "repository": {
@@ -25,15 +25,17 @@
25
25
  "@scure/btc-signer": "1.4.0",
26
26
  "@stacks/common": "6.13.0",
27
27
  "@stacks/transactions": "6.17.0",
28
+ "bignumber.js": "9.1.2",
28
29
  "bip32": "4.0.0",
30
+ "bitcoin-address-validation": "2.2.3",
29
31
  "bitcoinjs-lib": "6.1.5",
30
32
  "ecpair": "2.1.0",
31
33
  "just-memoize": "2.2.0",
32
34
  "varuint-bitcoin": "1.1.2",
33
- "@leather.io/constants": "0.13.5",
34
35
  "@leather.io/crypto": "1.6.14",
35
- "@leather.io/utils": "0.20.0",
36
- "@leather.io/models": "0.22.0"
36
+ "@leather.io/constants": "0.13.5",
37
+ "@leather.io/models": "0.22.0",
38
+ "@leather.io/utils": "0.20.0"
37
39
  },
38
40
  "devDependencies": {
39
41
  "eslint": "8.53.0",
@@ -43,9 +45,9 @@
43
45
  "typescript": "5.5.4",
44
46
  "vitest": "2.1.5",
45
47
  "@leather.io/eslint-config": "0.7.0",
46
- "@leather.io/rpc": "2.1.20",
48
+ "@leather.io/prettier-config": "0.6.0",
47
49
  "@leather.io/tsconfig-config": "0.6.0",
48
- "@leather.io/prettier-config": "0.6.0"
50
+ "@leather.io/rpc": "2.1.20"
49
51
  },
50
52
  "keywords": [
51
53
  "bitcoin",
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { BtcSizeFeeEstimator } from './btc-size-fee-estimator';
4
+
5
+ describe('BtcSizeFeeEstimator', () => {
6
+ const estimator = new BtcSizeFeeEstimator();
7
+
8
+ describe('getSizeOfScriptLengthElement', () => {
9
+ it('should return the correct size for small lengths', () => {
10
+ expect(estimator.getSizeOfScriptLengthElement(75)).toBe(2);
11
+ });
12
+
13
+ it('should return the correct size for large lengths', () => {
14
+ expect(estimator.getSizeOfScriptLengthElement(512)).toBe(3);
15
+ });
16
+ });
17
+
18
+ describe('getSizeOfletInt', () => {
19
+ it('should return 1 for values <= 0xFC', () => {
20
+ expect(estimator.getSizeOfletInt(250)).toBe(1);
21
+ });
22
+
23
+ it('should return 3 for values <= 0xFFFF', () => {
24
+ expect(estimator.getSizeOfletInt(0xabcd)).toBe(3);
25
+ });
26
+
27
+ it('should return 5 for values <= 0xFFFFFFFF', () => {
28
+ expect(estimator.getSizeOfletInt(0xabcdef)).toBe(5);
29
+ });
30
+ });
31
+
32
+ describe('getTxOverheadVBytes', () => {
33
+ it('should calculate the transaction overhead correctly', () => {
34
+ const result = estimator.getTxOverheadVBytes('p2wpkh', 1, 1);
35
+ expect(result).toEqual(10.75);
36
+ });
37
+ });
38
+
39
+ describe('prepareParams', () => {
40
+ it('should validate and return formatted parameters', () => {
41
+ const params = estimator.prepareParams({
42
+ input_count: 2,
43
+ input_script: 'p2wpkh',
44
+ input_m: 1,
45
+ input_n: 1,
46
+ p2pkh_output_count: 1,
47
+ p2sh_output_count: 1,
48
+ p2sh_p2wpkh_output_count: 1,
49
+ p2sh_p2wsh_output_count: 1,
50
+ p2wpkh_output_count: 1,
51
+ p2wsh_output_count: 1,
52
+ p2tr_output_count: 1,
53
+ });
54
+
55
+ expect(params.input_count).toBe(2);
56
+ expect(params.input_script).toBe('p2wpkh');
57
+ expect(params.input_m).toBe(1);
58
+ expect(params.input_n).toBe(1);
59
+ expect(params.p2pkh_output_count).toBe(1);
60
+ expect(params.p2sh_output_count).toBe(1);
61
+ expect(params.p2sh_p2wpkh_output_count).toBe(1);
62
+ expect(params.p2sh_p2wsh_output_count).toBe(1);
63
+ expect(params.p2wpkh_output_count).toBe(1);
64
+ expect(params.p2wsh_output_count).toBe(1);
65
+ expect(params.p2tr_output_count).toBe(1);
66
+ });
67
+ });
68
+
69
+ describe('calcTxSize', () => {
70
+ it('should calculate transaction size correctly', () => {
71
+ const { txVBytes, txBytes, txWeight } = estimator.calcTxSize({
72
+ input_count: 2,
73
+ input_script: 'p2wpkh',
74
+ input_m: 1,
75
+ input_n: 1,
76
+ p2pkh_output_count: 1,
77
+ p2sh_output_count: 1,
78
+ p2sh_p2wpkh_output_count: 1,
79
+ p2sh_p2wsh_output_count: 1,
80
+ p2wpkh_output_count: 1,
81
+ p2wsh_output_count: 1,
82
+ p2tr_output_count: 1,
83
+ });
84
+
85
+ expect(txVBytes).toEqual(393.25);
86
+ expect(txBytes).toEqual(609.5);
87
+ expect(txWeight).toEqual(1573);
88
+ });
89
+ });
90
+
91
+ describe('estimateFee', () => {
92
+ it('should calculate the correct fee based on transaction size and fee rate', () => {
93
+ const fee = estimator.estimateFee(250, 20);
94
+ expect(fee).toBe(5000);
95
+ });
96
+
97
+ it('should return 0 fee if fee rate is 0', () => {
98
+ const fee = estimator.estimateFee(250, 0);
99
+ expect(fee).toBe(0);
100
+ });
101
+ });
102
+
103
+ describe('formatFeeRange', () => {
104
+ it('should format fee range correctly', () => {
105
+ const feeRange = estimator.formatFeeRange(1000, 0.1);
106
+ expect(feeRange).toBe('900 - 1100');
107
+ });
108
+ });
109
+ });
@@ -0,0 +1,327 @@
1
+ // https://github.com/argvil19/bitcoin-transaction-size-calculator/blob/master/index.js
2
+ import BigNumber from 'bignumber.js';
3
+
4
+ export type InputScriptType =
5
+ | 'p2pkh'
6
+ | 'p2sh'
7
+ | 'p2sh-p2wpkh'
8
+ | 'p2sh-p2wsh'
9
+ | 'p2wpkh'
10
+ | 'p2wsh'
11
+ | 'p2tr';
12
+
13
+ export interface TxSizerParams {
14
+ input_count: number;
15
+ input_script: InputScriptType;
16
+ input_m: number;
17
+ input_n: number;
18
+ p2pkh_output_count: number;
19
+ p2sh_output_count: number;
20
+ p2sh_p2wpkh_output_count: number;
21
+ p2sh_p2wsh_output_count: number;
22
+ p2wpkh_output_count: number;
23
+ p2wsh_output_count: number;
24
+ p2tr_output_count: number;
25
+ }
26
+
27
+ export class BtcSizeFeeEstimator {
28
+ P2PKH_IN_SIZE = 148;
29
+ P2PKH_OUT_SIZE = 34;
30
+ P2SH_OUT_SIZE = 32;
31
+ P2SH_P2WPKH_OUT_SIZE = 32;
32
+ P2SH_P2WSH_OUT_SIZE = 32;
33
+ P2SH_P2WPKH_IN_SIZE = 91;
34
+ P2WPKH_IN_SIZE = 67.75;
35
+ P2WPKH_OUT_SIZE = 31;
36
+ P2WSH_OUT_SIZE = 43;
37
+ P2TR_OUT_SIZE = 43;
38
+ P2TR_IN_SIZE = 57.25;
39
+ PUBKEY_SIZE = 33;
40
+ SIGNATURE_SIZE = 72;
41
+ SUPPORTED_INPUT_SCRIPT_TYPES: InputScriptType[] = [
42
+ 'p2pkh',
43
+ 'p2sh',
44
+ 'p2sh-p2wpkh',
45
+ 'p2sh-p2wsh',
46
+ 'p2wpkh',
47
+ 'p2wsh',
48
+ 'p2tr',
49
+ ];
50
+
51
+ defaultParams: TxSizerParams = {
52
+ input_count: 0,
53
+ input_script: 'p2wpkh',
54
+ input_m: 0,
55
+ input_n: 0,
56
+ p2pkh_output_count: 0,
57
+ p2sh_output_count: 0,
58
+ p2sh_p2wpkh_output_count: 0,
59
+ p2sh_p2wsh_output_count: 0,
60
+ p2wpkh_output_count: 0,
61
+ p2wsh_output_count: 0,
62
+ p2tr_output_count: 0,
63
+ };
64
+
65
+ params: TxSizerParams = { ...this.defaultParams };
66
+
67
+ getSizeOfScriptLengthElement(length: number) {
68
+ if (length < 75) {
69
+ return 1;
70
+ } else if (length <= 255) {
71
+ return 2;
72
+ } else if (length <= 65535) {
73
+ return 3;
74
+ } else if (length <= 4294967295) {
75
+ return 5;
76
+ } else {
77
+ throw new Error('Size of redeem script is too large');
78
+ }
79
+ }
80
+
81
+ getSizeOfletInt(length: number) {
82
+ if (length < 253) {
83
+ return 1;
84
+ } else if (length < 65535) {
85
+ return 3;
86
+ } else if (length < 4294967295) {
87
+ return 5;
88
+ } else if (new BigNumber(length).isLessThan('18446744073709551615')) {
89
+ return 9;
90
+ } else {
91
+ throw new Error('Invalid let int');
92
+ }
93
+ }
94
+
95
+ getTxOverheadVBytes(input_script: InputScriptType, input_count: number, output_count: number) {
96
+ let witness_vbytes;
97
+ if (input_script === 'p2pkh' || input_script === 'p2sh') {
98
+ witness_vbytes = 0;
99
+ } else {
100
+ // Transactions with segwit inputs have extra overhead
101
+ witness_vbytes =
102
+ 0.25 + // segwit marker
103
+ 0.25 + // segwit flag
104
+ this.getSizeOfletInt(input_count) / 4; // witness element count
105
+ }
106
+
107
+ return (
108
+ 4 + // nVersion
109
+ this.getSizeOfletInt(input_count) + // number of inputs
110
+ this.getSizeOfletInt(output_count) + // number of outputs
111
+ 4 + // nLockTime
112
+ witness_vbytes
113
+ );
114
+ }
115
+
116
+ getTxOverheadExtraRawBytes(input_script: InputScriptType, input_count: number) {
117
+ let witness_vbytes;
118
+ if (input_script === 'p2pkh' || input_script === 'p2sh') {
119
+ witness_vbytes = 0;
120
+ } else {
121
+ // Transactions with segwit inputs have extra overhead
122
+ witness_vbytes =
123
+ 0.25 + // segwit marker
124
+ 0.25 + // segwit flag
125
+ this.getSizeOfletInt(input_count) / 4; // witness element count
126
+ }
127
+
128
+ return witness_vbytes * 3;
129
+ }
130
+
131
+ prepareParams(opts: Partial<TxSizerParams>) {
132
+ // Verify opts and set them to this.params
133
+ opts = opts || Object.assign(this.defaultParams);
134
+
135
+ const input_count = opts.input_count || this.defaultParams.input_count;
136
+ if (!Number.isInteger(input_count) || input_count < 0) {
137
+ throw new Error('expecting positive input count, got: ' + input_count);
138
+ }
139
+
140
+ const input_script = opts.input_script || this.defaultParams.input_script;
141
+ if (this.SUPPORTED_INPUT_SCRIPT_TYPES.indexOf(input_script) === -1) {
142
+ throw new Error('Not supported input script type');
143
+ }
144
+
145
+ const input_m = opts.input_m || this.defaultParams.input_m;
146
+ if (!Number.isInteger(input_m) || input_m < 0) {
147
+ throw new Error('expecting positive signature count');
148
+ }
149
+
150
+ const input_n = opts.input_n || this.defaultParams.input_n;
151
+ if (!Number.isInteger(input_n) || input_n < 0) {
152
+ throw new Error('expecting positive pubkey count');
153
+ }
154
+
155
+ const p2pkh_output_count = opts.p2pkh_output_count || this.defaultParams.p2pkh_output_count;
156
+ if (!Number.isInteger(p2pkh_output_count) || p2pkh_output_count < 0) {
157
+ throw new Error('expecting positive p2pkh output count');
158
+ }
159
+
160
+ const p2sh_output_count = opts.p2sh_output_count || this.defaultParams.p2sh_output_count;
161
+ if (!Number.isInteger(p2sh_output_count) || p2sh_output_count < 0) {
162
+ throw new Error('expecting positive p2sh output count');
163
+ }
164
+
165
+ const p2sh_p2wpkh_output_count =
166
+ opts.p2sh_p2wpkh_output_count || this.defaultParams.p2sh_p2wpkh_output_count;
167
+ if (!Number.isInteger(p2sh_p2wpkh_output_count) || p2sh_p2wpkh_output_count < 0) {
168
+ throw new Error('expecting positive p2sh-p2wpkh output count');
169
+ }
170
+
171
+ const p2sh_p2wsh_output_count =
172
+ opts.p2sh_p2wsh_output_count || this.defaultParams.p2sh_p2wsh_output_count;
173
+ if (!Number.isInteger(p2sh_p2wsh_output_count) || p2sh_p2wsh_output_count < 0) {
174
+ throw new Error('expecting positive p2sh-p2wsh output count');
175
+ }
176
+
177
+ const p2wpkh_output_count = opts.p2wpkh_output_count || this.defaultParams.p2wpkh_output_count;
178
+ if (!Number.isInteger(p2wpkh_output_count) || p2wpkh_output_count < 0) {
179
+ throw new Error('expecting positive p2wpkh output count');
180
+ }
181
+
182
+ const p2wsh_output_count = opts.p2wsh_output_count || this.defaultParams.p2wsh_output_count;
183
+ if (!Number.isInteger(p2wsh_output_count) || p2wsh_output_count < 0) {
184
+ throw new Error('expecting positive p2wsh output count');
185
+ }
186
+
187
+ const p2tr_output_count = opts.p2tr_output_count || this.defaultParams.p2tr_output_count;
188
+ if (!Number.isInteger(p2tr_output_count) || p2tr_output_count < 0) {
189
+ throw new Error('expecting positive p2tr output count');
190
+ }
191
+
192
+ this.params = {
193
+ input_count,
194
+ input_script,
195
+ input_m,
196
+ input_n,
197
+ p2pkh_output_count,
198
+ p2sh_output_count,
199
+ p2sh_p2wpkh_output_count,
200
+ p2sh_p2wsh_output_count,
201
+ p2wpkh_output_count,
202
+ p2wsh_output_count,
203
+ p2tr_output_count,
204
+ };
205
+
206
+ return this.params;
207
+ }
208
+
209
+ getOutputCount() {
210
+ return (
211
+ this.params.p2pkh_output_count +
212
+ this.params.p2sh_output_count +
213
+ this.params.p2sh_p2wpkh_output_count +
214
+ this.params.p2sh_p2wsh_output_count +
215
+ this.params.p2wpkh_output_count +
216
+ this.params.p2wsh_output_count +
217
+ this.params.p2tr_output_count
218
+ );
219
+ }
220
+
221
+ getSizeBasedOnInputType() {
222
+ // In most cases the input size is predictable. For multisig inputs we need to perform a detailed calculation
223
+ let inputSize = 0; // in virtual bytes
224
+ let inputWitnessSize = 0;
225
+ let redeemScriptSize;
226
+ switch (this.params.input_script) {
227
+ case 'p2pkh':
228
+ inputSize = this.P2PKH_IN_SIZE;
229
+ break;
230
+ case 'p2sh-p2wpkh':
231
+ inputSize = this.P2SH_P2WPKH_IN_SIZE;
232
+ inputWitnessSize = 107; // size(signature) + signature + size(pubkey) + pubkey
233
+ break;
234
+ case 'p2wpkh':
235
+ inputSize = this.P2WPKH_IN_SIZE;
236
+ inputWitnessSize = 107; // size(signature) + signature + size(pubkey) + pubkey
237
+ break;
238
+ case 'p2tr': // Only consider the cooperative taproot signing path assume multisig is done via aggregate signatures
239
+ inputSize = this.P2TR_IN_SIZE;
240
+ inputWitnessSize = 65; // getSizeOfletInt(schnorrSignature) + schnorrSignature
241
+ break;
242
+ case 'p2sh':
243
+ redeemScriptSize =
244
+ 1 + // OP_M
245
+ this.params.input_n * (1 + this.PUBKEY_SIZE) + // OP_PUSH33 <pubkey>
246
+ 1 + // OP_N
247
+ 1; // OP_CHECKMULTISIG
248
+ const scriptSigSize =
249
+ 1 + // size(0)
250
+ this.params.input_m * (1 + this.SIGNATURE_SIZE) + // size(SIGNATURE_SIZE) + signature
251
+ this.getSizeOfScriptLengthElement(redeemScriptSize) +
252
+ redeemScriptSize;
253
+ inputSize = 32 + 4 + this.getSizeOfletInt(scriptSigSize) + scriptSigSize + 4;
254
+ break;
255
+ case 'p2sh-p2wsh':
256
+ case 'p2wsh':
257
+ redeemScriptSize =
258
+ 1 + // OP_M
259
+ this.params.input_n * (1 + this.PUBKEY_SIZE) + // OP_PUSH33 <pubkey>
260
+ 1 + // OP_N
261
+ 1; // OP_CHECKMULTISIG
262
+ inputWitnessSize =
263
+ 1 + // size(0)
264
+ this.params.input_m * (1 + this.SIGNATURE_SIZE) + // size(SIGNATURE_SIZE) + signature
265
+ this.getSizeOfScriptLengthElement(redeemScriptSize) +
266
+ redeemScriptSize;
267
+ inputSize =
268
+ 36 + // outpoint (spent UTXO ID)
269
+ inputWitnessSize / 4 + // witness program
270
+ 4; // nSequence
271
+ if (this.params.input_script === 'p2sh-p2wsh') {
272
+ inputSize += 32 + 3; // P2SH wrapper (redeemscript hash) + overhead?
273
+ }
274
+ }
275
+
276
+ return {
277
+ inputSize,
278
+ inputWitnessSize,
279
+ };
280
+ }
281
+
282
+ calcTxSize(opts: Partial<TxSizerParams>) {
283
+ this.prepareParams(opts);
284
+ const output_count = this.getOutputCount();
285
+ const { inputSize, inputWitnessSize } = this.getSizeBasedOnInputType();
286
+
287
+ const txVBytes =
288
+ this.getTxOverheadVBytes(this.params.input_script, this.params.input_count, output_count) +
289
+ inputSize * this.params.input_count +
290
+ this.P2PKH_OUT_SIZE * this.params.p2pkh_output_count +
291
+ this.P2SH_OUT_SIZE * this.params.p2sh_output_count +
292
+ this.P2SH_P2WPKH_OUT_SIZE * this.params.p2sh_p2wpkh_output_count +
293
+ this.P2SH_P2WSH_OUT_SIZE * this.params.p2sh_p2wsh_output_count +
294
+ this.P2WPKH_OUT_SIZE * this.params.p2wpkh_output_count +
295
+ this.P2WSH_OUT_SIZE * this.params.p2wsh_output_count +
296
+ this.P2TR_OUT_SIZE * this.params.p2tr_output_count;
297
+
298
+ const txBytes =
299
+ this.getTxOverheadExtraRawBytes(this.params.input_script, this.params.input_count) +
300
+ txVBytes +
301
+ inputWitnessSize * this.params.input_count;
302
+ const txWeight = txVBytes * 4;
303
+
304
+ return { txVBytes, txBytes, txWeight };
305
+ }
306
+
307
+ estimateFee(vbyte: number, satVb: number) {
308
+ if (isNaN(vbyte) || isNaN(satVb)) {
309
+ throw new Error('Parameters should be numbers');
310
+ }
311
+ return vbyte * satVb;
312
+ }
313
+
314
+ formatFeeRange(fee: number, multiplier: number) {
315
+ if (isNaN(fee) || isNaN(multiplier)) {
316
+ throw new Error('Parameters should be numbers');
317
+ }
318
+
319
+ if (multiplier < 0) {
320
+ throw new Error('Multiplier cant be negative');
321
+ }
322
+
323
+ const multipliedFee = fee * multiplier;
324
+
325
+ return fee - multipliedFee + ' - ' + (fee + multipliedFee);
326
+ }
327
+ }
@@ -0,0 +1,54 @@
1
+ import { calculateMaxBitcoinSpend } from './calculate-max-spend';
2
+ import { generateMockAverageFee, mockUtxos } from './coin-selection.mocks';
3
+
4
+ describe(calculateMaxBitcoinSpend.name, () => {
5
+ test('with 1 sat/vb fee', () => {
6
+ const fee = 1;
7
+ const maxBitcoinSpend = calculateMaxBitcoinSpend({
8
+ address: '',
9
+ utxos: mockUtxos,
10
+ fetchedFeeRates: generateMockAverageFee(fee),
11
+ });
12
+ expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50087948);
13
+ });
14
+
15
+ test('with 5 sat/vb fee', () => {
16
+ const fee = 5;
17
+ const maxBitcoinSpend = calculateMaxBitcoinSpend({
18
+ address: '',
19
+ utxos: mockUtxos,
20
+ fetchedFeeRates: generateMockAverageFee(fee),
21
+ });
22
+ expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50085342);
23
+ });
24
+
25
+ test('with 30 sat/vb fee', () => {
26
+ const fee = 30;
27
+ const maxBitcoinSpend = calculateMaxBitcoinSpend({
28
+ address: '',
29
+ utxos: mockUtxos,
30
+ fetchedFeeRates: generateMockAverageFee(fee),
31
+ });
32
+ expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50073585);
33
+ });
34
+
35
+ test('with 100 sat/vb fee', () => {
36
+ const fee = 100;
37
+ const maxBitcoinSpend = calculateMaxBitcoinSpend({
38
+ address: '',
39
+ utxos: mockUtxos,
40
+ fetchedFeeRates: generateMockAverageFee(fee),
41
+ });
42
+ expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50046950);
43
+ });
44
+
45
+ test('with 400 sat/vb fee', () => {
46
+ const fee = 400;
47
+ const maxBitcoinSpend = calculateMaxBitcoinSpend({
48
+ address: '',
49
+ utxos: mockUtxos,
50
+ fetchedFeeRates: generateMockAverageFee(fee),
51
+ });
52
+ expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(49969100);
53
+ });
54
+ });
@@ -0,0 +1,47 @@
1
+ import BigNumber from 'bignumber.js';
2
+
3
+ import type { AverageBitcoinFeeRates } from '@leather.io/models';
4
+ import { createMoney, satToBtc } from '@leather.io/utils';
5
+
6
+ import { Utxo, filterUneconomicalUtxos, getSpendableAmount } from './coin-selection.utils';
7
+
8
+ interface CalculateMaxBitcoinSpend {
9
+ address: string;
10
+ utxos: Utxo[];
11
+ fetchedFeeRates?: AverageBitcoinFeeRates;
12
+ feeRate?: number;
13
+ }
14
+
15
+ export function calculateMaxBitcoinSpend({
16
+ address,
17
+ utxos,
18
+ feeRate,
19
+ fetchedFeeRates,
20
+ }: CalculateMaxBitcoinSpend) {
21
+ if (!utxos.length || !fetchedFeeRates)
22
+ return {
23
+ spendAllFee: 0,
24
+ amount: createMoney(0, 'BTC'),
25
+ spendableBitcoin: new BigNumber(0),
26
+ };
27
+
28
+ const currentFeeRate = feeRate ?? fetchedFeeRates.halfHourFee.toNumber();
29
+
30
+ const filteredUtxos = filterUneconomicalUtxos({
31
+ utxos,
32
+ feeRate: currentFeeRate,
33
+ recipients: [{ address, amount: createMoney(0, 'BTC') }],
34
+ });
35
+
36
+ const { spendableAmount, fee } = getSpendableAmount({
37
+ utxos: filteredUtxos,
38
+ feeRate: currentFeeRate,
39
+ recipients: [{ address, amount: createMoney(0, 'BTC') }],
40
+ });
41
+
42
+ return {
43
+ spendAllFee: fee,
44
+ amount: createMoney(spendableAmount, 'BTC'),
45
+ spendableBitcoin: satToBtc(spendableAmount),
46
+ };
47
+ }
@@ -0,0 +1,43 @@
1
+ import { sha256 } from '@noble/hashes/sha256';
2
+ import { hexToBytes } from '@noble/hashes/utils';
3
+ import BigNumber from 'bignumber.js';
4
+
5
+ import { Utxo } from './coin-selection.utils';
6
+
7
+ function generateMockHex() {
8
+ return Math.floor(Math.random() * 0xffffff)
9
+ .toString(16)
10
+ .padEnd(6, '0');
11
+ }
12
+
13
+ function generateMockTxId(value: number): Utxo {
14
+ return {
15
+ txid: sha256(sha256(hexToBytes(generateMockHex()))).toString(),
16
+ vout: 0,
17
+ status: {
18
+ confirmed: true,
19
+ block_height: 2568495,
20
+ block_hash: '000000000000008622fafce4a5388861b252d534f819d0f7cb5d4f2c5f9c1638',
21
+ block_time: 1703787327,
22
+ },
23
+ value,
24
+ };
25
+ }
26
+
27
+ export function generateMockTransactions(values: number[]) {
28
+ return values.map(val => generateMockTxId(val));
29
+ }
30
+
31
+ export function generateMockAverageFee(value: number) {
32
+ return {
33
+ hourFee: BigNumber(value / 2),
34
+ halfHourFee: BigNumber(value),
35
+ fastestFee: BigNumber(value * 2),
36
+ };
37
+ }
38
+
39
+ export const mockUtxos = generateMockTransactions([
40
+ 600, 600, 1200, 1200, 10000, 10000, 25000, 40000, 50000000,
41
+ ]);
42
+
43
+ export const mockAverageFee = generateMockAverageFee(10);
@@ -0,0 +1,230 @@
1
+ import { BTC_P2WPKH_DUST_AMOUNT } from '@leather.io/constants';
2
+ import { createMoney, createNullArrayOfLength, sumNumbers } from '@leather.io/utils';
3
+
4
+ import { determineUtxosForSpend, determineUtxosForSpendAll } from './coin-selection';
5
+ import { filterUneconomicalUtxos, getSizeInfo } from './coin-selection.utils';
6
+
7
+ const demoUtxos = [
8
+ { value: 8200 },
9
+ { value: 8490 },
10
+ { value: 8790 },
11
+ { value: 19 },
12
+ { value: 2000 },
13
+ { value: 2340 },
14
+ { value: 1230 },
15
+ { value: 120 },
16
+ { value: 8 },
17
+ { value: 1002 },
18
+ { value: 1382 },
19
+ { value: 1400 },
20
+ { value: 909 },
21
+ ];
22
+
23
+ function generate10kSpendWithDummyUtxoSet(recipient: string) {
24
+ return determineUtxosForSpend({
25
+ utxos: demoUtxos as any,
26
+ feeRate: 20,
27
+ recipients: [{ address: recipient, amount: createMoney(10_000, 'BTC') }],
28
+ });
29
+ }
30
+
31
+ describe(determineUtxosForSpend.name, () => {
32
+ describe('Estimated size', () => {
33
+ test('that Native Segwit, 1 input 2 outputs weighs 140 vBytes', () => {
34
+ const estimation = determineUtxosForSpend({
35
+ utxos: [{ value: 50_000 }] as any[],
36
+ recipients: [
37
+ {
38
+ address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
39
+ amount: createMoney(40_000, 'BTC'),
40
+ },
41
+ ],
42
+ feeRate: 20,
43
+ });
44
+ expect(estimation.txVBytes).toBeGreaterThan(140);
45
+ expect(estimation.txVBytes).toBeLessThan(142);
46
+ });
47
+
48
+ test('that Native Segwit, 2 input 2 outputs weighs 200vBytes', () => {
49
+ const estimation = determineUtxosForSpend({
50
+ utxos: [{ value: 50_000 }, { value: 50_000 }] as any[],
51
+ recipients: [
52
+ {
53
+ address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
54
+ amount: createMoney(60_000, 'BTC'),
55
+ },
56
+ ],
57
+ feeRate: 20,
58
+ });
59
+ expect(estimation.txVBytes).toBeGreaterThan(208);
60
+ expect(estimation.txVBytes).toBeLessThan(209);
61
+ });
62
+
63
+ test('that Native Segwit, 10 input 2 outputs weighs 200vBytes', () => {
64
+ const estimation = determineUtxosForSpend({
65
+ utxos: [
66
+ { value: 20_000 },
67
+ { value: 20_000 },
68
+ { value: 10_000 },
69
+ { value: 10_000 },
70
+ { value: 10_000 },
71
+ { value: 10_000 },
72
+ { value: 10_000 },
73
+ { value: 10_000 },
74
+ { value: 10_000 },
75
+ { value: 10_000 },
76
+ ] as any[],
77
+ recipients: [
78
+ {
79
+ address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
80
+ amount: createMoney(100_000, 'BTC'),
81
+ },
82
+ ],
83
+ feeRate: 20,
84
+ });
85
+ expect(estimation.txVBytes).toBeGreaterThan(750);
86
+ expect(estimation.txVBytes).toBeLessThan(751);
87
+ });
88
+ });
89
+
90
+ describe('sorting algorithm', () => {
91
+ test('that it filters out dust utxos', () => {
92
+ const result = generate10kSpendWithDummyUtxoSet('tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m');
93
+ const hasDust = result.filteredUtxos.some(utxo => utxo.value <= BTC_P2WPKH_DUST_AMOUNT);
94
+ expect(hasDust).toBeFalsy();
95
+ });
96
+
97
+ test('that it sorts utxos in decending order', () => {
98
+ const result = generate10kSpendWithDummyUtxoSet('tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m');
99
+ result.inputs.forEach((u, i) => {
100
+ const nextUtxo = result.inputs[i + 1];
101
+ if (!nextUtxo) return;
102
+ expect(u.value >= nextUtxo.value).toEqual(true);
103
+ });
104
+ });
105
+ });
106
+
107
+ test('that it accepts a wrapped segwit address', () =>
108
+ expect(() =>
109
+ generate10kSpendWithDummyUtxoSet('33SVjoCHJovrXxjDKLFSXo1h3t5KgkPzfH')
110
+ ).not.toThrowError());
111
+
112
+ test('that it accepts a legacy addresses', () =>
113
+ expect(() =>
114
+ generate10kSpendWithDummyUtxoSet('15PyZveQd28E2SHZu2ugkWZBp6iER41vXj')
115
+ ).not.toThrowError());
116
+
117
+ test('that it throws an error with non-legit address', () => {
118
+ expect(() =>
119
+ generate10kSpendWithDummyUtxoSet('whoop-de-da-boop-da-de-not-a-bitcoin-address')
120
+ ).toThrowError();
121
+ });
122
+
123
+ test('that given a set of utxos, legacy is more expensive', () => {
124
+ const legacy = generate10kSpendWithDummyUtxoSet('15PyZveQd28E2SHZu2ugkWZBp6iER41vXj');
125
+ const segwit = generate10kSpendWithDummyUtxoSet('33SVjoCHJovrXxjDKLFSXo1h3t5KgkPzfH');
126
+ expect(legacy.fee).toBeGreaterThan(segwit.fee);
127
+ });
128
+
129
+ test('that given a set of utxos, wrapped segwit is more expensive than native', () => {
130
+ const segwit = generate10kSpendWithDummyUtxoSet('33SVjoCHJovrXxjDKLFSXo1h3t5KgkPzfH');
131
+ const native = generate10kSpendWithDummyUtxoSet('tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m');
132
+ expect(segwit.fee).toBeGreaterThan(native.fee);
133
+ });
134
+
135
+ test('that given a set of utxos, taproot is more expensive than native segwit', () => {
136
+ // Non-obvious behaviour.
137
+ // P2TR outputs = 34 vBytes
138
+ // P2WPKH outputs = 22 vBytes
139
+ const native = generate10kSpendWithDummyUtxoSet('tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m');
140
+ const taproot = generate10kSpendWithDummyUtxoSet(
141
+ 'tb1parwmj7533de3k2fw2kntyqacspvhm67qnjcmpqnnpfvzu05l69nsczdywd'
142
+ );
143
+ expect(taproot.fee).toBeGreaterThan(native.fee);
144
+ });
145
+
146
+ test('against a random set of generated utxos', () => {
147
+ const testData = createNullArrayOfLength(50).map(() => ({
148
+ value: Math.ceil(Math.random() * 10000),
149
+ }));
150
+ const amount = 29123n;
151
+ const result = determineUtxosForSpend({
152
+ utxos: testData as any,
153
+ recipients: [
154
+ {
155
+ address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
156
+ amount: createMoney(Number(amount), 'BTC'),
157
+ },
158
+ ],
159
+ feeRate: 3,
160
+ });
161
+ expect(result.outputs[0].value).toEqual(29123n);
162
+
163
+ expect(result.outputs[1].value.toString()).toEqual(
164
+ sumNumbers(result.inputs.map(i => i.value))
165
+ .minus(result.fee)
166
+ .minus(amount.toString())
167
+ .toString()
168
+ );
169
+ });
170
+
171
+ test('that spending all economical spendable utxos does not result in dust utxos', () => {
172
+ const feeRate = 3;
173
+ const recipients = [
174
+ {
175
+ address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
176
+ amount: createMoney(Number(1), 'BTC'),
177
+ },
178
+ ];
179
+ const filteredUtxos = filterUneconomicalUtxos({
180
+ utxos: demoUtxos.sort((a, b) => b.value - a.value) as any,
181
+ feeRate,
182
+ recipients,
183
+ });
184
+ const amount = filteredUtxos.reduce((total, utxo) => total + utxo.value, 0) - 2251;
185
+ recipients[0].amount = createMoney(Number(amount), 'BTC');
186
+
187
+ const result = determineUtxosForSpend({
188
+ utxos: filteredUtxos as any,
189
+ recipients: [
190
+ {
191
+ address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
192
+ amount: createMoney(Number(amount), 'BTC'),
193
+ },
194
+ ],
195
+ feeRate,
196
+ });
197
+ expect(result.inputs.length).toEqual(10);
198
+ expect(result.outputs.length).toEqual(1);
199
+ expect(result.fee).toEqual(2251);
200
+ });
201
+
202
+ test('that spending all utxos with sendMax does not result in dust utxos', () => {
203
+ const utxos = [{ value: 1000 }, { value: 2000 }, { value: 3000 }];
204
+ const recipients = [
205
+ {
206
+ address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
207
+ amount: createMoney(Number(1), 'BTC'),
208
+ },
209
+ ];
210
+ const sizeInfo = getSizeInfo({
211
+ inputLength: utxos.length,
212
+ isSendMax: true,
213
+ recipients,
214
+ });
215
+ const feeRate = 3;
216
+ const fee = Math.floor(sizeInfo.txVBytes * feeRate);
217
+ const amount = utxos.reduce((total, utxo) => total + utxo.value, 0) - fee;
218
+ recipients[0].amount = createMoney(Number(amount), 'BTC');
219
+
220
+ const result = determineUtxosForSpendAll({
221
+ utxos: utxos as any,
222
+ recipients,
223
+ feeRate,
224
+ });
225
+ expect(result.inputs.length).toEqual(utxos.length);
226
+ expect(result.outputs.length).toEqual(1);
227
+ expect(result.fee).toEqual(735);
228
+ expect(fee).toEqual(735);
229
+ });
230
+ });
@@ -0,0 +1,140 @@
1
+ import BigNumber from 'bignumber.js';
2
+ import { validate } from 'bitcoin-address-validation';
3
+
4
+ import { BTC_P2WPKH_DUST_AMOUNT } from '@leather.io/constants';
5
+ import { sumMoney, sumNumbers } from '@leather.io/utils';
6
+
7
+ import {
8
+ TransferRecipient,
9
+ Utxo,
10
+ filterUneconomicalUtxos,
11
+ getSizeInfo,
12
+ } from './coin-selection.utils';
13
+
14
+ export class InsufficientFundsError extends Error {
15
+ constructor() {
16
+ super('Insufficient funds');
17
+ }
18
+ }
19
+
20
+ interface Output {
21
+ value: bigint;
22
+ address?: string;
23
+ }
24
+
25
+ export interface DetermineUtxosForSpendArgs {
26
+ feeRate: number;
27
+ recipients: TransferRecipient[];
28
+ utxos: Utxo[];
29
+ }
30
+
31
+ function getUtxoTotal(utxos: Utxo[]) {
32
+ return sumNumbers(utxos.map(utxo => utxo.value));
33
+ }
34
+
35
+ export function determineUtxosForSpendAll({
36
+ feeRate,
37
+ recipients,
38
+ utxos,
39
+ }: DetermineUtxosForSpendArgs) {
40
+ recipients.forEach(recipient => {
41
+ if (!validate(recipient.address))
42
+ throw new Error('Cannot calculate spend of invalid address type');
43
+ });
44
+ const filteredUtxos = filterUneconomicalUtxos({ utxos, feeRate, recipients });
45
+
46
+ const sizeInfo = getSizeInfo({
47
+ inputLength: filteredUtxos.length,
48
+ isSendMax: true,
49
+ recipients,
50
+ });
51
+
52
+ // Fee has already been deducted from the amount with send all
53
+ const outputs = recipients.map(({ address, amount }) => ({
54
+ value: BigInt(amount.amount.toNumber()),
55
+ address,
56
+ }));
57
+
58
+ const fee = Math.ceil(sizeInfo.txVBytes * feeRate);
59
+
60
+ return {
61
+ inputs: filteredUtxos,
62
+ outputs,
63
+ size: sizeInfo.txVBytes,
64
+ fee,
65
+ };
66
+ }
67
+
68
+ export function determineUtxosForSpend({ feeRate, recipients, utxos }: DetermineUtxosForSpendArgs) {
69
+ recipients.forEach(recipient => {
70
+ if (!validate(recipient.address))
71
+ throw new Error('Cannot calculate spend of invalid address type');
72
+ });
73
+ const filteredUtxos = filterUneconomicalUtxos({
74
+ utxos: utxos.sort((a, b) => b.value - a.value),
75
+ feeRate,
76
+ recipients,
77
+ });
78
+ if (!filteredUtxos.length) throw new InsufficientFundsError();
79
+
80
+ const amount = sumMoney(recipients.map(recipient => recipient.amount));
81
+
82
+ // Prepopulate with first UTXO, at least one is needed
83
+ const neededUtxos: Utxo[] = [filteredUtxos[0]];
84
+
85
+ function estimateTransactionSize() {
86
+ return getSizeInfo({
87
+ inputLength: neededUtxos.length,
88
+ recipients,
89
+ });
90
+ }
91
+
92
+ function hasSufficientUtxosForTx() {
93
+ const txEstimation = estimateTransactionSize();
94
+ const neededAmount = new BigNumber(txEstimation.txVBytes * feeRate).plus(amount.amount);
95
+ return getUtxoTotal(neededUtxos).isGreaterThanOrEqualTo(neededAmount);
96
+ }
97
+
98
+ function getRemainingUnspentUtxos() {
99
+ return filteredUtxos.filter(utxo => !neededUtxos.includes(utxo));
100
+ }
101
+
102
+ while (!hasSufficientUtxosForTx()) {
103
+ const [nextUtxo] = getRemainingUnspentUtxos();
104
+ if (!nextUtxo) throw new InsufficientFundsError();
105
+ neededUtxos.push(nextUtxo);
106
+ }
107
+
108
+ const fee = Math.ceil(
109
+ new BigNumber(estimateTransactionSize().txVBytes).multipliedBy(feeRate).toNumber()
110
+ );
111
+
112
+ const changeAmount =
113
+ BigInt(getUtxoTotal(neededUtxos).toString()) - BigInt(amount.amount.toNumber()) - BigInt(fee);
114
+
115
+ const changeUtxos: Output[] =
116
+ changeAmount > BTC_P2WPKH_DUST_AMOUNT
117
+ ? [
118
+ {
119
+ value: changeAmount,
120
+ },
121
+ ]
122
+ : [];
123
+
124
+ const outputs: Output[] = [
125
+ ...recipients.map(({ address, amount }) => ({
126
+ value: BigInt(amount.amount.toNumber()),
127
+ address,
128
+ })),
129
+ ...changeUtxos,
130
+ ];
131
+
132
+ return {
133
+ filteredUtxos,
134
+ inputs: neededUtxos,
135
+ outputs,
136
+ size: estimateTransactionSize().txVBytes,
137
+ fee,
138
+ ...estimateTransactionSize(),
139
+ };
140
+ }
@@ -0,0 +1,64 @@
1
+ import { createMoney } from '@leather.io/utils';
2
+
3
+ import { mockUtxos } from './coin-selection.mocks';
4
+ import { filterUneconomicalUtxos } from './coin-selection.utils';
5
+
6
+ describe(filterUneconomicalUtxos.name, () => {
7
+ const recipients = [
8
+ {
9
+ address: '',
10
+ amount: createMoney(0, 'BTC'),
11
+ },
12
+ ];
13
+
14
+ test('with 1 sat/vb fee', () => {
15
+ const fee = 1;
16
+ const filteredUtxos = filterUneconomicalUtxos({
17
+ utxos: mockUtxos,
18
+ feeRate: fee,
19
+ recipients,
20
+ });
21
+
22
+ expect(filteredUtxos.length).toEqual(9);
23
+ });
24
+
25
+ test('with 10 sat/vb fee', () => {
26
+ const fee = 10;
27
+ const filteredUtxos = filterUneconomicalUtxos({
28
+ recipients,
29
+ utxos: mockUtxos,
30
+ feeRate: fee,
31
+ });
32
+ expect(filteredUtxos.length).toEqual(7);
33
+ });
34
+
35
+ test('with 30 sat/vb fee', () => {
36
+ const fee = 30;
37
+ const filteredUtxos = filterUneconomicalUtxos({
38
+ recipients,
39
+ utxos: mockUtxos,
40
+ feeRate: fee,
41
+ });
42
+ expect(filteredUtxos.length).toEqual(5);
43
+ });
44
+
45
+ test('with 200 sat/vb fee', () => {
46
+ const fee = 200;
47
+ const filteredUtxos = filterUneconomicalUtxos({
48
+ recipients,
49
+ utxos: mockUtxos,
50
+ feeRate: fee,
51
+ });
52
+ expect(filteredUtxos.length).toEqual(3);
53
+ });
54
+
55
+ test('with 400 sat/vb fee', () => {
56
+ const fee = 400;
57
+ const filteredUtxos = filterUneconomicalUtxos({
58
+ recipients,
59
+ utxos: mockUtxos,
60
+ feeRate: fee,
61
+ });
62
+ expect(filteredUtxos.length).toEqual(2);
63
+ });
64
+ });
@@ -0,0 +1,118 @@
1
+ import BigNumber from 'bignumber.js';
2
+ import validate, { AddressInfo, AddressType, getAddressInfo } from 'bitcoin-address-validation';
3
+
4
+ import { BTC_P2WPKH_DUST_AMOUNT } from '@leather.io/constants';
5
+ import { Money } from '@leather.io/models';
6
+
7
+ import { BtcSizeFeeEstimator } from '../btc-size-fee-estimator';
8
+
9
+ export interface TransferRecipient {
10
+ address: string;
11
+ amount: Money;
12
+ }
13
+
14
+ export interface Utxo extends Record<string, any> {
15
+ txid: string;
16
+ value: number;
17
+ }
18
+
19
+ export function getSizeInfo(payload: {
20
+ inputLength: number;
21
+ recipients: TransferRecipient[];
22
+ isSendMax?: boolean;
23
+ }) {
24
+ const { inputLength, recipients, isSendMax } = payload;
25
+
26
+ const validAddressesInfo = recipients
27
+ .map(recipient => validate(recipient.address) && getAddressInfo(recipient.address))
28
+ .filter(Boolean) as AddressInfo[];
29
+
30
+ function getTxOutputsLengthByPaymentType() {
31
+ return validAddressesInfo.reduce(
32
+ (acc, { type }) => {
33
+ acc[type] = (acc[type] || 0) + 1;
34
+ return acc;
35
+ },
36
+ {} as Record<AddressType, number>
37
+ );
38
+ }
39
+
40
+ const outputTypesCount = getTxOutputsLengthByPaymentType();
41
+
42
+ // Add a change address if not sending max (defaults to p2wpkh)
43
+ if (!isSendMax) {
44
+ outputTypesCount[AddressType.p2wpkh] = (outputTypesCount[AddressType.p2wpkh] || 0) + 1;
45
+ }
46
+
47
+ // Prepare the output data map for consumption by the txSizer
48
+ const outputsData = Object.entries(outputTypesCount).reduce(
49
+ (acc, [type, count]) => {
50
+ acc[type + '_output_count'] = count;
51
+ return acc;
52
+ },
53
+ {} as Record<string, number>
54
+ );
55
+
56
+ const txSizer = new BtcSizeFeeEstimator();
57
+ const sizeInfo = txSizer.calcTxSize({
58
+ input_script: 'p2wpkh',
59
+ input_count: inputLength,
60
+ ...outputsData,
61
+ });
62
+
63
+ return sizeInfo;
64
+ }
65
+
66
+ export function getSpendableAmount({
67
+ utxos,
68
+ feeRate,
69
+ recipients,
70
+ }: {
71
+ utxos: Utxo[];
72
+ feeRate: number;
73
+ recipients: TransferRecipient[];
74
+ }) {
75
+ const balance = utxos.map(utxo => utxo.value).reduce((prevVal, curVal) => prevVal + curVal, 0);
76
+
77
+ const size = getSizeInfo({
78
+ inputLength: utxos.length,
79
+ recipients,
80
+ });
81
+ const fee = Math.ceil(size.txVBytes * feeRate);
82
+ const bigNumberBalance = BigNumber(balance);
83
+ return {
84
+ spendableAmount: BigNumber.max(0, bigNumberBalance.minus(fee)),
85
+ fee,
86
+ };
87
+ }
88
+
89
+ // Check if the spendable amount drops when adding a utxo
90
+ export function filterUneconomicalUtxos({
91
+ utxos,
92
+ feeRate,
93
+ recipients,
94
+ }: {
95
+ utxos: Utxo[];
96
+ feeRate: number;
97
+ recipients: TransferRecipient[];
98
+ }) {
99
+ const { spendableAmount: fullSpendableAmount } = getSpendableAmount({
100
+ utxos,
101
+ feeRate,
102
+ recipients,
103
+ });
104
+
105
+ const filteredUtxos = utxos
106
+ .filter(utxo => utxo.value >= BTC_P2WPKH_DUST_AMOUNT)
107
+ .filter(utxo => {
108
+ // Calculate spendableAmount without that utxo
109
+ const { spendableAmount } = getSpendableAmount({
110
+ utxos: utxos.filter(u => u.txid !== utxo.txid),
111
+ feeRate,
112
+ recipients,
113
+ });
114
+ // If fullSpendableAmount is greater, do not use that utxo
115
+ return spendableAmount.toNumber() < fullSpendableAmount.toNumber();
116
+ });
117
+ return filteredUtxos;
118
+ }