@leather.io/bitcoin 0.36.6 → 0.37.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.
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.36.6",
5
+ "version": "0.37.0",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/leather.io/mono/tree/dev/packages/bitcoin",
8
8
  "repository": {
@@ -32,10 +32,10 @@
32
32
  "just-memoize": "2.2.0",
33
33
  "varuint-bitcoin": "1.1.2",
34
34
  "zod": "4.0.17",
35
- "@leather.io/constants": "0.31.0",
36
- "@leather.io/crypto": "1.12.16",
37
- "@leather.io/utils": "0.49.10",
38
- "@leather.io/models": "0.53.0"
35
+ "@leather.io/crypto": "1.12.17",
36
+ "@leather.io/models": "0.54.0",
37
+ "@leather.io/utils": "0.50.0",
38
+ "@leather.io/constants": "0.32.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "prettier": "3.5.1",
@@ -43,10 +43,10 @@
43
43
  "tslib": "2.6.2",
44
44
  "typescript": "5.9.3",
45
45
  "vitest": "2.1.9",
46
- "@leather.io/prettier-config": "0.9.0",
47
46
  "@leather.io/test-config": "0.1.3",
47
+ "@leather.io/rpc": "2.21.11",
48
48
  "@leather.io/tsconfig-config": "0.11.1",
49
- "@leather.io/rpc": "2.21.10"
49
+ "@leather.io/prettier-config": "0.9.0"
50
50
  },
