@scure/btc-signer 2.0.1 → 2.3.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
@@ -2,7 +2,7 @@ import { hex } from '@scure/base';
2
2
  import * as P from 'micro-packed';
3
3
  import { Address, type CustomScript, OutScript, checkScript, tapLeafHash } from './payment.ts';
4
4
  import * as psbt from './psbt.ts';
5
- import { CompactSizeLen, RawWitness, Script, VarBytes } from './script.ts';
5
+ import { CompactSizeLen, Script } from './script.ts';
6
6
  import {
7
7
  SignatureHash,
8
8
  Transaction,
@@ -14,19 +14,26 @@ import {
14
14
  toVsize,
15
15
  } from './transaction.ts';
16
16
  import {
17
+ abigint,
18
+ aarray,
19
+ astring,
17
20
  type Bytes,
18
21
  NETWORK,
19
22
  PubT,
20
23
  TAPROOT_UNSPENDABLE_KEY,
24
+ type TArg,
21
25
  compareBytes,
22
26
  equalBytes,
23
27
  isBytes,
24
28
  sha256,
25
29
  validatePubkey,
30
+ validateObject,
26
31
  } from './utils.ts';
27
32
 
28
33
  // UTXO Select
34
+ /** Minimal output target accepted by the UTXO selector. */
29
35
  export type Output = { address: string; amount: bigint } | { script: Uint8Array; amount: bigint };
36
+ /** Intermediate accumulation result returned by selection heuristics. */
30
37
  export type Accumulated =
31
38
  | {
32
39
  indices: number[];
@@ -38,14 +45,27 @@ export type Accumulated =
38
45
  type TapLeafScript = psbt.TransactionInput['tapLeafScript'];
39
46
  type TB = Parameters<typeof psbt.TaprootControlBlock.encode>[0];
40
47
  const encodeTapBlock = (item: TB) => psbt.TaprootControlBlock.encode(item);
48
+ // Be friendly to bad ECMAScript parsers by not using bigint literals.
49
+ // prettier-ignore
50
+ const _0n = /* @__PURE__ */ BigInt(0), _3n = /* @__PURE__ */ BigInt(3);
51
+ // Serialized length of VarBytes(data) without allocating the encoded copy
52
+ const varLen = (dataLen: number) => CompactSizeLen.encode(dataLen).length + dataLen;
41
53
 
42
- function iterLeafs(tapLeafScript: TapLeafScript, sigSize: number, customScripts?: CustomScript[]) {
43
- if (!tapLeafScript || !tapLeafScript.length) throw new Error('no leafs');
54
+ function iterLeafs(
55
+ tapLeafScript: TArg<TapLeafScript>,
56
+ sigSize: number,
57
+ customScripts?: TArg<CustomScript[]>
58
+ ) {
59
+ const _tapLeafScript = tapLeafScript as TapLeafScript;
60
+ const _customScripts = customScripts as CustomScript[] | undefined;
61
+ if (!_tapLeafScript || !_tapLeafScript.length) throw new Error('no leafs');
62
+ // Dummy non-empty Schnorr signature bytes for weight estimation.
63
+ // Unsigned tr_ms slots use P.EMPTY below.
44
64
  const empty = () => new Uint8Array(sigSize);
45
65
  // If user want to select specific leaf, which can signed,
46
66
  // it is possible to remove all other leafs manually.
47
67
  // Sort leafs by control block length.
48
- const leafs = tapLeafScript.sort(
68
+ const leafs = _tapLeafScript.sort(
49
69
  (a, b) => encodeTapBlock(a[0]).length - encodeTapBlock(b[0]).length
50
70
  );
51
71
  for (const [cb, _script] of leafs) {
@@ -63,9 +83,9 @@ function iterLeafs(tapLeafScript: TapLeafScript, sigSize: number, customScripts?
63
83
  } else if (outs.type === 'tr_ns') {
64
84
  for (const _pub of outs.pubkeys) signatures.push(empty());
65
85
  } else {
66
- if (!customScripts) throw new Error('Finalize: Unknown tapLeafScript');
86
+ if (!_customScripts) throw new Error('Finalize: Unknown tapLeafScript');
67
87
  const leafHash = tapLeafHash(script, ver);
68
- for (const c of customScripts) {
88
+ for (const c of _customScripts) {
69
89
  if (!c.finalizeTaproot) continue;
70
90
  const scriptDecoded = Script.decode(script);
71
91
  const csEncoded = c.encode(scriptDecoded);
@@ -87,6 +107,9 @@ function iterLeafs(tapLeafScript: TapLeafScript, sigSize: number, customScripts?
87
107
  if (!finalized) continue;
88
108
  return finalized.concat(encodeTapBlock(cb));
89
109
  }
110
+ // UTXO selection may run without the real signer/finalizer process. When no matching local
111
+ // finalizeTaproot hook exists, keep a minimal script-path witness lower bound here instead of
112
+ // failing selection; callers that need exact fee estimates must provide the matching hook.
90
113
  }
91
114
  // Witness is stack, so last element will be used first
92
115
  return signatures.reverse().concat([script, encodeTapBlock(cb)]);
@@ -96,23 +119,32 @@ function iterLeafs(tapLeafScript: TapLeafScript, sigSize: number, customScripts?
96
119
 
97
120
  function estimateInput(
98
121
  inputType: ReturnType<typeof getInputType>,
99
- input: psbt.TransactionInput,
100
- opts: TxOpts
122
+ input: TArg<psbt.TransactionInput>,
123
+ opts: TArg<TxOpts>
101
124
  ) {
125
+ const _input = input as psbt.TransactionInput;
126
+ const _opts = opts as TxOpts;
102
127
  let script: Bytes = P.EMPTY;
103
128
  let witness: Bytes[] | undefined;
104
129
 
105
130
  // schnorr sig is always 64 bytes. except for cases when sighash is not default!
106
131
  if (inputType.txType === 'taproot') {
107
132
  const SCHNORR_SIG_SIZE = inputType.sighash !== SignatureHash.DEFAULT ? 65 : 64;
108
- if (input.tapInternalKey && !equalBytes(input.tapInternalKey, TAPROOT_UNSPENDABLE_KEY)) {
133
+ // BIP371 `PSBT_IN_TAP_INTERNAL_KEY` is signer metadata, but UTXO selection
134
+ // runs before signer availability is known. We intentionally treat a
135
+ // present internal key as a key-path hint here to avoid overestimating fees
136
+ // on the online side. Callers that know only script-path signing is
137
+ // possible should omit `tapInternalKey` or pre-filter `tapLeafScript`
138
+ // before estimation.
139
+ if (_input.tapInternalKey && !equalBytes(_input.tapInternalKey, TAPROOT_UNSPENDABLE_KEY)) {
109
140
  witness = [new Uint8Array(SCHNORR_SIG_SIZE)];
110
- } else if (input.tapLeafScript) {
111
- witness = iterLeafs(input.tapLeafScript, SCHNORR_SIG_SIZE, opts.customScripts);
141
+ } else if (_input.tapLeafScript) {
142
+ witness = iterLeafs(_input.tapLeafScript, SCHNORR_SIG_SIZE, _opts.customScripts);
112
143
  } else throw new Error('estimateInput/taproot: unknown input');
113
144
  } 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).
145
+ // It is possible to grind signatures until they have minimal size, but
146
+ // that changes the fee by +N satoshi. It would make estimation exact, but
147
+ // is very hard for multisig because every signature would need to stay small.
116
148
  const empty = () => new Uint8Array(72); // max size of sigs
117
149
  const emptyPub = () => new Uint8Array(33); // size of pubkey
118
150
  let inputScript = P.EMPTY;
@@ -131,7 +163,7 @@ function estimateInput(
131
163
  } else if (ltype === 'wpkh') {
132
164
  inputScript = P.EMPTY;
133
165
  inputWitness = [empty(), emptyPub()];
134
- } else if (ltype === 'unknown' && !opts.allowUnknownInputs)
166
+ } else if (ltype === 'unknown' && !_opts.allowUnknownInputs)
135
167
  throw new Error('Unknown inputs are not allowed');
136
168
  if (inputType.type.includes('wsh-')) {
137
169
  // P2WSH
@@ -152,10 +184,11 @@ function estimateInput(
152
184
  } else if (inputType.type.startsWith('wsh-')) {
153
185
  } else if (inputType.txType !== 'segwit') script = inputScript;
154
186
  }
155
- let weight = 160 + 4 * VarBytes.encode(script).length;
187
+ let weight = 160 + 4 * varLen(script.length);
156
188
  let hasWitnesses = false;
157
189
  if (witness) {
158
- weight += RawWitness.encode(witness).length;
190
+ weight += CompactSizeLen.encode(witness.length).length;
191
+ for (const w of witness) weight += varLen(w.length);
159
192
  hasWitnesses = true;
160
193
  }
161
194
  return { weight, hasWitnesses };
@@ -163,50 +196,57 @@ function estimateInput(
163
196
 
164
197
  // Exported for tests, internal method
165
198
  export const _cmpBig = (a: bigint, b: bigint): 0 | 1 | -1 => {
199
+ // Array.sort comparators must return a number, so normalize bigint comparisons to -1/0/1
200
+ // instead of coercing large differences through Number(...) and losing ordering precision.
166
201
  const n = a - b;
167
- if (n < 0n) return -1;
168
- else if (n > 0n) return 1;
202
+ if (n < _0n) return -1;
203
+ else if (n > _0n) return 1;
169
204
  return 0;
170
205
  };
171
206
 
207
+ /** Options for fee estimation and UTXO selection. */
172
208
  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.
209
+ // NOTE: feePerByte is an integer sat/vbyte bigint, so fractional rates are impossible here.
210
+ // Zero is useful on regtest/in tests, but negative rates are not supported.
174
211
  feePerByte: bigint; // satoshi per vbyte
175
212
  changeAddress: string; // address where change will be sent
176
213
  // Optional
177
214
  alwaysChange?: boolean; // always create change, even if less than dust threshold
178
215
  bip69?: boolean; // https://github.com/bitcoin/bips/blob/master/bip-0069.mediawiki
179
216
  network?: typeof NETWORK;
180
- dust?: number; // how much vbytes considered dust?
217
+ dust?: bigint; // how much vbytes considered dust?
181
218
  dustRelayFeeRate?: bigint; // fee per dust byte (DUST_RELAY_TX_FEE)
182
219
  createTx?: boolean; // Create tx inside selection
183
220
  requiredInputs?: psbt.TransactionInputUpdate[]; // these inputs always will be used
184
221
  allowSameUtxo?: boolean; // allow using UTXO multiple times (for test purposes)
185
222
  };
186
223
 
187
- function getScript(o: Output, opts: TxOpts = {}, network = NETWORK) {
224
+ function getScript(o: TArg<Output>, opts: TArg<TxOpts> = {}, network = NETWORK) {
225
+ validateObject(o as Record<string, any>, {}, {}, 'output');
226
+ const _o = o as Output;
227
+ const _opts = opts as TxOpts;
188
228
  let script;
189
- if ('script' in o && isBytes(o.script)) {
190
- script = o.script;
229
+ if ('script' in _o && isBytes(_o.script)) {
230
+ script = _o.script;
191
231
  }
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));
232
+ if ('address' in _o) {
233
+ astring(_o.address, 'output.address');
234
+ // Address.decode() only yields known descriptors for valid output addresses, but the wrapped
235
+ // coder type still includes `undefined`, so narrow before re-encoding the script template.
236
+ script = OutScript.encode(
237
+ Address(network).decode(_o.address) as Parameters<typeof OutScript.encode>[0]
238
+ );
196
239
  }
197
240
  if (!script) throw new Error('Estimator: wrong output script');
198
- if (typeof o.amount !== 'bigint')
199
- throw new Error(
200
- `Estimator: wrong output amount=${
201
- o.amount
202
- }, should be of type bigint but got ${typeof o.amount}.`
203
- );
204
- if (script && !opts.allowUnknownOutputs && OutScript.decode(script).type === 'unknown') {
241
+ // Keep selector-only `createTx: false` flows aligned with the transaction/PSBT output boundary:
242
+ // satoshi-denominated outputs are not allowed to go negative.
243
+ abigint(_o.amount, 'output.amount');
244
+ if (script && !_opts.allowUnknownOutputs && OutScript.decode(script).type === 'unknown') {
205
245
  throw new Error(
206
246
  'Estimator: unknown output script type, there is a chance that input is unspendable. Pass allowUnknownOutputs=true, if you sure'
207
247
  );
208
248
  }
209
- if (!opts.disableScriptCheck) checkScript(script);
249
+ if (!_opts.disableScriptCheck) checkScript(script);
210
250
  return script;
211
251
  }
212
252
 
@@ -216,6 +256,7 @@ type SortStrategy = 'Newest' | 'Oldest' | 'Smallest' | 'Biggest';
216
256
  type ExactStrategy = `exact${SortStrategy}`;
217
257
  type AccumStrategy = `accum${SortStrategy}`;
218
258
 
259
+ /** Supported UTXO selection strategies. */
219
260
  export type SelectionStrategy =
220
261
  | 'all'
221
262
  | 'default'
@@ -245,12 +286,9 @@ export class _Estimator {
245
286
  constructor(inputs: psbt.TransactionInputUpdate[], outputs: Output[], opts: EstimatorOpts) {
246
287
  this.outputs = outputs;
247
288
  this.opts = opts;
248
- if (typeof opts.feePerByte !== 'bigint')
249
- throw new Error(
250
- `Estimator: wrong feePerByte=${
251
- opts.feePerByte
252
- }, should be of type bigint but got ${typeof opts.feePerByte}.`
253
- );
289
+ // Zero-fee estimation is useful on regtest/in tests, but negative fee rates would make
290
+ // `getSatoshi(...)` produce nonsensical negative fees throughout selection.
291
+ abigint(opts.feePerByte, 'opts.feePerByte');
254
292
  // Dust stuff
255
293
  // TODO: think about this more:
256
294
  // - current dust filters tx which cannot be relayed by core
@@ -263,39 +301,37 @@ export class _Estimator {
263
301
  const inputsDust = 32 + 4 + 1 + 107 + 4; // NOTE: can be smaller for segwit tx?
264
302
  const outputDust = 34; // NOTE: 'nSize = GetSerializeSize(txout)'
265
303
  const dustBytes = opts.dust === undefined ? BigInt(inputsDust + outputDust) : opts.dust;
266
- if (typeof dustBytes !== 'bigint') {
267
- throw new Error(
268
- `Estimator: wrong dust=${opts.dust}, should be of type bigint but got ${typeof opts.dust}.`
269
- );
270
- }
304
+ abigint(dustBytes, 'opts.dust');
271
305
  // 3 sat/vb is the default minimum fee rate used to calculate dust thresholds by bitcoin core.
272
306
  // 3000 sat/kvb -> 3 sat/vb.
273
307
  // https://github.com/bitcoin/bitcoin/blob/27a770b34b8f1dbb84760f442edb3e23a0c2420b/src/policy/policy.h#L55
274
- const dustFee = opts.dustRelayFeeRate === undefined ? 3n : opts.dustRelayFeeRate;
275
- if (typeof dustFee !== 'bigint') {
276
- throw new Error(
277
- `Estimator: wrong dustRelayFeeRate=${opts.dustRelayFeeRate}, should be of type bigint but got ${typeof opts.dustRelayFeeRate}.`
278
- );
279
- }
308
+ const dustFee = opts.dustRelayFeeRate === undefined ? _3n : opts.dustRelayFeeRate;
309
+ abigint(dustFee, 'opts.dustRelayFeeRate');
280
310
  // Dust uses feePerbyte by default, but we allow separate dust fee if needed
281
311
  this.dust = dustBytes * dustFee;
282
312
  if (opts.requiredInputs !== undefined && !Array.isArray(opts.requiredInputs))
283
313
  throw new Error(`Estimator: wrong required inputs=${opts.requiredInputs}`);
284
314
  const network = opts.network || NETWORK;
285
- let amount = 0n;
315
+ let amount = _0n;
286
316
  // Base weight: tx with outputs, no inputs
287
317
  let baseWeight = 32;
288
318
  for (const o of outputs) {
289
319
  const script = getScript(o, opts, opts.network);
290
- baseWeight += 32 + 4 * VarBytes.encode(script).length;
320
+ baseWeight += 32 + 4 * varLen(script.length);
291
321
  amount += o.amount;
292
322
  }
293
- if (typeof opts.changeAddress !== 'string')
294
- throw new Error(`Estimator: wrong change address=${opts.changeAddress}`);
323
+ astring(opts.changeAddress, 'opts.changeAddress');
295
324
  let changeWeight =
296
325
  baseWeight +
297
326
  32 +
298
- 4 * VarBytes.encode(OutScript.encode(Address(network).decode(opts.changeAddress))).length;
327
+ // Same Address.decode() narrowing as above: the estimator only reaches this path for a
328
+ // concrete change output address, not an unknown descriptor.
329
+ 4 *
330
+ varLen(
331
+ OutScript.encode(
332
+ Address(network).decode(opts.changeAddress) as Parameters<typeof OutScript.encode>[0]
333
+ ).length
334
+ );
299
335
  baseWeight += 4 * CompactSizeLen.encode(outputs.length).length;
300
336
  // If there a lot of outputs change can change fee
301
337
  changeWeight += 4 * CompactSizeLen.encode(outputs.length + 1).length;
@@ -316,13 +352,16 @@ export class _Estimator {
316
352
  opts.disableScriptCheck,
317
353
  opts.allowUnknown
318
354
  );
319
- inputBeforeSign(normalized); // check fields
355
+ inputBeforeSign(normalized as TArg<psbt.TransactionInput>); // check fields
320
356
  const key = `${hex.encode(normalized.txid!)}:${normalized.index}`;
321
357
  if (!opts.allowSameUtxo && inputKeys.has(key))
322
358
  throw new Error(`Estimator: same input passed multiple times: ${key}`);
323
359
  inputKeys.add(key);
324
- const inputType = getInputType(normalized, opts.allowLegacyWitnessUtxo);
325
- const prev = getPrevOut(normalized);
360
+ const inputType = getInputType(
361
+ normalized as TArg<psbt.TransactionInput>,
362
+ opts.allowLegacyWitnessUtxo
363
+ );
364
+ const prev = getPrevOut(normalized as TArg<psbt.TransactionInput>);
326
365
  const estimate = estimateInput(inputType, normalized, this.opts);
327
366
  const value = prev.amount - opts.feePerByte * BigInt(toVsize(estimate.weight)); // value = amount-fee
328
367
  return { inputType, normalized, amount: prev.amount, value, estimate };
@@ -353,8 +392,8 @@ export class _Estimator {
353
392
  return compareBytes(scripts[a], scripts[b]);
354
393
  });
355
394
  }
356
- private getSatoshi(weigth: number) {
357
- return this.opts.feePerByte * BigInt(toVsize(weigth));
395
+ private getSatoshi(weight: number) {
396
+ return this.opts.feePerByte * BigInt(toVsize(weight));
358
397
  }
359
398
 
360
399
  // Sort by value instead of amount
@@ -390,26 +429,33 @@ export class _Estimator {
390
429
  let weight = this.opts.alwaysChange ? this.changeWeight : this.baseWeight;
391
430
  let hasWitnesses = false;
392
431
  let num = 0;
393
- let inputsAmount = 0n;
432
+ let inputsAmount = _0n;
394
433
  const targetAmount = this.amount;
395
434
  const res: Set<number> = new Set();
396
435
  let fee;
436
+ // BIP144 serialization uses a var_int `txin_count`, so fee accounting must use the post-add
437
+ // input count here; the CompactSize prefix grows from 1 to 3 bytes at 253 inputs.
438
+ const getTotal = (newWeight: number, newNum: number) => {
439
+ const totalWeight = newWeight + 4 * CompactSizeLen.encode(newNum).length;
440
+ return { totalWeight, fee: this.getSatoshi(totalWeight) };
441
+ };
397
442
  for (const idx of this.requiredIndices) {
398
443
  this.checkInputIdx(idx);
399
444
  if (res.has(idx)) throw new Error('required input encountered multiple times'); // should not happen
400
445
  const { estimate, amount } = this.normalizedInputs[idx];
401
446
  let newWeight = weight + estimate.weight;
402
447
  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);
448
+ const newNum = num + 1;
449
+ const total = getTotal(newWeight, newNum);
450
+ fee = total.fee;
405
451
  weight = newWeight;
406
452
  if (estimate.hasWitnesses) hasWitnesses = true;
407
- num++;
453
+ num = newNum;
408
454
  inputsAmount += amount;
409
455
  res.add(idx);
410
456
  // inputsAmount is enough to cover cost of tx
411
457
  if (!all && targetAmount + fee <= inputsAmount && num >= this.requiredIndices.length)
412
- return { indices: Array.from(res), fee, weight: totalWeight, total: inputsAmount };
458
+ return { indices: Array.from(res), fee, weight: total.totalWeight, total: inputsAmount };
413
459
  }
414
460
  for (const idx of indices) {
415
461
  this.checkInputIdx(idx);
@@ -417,26 +463,36 @@ export class _Estimator {
417
463
  const { estimate, amount, value } = this.normalizedInputs[idx];
418
464
  let newWeight = weight + estimate.weight;
419
465
  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);
466
+ const newNum = num + 1;
467
+ const total = getTotal(newWeight, newNum);
468
+ fee = total.fee;
422
469
  // Best case scenario exact(biggest) -> we find biggest output, less than target+threshold
423
470
  if (exact && amount + inputsAmount > targetAmount + fee + this.dust) continue; // skip if added value is bigger than dust
424
471
  // Negative: cost of using input is more than value provided (negative)
425
472
  // By default 'blackjack' mode in coinselect doesn't use that, which means
426
473
  // it will use negative output if sorted by 'smallest'
427
- if (skipNegative && value <= 0n) continue;
474
+ if (skipNegative && value <= _0n) continue;
428
475
  weight = newWeight;
429
476
  if (estimate.hasWitnesses) hasWitnesses = true;
430
- num++;
477
+ num = newNum;
431
478
  inputsAmount += amount;
432
479
  res.add(idx);
433
480
  // inputsAmount is enough to cover cost of tx
434
481
  if (!all && targetAmount + fee <= inputsAmount)
435
- return { indices: Array.from(res), fee, weight: totalWeight, total: inputsAmount };
482
+ return { indices: Array.from(res), fee, weight: total.totalWeight, total: inputsAmount };
436
483
  }
437
484
  if (all) {
438
- const newWeight = weight + 4 * CompactSizeLen.encode(num).length;
439
- return { indices: Array.from(res), fee, weight: newWeight, total: inputsAmount };
485
+ const total = getTotal(weight, num);
486
+ // 'all' accumulates unconditionally, so sufficiency must be checked here; otherwise
487
+ // result() would report a negative fee (or throw its internal negative-change error).
488
+ // Insufficient funds are a selection failure, same as for accumulation strategies.
489
+ if (targetAmount + total.fee > inputsAmount) return undefined;
490
+ return {
491
+ indices: Array.from(res),
492
+ fee: total.fee,
493
+ weight: total.totalWeight,
494
+ total: inputsAmount,
495
+ };
440
496
  }
441
497
  return undefined;
442
498
  }
@@ -466,8 +522,15 @@ export class _Estimator {
466
522
  Biggest: () => this.biggest,
467
523
  };
468
524
  if (strategy.startsWith('exact')) {
469
- const [exactData, left] = strategy.slice(5).split('/') as [SortStrategy, SelectionStrategy];
525
+ // Reject malformed `exact...` strings up front so a successful exact match cannot hide
526
+ // a missing or garbage `/accum...` fallback suffix.
527
+ const parts = strategy.split('/');
528
+ if (parts.length !== 2) throw new Error(`Estimator.select: wrong strategy=${strategy}`);
529
+ const [exactStrategy, left] = parts as [ExactStrategy, AccumStrategy];
530
+ const exactData = exactStrategy.slice(5) as SortStrategy;
470
531
  if (!data[exactData]) throw new Error(`Estimator.select: wrong strategy=${strategy}`);
532
+ if (!left.startsWith('accum'))
533
+ throw new Error(`Estimator.select: wrong strategy=${strategy}`);
471
534
  strategy = left;
472
535
  const exact = this.accumulate(data[exactData](), true, true);
473
536
  if (exact) return exact;
@@ -491,14 +554,17 @@ export class _Estimator {
491
554
 
492
555
  const changeFee = this.getSatoshi(changeWeight);
493
556
  let fee = s.fee;
557
+ // If dust suppresses the change output, the leftover becomes additional miner fee, so
558
+ // the returned fee/weight need to follow the no-change transaction shape instead of changeWeight.
494
559
  const change = total - this.amount - changeFee;
495
560
  if (change > this.dust) needChange = true;
561
+ else if (!needChange) fee = total - this.amount;
496
562
  let inputs = indices;
497
563
  let outputs = Array.from(this.outputs);
498
564
  if (needChange) {
499
565
  fee = changeFee;
500
566
  // this shouldn't happen!
501
- if (change < 0n) throw new Error(`Estimator.result: negative change=${change}`);
567
+ if (change < _0n) throw new Error(`Estimator.result: negative change=${change}`);
502
568
  outputs.push({ address: this.opts.changeAddress, amount: change });
503
569
  }
504
570
  if (this.opts.bip69) {
@@ -509,7 +575,7 @@ export class _Estimator {
509
575
  inputs: inputs.map((i) => this.normalizedInputs[i].normalized),
510
576
  outputs,
511
577
  fee,
512
- weight: this.opts.alwaysChange ? s.weight : changeWeight,
578
+ weight: needChange ? changeWeight : s.weight,
513
579
  change: !!needChange,
514
580
  };
515
581
  let tx;
@@ -525,14 +591,47 @@ export class _Estimator {
525
591
  }
526
592
  }
527
593
 
594
+ /**
595
+ * Selects inputs for the requested outputs using the configured strategy.
596
+ * @param inputs - candidate inputs that may be selected
597
+ * @param outputs - desired transaction outputs
598
+ * @param strategy - selection heuristic to use
599
+ * @param opts - Fee-estimation and transaction-construction options. See {@link EstimatorOpts}.
600
+ * @returns Selection result, optionally including a constructed transaction.
601
+ * @throws If the UTXO set, outputs, or estimator options are invalid. {@link Error}
602
+ * @example
603
+ * Estimate fees, pick inputs, and build a transaction for the selected set.
604
+ * ```ts
605
+ * import { p2wpkh } from '@scure/btc-signer/payment.js';
606
+ * import { pubECDSA, randomPrivateKeyBytes } from '@scure/btc-signer/utils.js';
607
+ * import { selectUTXO } from '@scure/btc-signer/utxo.js';
608
+ * import { hex } from '@scure/base';
609
+ * const spend = p2wpkh(pubECDSA(randomPrivateKeyBytes()));
610
+ * const change = p2wpkh(pubECDSA(randomPrivateKeyBytes()));
611
+ * selectUTXO(
612
+ * [{
613
+ * txid: hex.decode('0000000000000000000000000000000000000000000000000000000000000001'),
614
+ * index: 0,
615
+ * witnessUtxo: { amount: 50_000n, script: spend.script },
616
+ * }],
617
+ * [{ address: spend.address!, amount: 10_000n }],
618
+ * 'default',
619
+ * { feePerByte: 1n, changeAddress: change.address! }
620
+ * );
621
+ * ```
622
+ */
528
623
  export function selectUTXO(
529
- inputs: psbt.TransactionInputUpdate[],
530
- outputs: Output[],
624
+ inputs: TArg<psbt.TransactionInputUpdate[]>,
625
+ outputs: TArg<Output[]>,
531
626
  strategy: SelectionStrategy,
532
- opts: EstimatorOpts
627
+ opts: TArg<EstimatorOpts>
533
628
  ) {
534
- // Defaults: do we want bip69 by default?
535
- const _opts = { createTx: true, bip69: true, ...opts };
536
- const est = new _Estimator(inputs, outputs, _opts);
629
+ aarray(inputs, 'inputs');
630
+ aarray(outputs, 'outputs');
631
+ validateObject(opts as Record<string, any>, {}, {}, 'opts');
632
+ astring(strategy, 'strategy');
633
+ // Public wrapper defaults to BIP69 ordering and tx construction unless callers override them.
634
+ const _opts = { createTx: true, bip69: true, ...(opts as EstimatorOpts) };
635
+ const est = new _Estimator(inputs as psbt.TransactionInputUpdate[], outputs as Output[], _opts);
537
636
  return est.result(strategy);
538
637
  }