@scure/btc-signer 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,23 +4,74 @@ import { Address, type CustomScript, OutScript, checkScript, tapLeafHash } from
4
4
  import * as psbt from './psbt.ts';
5
5
  import {
6
6
  CompactSizeLen,
7
+ OP,
7
8
  RawOldTx,
9
+ RawInput,
8
10
  RawOutput,
9
11
  RawTx,
10
12
  RawWitness,
11
13
  Script,
14
+ scriptPushLen,
12
15
  VarBytes,
13
16
  } from './script.ts';
14
17
  import * as u from './utils.ts';
15
- import { type Bytes, NETWORK, concatBytes, equalBytes, isBytes } from './utils.ts';
18
+ import {
19
+ type Bytes,
20
+ NETWORK,
21
+ concatBytes,
22
+ equalBytes,
23
+ isBytes,
24
+ type TArg,
25
+ type TRet,
26
+ } from './utils.ts';
16
27
 
17
- const EMPTY32: Uint8Array = new Uint8Array(32);
28
+ const EMPTY32: Uint8Array = /* @__PURE__ */ new Uint8Array(32);
18
29
  const EMPTY_OUTPUT: P.UnwrapCoder<typeof RawOutput> = {
19
30
  amount: 0xffffffffffffffffn,
20
31
  script: P.EMPTY,
21
32
  };
33
+ /**
34
+ * Converts transaction weight units into virtual bytes.
35
+ * @param weight - transaction weight
36
+ * @returns Rounded-up virtual size.
37
+ * @example
38
+ * Convert transaction weight units into virtual bytes.
39
+ * ```ts
40
+ * toVsize(4);
41
+ * ```
42
+ */
22
43
  export const toVsize = (weight: number): number => Math.ceil(weight / 4);
23
44
 
45
+ const stripCodeSeparator = (script: TArg<Bytes>): TRet<Bytes> => {
46
+ // Reuse Script's raw pushdata-length parser here. Legacy sighash must remove
47
+ // only actual OP_CODESEPARATOR opcodes while preserving every other original
48
+ // byte, because semantic decode/re-encode would change the signed digest.
49
+ let start = 0;
50
+ const out: Uint8Array[] = [];
51
+ for (let i = 0; i < script.length; ) {
52
+ const pos = i;
53
+ const op = script[i++];
54
+ if (op === OP.CODESEPARATOR) {
55
+ if (start < pos) out.push(script.subarray(start, pos));
56
+ start = i;
57
+ continue;
58
+ }
59
+ const len = scriptPushLen(op, (bytes) => {
60
+ if (i + bytes > script.length) throw new Error('Unexpected end of script');
61
+ let len = 0;
62
+ for (let j = 0; j < bytes; j++) len |= script[i + j] << (8 * j);
63
+ i += bytes;
64
+ return len;
65
+ });
66
+ if (len === undefined) continue;
67
+ i += len;
68
+ if (i > script.length) throw new Error('Unexpected end of script');
69
+ }
70
+ if (start === 0) return script as TRet<Bytes>;
71
+ if (start < script.length) out.push(script.subarray(start));
72
+ return (out.length ? concatBytes(...out) : P.EMPTY) as TRet<Bytes>;
73
+ };
74
+
24
75
  // @scure/bip32 interface
25
76
  interface HDKey {
26
77
  publicKey: Bytes;
@@ -31,17 +82,61 @@ interface HDKey {
31
82
  sign(hash: Bytes): Bytes;
32
83
  }
33
84
 
85
+ /** Signing source accepted by transaction signing helpers. */
34
86
  export type Signer = Bytes | HDKey;
35
87
 
88
+ /** Decimal precision used for BTC string formatting. */
36
89
  export const PRECISION = 8;
90
+ /** Default transaction version used for newly created transactions. */
37
91
  export const DEFAULT_VERSION = 2;
92
+ /** Default transaction locktime. */
38
93
  export const DEFAULT_LOCKTIME = 0;
94
+ /** Default input sequence number.
95
+ * Final (`0xffffffff`): matches the PSBT omission default and disables nLockTime/CLTV semantics
96
+ * unless callers choose a lower sequence explicitly (for example `0xfffffffe` with lockTime).
97
+ */
39
98
  export const DEFAULT_SEQUENCE = 4294967295;
40
- export const Decimal: P.Coder<bigint, string> = P.coders.decimal(PRECISION);
99
+ /**
100
+ * Decimal coder for BTC-denominated strings.
101
+ * This is a fixed-precision BTC-string to satoshi-bigint helper, not a validator
102
+ * for transaction/PSBT output amounts. Signed values are intentional here, so
103
+ * callers can reuse the helper for display/history-style deltas as well as
104
+ * unsigned transfer amounts. It keeps the BTC scale at 8 fractional digits and
105
+ * rejects over-precise inputs instead of rounding.
106
+ * @example
107
+ * Convert between satoshi bigint values and BTC-denominated decimal strings.
108
+ * ```ts
109
+ * Decimal.encode(1n);
110
+ * ```
111
+ */
112
+ export const Decimal: P.Coder<bigint, string> = /* @__PURE__ */ (() =>
113
+ Object.freeze(P.coders.decimal(PRECISION)))();
41
114
 
42
115
  // Same as value || def, but doesn't overwrites zero ('0', 0, 0n, etc)
116
+ /**
117
+ * Returns a fallback only when the value is `undefined`.
118
+ * @param value - optional value
119
+ * @param def - fallback value
120
+ * @returns `value` when defined, otherwise `def`.
121
+ * @example
122
+ * Keep zero-like values but replace `undefined` with a fallback.
123
+ * ```ts
124
+ * def(undefined, 1);
125
+ * ```
126
+ */
43
127
  export const def = <T>(value: T | undefined, def: T): T => (value === undefined ? def : value);
44
128
 
129
+ /**
130
+ * Deep-clones plain transaction data structures.
131
+ * @param obj - value to clone
132
+ * @returns Deep copy of the input value.
133
+ * @throws If the value contains an unsupported runtime type. {@link Error}
134
+ * @example
135
+ * Clone plain transaction data structures before mutating them.
136
+ * ```ts
137
+ * cloneDeep({ a: [new Uint8Array([1])] });
138
+ * ```
139
+ */
45
140
  export function cloneDeep<T>(obj: T): T {
46
141
  if (Array.isArray(obj)) return obj.map((i) => cloneDeep(i)) as unknown as T;
47
142
  // slice of nodejs Buffer doesn't copy
@@ -56,70 +151,110 @@ export function cloneDeep<T>(obj: T): T {
56
151
  Object.entries(obj).map(([k, v]) => [k, cloneDeep(v)])
57
152
  ) as unknown as T;
58
153
  }
59
- throw new Error(`cloneDeep: unknown type=${obj} (${typeof obj})`);
154
+ // Don't interpolate unsupported values here: Symbol string coercion would
155
+ // throw before cloneDeep can surface its own stable helper error.
156
+ throw new Error(`cloneDeep: unknown type=${typeof obj}`);
60
157
  }
61
158
 
62
159
  // Mostly security features, hardened defaults;
63
160
  // but you still can parse other people tx with unspendable outputs and stuff if you want
161
+ /** Transaction construction and parsing options. */
64
162
  export interface TxOpts {
163
+ /** Transaction version to place into new transactions and imported PSBTs. */
65
164
  version?: number;
165
+ /** Global locktime for the transaction. */
66
166
  lockTime?: number;
167
+ /** PSBT version to emit when serializing. */
67
168
  PSBTVersion?: number;
68
169
  // Flags
69
170
  // Allow non-standard transaction version
171
+ /** Allow transaction versions outside the standard small set. */
70
172
  allowUnknownVersion?: boolean;
71
173
  // Allow output scripts to be unknown scripts (probably unspendable)
72
- /** @deprecated Use `allowUnknownOutputs` */
174
+ /**
175
+ * Deprecated alias for {@link allowUnknownOutputs}.
176
+ * @deprecated Use `allowUnknownOutputs`.
177
+ */
73
178
  allowUnknowOutput?: boolean;
179
+ /** Allow outputs with scripts this library does not recognize. */
74
180
  allowUnknownOutputs?: boolean;
75
181
  // Try to sign/finalize unknown input. All bets are off, but there is chance that it will work
76
- /** @deprecated Use `allowUnknownInputs` */
182
+ /**
183
+ * Deprecated alias for {@link allowUnknownInputs}.
184
+ * @deprecated Use `allowUnknownInputs`.
185
+ */
77
186
  allowUnknowInput?: boolean;
187
+ /** Allow signing and finalizing inputs with unknown script shapes. */
78
188
  allowUnknownInputs?: boolean;
79
189
  // Check input/output scripts for sanity
190
+ /** Skip redeem-script and witness-script consistency checks. */
80
191
  disableScriptCheck?: boolean;
81
192
  // There is strange behaviour where tx without outputs encoded with empty output in the end,
82
193
  // tx without outputs in BIP174 doesn't have itb
194
+ /** Match the odd empty-output encoding used by `bip174js`. */
83
195
  bip174jsCompat?: boolean;
84
196
  // If transaction data comes from untrusted source, then it can be modified in such way that will
85
197
  // result paying higher mining fee
198
+ /** Permit legacy inputs that only provide witness UTXO data. */
86
199
  allowLegacyWitnessUtxo?: boolean;
87
- lowR?: boolean; // Use lowR signatures
88
- customScripts?: CustomScript[]; // UNSAFE: Custom payment scripts
200
+ /** Grind ECDSA signatures until they use a low-R encoding. */
201
+ lowR?: boolean;
202
+ /** UNSAFE: additional custom payment-script codecs and finalizers. */
203
+ customScripts?: CustomScript[];
89
204
  // Allow to add additional unknown keys/values to the "unknown" array member
205
+ /** Preserve unknown PSBT key/value pairs instead of stripping them. */
90
206
  allowUnknown?: boolean;
91
207
  }
