Skip to content

Commit 2b9bc51

Browse files
Merge pull request #116 from BitGo/BTC-2980.fix-signing
feat(wasm-utxo): redesign PSBT signing API for clarity and performance
2 parents 2a46648 + e045dc5 commit 2b9bc51

11 files changed

Lines changed: 1573 additions & 113 deletions

File tree

packages/wasm-utxo/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ js/*.js
88
js/*.d.ts
99
js/wasm
1010
.vscode
11+
.cursor
12+
test/benchmark/results/

packages/wasm-utxo/.mocharc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"extensions": ["ts", "tsx", "js", "jsx"],
33
"spec": ["test/**/*.ts"],
4+
"ignore": ["test/benchmark/**"],
45
"node-option": ["import=tsx/esm", "experimental-wasm-modules"]
56
}

packages/wasm-utxo/js/bip32.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,21 @@ export class BIP32 implements BIP32Interface {
224224
return this._wasm;
225225
}
226226
}
227+
228+
/**
229+
* Type guard to check if a value is a BIP32Arg
230+
*
231+
* @param key - The value to check
232+
* @returns true if the value is a BIP32Arg (string, BIP32, WasmBIP32, or BIP32Interface)
233+
*/
234+
export function isBIP32Arg(key: unknown): key is BIP32Arg {
235+
return (
236+
typeof key === "string" ||
237+
key instanceof BIP32 ||
238+
key instanceof WasmBIP32 ||
239+
(typeof key === "object" &&
240+
key !== null &&
241+
"derive" in key &&
242+
typeof (key as BIP32Interface).derive === "function")
243+
);
244+
}

packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts

Lines changed: 106 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js";
22
import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js";
33
import { type ReplayProtectionArg, ReplayProtection } from "./ReplayProtection.js";
4-
import { type BIP32Arg, BIP32 } from "../bip32.js";
4+
import { type BIP32Arg, BIP32, isBIP32Arg } from "../bip32.js";
55
import { type ECPairArg, ECPair } from "../ecpair.js";
66
import type { UtxolibName } from "../utxolibCompat.js";
77
import type { CoinName } from "../coinName.js";
@@ -515,60 +515,126 @@ export class BitGoPsbt {
515515
}
516516

