@scure/btc-signer 2.0.0 → 2.2.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/src/utxo.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  NETWORK,
19
19
  PubT,
20
20
  TAPROOT_UNSPENDABLE_KEY,
21
+ type TArg,
21
22
  compareBytes,
22
23
  equalBytes,
23
24
  isBytes,
@@ -26,7 +27,9 @@ import {
26
27
  } from './utils.ts';
27
28
 
28
29
  // UTXO Select
30
+ /** Minimal output target accepted by the UTXO selector. */
29
31
  export type Output = { address: string; amount: bigint } | { script: Uint8Array; amount: bigint };
32
+ /** Intermediate accumulation result returned by selection heuristics. */
30
33
  export type Accumulated =
31
34
  | {
32
35
  indices: number[];
@@ -39,13 +42,21 @@ type TapLeafScript = psbt.TransactionInput['tapLeafScript'];
39
42
  type TB = Parameters<typeof psbt.TaprootControlBlock.encode>[0];
40
43
  const encodeTapBlock = (item: TB) => psbt.TaprootControlBlock.encode(item);
41
44
 
42
- function iterLeafs(tapLeafScript: TapLeafScript, sigSize: number, customScripts?: CustomScript[]) {
43
- if (!tapLeafScript || !tapLeafScript.length) throw new Error('no leafs');
45
+ function iterLeafs(
46
+ tapLeafScript: TArg<TapLeafScript>,
47
+ sigSize: number,
48
+ customScripts?: TArg<CustomScript[]>
49
+ ) {
50
+ const _tapLeafScript = tapLeafScript as TapLeafScript;
51
+ const _customScripts = customScripts as CustomScript[] | undefined;
52
+ if (!_tapLeafScript || !_tapLeafScript.length) throw new Error('no leafs');
53
+ // Dummy non-empty Schnorr signature bytes for weight estimation.
54
+ // Unsigned tr_ms slots use P.EMPTY below.
44
55
  const empty = () => new Uint8Array(sigSize);
45
56
  // If user want to select specific leaf, which can signed,
46
57
  // it is possible to remove all other leafs manually.
47
58
  // Sort leafs by control block length.
48
- const leafs = tapLeafScript.sort(
59
+ const leafs = _tapLeafScript.sort(
49
60
  (a, b) => encodeTapBlock(a[0]).length - encodeTapBlock(b[0]).length
50
61
  );
51
62
  for (const [cb, _script] of leafs) {
@@ -63,9 +74,9 @@ function iterLeafs(tapLeafScript: TapLeafScript, sigSize: number, customScripts?
63
74
  } else if (outs.type === 'tr_ns') {
64
75
  for (const _pub of outs.pubkeys) signatures.push(empty());
65
76
  } else {
66
- if (!customScripts) throw new Error('Finalize: Unknown tapLeafScript');
77
+ if (!_customScripts) throw new Error('Finalize: Unknown tapLeafScript');
67
78
  const leafHash = tapLeafHash(script, ver);
68
- for (const c of customScripts) {
79
+ for (const c of _customScripts) {
69
80
  if (!c.finalizeTaproot) continue;
70
81
  const scriptDecoded = Script.decode(script);
71
82
  const csEncoded = c.encode(scriptDecoded);
@@ -87,6 +98,9 @@ function iterLeafs(tapLeafScript: TapLeafScript, sigSize: number, customScripts?
87
98
  if (!finalized) continue;
88
99
  return finalized.concat(encodeTapBlock(cb));
89
100
  }
101
+ // UTXO selection may run without the real signer/finalizer process. When no matching local
102
+ // finalizeTaproot hook exists, keep a minimal script-path witness lower bound here instead of
103
+ // failing selection; callers that need exact fee estimates must provide the matching hook.
90
104
  }
91
105
  // Witness is stack, so last element will be used first
92
106
  return signatures.reverse().concat([script, encodeTapBlock(cb)]);
@@ -96,23 +110,32 @@ function iterLeafs(tapLeafScript: TapLeafScript, sigSize: number, customScripts?
96
110
 
97
111
  function estimateInput(
98
112
  inputType: ReturnType<typeof getInputType>,
99
- input: psbt.TransactionInput,
100
- opts: TxOpts
113
+ input: TArg<psbt.TransactionInput>,
114
+ opts: TArg<TxOpts>
101
115
  ) {
116
+ const _input = input as psbt.TransactionInput;
117
+ const _opts = opts as TxOpts;
102
118
  let script: Bytes = P.EMPTY;
103
119
  let witness: Bytes[] | undefined;
104
120
 
105
121
  // schnorr sig is always 64 bytes. except for cases when sighash is not default!
106
122
  if (inputType.txType === 'taproot') {
107
123
  const SCHNORR_SIG_SIZE = inputType.sighash !== SignatureHash.DEFAULT ? 65 : 64;
108
- if (input.tapInternalKey && !equalBytes(input.tapInternalKey, TAPROOT_UNSPENDABLE_KEY)) {
124
+ // BIP371 `PSBT_IN_TAP_INTERNAL_KEY` is signer metadata, but UTXO selection
125
+ // runs before signer availability is known. We intentionally treat a
126
+ // present internal key as a key-path hint here to avoid overestimating fees
127
+ // on the online side. Callers that know only script-path signing is
128
+ // possible should omit `tapInternalKey` or pre-filter `tapLeafScript`
129
+ // before estimation.
130
+ if (_input.tapInternalKey && !equalBytes(_input.tapInternalKey, TAPROOT_UNSPENDABLE_KEY)) {
109
131
  witness = [new Uint8Array(SCHNORR_SIG_SIZE)];
110
- } else if (input.tapLeafScript) {
111
- witness = iterLeafs(input.tapLeafScript, SCHNORR_SIG_SIZE, opts.customScripts);
132
+ } else if (_input.tapLeafScript) {
133
+ witness = iterLeafs(_input.tapLeafScript, SCHNORR_SIG_SIZE, _opts.customScripts);
112
134
  } else throw new Error('estimateInput/taproot: unknown input');
113
135
  } else {
114
- // It is possible to grind signatures until it has minimal size (but changing fee value +N satoshi),
115
- // which will make estimations exact. But will be very hard for multi sig (need to make sure all signatures has small size).
136
+ // It is possible to grind signatures until they have minimal size, but
137
+ // that changes the fee by +N satoshi. It would make estimation exact, but
138
+ // is very hard for multisig because every signature would need to stay small.
116
139
  const empty = () => new Uint8Array(72); // max size of sigs
117
140
  const emptyPub = () => new Uint8Array(33); // size of pubkey
118
141
  let inputScript = P.EMPTY;
@@ -131,7 +154,7 @@ function estimateInput(
131
154
  } else if (ltype === 'wpkh') {
132
155
  inputScript = P.EMPTY;
133
156
  inputWitness = [empty(), emptyPub()];
134
- } else if (ltype === 'unknown' && !opts.allowUnknownInputs)
157
+ } else if (ltype === 'unknown' && !_opts.allowUnknownInputs)
135
158
  throw new Error('Unknown inputs are not allowed');
136
159
  if (inputType.type.includes('wsh-')) {
137
160
  // P2WSH
@@ -163,50 +186,63 @@ function estimateInput(
163
186
 
164
187
  // Exported for tests, internal method
165
188
  export const _cmpBig = (a: bigint, b: bigint): 0 | 1 | -1 => {
189
+ // Array.sort comparators must return a number, so normalize bigint comparisons to -1/0/1
190
+ // instead of coercing large differences through Number(...) and losing ordering precision.
166
191
  const n = a - b;
167
192
  if (n < 0n) return -1;
168
193
  else if (n > 0n) return 1;
169
194
  return 0;
170
195
  };
171
196
 
197
+ /** Options for fee estimation and UTXO selection. */
172
198
  export type EstimatorOpts = TxOpts & {
173
- // NOTE: fees less than 1 satoshi per vbyte is not supported. Please create issue if you have valid use case for that.
199
+ // NOTE: feePerByte is an integer sat/vbyte bigint, so fractional rates are impossible here.
200
+ // Zero is useful on regtest/in tests, but negative rates are not supported.
174
201
  feePerByte: bigint; // satoshi per vbyte
175
202
  changeAddress: string; // address where change will be sent
176
203
  // Optional
177
204
  alwaysChange?: boolean; // always create change, even if less than dust threshold
178
205
  bip69?: boolean; // https://github.com/bitcoin/bips/blob/master/bip-0069.mediawiki
179
206
  network?: typeof NETWORK;
180
- dust?: number; // how much vbytes considered dust?
207
+ dust?: bigint; // how much vbytes considered dust?
181
208
  dustRelayFeeRate?: bigint; // fee per dust byte (DUST_RELAY_TX_FEE)
182
209
  createTx?: boolean; // Create tx inside selection
183
210
  requiredInputs?: psbt.TransactionInputUpdate[]; // these inputs always will be used
184
211
  allowSameUtxo?: boolean; // allow using UTXO multiple times (for test purposes)
185
212
  };
186
213
 
187
- function getScript(o: Output, opts: TxOpts = {}, network = NETWORK) {
214
+ function getScript(o: TArg<Output>, opts: TArg<TxOpts> = {}, network = NETWORK) {
215
+ const _o = o as Output;
216
+ const _opts = opts as TxOpts;
188
217
  let script;
189
- if ('script' in o && isBytes(o.script)) {
190
- script = o.script;
218
+ if ('script' in _o && isBytes(_o.script)) {
219
+ script = _o.script;
191
220
  }
192
- if ('address' in o) {
193
- if (typeof o.address !== 'string')
194
- throw new Error(`Estimator: wrong output address=${o.address}`);
195
- script = OutScript.encode(Address(network).decode(o.address));
221
+ if ('address' in _o) {
222
+ if (typeof _o.address !== 'string')
223
+ throw new Error(`Estimator: wrong output address=${_o.address}`);
224
+ // Address.decode() only yields known descriptors for valid output addresses, but the wrapped
225
+ // coder type still includes `undefined`, so narrow before re-encoding the script template.
226
+ script = OutScript.encode(
227
+ Address(network).decode(_o.address) as Parameters<typeof OutScript.encode>[0]
228
+ );
196
229
  }
197
230
  if (!script) throw new Error('Estimator: wrong output script');
198
- if (typeof o.amount !== 'bigint')
231
+ if (typeof _o.amount !== 'bigint')
199
232
  throw new Error(
200
233
  `Estimator: wrong output amount=${
201
- o.amount
202
- }, should be of type bigint but got ${typeof o.amount}.`
234
+ _o.amount
235
+ }, should be of type bigint but got ${typeof _o.amount}.`
203
236
  );
204
- if (script && !opts.allowUnknownOutputs && OutScript.decode(script).type === 'unknown') {
237
+ // Keep selector-only `createTx: false` flows aligned with the transaction/PSBT output boundary:
238
+ // satoshi-denominated outputs are not allowed to go negative.
239
+ if (_o.amount < 0n) throw new Error(`Estimator: wrong output amount=${_o.amount}`);
240
+ if (script && !_opts.allowUnknownOutputs && OutScript.decode(script).type === 'unknown') {
205
241
  throw new Error(
206
242
  'Estimator: unknown output script type, there is a chance that input is unspendable. Pass allowUnknownOutputs=true, if you sure'
207
243
  );
208
244
  }
209
- if (!opts.disableScriptCheck) checkScript(script);
245
+ if (!_opts.disableScriptCheck) checkScript(script);
210
246
  return script;
211
247
  }
212
248
 
@@ -216,6 +252,7 @@ type SortStrategy = 'Newest' | 'Oldest' | 'Smallest' | 'Biggest';
216
252
  type ExactStrategy = `exact${SortStrategy}`;
217
253
  type AccumStrategy = `accum${SortStrategy}`;
218
254
 
255
+ /** Supported UTXO selection strategies. */
219
256
  export type SelectionStrategy =
220
257
  | 'all'
221
258
  | 'default'
@@ -251,6 +288,10 @@ export class _Estimator {
251
288
  opts.feePerByte
252
289
  }, should be of type bigint but got ${typeof opts.feePerByte}.`
253
290
  );
291
+ // Zero-fee estimation is useful on regtest/in tests, but negative fee rates would make
292
+ // `getSatoshi(...)` produce nonsensical negative fees throughout selection.
293
+ if (opts.feePerByte < 0n)
294
+ throw new Error(`Estimator: feePerByte must be >= 0 satoshi per vbyte`);
254
295
  // Dust stuff
255
296
  // TODO: think about this more:
256
297
  // - current dust filters tx which cannot be relayed by core
@@ -295,7 +336,14 @@ export class _Estimator {
295
336
  let changeWeight =
296
337
  baseWeight +
297
338
  32 +
298
- 4 * VarBytes.encode(OutScript.encode(Address(network).decode(opts.changeAddress))).length;
339
+ // Same Address.decode() narrowing as above: the estimator only reaches this path for a
340
+ // concrete change output address, not an unknown descriptor.
341
+ 4 *
342
+ VarBytes.encode(
343
+ OutScript.encode(
344
+ Address(network).decode(opts.changeAddress) as Parameters<typeof OutScript.encode>[0]
345
+ )
346
+ ).length;
299
347
  baseWeight += 4 * CompactSizeLen.encode(outputs.length).length;
300
348
  // If there a lot of outputs change can change fee
301
349
  changeWeight += 4 * CompactSizeLen.encode(outputs.length + 1).length;
@@ -316,13 +364,16 @@ export class _Estimator {
316
364
  opts.disableScriptCheck,
317
365
  opts.allowUnknown
318
366
  );
319
- inputBeforeSign(normalized); // check fields
367
+ inputBeforeSign(normalized as TArg<psbt.TransactionInput>); // check fields
320
368
  const key = `${hex.encode(normalized.txid!)}:${normalized.index}`;
321
369
  if (!opts.allowSameUtxo && inputKeys.has(key))
322
370
  throw new Error(`Estimator: same input passed multiple times: ${key}`);
323
371
  inputKeys.add(key);
324
- const inputType = getInputType(normalized, opts.allowLegacyWitnessUtxo);
325
- const prev = getPrevOut(normalized);
372
+ const inputType = getInputType(
373
+ normalized as TArg<psbt.TransactionInput>,
374
+ opts.allowLegacyWitnessUtxo
375
+ );
376
+ const prev = getPrevOut(normalized as TArg<psbt.TransactionInput>);
326
377
  const estimate = estimateInput(inputType, normalized, this.opts);
327
378
  const value = prev.amount - opts.feePerByte * BigInt(toVsize(estimate.weight)); // value = amount-fee
328
379
  return { inputType, normalized, amount: prev.amount, value, estimate };
@@ -353,8 +404,8 @@ export class _Estimator {
353
404
  return compareBytes(scripts[a], scripts[b]);
354
405
  });
355
406
  }
356
- private getSatoshi(weigth: number) {
357
- return this.opts.feePerByte * BigInt(toVsize(weigth));
407
+ private getSatoshi(weight: number) {
408
+ return this.opts.feePerByte * BigInt(toVsize(weight));
358
409
  }
359
410
 
360
411
  // Sort by value instead of amount
@@ -394,22 +445,29 @@ export class _Estimator {
394
445
  const targetAmount = this.amount;
395
446
  const res: Set<number> = new Set();
396
447
  let fee;
448
+ // BIP144 serialization uses a var_int `txin_count`, so fee accounting must use the post-add
449
+ // input count here; the CompactSize prefix grows from 1 to 3 bytes at 253 inputs.
450
+ const getTotal = (newWeight: number, newNum: number) => {
451
+ const totalWeight = newWeight + 4 * CompactSizeLen.encode(newNum).length;
452
+ return { totalWeight, fee: this.getSatoshi(totalWeight) };
453
+ };
397
454
  for (const idx of this.requiredIndices) {
398
455
  this.checkInputIdx(idx);
399
456
  if (res.has(idx)) throw new Error('required input encountered multiple times'); // should not happen
400
457
  const { estimate, amount } = this.normalizedInputs[idx];
401
458
  let newWeight = weight + estimate.weight;
402
459
  if (!hasWitnesses && estimate.hasWitnesses) newWeight += 2; // enable witness if needed
403
- const totalWeight = newWeight + 4 * CompactSizeLen.encode(num).length; // number of outputs can change weight
404
- fee = this.getSatoshi(totalWeight);
460
+ const newNum = num + 1;
461
+ const total = getTotal(newWeight, newNum);
462
+ fee = total.fee;
405
463
  weight = newWeight;
406
464
  if (estimate.hasWitnesses) hasWitnesses = true;
407
- num++;
465
+ num = newNum;
408
466
  inputsAmount += amount;
409
467
  res.add(idx);
410
468
  // inputsAmount is enough to cover cost of tx
411
469
  if (!all && targetAmount + fee <= inputsAmount && num >= this.requiredIndices.length)
412
- return { indices: Array.from(res), fee, weight: totalWeight, total: inputsAmount };
470
+ return { indices: Array.from(res), fee, weight: total.totalWeight, total: inputsAmount };
413
471
  }
414
472
  for (const idx of indices) {
415
473
  this.checkInputIdx(idx);
@@ -417,8 +475,9 @@ export class _Estimator {
417
475
  const { estimate, amount, value } = this.normalizedInputs[idx];
418
476
  let newWeight = weight + estimate.weight;
419
477
  if (!hasWitnesses && estimate.hasWitnesses) newWeight += 2; // enable witness if needed
420
- const totalWeight = newWeight + 4 * CompactSizeLen.encode(num).length; // number of outputs can change weight
421
- fee = this.getSatoshi(totalWeight);
478
+ const newNum = num + 1;
479
+ const total = getTotal(newWeight, newNum);
480
+ fee = total.fee;
422
481
  // Best case scenario exact(biggest) -> we find biggest output, less than target+threshold
423
482
  if (exact && amount + inputsAmount > targetAmount + fee + this.dust) continue; // skip if added value is bigger than dust
424
483
  // Negative: cost of using input is more than value provided (negative)
@@ -427,16 +486,21 @@ export class _Estimator {
427
486
  if (skipNegative && value <= 0n) continue;
428
487
  weight = newWeight;
429
488
  if (estimate.hasWitnesses) hasWitnesses = true;
430
- num++;
489
+ num = newNum;
431
490
  inputsAmount += amount;
432
491
  res.add(idx);
433
492
  // inputsAmount is enough to cover cost of tx
434
493
  if (!all && targetAmount + fee <= inputsAmount)
435
- return { indices: Array.from(res), fee, weight: totalWeight, total: inputsAmount };
494
+ return { indices: Array.from(res), fee, weight: total.totalWeight, total: inputsAmount };
436
495
  }
437
496
  if (all) {
438
- const newWeight = weight + 4 * CompactSizeLen.encode(num).length;
439
- return { indices: Array.from(res), fee, weight: newWeight, total: inputsAmount };
497
+ const total = getTotal(weight, num);
498
+ return {
499
+ indices: Array.from(res),
500
+ fee: total.fee,
501
+ weight: total.totalWeight,
502
+ total: inputsAmount,
503
+ };
440
504
  }
441
505
  return undefined;
442
506
  }
@@ -466,8 +530,15 @@ export class _Estimator {
466
530
  Biggest: () => this.biggest,
467
531
  };
468
532
  if (strategy.startsWith('exact')) {
469
- const [exactData, left] = strategy.slice(5).split('/') as [SortStrategy, SelectionStrategy];
533
+ // Reject malformed `exact...` strings up front so a successful exact match cannot hide
534
+ // a missing or garbage `/accum...` fallback suffix.
535
+ const parts = strategy.split('/');
536
+ if (parts.length !== 2) throw new Error(`Estimator.select: wrong strategy=${strategy}`);
537
+ const [exactStrategy, left] = parts as [ExactStrategy, AccumStrategy];
538
+ const exactData = exactStrategy.slice(5) as SortStrategy;
470
539
  if (!data[exactData]) throw new Error(`Estimator.select: wrong strategy=${strategy}`);
540
+ if (!left.startsWith('accum'))
541
+ throw new Error(`Estimator.select: wrong strategy=${strategy}`);
471
542
  strategy = left;
472
543
  const exact = this.accumulate(data[exactData](), true, true);
473
544
  if (exact) return exact;
@@ -491,8 +562,11 @@ export class _Estimator {
491
562
 
492
563
  const changeFee = this.getSatoshi(changeWeight);
493
564
  let fee = s.fee;
565
+ // If dust suppresses the change output, the leftover becomes additional miner fee, so
566
+ // the returned fee/weight need to follow the no-change transaction shape instead of changeWeight.
494
567
  const change = total - this.amount - changeFee;
495
568
  if (change > this.dust) needChange = true;
569
+ else if (!needChange) fee = total - this.amount;
496
570
  let inputs = indices;
497
571
  let outputs = Array.from(this.outputs);
498
572
  if (needChange) {
@@ -509,7 +583,7 @@ export class _Estimator {
509
583
  inputs: inputs.map((i) => this.normalizedInputs[i].normalized),
510
584
  outputs,
511
585
  fee,
512
- weight: this.opts.alwaysChange ? s.weight : changeWeight,
586
+ weight: needChange ? changeWeight : s.weight,
513
587
  change: !!needChange,
514
588
  };
515
589
  let tx;
@@ -525,14 +599,43 @@ export class _Estimator {
525
599
  }
526
600
  }
527
601
 
602
+ /**
603
+ * Selects inputs for the requested outputs using the configured strategy.
604
+ * @param inputs - candidate inputs that may be selected
605
+ * @param outputs - desired transaction outputs
606
+ * @param strategy - selection heuristic to use
607
+ * @param opts - Fee-estimation and transaction-construction options. See {@link EstimatorOpts}.
608
+ * @returns Selection result, optionally including a constructed transaction.
609
+ * @throws If the UTXO set, outputs, or estimator options are invalid. {@link Error}
610
+ * @example
611
+ * Estimate fees, pick inputs, and build a transaction for the selected set.
612
+ * ```ts
613
+ * import { p2wpkh } from '@scure/btc-signer/payment.js';
614
+ * import { pubECDSA, randomPrivateKeyBytes } from '@scure/btc-signer/utils.js';
615
+ * import { selectUTXO } from '@scure/btc-signer/utxo.js';
616
+ * import { hex } from '@scure/base';
617
+ * const spend = p2wpkh(pubECDSA(randomPrivateKeyBytes()));
618
+ * const change = p2wpkh(pubECDSA(randomPrivateKeyBytes()));
619
+ * selectUTXO(
620
+ * [{
621
+ * txid: hex.decode('0000000000000000000000000000000000000000000000000000000000000001'),
622
+ * index: 0,
623
+ * witnessUtxo: { amount: 50_000n, script: spend.script },
624
+ * }],
625
+ * [{ address: spend.address!, amount: 10_000n }],
626
+ * 'default',
627
+ * { feePerByte: 1n, changeAddress: change.address! }
628
+ * );
629
+ * ```
630
+ */
528
631
  export function selectUTXO(
529
- inputs: psbt.TransactionInputUpdate[],
530
- outputs: Output[],
632
+ inputs: TArg<psbt.TransactionInputUpdate[]>,
633
+ outputs: TArg<Output[]>,
531
634
  strategy: SelectionStrategy,
532
- opts: EstimatorOpts
635
+ opts: TArg<EstimatorOpts>
533
636
  ) {
534
- // Defaults: do we want bip69 by default?
535
- const _opts = { createTx: true, bip69: true, ...opts };
536
- const est = new _Estimator(inputs, outputs, _opts);
637
+ // Public wrapper defaults to BIP69 ordering and tx construction unless callers override them.
638
+ const _opts = { createTx: true, bip69: true, ...(opts as EstimatorOpts) };
639
+ const est = new _Estimator(inputs as psbt.TransactionInputUpdate[], outputs as Output[], _opts);
537
640
  return est.result(strategy);
538
641
  }