92
208
 
93
209
  /**
94
210
  * Internal, exported only for backwards-compat. Use `SigHash` instead.
95
- * @deprecated
211
+ * @deprecated Use {@link SigHash} instead.
212
+ * @example
213
+ * Combine the legacy bit flags when interoperating with older code.
214
+ * ```ts
215
+ * SignatureHash.ALL | SignatureHash.ANYONECANPAY;
216
+ * ```
96
217
  */
97
- export const SignatureHash = {
98
- DEFAULT: 0,
99
- ALL: 1,
100
- NONE: 2,
101
- SINGLE: 3,
102
- ANYONECANPAY: 0x80,
103
- };
218
+ export const SignatureHash = /* @__PURE__ */ (() =>
219
+ Object.freeze({
220
+ DEFAULT: 0,
221
+ ALL: 1,
222
+ NONE: 2,
223
+ SINGLE: 3,
224
+ ANYONECANPAY: 0x80,
225
+ } as const))();
104
226
 
105
- export const SigHash = {
106
- DEFAULT: SignatureHash.DEFAULT,
107
- ALL: SignatureHash.ALL,
108
- NONE: SignatureHash.NONE,
109
- SINGLE: SignatureHash.SINGLE,
110
- DEFAULT_ANYONECANPAY: SignatureHash.DEFAULT | SignatureHash.ANYONECANPAY,
111
- ALL_ANYONECANPAY: SignatureHash.ALL | SignatureHash.ANYONECANPAY,
112
- NONE_ANYONECANPAY: SignatureHash.NONE | SignatureHash.ANYONECANPAY,
113
- SINGLE_ANYONECANPAY: SignatureHash.SINGLE | SignatureHash.ANYONECANPAY,
114
- } as const;
115
- export const SigHashNames = u.reverseObject(SigHash);
227
+ /**
228
+ * Common signature hash flag combinations.
229
+ * @example
230
+ * Use the predefined signature-hash combinations exported by the library.
231
+ * ```ts
232
+ * SigHash.SINGLE_ANYONECANPAY;
233
+ * ```
234
+ */
235
+ export const SigHash = /* @__PURE__ */ (() =>
236
+ Object.freeze({
237
+ DEFAULT: SignatureHash.DEFAULT,
238
+ ALL: SignatureHash.ALL,
239
+ NONE: SignatureHash.NONE,
240
+ SINGLE: SignatureHash.SINGLE,
241
+ // BIP341 only permits 0x00, 0x01, 0x02, 0x03, 0x81, 0x82, and 0x83 for taproot, so
242
+ // the mechanical `DEFAULT | ANYONECANPAY` combination (0x80) is invalid and not exported.
243
+ // DEFAULT_ANYONECANPAY: SignatureHash.DEFAULT | SignatureHash.ANYONECANPAY,
244
+ ALL_ANYONECANPAY: SignatureHash.ALL | SignatureHash.ANYONECANPAY,
245
+ NONE_ANYONECANPAY: SignatureHash.NONE | SignatureHash.ANYONECANPAY,
246
+ SINGLE_ANYONECANPAY: SignatureHash.SINGLE | SignatureHash.ANYONECANPAY,
247
+ } as const))();
248
+ /** Reverse lookup table for signature hash flag names. */
249
+ export const SigHashNames = /* @__PURE__ */ (() => Object.freeze(u.reverseObject(SigHash)))();
250
+ /** Signature-hash flag number accepted by signing helpers. */
116
251
  export type SigHash = u.ValueOf<typeof SigHash>;
117
252
 
