Skip to content

Commit e045dc5

Browse files
feat(wasm-utxo): redesign PSBT signing API for clarity and performance
Problem: The previous signing API had several issues: 1. `signInput(index, key)` claimed to sign only one input but actually signed ALL inputs internally (for ECDSA), which was misleading 2. MuSig2 bulk signing was inefficient - it recreated SighashCache for each input, recomputing sha_prevouts, sha_amounts, sha_scriptpubkeys, sha_sequences, and sha_outputs redundantly 3. The API didn't clearly communicate performance characteristics Solution: Implement an honest, clean WASM/Rust API that explicitly communicates what each method does and its performance implications. New WASM API (backward compatible - old methods kept as deprecated): - is_musig2_input(index) - check if input is MuSig2 keypath - sign_all_wallet_inputs(xpriv) - bulk sign ECDSA/Schnorr script path - sign_wallet_input(index, xpriv) - single input with save/restore (documented as NOT faster than bulk) - sign_musig2_input(index, xpriv) - single MuSig2 input - sign_all_musig2_inputs(xpriv) - NEW: bulk MuSig2 with SighashCache reuse for 2x performance improvement - sign_replay_protection_inputs(ecpair) - bulk replay protection TypeScript changes: - Added isBIP32Arg() type guard to js/bip32.ts - Updated sign(key) to call both sign_all_wallet_inputs and sign_all_musig2_inputs for optimal bulk performance - Updated signInput() to route to appropriate method based on input type Rust changes: - Added sign_with_first_round_and_cache() to Musig2Context that accepts external SighashCache and prevouts for efficient batch MuSig2 signing - Added corresponding wrapper in BitGoPsbt Performance results (1000 inputs): - ECDSA bulk: ~1.7ms per input (unchanged) - MuSig2 bulk: ~4.5ms per input (was ~9.5ms - 2x improvement!) The MuSig2 improvement comes from reusing SighashCache which caches the shared BIP-341 sighash components across all inputs. Issue: BTC-2980
1 parent c437a83 commit e045dc5

8 files changed

Lines changed: 1273 additions & 158 deletions

File tree

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
}

0 commit comments

Comments
 (0)