@scure/btc-signer 2.2.0 → 2.4.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.
@@ -1,6 +1,14 @@
1
1
  import { hex } from '@scure/base';
2
+ import { anumber } from '@noble/hashes/utils.js';
2
3
  import * as P from 'micro-packed';
3
- import { Address, type CustomScript, OutScript, checkScript, tapLeafHash } from './payment.ts';
4
+ import {
5
+ Address,
6
+ type CustomScript,
7
+ OutScript,
8
+ _WitnessOutScript,
9
+ checkScript,
10
+ tapLeafHash,
11
+ } from './payment.ts';
4
12
  import * as psbt from './psbt.ts';
5
13
  import {
6
14
  CompactSizeLen,
@@ -18,16 +26,22 @@ import * as u from './utils.ts';
18
26
  import {
19
27
  type Bytes,
20
28
  NETWORK,
29
+ abigint,
21
30
  concatBytes,
22
31
  equalBytes,
23
32
  isBytes,
24
33
  type TArg,
25
34
  type TRet,
35
+ validateObject,
26
36
  } from './utils.ts';
27
37
 
38
+ // Be friendly to bad ECMAScript parsers by not using bigint literals.
39
+ // prettier-ignore
40
+ const _0n = /* @__PURE__ */ BigInt(0), _1n = /* @__PURE__ */ BigInt(1);
41
+ const U64_MAX = /* @__PURE__ */ BigInt('0xffffffffffffffff');
28
42
  const EMPTY32: Uint8Array = /* @__PURE__ */ new Uint8Array(32);
29
43
  const EMPTY_OUTPUT: P.UnwrapCoder<typeof RawOutput> = {
30
- amount: 0xffffffffffffffffn,
44
+ amount: U64_MAX,
31
45
  script: P.EMPTY,
32
46
  };
33
47
  /**
@@ -48,7 +62,7 @@ const stripCodeSeparator = (script: TArg<Bytes>): TRet<Bytes> => {
48
62
  // byte, because semantic decode/re-encode would change the signed digest.
49
63
  let start = 0;
50
64
  const out: Uint8Array[] = [];
51
- for (let i = 0; i < script.length; ) {
65
+ for (let i = 0; i < script.length;) {
52
66
  const pos = i;
53
67
  const op = script[i++];
54
68
  if (op === OP.CODESEPARATOR) {
@@ -158,6 +172,8 @@ export function cloneDeep<T>(obj: T): T {
158
172
 
159
173
  // Mostly security features, hardened defaults;
160
174
  // but you still can parse other people tx with unspendable outputs and stuff if you want
175
+ /** PSBT unknown/proprietary-field handling policy. */
176
+ export type Unknowns = psbt.Unknowns;
161
177
  /** Transaction construction and parsing options. */
162
178
  export interface TxOpts {
163
179
  /** Transaction version to place into new transactions and imported PSBTs. */
@@ -187,7 +203,7 @@ export interface TxOpts {
187
203
  /** Allow signing and finalizing inputs with unknown script shapes. */
188
204
  allowUnknownInputs?: boolean;
189
205
  // Check input/output scripts for sanity
190
- /** Skip redeem-script and witness-script consistency checks. */
206
+ /** Skip redeem/witness-script and Taproot commitment consistency checks. */
191
207
  disableScriptCheck?: boolean;
192
208
  // There is strange behaviour where tx without outputs encoded with empty output in the end,
193
209
  // tx without outputs in BIP174 doesn't have itb
@@ -197,12 +213,30 @@ export interface TxOpts {
197
213
  // result paying higher mining fee
198
214
  /** Permit legacy inputs that only provide witness UTXO data. */
199
215
  allowLegacyWitnessUtxo?: boolean;
216
+ /**
217
+ * Before signing, require every input to provide a full previous transaction whose txid and
218
+ * selected output match the input. Use this for untrusted or multi-party PSBTs to prevent forged
219
+ * witness-UTXO amounts from hiding an excessive transaction fee.
220
+ */
221
+ strictPrevoutValidation?: boolean;
200
222
  /** Grind ECDSA signatures until they use a low-R encoding. */
201
223
  lowR?: boolean;
202
224
  /** UNSAFE: additional custom payment-script codecs and finalizers. */
203
225
  customScripts?: CustomScript[];
204
- // Allow to add additional unknown keys/values to the "unknown" array member
205
- /** Preserve unknown PSBT key/value pairs instead of stripping them. */
226
+ /** Unknown PSBT field policy. Defaults to `strip`. */
227
+ unknown?: Unknowns;
228
+ /** Proprietary PSBT field policy. Defaults to the resolved {@link unknown} policy. */
229
+ proprietary?: Unknowns;
230
+ /**
231
+ * Treat an absent PSBTv2 transaction-modifiable field as allowing input/output changes. Older
232
+ * scure versions emitted PSBTv2 without this field, so this opts out of strict BIP370 behavior
233
+ * when upgrading and editing their PSBTs.
234
+ */
235
+ allowMissingTxModifiable?: boolean;
236
+ /**
237
+ * Deprecated alias for {@link unknown}: true selects `ignore`, false selects `strip`.
238
+ * @deprecated Use `unknown`.
239
+ */
206
240
  allowUnknown?: boolean;
207
241
  }
208
242
 
@@ -301,6 +335,7 @@ function outputBeforeSign(i: TArg<psbt.TransactionOutput>): TRet<psbt.Transactio
301
335
  * ```
302
336
  */
303
337
  export function inputBeforeSign(i: TArg<psbt.TransactionInput>): TRet<TransactionInputRequired> {
338
+ validateObject(i as Record<string, any>, {}, {}, 'i');
304
339
  if (i.txid === undefined || i.index === undefined)
305
340
  throw new Error('Transaction/input: txid and index required');
306
341
  const res = {
@@ -314,13 +349,65 @@ export function inputBeforeSign(i: TArg<psbt.TransactionInput>): TRet<Transactio
314
349
  RawInput.encode(res);
315
350
  return res as TRet<TransactionInputRequired>;
316
351
  }
317
- function cleanFinalInput(i: TArg<PSBTInputs>) {
352
+ type ExtensionMap = { unknown?: unknown[]; proprietary?: unknown[] };
353
+ const cleanExtensions = <T extends ExtensionMap>(
354
+ map: T,
355
+ unknownMode: Unknowns,
356
+ proprietaryMode: Unknowns,
357
+ rejectStrip = false
358
+ ): T => {
359
+ const out = { ...map } as T & Record<string, unknown>;
360
+ for (const [name, mode] of [
361
+ ['unknown', unknownMode],
362
+ ['proprietary', proprietaryMode],
363
+ ] as const) {
364
+ const value = out[name];
365
+ // Policy cleanup must not reinterpret malformed caller metadata as an empty keyed map.
366
+ if (value !== undefined) u.aarray(value, `${name} PSBT field`);
367
+ const rows = value as unknown[] | undefined;
368
+ if (!rows?.length) {
369
+ delete out[name];
370
+ continue;
371
+ }
372
+ if (mode === 'strict')
373
+ throw new Error(`PSBT: ${name} PSBT field is not allowed in strict mode`);
374
+ if (mode === 'strip') {
375
+ // Silent stripping is appropriate at relay/cleanup boundaries. On direct mutation it would
376
+ // hide a caller bug by accepting metadata that can never become transaction state.
377
+ if (rejectStrip)
378
+ throw new Error(`PSBT: ${name} PSBT field cannot be supplied when policy is strip`);
379
+ delete out[name];
380
+ }
381
+ }
382
+ return out;
383
+ };
384
+
385
+ const cleanTxModifiable = (value: number | undefined, mode: Unknowns): number | undefined => {
386
+ if (value === undefined || !(value & ~0b111)) return value;
387
+ if (mode === 'strict') throw new Error('PSBT: unknown txModifiable bits in strict mode');
388
+ return mode === 'strip' ? value & 0b111 : value;
389
+ };
390
+
391
+ function cleanFinalInput(
392
+ i: TArg<PSBTInputs>,
393
+ unknownMode: Unknowns = 'strip',
394
+ proprietaryMode: Unknowns = 'strip'
395
+ ) {
318
396
  const _i = i as PSBTInputs;
397
+ const extensions = cleanExtensions(_i as ExtensionMap, unknownMode, proprietaryMode);
398
+ if (extensions.unknown) _i.unknown = extensions.unknown as PSBTInputs['unknown'];
399
+ else delete _i.unknown;
400
+ if (extensions.proprietary) _i.proprietary = extensions.proprietary as PSBTInputs['proprietary'];
401
+ else delete _i.proprietary;
319
402
  // BIP174 finalizers clear non-final input metadata after constructing final scripts/witnesses.
320
403
  // That intentionally drops sighashType here, so post-finalize mutation becomes conservative
321
- // until callers explicitly reopen the input by removing finalScriptSig/finalScriptWitness.
404
+ // until callers explicitly clear satisfaction by removing finalScriptSig/finalScriptWitness.
322
405
  for (const _k in _i) {
323
406
  const k = _k as keyof PSBTInputs;
407
+ // Proprietary records are cleanup metadata too, but callers may need their opaque protocol
408
+ // state after finalization for PSBT coordination outside transaction extraction. An empty
409
+ // keyed list encodes no records, so canonicalize it to absence like its serialized clone.
410
+ if (proprietaryMode === 'ignore' && k === 'proprietary' && _i.proprietary?.length) continue;
324
411
  if (!psbt.PSBTInputFinalKeys.includes(k)) delete _i[k];
325
412
  }
326
413
  }
@@ -343,9 +430,27 @@ function unpackSighash(hashType: number) {
343
430
  };
344
431
  }
345
432
 
433
+ const sighashScope = (sighash: number) => ({
434
+ sigInputs: sighash & SignatureHash.ANYONECANPAY,
435
+ sigOutputs: sighash === SignatureHash.DEFAULT ? SignatureHash.ALL : sighash & 0b11,
436
+ });
437
+
438
+ const normalizeUnknowns = (
439
+ name: 'unknown' | 'proprietary',
440
+ mode: Unknowns | undefined,
441
+ legacy: boolean | undefined,
442
+ fallback: Unknowns = 'strip'
443
+ ): Unknowns => {
444
+ const alias = legacy === undefined ? undefined : legacy ? 'ignore' : 'strip';
445
+ if (mode !== undefined && mode !== 'ignore' && mode !== 'strip' && mode !== 'strict')
446
+ throw new Error(`Transaction options wrong value: ${name}=${mode}`);
447
+ if (mode !== undefined && alias !== undefined && mode !== alias)
448
+ throw new Error(`Transaction options: conflicting ${name} options`);
449
+ return mode || alias || fallback;
450
+ };
451
+
346
452
  function validateOpts(opts: TArg<TxOpts>): TRet<Readonly<TxOpts>> {
347
- if (opts !== undefined && {}.toString.call(opts) !== '[object Object]')
348
- throw new Error(`Wrong object type for transaction options: ${opts}`);
453
+ if (opts !== undefined) validateObject(opts as Record<string, any>, {}, {}, 'opts');
349
454
 
350
455
  const _opts = {
351
456
  ...opts,
@@ -360,6 +465,7 @@ function validateOpts(opts: TArg<TxOpts>): TRet<Readonly<TxOpts>> {
360
465
  _opts.allowUnknownInputs = _opts.allowUnknowInput;
361
466
  if (typeof _opts.allowUnknowOutput !== 'undefined')
362
467
  _opts.allowUnknownOutputs = _opts.allowUnknowOutput;
468
+ if (_opts.allowMissingTxModifiable === undefined) _opts.allowMissingTxModifiable = true;
363
469
  if (typeof _opts.lockTime !== 'number') throw new Error('Transaction lock time should be number');
364
470
  P.U32LE.encode(_opts.lockTime); // Additional range checks that lockTime
365
471
  // There is no PSBT v1, and any new version will probably have fields which we don't know how to parse, which
@@ -374,20 +480,28 @@ function validateOpts(opts: TArg<TxOpts>): TRet<Readonly<TxOpts>> {
374
480
  'disableScriptCheck',
375
481
  'bip174jsCompat',
376
482
  'allowLegacyWitnessUtxo',
483
+ 'strictPrevoutValidation',
377
484
  'lowR',
485
+ 'allowUnknown',
486
+ 'allowMissingTxModifiable',
378
487
  ] as const) {
379
488
  const v = _opts[k];
380
489
  if (v === undefined) continue; // optional
381
490
  if (typeof v !== 'boolean')
382
491
  throw new Error(`Transation options wrong type: ${k}=${v} (${typeof v})`);
383
492
  }
493
+ _opts.unknown = normalizeUnknowns('unknown', _opts.unknown, _opts.allowUnknown);
494
+ _opts.proprietary = normalizeUnknowns('proprietary', _opts.proprietary, undefined, _opts.unknown);
384
495
  // 0 and -1 happens in tests
496
+ // With allowUnknownVersion any numeric version is fine; the ternary was inverted
497
+ // before 2026-07 (audit), which made the option throw for every numeric version.
385
498
  if (
386
499
  _opts.allowUnknownVersion
387
- ? typeof _opts.version === 'number'
500
+ ? typeof _opts.version !== 'number'
388
501
  : ![-1, 0, 1, 2, 3].includes(_opts.version)
389
502
  )
390
503
  throw new Error(`Unknown version: ${_opts.version}`);
504
+ P.I32LE.encode(_opts.version); // Validate the signed transaction-version wire domain.
391
505
  if (_opts.customScripts !== undefined) {
392
506
  const cs = _opts.customScripts;
393
507
  if (!Array.isArray(cs)) {
@@ -405,13 +519,117 @@ function validateOpts(opts: TArg<TxOpts>): TRet<Readonly<TxOpts>> {
405
519
  return Object.freeze(_opts) as TRet<Readonly<TxOpts>>;
406
520
  }
407
521
 
522
+ function checkTaprootInputCommitments(input: TArg<PSBTInputs>, prevScript: TArg<Bytes>): void {
523
+ const output = _WitnessOutScript.decode(prevScript);
524
+ const hasTaprootCommitments =
525
+ input.tapInternalKey !== undefined ||
526
+ input.tapMerkleRoot !== undefined ||
527
+ // Repeated keyed fields only exist on the PSBT wire when at least one entry is encoded.
528
+ !!input.tapLeafScript?.length;
529
+ if (output.type !== 'tr') {
530
+ if (hasTaprootCommitments)
531
+ throw new Error('validateInput: Taproot metadata without P2TR previous output');
532
+ return;
533
+ }
534
+
535
+ const checkOutputKey = (
536
+ internalKey: TArg<Bytes>,
537
+ merkleRoot: TArg<Bytes>,
538
+ parity?: number
539
+ ): void => {
540
+ const [outputKey, outputParity] = u.taprootTweakPubkey(internalKey, merkleRoot);
541
+ if (!equalBytes(outputKey, output.pubkey))
542
+ throw new Error('validateInput: Taproot commitment does not match previous output');
543
+ if (parity !== undefined && outputParity !== parity)
544
+ throw new Error('validateInput: Taproot control-block parity does not match previous output');
545
+ };
546
+
547
+ if (input.tapLeafScript) {
548
+ for (const [controlBlock, scriptWithVersion] of input.tapLeafScript) {
549
+ const leafVersion = scriptWithVersion[scriptWithVersion.length - 1];
550
+ const script = scriptWithVersion.subarray(0, -1);
551
+ let merkleRoot = tapLeafHash(script, leafVersion);
552
+ for (const sibling of controlBlock.merklePath) {
553
+ merkleRoot =
554
+ u.compareBytes(sibling, merkleRoot) === -1
555
+ ? u.tagSchnorr('TapBranch', sibling, merkleRoot)
556
+ : u.tagSchnorr('TapBranch', merkleRoot, sibling);
557
+ }
558
+ checkOutputKey(controlBlock.internalKey, merkleRoot, controlBlock.version & 1);
559
+ if (input.tapInternalKey && !equalBytes(input.tapInternalKey, controlBlock.internalKey))
560
+ throw new Error('validateInput: tapInternalKey does not match Taproot control block');
561
+ if (input.tapMerkleRoot && !equalBytes(input.tapMerkleRoot, merkleRoot))
562
+ throw new Error('validateInput: tapMerkleRoot does not match Taproot control block');
563
+ }
564
+ }
565
+
566
+ // A tree-bearing input can omit the aggregate root while still providing independently
567
+ // verifiable control blocks. With no leaves, an internal key without a root describes the
568
+ // standard key-only (empty-root) commitment.
569
+ if (input.tapInternalKey && (input.tapMerkleRoot || !input.tapLeafScript?.length))
570
+ checkOutputKey(input.tapInternalKey, input.tapMerkleRoot || P.EMPTY);
571
+ }
572
+
573
+ const LOCKTIME_THRESHOLD = 500_000_000;
574
+ function validateRequiredLocktimes(input: TArg<PSBTInputs>): void {
575
+ const height = input.requiredHeightLocktime;
576
+ if (height !== undefined) {
577
+ anumber(height, 'requiredHeightLocktime');
578
+ if (height === 0 || height >= LOCKTIME_THRESHOLD)
579
+ throw new RangeError(
580
+ `requiredHeightLocktime must be between 1 and ${LOCKTIME_THRESHOLD - 1}, got ${height}`
581
+ );
582
+ }
583
+ const time = input.requiredTimeLocktime;
584
+ if (time !== undefined) {
585
+ anumber(time, 'requiredTimeLocktime');
586
+ if (time < LOCKTIME_THRESHOLD || time > 0xffffffff)
587
+ throw new RangeError(
588
+ `requiredTimeLocktime must be between ${LOCKTIME_THRESHOLD} and 4294967295, got ${time}`
589
+ );
590
+ }
591
+ }
592
+
593
+ function resolvePSBTLocktime(
594
+ inputs: TArg<readonly PSBTInputs[]>,
595
+ fallback = DEFAULT_LOCKTIME
596
+ ): number {
597
+ let height = DEFAULT_LOCKTIME;
598
+ let time = DEFAULT_LOCKTIME;
599
+ let hasRequirements = false;
600
+ let heightSupported = true;
601
+ let timeSupported = true;
602
+ for (const input of inputs) {
603
+ validateRequiredLocktimes(input);
604
+ const hasHeight = input.requiredHeightLocktime !== undefined;
605
+ const hasTime = input.requiredTimeLocktime !== undefined;
606
+ if (!hasHeight && !hasTime) continue;
607
+ hasRequirements = true;
608
+ if (hasHeight) height = Math.max(height, input.requiredHeightLocktime!);
609
+ else heightSupported = false;
610
+ if (hasTime) time = Math.max(time, input.requiredTimeLocktime!);
611
+ else timeSupported = false;
612
+ }
613
+ if (!hasRequirements) return fallback;
614
+ // BIP370 requires height when every relevant input supports both domains.
615
+ if (heightSupported) return height;
616
+ if (timeSupported) return time;
617
+ throw new Error('PSBTv2: incompatible height-based and time-based locktime requirements');
618
+ }
619
+
408
620
  // NOTE: we cannot do this inside PSBTInput coder, because there is no index/txid at this point!
409
- function validateInput(i: TArg<psbt.TransactionInput>): TRet<PSBTInputs> {
621
+ function validateInput(
622
+ i: TArg<psbt.TransactionInput>,
623
+ disableScriptCheck = false
624
+ ): TRet<PSBTInputs> {
625
+ validateObject(i as Record<string, any>, {}, {}, 'i');
410
626
  const _i = i as PSBTInputs;
627
+ validateRequiredLocktimes(_i);
628
+ let prevOut: P.UnwrapCoder<typeof RawOutput> | undefined;
411
629
  if (_i.nonWitnessUtxo && _i.index !== undefined) {
412
630
  const last = _i.nonWitnessUtxo.outputs.length - 1;
413
631
  if (_i.index > last) throw new Error(`validateInput: index(${_i.index}) not in nonWitnessUtxo`);
414
- const prevOut = _i.nonWitnessUtxo.outputs[_i.index];
632
+ prevOut = _i.nonWitnessUtxo.outputs[_i.index];
415
633
  if (
416
634
  _i.witnessUtxo &&
417
635
  (!equalBytes(_i.witnessUtxo.script, prevOut.script) ||
@@ -433,6 +651,9 @@ function validateInput(i: TArg<psbt.TransactionInput>): TRet<PSBTInputs> {
433
651
  allowUnknownOutputs: true,
434
652
  disableScriptCheck: true,
435
653
  allowUnknownInputs: true,
654
+ // Consensus does not restrict nVersion; a previous tx with a non-standard
655
+ // version is still spendable and its txid must still be verifiable.
656
+ allowUnknownVersion: true,
436
657
  });
437
658
  const txid = hex.encode(_i.txid);
438
659
  // BIP174 requires the provided nonWitnessUtxo to hash to the prevout txid even when the
@@ -442,6 +663,10 @@ function validateInput(i: TArg<psbt.TransactionInput>): TRet<PSBTInputs> {
442
663
  // for any byte-order conversions required by their wire formats.
443
664
  if (tx.id !== txid) throw new Error(`nonWitnessUtxo: wrong txid, exp=${txid} got=${tx.id}`);
444
665
  }
666
+ } else if (_i.witnessUtxo) prevOut = _i.witnessUtxo;
667
+ if (prevOut && !disableScriptCheck) {
668
+ checkScript(prevOut.script, _i.redeemScript, _i.witnessScript);
669
+ checkTaprootInputCommitments(_i, prevOut.script);
445
670
  }
446
671
  return _i as TRet<PSBTInputs>;
447
672
  }
@@ -452,6 +677,35 @@ export type PSBTInputs = psbt.PSBTKeyMapKeys<typeof psbt.PSBTInput>;
452
677
  /** Canonical PSBT output shape used by the coder layer. */
453
678
  export type PSBTOutputs = psbt.PSBTKeyMapKeys<typeof psbt.PSBTOutput>;
454
679
 
680
+ type InputSignature = { sighash: number; taproot: boolean; scriptPath: boolean };
681
+
682
+ const inputSignatures = (input: TArg<PSBTInputs>): InputSignature[] => {
683
+ const _input = input as PSBTInputs;
684
+ const res: InputSignature[] = [];
685
+ const add = (signature: TArg<Bytes>, taproot: boolean, scriptPath = false) => {
686
+ const sig = signature as Bytes;
687
+ if (!sig.length) return;
688
+ // Taproot's 64-byte encoding omits the SIGHASH_DEFAULT byte; every other PSBT signature
689
+ // carries its sighash in the final byte, including signatures imported from another signer.
690
+ const sighash = taproot && sig.length === 64 ? SignatureHash.DEFAULT : sig[sig.length - 1];
691
+ res.push({ sighash, taproot, scriptPath });
692
+ };
693
+ for (const [, signature] of _input.partialSig || []) add(signature, false);
694
+ if (_input.tapKeySig) add(_input.tapKeySig, true);
695
+ for (const [, signature] of _input.tapScriptSig || []) add(signature, true, true);
696
+ return res;
697
+ };
698
+
699
+ const inputSignedKeys = {
700
+ // sighashType is signer policy rather than a digest byte, but changing it after one signature
701
+ // exists would make later signers interpret the same input under a different policy.
702
+ self: ['txid', 'index', 'sequence', 'nonWitnessUtxo', 'witnessUtxo', 'sighashType'],
703
+ ecdsa: ['redeemScript', 'witnessScript'],
704
+ tapscript: ['tapLeafScript'],
705
+ cross: ['txid', 'index'],
706
+ prevout: ['nonWitnessUtxo', 'witnessUtxo'],
707
+ } as const satisfies Record<string, readonly (keyof PSBTInputs)[]>;
708
+
455
709
  // Normalizes input
456
710
  /**
457
711
  * Extracts the previous output referenced by an input.
@@ -465,6 +719,7 @@ export type PSBTOutputs = psbt.PSBTKeyMapKeys<typeof psbt.PSBTOutput>;
465
719
  * ```
466
720
  */
467
721
  export function getPrevOut(input: TArg<psbt.TransactionInput>): P.UnwrapCoder<typeof RawOutput> {
722
+ validateObject(input as Record<string, any>, {}, {}, 'input');
468
723
  const _input = input as PSBTInputs;
469
724
  if (_input.nonWitnessUtxo) {
470
725
  if (_input.index === undefined) throw new Error('Unknown input index');
@@ -478,8 +733,17 @@ export function getPrevOut(input: TArg<psbt.TransactionInput>): P.UnwrapCoder<ty
478
733
  )
479
734
  throw new Error(`Wrong input index=${_input.index}`);
480
735
  return _input.nonWitnessUtxo.outputs[_input.index];
481
- } else if (_input.witnessUtxo) return _input.witnessUtxo;
482
- else throw new Error('Cannot find previous output info');
736
+ } else if ('witnessUtxo' in _input) {
737
+ // The presence check catches malformed provided values; narrow after the guard for TS.
738
+ const prev = _input.witnessUtxo as P.UnwrapCoder<typeof RawOutput>;
739
+ validateObject(prev as Record<string, any>, {}, {}, 'input.witnessUtxo');
740
+ abigint(prev.amount, 'input.witnessUtxo.amount');
741
+ if (!isBytes(prev.script))
742
+ throw new TypeError(
743
+ '"input.witnessUtxo.script" expected Uint8Array, got type=' + typeof prev.script
744
+ );
745
+ return prev;
746
+ } else throw new Error('Cannot find previous output info');
483
747
  }
484
748
 
485
749
  /**
@@ -487,9 +751,12 @@ export function getPrevOut(input: TArg<psbt.TransactionInput>): P.UnwrapCoder<ty
487
751
  * @param i - input update to normalize
488
752
  * @param cur - existing input value to merge with
489
753
  * @param allowedFields - fields that may still change on signed inputs
490
- * @param disableScriptCheck - whether to skip redeem/witness script sanity checks
491
- * @param allowUnknown - whether to keep unknown PSBT fields
754
+ * @param disableScriptCheck - whether to skip wrapper and Taproot commitment sanity checks
755
+ * @param unknown - handling policy for unknown PSBT fields
756
+ * @param proprietary - handling policy for proprietary PSBT fields
492
757
  * @returns Normalized PSBT input.
758
+ * @throws If the update conflicts with the existing input or its signatures. {@link Error}
759
+ * @throws If a numeric input field is outside its wire or protocol range. {@link RangeError}
493
760
  * @example
494
761
  * Accept hex txids from callers in the same display-order form used by `Transaction.id`, then
495
762
  * normalize them into the repo's internal `TransactionInput` shape.
@@ -508,8 +775,12 @@ export function normalizeInput(
508
775
  cur?: TArg<PSBTInputs>,
509
776
  allowedFields?: TArg<readonly (keyof PSBTInputs)[]>,
510
777
  disableScriptCheck = false,
511
- allowUnknown = false
778
+ unknown: Unknowns | boolean = 'strip',
779
+ proprietary: Unknowns | boolean = 'strip'
512
780
  ): TRet<PSBTInputs> {
781
+ validateObject(i as Record<string, any>, {}, {}, 'i');
782
+ if (cur !== undefined) validateObject(cur as Record<string, any>, {}, {}, 'cur');
783
+ if (allowedFields !== undefined) u.aarray(allowedFields, 'allowedFields');
513
784
  const _i = i as psbt.TransactionInputUpdate;
514
785
  const _cur = cur as PSBTInputs | undefined;
515
786
  const _allowedFields = allowedFields as readonly (keyof PSBTInputs)[] | undefined;
@@ -529,18 +800,26 @@ export function normalizeInput(
529
800
  if (!('nonWitnessUtxo' in _i) && res.nonWitnessUtxo === undefined) delete res.nonWitnessUtxo;
530
801
  if (res.sequence === undefined) res.sequence = DEFAULT_SEQUENCE;
531
802
  if (res.tapMerkleRoot === null) delete res.tapMerkleRoot;
532
- res = psbt.mergeKeyMap(psbt.PSBTInput, res, _cur, _allowedFields, allowUnknown) as PSBTInputs;
803
+ res = psbt.mergeKeyMap(
804
+ psbt.PSBTInput,
805
+ res,
806
+ _cur,
807
+ _allowedFields,
808
+ unknown,
809
+ proprietary
810
+ ) as PSBTInputs;
811
+ // An actual empty repeated field emits no PSBT keypairs. Canonicalize only arrays so malformed
812
+ // falsy values still reach the PSBT coder's validation instead of becoming valid absence.
813
+ if (Array.isArray(res.tapLeafScript) && !res.tapLeafScript.length) delete res.tapLeafScript;
814
+ validateRequiredLocktimes(res);
533
815
  // Public PSBT coder surface is wrapped with TArg/TRet for TS compatibility; normalizeInput keeps
534
816
  // the repo's historical raw internal shape and casts only at the validation boundary here.
535
817
  psbt.PSBTInputCoder.encode(res as Parameters<typeof psbt.PSBTInputCoder.encode>[0]); // Validates that everything is correct at this point
536
818
 
537
- let prevOut;
538
- if (res.nonWitnessUtxo && res.index !== undefined)
539
- prevOut = res.nonWitnessUtxo.outputs[res.index];
540
- else if (res.witnessUtxo) prevOut = res.witnessUtxo;
541
- if (prevOut && !disableScriptCheck)
542
- checkScript(prevOut && prevOut.script, res.redeemScript, res.witnessScript);
543
- return res as TRet<PSBTInputs>;
819
+ // Direct construction and UTXO selection consume nonWitnessUtxo amounts without crossing a
820
+ // PSBT serialization boundary. Enforce the same outpoint binding here so a mismatched previous
821
+ // transaction cannot understate a legacy input amount and turn the difference into mining fees.
822
+ return validateInput(res as TArg<psbt.TransactionInput>, disableScriptCheck);
544
823
  }
545
824
 
546
825
  /**
@@ -572,10 +851,13 @@ export function getInputType(input: TArg<psbt.TransactionInput>, allowLegacyWitn
572
851
  let txType = 'legacy';
573
852
  let defaultSighash: number = SignatureHash.ALL;
574
853
  const prevOut = getPrevOut(_input as TArg<psbt.TransactionInput>);
575
- const first = OutScript.decode(prevOut.script);
854
+ const first = _WitnessOutScript.decode(prevOut.script);
576
855
  let type = first.type;
577
856
  let cur = first;
578
857
  const stack = [first];
858
+ // Classification is semantic, but legacy/BIP143 scriptCode and finalization must retain the
859
+ // exact committed spelling (including consensus-valid non-minimal pushes).
860
+ let lastScript = prevOut.script;
579
861
  if (first.type === 'tr') {
580
862
  // Expected invariant: taproot inputs use PSBT_IN_TAP_* metadata only;
581
863
  // legacy redeemScript/witnessScript fields belong to P2SH/P2WSH paths.
@@ -592,10 +874,11 @@ export function getInputType(input: TArg<psbt.TransactionInput>, allowLegacyWitn
592
874
  if (first.type === 'wpkh' || first.type === 'wsh') txType = 'segwit';
593
875
  if (first.type === 'sh') {
594
876
  if (!_input.redeemScript) throw new Error('inputType: sh without redeemScript');
595
- let child = OutScript.decode(_input.redeemScript);
877
+ let child = _WitnessOutScript.decode(_input.redeemScript);
596
878
  if (child.type === 'wpkh' || child.type === 'wsh') txType = 'segwit';
597
879
  stack.push(child);
598
880
  cur = child;
881
+ lastScript = _input.redeemScript;
599
882
  type += `-${child.type}`;
600
883
  }
601
884
  // wsh can be inside sh
@@ -605,12 +888,12 @@ export function getInputType(input: TArg<psbt.TransactionInput>, allowLegacyWitn
605
888
  if (child.type === 'wsh') txType = 'segwit';
606
889
  stack.push(child);
607
890
  cur = child;
891
+ lastScript = _input.witnessScript;
608
892
  type += `-${child.type}`;
609
893
  }
610
894
  const last = stack[stack.length - 1];
611
895
  if (last.type === 'sh' || last.type === 'wsh')
612
896
  throw new Error('inputType: sh/wsh cannot be terminal type');
613
- const lastScript = OutScript.encode(last);
614
897
  const res = {
615
898
  type,
616
899
  txType,
@@ -657,8 +940,62 @@ export class Transaction {
657
940
  constructor(opts: TxOpts = {}) {
658
941
  const _opts = (this.opts = validateOpts(opts));
659
942
  // Merge with global structure of PSBTv2
660
- if (_opts.lockTime !== DEFAULT_LOCKTIME) this.global.fallbackLocktime = _opts.lockTime;
943
+ // Bitcoin Core sets fallback even when it is zero. Matching its common encoding reduces the
944
+ // fingerprint of locally created PSBTv2s; imported PSBTs replace this map and retain omission.
945
+ this.global.fallbackLocktime = _opts.lockTime;
661
946
  this.global.txVersion = _opts.version;
947
+ // A locally-created PSBTv2 is still under construction. Imported PSBTs replace this global
948
+ // map below, so an omitted field there retains BIP370's immutable meaning.
949
+ if (_opts.PSBTVersion === 2) this.global.txModifiable = 0b011;
950
+ }
951
+
952
+ private isPSBTv2(): boolean {
953
+ return (this.global.version ?? this.opts.PSBTVersion) === 2;
954
+ }
955
+
956
+ private requireTxModifiable(bit: number, kind: 'inputs' | 'outputs'): void {
957
+ if (!this.isPSBTv2()) return;
958
+ if (!(this.txModifiablePolicy() & bit)) throw new Error(`PSBTv2: ${kind} are not modifiable`);
959
+ }
960
+
961
+ private txModifiablePolicy(
962
+ allowMissing = this.opts.allowMissingTxModifiable,
963
+ unknownMode = this.opts.unknown!
964
+ ): number {
965
+ if (!this.isPSBTv2()) return 0b011;
966
+ if (this.global.txModifiable !== undefined)
967
+ return cleanTxModifiable(this.global.txModifiable, unknownMode)!;
968
+ return allowMissing ? 0b011 : 0;
969
+ }
970
+
971
+ private modifiable(
972
+ allowMissing = this.opts.allowMissingTxModifiable,
973
+ unknownMode = this.opts.unknown!
974
+ ): number {
975
+ let flags = this.txModifiablePolicy(allowMissing, unknownMode);
976
+ let hasOpaqueFinal = false;
977
+ let hasSingle = false;
978
+ for (let idx = 0; idx < this.inputs.length; idx++) {
979
+ const signatures = inputSignatures(this.inputs[idx]);
980
+ if (!signatures.length && this.inputStatus(idx) === 'finalized') hasOpaqueFinal = true;
981
+ for (const { sighash } of signatures) {
982
+ const { isAny, isNone, isSingle } = unpackSighash(sighash);
983
+ if (!isAny) flags &= ~0b001;
984
+ if (!isNone) flags &= ~0b010;
985
+ if (isSingle) hasSingle = true;
986
+ }
987
+ }
988
+ // Bit 2 summarizes signatures rather than granting policy. Preserve an imported summary for
989
+ // opaque or externally managed state, and union in every signature visible to this object.
990
+ if (hasSingle) flags |= 0b100;
991
+ // PSBTv0 and legacy field-less PSBTv2 cannot describe an opaque finalized sighash. Promotion
992
+ // must therefore deny both mutations instead of manufacturing permissions from absence.
993
+ if (hasOpaqueFinal && this.global.txModifiable === undefined) flags &= ~0b011;
994
+ return flags;
995
+ }
996
+
997
+ private get txModifiable(): number {
998
+ return this.modifiable();
662
999
  }
663
1000
 
664
1001
  // Import
@@ -696,21 +1033,54 @@ export class Transaction {
696
1033
  const tx = new Transaction({ ...opts, version, lockTime, PSBTVersion });
697
1034
  // We need slice here, because otherwise
698
1035
  const inputCount = PSBTVersion === 0 ? unsigned?.inputs.length : parsed.global.inputCount;
699
- tx.inputs = parsed.inputs.slice(0, inputCount).map(
700
- (i, j) =>
701
- validateInput({
702
- finalScriptSig: P.EMPTY,
703
- ...parsed.global.unsignedTx?.inputs[j],
704
- ...i,
705
- }) as PSBTInputs
706
- );
1036
+ tx.inputs = parsed.inputs.slice(0, inputCount).map((i, j) => {
1037
+ const input = {
1038
+ ...parsed.global.unsignedTx?.inputs[j],
1039
+ ...i,
1040
+ };
1041
+ // The unsigned transaction's empty scriptSig is framing, not a PSBT_IN_FINAL_SCRIPTSIG
1042
+ // record. Keeping it makes combination conflict with an otherwise identical finalized PSBT.
1043
+ if (!i.finalScriptSig?.length) delete input.finalScriptSig;
1044
+ return validateInput(input, tx.opts.disableScriptCheck) as PSBTInputs;
1045
+ });
707
1046
  const outputCount = PSBTVersion === 0 ? unsigned?.outputs.length : parsed.global.outputCount;
708
- tx.outputs = parsed.outputs.slice(0, outputCount).map((i, j) => ({
1047
+ // bip174js writes a phantom empty input map when a PSBTv0 transaction has zero inputs. Raw v0
1048
+ // framing necessarily reads it as the first output map, so skip it before pairing real maps
1049
+ // with the unsigned transaction's declared outputs.
1050
+ const hasBip174InputMap =
1051
+ PSBTVersion === 0 &&
1052
+ inputCount === 0 &&
1053
+ Object.keys(parsed.outputs[0] || {}).length === 0 &&
1054
+ ((outputCount! > 0 && parsed.outputs.length === outputCount! + 1) ||
1055
+ (outputCount === 0 &&
1056
+ parsed.outputs.length === 2 &&
1057
+ Object.keys(parsed.outputs[1]).length === 0));
1058
+ const outputStart = hasBip174InputMap ? 1 : 0;
1059
+ tx.outputs = parsed.outputs.slice(outputStart, outputStart + outputCount!).map((i, j) => ({
709
1060
  ...i,
710
1061
  ...parsed.global.unsignedTx?.outputs[j],
711
1062
  }));
712
- tx.global = { ...parsed.global, txVersion: version }; // just in case proprietary/unknown fields
713
- if (lockTime !== DEFAULT_LOCKTIME) tx.global.fallbackLocktime = lockTime;
1063
+ const unknownMode = tx.opts.unknown!;
1064
+ const proprietaryMode = tx.opts.proprietary!;
1065
+ // Unknown PSBT rows can carry opaque metadata between participants. The documented default is
1066
+ // to strip them; callers that need forward compatibility must opt in explicitly. Proprietary
1067
+ // (0xfc) rows follow the same explicit policy, which defaults to the resolved unknown mode.
1068
+ tx.global = cleanExtensions(
1069
+ { ...parsed.global, txVersion: version },
1070
+ unknownMode,
1071
+ proprietaryMode
1072
+ );
1073
+ tx.inputs = tx.inputs.map((input) => cleanExtensions(input, unknownMode, proprietaryMode));
1074
+ tx.outputs = tx.outputs.map((output) => cleanExtensions(output, unknownMode, proprietaryMode));
1075
+ if (tx.global.txModifiable !== undefined)
1076
+ tx.global.txModifiable = cleanTxModifiable(tx.global.txModifiable, unknownMode);
1077
+ // A high-level Transaction must have a determinable nLockTime. Raw PSBT coders can still be
1078
+ // used by callers that need to inspect or relay a structurally valid but incompatible PSBT.
1079
+ resolvePSBTLocktime(tx.inputs, tx.global.fallbackLocktime ?? DEFAULT_LOCKTIME);
1080
+ // PSBTv0 always provides nLockTime in its unsigned transaction. Retain zero internally too so
1081
+ // promotion to v2 matches fresh construction and Bitcoin Core rather than gaining a
1082
+ // fingerprint.
1083
+ if (PSBTVersion === 0) tx.global.fallbackLocktime = def(lockTime, DEFAULT_LOCKTIME);
714
1084
  return tx;
715
1085
  }
716
1086
  // Prefer `global.version` when present so cross-version combiners can serialize at the highest
@@ -718,6 +1088,7 @@ export class Transaction {
718
1088
  toPSBT(
719
1089
  PSBTVersion: number | undefined = this.global.version || this.opts.PSBTVersion
720
1090
  ): Uint8Array {
1091
+ if (PSBTVersion !== undefined) anumber(PSBTVersion, 'PSBTVersion');
721
1092
  if (PSBTVersion !== 0 && PSBTVersion !== 2)
722
1093
  throw new Error(`Wrong PSBT version=${PSBTVersion}`);
723
1094
  // if (PSBTVersion === 0 && this.inputs.length === 0) {
@@ -726,9 +1097,17 @@ export class Transaction {
726
1097
  // );
727
1098
  // }
728
1099
  const inputs = this.inputs.map((i) =>
729
- // For PSBTv0 the prevout txid/index live in global.unsignedTx rather than the input map, so
730
- // validate the full transaction input before version filtering drops those fields.
731
- psbt.cleanPSBTFields(PSBTVersion, psbt.PSBTInput, validateInput(i) as TArg<PSBTInputs>)
1100
+ cleanExtensions(
1101
+ // For PSBTv0 the prevout txid/index live in global.unsignedTx rather than the input map, so
1102
+ // validate the full transaction input before version filtering drops those fields.
1103
+ psbt.cleanPSBTFields(
1104
+ PSBTVersion,
1105
+ psbt.PSBTInput,
1106
+ validateInput(i, this.opts.disableScriptCheck) as TArg<PSBTInputs>
1107
+ ),
1108
+ this.opts.unknown!,
1109
+ this.opts.proprietary!
1110
+ )
732
1111
  );
733
1112
  for (const inp of inputs) {
734
1113
  // Don't serialize empty fields
@@ -736,8 +1115,16 @@ export class Transaction {
736
1115
  if (inp.finalScriptSig && !inp.finalScriptSig.length) delete inp.finalScriptSig;
737
1116
  if (inp.finalScriptWitness && !inp.finalScriptWitness.length) delete inp.finalScriptWitness;
738
1117
  }
739
- const outputs = this.outputs.map((i) => psbt.cleanPSBTFields(PSBTVersion, psbt.PSBTOutput, i));
740
- const global = { ...this.global };
1118
+ const outputs = this.outputs.map((i) =>
1119
+ cleanExtensions(
1120
+ psbt.cleanPSBTFields(PSBTVersion, psbt.PSBTOutput, i),
1121
+ this.opts.unknown!,
1122
+ this.opts.proprietary!
1123
+ )
1124
+ );
1125
+ const global = cleanExtensions({ ...this.global }, this.opts.unknown!, this.opts.proprietary!);
1126
+ if (global.txModifiable !== undefined)
1127
+ global.txModifiable = cleanTxModifiable(global.txModifiable, this.opts.unknown!);
741
1128
  if (PSBTVersion === 0) {
742
1129
  /*
743
1130
  - Bitcoin raw transaction expects to have at least 1 input because it uses case with zero inputs as marker for SegWit
@@ -762,10 +1149,12 @@ export class Transaction {
762
1149
  delete global.txVersion;
763
1150
  // PSBTv0 carries the unsigned transaction as one blob, so the PSBTv2 framing fields must be
764
1151
  // removed here. Keeping `global.version` would make validation treat this rebuilt v0 map as
765
- // PSBTv2 and reject the required `unsignedTx` field.
1152
+ // PSBTv2 and reject the required `unsignedTx` field. Transaction-modifiable is also v2-only;
1153
+ // its restrictions remain represented by the signatures when explicitly converting to v0.
766
1154
  delete global.inputCount;
767
1155
  delete global.outputCount;
768
1156
  delete global.version;
1157
+ delete global.txModifiable;
769
1158
  } else {
770
1159
  // Cross-version merges and v0->v2 re-exports can still carry the PSBTv0 unsignedTx blob in
771
1160
  // `this.global`, but PSBTv2 serializes the transaction through split global/input/output
@@ -775,13 +1164,19 @@ export class Transaction {
775
1164
  global.txVersion = this.version;
776
1165
  global.inputCount = this.inputs.length;
777
1166
  global.outputCount = this.outputs.length;
778
- if (global.fallbackLocktime && global.fallbackLocktime === DEFAULT_LOCKTIME)
779
- delete global.fallbackLocktime;
780
- }
781
- if (this.opts.bip174jsCompat) {
782
- if (!inputs.length) inputs.push({});
783
- if (!outputs.length) outputs.push({});
1167
+ // Core serializes this optional field exactly as stored. Preserve no-op v2 round-trips;
1168
+ // only v0 promotion and the explicit legacy-omission compatibility mode materialize policy.
1169
+ if (
1170
+ !this.isPSBTv2() ||
1171
+ (global.txModifiable === undefined && this.opts.allowMissingTxModifiable)
1172
+ )
1173
+ global.txModifiable = this.txModifiable;
784
1174
  }
1175
+ // bip174js historically emits one empty output map for a PSBTv0 transaction with no outputs.
1176
+ // Input maps are count-framed by the unsigned transaction, so a phantom input map cannot be
1177
+ // represented: with zero inputs it would be decoded as an output map instead. PSBTv2 has
1178
+ // explicit counts for both map arrays and does not use this compatibility encoding.
1179
+ if (this.opts.bip174jsCompat && PSBTVersion === 0 && !outputs.length) outputs.push({});
785
1180
  const raw = { global, inputs, outputs };
786
1181
  return PSBTVersion === 0
787
1182
  ? psbt.RawPSBTV0.encode(raw as Parameters<typeof psbt.RawPSBTV0.encode>[0])
@@ -790,23 +1185,7 @@ export class Transaction {
790
1185
 
791
1186
  // BIP370 lockTime (https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki#determining-lock-time)
792
1187
  get lockTime(): number {
793
- let height = DEFAULT_LOCKTIME;
794
- let heightCnt = 0;
795
- let time = DEFAULT_LOCKTIME;
796
- let timeCnt = 0;
797
- for (const i of this.inputs) {
798
- if (i.requiredHeightLocktime) {
799
- height = Math.max(height, i.requiredHeightLocktime);
800
- heightCnt++;
801
- }
802
- if (i.requiredTimeLocktime) {
803
- time = Math.max(time, i.requiredTimeLocktime);
804
- timeCnt++;
805
- }
806
- }
807
- if (heightCnt && heightCnt >= timeCnt) return height;
808
- if (time !== DEFAULT_LOCKTIME) return time;
809
- return this.global.fallbackLocktime || DEFAULT_LOCKTIME;
1188
+ return resolvePSBTLocktime(this.inputs, this.global.fallbackLocktime ?? DEFAULT_LOCKTIME);
810
1189
  }
811
1190
 
812
1191
  get version(): number {
@@ -828,6 +1207,13 @@ export class Transaction {
828
1207
  if (input.partialSig && input.partialSig.length) return 'signed';
829
1208
  return 'unsigned';
830
1209
  }
1210
+ private cleanFinalInput(input: PSBTInputs): void {
1211
+ // Core preserves producer policy during finalization. Once cleanup makes signatures opaque,
1212
+ // signStatus conservatively locks transaction mutation until the input is explicitly reopened.
1213
+ cleanFinalInput(input as TArg<PSBTInputs>, this.opts.unknown!, this.opts.proprietary!);
1214
+ if (this.global.txModifiable !== undefined)
1215
+ this.global.txModifiable = cleanTxModifiable(this.global.txModifiable, this.opts.unknown!);
1216
+ }
831
1217
  // Cannot replace unpackSighash, tests rely on very generic implemenetation with signing inputs outside of range
832
1218
  // We will lose some vectors -> smaller test coverage of preimages (very important!)
833
1219
  private inputSighash(idx: number) {
@@ -840,30 +1226,70 @@ export class Transaction {
840
1226
  // ALL + ANYONE -- specific input + all outputs
841
1227
  // NONE + ANYONE -- specific input + no outputs
842
1228
  // SINGLE -- specific inputs + output with same index
843
- const sigOutputs = sighash === SignatureHash.DEFAULT ? SignatureHash.ALL : sighash & 0b11;
844
- const sigInputs = sighash & SignatureHash.ANYONECANPAY;
845
- return { sigInputs, sigOutputs };
1229
+ return sighashScope(sighash);
846
1230
  }
847
1231
  // Very nice for debug purposes, but slow. If there is too much inputs/outputs to add, will be quadratic.
848
1232
  // Some cache will be nice, but there chance to have bugs with cache invalidation
849
- private signStatus() {
1233
+ private signatures() {
1234
+ const res: (InputSignature & { idx: number })[] = [];
1235
+ for (let idx = 0; idx < this.inputs.length; idx++) {
1236
+ const actual = inputSignatures(this.inputs[idx]);
1237
+ for (const signature of actual) res.push({ idx, ...signature });
1238
+ if (actual.length || this.inputStatus(idx) !== 'finalized') continue;
1239
+ let taproot = true;
1240
+ try {
1241
+ taproot = _WitnessOutScript.decode(getPrevOut(this.inputs[idx]).script).type === 'tr';
1242
+ } catch {
1243
+ // Finalization may remove the data needed to classify an imported opaque signature.
1244
+ // Treating it as Taproot conservatively protects the larger all-prevout commitment.
1245
+ }
1246
+ const declared = this.inputs[idx].sighashType;
1247
+ const sighash = declared === undefined ? SignatureHash.DEFAULT : declared;
1248
+ res.push({ idx, sighash, taproot, scriptPath: taproot });
1249
+ }
1250
+ return res;
1251
+ }
1252
+
1253
+ private signedInputKeys(idx: number, signatures = this.signatures()): (keyof PSBTInputs)[] {
1254
+ const res = new Set<keyof PSBTInputs>();
1255
+ const add = (keys: readonly (keyof PSBTInputs)[]) => {
1256
+ for (const key of keys) res.add(key);
1257
+ };
1258
+ for (const signature of signatures) {
1259
+ const { isAny, isNone, isSingle } = unpackSighash(signature.sighash);
1260
+ if (signature.idx === idx) {
1261
+ add(inputSignedKeys.self);
1262
+ if (signature.taproot) {
1263
+ if (signature.scriptPath) add(inputSignedKeys.tapscript);
1264
+ } else add(inputSignedKeys.ecdsa);
1265
+ } else if (!isAny) {
1266
+ add(inputSignedKeys.cross);
1267
+ // Legacy and BIP143 omit other sequences for NONE/SINGLE. BIP341 commits every sequence
1268
+ // whenever ANYONECANPAY is absent, independently of the output sighash mode.
1269
+ if (signature.taproot || (!isNone && !isSingle)) res.add('sequence');
1270
+ if (signature.taproot) add(inputSignedKeys.prevout);
1271
+ }
1272
+ }
1273
+ return [...res];
1274
+ }
1275
+
1276
+ private signStatus(signatures = this.signatures()) {
850
1277
  // if addInput or addOutput is not possible, then all inputs or outputs are signed
851
1278
  let addInput = true,
852
1279
  addOutput = true;
853
- let inputs = [],
854
- outputs = [];
855
- for (let idx = 0; idx < this.inputs.length; idx++) {
856
- const status = this.inputStatus(idx);
857
- // Unsigned input doesn't affect anything
858
- if (status === 'unsigned') continue;
859
- const { sigInputs, sigOutputs } = this.inputSighash(idx);
1280
+ let inputs: number[] = [],
1281
+ outputs: number[] = [];
1282
+ for (const { idx, sighash } of signatures) {
1283
+ const { sigInputs, sigOutputs } = sighashScope(sighash);
860
1284
  // Input type
861
- if (sigInputs === SignatureHash.ANYONECANPAY) inputs.push(idx);
862
- else addInput = false;
1285
+ if (sigInputs === SignatureHash.ANYONECANPAY) {
1286
+ if (!inputs.includes(idx)) inputs.push(idx);
1287
+ } else addInput = false;
863
1288
  // Output type
864
1289
  if (sigOutputs === SignatureHash.ALL) addOutput = false;
865
- else if (sigOutputs === SignatureHash.SINGLE) outputs.push(idx);
866
- else if (sigOutputs === SignatureHash.NONE) {
1290
+ else if (sigOutputs === SignatureHash.SINGLE) {
1291
+ if (!outputs.includes(idx)) outputs.push(idx);
1292
+ } else if (sigOutputs === SignatureHash.NONE) {
867
1293
  // Doesn't affect any outputs at all
868
1294
  } else throw new Error(`Wrong signature hash output type: ${sigOutputs}`);
869
1295
  }
@@ -878,27 +1304,33 @@ export class Transaction {
878
1304
 
879
1305
  // Info utils
880
1306
  get hasWitnesses(): boolean {
881
- let out = false;
882
1307
  for (const i of this.inputs)
883
- if (i.finalScriptWitness && i.finalScriptWitness.length) out = true;
884
- return out;
1308
+ if (i.finalScriptWitness && i.finalScriptWitness.length) return true;
1309
+ return false;
885
1310
  }
886
1311
  // https://en.bitcoin.it/wiki/Weight_units
887
1312
  get weight(): number {
888
1313
  if (!this.isFinal) throw new Error('Transaction is not finalized');
1314
+ // Serialized length of VarBytes(data) without allocating the encoded copy
1315
+ const varLen = (dataLen: number) => CompactSizeLen.encode(dataLen).length + dataLen;
1316
+ const hasWitnesses = this.hasWitnesses;
889
1317
  let out = 32;
890
1318
  // Outputs
891
1319
  const outputs = this.outputs.map(outputBeforeSign);
892
1320
  out += 4 * CompactSizeLen.encode(this.outputs.length).length;
893
- for (const o of outputs) out += 32 + 4 * VarBytes.encode(o.script).length;
1321
+ for (const o of outputs) out += 32 + 4 * varLen(o.script.length);
894
1322
  // Inputs
895
- if (this.hasWitnesses) out += 2;
1323
+ if (hasWitnesses) out += 2;
896
1324
  out += 4 * CompactSizeLen.encode(this.inputs.length).length;
897
1325
  for (const i of this.inputs) {
898
- out += 160 + 4 * VarBytes.encode(i.finalScriptSig || P.EMPTY).length;
1326
+ out += 160 + 4 * varLen((i.finalScriptSig || P.EMPTY).length);
899
1327
  // Once segwit serialization is active, every input contributes one witness vector, including
900
1328
  // legacy inputs whose empty vector still encodes as a single zero-item-count byte.
901
- if (this.hasWitnesses) out += RawWitness.encode(i.finalScriptWitness || []).length;
1329
+ if (hasWitnesses) {
1330
+ const witness = i.finalScriptWitness || [];
1331
+ out += CompactSizeLen.encode(witness.length).length;
1332
+ for (const w of witness) out += varLen(w.length);
1333
+ }
902
1334
  }
903
1335
  return out;
904
1336
  }
@@ -933,8 +1365,28 @@ export class Transaction {
933
1365
  }
934
1366
  // Input stuff
935
1367
  private checkInputIdx(idx: number) {
936
- if (!Number.isSafeInteger(idx) || 0 > idx || idx >= this.inputs.length)
937
- throw new Error(`Wrong input index=${idx}`);
1368
+ anumber(idx, 'idx');
1369
+ if (idx >= this.inputs.length) throw new Error(`Wrong input index=${idx}`);
1370
+ }
1371
+ private validatePrevoutsForSigning(): void {
1372
+ if (!this.opts.strictPrevoutValidation) return;
1373
+ for (let i = 0; i < this.inputs.length; i++) {
1374
+ const input = this.inputs[i];
1375
+ if (!input.nonWitnessUtxo) {
1376
+ throw new Error(
1377
+ `Transaction/sign: strictPrevoutValidation requires nonWitnessUtxo for input=${i}`
1378
+ );
1379
+ }
1380
+ if (input.txid === undefined || input.index === undefined) {
1381
+ throw new Error(
1382
+ `Transaction/sign: strictPrevoutValidation requires an outpoint for input=${i}`
1383
+ );
1384
+ }
1385
+ // A full previous transaction is only a trusted amount/script commitment after its txid and
1386
+ // selected output have been checked against the unsigned transaction. Also cross-check a
1387
+ // redundant witnessUtxo when one is present.
1388
+ validateInput(input as TArg<psbt.TransactionInput>, this.opts.disableScriptCheck);
1389
+ }
938
1390
  }
939
1391
  getInput(idx: number): psbt.TransactionInput {
940
1392
  this.checkInputIdx(idx);
@@ -945,15 +1397,32 @@ export class Transaction {
945
1397
  }
946
1398
  // Modification
947
1399
  addInput(input: TArg<psbt.TransactionInputUpdate>, _ignoreSignStatus = false): number {
948
- if (!_ignoreSignStatus && !this.signStatus().addInput)
949
- throw new Error('Tx has signed inputs, cannot add new one');
1400
+ validateObject(input as Record<string, any>, {}, {}, 'input');
1401
+ cleanExtensions(input as ExtensionMap, this.opts.unknown!, this.opts.proprietary!, true);
1402
+ this.requireTxModifiable(0b001, 'inputs');
1403
+ const signatures = _ignoreSignStatus ? undefined : this.signatures();
1404
+ const status = signatures && this.signStatus(signatures);
1405
+ if (status && !status.addInput) throw new Error('Tx has signed inputs, cannot add new one');
950
1406
  // normalizeInput preserves nested caller-owned byte arrays, so detach them here before the
951
1407
  // new input becomes transaction state and later caller mutation can rewrite it by aliasing.
952
- this.inputs.push(
953
- cloneDeep(
954
- normalizeInput(input, undefined, undefined, this.opts.disableScriptCheck)
955
- ) as PSBTInputs
1408
+ const normalized = cloneDeep(
1409
+ normalizeInput(
1410
+ input,
1411
+ undefined,
1412
+ undefined,
1413
+ this.opts.disableScriptCheck,
1414
+ this.opts.unknown!,
1415
+ this.opts.proprietary!
1416
+ )
1417
+ ) as PSBTInputs;
1418
+ const nextLockTime = resolvePSBTLocktime(
1419
+ [...this.inputs, normalized],
1420
+ this.global.fallbackLocktime ?? DEFAULT_LOCKTIME
956
1421
  );
1422
+ // ANYONECANPAY permits adding an outpoint, but every signature still commits to nLockTime.
1423
+ if (signatures?.length && nextLockTime !== this.lockTime)
1424
+ throw new Error('Tx has signed inputs, cannot change lockTime');
1425
+ this.inputs.push(normalized);
957
1426
  return this.inputs.length - 1;
958
1427
  }
959
1428
  updateInput(
@@ -962,28 +1431,59 @@ export class Transaction {
962
1431
  _ignoreSignStatus = false
963
1432
  ): void {
964
1433
  this.checkInputIdx(idx);
965
- let allowedFields = undefined;
966
- if (!_ignoreSignStatus) {
967
- const status = this.signStatus();
968
- if (!status.addInput || status.inputs.includes(idx))
969
- allowedFields = psbt.PSBTInputUnsignedKeys;
1434
+ cleanExtensions(input as ExtensionMap, this.opts.unknown!, this.opts.proprietary!, true);
1435
+ let allowedFields: (keyof PSBTInputs)[] | undefined;
1436
+ const signatures = _ignoreSignStatus ? undefined : this.signatures();
1437
+ if (signatures?.length) {
1438
+ if (this.inputStatus(idx) === 'finalized') {
1439
+ // Once finalized, only already-present signature/final fields may be repeated or removed.
1440
+ // In particular, do not let a native-SegWit final witness gain a stray finalScriptSig.
1441
+ allowedFields = psbt.PSBTInputSignatureKeys.filter(
1442
+ (key) => this.inputs[idx][key] !== undefined
1443
+ );
1444
+ } else {
1445
+ const signed = new Set(this.signedInputKeys(idx, signatures));
1446
+ if (signed.size)
1447
+ allowedFields = (Object.keys(psbt.PSBTInput) as (keyof PSBTInputs)[]).filter(
1448
+ (key) => !signed.has(key)
1449
+ );
1450
+ }
970
1451
  }
971
1452
  // normalizeInput preserves nested caller-owned byte arrays, so detach the merged result here
972
1453
  // before the updated input becomes transaction state and later caller mutation can rewrite it.
973
- this.inputs[idx] = cloneDeep(
1454
+ const normalized = cloneDeep(
974
1455
  normalizeInput(
975
1456
  input,
976
1457
  this.inputs[idx],
977
1458
  allowedFields,
978
1459
  this.opts.disableScriptCheck,
979
- this.opts.allowUnknown
1460
+ this.opts.unknown!,
1461
+ this.opts.proprietary!
980
1462
  )
981
1463
  ) as PSBTInputs;
1464
+ const inputs = this.inputs.slice();
1465
+ inputs[idx] = normalized;
1466
+ const nextLockTime = resolvePSBTLocktime(
1467
+ inputs,
1468
+ this.global.fallbackLocktime ?? DEFAULT_LOCKTIME
1469
+ );
1470
+ if (signatures?.length && nextLockTime !== this.lockTime)
1471
+ throw new Error('Tx has signed inputs, cannot change lockTime');
1472
+ const current = this.inputs[idx];
1473
+ const transactionChanged =
1474
+ current.index !== normalized.index ||
1475
+ def(current.sequence, DEFAULT_SEQUENCE) !== def(normalized.sequence, DEFAULT_SEQUENCE) ||
1476
+ (current.txid === undefined
1477
+ ? normalized.txid !== undefined
1478
+ : normalized.txid === undefined || !equalBytes(current.txid, normalized.txid)) ||
1479
+ nextLockTime !== this.lockTime;
1480
+ if (transactionChanged) this.requireTxModifiable(0b001, 'inputs');
1481
+ this.inputs[idx] = normalized;
982
1482
  }
983
1483
  // Output stuff
984
1484
  private checkOutputIdx(idx: number) {
985
- if (!Number.isSafeInteger(idx) || 0 > idx || idx >= this.outputs.length)
986
- throw new Error(`Wrong output index=${idx}`);
1485
+ anumber(idx, 'idx');
1486
+ if (idx >= this.outputs.length) throw new Error(`Wrong output index=${idx}`);
987
1487
  }
988
1488
  getOutput(idx: number): psbt.TransactionOutput {
989
1489
  this.checkOutputIdx(idx);
@@ -993,7 +1493,7 @@ export class Transaction {
993
1493
  const out = this.getOutput(idx);
994
1494
  if (!out.script) return;
995
1495
  return Address(network).encode(
996
- OutScript.decode(out.script) as Parameters<ReturnType<typeof Address>['encode']>[0]
1496
+ _WitnessOutScript.decode(out.script) as Parameters<ReturnType<typeof Address>['encode']>[0]
997
1497
  );
998
1498
  }
999
1499
 
@@ -1005,22 +1505,27 @@ export class Transaction {
1005
1505
  cur?: PSBTOutputs,
1006
1506
  allowedFields?: readonly (keyof typeof psbt.PSBTOutput)[]
1007
1507
  ): PSBTOutputs {
1508
+ validateObject(o as Record<string, any>, {}, {}, 'o');
1008
1509
  let { amount, script } = o;
1009
1510
  if (amount === undefined) amount = cur?.amount;
1010
- if (typeof amount !== 'bigint')
1011
- throw new Error(
1012
- `Wrong amount type, should be of type bigint in sats, but got ${amount} of type ${typeof amount}`
1013
- );
1511
+ amount = abigint(amount, 'o.amount');
1014
1512
  if (typeof script === 'string') script = hex.decode(script);
1015
1513
  if (script === undefined) script = cur?.script;
1016
1514
  let res: PSBTOutputs = { ...cur, ...(o as PSBTOutputs & { script?: string }), amount, script };
1017
1515
  if (res.amount === undefined) delete res.amount;
1018
- res = psbt.mergeKeyMap(psbt.PSBTOutput, res, cur, allowedFields, this.opts.allowUnknown);
1516
+ res = psbt.mergeKeyMap(
1517
+ psbt.PSBTOutput,
1518
+ res,
1519
+ cur,
1520
+ allowedFields,
1521
+ this.opts.unknown!,
1522
+ this.opts.proprietary!
1523
+ );
1019
1524
  psbt.PSBTOutputCoder.encode(res as Parameters<typeof psbt.PSBTOutputCoder.encode>[0]);
1020
1525
  if (
1021
1526
  res.script &&
1022
1527
  !this.opts.allowUnknownOutputs &&
1023
- OutScript.decode(res.script).type === 'unknown'
1528
+ _WitnessOutScript.decode(res.script).type === 'unknown'
1024
1529
  ) {
1025
1530
  throw new Error(
1026
1531
  'Transaction/output: unknown output script type, there is a chance that input is unspendable. Pass allowUnknownOutputs=true, if you sure'
@@ -1030,7 +1535,11 @@ export class Transaction {
1030
1535
  return res;
1031
1536
  }
1032
1537
  addOutput(o: TArg<psbt.TransactionOutputUpdate>, _ignoreSignStatus = false): number {
1033
- if (!_ignoreSignStatus && !this.signStatus().addOutput)
1538
+ cleanExtensions(o as ExtensionMap, this.opts.unknown!, this.opts.proprietary!, true);
1539
+ this.requireTxModifiable(0b010, 'outputs');
1540
+ const status = _ignoreSignStatus ? undefined : this.signStatus();
1541
+ // Appending the previously missing same-index output changes a SIGHASH_SINGLE digest.
1542
+ if (status && (!status.addOutput || status.outputs.includes(this.outputs.length)))
1034
1543
  throw new Error('Tx has signed outputs, cannot add new one');
1035
1544
  // normalizeOutput preserves nested caller-owned script bytes, so detach them here before the
1036
1545
  // new output becomes transaction state and later caller mutation can rewrite it by aliasing.
@@ -1043,6 +1552,7 @@ export class Transaction {
1043
1552
  _ignoreSignStatus = false
1044
1553
  ): void {
1045
1554
  this.checkOutputIdx(idx);
1555
+ cleanExtensions(output as ExtensionMap, this.opts.unknown!, this.opts.proprietary!, true);
1046
1556
  let allowedFields = undefined;
1047
1557
  if (!_ignoreSignStatus) {
1048
1558
  const status = this.signStatus();
@@ -1051,7 +1561,15 @@ export class Transaction {
1051
1561
  }
1052
1562
  // updateOutput replaces stored state with normalizeOutput(...) directly, so detach the result
1053
1563
  // before storing it or later caller mutation of `output.script` will rewrite transaction state.
1054
- this.outputs[idx] = cloneDeep(this.normalizeOutput(output, this.outputs[idx], allowedFields));
1564
+ const current = this.outputs[idx];
1565
+ const normalized = cloneDeep(this.normalizeOutput(output, current, allowedFields));
1566
+ const transactionChanged =
1567
+ current.amount !== normalized.amount ||
1568
+ (current.script === undefined
1569
+ ? normalized.script !== undefined
1570
+ : normalized.script === undefined || !equalBytes(current.script, normalized.script));
1571
+ if (transactionChanged) this.requireTxModifiable(0b010, 'outputs');
1572
+ this.outputs[idx] = normalized;
1055
1573
  }
1056
1574
  addOutputAddress(address: string, amount: bigint, network: u.BTC_NETWORK = NETWORK): number {
1057
1575
  return this.addOutput({
@@ -1065,7 +1583,7 @@ export class Transaction {
1065
1583
  }
1066
1584
  // Utils
1067
1585
  get fee(): bigint {
1068
- let res = 0n;
1586
+ let res = _0n;
1069
1587
  for (const i of this.inputs) {
1070
1588
  const prevOut = getPrevOut(i);
1071
1589
  if (!prevOut) throw new Error('Empty input amount');
@@ -1084,7 +1602,8 @@ export class Transaction {
1084
1602
  const { isAny, isNone, isSingle } = unpackSighash(hashType);
1085
1603
  if (idx < 0 || !Number.isSafeInteger(idx)) throw new Error(`Invalid input idx=${idx}`);
1086
1604
  if ((isSingle && idx >= this.outputs.length) || idx >= this.inputs.length)
1087
- return P.U256BE.encode(1n);
1605
+ // Bitcoin Core passes uint256::ONE's internal little-endian bytes directly to ECDSA.
1606
+ return P.U256LE.encode(_1n);
1088
1607
  prevOutScript = stripCodeSeparator(prevOutScript);
1089
1608
  let inputs: TransactionInputRequired[] = this.inputs
1090
1609
  .map(inputBeforeSign)
@@ -1124,8 +1643,8 @@ export class Transaction {
1124
1643
  ): Uint8Array {
1125
1644
  // BIP143 serializes txTo.vin[nIn].prevout and txTo.vin[nIn].nSequence, so reject an invalid
1126
1645
  // nIn explicitly instead of leaking a later undefined-input TypeError from inputs[idx].
1127
- if (idx < 0 || !Number.isSafeInteger(idx) || idx >= this.inputs.length)
1128
- throw new Error(`Invalid input idx=${idx}`);
1646
+ anumber(idx, 'idx');
1647
+ if (idx >= this.inputs.length) throw new Error(`Invalid input idx=${idx}`);
1129
1648
  const { isAny, isNone, isSingle } = unpackSighash(hashType);
1130
1649
  let inputHash = EMPTY32;
1131
1650
  let sequenceHash = EMPTY32;
@@ -1164,15 +1683,16 @@ export class Transaction {
1164
1683
  leafVer = 0xc0,
1165
1684
  annex?: Bytes
1166
1685
  ): Uint8Array {
1167
- if (!Array.isArray(amount) || this.inputs.length !== amount.length)
1168
- throw new Error(`Invalid amounts array=${amount}`);
1169
- if (!Array.isArray(prevOutScript) || this.inputs.length !== prevOutScript.length)
1170
- throw new Error(`Invalid prevOutScript array=${prevOutScript}`);
1171
1686
  // BIP341 SigMsg commits either to input_index or to the selected input's outpoint/amount/script/
1172
1687
  // sequence under ANYONECANPAY, so reject an invalid index explicitly instead of hashing a
1173
1688
  // nonexistent input or leaking a later integer-encoding RangeError for negative idx.
1174
- if (idx < 0 || !Number.isSafeInteger(idx) || idx >= this.inputs.length)
1175
- throw new Error(`Invalid input idx=${idx}`);
1689
+ anumber(idx, 'idx');
1690
+ if (idx >= this.inputs.length) throw new Error(`Invalid input idx=${idx}`);
1691
+ u.aarray(amount, 'amount');
1692
+ u.aarray(prevOutScript, 'prevOutScript');
1693
+ if (this.inputs.length !== amount.length) throw new Error(`Invalid amounts array=${amount}`);
1694
+ if (this.inputs.length !== prevOutScript.length)
1695
+ throw new Error(`Invalid prevOutScript array=${prevOutScript}`);
1176
1696
  const out: Bytes[] = [
1177
1697
  P.U8.encode(0),
1178
1698
  P.U8.encode(hashType), // U8 sigHash
@@ -1183,6 +1703,14 @@ export class Transaction {
1183
1703
  const inType = hashType & SignatureHash.ANYONECANPAY;
1184
1704
  const inputs = this.inputs.map(inputBeforeSign);
1185
1705
  const outputs = this.outputs.map(outputBeforeSign);
1706
+ // Unlike legacy and segwit v0, BIP341 defines no digest for SINGLE when the
1707
+ // corresponding output does not exist. Returning a digest here would produce
1708
+ // signatures that consensus can never accept.
1709
+ if (outType === SignatureHash.SINGLE && idx >= outputs.length) {
1710
+ throw new Error(
1711
+ `Input with sighash SINGLE, but there is no output with corresponding index=${idx}`
1712
+ );
1713
+ }
1186
1714
  if (inType !== SignatureHash.ANYONECANPAY) {
1187
1715
  out.push(
1188
1716
  ...[
@@ -1208,16 +1736,31 @@ export class Transaction {
1208
1736
  );
1209
1737
  } else out.push(P.U32LE.encode(idx));
1210
1738
  if (spendType & 1) out.push(u.sha256(VarBytes.encode(annex || P.EMPTY)));
1211
- if (outType === SignatureHash.SINGLE)
1212
- out.push(idx < outputs.length ? u.sha256(RawOutput.encode(outputs[idx])) : EMPTY32);
1739
+ if (outType === SignatureHash.SINGLE) out.push(u.sha256(RawOutput.encode(outputs[idx])));
1213
1740
  if (leafScript)
1214
1741
  out.push(tapLeafHash(leafScript, leafVer), P.U8.encode(0), P.I32LE.encode(codeSeparator));
1215
1742
  return u.tagSchnorr('TapSighash', ...out);
1216
1743
  }
1217
1744
  // Signer can be privateKey OR instance of bip32 HD stuff
1218
1745
  signIdx(privateKey: Signer, idx: number, allowedSighash?: SigHash[], _auxRand?: Bytes): boolean {
1746
+ if (!isBytes(privateKey)) {
1747
+ // HDKey is a structural external instance, so plain-object validation would
1748
+ // reject valid signers.
1749
+ if (
1750
+ !privateKey ||
1751
+ typeof privateKey !== 'object' ||
1752
+ typeof (privateKey as HDKey).deriveChild !== 'function'
1753
+ )
1754
+ throw new TypeError(
1755
+ '"privateKey" expected Uint8Array or HDKey, got type=' + typeof privateKey
1756
+ );
1757
+ }
1219
1758
  this.checkInputIdx(idx);
1220
- const input = this.inputs[idx];
1759
+ this.validatePrevoutsForSigning();
1760
+ const input = validateInput(
1761
+ this.inputs[idx] as TArg<psbt.TransactionInput>,
1762
+ this.opts.disableScriptCheck
1763
+ );
1221
1764
  const inputType = getInputType(
1222
1765
  input as TArg<psbt.TransactionInput>,
1223
1766
  this.opts.allowLegacyWitnessUtxo
@@ -1426,6 +1969,9 @@ export class Transaction {
1426
1969
  // Even worse: another user can add bip32 derivation, and spend money from different address.
1427
1970
  // Better api: signIdx
1428
1971
  sign(privateKey: Signer, allowedSighash?: number[], _auxRand?: Bytes): number {
1972
+ // Check transaction-wide strict requirements outside the per-input catch below so callers get
1973
+ // the actionable validation error instead of the generic "No inputs signed" result.
1974
+ this.validatePrevoutsForSigning();
1429
1975
  let num = 0;
1430
1976
  for (let i = 0; i < this.inputs.length; i++) {
1431
1977
  try {
@@ -1438,19 +1984,27 @@ export class Transaction {
1438
1984
 
1439
1985
  finalizeIdx(idx: number): void {
1440
1986
  this.checkInputIdx(idx);
1441
- if (this.fee < 0n) throw new Error('Outputs spends more than inputs amount');
1987
+ if (this.fee < _0n) throw new Error('Outputs spends more than inputs amount');
1442
1988
  const input = this.inputs[idx];
1989
+ // Validate strict extension policy before constructing satisfaction so a rejection is atomic.
1990
+ cleanExtensions(input, this.opts.unknown!, this.opts.proprietary!);
1991
+ cleanTxModifiable(this.global.txModifiable, this.opts.unknown!);
1443
1992
  const inputType = getInputType(input, this.opts.allowLegacyWitnessUtxo);
1444
1993
  // Taproot finalize
1445
1994
  if (inputType.txType === 'taproot') {
1446
1995
  if (input.tapKeySig) input.finalScriptWitness = [input.tapKeySig];
1447
1996
  else if (input.tapLeafScript && input.tapScriptSig) {
1448
- // Sort leafs by control block length.
1449
- const leafs = input.tapLeafScript.sort(
1450
- (a, b) =>
1451
- psbt.TaprootControlBlock.encode(a[0]).length -
1452
- psbt.TaprootControlBlock.encode(b[0]).length
1453
- );
1997
+ // Preserve the old shallowest-path tie-break without mutating caller-visible leaf order.
1998
+ const leafs = input.tapLeafScript
1999
+ .slice()
2000
+ .sort(
2001
+ (a, b) =>
2002
+ psbt.TaprootControlBlock.encode(a[0]).length -
2003
+ psbt.TaprootControlBlock.encode(b[0]).length
2004
+ );
2005
+ let smallest: Bytes[] | undefined;
2006
+ let smallestSize = Number.POSITIVE_INFINITY;
2007
+ let unsupported = false;
1454
2008
  for (const [cb, _script] of leafs) {
1455
2009
  // Last byte is version
1456
2010
  const script = _script.slice(0, -1);
@@ -1459,6 +2013,7 @@ export class Transaction {
1459
2013
  const hash = tapLeafHash(script, ver);
1460
2014
  const scriptSig = input.tapScriptSig.filter((i) => equalBytes(i[0].leafHash, hash));
1461
2015
  let signatures: Bytes[] = [];
2016
+ let witness: Bytes[] | undefined;
1462
2017
  if (outScript.type === 'tr_ms') {
1463
2018
  const m = outScript.m;
1464
2019
  const pubkeys = outScript.pubkeys;
@@ -1498,33 +2053,43 @@ export class Transaction {
1498
2053
  if (!signatures.length) continue;
1499
2054
  } else {
1500
2055
  const custom = this.opts.customScripts;
2056
+ let recognized = false;
1501
2057
  if (custom) {
1502
2058
  for (const c of custom) {
1503
2059
  if (!c.finalizeTaproot) continue;
1504
2060
  const scriptDecoded = Script.decode(script);
1505
2061
  const csEncoded = c.encode(scriptDecoded);
1506
2062
  if (csEncoded === undefined) continue;
2063
+ recognized = true;
2064
+ // Do not catch hook errors here. `undefined` means no satisfaction, while a throw
2065
+ // from a matching custom finalizer reports broken leaf/signature data and must
2066
+ // abort even when another leaf has already produced a valid witness candidate.
1507
2067
  const finalized = c.finalizeTaproot(script, csEncoded, scriptSig);
1508
2068
  if (!finalized) continue;
1509
- input.finalScriptWitness = finalized.concat(psbt.TaprootControlBlock.encode(cb));
1510
- delete input.finalScriptSig;
1511
- cleanFinalInput(input as TArg<PSBTInputs>);
1512
- return;
2069
+ witness = finalized.concat(psbt.TaprootControlBlock.encode(cb));
2070
+ break;
1513
2071
  }
1514
2072
  }
1515
- throw new Error('Finalize: Unknown tapLeafScript');
2073
+ // Minimum search inspects every leaf, so an unsupported path cannot block an already
2074
+ // complete known path merely because it appears later. Retain the old error when no
2075
+ // supported satisfaction exists at all.
2076
+ if (!witness && !recognized && scriptSig.length) unsupported = true;
2077
+ if (!witness) continue;
1516
2078
  }
1517
2079
  // Witness is stack, so last element will be used first
1518
- input.finalScriptWitness = signatures
1519
- .reverse()
1520
- .concat([script, psbt.TaprootControlBlock.encode(cb)]);
1521
- break;
2080
+ witness ||= signatures.reverse().concat([script, psbt.TaprootControlBlock.encode(cb)]);
2081
+ const size = RawWitness.encode(witness).length;
2082
+ if (size >= smallestSize) continue;
2083
+ smallest = witness;
2084
+ smallestSize = size;
1522
2085
  }
1523
- if (!input.finalScriptWitness) throw new Error('finalize/taproot: empty witness');
2086
+ if (!smallest && unsupported) throw new Error('Finalize: Unknown tapLeafScript');
2087
+ if (!smallest) throw new Error('finalize/taproot: empty witness');
2088
+ input.finalScriptWitness = smallest;
1524
2089
  } else throw new Error('finalize/taproot: unknown input');
1525
2090
  // BIP174 Input Finalizer: if scriptSig is empty for an input, 0x07 remains unset.
1526
2091
  delete input.finalScriptSig;
1527
- cleanFinalInput(input as TArg<PSBTInputs>);
2092
+ this.cleanFinalInput(input);
1528
2093
  return;
1529
2094
  }
1530
2095
  if (!input.partialSig || !input.partialSig.length) throw new Error('Not enough partial sign');
@@ -1584,7 +2149,7 @@ export class Transaction {
1584
2149
  if (!finalScriptSig && !finalScriptWitness) throw new Error('Unknown error finalizing input');
1585
2150
  if (finalScriptSig) input.finalScriptSig = finalScriptSig;
1586
2151
  if (finalScriptWitness) input.finalScriptWitness = finalScriptWitness;
1587
- cleanFinalInput(input as TArg<PSBTInputs>);
2152
+ this.cleanFinalInput(input);
1588
2153
  }
1589
2154
  finalize(): void {
1590
2155
  for (let i = 0; i < this.inputs.length; i++) this.finalizeIdx(i);
@@ -1592,19 +2157,31 @@ export class Transaction {
1592
2157
  extract(): Uint8Array {
1593
2158
  if (!this.isFinal) throw new Error('Transaction has unfinalized inputs');
1594
2159
  if (!this.outputs.length) throw new Error('Transaction has no outputs');
1595
- if (this.fee < 0n) throw new Error('Outputs spends more than inputs amount');
2160
+ if (this.fee < _0n) throw new Error('Outputs spends more than inputs amount');
1596
2161
  return this.toBytes(true, true);
1597
2162
  }
1598
2163
  combine(other: Transaction): this {
2164
+ if (!(other instanceof Transaction))
2165
+ throw new TypeError('"other" expected Transaction, got type=' + typeof other);
2166
+ // Match main's accumulator model: operation policy belongs to the receiver that is mutated.
2167
+ const opts = this.opts;
1599
2168
  // BIP174 combiners merge same-transaction PSBTs across versions and emit the highest required
1600
2169
  // version, so PSBTVersion mismatches are normalized below instead of treated as conflicts.
1601
2170
  const PSBTVersion = Math.max(this.opts.PSBTVersion || 0, other.opts.PSBTVersion || 0);
1602
- for (const k of ['version', 'lockTime'] as const) {
1603
- if (this.opts[k] !== other.opts[k]) {
2171
+ if (this.opts.version !== other.opts.version)
2172
+ throw new Error(
2173
+ `Transaction/combine: different version this=${this.opts.version} ` +
2174
+ `other=${other.opts.version}`
2175
+ );
2176
+ const thisV2 = this.isPSBTv2();
2177
+ const otherV2 = other.isPSBTv2();
2178
+ if (!thisV2 || !otherV2) {
2179
+ const thisLockTime = this.lockTime;
2180
+ const otherLockTime = other.lockTime;
2181
+ if (thisLockTime !== otherLockTime)
1604
2182
  throw new Error(
1605
- `Transaction/combine: different ${k} this=${this.opts[k]} other=${other.opts[k]}`
2183
+ `Transaction/combine: different lockTime this=${thisLockTime} other=${otherLockTime}`
1606
2184
  );
1607
- }
1608
2185
  }
1609
2186
  for (const k of ['inputs', 'outputs'] as const) {
1610
2187
  if (this[k].length !== other[k].length) {
@@ -1615,18 +2192,139 @@ export class Transaction {
1615
2192
  }
1616
2193
  // Same-transaction checks must compare the normalized unsigned tx bytes here: PSBTv0 stores
1617
2194
  // `global.unsignedTx`, while PSBTv2 reconstructs the same transaction from split fields.
1618
- if (!equalBytes(this.unsignedTx, other.unsignedTx))
2195
+ const unsignedTx = this.unsignedTx;
2196
+ if (!equalBytes(unsignedTx, other.unsignedTx))
1619
2197
  throw new Error(`Transaction/combine: different unsigned tx`);
1620
- this.global = psbt.mergeKeyMap(
2198
+ let txModifiable: number | undefined;
2199
+ if (thisV2 && otherV2) {
2200
+ // Core combines the stored optional bytes without deriving replacements from signatures.
2201
+ // Only explicit legacy-omission compatibility gives an absent field an effective value.
2202
+ const policy = (tx: Transaction) => {
2203
+ if (tx.global.txModifiable !== undefined)
2204
+ return cleanTxModifiable(tx.global.txModifiable, opts.unknown!)!;
2205
+ return opts.allowMissingTxModifiable ? tx.modifiable(true, opts.unknown!) : 0;
2206
+ };
2207
+ const a = policy(this);
2208
+ const b = policy(other);
2209
+ // Known mutability permissions use intersection and SIGHASH_SINGLE presence uses union.
2210
+ // Future flag bits must agree because this implementation does not know how to merge them.
2211
+ if ((a & ~0b111) !== (b & ~0b111))
2212
+ throw new Error('Transaction/combine: conflicting unknown txModifiable flags');
2213
+ txModifiable = (a & ~0b111) | (a & b & 0b011) | ((a | b) & 0b100);
2214
+ // Preserve Core's optional-field semantics when neither participant supplied policy.
2215
+ if (
2216
+ txModifiable === 0 &&
2217
+ this.global.txModifiable === undefined &&
2218
+ other.global.txModifiable === undefined &&
2219
+ !opts.allowMissingTxModifiable
2220
+ )
2221
+ txModifiable = undefined;
2222
+ } else if (thisV2) txModifiable = this.modifiable(opts.allowMissingTxModifiable, opts.unknown!);
2223
+ else if (otherV2) txModifiable = other.modifiable(opts.allowMissingTxModifiable, opts.unknown!);
2224
+ const thisGlobal = { ...this.global };
2225
+ const otherGlobal = { ...other.global };
2226
+ // PSBTv0 has no fallback-locktime field: fromPSBT caches unsignedTx.nLockTime there only for
2227
+ // effective-locktime resolution and v2 promotion. Do not merge that cache as a v2 wire value.
2228
+ if (thisV2 !== otherV2) {
2229
+ if (!thisV2) delete thisGlobal.fallbackLocktime;
2230
+ if (!otherV2) delete otherGlobal.fallbackLocktime;
2231
+ // BIP174 permits v0 to encode version zero explicitly or omit it. Remove only that v0
2232
+ // spelling before scalar conflicts; retaining the v2 field anchors repeated promotion when
2233
+ // the accumulator's original options still target v0.
2234
+ if (!thisV2) delete thisGlobal.version;
2235
+ if (!otherV2) delete otherGlobal.version;
2236
+ }
2237
+ // Transaction-modifiable has dedicated bitwise merge rules above. Fallback locktime is only
2238
+ // one input to the effective locktime resolved from the combined input maps, so retain receiver
2239
+ // precedence and validate the resulting unsigned transaction after those maps merge below.
2240
+ const fallbackLocktime =
2241
+ thisGlobal.fallbackLocktime !== undefined
2242
+ ? thisGlobal.fallbackLocktime
2243
+ : otherGlobal.fallbackLocktime;
2244
+ delete thisGlobal.txModifiable;
2245
+ delete otherGlobal.txModifiable;
2246
+ delete thisGlobal.fallbackLocktime;
2247
+ delete otherGlobal.fallbackLocktime;
2248
+ // Every ordinary global scalar must agree when both participants provide it; silently choosing
2249
+ // either value can detach extension metadata such as a BIP322 message from its signatures.
2250
+ const global = psbt.combineKeyMap(
1621
2251
  psbt.PSBTGlobal,
1622
- this.global,
1623
- other.global,
1624
- undefined,
1625
- this.opts.allowUnknown
2252
+ thisGlobal,
2253
+ otherGlobal,
2254
+ opts.unknown!,
2255
+ opts.proprietary!
1626
2256
  );
1627
- if (PSBTVersion) this.global.version = PSBTVersion;
1628
- for (let i = 0; i < this.inputs.length; i++) this.updateInput(i, other.inputs[i], true);
1629
- for (let i = 0; i < this.outputs.length; i++) this.updateOutput(i, other.outputs[i], true);
2257
+ if (fallbackLocktime !== undefined) global.fallbackLocktime = fallbackLocktime;
2258
+ if (PSBTVersion) global.version = PSBTVersion;
2259
+ if (txModifiable === undefined) delete global.txModifiable;
2260
+ else global.txModifiable = txModifiable;
2261
+ let hasOpaqueFinalizedV0 = false;
2262
+ const inputs = this.inputs.map((current, i) => {
2263
+ const currentFinal = this.inputStatus(i) === 'finalized';
2264
+ const otherFinal = other.inputStatus(i) === 'finalized';
2265
+ // Finalized v0 maps no longer contain the partial signatures needed to derive v2 flags.
2266
+ if ((!thisV2 && currentFinal) || (!otherV2 && otherFinal)) hasOpaqueFinalizedV0 = true;
2267
+ if (currentFinal && otherFinal) {
2268
+ // Two finalized PSBTs must describe the same complete satisfaction. Requiring matching
2269
+ // presence as well as matching values prevents combining witness-only and scriptSig-only
2270
+ // final states into a third, unreviewed satisfaction.
2271
+ for (const k of ['finalScriptSig', 'finalScriptWitness'] as const) {
2272
+ const currentHas = !!this.inputs[i][k]?.length;
2273
+ const otherHas = !!other.inputs[i][k]?.length;
2274
+ if (currentHas !== otherHas)
2275
+ throw new Error(`Transaction/combine: different finalized field=${k} input=${i}`);
2276
+ }
2277
+ }
2278
+ const combined = psbt.combineKeyMap(
2279
+ psbt.PSBTInput,
2280
+ current,
2281
+ other.inputs[i],
2282
+ opts.unknown!,
2283
+ opts.proprietary!
2284
+ ) as PSBTInputs;
2285
+ // A final satisfaction supersedes partial signatures and transient signing metadata. This
2286
+ // also avoids manufacturing a contradictory final+partial input from two valid PSBTs.
2287
+ if (currentFinal || otherFinal)
2288
+ cleanFinalInput(combined as TArg<PSBTInputs>, opts.unknown!, opts.proprietary!);
2289
+ return cloneDeep(
2290
+ normalizeInput(
2291
+ combined,
2292
+ undefined,
2293
+ undefined,
2294
+ opts.disableScriptCheck,
2295
+ opts.unknown!,
2296
+ opts.proprietary!
2297
+ )
2298
+ ) as PSBTInputs;
2299
+ });
2300
+ // A promoted opaque satisfaction may commit to both transaction halves. Clear only known
2301
+ // permissions; retain a v2 participant's SIGHASH_SINGLE indicator and any future flag bits.
2302
+ if (hasOpaqueFinalizedV0 && global.txModifiable !== undefined) global.txModifiable &= ~0b011;
2303
+ const candidate = new Transaction({ ...opts, PSBTVersion });
2304
+ const outputs = this.outputs.map((current, i) => {
2305
+ const combined = psbt.combineKeyMap(
2306
+ psbt.PSBTOutput,
2307
+ current,
2308
+ other.outputs[i],
2309
+ opts.unknown!,
2310
+ opts.proprietary!
2311
+ );
2312
+ return cloneDeep(candidate.normalizeOutput(combined));
2313
+ });
2314
+ // Build and validate a detached candidate before touching the receiver. Combining
2315
+ // complementary v2 locktime fields can otherwise create a different unsigned transaction.
2316
+ candidate.global = global;
2317
+ candidate.inputs = inputs;
2318
+ candidate.outputs = outputs;
2319
+ // A v0 input map can contribute signatures while the combined transaction is promoted to v2.
2320
+ // All-v2 restrictions were already intersected above, preserving mutual field omission.
2321
+ if ((!thisV2 || !otherV2) && candidate.isPSBTv2())
2322
+ candidate.global.txModifiable = candidate.txModifiable;
2323
+ if (!equalBytes(candidate.unsignedTx, unsignedTx))
2324
+ throw new Error('Transaction/combine: combined unsigned tx differs');
2325
+ this.global = candidate.global;
2326
+ this.inputs = candidate.inputs;
2327
+ this.outputs = candidate.outputs;
1630
2328
  return this;
1631
2329
  }
1632
2330
  clone(): Transaction {
@@ -1638,6 +2336,7 @@ export class Transaction {
1638
2336
  /**
1639
2337
  * Merges multiple PSBT blobs into one.
1640
2338
  * @param psbts - PSBT byte arrays to combine
2339
+ * @param opts - Transaction parsing, combination, and serialization options. See {@link TxOpts}.
1641
2340
  * @returns Combined PSBT bytes.
1642
2341
  * @throws If the PSBT list is empty or the partial transactions cannot be combined. {@link Error}
1643
2342
  * @example
@@ -1648,12 +2347,14 @@ export class Transaction {
1648
2347
  * PSBTCombine([psbt, psbt]);
1649
2348
  * ```
1650
2349
  */
1651
- export function PSBTCombine(psbts: TArg<Bytes[]>): TRet<Bytes> {
2350
+ export function PSBTCombine(psbts: TArg<Bytes[]>, opts: TArg<TxOpts> = {}): TRet<Bytes> {
1652
2351
  if (!psbts || !Array.isArray(psbts) || !psbts.length)
1653
2352
  throw new Error('PSBTCombine: wrong PSBT list');
1654
- const tx = Transaction.fromPSBT(psbts[0]);
1655
- for (let i = 1; i < psbts.length; i++) tx.combine(Transaction.fromPSBT(psbts[i]));
1656
- return tx.toPSBT() as TRet<Bytes>;
2353
+ // Options affect both map cleanup during combination and the encoding of the returned PSBT.
2354
+ const combineOpts = opts as TxOpts;
2355
+ const tx = Transaction.fromPSBT(psbts[0], combineOpts);
2356
+ for (let i = 1; i < psbts.length; i++) tx.combine(Transaction.fromPSBT(psbts[i], tx.opts));
2357
+ return tx.toPSBT(combineOpts.PSBTVersion) as TRet<Bytes>;
1657
2358
  }
1658
2359
 
1659
2360
  // Copy-pasted from bip32 derive, maybe do something like 'bip32.parsePath'?