517517
/**
518-
* Sign a single input with a private key
518+
* Sign all matching inputs with a private key.
519+
*
520+
* This method signs all inputs that match the provided key in a single efficient pass.
521+
* It accepts either:
522+
* - An xpriv (BIP32Arg: base58 string, BIP32 instance, or WasmBIP32) for wallet inputs
523+
* - A raw privkey (ECPairArg: Buffer, ECPair instance, or WasmECPair) for replay protection inputs
524+
*
525+
* **Note:** MuSig2 inputs are skipped by this method when using xpriv because they require
526+
* FirstRound state. After calling this method, sign MuSig2 inputs individually using
527+
* `signInput()` after calling `generateMusig2Nonces()`.
528+
*
529+
* @param key - Either an xpriv (BIP32Arg) or a raw privkey (ECPairArg)
530+
* @returns Array of input indices that were signed
531+
* @throws Error if signing fails
532+
*
533+
* @example
534+
* ```typescript
535+
* // Sign all wallet inputs with user's xpriv
536+
* const signedIndices = psbt.sign(userXpriv);
537+
* console.log(`Signed inputs: ${signedIndices.join(", ")}`);
538+
*
539+
* // Sign all replay protection inputs with raw privkey
540+
* const rpSignedIndices = psbt.sign(replayProtectionPrivkey);
541+
* ```
542+
*/
543+
sign(key: BIP32Arg | ECPairArg): number[];
544+
545+
/**
546+
* Sign a single input with a private key.
547+
*
548+
* @deprecated Use `sign(key)` to sign all matching inputs (more efficient), or use
549+
* `signInput(inputIndex, key)` for explicit single-input signing.
550+
*
551+
* **Note:** This method is NOT more efficient than `sign(key)` for non-MuSig2 inputs.
552+
* The underlying miniscript library signs all inputs regardless. This overload exists
553+
* for backward compatibility only.
554+
*
555+
* @param inputIndex - The index of the input to sign (0-based)
556+
* @param key - Either an xpriv (BIP32Arg) or a raw privkey (ECPairArg)
557+
* @throws Error if signing fails, or if generateMusig2Nonces() was not called first for MuSig2 inputs
558+
*/
559+
sign(inputIndex: number, key: BIP32Arg | ECPairArg): void;
560+
561+
sign(
562+
inputIndexOrKey: number | BIP32Arg | ECPairArg,
563+
key?: BIP32Arg | ECPairArg,
564+
): number[] | void {
565+
// Detect which overload was called
566+
if (typeof inputIndexOrKey === "number") {
567+
// Called as sign(inputIndex, key) - deprecated single-input signing
568+
if (key === undefined) {
569+
throw new Error("Key is required when signing a single input");
570+
}
571+
this.signInput(inputIndexOrKey, key);
572+
return;
573+
}
574+
575+
// Called as sign(key) - sign all matching inputs
576+
const keyArg = inputIndexOrKey;
577+
578+
if (isBIP32Arg(keyArg)) {
579+
// It's a BIP32Arg - sign all wallet inputs (ECDSA + MuSig2)
580+
const wasmKey = BIP32.from(keyArg);
581+
// Sign all non-MuSig2 wallet inputs
582+
const walletSigned = this._wasm.sign_all_wallet_inputs(wasmKey.wasm) as number[];
583+
// Sign all MuSig2 keypath inputs (more efficient - reuses SighashCache)
584+
const musig2Signed = this._wasm.sign_all_musig2_inputs(wasmKey.wasm) as number[];
585+
return [...walletSigned, ...musig2Signed];
586+
} else {
587+
// It's an ECPairArg - sign all replay protection inputs
588+
const wasmKey = ECPair.from(keyArg as ECPairArg);
589+
return this._wasm.sign_replay_protection_inputs(wasmKey.wasm) as number[];
590+
}
591+
}
592+
593+
/**
594+
* Sign a single input with a private key.
519595
*
520596
* This method signs a specific input using the provided key. It accepts either:
521-
* - An xpriv (BIP32Arg: base58 string, BIP32 instance, or WasmBIP32) for wallet inputs - derives the key and signs
522-
* - A raw privkey (ECPairArg: Buffer, ECPair instance, or WasmECPair) for replay protection inputs - signs directly
597+
* - An xpriv (BIP32Arg: base58 string, BIP32 instance, or WasmBIP32) for wallet inputs
598+
* - A raw privkey (ECPairArg: Buffer, ECPair instance, or WasmECPair) for replay protection inputs
599+
*
600+
* **Important:** This method is NOT faster than `sign(key)` for non-MuSig2 inputs.
601+
* The underlying miniscript library signs all inputs regardless. This method uses a
602+
* save/restore pattern to ensure only the target input receives the signature.
523603
*
524-
* This method automatically detects and handles different input types:
525-
* - For regular inputs: uses standard PSBT signing
526-
* - For MuSig2 inputs: uses the FirstRound state stored by generateMusig2Nonces()
527-
* - For replay protection inputs: signs with legacy P2SH sighash
604+
* Use this method only when you need precise control over which inputs are signed,
605+
* for example:
606+
* - Signing MuSig2 inputs (after calling generateMusig2Nonces())
607+
* - Mixed transactions where different inputs need different keys
608+
* - Testing or debugging signing behavior
528609
*
529610
* @param inputIndex - The index of the input to sign (0-based)
530611
* @param key - Either an xpriv (BIP32Arg) or a raw privkey (ECPairArg)
531612
* @throws Error if signing fails, or if generateMusig2Nonces() was not called first for MuSig2 inputs
532613
*
533614
* @example
534615
* ```typescript
535-
* // Parse transaction to identify input types
536-
* const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, replayProtection);
537-
*
538-
* // Sign regular wallet inputs with xpriv
539-
* for (let i = 0; i < parsed.inputs.length; i++) {
540-
* const input = parsed.inputs[i];
541-
* if (input.scriptId !== null && input.scriptType !== "p2shP2pk") {
542-
* psbt.sign(i, userXpriv);
543-
* }
544-
* }
616+
* // Sign a specific MuSig2 input after nonce generation
617+
* psbt.generateMusig2Nonces(userXpriv);
618+
* psbt.signInput(musig2InputIndex, userXpriv);
545619
*
546-
* // Sign replay protection inputs with raw privkey
547-
* const userPrivkey = bip32.fromBase58(userXpriv).privateKey!;
548-
* for (let i = 0; i < parsed.inputs.length; i++) {
549-
* const input = parsed.inputs[i];
550-
* if (input.scriptType === "p2shP2pk") {
551-
* psbt.sign(i, userPrivkey);
552-
* }
553-
* }
620+
* // Sign a specific replay protection input
621+
* psbt.signInput(rpInputIndex, replayProtectionPrivkey);
554622
* ```
555623
*/
556-
sign(inputIndex: number, key: BIP32Arg | ECPairArg): void {
557-
// Detect key type
558-
// If string or has 'derive' method → BIP32Arg
559-
// Otherwise → ECPairArg
560-
if (
561-
typeof key === "string" ||
562-
(typeof key === "object" &&
563-
key !== null &&
564-
"derive" in key &&
565-
typeof key.derive === "function")
566-
) {
624+
signInput(inputIndex: number, key: BIP32Arg | ECPairArg): void {
625+
if (isBIP32Arg(key)) {
567626
// It's a BIP32Arg
568-
const wasmKey = BIP32.from(key as BIP32Arg);
569-
this._wasm.sign_with_xpriv(inputIndex, wasmKey.wasm);
627+
const wasmKey = BIP32.from(key);
628+
// Route to the appropriate method based on input type
629+
if (this._wasm.is_musig2_input(inputIndex)) {
630+
// MuSig2 keypath: true single-input signing (efficient)
631+
this._wasm.sign_musig2_input(inputIndex, wasmKey.wasm);
632+
} else {
633+
// ECDSA/Schnorr script path: save/restore pattern (not faster than bulk)
634+
this._wasm.sign_wallet_input(inputIndex, wasmKey.wasm);
635+
}
570636
} else {
571-
// It's an ECPairArg
637+
// It's an ECPairArg - for replay protection inputs
572638
const wasmKey = ECPair.from(key as ECPairArg);
573639
this._wasm.sign_with_privkey(inputIndex, wasmKey.wasm);
574640
}

packages/wasm-utxo/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"scripts": {
3636
"test": "npm run test:mocha && npm run test:wasm-pack && npm run test:imports",
3737
"test:mocha": "mocha --recursive test",
38+
"test:benchmark": "mocha test/benchmark/signing.ts --timeout 600000",
3839
"test:wasm-pack": "npm run test:wasm-pack-node && npm run test:wasm-pack-chrome",
3940
"test:wasm-pack-node": "./scripts/wasm-pack-test.sh --node",
4041
"test:wasm-pack-chrome": "./scripts/wasm-pack-test.sh --headless --chrome",

0 commit comments

Comments
 (0)