@scure/btc-signer 2.3.0 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/transaction.js CHANGED
@@ -1,9 +1,9 @@
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, OutScript, checkScript, tapLeafHash } from "./payment.js";
4
+ import { Address, OutScript, _WitnessOutScript, checkScript, tapLeafHash, } from "./payment.js";
5
5
  import * as psbt from "./psbt.js";
6
- import { CompactSizeLen, OP, RawOldTx, RawInput, RawOutput, RawTx, Script, scriptPushLen, VarBytes, } from "./script.js";
6
+ import { CompactSizeLen, OP, RawOldTx, RawInput, RawOutput, RawTx, RawWitness, Script, scriptPushLen, VarBytes, } from "./script.js";
7
7
  import * as u from "./utils.js";
8
8
  import { NETWORK, abigint, concatBytes, equalBytes, isBytes, validateObject, } from "./utils.js";
9
9
  // Be friendly to bad ECMAScript parsers by not using bigint literals.
@@ -214,13 +214,61 @@ export function inputBeforeSign(i) {
214
214
  RawInput.encode(res);
215
215
  return res;
216
216
  }
217
- function cleanFinalInput(i) {
217
+ const cleanExtensions = (map, unknownMode, proprietaryMode, rejectStrip = false) => {
218
+ const out = { ...map };
219
+ for (const [name, mode] of [
220
+ ['unknown', unknownMode],
221
+ ['proprietary', proprietaryMode],
222
+ ]) {
223
+ const value = out[name];
224
+ // Policy cleanup must not reinterpret malformed caller metadata as an empty keyed map.
225
+ if (value !== undefined)
226
+ u.aarray(value, `${name} PSBT field`);
227
+ const rows = value;
228
+ if (!rows?.length) {
229
+ delete out[name];
230
+ continue;
231
+ }
232
+ if (mode === 'strict')
233
+ throw new Error(`PSBT: ${name} PSBT field is not allowed in strict mode`);
234
+ if (mode === 'strip') {
235
+ // Silent stripping is appropriate at relay/cleanup boundaries. On direct mutation it would
236
+ // hide a caller bug by accepting metadata that can never become transaction state.
237
+ if (rejectStrip)
238
+ throw new Error(`PSBT: ${name} PSBT field cannot be supplied when policy is strip`);
239
+ delete out[name];
240
+ }
241
+ }
242
+ return out;
243
+ };
244
+ const cleanTxModifiable = (value, mode) => {
245
+ if (value === undefined || !(value & ~0b111))
246
+ return value;
247
+ if (mode === 'strict')
248
+ throw new Error('PSBT: unknown txModifiable bits in strict mode');
249
+ return mode === 'strip' ? value & 0b111 : value;
250
+ };
251
+ function cleanFinalInput(i, unknownMode = 'strip', proprietaryMode = 'strip') {
218
252
  const _i = i;
253
+ const extensions = cleanExtensions(_i, unknownMode, proprietaryMode);
254
+ if (extensions.unknown)
255
+ _i.unknown = extensions.unknown;
256
+ else
257
+ delete _i.unknown;
258
+ if (extensions.proprietary)
259
+ _i.proprietary = extensions.proprietary;
260
+ else
261
+ delete _i.proprietary;
219
262
  // BIP174 finalizers clear non-final input metadata after constructing final scripts/witnesses.
220
263
  // That intentionally drops sighashType here, so post-finalize mutation becomes conservative
221
- // until callers explicitly reopen the input by removing finalScriptSig/finalScriptWitness.
264
+ // until callers explicitly clear satisfaction by removing finalScriptSig/finalScriptWitness.
222
265
  for (const _k in _i) {
223
266
  const k = _k;
267
+ // Proprietary records are cleanup metadata too, but callers may need their opaque protocol
268
+ // state after finalization for PSBT coordination outside transaction extraction. An empty
269
+ // keyed list encodes no records, so canonicalize it to absence like its serialized clone.
270
+ if (proprietaryMode === 'ignore' && k === 'proprietary' && _i.proprietary?.length)
271
+ continue;
224
272
  if (!psbt.PSBTInputFinalKeys.includes(k))
225
273
  delete _i[k];
226
274
  }
@@ -240,6 +288,18 @@ function unpackSighash(hashType) {
240
288
  isSingle: masked === SignatureHash.SINGLE,
241
289
  };
242
290
  }
291
+ const sighashScope = (sighash) => ({
292
+ sigInputs: sighash & SignatureHash.ANYONECANPAY,
293
+ sigOutputs: sighash === SignatureHash.DEFAULT ? SignatureHash.ALL : sighash & 0b11,
294
+ });
295
+ const normalizeUnknowns = (name, mode, legacy, fallback = 'strip') => {
296
+ const alias = legacy === undefined ? undefined : legacy ? 'ignore' : 'strip';
297
+ if (mode !== undefined && mode !== 'ignore' && mode !== 'strip' && mode !== 'strict')
298
+ throw new Error(`Transaction options wrong value: ${name}=${mode}`);
299
+ if (mode !== undefined && alias !== undefined && mode !== alias)
300
+ throw new Error(`Transaction options: conflicting ${name} options`);
301
+ return mode || alias || fallback;
302
+ };
243
303
  function validateOpts(opts) {
244
304
  if (opts !== undefined)
245
305
  validateObject(opts, {}, {}, 'opts');
@@ -256,6 +316,8 @@ function validateOpts(opts) {
256
316
  _opts.allowUnknownInputs = _opts.allowUnknowInput;
257
317
  if (typeof _opts.allowUnknowOutput !== 'undefined')
258
318
  _opts.allowUnknownOutputs = _opts.allowUnknowOutput;
319
+ if (_opts.allowMissingTxModifiable === undefined)
320
+ _opts.allowMissingTxModifiable = true;
259
321
  if (typeof _opts.lockTime !== 'number')
260
322
  throw new Error('Transaction lock time should be number');
261
323
  P.U32LE.encode(_opts.lockTime); // Additional range checks that lockTime
@@ -271,7 +333,10 @@ function validateOpts(opts) {
271
333
  'disableScriptCheck',
272
334
  'bip174jsCompat',
273
335
  'allowLegacyWitnessUtxo',
336
+ 'strictPrevoutValidation',
274
337
  'lowR',
338
+ 'allowUnknown',
339
+ 'allowMissingTxModifiable',
275
340
  ]) {
276
341
  const v = _opts[k];
277
342
  if (v === undefined)
@@ -279,6 +344,8 @@ function validateOpts(opts) {
279
344
  if (typeof v !== 'boolean')
280
345
  throw new Error(`Transation options wrong type: ${k}=${v} (${typeof v})`);
281
346
  }
347
+ _opts.unknown = normalizeUnknowns('unknown', _opts.unknown, _opts.allowUnknown);
348
+ _opts.proprietary = normalizeUnknowns('proprietary', _opts.proprietary, undefined, _opts.unknown);
282
349
  // 0 and -1 happens in tests
283
350
  // With allowUnknownVersion any numeric version is fine; the ternary was inverted
284
351
  // before 2026-07 (audit), which made the option throw for every numeric version.
@@ -301,15 +368,105 @@ function validateOpts(opts) {
301
368
  }
302
369
  return Object.freeze(_opts);
303
370
  }
371
+ function checkTaprootInputCommitments(input, prevScript) {
372
+ const output = _WitnessOutScript.decode(prevScript);
373
+ const hasTaprootCommitments = input.tapInternalKey !== undefined ||
374
+ input.tapMerkleRoot !== undefined ||
375
+ // Repeated keyed fields only exist on the PSBT wire when at least one entry is encoded.
376
+ !!input.tapLeafScript?.length;
377
+ if (output.type !== 'tr') {
378
+ if (hasTaprootCommitments)
379
+ throw new Error('validateInput: Taproot metadata without P2TR previous output');
380
+ return;
381
+ }
382
+ const checkOutputKey = (internalKey, merkleRoot, parity) => {
383
+ const [outputKey, outputParity] = u.taprootTweakPubkey(internalKey, merkleRoot);
384
+ if (!equalBytes(outputKey, output.pubkey))
385
+ throw new Error('validateInput: Taproot commitment does not match previous output');
386
+ if (parity !== undefined && outputParity !== parity)
387
+ throw new Error('validateInput: Taproot control-block parity does not match previous output');
388
+ };
389
+ if (input.tapLeafScript) {
390
+ for (const [controlBlock, scriptWithVersion] of input.tapLeafScript) {
391
+ const leafVersion = scriptWithVersion[scriptWithVersion.length - 1];
392
+ const script = scriptWithVersion.subarray(0, -1);
393
+ let merkleRoot = tapLeafHash(script, leafVersion);
394
+ for (const sibling of controlBlock.merklePath) {
395
+ merkleRoot =
396
+ u.compareBytes(sibling, merkleRoot) === -1
397
+ ? u.tagSchnorr('TapBranch', sibling, merkleRoot)
398
+ : u.tagSchnorr('TapBranch', merkleRoot, sibling);
399
+ }
400
+ checkOutputKey(controlBlock.internalKey, merkleRoot, controlBlock.version & 1);
401
+ if (input.tapInternalKey && !equalBytes(input.tapInternalKey, controlBlock.internalKey))
402
+ throw new Error('validateInput: tapInternalKey does not match Taproot control block');
403
+ if (input.tapMerkleRoot && !equalBytes(input.tapMerkleRoot, merkleRoot))
404
+ throw new Error('validateInput: tapMerkleRoot does not match Taproot control block');
405
+ }
406
+ }
407
+ // A tree-bearing input can omit the aggregate root while still providing independently
408
+ // verifiable control blocks. With no leaves, an internal key without a root describes the
409
+ // standard key-only (empty-root) commitment.
410
+ if (input.tapInternalKey && (input.tapMerkleRoot || !input.tapLeafScript?.length))
411
+ checkOutputKey(input.tapInternalKey, input.tapMerkleRoot || P.EMPTY);
412
+ }
413
+ const LOCKTIME_THRESHOLD = 500_000_000;
414
+ function validateRequiredLocktimes(input) {
415
+ const height = input.requiredHeightLocktime;
416
+ if (height !== undefined) {
417
+ anumber(height, 'requiredHeightLocktime');
418
+ if (height === 0 || height >= LOCKTIME_THRESHOLD)
419
+ throw new RangeError(`requiredHeightLocktime must be between 1 and ${LOCKTIME_THRESHOLD - 1}, got ${height}`);
420
+ }
421
+ const time = input.requiredTimeLocktime;
422
+ if (time !== undefined) {
423
+ anumber(time, 'requiredTimeLocktime');
424
+ if (time < LOCKTIME_THRESHOLD || time > 0xffffffff)
425
+ throw new RangeError(`requiredTimeLocktime must be between ${LOCKTIME_THRESHOLD} and 4294967295, got ${time}`);
426
+ }
427
+ }
428
+ function resolvePSBTLocktime(inputs, fallback = DEFAULT_LOCKTIME) {
429
+ let height = DEFAULT_LOCKTIME;
430
+ let time = DEFAULT_LOCKTIME;
431
+ let hasRequirements = false;
432
+ let heightSupported = true;
433
+ let timeSupported = true;
434
+ for (const input of inputs) {
435
+ validateRequiredLocktimes(input);
436
+ const hasHeight = input.requiredHeightLocktime !== undefined;
437
+ const hasTime = input.requiredTimeLocktime !== undefined;
438
+ if (!hasHeight && !hasTime)
439
+ continue;
440
+ hasRequirements = true;
441
+ if (hasHeight)
442
+ height = Math.max(height, input.requiredHeightLocktime);
443
+ else
444
+ heightSupported = false;
445
+ if (hasTime)
446
+ time = Math.max(time, input.requiredTimeLocktime);
447
+ else
448
+ timeSupported = false;
449
+ }
450
+ if (!hasRequirements)
451
+ return fallback;
452
+ // BIP370 requires height when every relevant input supports both domains.
453
+ if (heightSupported)
454
+ return height;
455
+ if (timeSupported)
456
+ return time;
457
+ throw new Error('PSBTv2: incompatible height-based and time-based locktime requirements');
458
+ }
304
459
  // NOTE: we cannot do this inside PSBTInput coder, because there is no index/txid at this point!
305
- function validateInput(i) {
460
+ function validateInput(i, disableScriptCheck = false) {
306
461
  validateObject(i, {}, {}, 'i');
307
462
  const _i = i;
463
+ validateRequiredLocktimes(_i);
464
+ let prevOut;
308
465
  if (_i.nonWitnessUtxo && _i.index !== undefined) {
309
466
  const last = _i.nonWitnessUtxo.outputs.length - 1;
310
467
  if (_i.index > last)
311
468
  throw new Error(`validateInput: index(${_i.index}) not in nonWitnessUtxo`);
312
- const prevOut = _i.nonWitnessUtxo.outputs[_i.index];
469
+ prevOut = _i.nonWitnessUtxo.outputs[_i.index];
313
470
  if (_i.witnessUtxo &&
314
471
  (!equalBytes(_i.witnessUtxo.script, prevOut.script) ||
315
472
  _i.witnessUtxo.amount !== prevOut.amount))
@@ -344,8 +501,43 @@ function validateInput(i) {
344
501
  throw new Error(`nonWitnessUtxo: wrong txid, exp=${txid} got=${tx.id}`);
345
502
  }
346
503
  }
504
+ else if (_i.witnessUtxo)
505
+ prevOut = _i.witnessUtxo;
506
+ if (prevOut && !disableScriptCheck) {
507
+ checkScript(prevOut.script, _i.redeemScript, _i.witnessScript);
508
+ checkTaprootInputCommitments(_i, prevOut.script);
509
+ }
347
510
  return _i;
348
511
  }
512
+ const inputSignatures = (input) => {
513
+ const _input = input;
514
+ const res = [];
515
+ const add = (signature, taproot, scriptPath = false) => {
516
+ const sig = signature;
517
+ if (!sig.length)
518
+ return;
519
+ // Taproot's 64-byte encoding omits the SIGHASH_DEFAULT byte; every other PSBT signature
520
+ // carries its sighash in the final byte, including signatures imported from another signer.
521
+ const sighash = taproot && sig.length === 64 ? SignatureHash.DEFAULT : sig[sig.length - 1];
522
+ res.push({ sighash, taproot, scriptPath });
523
+ };
524
+ for (const [, signature] of _input.partialSig || [])
525
+ add(signature, false);
526
+ if (_input.tapKeySig)
527
+ add(_input.tapKeySig, true);
528
+ for (const [, signature] of _input.tapScriptSig || [])
529
+ add(signature, true, true);
530
+ return res;
531
+ };
532
+ const inputSignedKeys = {
533
+ // sighashType is signer policy rather than a digest byte, but changing it after one signature
534
+ // exists would make later signers interpret the same input under a different policy.
535
+ self: ['txid', 'index', 'sequence', 'nonWitnessUtxo', 'witnessUtxo', 'sighashType'],
536
+ ecdsa: ['redeemScript', 'witnessScript'],
537
+ tapscript: ['tapLeafScript'],
538
+ cross: ['txid', 'index'],
539
+ prevout: ['nonWitnessUtxo', 'witnessUtxo'],
540
+ };
349
541
  // Normalizes input
350
542
  /**
351
543
  * Extracts the previous output referenced by an input.
@@ -390,9 +582,12 @@ export function getPrevOut(input) {
390
582
  * @param i - input update to normalize
391
583
  * @param cur - existing input value to merge with
392
584
  * @param allowedFields - fields that may still change on signed inputs
393
- * @param disableScriptCheck - whether to skip redeem/witness script sanity checks
394
- * @param allowUnknown - whether to keep unknown PSBT fields
585
+ * @param disableScriptCheck - whether to skip wrapper and Taproot commitment sanity checks
586
+ * @param unknown - handling policy for unknown PSBT fields
587
+ * @param proprietary - handling policy for proprietary PSBT fields
395
588
  * @returns Normalized PSBT input.
589
+ * @throws If the update conflicts with the existing input or its signatures. {@link Error}
590
+ * @throws If a numeric input field is outside its wire or protocol range. {@link RangeError}
396
591
  * @example
397
592
  * Accept hex txids from callers in the same display-order form used by `Transaction.id`, then
398
593
  * normalize them into the repo's internal `TransactionInput` shape.
@@ -406,7 +601,7 @@ export function getPrevOut(input) {
406
601
  * });
407
602
  * ```
408
603
  */
409
- export function normalizeInput(i, cur, allowedFields, disableScriptCheck = false, allowUnknown = false) {
604
+ export function normalizeInput(i, cur, allowedFields, disableScriptCheck = false, unknown = 'strip', proprietary = 'strip') {
410
605
  validateObject(i, {}, {}, 'i');
411
606
  if (cur !== undefined)
412
607
  validateObject(cur, {}, {}, 'cur');
@@ -438,18 +633,19 @@ export function normalizeInput(i, cur, allowedFields, disableScriptCheck = false
438
633
  res.sequence = DEFAULT_SEQUENCE;
439
634
  if (res.tapMerkleRoot === null)
440
635
  delete res.tapMerkleRoot;
441
- res = psbt.mergeKeyMap(psbt.PSBTInput, res, _cur, _allowedFields, allowUnknown);
636
+ res = psbt.mergeKeyMap(psbt.PSBTInput, res, _cur, _allowedFields, unknown, proprietary);
637
+ // An actual empty repeated field emits no PSBT keypairs. Canonicalize only arrays so malformed
638
+ // falsy values still reach the PSBT coder's validation instead of becoming valid absence.
639
+ if (Array.isArray(res.tapLeafScript) && !res.tapLeafScript.length)
640
+ delete res.tapLeafScript;
641
+ validateRequiredLocktimes(res);
442
642
  // Public PSBT coder surface is wrapped with TArg/TRet for TS compatibility; normalizeInput keeps
443
643
  // the repo's historical raw internal shape and casts only at the validation boundary here.
444
644
  psbt.PSBTInputCoder.encode(res); // Validates that everything is correct at this point
445
- let prevOut;
446
- if (res.nonWitnessUtxo && res.index !== undefined)
447
- prevOut = res.nonWitnessUtxo.outputs[res.index];
448
- else if (res.witnessUtxo)
449
- prevOut = res.witnessUtxo;
450
- if (prevOut && !disableScriptCheck)
451
- checkScript(prevOut && prevOut.script, res.redeemScript, res.witnessScript);
452
- return res;
645
+ // Direct construction and UTXO selection consume nonWitnessUtxo amounts without crossing a
646
+ // PSBT serialization boundary. Enforce the same outpoint binding here so a mismatched previous
647
+ // transaction cannot understate a legacy input amount and turn the difference into mining fees.
648
+ return validateInput(res, disableScriptCheck);
453
649
  }
454
650
  /**
455
651
  * Determines how an input should be signed and finalized.
@@ -480,10 +676,13 @@ export function getInputType(input, allowLegacyWitnessUtxo = false) {
480
676
  let txType = 'legacy';
481
677
  let defaultSighash = SignatureHash.ALL;
482
678
  const prevOut = getPrevOut(_input);
483
- const first = OutScript.decode(prevOut.script);
679
+ const first = _WitnessOutScript.decode(prevOut.script);
484
680
  let type = first.type;
485
681
  let cur = first;
486
682
  const stack = [first];
683
+ // Classification is semantic, but legacy/BIP143 scriptCode and finalization must retain the
684
+ // exact committed spelling (including consensus-valid non-minimal pushes).
685
+ let lastScript = prevOut.script;
487
686
  if (first.type === 'tr') {
488
687
  // Expected invariant: taproot inputs use PSBT_IN_TAP_* metadata only;
489
688
  // legacy redeemScript/witnessScript fields belong to P2SH/P2WSH paths.
@@ -503,11 +702,12 @@ export function getInputType(input, allowLegacyWitnessUtxo = false) {
503
702
  if (first.type === 'sh') {
504
703
  if (!_input.redeemScript)
505
704
  throw new Error('inputType: sh without redeemScript');
506
- let child = OutScript.decode(_input.redeemScript);
705
+ let child = _WitnessOutScript.decode(_input.redeemScript);
507
706
  if (child.type === 'wpkh' || child.type === 'wsh')
508
707
  txType = 'segwit';
509
708
  stack.push(child);
510
709
  cur = child;
710
+ lastScript = _input.redeemScript;
511
711
  type += `-${child.type}`;
512
712
  }
513
713
  // wsh can be inside sh
@@ -519,12 +719,12 @@ export function getInputType(input, allowLegacyWitnessUtxo = false) {
519
719
  txType = 'segwit';
520
720
  stack.push(child);
521
721
  cur = child;
722
+ lastScript = _input.witnessScript;
522
723
  type += `-${child.type}`;
523
724
  }
524
725
  const last = stack[stack.length - 1];
525
726
  if (last.type === 'sh' || last.type === 'wsh')
526
727
  throw new Error('inputType: sh/wsh cannot be terminal type');
527
- const lastScript = OutScript.encode(last);
528
728
  const res = {
529
729
  type,
530
730
  txType,
@@ -568,9 +768,61 @@ export class Transaction {
568
768
  constructor(opts = {}) {
569
769
  const _opts = (this.opts = validateOpts(opts));
570
770
  // Merge with global structure of PSBTv2
571
- if (_opts.lockTime !== DEFAULT_LOCKTIME)
572
- this.global.fallbackLocktime = _opts.lockTime;
771
+ // Bitcoin Core sets fallback even when it is zero. Matching its common encoding reduces the
772
+ // fingerprint of locally created PSBTv2s; imported PSBTs replace this map and retain omission.
773
+ this.global.fallbackLocktime = _opts.lockTime;
573
774
  this.global.txVersion = _opts.version;
775
+ // A locally-created PSBTv2 is still under construction. Imported PSBTs replace this global
776
+ // map below, so an omitted field there retains BIP370's immutable meaning.
777
+ if (_opts.PSBTVersion === 2)
778
+ this.global.txModifiable = 0b011;
779
+ }
780
+ isPSBTv2() {
781
+ return (this.global.version ?? this.opts.PSBTVersion) === 2;
782
+ }
783
+ requireTxModifiable(bit, kind) {
784
+ if (!this.isPSBTv2())
785
+ return;
786
+ if (!(this.txModifiablePolicy() & bit))
787
+ throw new Error(`PSBTv2: ${kind} are not modifiable`);
788
+ }
789
+ txModifiablePolicy(allowMissing = this.opts.allowMissingTxModifiable, unknownMode = this.opts.unknown) {
790
+ if (!this.isPSBTv2())
791
+ return 0b011;
792
+ if (this.global.txModifiable !== undefined)
793
+ return cleanTxModifiable(this.global.txModifiable, unknownMode);
794
+ return allowMissing ? 0b011 : 0;
795
+ }
796
+ modifiable(allowMissing = this.opts.allowMissingTxModifiable, unknownMode = this.opts.unknown) {
797
+ let flags = this.txModifiablePolicy(allowMissing, unknownMode);
798
+ let hasOpaqueFinal = false;
799
+ let hasSingle = false;
800
+ for (let idx = 0; idx < this.inputs.length; idx++) {
801
+ const signatures = inputSignatures(this.inputs[idx]);
802
+ if (!signatures.length && this.inputStatus(idx) === 'finalized')
803
+ hasOpaqueFinal = true;
804
+ for (const { sighash } of signatures) {
805
+ const { isAny, isNone, isSingle } = unpackSighash(sighash);
806
+ if (!isAny)
807
+ flags &= ~0b001;
808
+ if (!isNone)
809
+ flags &= ~0b010;
810
+ if (isSingle)
811
+ hasSingle = true;
812
+ }
813
+ }
814
+ // Bit 2 summarizes signatures rather than granting policy. Preserve an imported summary for
815
+ // opaque or externally managed state, and union in every signature visible to this object.
816
+ if (hasSingle)
817
+ flags |= 0b100;
818
+ // PSBTv0 and legacy field-less PSBTv2 cannot describe an opaque finalized sighash. Promotion
819
+ // must therefore deny both mutations instead of manufacturing permissions from absence.
820
+ if (hasOpaqueFinal && this.global.txModifiable === undefined)
821
+ flags &= ~0b011;
822
+ return flags;
823
+ }
824
+ get txModifiable() {
825
+ return this.modifiable();
574
826
  }
575
827
  // Import
576
828
  static fromRaw(raw, opts = {}) {
@@ -610,19 +862,51 @@ export class Transaction {
610
862
  const tx = new Transaction({ ...opts, version, lockTime, PSBTVersion });
611
863
  // We need slice here, because otherwise
612
864
  const inputCount = PSBTVersion === 0 ? unsigned?.inputs.length : parsed.global.inputCount;
613
- tx.inputs = parsed.inputs.slice(0, inputCount).map((i, j) => validateInput({
614
- finalScriptSig: P.EMPTY,
615
- ...parsed.global.unsignedTx?.inputs[j],
616
- ...i,
617
- }));
865
+ tx.inputs = parsed.inputs.slice(0, inputCount).map((i, j) => {
866
+ const input = {
867
+ ...parsed.global.unsignedTx?.inputs[j],
868
+ ...i,
869
+ };
870
+ // The unsigned transaction's empty scriptSig is framing, not a PSBT_IN_FINAL_SCRIPTSIG
871
+ // record. Keeping it makes combination conflict with an otherwise identical finalized PSBT.
872
+ if (!i.finalScriptSig?.length)
873
+ delete input.finalScriptSig;
874
+ return validateInput(input, tx.opts.disableScriptCheck);
875
+ });
618
876
  const outputCount = PSBTVersion === 0 ? unsigned?.outputs.length : parsed.global.outputCount;
619
- tx.outputs = parsed.outputs.slice(0, outputCount).map((i, j) => ({
877
+ // bip174js writes a phantom empty input map when a PSBTv0 transaction has zero inputs. Raw v0
878
+ // framing necessarily reads it as the first output map, so skip it before pairing real maps
879
+ // with the unsigned transaction's declared outputs.
880
+ const hasBip174InputMap = PSBTVersion === 0 &&
881
+ inputCount === 0 &&
882
+ Object.keys(parsed.outputs[0] || {}).length === 0 &&
883
+ ((outputCount > 0 && parsed.outputs.length === outputCount + 1) ||
884
+ (outputCount === 0 &&
885
+ parsed.outputs.length === 2 &&
886
+ Object.keys(parsed.outputs[1]).length === 0));
887
+ const outputStart = hasBip174InputMap ? 1 : 0;
888
+ tx.outputs = parsed.outputs.slice(outputStart, outputStart + outputCount).map((i, j) => ({
620
889
  ...i,
621
890
  ...parsed.global.unsignedTx?.outputs[j],
622
891
  }));
623
- tx.global = { ...parsed.global, txVersion: version }; // just in case proprietary/unknown fields
624
- if (lockTime !== DEFAULT_LOCKTIME)
625
- tx.global.fallbackLocktime = lockTime;
892
+ const unknownMode = tx.opts.unknown;
893
+ const proprietaryMode = tx.opts.proprietary;
894
+ // Unknown PSBT rows can carry opaque metadata between participants. The documented default is
895
+ // to strip them; callers that need forward compatibility must opt in explicitly. Proprietary
896
+ // (0xfc) rows follow the same explicit policy, which defaults to the resolved unknown mode.
897
+ tx.global = cleanExtensions({ ...parsed.global, txVersion: version }, unknownMode, proprietaryMode);
898
+ tx.inputs = tx.inputs.map((input) => cleanExtensions(input, unknownMode, proprietaryMode));
899
+ tx.outputs = tx.outputs.map((output) => cleanExtensions(output, unknownMode, proprietaryMode));
900
+ if (tx.global.txModifiable !== undefined)
901
+ tx.global.txModifiable = cleanTxModifiable(tx.global.txModifiable, unknownMode);
902
+ // A high-level Transaction must have a determinable nLockTime. Raw PSBT coders can still be
903
+ // used by callers that need to inspect or relay a structurally valid but incompatible PSBT.
904
+ resolvePSBTLocktime(tx.inputs, tx.global.fallbackLocktime ?? DEFAULT_LOCKTIME);
905
+ // PSBTv0 always provides nLockTime in its unsigned transaction. Retain zero internally too so
906
+ // promotion to v2 matches fresh construction and Bitcoin Core rather than gaining a
907
+ // fingerprint.
908
+ if (PSBTVersion === 0)
909
+ tx.global.fallbackLocktime = def(lockTime, DEFAULT_LOCKTIME);
626
910
  return tx;
627
911
  }
628
912
  // Prefer `global.version` when present so cross-version combiners can serialize at the highest
@@ -637,10 +921,10 @@ export class Transaction {
637
921
  // 'PSBT version=0 export for transaction without inputs disabled, please use version=2. Please check `toPSBT` method for explanation.'
638
922
  // );
639
923
  // }
640
- const inputs = this.inputs.map((i) =>
924
+ const inputs = this.inputs.map((i) => cleanExtensions(
641
925
  // For PSBTv0 the prevout txid/index live in global.unsignedTx rather than the input map, so
642
926
  // validate the full transaction input before version filtering drops those fields.
643
- psbt.cleanPSBTFields(PSBTVersion, psbt.PSBTInput, validateInput(i)));
927
+ psbt.cleanPSBTFields(PSBTVersion, psbt.PSBTInput, validateInput(i, this.opts.disableScriptCheck)), this.opts.unknown, this.opts.proprietary));
644
928
  for (const inp of inputs) {
645
929
  // Don't serialize empty fields
646
930
  if (inp.partialSig && !inp.partialSig.length)
@@ -650,8 +934,10 @@ export class Transaction {
650
934
  if (inp.finalScriptWitness && !inp.finalScriptWitness.length)
651
935
  delete inp.finalScriptWitness;
652
936
  }
653
- const outputs = this.outputs.map((i) => psbt.cleanPSBTFields(PSBTVersion, psbt.PSBTOutput, i));
654
- const global = { ...this.global };
937
+ const outputs = this.outputs.map((i) => cleanExtensions(psbt.cleanPSBTFields(PSBTVersion, psbt.PSBTOutput, i), this.opts.unknown, this.opts.proprietary));
938
+ const global = cleanExtensions({ ...this.global }, this.opts.unknown, this.opts.proprietary);
939
+ if (global.txModifiable !== undefined)
940
+ global.txModifiable = cleanTxModifiable(global.txModifiable, this.opts.unknown);
655
941
  if (PSBTVersion === 0) {
656
942
  /*
657
943
  - Bitcoin raw transaction expects to have at least 1 input because it uses case with zero inputs as marker for SegWit
@@ -674,10 +960,12 @@ export class Transaction {
674
960
  delete global.txVersion;
675
961
  // PSBTv0 carries the unsigned transaction as one blob, so the PSBTv2 framing fields must be
676
962
  // removed here. Keeping `global.version` would make validation treat this rebuilt v0 map as
677
- // PSBTv2 and reject the required `unsignedTx` field.
963
+ // PSBTv2 and reject the required `unsignedTx` field. Transaction-modifiable is also v2-only;
964
+ // its restrictions remain represented by the signatures when explicitly converting to v0.
678
965
  delete global.inputCount;
679
966
  delete global.outputCount;
680
967
  delete global.version;
968
+ delete global.txModifiable;
681
969
  }
682
970
  else {
683
971
  // Cross-version merges and v0->v2 re-exports can still carry the PSBTv0 unsignedTx blob in
@@ -688,15 +976,18 @@ export class Transaction {
688
976
  global.txVersion = this.version;
689
977
  global.inputCount = this.inputs.length;
690
978
  global.outputCount = this.outputs.length;
691
- if (global.fallbackLocktime && global.fallbackLocktime === DEFAULT_LOCKTIME)
692
- delete global.fallbackLocktime;
693
- }
694
- if (this.opts.bip174jsCompat) {
695
- if (!inputs.length)
696
- inputs.push({});
697
- if (!outputs.length)
698
- outputs.push({});
979
+ // Core serializes this optional field exactly as stored. Preserve no-op v2 round-trips;
980
+ // only v0 promotion and the explicit legacy-omission compatibility mode materialize policy.
981
+ if (!this.isPSBTv2() ||
982
+ (global.txModifiable === undefined && this.opts.allowMissingTxModifiable))
983
+ global.txModifiable = this.txModifiable;
699
984
  }
985
+ // bip174js historically emits one empty output map for a PSBTv0 transaction with no outputs.
986
+ // Input maps are count-framed by the unsigned transaction, so a phantom input map cannot be
987
+ // represented: with zero inputs it would be decoded as an output map instead. PSBTv2 has
988
+ // explicit counts for both map arrays and does not use this compatibility encoding.
989
+ if (this.opts.bip174jsCompat && PSBTVersion === 0 && !outputs.length)
990
+ outputs.push({});
700
991
  const raw = { global, inputs, outputs };
701
992
  return PSBTVersion === 0
702
993
  ? psbt.RawPSBTV0.encode(raw)
@@ -704,25 +995,7 @@ export class Transaction {
704
995
  }
705
996
  // BIP370 lockTime (https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki#determining-lock-time)
706
997
  get lockTime() {
707
- let height = DEFAULT_LOCKTIME;
708
- let heightCnt = 0;
709
- let time = DEFAULT_LOCKTIME;
710
- let timeCnt = 0;
711
- for (const i of this.inputs) {
712
- if (i.requiredHeightLocktime) {
713
- height = Math.max(height, i.requiredHeightLocktime);
714
- heightCnt++;
715
- }
716
- if (i.requiredTimeLocktime) {
717
- time = Math.max(time, i.requiredTimeLocktime);
718
- timeCnt++;
719
- }
720
- }
721
- if (heightCnt && heightCnt >= timeCnt)
722
- return height;
723
- if (time !== DEFAULT_LOCKTIME)
724
- return time;
725
- return this.global.fallbackLocktime || DEFAULT_LOCKTIME;
998
+ return resolvePSBTLocktime(this.inputs, this.global.fallbackLocktime ?? DEFAULT_LOCKTIME);
726
999
  }
727
1000
  get version() {
728
1001
  // Should be not possible
@@ -748,6 +1021,13 @@ export class Transaction {
748
1021
  return 'signed';
749
1022
  return 'unsigned';
750
1023
  }
1024
+ cleanFinalInput(input) {
1025
+ // Core preserves producer policy during finalization. Once cleanup makes signatures opaque,
1026
+ // signStatus conservatively locks transaction mutation until the input is explicitly reopened.
1027
+ cleanFinalInput(input, this.opts.unknown, this.opts.proprietary);
1028
+ if (this.global.txModifiable !== undefined)
1029
+ this.global.txModifiable = cleanTxModifiable(this.global.txModifiable, this.opts.unknown);
1030
+ }
751
1031
  // Cannot replace unpackSighash, tests rely on very generic implemenetation with signing inputs outside of range
752
1032
  // We will lose some vectors -> smaller test coverage of preimages (very important!)
753
1033
  inputSighash(idx) {
@@ -760,32 +1040,81 @@ export class Transaction {
760
1040
  // ALL + ANYONE -- specific input + all outputs
761
1041
  // NONE + ANYONE -- specific input + no outputs
762
1042
  // SINGLE -- specific inputs + output with same index
763
- const sigOutputs = sighash === SignatureHash.DEFAULT ? SignatureHash.ALL : sighash & 0b11;
764
- const sigInputs = sighash & SignatureHash.ANYONECANPAY;
765
- return { sigInputs, sigOutputs };
1043
+ return sighashScope(sighash);
766
1044
  }
767
1045
  // Very nice for debug purposes, but slow. If there is too much inputs/outputs to add, will be quadratic.
768
1046
  // Some cache will be nice, but there chance to have bugs with cache invalidation
769
- signStatus() {
1047
+ signatures() {
1048
+ const res = [];
1049
+ for (let idx = 0; idx < this.inputs.length; idx++) {
1050
+ const actual = inputSignatures(this.inputs[idx]);
1051
+ for (const signature of actual)
1052
+ res.push({ idx, ...signature });
1053
+ if (actual.length || this.inputStatus(idx) !== 'finalized')
1054
+ continue;
1055
+ let taproot = true;
1056
+ try {
1057
+ taproot = _WitnessOutScript.decode(getPrevOut(this.inputs[idx]).script).type === 'tr';
1058
+ }
1059
+ catch {
1060
+ // Finalization may remove the data needed to classify an imported opaque signature.
1061
+ // Treating it as Taproot conservatively protects the larger all-prevout commitment.
1062
+ }
1063
+ const declared = this.inputs[idx].sighashType;
1064
+ const sighash = declared === undefined ? SignatureHash.DEFAULT : declared;
1065
+ res.push({ idx, sighash, taproot, scriptPath: taproot });
1066
+ }
1067
+ return res;
1068
+ }
1069
+ signedInputKeys(idx, signatures = this.signatures()) {
1070
+ const res = new Set();
1071
+ const add = (keys) => {
1072
+ for (const key of keys)
1073
+ res.add(key);
1074
+ };
1075
+ for (const signature of signatures) {
1076
+ const { isAny, isNone, isSingle } = unpackSighash(signature.sighash);
1077
+ if (signature.idx === idx) {
1078
+ add(inputSignedKeys.self);
1079
+ if (signature.taproot) {
1080
+ if (signature.scriptPath)
1081
+ add(inputSignedKeys.tapscript);
1082
+ }
1083
+ else
1084
+ add(inputSignedKeys.ecdsa);
1085
+ }
1086
+ else if (!isAny) {
1087
+ add(inputSignedKeys.cross);
1088
+ // Legacy and BIP143 omit other sequences for NONE/SINGLE. BIP341 commits every sequence
1089
+ // whenever ANYONECANPAY is absent, independently of the output sighash mode.
1090
+ if (signature.taproot || (!isNone && !isSingle))
1091
+ res.add('sequence');
1092
+ if (signature.taproot)
1093
+ add(inputSignedKeys.prevout);
1094
+ }
1095
+ }
1096
+ return [...res];
1097
+ }
1098
+ signStatus(signatures = this.signatures()) {
770
1099
  // if addInput or addOutput is not possible, then all inputs or outputs are signed
771
1100
  let addInput = true, addOutput = true;
772
1101
  let inputs = [], outputs = [];
773
- for (let idx = 0; idx < this.inputs.length; idx++) {
774
- const status = this.inputStatus(idx);
775
- // Unsigned input doesn't affect anything
776
- if (status === 'unsigned')
777
- continue;
778
- const { sigInputs, sigOutputs } = this.inputSighash(idx);
1102
+ for (const { idx, sighash } of signatures) {
1103
+ const { sigInputs, sigOutputs } = sighashScope(sighash);
779
1104
  // Input type
780
- if (sigInputs === SignatureHash.ANYONECANPAY)
781
- inputs.push(idx);
1105
+ if (sigInputs === SignatureHash.ANYONECANPAY) {
1106
+ if (!inputs.includes(idx))
1107
+ inputs.push(idx);
1108
+ }
782
1109
  else
783
1110
  addInput = false;
784
1111
  // Output type
785
1112
  if (sigOutputs === SignatureHash.ALL)
786
1113
  addOutput = false;
787
- else if (sigOutputs === SignatureHash.SINGLE)
788
- outputs.push(idx);
1114
+ else if (sigOutputs === SignatureHash.SINGLE) {
1115
+ if (!outputs.includes(idx))
1116
+ outputs.push(idx);
1117
+ }
789
1118
  else if (sigOutputs === SignatureHash.NONE) {
790
1119
  // Doesn't affect any outputs at all
791
1120
  }
@@ -871,6 +1200,23 @@ export class Transaction {
871
1200
  if (idx >= this.inputs.length)
872
1201
  throw new Error(`Wrong input index=${idx}`);
873
1202
  }
1203
+ validatePrevoutsForSigning() {
1204
+ if (!this.opts.strictPrevoutValidation)
1205
+ return;
1206
+ for (let i = 0; i < this.inputs.length; i++) {
1207
+ const input = this.inputs[i];
1208
+ if (!input.nonWitnessUtxo) {
1209
+ throw new Error(`Transaction/sign: strictPrevoutValidation requires nonWitnessUtxo for input=${i}`);
1210
+ }
1211
+ if (input.txid === undefined || input.index === undefined) {
1212
+ throw new Error(`Transaction/sign: strictPrevoutValidation requires an outpoint for input=${i}`);
1213
+ }
1214
+ // A full previous transaction is only a trusted amount/script commitment after its txid and
1215
+ // selected output have been checked against the unsigned transaction. Also cross-check a
1216
+ // redundant witnessUtxo when one is present.
1217
+ validateInput(input, this.opts.disableScriptCheck);
1218
+ }
1219
+ }
874
1220
  getInput(idx) {
875
1221
  this.checkInputIdx(idx);
876
1222
  return cloneDeep(this.inputs[idx]);
@@ -881,24 +1227,57 @@ export class Transaction {
881
1227
  // Modification
882
1228
  addInput(input, _ignoreSignStatus = false) {
883
1229
  validateObject(input, {}, {}, 'input');
884
- if (!_ignoreSignStatus && !this.signStatus().addInput)
1230
+ cleanExtensions(input, this.opts.unknown, this.opts.proprietary, true);
1231
+ this.requireTxModifiable(0b001, 'inputs');
1232
+ const signatures = _ignoreSignStatus ? undefined : this.signatures();
1233
+ const status = signatures && this.signStatus(signatures);
1234
+ if (status && !status.addInput)
885
1235
  throw new Error('Tx has signed inputs, cannot add new one');
886
1236
  // normalizeInput preserves nested caller-owned byte arrays, so detach them here before the
887
1237
  // new input becomes transaction state and later caller mutation can rewrite it by aliasing.
888
- this.inputs.push(cloneDeep(normalizeInput(input, undefined, undefined, this.opts.disableScriptCheck)));
1238
+ const normalized = cloneDeep(normalizeInput(input, undefined, undefined, this.opts.disableScriptCheck, this.opts.unknown, this.opts.proprietary));
1239
+ const nextLockTime = resolvePSBTLocktime([...this.inputs, normalized], this.global.fallbackLocktime ?? DEFAULT_LOCKTIME);
1240
+ // ANYONECANPAY permits adding an outpoint, but every signature still commits to nLockTime.
1241
+ if (signatures?.length && nextLockTime !== this.lockTime)
1242
+ throw new Error('Tx has signed inputs, cannot change lockTime');
1243
+ this.inputs.push(normalized);
889
1244
  return this.inputs.length - 1;
890
1245
  }
891
1246
  updateInput(idx, input, _ignoreSignStatus = false) {
892
1247
  this.checkInputIdx(idx);
893
- let allowedFields = undefined;
894
- if (!_ignoreSignStatus) {
895
- const status = this.signStatus();
896
- if (!status.addInput || status.inputs.includes(idx))
897
- allowedFields = psbt.PSBTInputUnsignedKeys;
1248
+ cleanExtensions(input, this.opts.unknown, this.opts.proprietary, true);
1249
+ let allowedFields;
1250
+ const signatures = _ignoreSignStatus ? undefined : this.signatures();
1251
+ if (signatures?.length) {
1252
+ if (this.inputStatus(idx) === 'finalized') {
1253
+ // Once finalized, only already-present signature/final fields may be repeated or removed.
1254
+ // In particular, do not let a native-SegWit final witness gain a stray finalScriptSig.
1255
+ allowedFields = psbt.PSBTInputSignatureKeys.filter((key) => this.inputs[idx][key] !== undefined);
1256
+ }
1257
+ else {
1258
+ const signed = new Set(this.signedInputKeys(idx, signatures));
1259
+ if (signed.size)
1260
+ allowedFields = Object.keys(psbt.PSBTInput).filter((key) => !signed.has(key));
1261
+ }
898
1262
  }
899
1263
  // normalizeInput preserves nested caller-owned byte arrays, so detach the merged result here
900
1264
  // before the updated input becomes transaction state and later caller mutation can rewrite it.
901
- this.inputs[idx] = cloneDeep(normalizeInput(input, this.inputs[idx], allowedFields, this.opts.disableScriptCheck, this.opts.allowUnknown));
1265
+ const normalized = cloneDeep(normalizeInput(input, this.inputs[idx], allowedFields, this.opts.disableScriptCheck, this.opts.unknown, this.opts.proprietary));
1266
+ const inputs = this.inputs.slice();
1267
+ inputs[idx] = normalized;
1268
+ const nextLockTime = resolvePSBTLocktime(inputs, this.global.fallbackLocktime ?? DEFAULT_LOCKTIME);
1269
+ if (signatures?.length && nextLockTime !== this.lockTime)
1270
+ throw new Error('Tx has signed inputs, cannot change lockTime');
1271
+ const current = this.inputs[idx];
1272
+ const transactionChanged = current.index !== normalized.index ||
1273
+ def(current.sequence, DEFAULT_SEQUENCE) !== def(normalized.sequence, DEFAULT_SEQUENCE) ||
1274
+ (current.txid === undefined
1275
+ ? normalized.txid !== undefined
1276
+ : normalized.txid === undefined || !equalBytes(current.txid, normalized.txid)) ||
1277
+ nextLockTime !== this.lockTime;
1278
+ if (transactionChanged)
1279
+ this.requireTxModifiable(0b001, 'inputs');
1280
+ this.inputs[idx] = normalized;
902
1281
  }
903
1282
  // Output stuff
904
1283
  checkOutputIdx(idx) {
@@ -914,7 +1293,7 @@ export class Transaction {
914
1293
  const out = this.getOutput(idx);
915
1294
  if (!out.script)
916
1295
  return;
917
- return Address(network).encode(OutScript.decode(out.script));
1296
+ return Address(network).encode(_WitnessOutScript.decode(out.script));
918
1297
  }
919
1298
  get outputsLength() {
920
1299
  return this.outputs.length;
@@ -932,11 +1311,11 @@ export class Transaction {
932
1311
  let res = { ...cur, ...o, amount, script };
933
1312
  if (res.amount === undefined)
934
1313
  delete res.amount;
935
- res = psbt.mergeKeyMap(psbt.PSBTOutput, res, cur, allowedFields, this.opts.allowUnknown);
1314
+ res = psbt.mergeKeyMap(psbt.PSBTOutput, res, cur, allowedFields, this.opts.unknown, this.opts.proprietary);
936
1315
  psbt.PSBTOutputCoder.encode(res);
937
1316
  if (res.script &&
938
1317
  !this.opts.allowUnknownOutputs &&
939
- OutScript.decode(res.script).type === 'unknown') {
1318
+ _WitnessOutScript.decode(res.script).type === 'unknown') {
940
1319
  throw new Error('Transaction/output: unknown output script type, there is a chance that input is unspendable. Pass allowUnknownOutputs=true, if you sure');
941
1320
  }
942
1321
  if (!this.opts.disableScriptCheck)
@@ -944,7 +1323,11 @@ export class Transaction {
944
1323
  return res;
945
1324
  }
946
1325
  addOutput(o, _ignoreSignStatus = false) {
947
- if (!_ignoreSignStatus && !this.signStatus().addOutput)
1326
+ cleanExtensions(o, this.opts.unknown, this.opts.proprietary, true);
1327
+ this.requireTxModifiable(0b010, 'outputs');
1328
+ const status = _ignoreSignStatus ? undefined : this.signStatus();
1329
+ // Appending the previously missing same-index output changes a SIGHASH_SINGLE digest.
1330
+ if (status && (!status.addOutput || status.outputs.includes(this.outputs.length)))
948
1331
  throw new Error('Tx has signed outputs, cannot add new one');
949
1332
  // normalizeOutput preserves nested caller-owned script bytes, so detach them here before the
950
1333
  // new output becomes transaction state and later caller mutation can rewrite it by aliasing.
@@ -953,6 +1336,7 @@ export class Transaction {
953
1336
  }
954
1337
  updateOutput(idx, output, _ignoreSignStatus = false) {
955
1338
  this.checkOutputIdx(idx);
1339
+ cleanExtensions(output, this.opts.unknown, this.opts.proprietary, true);
956
1340
  let allowedFields = undefined;
957
1341
  if (!_ignoreSignStatus) {
958
1342
  const status = this.signStatus();
@@ -961,7 +1345,15 @@ export class Transaction {
961
1345
  }
962
1346
  // updateOutput replaces stored state with normalizeOutput(...) directly, so detach the result
963
1347
  // before storing it or later caller mutation of `output.script` will rewrite transaction state.
964
- this.outputs[idx] = cloneDeep(this.normalizeOutput(output, this.outputs[idx], allowedFields));
1348
+ const current = this.outputs[idx];
1349
+ const normalized = cloneDeep(this.normalizeOutput(output, current, allowedFields));
1350
+ const transactionChanged = current.amount !== normalized.amount ||
1351
+ (current.script === undefined
1352
+ ? normalized.script !== undefined
1353
+ : normalized.script === undefined || !equalBytes(current.script, normalized.script));
1354
+ if (transactionChanged)
1355
+ this.requireTxModifiable(0b010, 'outputs');
1356
+ this.outputs[idx] = normalized;
965
1357
  }
966
1358
  addOutputAddress(address, amount, network = NETWORK) {
967
1359
  return this.addOutput({
@@ -994,7 +1386,8 @@ export class Transaction {
994
1386
  if (idx < 0 || !Number.isSafeInteger(idx))
995
1387
  throw new Error(`Invalid input idx=${idx}`);
996
1388
  if ((isSingle && idx >= this.outputs.length) || idx >= this.inputs.length)
997
- return P.U256BE.encode(_1n);
1389
+ // Bitcoin Core passes uint256::ONE's internal little-endian bytes directly to ECDSA.
1390
+ return P.U256LE.encode(_1n);
998
1391
  prevOutScript = stripCodeSeparator(prevOutScript);
999
1392
  let inputs = this.inputs
1000
1393
  .map(inputBeforeSign)
@@ -1075,6 +1468,12 @@ export class Transaction {
1075
1468
  const inType = hashType & SignatureHash.ANYONECANPAY;
1076
1469
  const inputs = this.inputs.map(inputBeforeSign);
1077
1470
  const outputs = this.outputs.map(outputBeforeSign);
1471
+ // Unlike legacy and segwit v0, BIP341 defines no digest for SINGLE when the
1472
+ // corresponding output does not exist. Returning a digest here would produce
1473
+ // signatures that consensus can never accept.
1474
+ if (outType === SignatureHash.SINGLE && idx >= outputs.length) {
1475
+ throw new Error(`Input with sighash SINGLE, but there is no output with corresponding index=${idx}`);
1476
+ }
1078
1477
  if (inType !== SignatureHash.ANYONECANPAY) {
1079
1478
  out.push(...[
1080
1479
  inputs.map(TxHashIdx.encode),
@@ -1097,7 +1496,7 @@ export class Transaction {
1097
1496
  if (spendType & 1)
1098
1497
  out.push(u.sha256(VarBytes.encode(annex || P.EMPTY)));
1099
1498
  if (outType === SignatureHash.SINGLE)
1100
- out.push(idx < outputs.length ? u.sha256(RawOutput.encode(outputs[idx])) : EMPTY32);
1499
+ out.push(u.sha256(RawOutput.encode(outputs[idx])));
1101
1500
  if (leafScript)
1102
1501
  out.push(tapLeafHash(leafScript, leafVer), P.U8.encode(0), P.I32LE.encode(codeSeparator));
1103
1502
  return u.tagSchnorr('TapSighash', ...out);
@@ -1113,7 +1512,8 @@ export class Transaction {
1113
1512
  throw new TypeError('"privateKey" expected Uint8Array or HDKey, got type=' + typeof privateKey);
1114
1513
  }
1115
1514
  this.checkInputIdx(idx);
1116
- const input = this.inputs[idx];
1515
+ this.validatePrevoutsForSigning();
1516
+ const input = validateInput(this.inputs[idx], this.opts.disableScriptCheck);
1117
1517
  const inputType = getInputType(input, this.opts.allowLegacyWitnessUtxo);
1118
1518
  const canSign = (privateKey) => {
1119
1519
  if (inputType.txType === 'taproot') {
@@ -1295,6 +1695,9 @@ export class Transaction {
1295
1695
  // Even worse: another user can add bip32 derivation, and spend money from different address.
1296
1696
  // Better api: signIdx
1297
1697
  sign(privateKey, allowedSighash, _auxRand) {
1698
+ // Check transaction-wide strict requirements outside the per-input catch below so callers get
1699
+ // the actionable validation error instead of the generic "No inputs signed" result.
1700
+ this.validatePrevoutsForSigning();
1298
1701
  let num = 0;
1299
1702
  for (let i = 0; i < this.inputs.length; i++) {
1300
1703
  try {
@@ -1312,15 +1715,23 @@ export class Transaction {
1312
1715
  if (this.fee < _0n)
1313
1716
  throw new Error('Outputs spends more than inputs amount');
1314
1717
  const input = this.inputs[idx];
1718
+ // Validate strict extension policy before constructing satisfaction so a rejection is atomic.
1719
+ cleanExtensions(input, this.opts.unknown, this.opts.proprietary);
1720
+ cleanTxModifiable(this.global.txModifiable, this.opts.unknown);
1315
1721
  const inputType = getInputType(input, this.opts.allowLegacyWitnessUtxo);
1316
1722
  // Taproot finalize
1317
1723
  if (inputType.txType === 'taproot') {
1318
1724
  if (input.tapKeySig)
1319
1725
  input.finalScriptWitness = [input.tapKeySig];
1320
1726
  else if (input.tapLeafScript && input.tapScriptSig) {
1321
- // Sort leafs by control block length.
1322
- const leafs = input.tapLeafScript.sort((a, b) => psbt.TaprootControlBlock.encode(a[0]).length -
1727
+ // Preserve the old shallowest-path tie-break without mutating caller-visible leaf order.
1728
+ const leafs = input.tapLeafScript
1729
+ .slice()
1730
+ .sort((a, b) => psbt.TaprootControlBlock.encode(a[0]).length -
1323
1731
  psbt.TaprootControlBlock.encode(b[0]).length);
1732
+ let smallest;
1733
+ let smallestSize = Number.POSITIVE_INFINITY;
1734
+ let unsupported = false;
1324
1735
  for (const [cb, _script] of leafs) {
1325
1736
  // Last byte is version
1326
1737
  const script = _script.slice(0, -1);
@@ -1329,6 +1740,7 @@ export class Transaction {
1329
1740
  const hash = tapLeafHash(script, ver);
1330
1741
  const scriptSig = input.tapScriptSig.filter((i) => equalBytes(i[0].leafHash, hash));
1331
1742
  let signatures = [];
1743
+ let witness;
1332
1744
  if (outScript.type === 'tr_ms') {
1333
1745
  const m = outScript.m;
1334
1746
  const pubkeys = outScript.pubkeys;
@@ -1375,6 +1787,7 @@ export class Transaction {
1375
1787
  }
1376
1788
  else {
1377
1789
  const custom = this.opts.customScripts;
1790
+ let recognized = false;
1378
1791
  if (custom) {
1379
1792
  for (const c of custom) {
1380
1793
  if (!c.finalizeTaproot)
@@ -1383,31 +1796,44 @@ export class Transaction {
1383
1796
  const csEncoded = c.encode(scriptDecoded);
1384
1797
  if (csEncoded === undefined)
1385
1798
  continue;
1799
+ recognized = true;
1800
+ // Do not catch hook errors here. `undefined` means no satisfaction, while a throw
1801
+ // from a matching custom finalizer reports broken leaf/signature data and must
1802
+ // abort even when another leaf has already produced a valid witness candidate.
1386
1803
  const finalized = c.finalizeTaproot(script, csEncoded, scriptSig);
1387
1804
  if (!finalized)
1388
1805
  continue;
1389
- input.finalScriptWitness = finalized.concat(psbt.TaprootControlBlock.encode(cb));
1390
- delete input.finalScriptSig;
1391
- cleanFinalInput(input);
1392
- return;
1806
+ witness = finalized.concat(psbt.TaprootControlBlock.encode(cb));
1807
+ break;
1393
1808
  }
1394
1809
  }
1395
- throw new Error('Finalize: Unknown tapLeafScript');
1810
+ // Minimum search inspects every leaf, so an unsupported path cannot block an already
1811
+ // complete known path merely because it appears later. Retain the old error when no
1812
+ // supported satisfaction exists at all.
1813
+ if (!witness && !recognized && scriptSig.length)
1814
+ unsupported = true;
1815
+ if (!witness)
1816
+ continue;
1396
1817
  }
1397
1818
  // Witness is stack, so last element will be used first
1398
- input.finalScriptWitness = signatures
1399
- .reverse()
1400
- .concat([script, psbt.TaprootControlBlock.encode(cb)]);
1401
- break;
1819
+ witness ||= signatures.reverse().concat([script, psbt.TaprootControlBlock.encode(cb)]);
1820
+ const size = RawWitness.encode(witness).length;
1821
+ if (size >= smallestSize)
1822
+ continue;
1823
+ smallest = witness;
1824
+ smallestSize = size;
1402
1825
  }
1403
- if (!input.finalScriptWitness)
1826
+ if (!smallest && unsupported)
1827
+ throw new Error('Finalize: Unknown tapLeafScript');
1828
+ if (!smallest)
1404
1829
  throw new Error('finalize/taproot: empty witness');
1830
+ input.finalScriptWitness = smallest;
1405
1831
  }
1406
1832
  else
1407
1833
  throw new Error('finalize/taproot: unknown input');
1408
1834
  // BIP174 Input Finalizer: if scriptSig is empty for an input, 0x07 remains unset.
1409
1835
  delete input.finalScriptSig;
1410
- cleanFinalInput(input);
1836
+ this.cleanFinalInput(input);
1411
1837
  return;
1412
1838
  }
1413
1839
  if (!input.partialSig || !input.partialSig.length)
@@ -1478,7 +1904,7 @@ export class Transaction {
1478
1904
  input.finalScriptSig = finalScriptSig;
1479
1905
  if (finalScriptWitness)
1480
1906
  input.finalScriptWitness = finalScriptWitness;
1481
- cleanFinalInput(input);
1907
+ this.cleanFinalInput(input);
1482
1908
  }
1483
1909
  finalize() {
1484
1910
  for (let i = 0; i < this.inputs.length; i++)
@@ -1496,13 +1922,21 @@ export class Transaction {
1496
1922
  combine(other) {
1497
1923
  if (!(other instanceof Transaction))
1498
1924
  throw new TypeError('"other" expected Transaction, got type=' + typeof other);
1925
+ // Match main's accumulator model: operation policy belongs to the receiver that is mutated.
1926
+ const opts = this.opts;
1499
1927
  // BIP174 combiners merge same-transaction PSBTs across versions and emit the highest required
1500
1928
  // version, so PSBTVersion mismatches are normalized below instead of treated as conflicts.
1501
1929
  const PSBTVersion = Math.max(this.opts.PSBTVersion || 0, other.opts.PSBTVersion || 0);
1502
- for (const k of ['version', 'lockTime']) {
1503
- if (this.opts[k] !== other.opts[k]) {
1504
- throw new Error(`Transaction/combine: different ${k} this=${this.opts[k]} other=${other.opts[k]}`);
1505
- }
1930
+ if (this.opts.version !== other.opts.version)
1931
+ throw new Error(`Transaction/combine: different version this=${this.opts.version} ` +
1932
+ `other=${other.opts.version}`);
1933
+ const thisV2 = this.isPSBTv2();
1934
+ const otherV2 = other.isPSBTv2();
1935
+ if (!thisV2 || !otherV2) {
1936
+ const thisLockTime = this.lockTime;
1937
+ const otherLockTime = other.lockTime;
1938
+ if (thisLockTime !== otherLockTime)
1939
+ throw new Error(`Transaction/combine: different lockTime this=${thisLockTime} other=${otherLockTime}`);
1506
1940
  }
1507
1941
  for (const k of ['inputs', 'outputs']) {
1508
1942
  if (this[k].length !== other[k].length) {
@@ -1511,15 +1945,122 @@ export class Transaction {
1511
1945
  }
1512
1946
  // Same-transaction checks must compare the normalized unsigned tx bytes here: PSBTv0 stores
1513
1947
  // `global.unsignedTx`, while PSBTv2 reconstructs the same transaction from split fields.
1514
- if (!equalBytes(this.unsignedTx, other.unsignedTx))
1948
+ const unsignedTx = this.unsignedTx;
1949
+ if (!equalBytes(unsignedTx, other.unsignedTx))
1515
1950
  throw new Error(`Transaction/combine: different unsigned tx`);
1516
- this.global = psbt.mergeKeyMap(psbt.PSBTGlobal, this.global, other.global, undefined, this.opts.allowUnknown);
1951
+ let txModifiable;
1952
+ if (thisV2 && otherV2) {
1953
+ // Core combines the stored optional bytes without deriving replacements from signatures.
1954
+ // Only explicit legacy-omission compatibility gives an absent field an effective value.
1955
+ const policy = (tx) => {
1956
+ if (tx.global.txModifiable !== undefined)
1957
+ return cleanTxModifiable(tx.global.txModifiable, opts.unknown);
1958
+ return opts.allowMissingTxModifiable ? tx.modifiable(true, opts.unknown) : 0;
1959
+ };
1960
+ const a = policy(this);
1961
+ const b = policy(other);
1962
+ // Known mutability permissions use intersection and SIGHASH_SINGLE presence uses union.
1963
+ // Future flag bits must agree because this implementation does not know how to merge them.
1964
+ if ((a & ~0b111) !== (b & ~0b111))
1965
+ throw new Error('Transaction/combine: conflicting unknown txModifiable flags');
1966
+ txModifiable = (a & ~0b111) | (a & b & 0b011) | ((a | b) & 0b100);
1967
+ // Preserve Core's optional-field semantics when neither participant supplied policy.
1968
+ if (txModifiable === 0 &&
1969
+ this.global.txModifiable === undefined &&
1970
+ other.global.txModifiable === undefined &&
1971
+ !opts.allowMissingTxModifiable)
1972
+ txModifiable = undefined;
1973
+ }
1974
+ else if (thisV2)
1975
+ txModifiable = this.modifiable(opts.allowMissingTxModifiable, opts.unknown);
1976
+ else if (otherV2)
1977
+ txModifiable = other.modifiable(opts.allowMissingTxModifiable, opts.unknown);
1978
+ const thisGlobal = { ...this.global };
1979
+ const otherGlobal = { ...other.global };
1980
+ // PSBTv0 has no fallback-locktime field: fromPSBT caches unsignedTx.nLockTime there only for
1981
+ // effective-locktime resolution and v2 promotion. Do not merge that cache as a v2 wire value.
1982
+ if (thisV2 !== otherV2) {
1983
+ if (!thisV2)
1984
+ delete thisGlobal.fallbackLocktime;
1985
+ if (!otherV2)
1986
+ delete otherGlobal.fallbackLocktime;
1987
+ // BIP174 permits v0 to encode version zero explicitly or omit it. Remove only that v0
1988
+ // spelling before scalar conflicts; retaining the v2 field anchors repeated promotion when
1989
+ // the accumulator's original options still target v0.
1990
+ if (!thisV2)
1991
+ delete thisGlobal.version;
1992
+ if (!otherV2)
1993
+ delete otherGlobal.version;
1994
+ }
1995
+ // Transaction-modifiable has dedicated bitwise merge rules above. Fallback locktime is only
1996
+ // one input to the effective locktime resolved from the combined input maps, so retain receiver
1997
+ // precedence and validate the resulting unsigned transaction after those maps merge below.
1998
+ const fallbackLocktime = thisGlobal.fallbackLocktime !== undefined
1999
+ ? thisGlobal.fallbackLocktime
2000
+ : otherGlobal.fallbackLocktime;
2001
+ delete thisGlobal.txModifiable;
2002
+ delete otherGlobal.txModifiable;
2003
+ delete thisGlobal.fallbackLocktime;
2004
+ delete otherGlobal.fallbackLocktime;
2005
+ // Every ordinary global scalar must agree when both participants provide it; silently choosing
2006
+ // either value can detach extension metadata such as a BIP322 message from its signatures.
2007
+ const global = psbt.combineKeyMap(psbt.PSBTGlobal, thisGlobal, otherGlobal, opts.unknown, opts.proprietary);
2008
+ if (fallbackLocktime !== undefined)
2009
+ global.fallbackLocktime = fallbackLocktime;
1517
2010
  if (PSBTVersion)
1518
- this.global.version = PSBTVersion;
1519
- for (let i = 0; i < this.inputs.length; i++)
1520
- this.updateInput(i, other.inputs[i], true);
1521
- for (let i = 0; i < this.outputs.length; i++)
1522
- this.updateOutput(i, other.outputs[i], true);
2011
+ global.version = PSBTVersion;
2012
+ if (txModifiable === undefined)
2013
+ delete global.txModifiable;
2014
+ else
2015
+ global.txModifiable = txModifiable;
2016
+ let hasOpaqueFinalizedV0 = false;
2017
+ const inputs = this.inputs.map((current, i) => {
2018
+ const currentFinal = this.inputStatus(i) === 'finalized';
2019
+ const otherFinal = other.inputStatus(i) === 'finalized';
2020
+ // Finalized v0 maps no longer contain the partial signatures needed to derive v2 flags.
2021
+ if ((!thisV2 && currentFinal) || (!otherV2 && otherFinal))
2022
+ hasOpaqueFinalizedV0 = true;
2023
+ if (currentFinal && otherFinal) {
2024
+ // Two finalized PSBTs must describe the same complete satisfaction. Requiring matching
2025
+ // presence as well as matching values prevents combining witness-only and scriptSig-only
2026
+ // final states into a third, unreviewed satisfaction.
2027
+ for (const k of ['finalScriptSig', 'finalScriptWitness']) {
2028
+ const currentHas = !!this.inputs[i][k]?.length;
2029
+ const otherHas = !!other.inputs[i][k]?.length;
2030
+ if (currentHas !== otherHas)
2031
+ throw new Error(`Transaction/combine: different finalized field=${k} input=${i}`);
2032
+ }
2033
+ }
2034
+ const combined = psbt.combineKeyMap(psbt.PSBTInput, current, other.inputs[i], opts.unknown, opts.proprietary);
2035
+ // A final satisfaction supersedes partial signatures and transient signing metadata. This
2036
+ // also avoids manufacturing a contradictory final+partial input from two valid PSBTs.
2037
+ if (currentFinal || otherFinal)
2038
+ cleanFinalInput(combined, opts.unknown, opts.proprietary);
2039
+ return cloneDeep(normalizeInput(combined, undefined, undefined, opts.disableScriptCheck, opts.unknown, opts.proprietary));
2040
+ });
2041
+ // A promoted opaque satisfaction may commit to both transaction halves. Clear only known
2042
+ // permissions; retain a v2 participant's SIGHASH_SINGLE indicator and any future flag bits.
2043
+ if (hasOpaqueFinalizedV0 && global.txModifiable !== undefined)
2044
+ global.txModifiable &= ~0b011;
2045
+ const candidate = new Transaction({ ...opts, PSBTVersion });
2046
+ const outputs = this.outputs.map((current, i) => {
2047
+ const combined = psbt.combineKeyMap(psbt.PSBTOutput, current, other.outputs[i], opts.unknown, opts.proprietary);
2048
+ return cloneDeep(candidate.normalizeOutput(combined));
2049
+ });
2050
+ // Build and validate a detached candidate before touching the receiver. Combining
2051
+ // complementary v2 locktime fields can otherwise create a different unsigned transaction.
2052
+ candidate.global = global;
2053
+ candidate.inputs = inputs;
2054
+ candidate.outputs = outputs;
2055
+ // A v0 input map can contribute signatures while the combined transaction is promoted to v2.
2056
+ // All-v2 restrictions were already intersected above, preserving mutual field omission.
2057
+ if ((!thisV2 || !otherV2) && candidate.isPSBTv2())
2058
+ candidate.global.txModifiable = candidate.txModifiable;
2059
+ if (!equalBytes(candidate.unsignedTx, unsignedTx))
2060
+ throw new Error('Transaction/combine: combined unsigned tx differs');
2061
+ this.global = candidate.global;
2062
+ this.inputs = candidate.inputs;
2063
+ this.outputs = candidate.outputs;
1523
2064
  return this;
1524
2065
  }
1525
2066
  clone() {
@@ -1530,6 +2071,7 @@ export class Transaction {
1530
2071
  /**
1531
2072
  * Merges multiple PSBT blobs into one.
1532
2073
  * @param psbts - PSBT byte arrays to combine
2074
+ * @param opts - Transaction parsing, combination, and serialization options. See {@link TxOpts}.
1533
2075
  * @returns Combined PSBT bytes.
1534
2076
  * @throws If the PSBT list is empty or the partial transactions cannot be combined. {@link Error}
1535
2077
  * @example
@@ -1540,13 +2082,15 @@ export class Transaction {
1540
2082
  * PSBTCombine([psbt, psbt]);
1541
2083
  * ```
1542
2084
  */
1543
- export function PSBTCombine(psbts) {
2085
+ export function PSBTCombine(psbts, opts = {}) {
1544
2086
  if (!psbts || !Array.isArray(psbts) || !psbts.length)
1545
2087
  throw new Error('PSBTCombine: wrong PSBT list');
1546
- const tx = Transaction.fromPSBT(psbts[0]);
2088
+ // Options affect both map cleanup during combination and the encoding of the returned PSBT.
2089
+ const combineOpts = opts;
2090
+ const tx = Transaction.fromPSBT(psbts[0], combineOpts);
1547
2091
  for (let i = 1; i < psbts.length; i++)
1548
- tx.combine(Transaction.fromPSBT(psbts[i]));
1549
- return tx.toPSBT();
2092
+ tx.combine(Transaction.fromPSBT(psbts[i], tx.opts));
2093
+ return tx.toPSBT(combineOpts.PSBTVersion);
1550
2094
  }
1551
2095
  // Copy-pasted from bip32 derive, maybe do something like 'bip32.parsePath'?
1552
2096
  const HARDENED_OFFSET = 0x80000000;