@leather.io/bitcoin 0.37.0 → 0.37.2

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.
@@ -3,9 +3,10 @@ import * as btc from '@scure/btc-signer';
3
3
  import { describe, expect, it } from 'vitest';
4
4
 
5
5
  import { OwnedUtxo } from '@leather.io/models';
6
- import { createMoney } from '@leather.io/utils';
6
+ import { createMoney, hexToNumber } from '@leather.io/utils';
7
7
 
8
8
  import { getBtcSignerLibNetworkConfigByMode } from '../utils/bitcoin.network';
9
+ import { ecdsaPublicKeyToSchnorr } from '../utils/bitcoin.utils';
9
10
  import { createBitcoinAddress } from '../validation/bitcoin-address';
10
11
  import {
11
12
  GenerateBitcoinUnsignedTransactionArgs,
@@ -14,6 +15,7 @@ import {
14
15
 
15
16
  const publicKey = hex.decode('030000000000000000000000000000000000000000000000000000000000000001');
16
17
  const payment = btc.p2wpkh(publicKey, btc.TEST_NETWORK);
18
+ const taprootPayment = btc.p2tr(ecdsaPublicKeyToSchnorr(publicKey), undefined, btc.TEST_NETWORK);
17
19
 
18
20
  const mockResult = {
19
21
  inputs: [
@@ -59,11 +61,11 @@ describe(generateBitcoinUnsignedTransaction.name, () => {
59
61
  value: 200000,
60
62
  },
61
63
  ],
62
- payerLookup() {
64
+ payerLookup(keyOrigin: string) {
63
65
  return {
64
66
  paymentType: 'p2wpkh',
65
67
  address: createBitcoinAddress(payment.address!),
66
- keyOrigin: "deadbeef/84'/1'/0'/0/0",
68
+ keyOrigin,
67
69
  masterKeyFingerprint: 'deadbeef',
68
70
  network: 'testnet',
69
71
  payment: {
@@ -77,21 +79,36 @@ describe(generateBitcoinUnsignedTransaction.name, () => {
77
79
 
78
80
  it('should generate an unsigned transaction with correct inputs and outputs', () => {
79
81
  const result = generateBitcoinUnsignedTransaction(mockArgs);
80
- if (result) {
81
- expect(result.inputs).toEqual(mockResult.inputs);
82
- expect(result.fee).toEqual(mockResult.fee);
83
- expect(result.hex).toBeDefined();
84
- expect(result.psbt).toBeDefined();
85
- }
82
+
83
+ expect(result.inputs).toEqual(mockResult.inputs);
84
+ expect(result.fee).toEqual(mockResult.fee);
85
+
86
+ const psbtMagic = [0x70, 0x73, 0x62, 0x74, 0xff];
87
+ expect(Array.from(result.psbt.slice(0, 5))).toEqual(psbtMagic);
88
+ expect(result.hex.length).toBeGreaterThan(0);
89
+
90
+ const psbtInput = result.tx.getInput(0);
91
+ expect(psbtInput.bip32Derivation).toHaveLength(1);
92
+ expect(psbtInput.bip32Derivation![0][0]).toEqual(publicKey);
93
+ expect(psbtInput.bip32Derivation![0][1].fingerprint).toBe(hexToNumber('deadbeef'));
94
+ expect(psbtInput.bip32Derivation![0][1].path).toEqual(btc.bip32Path("m/84'/1'/0'/0/1"));
95
+ expect(psbtInput.tapInternalKey).toBeUndefined();
96
+ expect(psbtInput.witnessUtxo!.script).toEqual(payment.script);
97
+ expect(psbtInput.witnessUtxo!.amount).toBe(BigInt(200000));
86
98
  });
87
99
 
88
100
  it('should add change address to output correctly', () => {
89
101
  const result = generateBitcoinUnsignedTransaction(mockArgs);
102
+ const fee = BigInt(result.fee.amount.toNumber());
103
+ const inputTotal = BigInt(200000);
90
104
 
91
- if (result) {
92
- expect(result.tx.outputsLength).toBe(2);
93
- expect(result.tx.getOutput(1).script).toBeDefined();
94
- }
105
+ expect(result.tx.outputsLength).toBe(2);
106
+ expect(result.tx.getOutput(0).amount).toBe(BigInt(150000));
107
+ expect(result.tx.getOutput(1).script).toBeDefined();
108
+
109
+ const recipientAmount = result.tx.getOutput(0).amount!;
110
+ const changeAmount = result.tx.getOutput(1).amount!;
111
+ expect(recipientAmount + changeAmount + fee).toBe(inputTotal);
95
112
  });
96
113
 
97
114
  it('should throw an error if inputs are empty', () => {
@@ -104,4 +121,325 @@ describe(generateBitcoinUnsignedTransaction.name, () => {
104
121
  'InsufficientFunds'
105
122
  );
106
123
  });
124
+
125
+ it('should not produce change output when change would be below dust threshold', () => {
126
+ const dustArgs: GenerateBitcoinUnsignedTransactionArgs<OwnedUtxo> = {
127
+ ...mockArgs,
128
+ utxos: [
129
+ {
130
+ address: payment.address!,
131
+ path: "m/84'/1'/0'/0/0",
132
+ keyOrigin: "deadbeef/84'/1'/0'/0/0",
133
+ txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6',
134
+ vout: 0,
135
+ value: 150400,
136
+ },
137
+ ],
138
+ recipients: [
139
+ {
140
+ address: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa',
141
+ amount: createMoney(150000, 'BTC'),
142
+ },
143
+ ],
144
+ };
145
+
146
+ const result = generateBitcoinUnsignedTransaction(dustArgs);
147
+ const fee = BigInt(result.fee.amount.toNumber());
148
+
149
+ expect(result.tx.outputsLength).toBe(1);
150
+ expect(result.tx.getOutput(0).amount).toBe(BigInt(150000));
151
+
152
+ // Change (150400 - 150000 - fee) is below dust, so no change output.
153
+ // The sub-dust remainder becomes an implicit miner tip.
154
+ const remainder = BigInt(150400) - BigInt(150000) - fee;
155
+ expect(remainder).toBeGreaterThan(0n);
156
+ expect(remainder).toBeLessThan(294n);
157
+ });
158
+
159
+ it('should build correct transaction with isSendingMax for segwit inputs', () => {
160
+ const sendMaxArgs: GenerateBitcoinUnsignedTransactionArgs<OwnedUtxo> = {
161
+ ...mockArgs,
162
+ isSendingMax: true,
163
+ };
164
+
165
+ const result = generateBitcoinUnsignedTransaction(sendMaxArgs);
166
+
167
+ expect(result.tx.outputsLength).toBe(1);
168
+ expect(result.tx.getOutput(0).amount).toBe(BigInt(150000));
169
+ expect(result.fee.amount.isGreaterThan(0)).toBe(true);
170
+ expect(result.inputs.length).toBeGreaterThanOrEqual(1);
171
+
172
+ const psbtInput = result.tx.getInput(0);
173
+ expect(psbtInput.bip32Derivation).toHaveLength(1);
174
+ expect(psbtInput.tapInternalKey).toBeUndefined();
175
+ });
176
+
177
+ describe('taproot inputs', () => {
178
+ const taprootMockArgs: GenerateBitcoinUnsignedTransactionArgs<OwnedUtxo> = {
179
+ feeRate: 1,
180
+ isSendingMax: false,
181
+ network: getBtcSignerLibNetworkConfigByMode('testnet'),
182
+ changeAddress: createBitcoinAddress(payment.address!),
183
+ recipients: [
184
+ {
185
+ address: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa',
186
+ amount: createMoney(150000, 'BTC'),
187
+ },
188
+ ],
189
+ utxos: [
190
+ {
191
+ address: taprootPayment.address!,
192
+ path: "m/86'/1'/0'/0/0",
193
+ keyOrigin: "deadbeef/86'/1'/0'/0/0",
194
+ txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6',
195
+ vout: 0,
196
+ value: 100000,
197
+ },
198
+ {
199
+ address: taprootPayment.address!,
200
+ path: "m/86'/1'/0'/0/1",
201
+ keyOrigin: "deadbeef/86'/1'/0'/0/1",
202
+ txid: 'c715ea469c8d794f6dd7e0043148631f69d411c428ef0ab2b04e4528ffe8319f',
203
+ vout: 1,
204
+ value: 200000,
205
+ },
206
+ ],
207
+ payerLookup(keyOrigin: string) {
208
+ return {
209
+ paymentType: 'p2tr' as const,
210
+ address: createBitcoinAddress(taprootPayment.address!),
211
+ keyOrigin,
212
+ masterKeyFingerprint: 'deadbeef',
213
+ network: 'testnet',
214
+ payment: taprootPayment,
215
+ publicKey,
216
+ };
217
+ },
218
+ };
219
+
220
+ it('should set tapInternalKey and tapBip32Derivation for p2tr inputs', () => {
221
+ const result = generateBitcoinUnsignedTransaction(taprootMockArgs);
222
+
223
+ expect(result.hex).toBeDefined();
224
+ expect(result.psbt).toBeDefined();
225
+ expect(result.inputs.length).toBe(1);
226
+
227
+ const psbtInput = result.tx.getInput(0);
228
+
229
+ expect(hex.encode(psbtInput.txid!)).toBe(
230
+ 'c715ea469c8d794f6dd7e0043148631f69d411c428ef0ab2b04e4528ffe8319f'
231
+ );
232
+ expect(psbtInput.index).toBe(1);
233
+
234
+ expect(psbtInput.tapInternalKey).toEqual(taprootPayment.tapInternalKey);
235
+ expect(psbtInput.tapBip32Derivation).toHaveLength(1);
236
+ expect(psbtInput.tapBip32Derivation![0][0]).toEqual(ecdsaPublicKeyToSchnorr(publicKey));
237
+ expect(psbtInput.tapBip32Derivation![0][1].hashes).toEqual([]);
238
+ expect(psbtInput.tapBip32Derivation![0][1].der.fingerprint).toBe(hexToNumber('deadbeef'));
239
+ expect(psbtInput.tapBip32Derivation![0][1].der.path).toEqual(
240
+ btc.bip32Path("m/86'/1'/0'/0/1")
241
+ );
242
+ expect(psbtInput.bip32Derivation).toBeUndefined();
243
+ expect(psbtInput.witnessUtxo!.script).toEqual(taprootPayment.script);
244
+ expect(psbtInput.witnessUtxo!.amount).toBe(BigInt(200000));
245
+
246
+ expect(result.tx.outputsLength).toBe(2);
247
+ expect(result.tx.getOutput(0).amount).toBe(BigInt(150000));
248
+ });
249
+
250
+ it('should handle mixed p2wpkh and p2tr inputs', () => {
251
+ const mixedArgs: GenerateBitcoinUnsignedTransactionArgs<OwnedUtxo> = {
252
+ feeRate: 1,
253
+ isSendingMax: false,
254
+ network: getBtcSignerLibNetworkConfigByMode('testnet'),
255
+ changeAddress: createBitcoinAddress(payment.address!),
256
+ recipients: [
257
+ {
258
+ address: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa',
259
+ amount: createMoney(350000, 'BTC'),
260
+ },
261
+ ],
262
+ utxos: [
263
+ {
264
+ address: payment.address!,
265
+ path: "m/84'/1'/0'/0/0",
266
+ keyOrigin: "deadbeef/84'/1'/0'/0/0",
267
+ txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6',
268
+ vout: 0,
269
+ value: 300000,
270
+ },
271
+ {
272
+ address: taprootPayment.address!,
273
+ path: "m/86'/1'/0'/0/0",
274
+ keyOrigin: "deadbeef/86'/1'/0'/0/0",
275
+ txid: 'c715ea469c8d794f6dd7e0043148631f69d411c428ef0ab2b04e4528ffe8319f',
276
+ vout: 1,
277
+ value: 200000,
278
+ },
279
+ ],
280
+ payerLookup(keyOrigin: string) {
281
+ if (keyOrigin.includes("86'")) {
282
+ return {
283
+ paymentType: 'p2tr' as const,
284
+ address: createBitcoinAddress(taprootPayment.address!),
285
+ keyOrigin,
286
+ masterKeyFingerprint: 'deadbeef',
287
+ network: 'testnet',
288
+ payment: taprootPayment,
289
+ publicKey,
290
+ };
291
+ }
292
+ return {
293
+ paymentType: 'p2wpkh' as const,
294
+ address: createBitcoinAddress(payment.address!),
295
+ keyOrigin,
296
+ masterKeyFingerprint: 'deadbeef',
297
+ network: 'testnet',
298
+ payment: { script: payment.script, type: 'p2wpkh' },
299
+ publicKey,
300
+ };
301
+ },
302
+ };
303
+
304
+ const result = generateBitcoinUnsignedTransaction(mixedArgs);
305
+
306
+ expect(result.inputs.length).toBe(2);
307
+ expect(result.fee.amount.isGreaterThan(0)).toBe(true);
308
+
309
+ const segwitInput = result.tx.getInput(0);
310
+ expect(segwitInput.index).toBe(0);
311
+ expect(segwitInput.bip32Derivation).toBeDefined();
312
+ expect(segwitInput.tapInternalKey).toBeUndefined();
313
+ expect(segwitInput.witnessUtxo!.script).toEqual(payment.script);
314
+ expect(segwitInput.witnessUtxo!.amount).toBe(BigInt(300000));
315
+
316
+ const taprootInput = result.tx.getInput(1);
317
+ expect(taprootInput.index).toBe(1);
318
+ expect(taprootInput.tapBip32Derivation).toBeDefined();
319
+ expect(taprootInput.tapInternalKey).toEqual(taprootPayment.tapInternalKey);
320
+ expect(taprootInput.bip32Derivation).toBeUndefined();
321
+ expect(taprootInput.witnessUtxo!.script).toEqual(taprootPayment.script);
322
+ expect(taprootInput.witnessUtxo!.amount).toBe(BigInt(200000));
323
+ });
324
+
325
+ it('should use native segwit change address even with all taproot inputs', () => {
326
+ const result = generateBitcoinUnsignedTransaction(taprootMockArgs);
327
+
328
+ expect(result.tx.outputsLength).toBe(2);
329
+
330
+ const changeScript = result.tx.getOutput(1).script!;
331
+ expect(changeScript[0]).toBe(0x00);
332
+ expect(changeScript[1]).toBe(0x14);
333
+ expect(changeScript.length).toBe(22);
334
+ });
335
+
336
+ it('should build psbt with isSendingMax for taproot inputs', () => {
337
+ const sendMaxArgs: GenerateBitcoinUnsignedTransactionArgs<OwnedUtxo> = {
338
+ feeRate: 1,
339
+ isSendingMax: true,
340
+ network: getBtcSignerLibNetworkConfigByMode('testnet'),
341
+ changeAddress: createBitcoinAddress(payment.address!),
342
+ recipients: [
343
+ {
344
+ address: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa',
345
+ amount: createMoney(1, 'BTC'),
346
+ },
347
+ ],
348
+ utxos: [
349
+ {
350
+ address: taprootPayment.address!,
351
+ path: "m/86'/1'/0'/0/0",
352
+ keyOrigin: "deadbeef/86'/1'/0'/0/0",
353
+ txid: 'c715ea469c8d794f6dd7e0043148631f69d411c428ef0ab2b04e4528ffe8319f',
354
+ vout: 0,
355
+ value: 200000,
356
+ },
357
+ ],
358
+ payerLookup(keyOrigin: string) {
359
+ return {
360
+ paymentType: 'p2tr' as const,
361
+ address: createBitcoinAddress(taprootPayment.address!),
362
+ keyOrigin,
363
+ masterKeyFingerprint: 'deadbeef',
364
+ network: 'testnet',
365
+ payment: taprootPayment,
366
+ publicKey,
367
+ };
368
+ },
369
+ };
370
+
371
+ const result = generateBitcoinUnsignedTransaction(sendMaxArgs);
372
+
373
+ expect(result.inputs.length).toBe(1);
374
+ expect(result.tx.outputsLength).toBe(1);
375
+
376
+ expect(result.tx.getOutput(0).amount).toBe(1n);
377
+ expect(result.fee.amount.isGreaterThan(0)).toBe(true);
378
+
379
+ const psbtInput = result.tx.getInput(0);
380
+ expect(psbtInput.tapInternalKey).toEqual(taprootPayment.tapInternalKey);
381
+ expect(psbtInput.tapBip32Derivation).toHaveLength(1);
382
+ });
383
+
384
+ it('should skip inputs when payerLookup returns undefined', () => {
385
+ const undefinedPayerArgs: GenerateBitcoinUnsignedTransactionArgs<OwnedUtxo> = {
386
+ feeRate: 1,
387
+ isSendingMax: false,
388
+ network: getBtcSignerLibNetworkConfigByMode('testnet'),
389
+ changeAddress: createBitcoinAddress(payment.address!),
390
+ recipients: [
391
+ {
392
+ address: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa',
393
+ amount: createMoney(250000, 'BTC'),
394
+ },
395
+ ],
396
+ utxos: [
397
+ {
398
+ address: taprootPayment.address!,
399
+ path: "m/86'/1'/0'/0/0",
400
+ keyOrigin: "deadbeef/86'/1'/0'/0/0",
401
+ txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6',
402
+ vout: 0,
403
+ value: 200000,
404
+ },
405
+ {
406
+ address: taprootPayment.address!,
407
+ path: "m/86'/1'/0'/0/1",
408
+ keyOrigin: "deadbeef/86'/1'/0'/0/1",
409
+ txid: 'c715ea469c8d794f6dd7e0043148631f69d411c428ef0ab2b04e4528ffe8319f',
410
+ vout: 1,
411
+ value: 100000,
412
+ },
413
+ ],
414
+ payerLookup(keyOrigin: string) {
415
+ if (keyOrigin === "deadbeef/86'/1'/0'/0/1") return undefined;
416
+ return {
417
+ paymentType: 'p2tr' as const,
418
+ address: createBitcoinAddress(taprootPayment.address!),
419
+ keyOrigin,
420
+ masterKeyFingerprint: 'deadbeef',
421
+ network: 'testnet',
422
+ payment: taprootPayment,
423
+ publicKey,
424
+ };
425
+ },
426
+ };
427
+
428
+ const result = generateBitcoinUnsignedTransaction(undefinedPayerArgs);
429
+
430
+ expect(result.inputs.length).toBe(2);
431
+ expect(result.tx.inputsLength).toBe(1);
432
+ expect(result.tx.getInput(0).tapInternalKey).toEqual(taprootPayment.tapInternalKey);
433
+
434
+ // Known limitation: coin selection computed fee for 2 inputs but only 1
435
+ // was added to the tx, producing an unbroadcastable transaction where
436
+ // total output value exceeds the single input's value
437
+ const singleInputValue = BigInt(200000);
438
+ let totalOutputValue = 0n;
439
+ for (let i = 0; i < result.tx.outputsLength; i++) {
440
+ totalOutputValue += result.tx.getOutput(i).amount!;
441
+ }
442
+ expect(totalOutputValue).toBeGreaterThan(singleInputValue);
443
+ });
444
+ });
107
445
  });