@leather.io/bitcoin 0.36.6 → 0.37.1

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,8 +1,17 @@
1
1
  import { createMoney } from '@leather.io/utils';
2
2
 
3
3
  import { recipientAddress } from '../mocks/mocks';
4
- import { mockUtxos } from './coin-selection.mocks';
5
- import { filterUneconomicalUtxos } from './coin-selection.utils';
4
+ import {
5
+ generateMockTaprootTransactions,
6
+ generateMockTransactions,
7
+ mockTaprootUtxos,
8
+ mockUtxos,
9
+ } from './coin-selection.mocks';
10
+ import {
11
+ countInputsByScriptType,
12
+ filterUneconomicalUtxos,
13
+ getSpendableAmount,
14
+ } from './coin-selection.utils';
6
15
 
7
16
  describe(filterUneconomicalUtxos.name, () => {
8
17
  const recipients = [
@@ -62,4 +71,106 @@ describe(filterUneconomicalUtxos.name, () => {
62
71
  });
63
72
  expect(filteredUtxos.length).toEqual(2);
64
73
  });
74
+
75
+ describe('with taproot utxos', () => {
76
+ test('that every surviving taproot utxo increases spendable amount', () => {
77
+ const feeRate = 200;
78
+ const survivors = filterUneconomicalUtxos({
79
+ utxos: mockTaprootUtxos,
80
+ feeRate,
81
+ recipients,
82
+ });
83
+
84
+ const { spendableAmount: fullAmount } = getSpendableAmount({
85
+ utxos: mockTaprootUtxos,
86
+ feeRate,
87
+ recipients,
88
+ });
89
+
90
+ for (const utxo of survivors) {
91
+ const { spendableAmount: withoutUtxo } = getSpendableAmount({
92
+ utxos: mockTaprootUtxos.filter(u => u.txid !== utxo.txid),
93
+ feeRate,
94
+ recipients,
95
+ });
96
+ expect(fullAmount.toNumber()).toBeGreaterThan(withoutUtxo.toNumber());
97
+ }
98
+ });
99
+
100
+ test('that every filtered taproot utxo does not increase spendable amount', () => {
101
+ const feeRate = 200;
102
+ const survivors = filterUneconomicalUtxos({
103
+ utxos: mockTaprootUtxos,
104
+ feeRate,
105
+ recipients,
106
+ });
107
+ const filtered = mockTaprootUtxos.filter(u => !survivors.some(s => s.txid === u.txid));
108
+
109
+ expect(filtered.length).toBeGreaterThan(0);
110
+
111
+ const { spendableAmount: fullAmount } = getSpendableAmount({
112
+ utxos: mockTaprootUtxos,
113
+ feeRate,
114
+ recipients,
115
+ });
116
+
117
+ for (const utxo of filtered) {
118
+ const { spendableAmount: withoutUtxo } = getSpendableAmount({
119
+ utxos: mockTaprootUtxos.filter(u => u.txid !== utxo.txid),
120
+ feeRate,
121
+ recipients,
122
+ });
123
+ expect(withoutUtxo.toNumber()).toBeGreaterThanOrEqual(fullAmount.toNumber());
124
+ }
125
+ });
126
+
127
+ test('that taproot inputs are cheaper so more survive than native segwit', () => {
128
+ const feeRate = 10;
129
+ const segwitResult = filterUneconomicalUtxos({
130
+ utxos: mockUtxos,
131
+ feeRate,
132
+ recipients,
133
+ });
134
+ const taprootResult = filterUneconomicalUtxos({
135
+ utxos: mockTaprootUtxos,
136
+ feeRate,
137
+ recipients,
138
+ });
139
+
140
+ expect(taprootResult.length).toBeGreaterThan(segwitResult.length);
141
+ });
142
+
143
+ test('that taproot utxo below dust threshold is filtered even at low fee rate', () => {
144
+ const utxosWithDust = generateMockTaprootTransactions([200, 50000000]);
145
+ const result = filterUneconomicalUtxos({
146
+ utxos: utxosWithDust,
147
+ feeRate: 1,
148
+ recipients,
149
+ });
150
+
151
+ expect(result.length).toEqual(1);
152
+ expect(result[0].value).toEqual(50000000);
153
+ });
154
+ });
155
+ });
156
+
157
+ describe(countInputsByScriptType.name, () => {
158
+ test('all native segwit utxos', () => {
159
+ const result = countInputsByScriptType(mockUtxos);
160
+ expect(result).toEqual({ p2wpkh: 9, p2tr: 0 });
161
+ });
162
+
163
+ test('all taproot utxos', () => {
164
+ const result = countInputsByScriptType(mockTaprootUtxos);
165
+ expect(result).toEqual({ p2wpkh: 0, p2tr: 9 });
166
+ });
167
+
168
+ test('mixed utxos', () => {
169
+ const mixed = [
170
+ ...generateMockTransactions([1000, 2000]),
171
+ ...generateMockTaprootTransactions([3000, 4000, 5000]),
172
+ ];
173
+ const result = countInputsByScriptType(mixed);
174
+ expect(result).toEqual({ p2wpkh: 2, p2tr: 3 });
175
+ });
65
176
  });
