@scure/btc-signer 2.0.1 → 2.3.0

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