118
253
  function getTaprootKeys(
119
- privKey: Bytes,
120
- pubKey: Bytes,
121
- internalKey: Bytes,
122
- merkleRoot: Bytes = P.EMPTY
254
+ privKey: TArg<Bytes>,
255
+ pubKey: TArg<Bytes>,
256
+ internalKey: TArg<Bytes>,
257
+ merkleRoot: TArg<Bytes> = P.EMPTY
123
258
  ) {
124
259
  if (equalBytes(internalKey, pubKey)) {
125
260
  privKey = u.taprootTweakPrivKey(privKey, merkleRoot);
@@ -129,40 +264,69 @@ function getTaprootKeys(
129
264
  }
130
265
 
131
266
  // User facing API with decoders
267
+ /** Minimal transaction input fields required to serialize and sign. */
132
268
  export type TransactionInputRequired = {
269
+ /** Previous transaction id being spent. */
133
270
  txid: Bytes;
271
+ /** Previous output index inside that transaction. */
134
272
  index: number;
273
+ /** Final sequence number that will be serialized for the input. */
135
274
  sequence: number;
275
+ /** Final scriptSig bytes that will be serialized for the input. */
136
276
  finalScriptSig: Bytes;
137
277
  };
138
278
 
139
279
  // Force check amount/script
140
- function outputBeforeSign(i: psbt.TransactionOutput): psbt.TransactionOutputRequired {
280
+ function outputBeforeSign(i: TArg<psbt.TransactionOutput>): TRet<psbt.TransactionOutputRequired> {
141
281
  if (i.script === undefined || i.amount === undefined)
142
282
  throw new Error('Transaction/output: script and amount required');
143
- return { script: i.script, amount: i.amount };
283
+ return { script: i.script, amount: i.amount } as TRet<psbt.TransactionOutputRequired>;
144
284
  }
145
285
 
146
286
  // Force check index/txid/sequence
147
- export function inputBeforeSign(i: psbt.TransactionInput): TransactionInputRequired {
287
+ /**
288
+ * Normalizes a PSBT input into the fields needed for signing.
289
+ * @param i - PSBT input to validate
290
+ * @returns Input fields required for signing.
291
+ * @throws If the input is missing `txid` or `index`. {@link Error}
292
+ * @example
293
+ * Fill in defaults for the fields the signer expects to see.
294
+ * ```ts
295
+ * import { hex } from '@scure/base';
296
+ * import { inputBeforeSign } from '@scure/btc-signer/transaction.js';
297
+ * inputBeforeSign({
298
+ * txid: hex.decode('0000000000000000000000000000000000000000000000000000000000000001'),
299
+ * index: 0,
300
+ * });
301
+ * ```
302
+ */
303
+ export function inputBeforeSign(i: TArg<psbt.TransactionInput>): TRet<TransactionInputRequired> {
148
304
  if (i.txid === undefined || i.index === undefined)
149
305
  throw new Error('Transaction/input: txid and index required');
150
- return {
306
+ const res = {
151
307
  txid: i.txid,
152
308
  index: i.index,
153
309
  sequence: def(i.sequence, DEFAULT_SEQUENCE),
154
310
  finalScriptSig: def(i.finalScriptSig, P.EMPTY),
155
311
  };
312
+ // This helper is the public "normalize for signing" boundary, so reuse RawInput's existing
313
+ // wire-shape checks here instead of letting malformed runtime field types fail much later.
314
+ RawInput.encode(res);
315
+ return res as TRet<TransactionInputRequired>;
156
316
  }
157
- function cleanFinalInput(i: psbt.TransactionInput) {
158
- for (const _k in i) {
159
- const k = _k as keyof psbt.TransactionInput;
160
- if (!psbt.PSBTInputFinalKeys.includes(k)) delete i[k];
317
+ function cleanFinalInput(i: TArg<PSBTInputs>) {
318
+ const _i = i as PSBTInputs;
319
+ // BIP174 finalizers clear non-final input metadata after constructing final scripts/witnesses.
320
+ // That intentionally drops sighashType here, so post-finalize mutation becomes conservative
321
+ // until callers explicitly reopen the input by removing finalScriptSig/finalScriptWitness.
322
+ for (const _k in _i) {
323
+ const k = _k as keyof PSBTInputs;
324
+ if (!psbt.PSBTInputFinalKeys.includes(k)) delete _i[k];
161
325
  }
162
326
  }
163
327
 
164
328
  // (TxHash, Idx)
165
- const TxHashIdx = P.struct({ txid: P.bytes(32, true), index: P.U32LE });
329
+ const TxHashIdx = /* @__PURE__ */ (() => P.struct({ txid: P.bytes(32, true), index: P.U32LE }))();
166
330
 
167
331
  function validateSigHash(s: SigHash) {
168
332
  if (typeof s !== 'number' || typeof SigHashNames[s] !== 'string')
@@ -179,7 +343,7 @@ function unpackSighash(hashType: number) {
179
343
  };
180
344
  }
181
345
 
182
- function validateOpts(opts: TxOpts): Readonly<TxOpts> {
346
+ function validateOpts(opts: TArg<TxOpts>): TRet<Readonly<TxOpts>> {
183
347
  if (opts !== undefined && {}.toString.call(opts) !== '[object Object]')
184
348
  throw new Error(`Wrong object type for transaction options: ${opts}`);
185
349
 
@@ -190,10 +354,12 @@ function validateOpts(opts: TxOpts): Readonly<TxOpts> {
190
354
  lockTime: def(opts.lockTime, 0),
191
355
  PSBTVersion: def(opts.PSBTVersion, 0),
192
356
  };
357
+ // Normalize deprecated aliases on the owned copy so they still affect tx.opts without rewriting the
358
+ // caller-owned options object passed to the constructor.
193
359
  if (typeof _opts.allowUnknowInput !== 'undefined')
194
- opts.allowUnknownInputs = _opts.allowUnknowInput;
360
+ _opts.allowUnknownInputs = _opts.allowUnknowInput;
195
361
  if (typeof _opts.allowUnknowOutput !== 'undefined')
196
- opts.allowUnknownOutputs = _opts.allowUnknowOutput;
362
+ _opts.allowUnknownOutputs = _opts.allowUnknowOutput;
197
363
  if (typeof _opts.lockTime !== 'number') throw new Error('Transaction lock time should be number');
198
364
  P.U32LE.encode(_opts.lockTime); // Additional range checks that lockTime
199
365
  // There is no PSBT v1, and any new version will probably have fields which we don't know how to parse, which
@@ -236,23 +402,25 @@ function validateOpts(opts: TxOpts): Readonly<TxOpts> {
236
402
  throw new Error(`wrong script=${s} (${typeof s})`);
237
403
  }
238
404
  }
239
- return Object.freeze(_opts);
405
+ return Object.freeze(_opts) as TRet<Readonly<TxOpts>>;
240
406
  }
241
407
 
242
408
  // NOTE: we cannot do this inside PSBTInput coder, because there is no index/txid at this point!
243
- function validateInput(i: psbt.TransactionInput): psbt.TransactionInput {
244
- if (i.nonWitnessUtxo && i.index !== undefined) {
245
- const last = i.nonWitnessUtxo.outputs.length - 1;
246
- if (i.index > last) throw new Error(`validateInput: index(${i.index}) not in nonWitnessUtxo`);
247
- const prevOut = i.nonWitnessUtxo.outputs[i.index];
409
+ function validateInput(i: TArg<psbt.TransactionInput>): TRet<PSBTInputs> {
410
+ const _i = i as PSBTInputs;
411
+ if (_i.nonWitnessUtxo && _i.index !== undefined) {
412
+ const last = _i.nonWitnessUtxo.outputs.length - 1;
413
+ if (_i.index > last) throw new Error(`validateInput: index(${_i.index}) not in nonWitnessUtxo`);
414
+ const prevOut = _i.nonWitnessUtxo.outputs[_i.index];
248
415
  if (
249
- i.witnessUtxo &&
250
- (!equalBytes(i.witnessUtxo.script, prevOut.script) || i.witnessUtxo.amount !== prevOut.amount)
416
+ _i.witnessUtxo &&
417
+ (!equalBytes(_i.witnessUtxo.script, prevOut.script) ||
418
+ _i.witnessUtxo.amount !== prevOut.amount)
251
419
  )
252
420
  throw new Error('validateInput: witnessUtxo different from nonWitnessUtxo');
253
- if (i.txid) {
254
- const outputs = i.nonWitnessUtxo.outputs;
255
- if (outputs.length - 1 < i.index) throw new Error('nonWitnessUtxo: incorect output index');
421
+ if (_i.txid) {
422
+ const outputs = _i.nonWitnessUtxo.outputs;
423
+ if (outputs.length - 1 < _i.index) throw new Error('nonWitnessUtxo: incorect output index');
256
424
  // At this point, we are using previous tx output to create new input.
257
425
  // Script safety checks are unnecessary:
258
426
  // - User has no control over previous tx. If somebody send money in same tx
@@ -261,56 +429,110 @@ function validateInput(i: psbt.TransactionInput): psbt.TransactionInput {
261
429
  // in case user wants to use wrong input by mistake
262
430
  // - Worst case: tx will be rejected by nodes. Still better than disallowing user
263
431
  // to spend real input, no matter how broken it looks
264
- const tx = Transaction.fromRaw(RawTx.encode(i.nonWitnessUtxo), {
432
+ const tx = Transaction.fromRaw(RawTx.encode(_i.nonWitnessUtxo), {
265
433
  allowUnknownOutputs: true,
266
434
  disableScriptCheck: true,
267
435
  allowUnknownInputs: true,
268
436
  });
269
- const txid = hex.encode(i.txid);
270
- // PSBTv2 vectors have non-final tx in inputs
271
- if (tx.isFinal && tx.id !== txid)
272
- throw new Error(`nonWitnessUtxo: wrong txid, exp=${txid} got=${tx.id}`);
437
+ const txid = hex.encode(_i.txid);
438
+ // BIP174 requires the provided nonWitnessUtxo to hash to the prevout txid even when the
439
+ // previous transaction is otherwise non-final; finality does not make its serialized txid optional.
440
+ // Keep the historical TransactionInput.txid convention here: internal txid bytes match
441
+ // `Transaction.id` (display-order hex), while raw-tx / PSBT boundary coders are responsible
442
+ // for any byte-order conversions required by their wire formats.
443
+ if (tx.id !== txid) throw new Error(`nonWitnessUtxo: wrong txid, exp=${txid} got=${tx.id}`);
273
444
  }
274
445
  }
275
- return i;
446
+ return _i as TRet<PSBTInputs>;
276
447
  }
277
448
 
449
+ /** Canonical PSBT input shape used by the coder layer. */
278
450
  export type PSBTInputs = psbt.PSBTKeyMapKeys<typeof psbt.PSBTInput>;
279
451
 
452
+ /** Canonical PSBT output shape used by the coder layer. */
453
+ export type PSBTOutputs = psbt.PSBTKeyMapKeys<typeof psbt.PSBTOutput>;
454
+
280
455
  // Normalizes input
281
- export function getPrevOut(input: psbt.TransactionInput): P.UnwrapCoder<typeof RawOutput> {
282
- if (input.nonWitnessUtxo) {
283
- if (input.index === undefined) throw new Error('Unknown input index');
284
- return input.nonWitnessUtxo.outputs[input.index];
285
- } else if (input.witnessUtxo) return input.witnessUtxo;
456
+ /**
457
+ * Extracts the previous output referenced by an input.
458
+ * @param input - PSBT input with previous output data
459
+ * @returns Previous output information.
460
+ * @throws If the input does not contain usable previous-output information. {@link Error}
461
+ * @example
462
+ * Read the previous output from either `witnessUtxo` or `nonWitnessUtxo`.
463
+ * ```ts
464
+ * getPrevOut({ witnessUtxo: { amount: 1n, script: new Uint8Array([0x51]) } });
465
+ * ```
466
+ */
467
+ export function getPrevOut(input: TArg<psbt.TransactionInput>): P.UnwrapCoder<typeof RawOutput> {
468
+ const _input = input as PSBTInputs;
469
+ if (_input.nonWitnessUtxo) {
470
+ if (_input.index === undefined) throw new Error('Unknown input index');
471
+ // BIP174 `PSBT_IN_NON_WITNESS_UTXO` is the full spent transaction, so the
472
+ // input outpoint index must name an existing output instead of leaking a
473
+ // synthetic `undefined` prevout into later signing / estimation callers.
474
+ if (
475
+ !Number.isSafeInteger(_input.index) ||
476
+ _input.index < 0 ||
477
+ _input.index >= _input.nonWitnessUtxo.outputs.length
478
+ )
479
+ throw new Error(`Wrong input index=${_input.index}`);
480
+ return _input.nonWitnessUtxo.outputs[_input.index];
481
+ } else if (_input.witnessUtxo) return _input.witnessUtxo;
286
482
  else throw new Error('Cannot find previous output info');
287
483
  }
288
484
 
485
+ /**
486
+ * Normalizes a transaction input update into canonical PSBT form.
487
+ * @param i - input update to normalize
488
+ * @param cur - existing input value to merge with
489
+ * @param allowedFields - fields that may still change on signed inputs
490
+ * @param disableScriptCheck - whether to skip redeem/witness script sanity checks
491
+ * @param allowUnknown - whether to keep unknown PSBT fields
492
+ * @returns Normalized PSBT input.
493
+ * @example
494
+ * Accept hex txids from callers in the same display-order form used by `Transaction.id`, then
495
+ * normalize them into the repo's internal `TransactionInput` shape.
496
+ * ```ts
497
+ * import { hex } from '@scure/base';
498
+ * import { normalizeInput } from '@scure/btc-signer/transaction.js';
499
+ * normalizeInput({
500
+ * txid: '0000000000000000000000000000000000000000000000000000000000000001',
501
+ * index: 0,
502
+ * witnessUtxo: { amount: 1n, script: new Uint8Array([0x51]) },
503
+ * });
504
+ * ```
505
+ */
289
506
  export function normalizeInput(
290
- i: psbt.TransactionInputUpdate,
291
- cur?: psbt.TransactionInput,
292
- allowedFields?: (keyof psbt.TransactionInput)[],
507
+ i: TArg<psbt.TransactionInputUpdate>,
508
+ cur?: TArg<PSBTInputs>,
509
+ allowedFields?: TArg<readonly (keyof PSBTInputs)[]>,
293
510
  disableScriptCheck = false,
294
511
  allowUnknown = false
295
- ): psbt.TransactionInput {
296
- let { nonWitnessUtxo, txid } = i;
512
+ ): TRet<PSBTInputs> {
513
+ const _i = i as psbt.TransactionInputUpdate;
514
+ const _cur = cur as PSBTInputs | undefined;
515
+ const _allowedFields = allowedFields as readonly (keyof PSBTInputs)[] | undefined;
516
+ let { nonWitnessUtxo, txid } = _i;
297
517
  // String support for common fields. We usually prefer Uint8Array to avoid errors
298
518
  // like hex looking string accidentally passed, however, in case of nonWitnessUtxo
299
519
  // it is better to expect string, since constructing this complex object will be
300
520
  // difficult for user
301
521
  if (typeof nonWitnessUtxo === 'string') nonWitnessUtxo = hex.decode(nonWitnessUtxo);
302
522
  if (isBytes(nonWitnessUtxo)) nonWitnessUtxo = RawTx.decode(nonWitnessUtxo);
303
- if (!('nonWitnessUtxo' in i) && nonWitnessUtxo === undefined)
304
- nonWitnessUtxo = cur?.nonWitnessUtxo;
523
+ if (!('nonWitnessUtxo' in _i) && nonWitnessUtxo === undefined)
524
+ nonWitnessUtxo = _cur?.nonWitnessUtxo;
305
525
  if (typeof txid === 'string') txid = hex.decode(txid);
306
526
  // TODO: if we have nonWitnessUtxo, we can extract txId from here
307
- if (txid === undefined) txid = cur?.txid;
308
- let res: PSBTInputs = { ...cur, ...i, nonWitnessUtxo, txid };
309
- if (!('nonWitnessUtxo' in i) && res.nonWitnessUtxo === undefined) delete res.nonWitnessUtxo;
527
+ if (txid === undefined) txid = _cur?.txid;
528
+ let res: PSBTInputs = { ..._cur, ..._i, nonWitnessUtxo, txid };
529
+ if (!('nonWitnessUtxo' in _i) && res.nonWitnessUtxo === undefined) delete res.nonWitnessUtxo;
310
530
  if (res.sequence === undefined) res.sequence = DEFAULT_SEQUENCE;
311
531
  if (res.tapMerkleRoot === null) delete res.tapMerkleRoot;
312
- res = psbt.mergeKeyMap(psbt.PSBTInput, res, cur, allowedFields, allowUnknown);
313
- psbt.PSBTInputCoder.encode(res); // Validates that everything is correct at this point
532
+ res = psbt.mergeKeyMap(psbt.PSBTInput, res, _cur, _allowedFields, allowUnknown) as PSBTInputs;
533
+ // Public PSBT coder surface is wrapped with TArg/TRet for TS compatibility; normalizeInput keeps
534
+ // the repo's historical raw internal shape and casts only at the validation boundary here.
535
+ psbt.PSBTInputCoder.encode(res as Parameters<typeof psbt.PSBTInputCoder.encode>[0]); // Validates that everything is correct at this point
314
536
 
315
537
  let prevOut;
316
538
  if (res.nonWitnessUtxo && res.index !== undefined)
@@ -318,18 +540,45 @@ export function normalizeInput(
318
540
  else if (res.witnessUtxo) prevOut = res.witnessUtxo;
319
541
  if (prevOut && !disableScriptCheck)
320
542
  checkScript(prevOut && prevOut.script, res.redeemScript, res.witnessScript);
321
- return res;
543
+ return res as TRet<PSBTInputs>;
322
544
  }
323
545
 
324
- export function getInputType(input: psbt.TransactionInput, allowLegacyWitnessUtxo = false) {
546
+ /**
547
+ * Determines how an input should be signed and finalized.
548
+ * Wrapper consistency is expected to be validated earlier by {@link normalizeInput}
549
+ * and {@link checkScript}; this helper classifies already-normalized inputs and is
550
+ * not a standalone redeemScript/witnessScript correctness gate for raw caller input.
551
+ * @param input - PSBT input to inspect
552
+ * @param allowLegacyWitnessUtxo - whether legacy inputs may rely on witness UTXO data only
553
+ * @returns Input classification including transaction type and sighash defaults.
554
+ * @throws If a documented runtime validation or state check fails. {@link Error}
555
+ * @example
556
+ * Detect how the signer should treat a SegWit input from its previous output script.
557
+ * ```ts
558
+ * import { hex } from '@scure/base';
559
+ * import { p2wpkh } from '@scure/btc-signer/payment.js';
560
+ * import { getInputType } from '@scure/btc-signer/transaction.js';
561
+ * import { pubECDSA, randomPrivateKeyBytes } from '@scure/btc-signer/utils.js';
562
+ * getInputType({
563
+ * witnessUtxo: {
564
+ * amount: 1n,
565
+ * script: p2wpkh(pubECDSA(randomPrivateKeyBytes())).script,
566
+ * },
567
+ * });
568
+ * ```
569
+ */
570
+ export function getInputType(input: TArg<psbt.TransactionInput>, allowLegacyWitnessUtxo = false) {
571
+ const _input = input as PSBTInputs;
325
572
  let txType = 'legacy';
326
- let defaultSighash = SignatureHash.ALL;
327
- const prevOut = getPrevOut(input);
573
+ let defaultSighash: number = SignatureHash.ALL;
574
+ const prevOut = getPrevOut(_input as TArg<psbt.TransactionInput>);
328
575
  const first = OutScript.decode(prevOut.script);
329
576
  let type = first.type;
330
577
  let cur = first;
331
578
  const stack = [first];
332
579
  if (first.type === 'tr') {
580
+ // Expected invariant: taproot inputs use PSBT_IN_TAP_* metadata only;
581
+ // legacy redeemScript/witnessScript fields belong to P2SH/P2WSH paths.
333
582
  defaultSighash = SignatureHash.DEFAULT;
334
583
  return {
335
584
  txType: 'taproot',
@@ -337,13 +586,13 @@ export function getInputType(input: psbt.TransactionInput, allowLegacyWitnessUtx
337
586
  last: first,
338
587
  lastScript: prevOut.script,
339
588
  defaultSighash,
340
- sighash: input.sighashType || defaultSighash,
589
+ sighash: _input.sighashType || defaultSighash,
341
590
  };
342
591
  } else {
343
592
  if (first.type === 'wpkh' || first.type === 'wsh') txType = 'segwit';
344
593
  if (first.type === 'sh') {
345
- if (!input.redeemScript) throw new Error('inputType: sh without redeemScript');
346
- let child = OutScript.decode(input.redeemScript);
594
+ if (!_input.redeemScript) throw new Error('inputType: sh without redeemScript');
595
+ let child = OutScript.decode(_input.redeemScript);
347
596
  if (child.type === 'wpkh' || child.type === 'wsh') txType = 'segwit';
348
597
  stack.push(child);
349
598
  cur = child;
@@ -351,8 +600,8 @@ export function getInputType(input: psbt.TransactionInput, allowLegacyWitnessUtx
351
600
  }
352
601
  // wsh can be inside sh
353
602
  if (cur.type === 'wsh') {
354
- if (!input.witnessScript) throw new Error('inputType: wsh without witnessScript');
355
- let child = OutScript.decode(input.witnessScript);
603
+ if (!_input.witnessScript) throw new Error('inputType: wsh without witnessScript');
604
+ let child = OutScript.decode(_input.witnessScript);
356
605
  if (child.type === 'wsh') txType = 'segwit';
357
606
  stack.push(child);
358
607
  cur = child;
@@ -368,9 +617,9 @@ export function getInputType(input: psbt.TransactionInput, allowLegacyWitnessUtx
368
617
  last,
369
618
  lastScript,
370
619
  defaultSighash,
371
- sighash: input.sighashType || defaultSighash,
620
+ sighash: _input.sighashType || defaultSighash,
372
621
  };
373
- if (txType === 'legacy' && !allowLegacyWitnessUtxo && !input.nonWitnessUtxo) {
622
+ if (txType === 'legacy' && !allowLegacyWitnessUtxo && !_input.nonWitnessUtxo) {
374
623
  throw new Error(
375
624
  `Transaction/sign: legacy input without nonWitnessUtxo, can result in attack that forces paying higher fees. Pass allowLegacyWitnessUtxo=true, if you sure`
376
625
  );
@@ -379,10 +628,31 @@ export function getInputType(input: psbt.TransactionInput, allowLegacyWitnessUtx
379
628
  }
380
629
  }
381
630
 
631
+ /**
632
+ * Mutable Bitcoin transaction and PSBT helper.
633
+ * @param opts - Transaction construction and PSBT serialization options. See {@link TxOpts}.
634
+ * @example
635
+ * Create a transaction, add one spend, and export it as PSBT.
636
+ * ```ts
637
+ * import { hex } from '@scure/base';
638
+ * import { p2wpkh } from '@scure/btc-signer/payment.js';
639
+ * import { Transaction } from '@scure/btc-signer/transaction.js';
640
+ * import { pubECDSA, randomPrivateKeyBytes } from '@scure/btc-signer/utils.js';
641
+ * const spend = p2wpkh(pubECDSA(randomPrivateKeyBytes()));
642
+ * const tx = new Transaction();
643
+ * tx.addInput({
644
+ * txid: hex.decode('0000000000000000000000000000000000000000000000000000000000000001'),
645
+ * index: 0,
646
+ * witnessUtxo: { amount: 2n, script: spend.script },
647
+ * });
648
+ * tx.addOutput({ script: spend.script, amount: 1n });
649
+ * tx.toPSBT();
650
+ * ```
651
+ */
382
652
  export class Transaction {
383
653
  private global: psbt.PSBTKeyMapKeys<typeof psbt.PSBTGlobal> = {};
384
- private inputs: psbt.TransactionInput[] = []; // use getInput()
385
- private outputs: psbt.TransactionOutput[] = []; // use getOutput()
654
+ private inputs: PSBTInputs[] = []; // use getInput()
655
+ private outputs: PSBTOutputs[] = []; // use getOutput()
386
656
  readonly opts: ReturnType<typeof validateOpts>;
387
657
  constructor(opts: TxOpts = {}) {
388
658
  const _opts = (this.opts = validateOpts(opts));
@@ -426,12 +696,13 @@ export class Transaction {
426
696
  const tx = new Transaction({ ...opts, version, lockTime, PSBTVersion });
427
697
  // We need slice here, because otherwise
428
698
  const inputCount = PSBTVersion === 0 ? unsigned?.inputs.length : parsed.global.inputCount;
429
- tx.inputs = parsed.inputs.slice(0, inputCount).map((i, j) =>
430
- validateInput({
431
- finalScriptSig: P.EMPTY,
432
- ...parsed.global.unsignedTx?.inputs[j],
433
- ...i,
434
- })
699
+ tx.inputs = parsed.inputs.slice(0, inputCount).map(
700
+ (i, j) =>
701
+ validateInput({
702
+ finalScriptSig: P.EMPTY,
703
+ ...parsed.global.unsignedTx?.inputs[j],
704
+ ...i,
705
+ }) as PSBTInputs
435
706
  );
436
707
  const outputCount = PSBTVersion === 0 ? unsigned?.outputs.length : parsed.global.outputCount;
437
708
  tx.outputs = parsed.outputs.slice(0, outputCount).map((i, j) => ({
@@ -442,7 +713,11 @@ export class Transaction {
442
713
  if (lockTime !== DEFAULT_LOCKTIME) tx.global.fallbackLocktime = lockTime;
443
714
  return tx;
444
715
  }
445
- toPSBT(PSBTVersion: number | undefined = this.opts.PSBTVersion): Uint8Array {
716
+ // Prefer `global.version` when present so cross-version combiners can serialize at the highest
717
+ // required PSBT version without mutating the frozen transaction options object.
718
+ toPSBT(
719
+ PSBTVersion: number | undefined = this.global.version || this.opts.PSBTVersion
720
+ ): Uint8Array {
446
721
  if (PSBTVersion !== 0 && PSBTVersion !== 2)
447
722
  throw new Error(`Wrong PSBT version=${PSBTVersion}`);
448
723
  // if (PSBTVersion === 0 && this.inputs.length === 0) {
@@ -451,7 +726,9 @@ export class Transaction {
451
726
  // );
452
727
  // }
453
728
  const inputs = this.inputs.map((i) =>
454
- validateInput(psbt.cleanPSBTFields(PSBTVersion, psbt.PSBTInput, i))
729
+ // For PSBTv0 the prevout txid/index live in global.unsignedTx rather than the input map, so
730
+ // validate the full transaction input before version filtering drops those fields.
731
+ psbt.cleanPSBTFields(PSBTVersion, psbt.PSBTInput, validateInput(i) as TArg<PSBTInputs>)
455
732
  );
456
733
  for (const inp of inputs) {
457
734
  // Don't serialize empty fields
@@ -472,16 +749,28 @@ export class Transaction {
472
749
  RawOldTx.encode({
473
750
  version: this.version,
474
751
  lockTime: this.lockTime,
475
- inputs: this.inputs.map(inputBeforeSign).map((i) => ({
476
- ...i,
477
- finalScriptSig: P.EMPTY,
478
- })),
479
- outputs: this.outputs.map(outputBeforeSign),
752
+ inputs: this.inputs
753
+ .map((i) => inputBeforeSign(i as TArg<psbt.TransactionInput>))
754
+ .map((i) => ({
755
+ ...i,
756
+ finalScriptSig: P.EMPTY,
757
+ })),
758
+ outputs: this.outputs.map((o) => outputBeforeSign(o as TArg<psbt.TransactionOutput>)),
480
759
  })
481
760
  );
482
761
  delete global.fallbackLocktime;
483
762
  delete global.txVersion;
763
+ // PSBTv0 carries the unsigned transaction as one blob, so the PSBTv2 framing fields must be
764
+ // removed here. Keeping `global.version` would make validation treat this rebuilt v0 map as
765
+ // PSBTv2 and reject the required `unsignedTx` field.
766
+ delete global.inputCount;
767
+ delete global.outputCount;
768
+ delete global.version;
484
769
  } else {
770
+ // Cross-version merges and v0->v2 re-exports can still carry the PSBTv0 unsignedTx blob in
771
+ // `this.global`, but PSBTv2 serializes the transaction through split global/input/output
772
+ // fields instead, so drop the stale v0-only field before PSBTv2 validation/encoding.
773
+ delete global.unsignedTx;
485
774
  global.version = PSBTVersion;
486
775
  global.txVersion = this.version;
487
776
  global.inputCount = this.inputs.length;
@@ -493,11 +782,10 @@ export class Transaction {
493
782
  if (!inputs.length) inputs.push({});
494
783
  if (!outputs.length) outputs.push({});
495
784
  }
496
- return (PSBTVersion === 0 ? psbt.RawPSBTV0 : psbt.RawPSBTV2).encode({
497
- global,
498
- inputs,
499
- outputs,
500
- });
785
+ const raw = { global, inputs, outputs };
786
+ return PSBTVersion === 0
787
+ ? psbt.RawPSBTV0.encode(raw as Parameters<typeof psbt.RawPSBTV0.encode>[0])
788
+ : psbt.RawPSBTV2.encode(raw as Parameters<typeof psbt.RawPSBTV2.encode>[0]);
501
789
  }
502
790
 
503
791
  // BIP370 lockTime (https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki#determining-lock-time)
@@ -608,8 +896,9 @@ export class Transaction {
608
896
  out += 4 * CompactSizeLen.encode(this.inputs.length).length;
609
897
  for (const i of this.inputs) {
610
898
  out += 160 + 4 * VarBytes.encode(i.finalScriptSig || P.EMPTY).length;
611
- if (this.hasWitnesses && i.finalScriptWitness)
612
- out += RawWitness.encode(i.finalScriptWitness).length;
899
+ // Once segwit serialization is active, every input contributes one witness vector, including
900
+ // legacy inputs whose empty vector still encodes as a single zero-item-count byte.
901
+ if (this.hasWitnesses) out += RawWitness.encode(i.finalScriptWitness || []).length;
613
902
  }
614
903
  return out;
615
904
  }
@@ -649,19 +938,29 @@ export class Transaction {
649
938
  }
650
939
  getInput(idx: number): psbt.TransactionInput {
651
940
  this.checkInputIdx(idx);
652
- return cloneDeep(this.inputs[idx]);
941
+ return cloneDeep(this.inputs[idx]) as psbt.TransactionInput;
653
942
  }
654
943
  get inputsLength(): number {
655
944
  return this.inputs.length;
656
945
  }
657
946
  // Modification
658
- addInput(input: psbt.TransactionInputUpdate, _ignoreSignStatus = false): number {
947
+ addInput(input: TArg<psbt.TransactionInputUpdate>, _ignoreSignStatus = false): number {
659
948
  if (!_ignoreSignStatus && !this.signStatus().addInput)
660
949
  throw new Error('Tx has signed inputs, cannot add new one');
661
- this.inputs.push(normalizeInput(input, undefined, undefined, this.opts.disableScriptCheck));
950
+ // normalizeInput preserves nested caller-owned byte arrays, so detach them here before the
951
+ // new input becomes transaction state and later caller mutation can rewrite it by aliasing.
952
+ this.inputs.push(
953
+ cloneDeep(
954
+ normalizeInput(input, undefined, undefined, this.opts.disableScriptCheck)
955
+ ) as PSBTInputs
956
+ );
662
957
  return this.inputs.length - 1;
663
958
  }
664
- updateInput(idx: number, input: psbt.TransactionInputUpdate, _ignoreSignStatus = false): void {
959
+ updateInput(
960
+ idx: number,
961
+ input: TArg<psbt.TransactionInputUpdate>,
962
+ _ignoreSignStatus = false
963
+ ): void {
665
964
  this.checkInputIdx(idx);
666
965
  let allowedFields = undefined;
667
966
  if (!_ignoreSignStatus) {
@@ -669,13 +968,17 @@ export class Transaction {
669
968
  if (!status.addInput || status.inputs.includes(idx))
670
969
  allowedFields = psbt.PSBTInputUnsignedKeys;
671
970
  }
672
- this.inputs[idx] = normalizeInput(
673
- input,
674
- this.inputs[idx],
675
- allowedFields,
676
- this.opts.disableScriptCheck,
677
- this.opts.allowUnknown
678
- );
971
+ // normalizeInput preserves nested caller-owned byte arrays, so detach the merged result here
972
+ // before the updated input becomes transaction state and later caller mutation can rewrite it.
973
+ this.inputs[idx] = cloneDeep(
974
+ normalizeInput(
975
+ input,
976
+ this.inputs[idx],
977
+ allowedFields,
978
+ this.opts.disableScriptCheck,
979
+ this.opts.allowUnknown
980
+ )
981
+ ) as PSBTInputs;
679
982
  }
680
983
  // Output stuff
681
984
  private checkOutputIdx(idx: number) {
@@ -684,22 +987,24 @@ export class Transaction {
684
987
  }
685
988
  getOutput(idx: number): psbt.TransactionOutput {
686
989
  this.checkOutputIdx(idx);
687
- return cloneDeep(this.outputs[idx]);
990
+ return cloneDeep(this.outputs[idx]) as psbt.TransactionOutput;
688
991
  }
689
992
  getOutputAddress(idx: number, network: u.BTC_NETWORK = NETWORK): string | undefined {
690
993
  const out = this.getOutput(idx);
691
994
  if (!out.script) return;
692
- return Address(network).encode(OutScript.decode(out.script));
995
+ return Address(network).encode(
996
+ OutScript.decode(out.script) as Parameters<ReturnType<typeof Address>['encode']>[0]
997
+ );
693
998
  }
694
999
 
695
1000
  get outputsLength(): number {
696
1001
  return this.outputs.length;
697
1002
  }
698
1003
  private normalizeOutput(
699
- o: psbt.TransactionOutputUpdate,
700
- cur?: psbt.TransactionOutput,
701
- allowedFields?: (keyof typeof psbt.PSBTOutput)[]
702
- ): psbt.TransactionOutput {
1004
+ o: TArg<psbt.TransactionOutputUpdate>,
1005
+ cur?: PSBTOutputs,
1006
+ allowedFields?: readonly (keyof typeof psbt.PSBTOutput)[]
1007
+ ): PSBTOutputs {
703
1008
  let { amount, script } = o;
704
1009
  if (amount === undefined) amount = cur?.amount;
705
1010
  if (typeof amount !== 'bigint')
@@ -708,10 +1013,10 @@ export class Transaction {
708
1013
  );
709
1014
  if (typeof script === 'string') script = hex.decode(script);
710
1015
  if (script === undefined) script = cur?.script;
711
- let res: psbt.PSBTKeyMapKeys<typeof psbt.PSBTOutput> = { ...cur, ...o, amount, script };
1016
+ let res: PSBTOutputs = { ...cur, ...(o as PSBTOutputs & { script?: string }), amount, script };
712
1017
  if (res.amount === undefined) delete res.amount;
713
1018
  res = psbt.mergeKeyMap(psbt.PSBTOutput, res, cur, allowedFields, this.opts.allowUnknown);
714
- psbt.PSBTOutputCoder.encode(res);
1019
+ psbt.PSBTOutputCoder.encode(res as Parameters<typeof psbt.PSBTOutputCoder.encode>[0]);
715
1020
  if (
716
1021
  res.script &&
717
1022
  !this.opts.allowUnknownOutputs &&
@@ -724,13 +1029,19 @@ export class Transaction {
724
1029
  if (!this.opts.disableScriptCheck) checkScript(res.script, res.redeemScript, res.witnessScript);
725
1030
  return res;
726
1031
  }
727
- addOutput(o: psbt.TransactionOutputUpdate, _ignoreSignStatus = false): number {
1032
+ addOutput(o: TArg<psbt.TransactionOutputUpdate>, _ignoreSignStatus = false): number {
728
1033
  if (!_ignoreSignStatus && !this.signStatus().addOutput)
729
1034
  throw new Error('Tx has signed outputs, cannot add new one');
730
- this.outputs.push(this.normalizeOutput(o));
1035
+ // normalizeOutput preserves nested caller-owned script bytes, so detach them here before the
1036
+ // new output becomes transaction state and later caller mutation can rewrite it by aliasing.
1037
+ this.outputs.push(cloneDeep(this.normalizeOutput(o)));
731
1038
  return this.outputs.length - 1;
732
1039
  }
733
- updateOutput(idx: number, output: psbt.TransactionOutputUpdate, _ignoreSignStatus = false): void {
1040
+ updateOutput(
1041
+ idx: number,
1042
+ output: TArg<psbt.TransactionOutputUpdate>,
1043
+ _ignoreSignStatus = false
1044
+ ): void {
734
1045
  this.checkOutputIdx(idx);
735
1046
  let allowedFields = undefined;
736
1047
  if (!_ignoreSignStatus) {
@@ -738,10 +1049,19 @@ export class Transaction {
738
1049
  if (!status.addOutput || status.outputs.includes(idx))
739
1050
  allowedFields = psbt.PSBTOutputUnsignedKeys;
740
1051
  }
741
- this.outputs[idx] = this.normalizeOutput(output, this.outputs[idx], allowedFields);
1052
+ // updateOutput replaces stored state with normalizeOutput(...) directly, so detach the result
1053
+ // before storing it or later caller mutation of `output.script` will rewrite transaction state.
1054
+ this.outputs[idx] = cloneDeep(this.normalizeOutput(output, this.outputs[idx], allowedFields));
742
1055
  }
743
1056
  addOutputAddress(address: string, amount: bigint, network: u.BTC_NETWORK = NETWORK): number {
744
- return this.addOutput({ script: OutScript.encode(Address(network).decode(address)), amount });
1057
+ return this.addOutput({
1058
+ // Address.decode() only returns recognized descriptors here, but its wrapped output type
1059
+ // still carries `undefined` for coder parity, so narrow before feeding OutScript.encode().
1060
+ script: OutScript.encode(
1061
+ Address(network).decode(address) as Parameters<typeof OutScript.encode>[0]
1062
+ ),
1063
+ amount,
1064
+ });
745
1065
  }
746
1066
  // Utils
747
1067
  get fee(): bigint {
@@ -765,9 +1085,7 @@ export class Transaction {
765
1085
  if (idx < 0 || !Number.isSafeInteger(idx)) throw new Error(`Invalid input idx=${idx}`);
766
1086
  if ((isSingle && idx >= this.outputs.length) || idx >= this.inputs.length)
767
1087
  return P.U256BE.encode(1n);
768
- prevOutScript = Script.encode(
769
- Script.decode(prevOutScript).filter((i) => i !== 'CODESEPARATOR')
770
- );
1088
+ prevOutScript = stripCodeSeparator(prevOutScript);
771
1089
  let inputs: TransactionInputRequired[] = this.inputs
772
1090
  .map(inputBeforeSign)
773
1091
  .map((input, inputIdx) => ({
@@ -784,7 +1102,10 @@ export class Transaction {
784
1102
  let outputs = this.outputs.map(outputBeforeSign);
785
1103
  if (isNone) outputs = [];
786
1104
  else if (isSingle) {
787
- outputs = outputs.slice(0, idx).fill(EMPTY_OUTPUT).concat([outputs[idx]]);
1105
+ outputs = outputs
1106
+ .slice(0, idx)
1107
+ .fill(EMPTY_OUTPUT as (typeof outputs)[number])
1108
+ .concat([outputs[idx]]);
788
1109
  }
789
1110
  const tmpTx = RawTx.encode({
790
1111
  lockTime: this.lockTime,
@@ -801,6 +1122,10 @@ export class Transaction {
801
1122
  hashType: number,
802
1123
  amount: bigint
803
1124
  ): Uint8Array {
1125
+ // BIP143 serializes txTo.vin[nIn].prevout and txTo.vin[nIn].nSequence, so reject an invalid
1126
+ // nIn explicitly instead of leaking a later undefined-input TypeError from inputs[idx].
1127
+ if (idx < 0 || !Number.isSafeInteger(idx) || idx >= this.inputs.length)
1128
+ throw new Error(`Invalid input idx=${idx}`);
804
1129
  const { isAny, isNone, isSingle } = unpackSighash(hashType);
805
1130
  let inputHash = EMPTY32;
806
1131
  let sequenceHash = EMPTY32;
@@ -843,6 +1168,11 @@ export class Transaction {
843
1168
  throw new Error(`Invalid amounts array=${amount}`);
844
1169
  if (!Array.isArray(prevOutScript) || this.inputs.length !== prevOutScript.length)
845
1170
  throw new Error(`Invalid prevOutScript array=${prevOutScript}`);
1171
+ // BIP341 SigMsg commits either to input_index or to the selected input's outpoint/amount/script/
1172
+ // sequence under ANYONECANPAY, so reject an invalid index explicitly instead of hashing a
1173
+ // nonexistent input or leaking a later integer-encoding RangeError for negative idx.
1174
+ if (idx < 0 || !Number.isSafeInteger(idx) || idx >= this.inputs.length)
1175
+ throw new Error(`Invalid input idx=${idx}`);
846
1176
  const out: Bytes[] = [
847
1177
  P.U8.encode(0),
848
1178
  P.U8.encode(hashType), // U8 sigHash
@@ -888,25 +1218,86 @@ export class Transaction {
888
1218
  signIdx(privateKey: Signer, idx: number, allowedSighash?: SigHash[], _auxRand?: Bytes): boolean {
889
1219
  this.checkInputIdx(idx);
890
1220
  const input = this.inputs[idx];
891
- const inputType = getInputType(input, this.opts.allowLegacyWitnessUtxo);
892
- // Handle BIP32 HDKey
1221
+ const inputType = getInputType(
1222
+ input as TArg<psbt.TransactionInput>,
1223
+ this.opts.allowLegacyWitnessUtxo
1224
+ );
1225
+ const canSign = (privateKey: Bytes): boolean => {
1226
+ if (inputType.txType === 'taproot') {
1227
+ const pubKey = u.pubSchnorr(privateKey);
1228
+ if (input.tapInternalKey && equalBytes(pubKey, input.tapInternalKey)) return true;
1229
+ if (!input.tapLeafScript) return false;
1230
+ for (const [_, leaf] of input.tapLeafScript) {
1231
+ for (const op of Script.decode(leaf.subarray(0, -1))) {
1232
+ if (isBytes(op) && equalBytes(op, pubKey)) return true;
1233
+ }
1234
+ }
1235
+ return false;
1236
+ }
1237
+ const pubKey = u.pubECDSA(privateKey);
1238
+ const pubKeyHash = u.hash160(pubKey);
1239
+ for (const op of Script.decode(inputType.lastScript)) {
1240
+ if (isBytes(op) && (equalBytes(op, pubKey) || equalBytes(op, pubKeyHash))) return true;
1241
+ }
1242
+ return false;
1243
+ };
1244
+ // Expected invariant: HD signing should use bip32Derivation for legacy/segwit inputs,
1245
+ // tapBip32Derivation for taproot inputs, and preserve caller sighash/auxRand constraints.
893
1246
  if (!isBytes(privateKey)) {
894
- if (!input.bip32Derivation || !input.bip32Derivation.length)
895
- throw new Error('bip32Derivation: empty');
896
- const signers = input.bip32Derivation
897
- .filter((i) => i[1].fingerprint == (privateKey as HDKey).fingerprint)
898
- .map(([pubKey, { path }]) => {
899
- let s = privateKey as HDKey;
900
- for (const i of path) s = s.deriveChild(i);
901
- if (!equalBytes(s.publicKey, pubKey)) throw new Error('bip32Derivation: wrong pubKey');
902
- if (!s.privateKey) throw new Error('bip32Derivation: no privateKey');
903
- return s;
904
- });
905
- if (!signers.length)
906
- throw new Error(`bip32Derivation: no items with fingerprint=${privateKey.fingerprint}`);
1247
+ const root = privateKey as HDKey;
1248
+ type DerRow = { pubKey: Bytes; fingerprint: number; path: readonly number[] };
1249
+ const deriveSigners = (
1250
+ label: string,
1251
+ rows: DerRow[] | undefined,
1252
+ pubKey: (signer: HDKey) => Bytes
1253
+ ): HDKey[] => {
1254
+ if (!rows || !rows.length) throw new Error(`${label}: empty`);
1255
+ const signers = rows
1256
+ .filter((row) => row.fingerprint == root.fingerprint)
1257
+ .map((row) => {
1258
+ let s = root;
1259
+ for (const i of row.path) s = s.deriveChild(i);
1260
+ if (!equalBytes(pubKey(s), row.pubKey)) throw new Error(`${label}: wrong pubKey`);
1261
+ if (!s.privateKey) throw new Error(`${label}: no privateKey`);
1262
+ return s;
1263
+ });
1264
+ if (!signers.length)
1265
+ throw new Error(`${label}: no items with fingerprint=${root.fingerprint}`);
1266
+ return signers;
1267
+ };
1268
+ const signers =
1269
+ inputType.txType === 'taproot'
1270
+ ? // BIP371 PSBT_IN_TAP_BIP32_DERIVATION stores x-only pubkeys plus `der`, so taproot HD
1271
+ // signing must derive against that map instead of legacy bip32Derivation.
1272
+ deriveSigners(
1273
+ 'tapBip32Derivation',
1274
+ input.tapBip32Derivation?.map(([pubKey, { der }]) => ({
1275
+ pubKey,
1276
+ fingerprint: der.fingerprint,
1277
+ path: der.path,
1278
+ })),
1279
+ (s) => s.publicKey.slice(1)
1280
+ )
1281
+ : deriveSigners(
1282
+ 'bip32Derivation',
1283
+ input.bip32Derivation?.map(([pubKey, der]) => ({
1284
+ pubKey,
1285
+ fingerprint: der.fingerprint,
1286
+ path: der.path,
1287
+ })),
1288
+ (s) => s.publicKey
1289
+ );
907
1290
  let signed = false;
908
- for (const s of signers) if (this.signIdx(s.privateKey, idx)) signed = true;
909
- return signed;
1291
+ for (const s of signers) {
1292
+ // PSBT may legitimately carry multiple same-fingerprint derivation entries (multisig or
1293
+ // taproot internal/script-path keys). Skip unrelated derived children instead of aborting
1294
+ // the whole HD signing attempt on the first non-applicable candidate.
1295
+ if (!canSign(s.privateKey)) continue;
1296
+ if (this.signIdx(s.privateKey, idx, allowedSighash, _auxRand)) signed = true;
1297
+ }
1298
+ if (signed) return true;
1299
+ if (inputType.txType === 'taproot') throw new Error('No taproot scripts signed');
1300
+ throw new Error(`Input script doesn't have pubKey: ${inputType.lastScript}`);
910
1301
  }
911
1302
  // Sighash checks
912
1303
  // Just for compat with bitcoinjs-lib, so users won't face unexpected behaviour.
@@ -1116,8 +1507,8 @@ export class Transaction {
1116
1507
  const finalized = c.finalizeTaproot(script, csEncoded, scriptSig);
1117
1508
  if (!finalized) continue;
1118
1509
  input.finalScriptWitness = finalized.concat(psbt.TaprootControlBlock.encode(cb));
1119
- input.finalScriptSig = P.EMPTY;
1120
- cleanFinalInput(input);
1510
+ delete input.finalScriptSig;
1511
+ cleanFinalInput(input as TArg<PSBTInputs>);
1121
1512
  return;
1122
1513
  }
1123
1514
  }
@@ -1131,8 +1522,9 @@ export class Transaction {
1131
1522
  }
1132
1523
  if (!input.finalScriptWitness) throw new Error('finalize/taproot: empty witness');
1133
1524
  } else throw new Error('finalize/taproot: unknown input');
1134
- input.finalScriptSig = P.EMPTY;
1135
- cleanFinalInput(input);
1525
+ // BIP174 Input Finalizer: if scriptSig is empty for an input, 0x07 remains unset.
1526
+ delete input.finalScriptSig;
1527
+ cleanFinalInput(input as TArg<PSBTInputs>);
1136
1528
  return;
1137
1529
  }
1138
1530
  if (!input.partialSig || !input.partialSig.length) throw new Error('Not enough partial sign');
@@ -1192,7 +1584,7 @@ export class Transaction {
1192
1584
  if (!finalScriptSig && !finalScriptWitness) throw new Error('Unknown error finalizing input');
1193
1585
  if (finalScriptSig) input.finalScriptSig = finalScriptSig;
1194
1586
  if (finalScriptWitness) input.finalScriptWitness = finalScriptWitness;
1195
- cleanFinalInput(input);
1587
+ cleanFinalInput(input as TArg<PSBTInputs>);
1196
1588
  }
1197
1589
  finalize(): void {
1198
1590
  for (let i = 0; i < this.inputs.length; i++) this.finalizeIdx(i);
@@ -1204,7 +1596,10 @@ export class Transaction {
1204
1596
  return this.toBytes(true, true);
1205
1597
  }
1206
1598
  combine(other: Transaction): this {
1207
- for (const k of ['PSBTVersion', 'version', 'lockTime'] as const) {
1599
+ // BIP174 combiners merge same-transaction PSBTs across versions and emit the highest required
1600
+ // version, so PSBTVersion mismatches are normalized below instead of treated as conflicts.
1601
+ const PSBTVersion = Math.max(this.opts.PSBTVersion || 0, other.opts.PSBTVersion || 0);
1602
+ for (const k of ['version', 'lockTime'] as const) {
1208
1603
  if (this.opts[k] !== other.opts[k]) {
1209
1604
  throw new Error(
1210
1605
  `Transaction/combine: different ${k} this=${this.opts[k]} other=${other.opts[k]}`
@@ -1218,11 +1613,9 @@ export class Transaction {
1218
1613
  );
1219
1614
  }
1220
1615
  }
1221
- const thisUnsigned = this.global.unsignedTx ? RawOldTx.encode(this.global.unsignedTx) : P.EMPTY;
1222
- const otherUnsigned = other.global.unsignedTx
1223
- ? RawOldTx.encode(other.global.unsignedTx)
1224
- : P.EMPTY;
1225
- if (!equalBytes(thisUnsigned, otherUnsigned))
1616
+ // Same-transaction checks must compare the normalized unsigned tx bytes here: PSBTv0 stores
1617
+ // `global.unsignedTx`, while PSBTv2 reconstructs the same transaction from split fields.
1618
+ if (!equalBytes(this.unsignedTx, other.unsignedTx))
1226
1619
  throw new Error(`Transaction/combine: different unsigned tx`);
1227
1620
  this.global = psbt.mergeKeyMap(
1228
1621
  psbt.PSBTGlobal,
@@ -1231,31 +1624,62 @@ export class Transaction {
1231
1624
  undefined,
1232
1625
  this.opts.allowUnknown
1233
1626
  );
1627
+ if (PSBTVersion) this.global.version = PSBTVersion;
1234
1628
  for (let i = 0; i < this.inputs.length; i++) this.updateInput(i, other.inputs[i], true);
1235
1629
  for (let i = 0; i < this.outputs.length; i++) this.updateOutput(i, other.outputs[i], true);
1236
1630
  return this;
1237
1631
  }
1238
1632
  clone(): Transaction {
1239
1633
  // deepClone probably faster, but this enforces that encoding is valid
1240
- return Transaction.fromPSBT(this.toPSBT(this.opts.PSBTVersion), this.opts);
1634
+ return Transaction.fromPSBT(this.toPSBT(), this.opts);
1241
1635
  }
1242
1636
  }
1243
1637
 
1244
- export function PSBTCombine(psbts: Bytes[]): Bytes {
1638
+ /**
1639
+ * Merges multiple PSBT blobs into one.
1640
+ * @param psbts - PSBT byte arrays to combine
1641
+ * @returns Combined PSBT bytes.
1642
+ * @throws If the PSBT list is empty or the partial transactions cannot be combined. {@link Error}
1643
+ * @example
1644
+ * Merge separate partially signed PSBTs that share the same unsigned transaction.
1645
+ * ```ts
1646
+ * import { PSBTCombine, Transaction } from '@scure/btc-signer/transaction.js';
1647
+ * const psbt = new Transaction().toPSBT();
1648
+ * PSBTCombine([psbt, psbt]);
1649
+ * ```
1650
+ */
1651
+ export function PSBTCombine(psbts: TArg<Bytes[]>): TRet<Bytes> {
1245
1652
  if (!psbts || !Array.isArray(psbts) || !psbts.length)
1246
1653
  throw new Error('PSBTCombine: wrong PSBT list');
1247
1654
  const tx = Transaction.fromPSBT(psbts[0]);
1248
1655
  for (let i = 1; i < psbts.length; i++) tx.combine(Transaction.fromPSBT(psbts[i]));
1249
- return tx.toPSBT();
1656
+ return tx.toPSBT() as TRet<Bytes>;
1250
1657
  }
1251
1658
 
1252
1659
  // Copy-pasted from bip32 derive, maybe do something like 'bip32.parsePath'?
1253
1660
  const HARDENED_OFFSET: number = 0x80000000;
1661
+ /**
1662
+ * Parses a BIP32 path string into child indices.
1663
+ * @param path - derivation path such as `m/0'/1`
1664
+ * @returns Array of encoded child indices.
1665
+ * @throws If the derivation path syntax or child indices are invalid. {@link Error}
1666
+ * @example
1667
+ * Parse a BIP32 derivation path into hardened and unhardened indices.
1668
+ * ```ts
1669
+ * bip32Path("m/0'/1");
1670
+ * ```
1671
+ */
1254
1672
  export function bip32Path(path: string): number[] {
1255
1673
  const out: number[] = [];
1674
+ // PSBT key-origin records only carry raw child indices, so this convenience
1675
+ // parser normalizes textual BIP32 roots into the same integer path array and
1676
+ // uses apostrophe suffixes for hardening.
1256
1677
  if (!/^[mM]'?/.test(path)) throw new Error('Path must start with "m" or "M"');
1257
1678
  if (/^[mM]'?$/.test(path)) return out;
1258
1679
  const parts = path.replace(/^[mM]'?\//, '').split('/');
1680
+ // BIP32 Serialization format `* 1 byte: depth`: extended keys cap depth at
1681
+ // 255, so deeper text paths cannot roundtrip.
1682
+ if (parts.length > 255) throw new Error('Path depth exceeds 255');
1259
1683
  for (const c of parts) {
1260
1684
  const m = /^(\d+)('?)$/.exec(c);
1261
1685
  if (!m || m.length !== 3) throw new Error(`Invalid child index: ${c}`);