@@ -5,18 +5,45 @@ import { BTC_P2WPKH_DUST_AMOUNT } from '@leather.io/constants';
5
5
  import { sumNumbers } from '@leather.io/utils';
6
6
 
7
7
  import { BtcSizeFeeEstimator } from '../fees/btc-size-fee-estimator';
8
+ import { inferPaymentTypeFromAddress } from '../utils/bitcoin.utils';
9
+ import { createBitcoinAddress } from '../validation/bitcoin-address';
8
10
  import { CoinSelectionRecipient } from './coin-selection';
9
11
 
10
- export function getUtxoTotal<T extends { value: number }>(utxos: T[]) {
12
+ export interface InputData {
13
+ value: number;
14
+ txid: string;
15
+ address: string;
16
+ }
17
+
18
+ export function getUtxoTotal<T extends InputData>(utxos: T[]) {
11
19
  return sumNumbers(utxos.map(utxo => utxo.value));
12
20
  }
13
21
 
14
- export function getSizeInfo(payload: {
15
- inputLength: number;
22
+ interface CountInputsByScriptTypeResponse {
23
+ p2wpkh: number;
24
+ p2tr: number;
25
+ }
26
+ export function countInputsByScriptType<T extends InputData>(
27
+ utxos: T[]
28
+ ): CountInputsByScriptTypeResponse {
29
+ return utxos.reduce(
30
+ (acc, utxo) => {
31
+ const paymentType = inferPaymentTypeFromAddress(createBitcoinAddress(utxo.address));
32
+ return {
33
+ ...acc,
34
+ [paymentType]: acc[paymentType] + 1,
35
+ };
36
+ },
37
+ { p2tr: 0, p2wpkh: 0 }
38
+ );
39
+ }
40
+
41
+ export function getSizeInfo<T extends InputData>(payload: {
42
+ utxos: T[];
16
43
  recipients: CoinSelectionRecipient[];
17
44
  isSendMax?: boolean;
18
45
  }) {
19
- const { inputLength, recipients, isSendMax } = payload;
46
+ const { utxos, recipients, isSendMax } = payload;
20
47
 
21
48
  const validAddressesInfo = recipients
22
49
  .map(recipient => validate(recipient.address) && getAddressInfo(recipient.address))
@@ -48,31 +75,34 @@ export function getSizeInfo(payload: {
48
75
  {} as Record<string, number>
49
76
  );
50
77
 
78
+ const { p2wpkh, p2tr } = countInputsByScriptType(utxos);
51
79
  const txSizer = new BtcSizeFeeEstimator();
52
- const sizeInfo = txSizer.calcTxSize({
53
- input_script: 'p2wpkh',
54
- input_count: inputLength,
80
+
81
+ return txSizer.calcMixedInputTxSize({
82
+ p2wpkh_input_count: p2wpkh,
83
+ p2tr_input_count: p2tr,
55
84
  ...outputsData,
56
85
  });
57
-
58
- return sizeInfo;
59
86
  }
87
+
60
88
  interface GetSpendableAmountArgs<T> {
61
89
  utxos: T[];
62
90
  feeRate: number;
63
91
  recipients: CoinSelectionRecipient[];
64
92
  isSendMax?: boolean;
65
93
  }
66
- export function getSpendableAmount<T extends { value: number }>({
94
+ export function getSpendableAmount<T extends InputData>({
67
95
  utxos,
68
96
  feeRate,
69
97
  recipients,
98
+ isSendMax,
70
99
  }: GetSpendableAmountArgs<T>) {
71
100
  const balance = utxos.map(utxo => utxo.value).reduce((prevVal, curVal) => prevVal + curVal, 0);
72
101
 
73
102
  const size = getSizeInfo({
74
- inputLength: utxos.length,
103
+ utxos,
75
104
  recipients,
105
+ isSendMax,
76
106
  });
77
107
  const fee = Math.ceil(size.txVBytes * feeRate);
78
108
  const bigNumberBalance = BigNumber(balance);
@@ -83,7 +113,7 @@ export function getSpendableAmount<T extends { value: number }>({
83
113
  }
84
114
 
85
115
  // Check if the spendable amount drops when adding a utxo
86
- export function filterUneconomicalUtxos<T extends { value: number; txid: string }>({
116
+ export function filterUneconomicalUtxos<T extends InputData>({
87
117
  utxos,
88
118
  feeRate,
89
119
  recipients,
@@ -13,7 +13,7 @@ describe('getBitcoinTransactionFee', () => {
13
13
  recipients: [{ address: recipientAddress, amount: createMoney(1000, 'BTC') }],
14
14
  utxos: [
15
15
  {
16
- address: taprootAddress,
16
+ address: recipientAddress,
17
17
  txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6',
18
18
  vout: 0,
19
19
  value: 2000,
@@ -32,7 +32,7 @@ describe('getBitcoinTransactionFee', () => {
32
32
  recipients: [{ address: recipientAddress, amount: createMoney(2000, 'BTC') }],
33
33
  utxos: [
34
34
  {
35
- address: taprootAddress,
35
+ address: recipientAddress,
36
36
  txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6',
37
37
  vout: 0,
38
38
  value: 2000,
@@ -56,6 +56,74 @@ describe('getBitcoinTransactionFee', () => {
56
56
  });
57
57
  });
58
58
 
59
+ describe('getBitcoinTransactionFee with taproot', () => {
60
+ const mockTxid = '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6';
61
+ const defaultRecipients = [{ address: recipientAddress, amount: createMoney(1000, 'BTC') }];
62
+
63
+ it('should return a lower fee for a taproot UTXO than native segwit', () => {
64
+ const segwitFee = getBitcoinTransactionFee({
65
+ recipients: defaultRecipients,
66
+ utxos: [{ address: recipientAddress, txid: mockTxid, value: 2000 }],
67
+ feeRate: 1,
68
+ });
69
+ const taprootFee = getBitcoinTransactionFee({
70
+ recipients: defaultRecipients,
71
+ utxos: [{ address: taprootAddress, txid: mockTxid, value: 2000 }],
72
+ feeRate: 1,
73
+ });
74
+
75
+ expect(segwitFee).not.toBeNull();
76
+ expect(taprootFee).not.toBeNull();
77
+ expect(taprootFee!.amount.toNumber()).toBeLessThan(segwitFee!.amount.toNumber());
78
+ });
79
+
80
+ it('should return a lower fee for taproot max send than native segwit', () => {
81
+ const maxSendRecipients = [{ address: recipientAddress, amount: createMoney(2000, 'BTC') }];
82
+
83
+ const segwitFee = getBitcoinTransactionFee({
84
+ isSendingMax: true,
85
+ recipients: maxSendRecipients,
86
+ utxos: [{ address: recipientAddress, txid: mockTxid, value: 2000 }],
87
+ feeRate: 2,
88
+ });
89
+ const taprootFee = getBitcoinTransactionFee({
90
+ isSendingMax: true,
91
+ recipients: maxSendRecipients,
92
+ utxos: [{ address: taprootAddress, txid: mockTxid, value: 2000 }],
93
+ feeRate: 2,
94
+ });
95
+
96
+ expect(segwitFee).not.toBeNull();
97
+ expect(taprootFee).not.toBeNull();
98
+ expect(taprootFee!.amount.toNumber()).toBeLessThan(segwitFee!.amount.toNumber());
99
+ });
100
+
101
+ it('should return a lower fee for mixed UTXOs than all native segwit', () => {
102
+ const mixedRecipients = [{ address: recipientAddress, amount: createMoney(15000, 'BTC') }];
103
+
104
+ const allSegwitFee = getBitcoinTransactionFee({
105
+ recipients: mixedRecipients,
106
+ utxos: [
107
+ { address: recipientAddress, txid: mockTxid, value: 10000 },
108
+ { address: recipientAddress, txid: mockTxid, value: 10000 },
109
+ ],
110
+ feeRate: 1,
111
+ });
112
+ const mixedFee = getBitcoinTransactionFee({
113
+ recipients: mixedRecipients,
114
+ utxos: [
115
+ { address: recipientAddress, txid: mockTxid, value: 10000 },
116
+ { address: taprootAddress, txid: mockTxid, value: 10000 },
117
+ ],
118
+ feeRate: 1,
119
+ });
120
+
121
+ expect(allSegwitFee).not.toBeNull();
122
+ expect(mixedFee).not.toBeNull();
123
+ expect(mixedFee!.amount.toNumber()).toBeLessThan(allSegwitFee!.amount.toNumber());
124
+ });
125
+ });
126
+
59
127
  describe('getBitcoinFees', () => {
60
128
  it('should return the fees for different fee rates', () => {
61
129
  const feeRates: AverageBitcoinFeeRates = {
@@ -68,7 +136,7 @@ describe('getBitcoinFees', () => {
68
136
  ];
69
137
  const utxos = [
70
138
  {
71
- address: taprootAddress,
139
+ address: recipientAddress,
72
140
  txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6',
73
141
  vout: 0,
74
142
  value: 2000,
@@ -1,3 +1,5 @@
1
+ import type { InputData } from 'coin-selection/coin-selection.utils';
2
+
1
3
  import { AverageBitcoinFeeRates, Money } from '@leather.io/models';
2
4
 
3
5
  import {
@@ -7,7 +9,7 @@ import {
7
9
  determineUtxosForSpendAll,
8
10
  } from '../coin-selection/coin-selection';
9
11
 
10
- type GetBitcoinTransactionFeeArgs = DetermineUtxosForSpendArgs<{ value: number; txid: string }> & {
12
+ type GetBitcoinTransactionFeeArgs = DetermineUtxosForSpendArgs<InputData> & {
11
13
  isSendingMax?: boolean;
12
14
  };
13
15
 
@@ -33,7 +35,7 @@ export interface GetBitcoinFeesArgs {
33
35
  feeRates: AverageBitcoinFeeRates;
34
36
  isSendingMax?: boolean;
35
37
  recipients: CoinSelectionRecipient[];
36
- utxos: { value: number; txid: string }[];
38
+ utxos: InputData[];
37
39
  }
38
40
  export function getBitcoinFees({ feeRates, isSendingMax, recipients, utxos }: GetBitcoinFeesArgs) {
39
41
  const defaultArgs = {
@@ -110,4 +110,78 @@ describe('BtcSizeFeeEstimator', () => {
110
110
  expect(feeRange).toBe('900 - 1100');
111
111
  });
112
112
  });
113
+
114
+ describe('calcMixedInputTxSize', () => {
115
+ it('should calculate size for p2wpkh-only inputs', () => {
116
+ const { txVBytes, txBytes, txWeight } = estimator.calcMixedInputTxSize({
117
+ p2wpkh_input_count: 2,
118
+ p2tr_input_count: 0,
119
+ p2wpkh_output_count: 1,
120
+ });
121
+
122
+ // overhead: 10.75, inputs: 2 * 67.75 = 135.5, outputs: 1 * 31 = 31
123
+ expect(txVBytes).toEqual(177.25);
124
+ // extraRawBytes: 2.25, witnessBytes: 2 * 107 = 214
125
+ expect(txBytes).toEqual(393.5);
126
+ expect(txWeight).toEqual(709);
127
+ });
128
+
129
+ it('should calculate size for p2tr-only inputs', () => {
130
+ const { txVBytes, txBytes, txWeight } = estimator.calcMixedInputTxSize({
131
+ p2wpkh_input_count: 0,
132
+ p2tr_input_count: 2,
133
+ p2tr_output_count: 1,
134
+ });
135
+
136
+ // overhead: 10.75, inputs: 2 * 57.25 = 114.5, outputs: 1 * 43 = 43
137
+ expect(txVBytes).toEqual(168.25);
138
+ // extraRawBytes: 2.25, witnessBytes: 2 * 65 = 130
139
+ expect(txBytes).toEqual(300.5);
140
+ expect(txWeight).toEqual(673);
141
+ });
142
+
143
+ it('should calculate size for mixed p2wpkh and p2tr inputs', () => {
144
+ const { txVBytes, txBytes, txWeight } = estimator.calcMixedInputTxSize({
145
+ p2wpkh_input_count: 1,
146
+ p2tr_input_count: 1,
147
+ p2wpkh_output_count: 1,
148
+ p2tr_output_count: 1,
149
+ });
150
+
151
+ // overhead: 10.75, inputs: 1 * 67.75 + 1 * 57.25 = 125, outputs: 31 + 43 = 74
152
+ expect(txVBytes).toEqual(209.75);
153
+ // extraRawBytes: 2.25, witnessBytes: 107 + 65 = 172
154
+ expect(txBytes).toEqual(384);
155
+ expect(txWeight).toEqual(839);
156
+ });
157
+
158
+ it('should default optional output counts to zero', () => {
159
+ const { txVBytes } = estimator.calcMixedInputTxSize({
160
+ p2wpkh_input_count: 1,
161
+ p2tr_input_count: 0,
162
+ });
163
+
164
+ // overhead: 10.75, inputs: 1 * 67.75, outputs: 0
165
+ expect(txVBytes).toEqual(78.5);
166
+ });
167
+
168
+ it('should calculate size with multiple output types', () => {
169
+ const { txVBytes, txBytes, txWeight } = estimator.calcMixedInputTxSize({
170
+ p2wpkh_input_count: 2,
171
+ p2tr_input_count: 1,
172
+ p2pkh_output_count: 1,
173
+ p2sh_output_count: 1,
174
+ p2wpkh_output_count: 1,
175
+ p2tr_output_count: 1,
176
+ });
177
+
178
+ // overhead: getTxOverheadVBytes('p2wpkh', 3, 4) = 4 + 1 + 1 + 4 + 0.75 = 10.75
179
+ // inputs: 2 * 67.75 + 1 * 57.25 = 192.75
180
+ // outputs: 34 + 32 + 31 + 43 = 140
181
+ expect(txVBytes).toEqual(343.5);
182
+ // extraRawBytes: 2.25, witnessBytes: 2 * 107 + 1 * 65 = 279
183
+ expect(txBytes).toEqual(624.75);
184
+ expect(txWeight).toEqual(1374);
185
+ });
186
+ });
113
187
  });
@@ -26,6 +26,26 @@ export interface TxSizerParams {
26
26
  p2tr_output_count: number;
27
27
  }
28
28
 
29
+ export interface MixedInputTxSizerParams {
30
+ p2wpkh_input_count: number;
31
+ p2tr_input_count: number;
32
+ p2pkh_input_count?: number;
33
+ p2sh_input_count?: number;
34
+ p2sh_p2wpkh_input_count?: number;
35
+ p2sh_p2wsh_input_count?: number;
36
+ p2wsh_input_count?: number;
37
+ input_m?: number;
38
+ input_n?: number;
39
+ p2pkh_output_count?: number;
40
+ p2sh_output_count?: number;
41
+ p2sh_p2wpkh_output_count?: number;
42
+ p2sh_p2wsh_output_count?: number;
43
+ p2wpkh_output_count?: number;
44
+ p2wsh_output_count?: number;
45
+ p2tr_output_count?: number;
46
+ _forceSegwit?: boolean;
47
+ }
48
+
29
49
  export class BtcSizeFeeEstimator {
30
50
  P2PKH_IN_SIZE = 148;
31
51
  P2PKH_OUT_SIZE = 34;
@@ -310,6 +330,50 @@ export class BtcSizeFeeEstimator {
310
330
  return { txVBytes, txBytes, txWeight };
311
331
  }
312
332
 
333
+ calcMixedInputTxSize(opts: MixedInputTxSizerParams) {
334
+ const { p2wpkh_input_count, p2tr_input_count } = opts;
335
+ const totalInputCount = p2wpkh_input_count + p2tr_input_count;
336
+
337
+ const p2pkh_output_count = opts.p2pkh_output_count ?? 0;
338
+ const p2sh_output_count = opts.p2sh_output_count ?? 0;
339
+ const p2sh_p2wpkh_output_count = opts.p2sh_p2wpkh_output_count ?? 0;
340
+ const p2sh_p2wsh_output_count = opts.p2sh_p2wsh_output_count ?? 0;
341
+ const p2wpkh_output_count = opts.p2wpkh_output_count ?? 0;
342
+ const p2wsh_output_count = opts.p2wsh_output_count ?? 0;
343
+ const p2tr_output_count = opts.p2tr_output_count ?? 0;
344
+
345
+ const output_count =
346
+ p2pkh_output_count +
347
+ p2sh_output_count +
348
+ p2sh_p2wpkh_output_count +
349
+ p2sh_p2wsh_output_count +
350
+ p2wpkh_output_count +
351
+ p2wsh_output_count +
352
+ p2tr_output_count;
353
+
354
+ const inputVBytes =
355
+ p2wpkh_input_count * this.P2WPKH_IN_SIZE + p2tr_input_count * this.P2TR_IN_SIZE;
356
+
357
+ const inputWitnessBytes = p2wpkh_input_count * 107 + p2tr_input_count * 65;
358
+
359
+ const txVBytes =
360
+ this.getTxOverheadVBytes('p2wpkh', totalInputCount, output_count) +
361
+ inputVBytes +
362
+ this.P2PKH_OUT_SIZE * p2pkh_output_count +
363
+ this.P2SH_OUT_SIZE * p2sh_output_count +
364
+ this.P2SH_P2WPKH_OUT_SIZE * p2sh_p2wpkh_output_count +
365
+ this.P2SH_P2WSH_OUT_SIZE * p2sh_p2wsh_output_count +
366
+ this.P2WPKH_OUT_SIZE * p2wpkh_output_count +
367
+ this.P2WSH_OUT_SIZE * p2wsh_output_count +
368
+ this.P2TR_OUT_SIZE * p2tr_output_count;
369
+
370
+ const txBytes =
371
+ this.getTxOverheadExtraRawBytes('p2wpkh', totalInputCount) + txVBytes + inputWitnessBytes;
372
+ const txWeight = txVBytes * 4;
373
+
374
+ return { txVBytes, txBytes, txWeight };
375
+ }
376
+
313
377
  estimateFee(vbyte: number, satVb: number) {
314
378
  if (isNaN(vbyte) || isNaN(satVb)) {
315
379
  throw new Error('Parameters should be numbers');
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from './coin-selection/coin-selection';
7
7
  export * from './coin-selection/coin-selection.utils';
8
8
 
9
9
  export * from './fees/bitcoin-fees';
10
+ export * from './fees/btc-size-fee-estimator';
10
11
 
11
12
  export * from './mocks/mocks';
12
13