51
51
  "keywords": [
52
52
  "bitcoin",
@@ -3,11 +3,15 @@ import BigNumber from 'bignumber.js';
3
3
  import type { Money } from '@leather.io/models';
4
4
  import { createMoney, satToBtc } from '@leather.io/utils';
5
5
 
6
- import { filterUneconomicalUtxos, getSpendableAmount } from './coin-selection.utils';
6
+ import {
7
+ type InputData,
8
+ filterUneconomicalUtxos,
9
+ getSpendableAmount,
10
+ } from './coin-selection.utils';
7
11
 
8
12
  interface CalculateMaxSpendParams {
9
13
  recipient: string;
10
- utxos: { value: number; txid: string }[];
14
+ utxos: InputData[];
11
15
  feeRate: number;
12
16
  }
13
17
 
@@ -37,4 +37,23 @@ export const mockUtxos = generateMockTransactions([
37
37
  600, 600, 1200, 1200, 10000, 10000, 25000, 40000, 50000000,
38
38
  ]);
39
39
 
40
+ function generateMockTaprootUtxo(value: number): OwnedUtxo {
41
+ return {
42
+ address: 'tb1parwmj7533de3k2fw2kntyqacspvhm67qnjcmpqnnpfvzu05l69nsczdywd',
43
+ path: `m/86'/1'/0'/0/0`,
44
+ keyOrigin: `deadbeef/86'/1'/0'/0/0`,
45
+ txid: sha256(sha256(hexToBytes(generateMockHex()))).toString(),
46
+ value,
47
+ vout: 0,
48
+ };
49
+ }
50
+
51
+ export function generateMockTaprootTransactions(values: number[]) {
52
+ return values.map(val => generateMockTaprootUtxo(val));
53
+ }
54
+
55
+ export const mockTaprootUtxos = generateMockTaprootTransactions([
56
+ 600, 600, 1200, 1200, 10000, 10000, 25000, 40000, 50000000,
57
+ ]);
58
+
40
59
  export const mockAverageFee = generateMockAverageFee(10);
@@ -10,22 +10,23 @@ import {
10
10
  } from '../mocks/mocks';
11
11
  import { isBitcoinAddress } from '../validation/bitcoin-address';
12
12
  import { determineUtxosForSpend, determineUtxosForSpendAll } from './coin-selection';
13
+ import { generateMockTaprootTransactions, generateMockTransactions } from './coin-selection.mocks';
13
14
  import { filterUneconomicalUtxos, getSizeInfo } from './coin-selection.utils';
14
15
 
15
16
  const demoUtxos = [
16
- { value: 8200 },
17
- { value: 8490 },
18
- { value: 8790 },
19
- { value: 19 },
20
- { value: 2000 },
21
- { value: 2340 },
22
- { value: 1230 },
23
- { value: 120 },
24
- { value: 8 },
25
- { value: 1002 },
26
- { value: 1382 },
27
- { value: 1400 },
28
- { value: 909 },
17
+ { txid: 'a1', value: 8200, address: recipientAddress },
18
+ { txid: 'a2', value: 8490, address: recipientAddress },
19
+ { txid: 'a3', value: 8790, address: recipientAddress },
20
+ { txid: 'a4', value: 19, address: recipientAddress },
21
+ { txid: 'a5', value: 2000, address: recipientAddress },
22
+ { txid: 'a6', value: 2340, address: recipientAddress },
23
+ { txid: 'a7', value: 1230, address: recipientAddress },
24
+ { txid: 'a8', value: 120, address: recipientAddress },
25
+ { txid: 'a9', value: 8, address: recipientAddress },
26
+ { txid: 'a10', value: 1002, address: recipientAddress },
27
+ { txid: 'a11', value: 1382, address: recipientAddress },
28
+ { txid: 'a12', value: 1400, address: recipientAddress },
29
+ { txid: 'a13', value: 909, address: recipientAddress },
29
30
  ];
30
31
 
31
32
  function generate10kSpendWithDummyUtxoSet(recipient: string) {
@@ -33,7 +34,7 @@ function generate10kSpendWithDummyUtxoSet(recipient: string) {
33
34
  throw new Error('Invalid Bitcoin address');
34
35
  }
35
36
  return determineUtxosForSpend({
36
- utxos: demoUtxos as any,
37
+ utxos: demoUtxos,
37
38
  feeRate: 20,
38
39
  recipients: [{ address: recipient, amount: createMoney(10_000, 'BTC') }],
39
40
  });
@@ -43,7 +44,7 @@ describe(determineUtxosForSpend.name, () => {
43
44
  describe('Estimated size', () => {
44
45
  test('that Native Segwit, 1 input 2 outputs weighs 140 vBytes', () => {
45
46
  const estimation = determineUtxosForSpend({
46
- utxos: [{ value: 50_000 }] as any[],
47
+ utxos: [{ txid: 'b1', value: 50_000, address: recipientAddress }],
47
48
  recipients: [
48
49
  {
49
50
  address: recipientAddress,
@@ -58,7 +59,10 @@ describe(determineUtxosForSpend.name, () => {
58
59
 
59
60
  test('that Native Segwit, 2 input 2 outputs weighs 200vBytes', () => {
60
61
  const estimation = determineUtxosForSpend({
61
- utxos: [{ value: 50_000 }, { value: 50_000 }] as any[],
62
+ utxos: [
63
+ { txid: 'c1', value: 50_000, address: recipientAddress },
64
+ { txid: 'c2', value: 50_000, address: recipientAddress },
65
+ ],
62
66
  recipients: [
63
67
  {
64
68
  address: recipientAddress,
@@ -74,17 +78,17 @@ describe(determineUtxosForSpend.name, () => {
74
78
  test('that Native Segwit, 10 input 2 outputs weighs 200vBytes', () => {
75
79
  const estimation = determineUtxosForSpend({
76
80
  utxos: [
77
- { value: 20_000 },
78
- { value: 20_000 },
79
- { value: 10_000 },
80
- { value: 10_000 },
81
- { value: 10_000 },
82
- { value: 10_000 },
83
- { value: 10_000 },
84
- { value: 10_000 },
85
- { value: 10_000 },
86
- { value: 10_000 },
87
- ] as any[],
81
+ { txid: 'd1', value: 20_000, address: recipientAddress },
82
+ { txid: 'd2', value: 20_000, address: recipientAddress },
83
+ { txid: 'd3', value: 10_000, address: recipientAddress },
84
+ { txid: 'd4', value: 10_000, address: recipientAddress },
85
+ { txid: 'd5', value: 10_000, address: recipientAddress },
86
+ { txid: 'd6', value: 10_000, address: recipientAddress },
87
+ { txid: 'd7', value: 10_000, address: recipientAddress },
88
+ { txid: 'd8', value: 10_000, address: recipientAddress },
89
+ { txid: 'd9', value: 10_000, address: recipientAddress },
90
+ { txid: 'd10', value: 10_000, address: recipientAddress },
91
+ ],
88
92
  recipients: [
89
93
  {
90
94
  address: recipientAddress,
@@ -147,12 +151,14 @@ describe(determineUtxosForSpend.name, () => {
147
151
  });
148
152
 
149
153
  test('against a random set of generated utxos', () => {
150
- const testData = createNullArrayOfLength(50).map(() => ({
154
+ const testData = createNullArrayOfLength(50).map((_, i) => ({
155
+ txid: `rnd${i}`,
151
156
  value: Math.ceil(Math.random() * 10000),
157
+ address: recipientAddress,
152
158
  }));
153
159
  const amount = 29123n;
154
160
  const result = determineUtxosForSpend({
155
- utxos: testData as any,
161
+ utxos: testData,
156
162
  recipients: [
157
163
  {
158
164
  address: recipientAddress,
@@ -180,7 +186,7 @@ describe(determineUtxosForSpend.name, () => {
180
186
  },
181
187
  ];
182
188
  const filteredUtxos = filterUneconomicalUtxos({
183
- utxos: demoUtxos.sort((a, b) => b.value - a.value) as any,
189
+ utxos: demoUtxos.sort((a, b) => b.value - a.value),
184
190
  feeRate,
185
191
  recipients,
186
192
  });
@@ -188,7 +194,7 @@ describe(determineUtxosForSpend.name, () => {
188
194
  recipients[0].amount = createMoney(amount, 'BTC');
189
195
 
190
196
  const result = determineUtxosForSpend({
191
- utxos: filteredUtxos as any,
197
+ utxos: filteredUtxos,
192
198
  recipients: [
193
199
  {
194
200
  address: recipientAddress,
@@ -203,7 +209,11 @@ describe(determineUtxosForSpend.name, () => {
203
209
  });
204
210
 
205
211
  test('that spending all utxos with sendMax does not result in dust utxos', () => {
206
- const utxos = [{ value: 1000 }, { value: 2000 }, { value: 3000 }];
212
+ const utxos = [
213
+ { txid: '123', value: 1000, address: recipientAddress },
214
+ { txid: '1323', value: 2000, address: recipientAddress },
215
+ { txid: '132355', value: 3000, address: recipientAddress },
216
+ ];
207
217
  const recipients = [
208
218
  {
209
219
  address: recipientAddress,
@@ -211,7 +221,7 @@ describe(determineUtxosForSpend.name, () => {
211
221
  },
212
222
  ];
213
223
  const sizeInfo = getSizeInfo({
214
- inputLength: utxos.length,
224
+ utxos,
215
225
  isSendMax: true,
216
226
  recipients,
217
227
  });
@@ -221,7 +231,7 @@ describe(determineUtxosForSpend.name, () => {
221
231
  recipients[0].amount = createMoney(amount, 'BTC');
222
232
 
223
233
  const result = determineUtxosForSpendAll({
224
- utxos: utxos as any,
234
+ utxos: utxos,
225
235
  recipients,
226
236
  feeRate,
227
237
  });
@@ -231,3 +241,44 @@ describe(determineUtxosForSpend.name, () => {
231
241
  expect(fee).toEqual(735);
232
242
  });
233
243
  });
244
+
245
+ describe('mixed input types', () => {
246
+ test('that spending from P2TR-only inputs produces correct fee estimation', () => {
247
+ const taprootUtxos = generateMockTaprootTransactions([50_000]);
248
+ const estimation = determineUtxosForSpend({
249
+ utxos: taprootUtxos,
250
+ recipients: [
251
+ {
252
+ address: recipientAddress,
253
+ amount: createMoney(40_000, 'BTC'),
254
+ },
255
+ ],
256
+ feeRate: 20,
257
+ });
258
+ expect(estimation.fee.amount.toNumber()).toBeGreaterThan(0);
259
+ expect(estimation.inputs).toHaveLength(1);
260
+ expect(estimation.txVBytes).toBeGreaterThanOrEqual(130);
261
+ expect(estimation.txVBytes).toBeLessThan(155);
262
+ });
263
+
264
+ test('that spending from mixed P2WPKH + P2TR inputs uses correct vBytes', () => {
265
+ const nativeSegwitUtxos = generateMockTransactions([30_000]);
266
+ const taprootUtxos = generateMockTaprootTransactions([30_000]);
267
+ const mixedUtxos = [...nativeSegwitUtxos, ...taprootUtxos];
268
+
269
+ const estimation = determineUtxosForSpend({
270
+ utxos: mixedUtxos,
271
+ recipients: [
272
+ {
273
+ address: recipientAddress,
274
+ amount: createMoney(50_000, 'BTC'),
275
+ },
276
+ ],
277
+ feeRate: 20,
278
+ });
279
+ expect(estimation.inputs).toHaveLength(2);
280
+ expect(estimation.fee.amount.toNumber()).toBeGreaterThan(0);
281
+ expect(estimation.txVBytes).toBeGreaterThan(170);
282
+ expect(estimation.txVBytes).toBeLessThan(210);
283
+ });
284
+ });
@@ -6,7 +6,12 @@ import { Money } from '@leather.io/models';
6
6
  import { createMoney, sumMoney } from '@leather.io/utils';
7
7
 
8
8
  import { BitcoinError } from '../validation/bitcoin-error';
9
- import { filterUneconomicalUtxos, getSizeInfo, getUtxoTotal } from './coin-selection.utils';
9
+ import {
10
+ type InputData,
11
+ filterUneconomicalUtxos,
12
+ getSizeInfo,
13
+ getUtxoTotal,
14
+ } from './coin-selection.utils';
10
15
 
11
16
  export interface CoinSelectionOutput {
12
17
  value: bigint;
@@ -23,7 +28,7 @@ export interface DetermineUtxosForSpendArgs<T> {
23
28
  recipients: CoinSelectionRecipient[];
24
29
  utxos: T[];
25
30
  }
26
- export function determineUtxosForSpendAll<T extends { value: number; txid: string }>({
31
+ export function determineUtxosForSpendAll<T extends InputData>({
27
32
  feeRate,
28
33
  recipients,
29
34
  utxos,
@@ -36,7 +41,7 @@ export function determineUtxosForSpendAll<T extends { value: number; txid: strin
36
41
  if (!filteredUtxos.length) throw new BitcoinError('InsufficientFunds');
37
42
 
38
43
  const sizeInfo = getSizeInfo({
39
- inputLength: filteredUtxos.length,
44
+ utxos: filteredUtxos,
40
45
  isSendMax: true,
41
46
  recipients,
42
47
  });
@@ -57,7 +62,7 @@ export function determineUtxosForSpendAll<T extends { value: number; txid: strin
57
62
  };
58
63
  }
59
64
 
60
- export function determineUtxosForSpend<T extends { value: number; txid: string }>({
65
+ export function determineUtxosForSpend<T extends InputData>({
61
66
  feeRate,
62
67
  recipients,
63
68
  utxos,
@@ -79,7 +84,7 @@ export function determineUtxosForSpend<T extends { value: number; txid: string }
79
84
 
80
85
  function estimateTransactionSize() {
81
86
  return getSizeInfo({
82
- inputLength: neededUtxos.length,
87
+ utxos: neededUtxos,
83
88
  recipients,
84
89
  });
85
90
  }
@@ -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,22 +75,23 @@ 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,
@@ -71,7 +99,7 @@ export function getSpendableAmount<T extends { value: number }>({
71
99
  const balance = utxos.map(utxo => utxo.value).reduce((prevVal, curVal) => prevVal + curVal, 0);
72
100
 
73
101
  const size = getSizeInfo({
74
- inputLength: utxos.length,
102
+ utxos,
75
103
  recipients,
76
104
  });
77
105
  const fee = Math.ceil(size.txVBytes * feeRate);
@@ -83,7 +111,7 @@ export function getSpendableAmount<T extends { value: number }>({
83
111
  }
84
112
 
85
113
  // Check if the spendable amount drops when adding a utxo
86
- export function filterUneconomicalUtxos<T extends { value: number; txid: string }>({
114
+ export function filterUneconomicalUtxos<T extends InputData>({
87
115
  utxos,
88
116
  feeRate,
89
117
  recipients,
@@ -4,7 +4,7 @@ import { AverageBitcoinFeeRates } from '@leather.io/models';
4
4
  import { createMoney } from '@leather.io/utils';
5
5
 
6
6
  import { CoinSelectionRecipient } from '../coin-selection/coin-selection';
7
- import { recipientAddress, taprootAddress } from '../mocks/mocks';
7
+ import { recipientAddress } from '../mocks/mocks';
8
8
  import { getBitcoinFees, getBitcoinTransactionFee } from './bitcoin-fees';
9
9
 
10
10
  describe('getBitcoinTransactionFee', () => {
@@ -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,
@@ -68,7 +68,7 @@ describe('getBitcoinFees', () => {
68
68
  ];
69
69
  const utxos = [
70
70
  {
71
- address: taprootAddress,
71
+ address: recipientAddress,
72
72
  txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6',
73
73
  vout: 0,
74
74
  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
 
@@ -1,4 +1,5 @@
1
1
  import * as btc from '@scure/btc-signer';
2
+ import type { InputData } from 'coin-selection/coin-selection.utils';
2
3
 
3
4
  import {
4
5
  CoinSelectionRecipient,
@@ -24,7 +25,7 @@ export interface GenerateBitcoinUnsignedTransactionArgs<T> {
24
25
  payerLookup(keyOrigin: string): BitcoinNativeSegwitPayer | BitcoinTaprootPayer | undefined;
25
26
  }
26
27
  export function generateBitcoinUnsignedTransaction<
27
- T extends { txid: string; vout: number; value: number; keyOrigin: string },
28
+ T extends InputData & { vout: number; keyOrigin: string },
28
29
  >({
29
30
  feeRate,
30
31
  isSendingMax,