@scure/btc-signer 2.3.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,7 +1,14 @@
1
1
  import { hex } from '@scure/base';
2
2
  import { anumber } from '@noble/hashes/utils.js';
3
3
  import * as P from 'micro-packed';
4
- 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';
5
12
  import * as psbt from './psbt.ts';
6
13
  import {
7
14
  CompactSizeLen,
@@ -10,6 +17,7 @@ import {
10
17
  RawInput,
11
18
  RawOutput,
12
19
  RawTx,
20
+ RawWitness,
13
21
  Script,
14
22
  scriptPushLen,
15
23
  VarBytes,
@@ -54,7 +62,7 @@ const stripCodeSeparator = (script: TArg<Bytes>): TRet<Bytes> => {
54
62
  // byte, because semantic decode/re-encode would change the signed digest.
55
63
  let start = 0;
56
64
  const out: Uint8Array[] = [];
57
- for (let i = 0; i < script.length; ) {
65
+ for (let i = 0; i < script.length;) {
58
66
  const pos = i;
59
67
  const op = script[i++];
60
68
  if (op === OP.CODESEPARATOR) {
@@ -164,6 +172,8 @@ export function cloneDeep<T>(obj: T): T {
164
172
 
165
173
  // Mostly security features, hardened defaults;
166
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;
167
177
  /** Transaction construction and parsing options. */
168
178
  export interface TxOpts {
169
179
  /** Transaction version to place into new transactions and imported PSBTs. */
@@ -193,7 +203,7 @@ export interface TxOpts {
193
203
  /** Allow signing and finalizing inputs with unknown script shapes. */
194
204
  allowUnknownInputs?: boolean;
195
205
  // Check input/output scripts for sanity
196
- /** Skip redeem-script and witness-script consistency checks. */
206
+ /** Skip redeem/witness-script and Taproot commitment consistency checks. */
197
207
  disableScriptCheck?: boolean;
198
208
  // There is strange behaviour where tx without outputs encoded with empty output in the end,
199
209
  // tx without outputs in BIP174 doesn't have itb
@@ -203,12 +213,30 @@ export interface TxOpts {
203
213
  // result paying higher mining fee
204
214
  /** Permit legacy inputs that only provide witness UTXO data. */
205
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;
206
222
  /** Grind ECDSA signatures until they use a low-R encoding. */
207
223
  lowR?: boolean;
208
224
  /** UNSAFE: additional custom payment-script codecs and finalizers. */
209
225
  customScripts?: CustomScript[];
210
- // Allow to add additional unknown keys/values to the "unknown" array member
211
- /** 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
+ */
212
240
  allowUnknown?: boolean;
213
241
  }
214
242
 
@@ -321,13 +349,65 @@ export function inputBeforeSign(i: TArg<psbt.TransactionInput>): TRet<Transactio
321
349
  RawInput.encode(res);
322
350
  return res as TRet<TransactionInputRequired>;
323
351
  }
324
- 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
+ ) {
325
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;
326
402
  // BIP174 finalizers clear non-final input metadata after constructing final scripts/witnesses.
327
403
  // That intentionally drops sighashType here, so post-finalize mutation becomes conservative
328
- // until callers explicitly reopen the input by removing finalScriptSig/finalScriptWitness.
404
+ // until callers explicitly clear satisfaction by removing finalScriptSig/finalScriptWitness.
329
405
  for (const _k in _i) {
330
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;
331
411
  if (!psbt.PSBTInputFinalKeys.includes(k)) delete _i[k];
332
412
  }
333
413
  }
@@ -350,6 +430,25 @@ function unpackSighash(hashType: number) {
350
430
  };
351
431
  }
352
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
+
353
452
  function validateOpts(opts: TArg<TxOpts>): TRet<Readonly<TxOpts>> {
354
453
  if (opts !== undefined) validateObject(opts as Record<string, any>, {}, {}, 'opts');
355
454
 
@@ -366,6 +465,7 @@ function validateOpts(opts: TArg<TxOpts>): TRet<Readonly<TxOpts>> {
366
465
  _opts.allowUnknownInputs = _opts.allowUnknowInput;
367
466
  if (typeof _opts.allowUnknowOutput !== 'undefined')
368
467
  _opts.allowUnknownOutputs = _opts.allowUnknowOutput;
468
+ if (_opts.allowMissingTxModifiable === undefined) _opts.allowMissingTxModifiable = true;
369
469
  if (typeof _opts.lockTime !== 'number') throw new Error('Transaction lock time should be number');
370
470
  P.U32LE.encode(_opts.lockTime); // Additional range checks that lockTime
371
471
  // There is no PSBT v1, and any new version will probably have fields which we don't know how to parse, which
@@ -380,13 +480,18 @@ function validateOpts(opts: TArg<TxOpts>): TRet<Readonly<TxOpts>> {
380
480
  'disableScriptCheck',
381
481
  'bip174jsCompat',
382
482
  'allowLegacyWitnessUtxo',
483
+ 'strictPrevoutValidation',
383
484
  'lowR',
485
+ 'allowUnknown',
486
+ 'allowMissingTxModifiable',
384
487
  ] as const) {
385
488
  const v = _opts[k];
386
489
  if (v === undefined) continue; // optional
387
490
  if (typeof v !== 'boolean')
388
491
  throw new Error(`Transation options wrong type: ${k}=${v} (${typeof v})`);
389
492
  }
493
+ _opts.unknown = normalizeUnknowns('unknown', _opts.unknown, _opts.allowUnknown);
494
+ _opts.proprietary = normalizeUnknowns('proprietary', _opts.proprietary, undefined, _opts.unknown);
390
495
  // 0 and -1 happens in tests
391
496
  // With allowUnknownVersion any numeric version is fine; the ternary was inverted
392
497
  // before 2026-07 (audit), which made the option throw for every numeric version.
@@ -414,14 +519,117 @@ function validateOpts(opts: TArg<TxOpts>): TRet<Readonly<TxOpts>> {
414
519
  return Object.freeze(_opts) as TRet<Readonly<TxOpts>>;
415
520
  }
416
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
+
417
620
  // NOTE: we cannot do this inside PSBTInput coder, because there is no index/txid at this point!
418
- function validateInput(i: TArg<psbt.TransactionInput>): TRet<PSBTInputs> {
621
+ function validateInput(
622
+ i: TArg<psbt.TransactionInput>,
623
+ disableScriptCheck = false
624
+ ): TRet<PSBTInputs> {
419
625
  validateObject(i as Record<string, any>, {}, {}, 'i');
420
626
  const _i = i as PSBTInputs;
627
+ validateRequiredLocktimes(_i);
628
+ let prevOut: P.UnwrapCoder<typeof RawOutput> | undefined;
421
629
  if (_i.nonWitnessUtxo && _i.index !== undefined) {
422
630
  const last = _i.nonWitnessUtxo.outputs.length - 1;
423
631
  if (_i.index > last) throw new Error(`validateInput: index(${_i.index}) not in nonWitnessUtxo`);
424
- const prevOut = _i.nonWitnessUtxo.outputs[_i.index];
632
+ prevOut = _i.nonWitnessUtxo.outputs[_i.index];
425
633
  if (
426
634
  _i.witnessUtxo &&
427
635
  (!equalBytes(_i.witnessUtxo.script, prevOut.script) ||
@@ -455,6 +663,10 @@ function validateInput(i: TArg<psbt.TransactionInput>): TRet<PSBTInputs> {
455
663
  // for any byte-order conversions required by their wire formats.
456
664
  if (tx.id !== txid) throw new Error(`nonWitnessUtxo: wrong txid, exp=${txid} got=${tx.id}`);
457
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);
458
670
  }
459
671
  return _i as TRet<PSBTInputs>;
460
672
  }
@@ -465,6 +677,35 @@ export type PSBTInputs = psbt.PSBTKeyMapKeys<typeof psbt.PSBTInput>;
465
677
  /** Canonical PSBT output shape used by the coder layer. */
466
678
  export type PSBTOutputs = psbt.PSBTKeyMapKeys<typeof psbt.PSBTOutput>;
467
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
+
468
709
  // Normalizes input
469
710
  /**
470
711
  * Extracts the previous output referenced by an input.
@@ -510,9 +751,12 @@ export function getPrevOut(input: TArg<psbt.TransactionInput>): P.UnwrapCoder<ty
510
751
  * @param i - input update to normalize
511
752
  * @param cur - existing input value to merge with
512
753
  * @param allowedFields - fields that may still change on signed inputs
513
- * @param disableScriptCheck - whether to skip redeem/witness script sanity checks
514
- * @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
515
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}
516
760
  * @example
517
761
  * Accept hex txids from callers in the same display-order form used by `Transaction.id`, then
518
762
  * normalize them into the repo's internal `TransactionInput` shape.
@@ -531,7 +775,8 @@ export function normalizeInput(
531
775
  cur?: TArg<PSBTInputs>,
532
776
  allowedFields?: TArg<readonly (keyof PSBTInputs)[]>,
533
777
  disableScriptCheck = false,
534
- allowUnknown = false
778
+ unknown: Unknowns | boolean = 'strip',
779
+ proprietary: Unknowns | boolean = 'strip'
535
780
  ): TRet<PSBTInputs> {
536
781
  validateObject(i as Record<string, any>, {}, {}, 'i');
537
782
  if (cur !== undefined) validateObject(cur as Record<string, any>, {}, {}, 'cur');
@@ -555,18 +800,26 @@ export function normalizeInput(
555
800
  if (!('nonWitnessUtxo' in _i) && res.nonWitnessUtxo === undefined) delete res.nonWitnessUtxo;
556
801
  if (res.sequence === undefined) res.sequence = DEFAULT_SEQUENCE;
557
802
  if (res.tapMerkleRoot === null) delete res.tapMerkleRoot;
558
- 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);
559
815
  // Public PSBT coder surface is wrapped with TArg/TRet for TS compatibility; normalizeInput keeps
560
816
  // the repo's historical raw internal shape and casts only at the validation boundary here.
561
817
  psbt.PSBTInputCoder.encode(res as Parameters<typeof psbt.PSBTInputCoder.encode>[0]); // Validates that everything is correct at this point
562
818
 
563
- let prevOut;
564
- if (res.nonWitnessUtxo && res.index !== undefined)
565
- prevOut = res.nonWitnessUtxo.outputs[res.index];
566
- else if (res.witnessUtxo) prevOut = res.witnessUtxo;
567
- if (prevOut && !disableScriptCheck)
568
- checkScript(prevOut && prevOut.script, res.redeemScript, res.witnessScript);
569
- 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);
570
823
  }
571
824
 
572
825
  /**
@@ -598,10 +851,13 @@ export function getInputType(input: TArg<psbt.TransactionInput>, allowLegacyWitn
598
851
  let txType = 'legacy';
599
852
  let defaultSighash: number = SignatureHash.ALL;
600
853
  const prevOut = getPrevOut(_input as TArg<psbt.TransactionInput>);
601
- const first = OutScript.decode(prevOut.script);
854
+ const first = _WitnessOutScript.decode(prevOut.script);
602
855
  let type = first.type;
603
856
  let cur = first;
604
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;
605
861
  if (first.type === 'tr') {
606
862
  // Expected invariant: taproot inputs use PSBT_IN_TAP_* metadata only;
607
863
  // legacy redeemScript/witnessScript fields belong to P2SH/P2WSH paths.
@@ -618,10 +874,11 @@ export function getInputType(input: TArg<psbt.TransactionInput>, allowLegacyWitn
618
874
  if (first.type === 'wpkh' || first.type === 'wsh') txType = 'segwit';
619
875
  if (first.type === 'sh') {
620
876
  if (!_input.redeemScript) throw new Error('inputType: sh without redeemScript');
621
- let child = OutScript.decode(_input.redeemScript);
877
+ let child = _WitnessOutScript.decode(_input.redeemScript);
622
878
  if (child.type === 'wpkh' || child.type === 'wsh') txType = 'segwit';
623
879
  stack.push(child);
624
880
  cur = child;
881
+ lastScript = _input.redeemScript;
625
882
  type += `-${child.type}`;
626
883
  }
627
884
  // wsh can be inside sh
@@ -631,12 +888,12 @@ export function getInputType(input: TArg<psbt.TransactionInput>, allowLegacyWitn
631
888
  if (child.type === 'wsh') txType = 'segwit';
632
889
  stack.push(child);
633
890
  cur = child;
891
+ lastScript = _input.witnessScript;
634
892
  type += `-${child.type}`;
635
893
  }
636
894
  const last = stack[stack.length - 1];
637
895
  if (last.type === 'sh' || last.type === 'wsh')
638
896
  throw new Error('inputType: sh/wsh cannot be terminal type');
639
- const lastScript = OutScript.encode(last);
640
897
  const res = {
641
898
  type,
642
899
  txType,
@@ -683,8 +940,62 @@ export class Transaction {
683
940
  constructor(opts: TxOpts = {}) {
684
941
  const _opts = (this.opts = validateOpts(opts));
685
942
  // Merge with global structure of PSBTv2
686
- 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;
687
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();
688
999
  }
689
1000
 
690
1001
  // Import
@@ -722,21 +1033,54 @@ export class Transaction {
722
1033
  const tx = new Transaction({ ...opts, version, lockTime, PSBTVersion });
723
1034
  // We need slice here, because otherwise
724
1035
  const inputCount = PSBTVersion === 0 ? unsigned?.inputs.length : parsed.global.inputCount;
725
- tx.inputs = parsed.inputs.slice(0, inputCount).map(
726
- (i, j) =>
727
- validateInput({
728
- finalScriptSig: P.EMPTY,
729
- ...parsed.global.unsignedTx?.inputs[j],
730
- ...i,
731
- }) as PSBTInputs
732
- );
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
+ });
733
1046
  const outputCount = PSBTVersion === 0 ? unsigned?.outputs.length : parsed.global.outputCount;
734
- 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) => ({
735
1060
  ...i,
736
1061
  ...parsed.global.unsignedTx?.outputs[j],
737
1062
  }));
738
- tx.global = { ...parsed.global, txVersion: version }; // just in case proprietary/unknown fields
739
- 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);
740
1084
  return tx;
741
1085
  }
742
1086
  // Prefer `global.version` when present so cross-version combiners can serialize at the highest
@@ -753,9 +1097,17 @@ export class Transaction {
753
1097
  // );
754
1098
  // }
755
1099
  const inputs = this.inputs.map((i) =>
756
- // For PSBTv0 the prevout txid/index live in global.unsignedTx rather than the input map, so
757
- // validate the full transaction input before version filtering drops those fields.
758
- 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
+ )
759
1111
  );
760
1112
  for (const inp of inputs) {
761
1113
  // Don't serialize empty fields
@@ -763,8 +1115,16 @@ export class Transaction {
763
1115
  if (inp.finalScriptSig && !inp.finalScriptSig.length) delete inp.finalScriptSig;
764
1116
  if (inp.finalScriptWitness && !inp.finalScriptWitness.length) delete inp.finalScriptWitness;
765
1117
  }
766
- const outputs = this.outputs.map((i) => psbt.cleanPSBTFields(PSBTVersion, psbt.PSBTOutput, i));
767
- 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!);
768
1128
  if (PSBTVersion === 0) {
769
1129
  /*
770
1130
  - Bitcoin raw transaction expects to have at least 1 input because it uses case with zero inputs as marker for SegWit
@@ -789,10 +1149,12 @@ export class Transaction {
789
1149
  delete global.txVersion;
790
1150
  // PSBTv0 carries the unsigned transaction as one blob, so the PSBTv2 framing fields must be
791
1151
  // removed here. Keeping `global.version` would make validation treat this rebuilt v0 map as
792
- // 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.
793
1154
  delete global.inputCount;
794
1155
  delete global.outputCount;
795
1156
  delete global.version;
1157
+ delete global.txModifiable;
796
1158
  } else {
797
1159
  // Cross-version merges and v0->v2 re-exports can still carry the PSBTv0 unsignedTx blob in
798
1160
  // `this.global`, but PSBTv2 serializes the transaction through split global/input/output
@@ -802,13 +1164,19 @@ export class Transaction {
802
1164
  global.txVersion = this.version;
803
1165
  global.inputCount = this.inputs.length;
804
1166
  global.outputCount = this.outputs.length;
805
- if (global.fallbackLocktime && global.fallbackLocktime === DEFAULT_LOCKTIME)
806
- delete global.fallbackLocktime;
807
- }
808
- if (this.opts.bip174jsCompat) {
809
- if (!inputs.length) inputs.push({});
810
- 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;
811
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({});
812
1180
  const raw = { global, inputs, outputs };
813
1181
  return PSBTVersion === 0
814
1182
  ? psbt.RawPSBTV0.encode(raw as Parameters<typeof psbt.RawPSBTV0.encode>[0])
@@ -817,23 +1185,7 @@ export class Transaction {
817
1185
 
818
1186
  // BIP370 lockTime (https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki#determining-lock-time)
819
1187
  get lockTime(): number {
820
- let height = DEFAULT_LOCKTIME;
821
- let heightCnt = 0;
822
- let time = DEFAULT_LOCKTIME;
823
- let timeCnt = 0;
824
- for (const i of this.inputs) {
825
- if (i.requiredHeightLocktime) {
826
- height = Math.max(height, i.requiredHeightLocktime);
827
- heightCnt++;
828
- }
829
- if (i.requiredTimeLocktime) {
830
- time = Math.max(time, i.requiredTimeLocktime);
831
- timeCnt++;
832
- }
833
- }
834
- if (heightCnt && heightCnt >= timeCnt) return height;
835
- if (time !== DEFAULT_LOCKTIME) return time;
836
- return this.global.fallbackLocktime || DEFAULT_LOCKTIME;
1188
+ return resolvePSBTLocktime(this.inputs, this.global.fallbackLocktime ?? DEFAULT_LOCKTIME);
837
1189
  }
838
1190
 
839
1191
  get version(): number {
@@ -855,6 +1207,13 @@ export class Transaction {
855
1207
  if (input.partialSig && input.partialSig.length) return 'signed';
856
1208
  return 'unsigned';
857
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
+ }
858
1217
  // Cannot replace unpackSighash, tests rely on very generic implemenetation with signing inputs outside of range
859
1218
  // We will lose some vectors -> smaller test coverage of preimages (very important!)
860
1219
  private inputSighash(idx: number) {
@@ -867,30 +1226,70 @@ export class Transaction {
867
1226
  // ALL + ANYONE -- specific input + all outputs
868
1227
  // NONE + ANYONE -- specific input + no outputs
869
1228
  // SINGLE -- specific inputs + output with same index
870
- const sigOutputs = sighash === SignatureHash.DEFAULT ? SignatureHash.ALL : sighash & 0b11;
871
- const sigInputs = sighash & SignatureHash.ANYONECANPAY;
872
- return { sigInputs, sigOutputs };
1229
+ return sighashScope(sighash);
873
1230
  }
874
1231
  // Very nice for debug purposes, but slow. If there is too much inputs/outputs to add, will be quadratic.
875
1232
  // Some cache will be nice, but there chance to have bugs with cache invalidation
876
- 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()) {
877
1277
  // if addInput or addOutput is not possible, then all inputs or outputs are signed
878
1278
  let addInput = true,
879
1279
  addOutput = true;
880
- let inputs = [],
881
- outputs = [];
882
- for (let idx = 0; idx < this.inputs.length; idx++) {
883
- const status = this.inputStatus(idx);
884
- // Unsigned input doesn't affect anything
885
- if (status === 'unsigned') continue;
886
- 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);
887
1284
  // Input type
888
- if (sigInputs === SignatureHash.ANYONECANPAY) inputs.push(idx);
889
- else addInput = false;
1285
+ if (sigInputs === SignatureHash.ANYONECANPAY) {
1286
+ if (!inputs.includes(idx)) inputs.push(idx);
1287
+ } else addInput = false;
890
1288
  // Output type
891
1289
  if (sigOutputs === SignatureHash.ALL) addOutput = false;
892
- else if (sigOutputs === SignatureHash.SINGLE) outputs.push(idx);
893
- 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) {
894
1293
  // Doesn't affect any outputs at all
895
1294
  } else throw new Error(`Wrong signature hash output type: ${sigOutputs}`);
896
1295
  }
@@ -969,6 +1368,26 @@ export class Transaction {
969
1368
  anumber(idx, 'idx');
970
1369
  if (idx >= this.inputs.length) throw new Error(`Wrong input index=${idx}`);
971
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
+ }
1390
+ }
972
1391
  getInput(idx: number): psbt.TransactionInput {
973
1392
  this.checkInputIdx(idx);
974
1393
  return cloneDeep(this.inputs[idx]) as psbt.TransactionInput;
@@ -979,15 +1398,31 @@ export class Transaction {
979
1398
  // Modification
980
1399
  addInput(input: TArg<psbt.TransactionInputUpdate>, _ignoreSignStatus = false): number {
981
1400
  validateObject(input as Record<string, any>, {}, {}, 'input');
982
- if (!_ignoreSignStatus && !this.signStatus().addInput)
983
- throw new Error('Tx has signed inputs, cannot add new one');
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');
984
1406
  // normalizeInput preserves nested caller-owned byte arrays, so detach them here before the
985
1407
  // new input becomes transaction state and later caller mutation can rewrite it by aliasing.
986
- this.inputs.push(
987
- cloneDeep(
988
- normalizeInput(input, undefined, undefined, this.opts.disableScriptCheck)
989
- ) 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
990
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);
991
1426
  return this.inputs.length - 1;
992
1427
  }
993
1428
  updateInput(
@@ -996,23 +1431,54 @@ export class Transaction {
996
1431
  _ignoreSignStatus = false
997
1432
  ): void {
998
1433
  this.checkInputIdx(idx);
999
- let allowedFields = undefined;
1000
- if (!_ignoreSignStatus) {
1001
- const status = this.signStatus();
1002
- if (!status.addInput || status.inputs.includes(idx))
1003
- 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
+ }
1004
1451
  }
1005
1452
  // normalizeInput preserves nested caller-owned byte arrays, so detach the merged result here
1006
1453
  // before the updated input becomes transaction state and later caller mutation can rewrite it.
1007
- this.inputs[idx] = cloneDeep(
1454
+ const normalized = cloneDeep(
1008
1455
  normalizeInput(
1009
1456
  input,
1010
1457
  this.inputs[idx],
1011
1458
  allowedFields,
1012
1459
  this.opts.disableScriptCheck,
1013
- this.opts.allowUnknown
1460
+ this.opts.unknown!,
1461
+ this.opts.proprietary!
1014
1462
  )
1015
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;
1016
1482
  }
1017
1483
  // Output stuff
1018
1484
  private checkOutputIdx(idx: number) {
@@ -1027,7 +1493,7 @@ export class Transaction {
1027
1493
  const out = this.getOutput(idx);
1028
1494
  if (!out.script) return;
1029
1495
  return Address(network).encode(
1030
- OutScript.decode(out.script) as Parameters<ReturnType<typeof Address>['encode']>[0]
1496
+ _WitnessOutScript.decode(out.script) as Parameters<ReturnType<typeof Address>['encode']>[0]
1031
1497
  );
1032
1498
  }
1033
1499
 
@@ -1047,12 +1513,19 @@ export class Transaction {
1047
1513
  if (script === undefined) script = cur?.script;
1048
1514
  let res: PSBTOutputs = { ...cur, ...(o as PSBTOutputs & { script?: string }), amount, script };
1049
1515
  if (res.amount === undefined) delete res.amount;
1050
- 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
+ );
1051
1524
  psbt.PSBTOutputCoder.encode(res as Parameters<typeof psbt.PSBTOutputCoder.encode>[0]);
1052
1525
  if (
1053
1526
  res.script &&
1054
1527
  !this.opts.allowUnknownOutputs &&
1055
- OutScript.decode(res.script).type === 'unknown'
1528
+ _WitnessOutScript.decode(res.script).type === 'unknown'
1056
1529
  ) {
1057
1530
  throw new Error(
1058
1531
  'Transaction/output: unknown output script type, there is a chance that input is unspendable. Pass allowUnknownOutputs=true, if you sure'
@@ -1062,7 +1535,11 @@ export class Transaction {
1062
1535
  return res;
1063
1536
  }
1064
1537
  addOutput(o: TArg<psbt.TransactionOutputUpdate>, _ignoreSignStatus = false): number {
1065
- 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)))
1066
1543
  throw new Error('Tx has signed outputs, cannot add new one');
1067
1544
  // normalizeOutput preserves nested caller-owned script bytes, so detach them here before the
1068
1545
  // new output becomes transaction state and later caller mutation can rewrite it by aliasing.
@@ -1075,6 +1552,7 @@ export class Transaction {
1075
1552
  _ignoreSignStatus = false
1076
1553
  ): void {
1077
1554
  this.checkOutputIdx(idx);
1555
+ cleanExtensions(output as ExtensionMap, this.opts.unknown!, this.opts.proprietary!, true);
1078
1556
  let allowedFields = undefined;
1079
1557
  if (!_ignoreSignStatus) {
1080
1558
  const status = this.signStatus();
@@ -1083,7 +1561,15 @@ export class Transaction {
1083
1561
  }
1084
1562
  // updateOutput replaces stored state with normalizeOutput(...) directly, so detach the result
1085
1563
  // before storing it or later caller mutation of `output.script` will rewrite transaction state.
1086
- 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;
1087
1573
  }
1088
1574
  addOutputAddress(address: string, amount: bigint, network: u.BTC_NETWORK = NETWORK): number {
1089
1575
  return this.addOutput({
@@ -1116,7 +1602,8 @@ export class Transaction {
1116
1602
  const { isAny, isNone, isSingle } = unpackSighash(hashType);
1117
1603
  if (idx < 0 || !Number.isSafeInteger(idx)) throw new Error(`Invalid input idx=${idx}`);
1118
1604
  if ((isSingle && idx >= this.outputs.length) || idx >= this.inputs.length)
1119
- 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);
1120
1607
  prevOutScript = stripCodeSeparator(prevOutScript);
1121
1608
  let inputs: TransactionInputRequired[] = this.inputs
1122
1609
  .map(inputBeforeSign)
@@ -1216,6 +1703,14 @@ export class Transaction {
1216
1703
  const inType = hashType & SignatureHash.ANYONECANPAY;
1217
1704
  const inputs = this.inputs.map(inputBeforeSign);
1218
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
+ }
1219
1714
  if (inType !== SignatureHash.ANYONECANPAY) {
1220
1715
  out.push(
1221
1716
  ...[
@@ -1241,8 +1736,7 @@ export class Transaction {
1241
1736
  );
1242
1737
  } else out.push(P.U32LE.encode(idx));
1243
1738
  if (spendType & 1) out.push(u.sha256(VarBytes.encode(annex || P.EMPTY)));
1244
- if (outType === SignatureHash.SINGLE)
1245
- 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])));
1246
1740
  if (leafScript)
1247
1741
  out.push(tapLeafHash(leafScript, leafVer), P.U8.encode(0), P.I32LE.encode(codeSeparator));
1248
1742
  return u.tagSchnorr('TapSighash', ...out);
@@ -1262,7 +1756,11 @@ export class Transaction {
1262
1756
  );
1263
1757
  }
1264
1758
  this.checkInputIdx(idx);
1265
- 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
+ );
1266
1764
  const inputType = getInputType(
1267
1765
  input as TArg<psbt.TransactionInput>,
1268
1766
  this.opts.allowLegacyWitnessUtxo
@@ -1471,6 +1969,9 @@ export class Transaction {
1471
1969
  // Even worse: another user can add bip32 derivation, and spend money from different address.
1472
1970
  // Better api: signIdx
1473
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();
1474
1975
  let num = 0;
1475
1976
  for (let i = 0; i < this.inputs.length; i++) {
1476
1977
  try {
@@ -1485,17 +1986,25 @@ export class Transaction {
1485
1986
  this.checkInputIdx(idx);
1486
1987
  if (this.fee < _0n) throw new Error('Outputs spends more than inputs amount');
1487
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!);
1488
1992
  const inputType = getInputType(input, this.opts.allowLegacyWitnessUtxo);
1489
1993
  // Taproot finalize
1490
1994
  if (inputType.txType === 'taproot') {
1491
1995
  if (input.tapKeySig) input.finalScriptWitness = [input.tapKeySig];
1492
1996
  else if (input.tapLeafScript && input.tapScriptSig) {
1493
- // Sort leafs by control block length.
1494
- const leafs = input.tapLeafScript.sort(
1495
- (a, b) =>
1496
- psbt.TaprootControlBlock.encode(a[0]).length -
1497
- psbt.TaprootControlBlock.encode(b[0]).length
1498
- );
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;
1499
2008
  for (const [cb, _script] of leafs) {
1500
2009
  // Last byte is version
1501
2010
  const script = _script.slice(0, -1);
@@ -1504,6 +2013,7 @@ export class Transaction {
1504
2013
  const hash = tapLeafHash(script, ver);
1505
2014
  const scriptSig = input.tapScriptSig.filter((i) => equalBytes(i[0].leafHash, hash));
1506
2015
  let signatures: Bytes[] = [];
2016
+ let witness: Bytes[] | undefined;
1507
2017
  if (outScript.type === 'tr_ms') {
1508
2018
  const m = outScript.m;
1509
2019
  const pubkeys = outScript.pubkeys;
@@ -1543,33 +2053,43 @@ export class Transaction {
1543
2053
  if (!signatures.length) continue;
1544
2054
  } else {
1545
2055
  const custom = this.opts.customScripts;
2056
+ let recognized = false;
1546
2057
  if (custom) {
1547
2058
  for (const c of custom) {
1548
2059
  if (!c.finalizeTaproot) continue;
1549
2060
  const scriptDecoded = Script.decode(script);
1550
2061
  const csEncoded = c.encode(scriptDecoded);
1551
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.
1552
2067
  const finalized = c.finalizeTaproot(script, csEncoded, scriptSig);
1553
2068
  if (!finalized) continue;
1554
- input.finalScriptWitness = finalized.concat(psbt.TaprootControlBlock.encode(cb));
1555
- delete input.finalScriptSig;
1556
- cleanFinalInput(input as TArg<PSBTInputs>);
1557
- return;
2069
+ witness = finalized.concat(psbt.TaprootControlBlock.encode(cb));
2070
+ break;
1558
2071
  }
1559
2072
  }
1560
- 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;
1561
2078
  }
1562
2079
  // Witness is stack, so last element will be used first
1563
- input.finalScriptWitness = signatures
1564
- .reverse()
1565
- .concat([script, psbt.TaprootControlBlock.encode(cb)]);
1566
- 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;
1567
2085
  }
1568
- 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;
1569
2089
  } else throw new Error('finalize/taproot: unknown input');
1570
2090
  // BIP174 Input Finalizer: if scriptSig is empty for an input, 0x07 remains unset.
1571
2091
  delete input.finalScriptSig;
1572
- cleanFinalInput(input as TArg<PSBTInputs>);
2092
+ this.cleanFinalInput(input);
1573
2093
  return;
1574
2094
  }
1575
2095
  if (!input.partialSig || !input.partialSig.length) throw new Error('Not enough partial sign');
@@ -1629,7 +2149,7 @@ export class Transaction {
1629
2149
  if (!finalScriptSig && !finalScriptWitness) throw new Error('Unknown error finalizing input');
1630
2150
  if (finalScriptSig) input.finalScriptSig = finalScriptSig;
1631
2151
  if (finalScriptWitness) input.finalScriptWitness = finalScriptWitness;
1632
- cleanFinalInput(input as TArg<PSBTInputs>);
2152
+ this.cleanFinalInput(input);
1633
2153
  }
1634
2154
  finalize(): void {
1635
2155
  for (let i = 0; i < this.inputs.length; i++) this.finalizeIdx(i);
@@ -1643,15 +2163,25 @@ export class Transaction {
1643
2163
  combine(other: Transaction): this {
1644
2164
  if (!(other instanceof Transaction))
1645
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;
1646
2168
  // BIP174 combiners merge same-transaction PSBTs across versions and emit the highest required
1647
2169
  // version, so PSBTVersion mismatches are normalized below instead of treated as conflicts.
1648
2170
  const PSBTVersion = Math.max(this.opts.PSBTVersion || 0, other.opts.PSBTVersion || 0);
1649
- for (const k of ['version', 'lockTime'] as const) {
1650
- 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)
1651
2182
  throw new Error(
1652
- `Transaction/combine: different ${k} this=${this.opts[k]} other=${other.opts[k]}`
2183
+ `Transaction/combine: different lockTime this=${thisLockTime} other=${otherLockTime}`
1653
2184
  );
1654
- }
1655
2185
  }
1656
2186
  for (const k of ['inputs', 'outputs'] as const) {
1657
2187
  if (this[k].length !== other[k].length) {
@@ -1662,18 +2192,139 @@ export class Transaction {
1662
2192
  }
1663
2193
  // Same-transaction checks must compare the normalized unsigned tx bytes here: PSBTv0 stores
1664
2194
  // `global.unsignedTx`, while PSBTv2 reconstructs the same transaction from split fields.
1665
- if (!equalBytes(this.unsignedTx, other.unsignedTx))
2195
+ const unsignedTx = this.unsignedTx;
2196
+ if (!equalBytes(unsignedTx, other.unsignedTx))
1666
2197
  throw new Error(`Transaction/combine: different unsigned tx`);
1667
- 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(
1668
2251
  psbt.PSBTGlobal,
1669
- this.global,
1670
- other.global,
1671
- undefined,
1672
- this.opts.allowUnknown
2252
+ thisGlobal,
2253
+ otherGlobal,
2254
+ opts.unknown!,
2255
+ opts.proprietary!
1673
2256
  );
1674
- if (PSBTVersion) this.global.version = PSBTVersion;
1675
- for (let i = 0; i < this.inputs.length; i++) this.updateInput(i, other.inputs[i], true);
1676
- 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;
1677
2328
  return this;
1678
2329
  }
1679
2330
  clone(): Transaction {
@@ -1685,6 +2336,7 @@ export class Transaction {
1685
2336
  /**
1686
2337
  * Merges multiple PSBT blobs into one.
1687
2338
  * @param psbts - PSBT byte arrays to combine
2339
+ * @param opts - Transaction parsing, combination, and serialization options. See {@link TxOpts}.
1688
2340
  * @returns Combined PSBT bytes.
1689
2341
  * @throws If the PSBT list is empty or the partial transactions cannot be combined. {@link Error}
1690
2342
  * @example
@@ -1695,12 +2347,14 @@ export class Transaction {
1695
2347
  * PSBTCombine([psbt, psbt]);
1696
2348
  * ```
1697
2349
  */
1698
- export function PSBTCombine(psbts: TArg<Bytes[]>): TRet<Bytes> {
2350
+ export function PSBTCombine(psbts: TArg<Bytes[]>, opts: TArg<TxOpts> = {}): TRet<Bytes> {
1699
2351
  if (!psbts || !Array.isArray(psbts) || !psbts.length)
1700
2352
  throw new Error('PSBTCombine: wrong PSBT list');
1701
- const tx = Transaction.fromPSBT(psbts[0]);
1702
- for (let i = 1; i < psbts.length; i++) tx.combine(Transaction.fromPSBT(psbts[i]));
1703
- 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>;
1704
2358
  }
1705
2359
 
1706
2360
  // Copy-pasted from bip32 derive, maybe do something like 'bip32.parsePath'?