diff --git a/packages/wasm-utxo/.gitignore b/packages/wasm-utxo/.gitignore index f2d18aa3..22abc3c7 100644 --- a/packages/wasm-utxo/.gitignore +++ b/packages/wasm-utxo/.gitignore @@ -8,3 +8,5 @@ js/*.js js/*.d.ts js/wasm .vscode +.cursor +test/benchmark/results/ diff --git a/packages/wasm-utxo/.mocharc.json b/packages/wasm-utxo/.mocharc.json index f585fb0e..1a5ea0f9 100644 --- a/packages/wasm-utxo/.mocharc.json +++ b/packages/wasm-utxo/.mocharc.json @@ -1,5 +1,6 @@ { "extensions": ["ts", "tsx", "js", "jsx"], "spec": ["test/**/*.ts"], + "ignore": ["test/benchmark/**"], "node-option": ["import=tsx/esm", "experimental-wasm-modules"] } diff --git a/packages/wasm-utxo/js/bip32.ts b/packages/wasm-utxo/js/bip32.ts index 5950c0c3..42a3e682 100644 --- a/packages/wasm-utxo/js/bip32.ts +++ b/packages/wasm-utxo/js/bip32.ts @@ -224,3 +224,21 @@ export class BIP32 implements BIP32Interface { return this._wasm; } } + +/** + * Type guard to check if a value is a BIP32Arg + * + * @param key - The value to check + * @returns true if the value is a BIP32Arg (string, BIP32, WasmBIP32, or BIP32Interface) + */ +export function isBIP32Arg(key: unknown): key is BIP32Arg { + return ( + typeof key === "string" || + key instanceof BIP32 || + key instanceof WasmBIP32 || + (typeof key === "object" && + key !== null && + "derive" in key && + typeof (key as BIP32Interface).derive === "function") + ); +} diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 4f6e575c..d86c6698 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -1,7 +1,7 @@ import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js"; import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js"; import { type ReplayProtectionArg, ReplayProtection } from "./ReplayProtection.js"; -import { type BIP32Arg, BIP32 } from "../bip32.js"; +import { type BIP32Arg, BIP32, isBIP32Arg } from "../bip32.js"; import { type ECPairArg, ECPair } from "../ecpair.js"; import type { UtxolibName } from "../utxolibCompat.js"; import type { CoinName } from "../coinName.js"; @@ -515,16 +515,97 @@ export class BitGoPsbt { } /** - * Sign a single input with a private key + * Sign all matching inputs with a private key. + * + * This method signs all inputs that match the provided key in a single efficient pass. + * It accepts either: + * - An xpriv (BIP32Arg: base58 string, BIP32 instance, or WasmBIP32) for wallet inputs + * - A raw privkey (ECPairArg: Buffer, ECPair instance, or WasmECPair) for replay protection inputs + * + * **Note:** MuSig2 inputs are skipped by this method when using xpriv because they require + * FirstRound state. After calling this method, sign MuSig2 inputs individually using + * `signInput()` after calling `generateMusig2Nonces()`. + * + * @param key - Either an xpriv (BIP32Arg) or a raw privkey (ECPairArg) + * @returns Array of input indices that were signed + * @throws Error if signing fails + * + * @example + * ```typescript + * // Sign all wallet inputs with user's xpriv + * const signedIndices = psbt.sign(userXpriv); + * console.log(`Signed inputs: ${signedIndices.join(", ")}`); + * + * // Sign all replay protection inputs with raw privkey + * const rpSignedIndices = psbt.sign(replayProtectionPrivkey); + * ``` + */ + sign(key: BIP32Arg | ECPairArg): number[]; + + /** + * Sign a single input with a private key. + * + * @deprecated Use `sign(key)` to sign all matching inputs (more efficient), or use + * `signInput(inputIndex, key)` for explicit single-input signing. + * + * **Note:** This method is NOT more efficient than `sign(key)` for non-MuSig2 inputs. + * The underlying miniscript library signs all inputs regardless. This overload exists + * for backward compatibility only. + * + * @param inputIndex - The index of the input to sign (0-based) + * @param key - Either an xpriv (BIP32Arg) or a raw privkey (ECPairArg) + * @throws Error if signing fails, or if generateMusig2Nonces() was not called first for MuSig2 inputs + */ + sign(inputIndex: number, key: BIP32Arg | ECPairArg): void; + + sign( + inputIndexOrKey: number | BIP32Arg | ECPairArg, + key?: BIP32Arg | ECPairArg, + ): number[] | void { + // Detect which overload was called + if (typeof inputIndexOrKey === "number") { + // Called as sign(inputIndex, key) - deprecated single-input signing + if (key === undefined) { + throw new Error("Key is required when signing a single input"); + } + this.signInput(inputIndexOrKey, key); + return; + } + + // Called as sign(key) - sign all matching inputs + const keyArg = inputIndexOrKey; + + if (isBIP32Arg(keyArg)) { + // It's a BIP32Arg - sign all wallet inputs (ECDSA + MuSig2) + const wasmKey = BIP32.from(keyArg); + // Sign all non-MuSig2 wallet inputs + const walletSigned = this._wasm.sign_all_wallet_inputs(wasmKey.wasm) as number[]; + // Sign all MuSig2 keypath inputs (more efficient - reuses SighashCache) + const musig2Signed = this._wasm.sign_all_musig2_inputs(wasmKey.wasm) as number[]; + return [...walletSigned, ...musig2Signed]; + } else { + // It's an ECPairArg - sign all replay protection inputs + const wasmKey = ECPair.from(keyArg as ECPairArg); + return this._wasm.sign_replay_protection_inputs(wasmKey.wasm) as number[]; + } + } + + /** + * Sign a single input with a private key. * * This method signs a specific input using the provided key. It accepts either: - * - An xpriv (BIP32Arg: base58 string, BIP32 instance, or WasmBIP32) for wallet inputs - derives the key and signs - * - A raw privkey (ECPairArg: Buffer, ECPair instance, or WasmECPair) for replay protection inputs - signs directly + * - An xpriv (BIP32Arg: base58 string, BIP32 instance, or WasmBIP32) for wallet inputs + * - A raw privkey (ECPairArg: Buffer, ECPair instance, or WasmECPair) for replay protection inputs + * + * **Important:** This method is NOT faster than `sign(key)` for non-MuSig2 inputs. + * The underlying miniscript library signs all inputs regardless. This method uses a + * save/restore pattern to ensure only the target input receives the signature. * - * This method automatically detects and handles different input types: - * - For regular inputs: uses standard PSBT signing - * - For MuSig2 inputs: uses the FirstRound state stored by generateMusig2Nonces() - * - For replay protection inputs: signs with legacy P2SH sighash + * Use this method only when you need precise control over which inputs are signed, + * for example: + * - Signing MuSig2 inputs (after calling generateMusig2Nonces()) + * - Mixed transactions where different inputs need different keys + * - Testing or debugging signing behavior * * @param inputIndex - The index of the input to sign (0-based) * @param key - Either an xpriv (BIP32Arg) or a raw privkey (ECPairArg) @@ -532,43 +613,28 @@ export class BitGoPsbt { * * @example * ```typescript - * // Parse transaction to identify input types - * const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, replayProtection); - * - * // Sign regular wallet inputs with xpriv - * for (let i = 0; i < parsed.inputs.length; i++) { - * const input = parsed.inputs[i]; - * if (input.scriptId !== null && input.scriptType !== "p2shP2pk") { - * psbt.sign(i, userXpriv); - * } - * } + * // Sign a specific MuSig2 input after nonce generation + * psbt.generateMusig2Nonces(userXpriv); + * psbt.signInput(musig2InputIndex, userXpriv); * - * // Sign replay protection inputs with raw privkey - * const userPrivkey = bip32.fromBase58(userXpriv).privateKey!; - * for (let i = 0; i < parsed.inputs.length; i++) { - * const input = parsed.inputs[i]; - * if (input.scriptType === "p2shP2pk") { - * psbt.sign(i, userPrivkey); - * } - * } + * // Sign a specific replay protection input + * psbt.signInput(rpInputIndex, replayProtectionPrivkey); * ``` */ - sign(inputIndex: number, key: BIP32Arg | ECPairArg): void { - // Detect key type - // If string or has 'derive' method → BIP32Arg - // Otherwise → ECPairArg - if ( - typeof key === "string" || - (typeof key === "object" && - key !== null && - "derive" in key && - typeof key.derive === "function") - ) { + signInput(inputIndex: number, key: BIP32Arg | ECPairArg): void { + if (isBIP32Arg(key)) { // It's a BIP32Arg - const wasmKey = BIP32.from(key as BIP32Arg); - this._wasm.sign_with_xpriv(inputIndex, wasmKey.wasm); + const wasmKey = BIP32.from(key); + // Route to the appropriate method based on input type + if (this._wasm.is_musig2_input(inputIndex)) { + // MuSig2 keypath: true single-input signing (efficient) + this._wasm.sign_musig2_input(inputIndex, wasmKey.wasm); + } else { + // ECDSA/Schnorr script path: save/restore pattern (not faster than bulk) + this._wasm.sign_wallet_input(inputIndex, wasmKey.wasm); + } } else { - // It's an ECPairArg + // It's an ECPairArg - for replay protection inputs const wasmKey = ECPair.from(key as ECPairArg); this._wasm.sign_with_privkey(inputIndex, wasmKey.wasm); } diff --git a/packages/wasm-utxo/package.json b/packages/wasm-utxo/package.json index 26a63aa1..94b8b02b 100644 --- a/packages/wasm-utxo/package.json +++ b/packages/wasm-utxo/package.json @@ -35,6 +35,7 @@ "scripts": { "test": "npm run test:mocha && npm run test:wasm-pack && npm run test:imports", "test:mocha": "mocha --recursive test", + "test:benchmark": "mocha test/benchmark/signing.ts --timeout 600000", "test:wasm-pack": "npm run test:wasm-pack-node && npm run test:wasm-pack-chrome", "test:wasm-pack-node": "./scripts/wasm-pack-test.sh --node", "test:wasm-pack-chrome": "./scripts/wasm-pack-test.sh --headless --chrome", diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index e28b1384..086e7dc3 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -1457,6 +1457,34 @@ impl BitGoPsbt { .map_err(|e| e.to_string()) } + /// Sign a MuSig2 input using an externally-provided SighashCache (for efficiency). + /// + /// This method is the same as `sign_with_first_round` but accepts an external + /// SighashCache and prevouts to avoid recomputing sha_prevouts, sha_amounts, etc. + /// when signing multiple MuSig2 inputs in a single pass. + /// + /// # Arguments + /// * `input_index` - The index of the MuSig2 input + /// * `first_round` - The FirstRound from generate_nonce_first_round() + /// * `xpriv` - The user's extended private key + /// * `sighash_cache` - External SighashCache (reused across inputs) + /// * `prevouts` - External prevouts slice (reused across inputs) + /// + /// # Returns + /// Ok(()) if the signature was successfully created and added to the PSBT + pub fn sign_with_first_round_and_cache>( + &mut self, + input_index: usize, + first_round: musig2::FirstRound, + xpriv: &miniscript::bitcoin::bip32::Xpriv, + sighash_cache: &mut crate::bitcoin::sighash::SighashCache, + prevouts: &[crate::bitcoin::TxOut], + ) -> Result<(), String> { + let mut ctx = self.musig2_context(input_index)?; + ctx.sign_with_first_round_and_cache(first_round, xpriv, sighash_cache, prevouts) + .map_err(|e| e.to_string()) + } + /// Sign a single input with a raw private key /// /// This method signs a specific input using the provided private key. It automatically @@ -1726,6 +1754,136 @@ impl BitGoPsbt { } } + /// Sign all replay protection inputs with the provided private key. + /// + /// This iterates through all inputs looking for P2SH-P2PK (replay protection) inputs + /// that match the provided public key and signs them. + /// + /// # Arguments + /// - `privkey`: The private key to sign with + /// + /// # Returns + /// - `Ok(Vec)` with indices of inputs that were signed + /// - `Err(String)` if signing fails + pub fn sign_all_replay_protection_inputs( + &mut self, + privkey: &secp256k1::SecretKey, + ) -> Result, String> { + let secp = secp256k1::Secp256k1::new(); + + // Derive public key from private key + let public_key = miniscript::bitcoin::PublicKey::from_slice( + &secp256k1::PublicKey::from_secret_key(&secp, privkey).serialize(), + ) + .map_err(|e| format!("Failed to derive public key: {}", e))?; + + let mut signed_indices = Vec::new(); + let num_inputs = self.psbt().inputs.len(); + + for input_index in 0..num_inputs { + // Check if this is a replay protection input (P2SH-P2PK) matching our key + let should_sign = { + let psbt = self.psbt(); + if let Some(redeem_script) = &psbt.inputs[input_index].redeem_script { + if let Ok(redeem_pubkey) = + Self::extract_pubkey_from_p2pk_redeem_script(redeem_script) + { + // Only sign if the pubkey matches + public_key == redeem_pubkey + } else { + false + } + } else { + false + } + }; + + if should_sign { + // Use the existing sign_with_privkey which handles RP inputs specially + self.sign_with_privkey(input_index, privkey)?; + signed_indices.push(input_index); + } + } + + Ok(signed_indices) + } + + /// Sign a single input with a raw private key, using save/restore for regular inputs. + /// + /// For replay protection inputs (P2SH-P2PK), this uses direct signing which is + /// already single-input. For regular inputs, this clones the PSBT, signs all, + /// then copies only the target input's signatures back. + /// + /// **Important:** This is NOT faster than signing all inputs for regular (non-RP) inputs. + /// The underlying miniscript library signs all inputs regardless. This method + /// just prevents signatures from being added to other inputs. + /// + /// # Arguments + /// - `input_index`: The index of the input to sign + /// - `privkey`: The private key to sign with + /// + /// # Returns + /// - `Ok(())` if the input was signed + /// - `Err(String)` if signing fails + pub fn sign_single_input_with_privkey( + &mut self, + input_index: usize, + privkey: &secp256k1::SecretKey, + ) -> Result<(), String> { + let psbt = self.psbt(); + if input_index >= psbt.inputs.len() { + return Err(format!( + "Input index {} out of bounds (total inputs: {})", + input_index, + psbt.inputs.len() + )); + } + + // Check if this is a MuSig2 input + if p2tr_musig2_input::Musig2Input::is_musig2_input(&psbt.inputs[input_index]) { + return Err( + "MuSig2 inputs cannot be signed with raw privkey. Use sign_with_first_round instead." + .to_string(), + ); + } + + // Check if this is a replay protection input (P2SH-P2PK) + // RP signing is already truly single-input, so no save/restore needed + if let Some(redeem_script) = &psbt.inputs[input_index].redeem_script { + if Self::extract_pubkey_from_p2pk_redeem_script(redeem_script).is_ok() { + // This is a replay protection input - use direct signing + return self.sign_with_privkey(input_index, privkey); + } + } + + // For regular inputs, use save/restore pattern + let mut cloned = self.clone(); + + // Sign on the clone (this signs all matching inputs) + cloned.sign_with_privkey(input_index, privkey)?; + + // Copy only the target input's signatures from the clone to self + let cloned_input = &cloned.psbt().inputs[input_index]; + let target_input = &mut self.psbt_mut().inputs[input_index]; + + // Copy partial_sigs (ECDSA signatures) + for (pubkey, sig) in &cloned_input.partial_sigs { + target_input.partial_sigs.insert(*pubkey, *sig); + } + + // Copy tap_script_sigs (Taproot script path signatures) + for (key, sig) in &cloned_input.tap_script_sigs { + target_input.tap_script_sigs.insert(key.clone(), *sig); + } + + // Copy tap_key_sig (Taproot key path signature) + if cloned_input.tap_key_sig.is_some() { + target_input.tap_key_sig = cloned_input.tap_key_sig; + } + + Ok(()) + } + /// Sign the PSBT with the provided key. /// Wraps the underlying PSBT's sign method from miniscript::psbt::PsbtExt. /// @@ -1801,6 +1959,150 @@ impl BitGoPsbt { } } + /// Sign all non-MuSig2 inputs with the provided xpriv in a single pass. + /// + /// This is more efficient than calling `sign_with_privkey` for each input individually + /// because miniscript's `sign` method already signs all matching inputs at once. + /// + /// **Note:** MuSig2 inputs are skipped by this method because they require FirstRound + /// state from nonce generation. Use dedicated MuSig2 signing methods for those inputs. + /// + /// # Arguments + /// - `xpriv`: The extended private key to sign with + /// + /// # Returns + /// - `Ok(SigningKeysMap)` mapping input indices to the public keys that signed them + /// - `Err(String)` if signing fails + pub fn sign_all_with_xpriv( + &mut self, + xpriv: &miniscript::bitcoin::bip32::Xpriv, + ) -> Result { + let secp = secp256k1::Secp256k1::new(); + + // Sign all inputs - miniscript handles this efficiently + match self.sign(xpriv, &secp) { + Ok(signing_keys) => Ok(signing_keys), + Err((partial_success, errors)) => { + // Filter out errors for MuSig2 inputs (they're expected to fail) + // and errors for inputs that don't match the key + let real_errors: Vec<_> = errors + .iter() + .filter(|(input_index, error)| { + // Don't report errors for MuSig2 inputs + let is_musig2 = self + .psbt() + .inputs + .get(**input_index) + .map(|input| p2tr_musig2_input::Musig2Input::is_musig2_input(input)) + .unwrap_or(false); + + // Don't report "key not found" errors - those are normal for inputs + // that belong to a different key + let is_key_not_found = + matches!(error, miniscript::bitcoin::psbt::SignError::KeyNotFound); + + !is_musig2 && !is_key_not_found + }) + .collect(); + + if real_errors.is_empty() { + // All errors were expected (MuSig2 or key not found) + Ok(partial_success) + } else { + Err(format!( + "Failed to sign {} input(s): {:?}", + real_errors.len(), + real_errors + )) + } + } + } + } + + /// Sign a single input with the provided xpriv, using save/restore to avoid + /// signing other inputs. + /// + /// For MuSig2 inputs, this delegates to `sign_with_first_round` which is already + /// single-input. For ECDSA inputs, this clones the PSBT, signs all inputs on the + /// clone, then copies only the target input's signatures back. + /// + /// **Important:** This is NOT faster than `sign_all_with_xpriv` for ECDSA inputs. + /// The underlying miniscript library signs all inputs regardless. This method + /// just prevents signatures from being added to other inputs. + /// + /// # Arguments + /// - `input_index`: The index of the input to sign + /// - `xpriv`: The extended private key to sign with + /// + /// # Returns + /// - `Ok(())` if the input was signed + /// - `Err(String)` if signing fails + pub fn sign_single_input_with_xpriv( + &mut self, + input_index: usize, + xpriv: &miniscript::bitcoin::bip32::Xpriv, + ) -> Result<(), String> { + let psbt = self.psbt(); + if input_index >= psbt.inputs.len() { + return Err(format!( + "Input index {} out of bounds (total inputs: {})", + input_index, + psbt.inputs.len() + )); + } + + // Check if this is a MuSig2 input - those have true single-input signing + if p2tr_musig2_input::Musig2Input::is_musig2_input(&psbt.inputs[input_index]) { + // MuSig2 signing is handled separately via sign_with_first_round + return Err( + "MuSig2 inputs require FirstRound state. Use sign_with_first_round instead." + .to_string(), + ); + } + + // For ECDSA inputs, we need to use save/restore pattern + // Clone the PSBT, sign all, then copy only the target input's signatures + let mut cloned = self.clone(); + let secp = secp256k1::Secp256k1::new(); + + // Sign on the clone (this signs all matching inputs) + let result = cloned.sign(xpriv, &secp); + + // Check if the target input was signed + let was_signed = match &result { + Ok(signing_keys) => signing_keys.contains_key(&input_index), + Err((partial_success, _)) => partial_success.contains_key(&input_index), + }; + + if !was_signed { + return Err(format!( + "Input {} was not signed (key may not match derivation path)", + input_index + )); + } + + // Copy only the target input's signatures from the clone to self + let cloned_input = &cloned.psbt().inputs[input_index]; + let target_input = &mut self.psbt_mut().inputs[input_index]; + + // Copy partial_sigs (ECDSA signatures) + for (pubkey, sig) in &cloned_input.partial_sigs { + target_input.partial_sigs.insert(*pubkey, *sig); + } + + // Copy tap_script_sigs (Taproot script path signatures) + for (key, sig) in &cloned_input.tap_script_sigs { + target_input.tap_script_sigs.insert(key.clone(), *sig); + } + + // Copy tap_key_sig (Taproot key path signature) + if cloned_input.tap_key_sig.is_some() { + target_input.tap_key_sig = cloned_input.tap_key_sig; + } + + Ok(()) + } + /// Parse inputs with wallet keys and replay protection /// /// # Arguments diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/p2tr_musig2_input.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/p2tr_musig2_input.rs index 0be8b31d..a40051e6 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/p2tr_musig2_input.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/p2tr_musig2_input.rs @@ -734,12 +734,73 @@ impl<'a> Musig2Context<'a> { /// # Returns /// Ok(()) if the signature was successfully created and set pub fn sign_with_first_round( + &mut self, + first_round: musig2::FirstRound, + xpriv: &Xpriv, + ) -> Result<(), Musig2Error> { + use crate::bitcoin::sighash::{Prevouts, SighashCache, TapSighashType}; + + // Compute sighash message (needed for finalize) + let prevouts = collect_prevouts(self.psbt)?; + let mut sighash_cache = SighashCache::new(&self.psbt.unsigned_tx); + let sighash = sighash_cache + .taproot_key_spend_signature_hash( + self.input_index, + &Prevouts::All(&prevouts), + TapSighashType::Default, + ) + .map_err(|e| { + Musig2Error::SignatureAggregation(format!("Failed to compute sighash: {}", e)) + })?; + + self.sign_with_first_round_impl(first_round, xpriv, sighash.to_byte_array()) + } + + /// Sign a MuSig2 input using an externally-provided SighashCache (for efficiency). + /// + /// This method is the same as `sign_with_first_round` but accepts an external + /// SighashCache and prevouts to avoid recomputing sha_prevouts, sha_amounts, etc. + /// when signing multiple MuSig2 inputs. + /// + /// # Arguments + /// * `first_round` - The FirstRound from generate_nonce_first_round() + /// * `xpriv` - The signer's extended private key + /// * `sighash_cache` - External SighashCache (reused across inputs) + /// * `prevouts` - External prevouts slice (reused across inputs) + /// + /// # Returns + /// Ok(()) if the signature was successfully created and set + pub fn sign_with_first_round_and_cache>( + &mut self, + first_round: musig2::FirstRound, + xpriv: &Xpriv, + sighash_cache: &mut crate::bitcoin::sighash::SighashCache, + prevouts: &[crate::bitcoin::TxOut], + ) -> Result<(), Musig2Error> { + use crate::bitcoin::sighash::{Prevouts, TapSighashType}; + + // Compute sighash using the shared cache + let sighash = sighash_cache + .taproot_key_spend_signature_hash( + self.input_index, + &Prevouts::All(prevouts), + TapSighashType::Default, + ) + .map_err(|e| { + Musig2Error::SignatureAggregation(format!("Failed to compute sighash: {}", e)) + })?; + + self.sign_with_first_round_impl(first_round, xpriv, sighash.to_byte_array()) + } + + /// Internal implementation of MuSig2 signing given a pre-computed sighash message. + fn sign_with_first_round_impl( &mut self, mut first_round: musig2::FirstRound, xpriv: &Xpriv, + message: [u8; 32], ) -> Result<(), Musig2Error> { use crate::bitcoin::bip32::Xpub; - use crate::bitcoin::sighash::{Prevouts, SighashCache, TapSighashType}; // Derive the signer's key for this input let tap_key_origins = &self.psbt.inputs[self.input_index].tap_key_origins; @@ -754,7 +815,6 @@ impl<'a> Musig2Context<'a> { let signer_index = self.musig2_input.get_signer_index(&signer_pub_key)?; // Receive nonces from all other participants - // We need to map each nonce to its correct signer index for nonce_data in &self.musig2_input.nonces { let nonce_signer_index = self .musig2_input @@ -773,27 +833,13 @@ impl<'a> Musig2Context<'a> { } } - // Compute sighash message (needed for finalize) - let prevouts = collect_prevouts(self.psbt)?; - let mut sighash_cache = SighashCache::new(&self.psbt.unsigned_tx); - let sighash = sighash_cache - .taproot_key_spend_signature_hash( - self.input_index, - &Prevouts::All(&prevouts), - TapSighashType::Default, - ) - .map_err(|e| { - Musig2Error::SignatureAggregation(format!("Failed to compute sighash: {}", e)) - })?; - let message = sighash.to_byte_array(); - // Convert secret key to scalar let secret_scalar = musig2::secp::Scalar::try_from(&derived_xpriv.private_key.secret_bytes()[..]).map_err( |e| Musig2Error::SignatureAggregation(format!("Failed to parse secret key: {}", e)), )?; - // Finalize FirstRound with seckey and message → SecondRound (signature created during finalization) + // Finalize FirstRound with seckey and message → SecondRound let second_round = first_round.finalize(secret_scalar, message).map_err(|e| { Musig2Error::SignatureAggregation(format!("Failed to finalize FirstRound: {}", e)) })?; diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index 4effeb30..7bf196b4 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -1020,6 +1020,400 @@ impl BitGoPsbt { .map_err(|e| WasmUtxoError::new(&format!("Failed to sign input: {}", e))) } + /// Sign all non-MuSig2 inputs with an extended private key (xpriv) in a single pass. + /// + /// This is more efficient than calling `sign_with_xpriv` for each input individually. + /// The underlying miniscript library's `sign` method signs all matching inputs at once. + /// + /// **Note:** MuSig2 inputs are skipped by this method because they require FirstRound + /// state from nonce generation. After calling this method, sign MuSig2 inputs + /// individually using `sign_with_xpriv`. + /// + /// # Arguments + /// - `xpriv`: The extended private key as a WasmBIP32 instance + /// + /// # Returns + /// - `Ok(JsValue)` with an array of input indices that were signed + /// - `Err(WasmUtxoError)` if signing fails + pub fn sign_all_with_xpriv(&mut self, xpriv: &WasmBIP32) -> Result { + // Extract Xpriv from WasmBIP32 + let xpriv = xpriv.to_xpriv()?; + + // Call the Rust implementation + let signing_keys = self + .psbt + .sign_all_with_xpriv(&xpriv) + .map_err(|e| WasmUtxoError::new(&format!("Failed to sign: {}", e)))?; + + // Convert to JsValue - array of input indices that were signed + let result = js_sys::Array::new(); + for input_index in signing_keys.keys() { + result.push(&JsValue::from(*input_index as u32)); + } + + Ok(JsValue::from(result)) + } + + /// Sign all replay protection inputs with a raw private key. + /// + /// This iterates through all inputs looking for P2SH-P2PK (replay protection) inputs + /// that match the provided public key and signs them. + /// + /// # Arguments + /// - `ecpair`: The ECPair containing the private key + /// + /// # Returns + /// - `Ok(JsValue)` with an array of input indices that were signed + /// - `Err(WasmUtxoError)` if signing fails + pub fn sign_all_replay_protection_inputs( + &mut self, + ecpair: &WasmECPair, + ) -> Result { + // Extract private key from WasmECPair + let privkey = ecpair.get_private_key()?; + + // Call the Rust implementation + let signed_indices = self + .psbt + .sign_all_replay_protection_inputs(&privkey) + .map_err(|e| WasmUtxoError::new(&format!("Failed to sign: {}", e)))?; + + // Convert to JsValue array + let result = js_sys::Array::new(); + for index in signed_indices { + result.push(&JsValue::from(index as u32)); + } + + Ok(JsValue::from(result)) + } + + /// Sign a single input with an extended private key, using save/restore for ECDSA inputs. + /// + /// For MuSig2 inputs, this returns an error (use sign_with_xpriv which handles FirstRound). + /// For ECDSA inputs, this clones the PSBT, signs all, then copies only the target + /// input's signatures back. + /// + /// **Important:** This is NOT faster than `sign_all_with_xpriv` for ECDSA inputs. + /// The underlying miniscript library signs all inputs regardless. This method + /// just prevents signatures from being added to other inputs. + /// + /// # Arguments + /// - `input_index`: The index of the input to sign (0-based) + /// - `xpriv`: The extended private key as a WasmBIP32 instance + /// + /// # Returns + /// - `Ok(())` if the input was signed + /// - `Err(WasmUtxoError)` if signing fails + pub fn sign_single_input_with_xpriv( + &mut self, + input_index: usize, + xpriv: &WasmBIP32, + ) -> Result<(), WasmUtxoError> { + // Extract Xpriv from WasmBIP32 + let xpriv = xpriv.to_xpriv()?; + + // Call the Rust implementation + self.psbt + .sign_single_input_with_xpriv(input_index, &xpriv) + .map_err(|e| { + WasmUtxoError::new(&format!("Failed to sign input {}: {}", input_index, e)) + }) + } + + /// Sign a single input with a raw private key, using save/restore for regular inputs. + /// + /// For replay protection inputs (P2SH-P2PK), this uses direct signing which is + /// already single-input. For regular inputs, this clones the PSBT, signs all, + /// then copies only the target input's signatures back. + /// + /// **Important:** This is NOT faster than signing all inputs for regular (non-RP) inputs. + /// The underlying miniscript library signs all inputs regardless. + /// + /// # Arguments + /// - `input_index`: The index of the input to sign (0-based) + /// - `ecpair`: The ECPair containing the private key + /// + /// # Returns + /// - `Ok(())` if the input was signed + /// - `Err(WasmUtxoError)` if signing fails + pub fn sign_single_input_with_privkey( + &mut self, + input_index: usize, + ecpair: &WasmECPair, + ) -> Result<(), WasmUtxoError> { + // Extract private key from WasmECPair + let privkey = ecpair.get_private_key()?; + + // Call the Rust implementation + self.psbt + .sign_single_input_with_privkey(input_index, &privkey) + .map_err(|e| { + WasmUtxoError::new(&format!("Failed to sign input {}: {}", input_index, e)) + }) + } + + // ==================== NEW CLEAN SIGNING API ==================== + + /// Check if an input is a MuSig2 keypath input. + /// + /// MuSig2 inputs require special handling: nonces must be generated first with + /// `generate_musig2_nonces()`, then signed with `sign_musig2_input()`. + /// + /// # Arguments + /// - `input_index`: The index of the input to check (0-based) + /// + /// # Returns + /// - `true` if the input is a MuSig2 keypath input + /// - `false` otherwise (or if input_index is out of bounds) + pub fn is_musig2_input(&self, input_index: usize) -> bool { + let psbt = self.psbt.psbt(); + if input_index >= psbt.inputs.len() { + return false; + } + crate::fixed_script_wallet::bitgo_psbt::p2tr_musig2_input::Musig2Input::is_musig2_input( + &psbt.inputs[input_index], + ) + } + + /// Sign all non-MuSig2 wallet inputs in a single efficient pass. + /// + /// This signs all ECDSA (P2SH, P2SH-P2WSH, P2WSH) and Taproot script path (P2TR) + /// inputs that match the provided xpriv. MuSig2 keypath inputs are skipped. + /// + /// This is the most efficient way to sign wallet inputs. After calling this, + /// sign any MuSig2 inputs using `sign_all_musig2_inputs()` or `sign_musig2_input()`. + /// + /// # Arguments + /// - `xpriv`: The extended private key as a WasmBIP32 instance + /// + /// # Returns + /// - `Ok(JsValue)` with an array of input indices that were signed + /// - `Err(WasmUtxoError)` if signing fails + pub fn sign_all_wallet_inputs(&mut self, xpriv: &WasmBIP32) -> Result { + let xpriv = xpriv.to_xpriv()?; + + let signing_keys = self + .psbt + .sign_all_with_xpriv(&xpriv) + .map_err(|e| WasmUtxoError::new(&format!("Failed to sign: {}", e)))?; + + let result = js_sys::Array::new(); + for input_index in signing_keys.keys() { + result.push(&JsValue::from(*input_index as u32)); + } + + Ok(JsValue::from(result)) + } + + /// Sign a single non-MuSig2 wallet input using save/restore pattern. + /// + /// For MuSig2 inputs, returns an error (use `sign_musig2_input` instead). + /// For ECDSA inputs, this uses a save/restore pattern: clones the PSBT, + /// signs all inputs on the clone, then copies only the target input's + /// signatures back. + /// + /// **Important:** This is NOT faster than `sign_all_wallet_inputs()` for ECDSA inputs. + /// The underlying library signs all inputs regardless. This method just ensures + /// that only the specified input gets signatures added to the PSBT. + /// Use `sign_all_wallet_inputs()` when signing multiple inputs with the same key. + /// + /// # Arguments + /// - `input_index`: The index of the input to sign (0-based) + /// - `xpriv`: The extended private key as a WasmBIP32 instance + /// + /// # Returns + /// - `Ok(())` if the input was signed + /// - `Err(WasmUtxoError)` if signing fails or input is MuSig2 + pub fn sign_wallet_input( + &mut self, + input_index: usize, + xpriv: &WasmBIP32, + ) -> Result<(), WasmUtxoError> { + let xpriv = xpriv.to_xpriv()?; + + self.psbt + .sign_single_input_with_xpriv(input_index, &xpriv) + .map_err(|e| { + WasmUtxoError::new(&format!("Failed to sign input {}: {}", input_index, e)) + }) + } + + /// Sign a single MuSig2 keypath input. + /// + /// This uses the FirstRound state generated by `generate_musig2_nonces()`. + /// Each FirstRound can only be used once (nonce reuse is a security risk). + /// + /// For non-MuSig2 inputs, returns an error (use `sign_wallet_input` instead). + /// + /// # Arguments + /// - `input_index`: The index of the input to sign (0-based) + /// - `xpriv`: The extended private key as a WasmBIP32 instance + /// + /// # Returns + /// - `Ok(())` if signing was successful + /// - `Err(WasmUtxoError)` if signing fails, no FirstRound exists, or not a MuSig2 input + pub fn sign_musig2_input( + &mut self, + input_index: usize, + xpriv: &WasmBIP32, + ) -> Result<(), WasmUtxoError> { + let xpriv = xpriv.to_xpriv()?; + let secp = miniscript::bitcoin::secp256k1::Secp256k1::new(); + + let psbt = self.psbt.psbt(); + if input_index >= psbt.inputs.len() { + return Err(WasmUtxoError::new(&format!( + "Input index {} out of bounds (total inputs: {})", + input_index, + psbt.inputs.len() + ))); + } + + if !crate::fixed_script_wallet::bitgo_psbt::p2tr_musig2_input::Musig2Input::is_musig2_input( + &psbt.inputs[input_index], + ) { + return Err(WasmUtxoError::new(&format!( + "Input {} is not a MuSig2 input. Use sign_wallet_input instead.", + input_index + ))); + } + + let xpub = miniscript::bitcoin::bip32::Xpub::from_priv(&secp, &xpriv); + let xpub_str = xpub.to_string(); + + let first_round = self.first_rounds.remove(&(input_index, xpub_str.clone())) + .ok_or_else(|| WasmUtxoError::new(&format!( + "No FirstRound found for input {} and xpub {}. You must call generate_musig2_nonces() first.", + input_index, xpub_str + )))?; + + self.psbt + .sign_with_first_round(input_index, first_round, &xpriv) + .map_err(|e| { + WasmUtxoError::new(&format!( + "Failed to sign MuSig2 input {}: {}", + input_index, e + )) + })?; + + Ok(()) + } + + /// Sign all MuSig2 keypath inputs in a single pass with optimized sighash computation. + /// + /// This is more efficient than calling `sign_musig2_input()` for each input because + /// it reuses the SighashCache across all inputs, avoiding redundant computation of + /// sha_prevouts, sha_amounts, sha_scriptpubkeys, sha_sequences, and sha_outputs. + /// + /// Each MuSig2 input requires a FirstRound from `generate_musig2_nonces()`. + /// FirstRounds that have already been consumed (signed) are skipped. + /// + /// # Arguments + /// - `xpriv`: The extended private key as a WasmBIP32 instance + /// + /// # Returns + /// - `Ok(JsValue)` with an array of input indices that were signed + /// - `Err(WasmUtxoError)` if signing fails + pub fn sign_all_musig2_inputs(&mut self, xpriv: &WasmBIP32) -> Result { + let xpriv = xpriv.to_xpriv()?; + let secp = miniscript::bitcoin::secp256k1::Secp256k1::new(); + let xpub = miniscript::bitcoin::bip32::Xpub::from_priv(&secp, &xpriv); + let xpub_str = xpub.to_string(); + + // Find all MuSig2 inputs that have a FirstRound for this xpub + let psbt = self.psbt.psbt(); + let musig2_indices: Vec = (0..psbt.inputs.len()) + .filter(|&i| { + crate::fixed_script_wallet::bitgo_psbt::p2tr_musig2_input::Musig2Input::is_musig2_input( + &psbt.inputs[i], + ) && self.first_rounds.contains_key(&(i, xpub_str.clone())) + }) + .collect(); + + if musig2_indices.is_empty() { + return Ok(JsValue::from(js_sys::Array::new())); + } + + // Collect prevouts once (shared across all inputs) + let prevouts = crate::fixed_script_wallet::bitgo_psbt::p2tr_musig2_input::collect_prevouts( + self.psbt.psbt(), + ) + .map_err(|e| WasmUtxoError::new(&format!("Failed to collect prevouts: {}", e)))?; + + // Clone the unsigned transaction for the SighashCache + // (needed to avoid borrow conflicts with psbt mutation) + let unsigned_tx = self.psbt.psbt().unsigned_tx.clone(); + + // Create SighashCache once (caches sha_prevouts, sha_amounts, etc.) + let mut sighash_cache = miniscript::bitcoin::sighash::SighashCache::new(&unsigned_tx); + + let mut signed_indices = Vec::new(); + + for input_index in musig2_indices { + // Remove the FirstRound (it can only be used once) + let first_round = match self.first_rounds.remove(&(input_index, xpub_str.clone())) { + Some(fr) => fr, + None => continue, // Already consumed + }; + + // Sign with the shared sighash cache + match self.psbt.sign_with_first_round_and_cache( + input_index, + first_round, + &xpriv, + &mut sighash_cache, + &prevouts, + ) { + Ok(()) => signed_indices.push(input_index), + Err(e) => { + return Err(WasmUtxoError::new(&format!( + "Failed to sign MuSig2 input {}: {}", + input_index, e + ))); + } + } + } + + let result = js_sys::Array::new(); + for index in signed_indices { + result.push(&JsValue::from(index as u32)); + } + + Ok(JsValue::from(result)) + } + + /// Sign all replay protection inputs with a raw private key. + /// + /// This iterates through all inputs looking for P2SH-P2PK (replay protection) inputs + /// that match the provided public key and signs them. + /// + /// # Arguments + /// - `ecpair`: The ECPair containing the private key + /// + /// # Returns + /// - `Ok(JsValue)` with an array of input indices that were signed + /// - `Err(WasmUtxoError)` if signing fails + pub fn sign_replay_protection_inputs( + &mut self, + ecpair: &WasmECPair, + ) -> Result { + let privkey = ecpair.get_private_key()?; + + let signed_indices = self + .psbt + .sign_all_replay_protection_inputs(&privkey) + .map_err(|e| WasmUtxoError::new(&format!("Failed to sign: {}", e)))?; + + let result = js_sys::Array::new(); + for index in signed_indices { + result.push(&JsValue::from(index as u32)); + } + + Ok(JsValue::from(result)) + } + + // ==================== END NEW API ==================== + /// Combine/merge data from another PSBT into this one /// /// This method copies MuSig2 nonces and signatures (proprietary key-value pairs) from the diff --git a/packages/wasm-utxo/test/benchmark/signing.ts b/packages/wasm-utxo/test/benchmark/signing.ts new file mode 100644 index 00000000..18d23087 --- /dev/null +++ b/packages/wasm-utxo/test/benchmark/signing.ts @@ -0,0 +1,333 @@ +/** + * Signing Performance Benchmark + * + * Benchmarks PSBT signing performance for different script types and input counts. + * Tests both bulk signing (sign(key)) and per-input signing (signInput(i, key)). + * + * Script types tested: + * - p2sh (chain 0/1) + * - p2shP2wsh (chain 10/11) + * - p2wsh (chain 20/21) + * - p2tr script path (chain 30/31) - "p2trLegacy" + * - p2trMusig2 keypath (chain 40/41) + * + * Run: npx mocha test/benchmark/signing.ts --timeout 300000 + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { BIP32 } from "../../js/bip32.js"; +import { BitGoPsbt, RootWalletKeys, type NetworkName } from "../../js/fixedScriptWallet/index.js"; +import type { IWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; +import type { BIP32Interface } from "../../js/bip32.js"; +import type { SignPath } from "../../js/fixedScriptWallet/BitGoPsbt.js"; + +type Triple = [T, T, T]; + +// Script type configuration +type ScriptTypeConfig = { + name: string; + chain: number; // external chain + needsSignPath: boolean; + signPath?: SignPath; + isMuSig2KeyPath?: boolean; +}; + +const SCRIPT_TYPES: ScriptTypeConfig[] = [ + { name: "p2sh", chain: 0, needsSignPath: false }, + { name: "p2shP2wsh", chain: 10, needsSignPath: false }, + { name: "p2wsh", chain: 20, needsSignPath: false }, + { + name: "p2trLegacy", + chain: 30, + needsSignPath: true, + signPath: { signer: "user", cosigner: "bitgo" }, + }, + { + name: "p2trMusig2KeyPath", + chain: 40, + needsSignPath: true, + signPath: { signer: "user", cosigner: "bitgo" }, + isMuSig2KeyPath: true, + }, +]; + +const INPUT_COUNTS = [10, 200, 500, 1000]; + +function createTestWalletKeys(): { keys: RootWalletKeys; xprivs: Triple } { + // Create three deterministic keys from seeds + const seeds = [ + Buffer.alloc(32, 0x01), // user + Buffer.alloc(32, 0x02), // backup + Buffer.alloc(32, 0x03), // bitgo + ]; + + const xprivs = seeds.map((seed) => BIP32.fromSeed(seed)) as Triple; + const xpubs = xprivs.map((k) => k.neutered()) as unknown as Triple; + + const walletKeysLike: IWalletKeys = { + triple: xpubs, + derivationPrefixes: ["0/0", "0/0", "0/0"], + }; + + return { + keys: RootWalletKeys.from(walletKeysLike), + xprivs, + }; +} + +function createPsbtWithInputs( + inputCount: number, + scriptType: ScriptTypeConfig, + walletKeys: RootWalletKeys, +): BitGoPsbt { + const network: NetworkName = "bitcoin"; + + const psbt = BitGoPsbt.createEmpty(network, walletKeys, { + version: 2, + lockTime: 0, + }); + + // Add inputs + for (let i = 0; i < inputCount; i++) { + // Create a unique txid for each input (32 bytes hex = 64 chars) + const txidBytes = Buffer.alloc(32); + txidBytes.writeUInt32BE(i, 0); + const txid = txidBytes.toString("hex"); + + const inputOptions = { + txid, + vout: 0, + value: BigInt(100000), // 0.001 BTC per input + sequence: 0xfffffffe, + }; + + const walletOptions: { + scriptId: { chain: number; index: number }; + signPath?: SignPath; + } = { + scriptId: { chain: scriptType.chain, index: i }, + }; + + if (scriptType.needsSignPath && scriptType.signPath) { + walletOptions.signPath = scriptType.signPath; + } + + psbt.addWalletInput(inputOptions, walletKeys, walletOptions); + } + + // Add a single output (change) + const changeChain = scriptType.chain + 1; // internal chain + psbt.addWalletOutput(walletKeys, { + chain: changeChain, + index: 0, + value: BigInt(inputCount * 100000 - 10000), // total minus fee + }); + + return psbt; +} + +type BenchmarkResult = { + scriptType: string; + inputCount: number; + bulkSignMs: number; + perInputSignMs: number | null; // null if skipped (only run for 10 inputs) + bulkSignPerInputMs: number; + perInputSignPerInputMs: number | null; +}; + +function benchmarkBulkSign( + psbt: BitGoPsbt, + _walletKeys: RootWalletKeys, + xprivs: Triple, + scriptType: ScriptTypeConfig, +): number { + // Clone PSBT for this benchmark + const testPsbt = BitGoPsbt.fromBytes(psbt.serialize(), "bitcoin"); + + // For MuSig2, generate nonces first (not timed) + if (scriptType.isMuSig2KeyPath) { + testPsbt.generateMusig2Nonces(xprivs[0]); // user + testPsbt.generateMusig2Nonces(xprivs[2]); // bitgo + } + + const start = performance.now(); + + // Sign with user key - the new API handles both ECDSA and MuSig2 in one call + testPsbt.sign(xprivs[0]); + + // Sign with bitgo key (second signer for 2-of-3) + testPsbt.sign(xprivs[2]); + + const end = performance.now(); + return end - start; +} + +function benchmarkPerInputSign( + psbt: BitGoPsbt, + walletKeys: RootWalletKeys, + xprivs: Triple, + scriptType: ScriptTypeConfig, +): number { + // Clone PSBT for this benchmark + const testPsbt = BitGoPsbt.fromBytes(psbt.serialize(), "bitcoin"); + + const parsed = testPsbt.parseTransactionWithWalletKeys(walletKeys, { publicKeys: [] }); + + // For MuSig2, generate nonces first (not timed) + if (scriptType.isMuSig2KeyPath) { + testPsbt.generateMusig2Nonces(xprivs[0]); // user + testPsbt.generateMusig2Nonces(xprivs[2]); // bitgo + } + + const start = performance.now(); + + // Sign each input individually with user key + for (let i = 0; i < parsed.inputs.length; i++) { + testPsbt.signInput(i, xprivs[0]); + } + + // Sign each input individually with bitgo key + for (let i = 0; i < parsed.inputs.length; i++) { + testPsbt.signInput(i, xprivs[2]); + } + + const end = performance.now(); + return end - start; +} + +function runBenchmark( + scriptType: ScriptTypeConfig, + inputCount: number, + walletKeys: RootWalletKeys, + xprivs: Triple, +): BenchmarkResult { + // Create PSBT + const psbt = createPsbtWithInputs(inputCount, scriptType, walletKeys); + + // Run bulk sign benchmark + const bulkSignMs = benchmarkBulkSign(psbt, walletKeys, xprivs, scriptType); + + // Run per-input sign benchmark only for 10 inputs (too slow for larger counts) + const perInputSignMs = + inputCount === 10 ? benchmarkPerInputSign(psbt, walletKeys, xprivs, scriptType) : null; + + return { + scriptType: scriptType.name, + inputCount, + bulkSignMs, + perInputSignMs, + bulkSignPerInputMs: bulkSignMs / inputCount, + perInputSignPerInputMs: perInputSignMs !== null ? perInputSignMs / inputCount : null, + }; +} + +function formatResults(results: BenchmarkResult[]): string { + const lines: string[] = []; + + lines.push("=".repeat(100)); + lines.push("SIGNING BENCHMARK RESULTS"); + lines.push("=".repeat(100)); + lines.push(""); + + // Group by script type + const byScriptType = new Map(); + for (const r of results) { + const existing = byScriptType.get(r.scriptType) ?? []; + existing.push(r); + byScriptType.set(r.scriptType, existing); + } + + for (const [scriptType, scriptResults] of byScriptType) { + lines.push(`\n${scriptType.toUpperCase()}`); + lines.push("-".repeat(100)); + lines.push( + "| Inputs | Bulk (ms) | Per-Input (ms) | Bulk/Input (ms) | PerInput/Input (ms) | Ratio |", + ); + lines.push( + "|--------|-----------|----------------|-----------------|---------------------|-------|", + ); + + for (const r of scriptResults) { + const perInputStr = + r.perInputSignMs !== null ? r.perInputSignMs.toFixed(1).padStart(14) : "-".padStart(14); + const perInputPerInputStr = + r.perInputSignPerInputMs !== null + ? r.perInputSignPerInputMs.toFixed(3).padStart(19) + : "-".padStart(19); + const ratioStr = + r.perInputSignMs !== null + ? (r.perInputSignMs / r.bulkSignMs).toFixed(2).padStart(5) + "x" + : "-".padStart(6); + lines.push( + `| ${r.inputCount.toString().padStart(6)} | ${r.bulkSignMs.toFixed(1).padStart(9)} | ${perInputStr} | ${r.bulkSignPerInputMs.toFixed(3).padStart(15)} | ${perInputPerInputStr} | ${ratioStr} |`, + ); + } + } + + lines.push(""); + lines.push("=".repeat(100)); + + // JSON output for easy comparison + lines.push("\nJSON Results (for automated comparison):"); + lines.push(JSON.stringify(results, null, 2)); + + return lines.join("\n"); +} + +describe("Signing Benchmark", function () { + // Increase timeout for benchmarks + this.timeout(300000); // 5 minutes + + let walletKeys: RootWalletKeys; + let xprivs: Triple; + const allResults: BenchmarkResult[] = []; + + before(function () { + const testKeys = createTestWalletKeys(); + walletKeys = testKeys.keys; + xprivs = testKeys.xprivs; + }); + + for (const scriptType of SCRIPT_TYPES) { + describe(`${scriptType.name}`, function () { + for (const inputCount of INPUT_COUNTS) { + it(`should benchmark ${inputCount} inputs`, function () { + console.log(`\nBenchmarking ${scriptType.name} with ${inputCount} inputs...`); + + const result = runBenchmark(scriptType, inputCount, walletKeys, xprivs); + allResults.push(result); + + console.log(` Bulk sign: ${result.bulkSignMs.toFixed(1)}ms`); + if (result.perInputSignMs !== null) { + console.log(` Per-input sign: ${result.perInputSignMs.toFixed(1)}ms`); + console.log( + ` Ratio (per-input/bulk): ${(result.perInputSignMs / result.bulkSignMs).toFixed(2)}x`, + ); + } else { + console.log(` Per-input sign: skipped (only run for 10 inputs)`); + } + }); + } + }); + } + + after(function () { + const output = formatResults(allResults); + console.log("\n" + output); + + // Write results to file + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const resultsDir = path.join(__dirname, "results"); + if (!fs.existsSync(resultsDir)) { + fs.mkdirSync(resultsDir, { recursive: true }); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const resultsFile = path.join(resultsDir, `benchmark-${timestamp}.txt`); + fs.writeFileSync(resultsFile, output); + console.log(`\nResults written to: ${resultsFile}`); + }); +}); diff --git a/packages/wasm-utxo/test/fixedScript/signAndVerifySignature.ts b/packages/wasm-utxo/test/fixedScript/signAndVerifySignature.ts index cd95a0a4..8a664b60 100644 --- a/packages/wasm-utxo/test/fixedScript/signAndVerifySignature.ts +++ b/packages/wasm-utxo/test/fixedScript/signAndVerifySignature.ts @@ -178,76 +178,39 @@ function verifyAllInputSignatures( }); } -function signInputAndVerify( - bitgoPsbt: BitGoPsbt, - index: number, - key: BIP32 | ECPair, - keyName: string, - inputType: string, -): void { - bitgoPsbt.sign(index, key); - assert.strictEqual( - bitgoPsbt.verifySignature(index, key), - true, - `Input ${index} signature mismatch key=${keyName} type=${inputType}`, - ); -} - /** * Sign all inputs in a PSBT according to the signature stage + * + * This uses the new API: + * - sign(key) to sign all wallet inputs at once (ECDSA + MuSig2) + * + * Note: sign(key) only returns indices of inputs that were newly signed. Inputs + * that already have a signature with the same key are skipped but still valid. + * * @param bitgoPsbt - The PSBT to sign - * @param rootWalletKeys - Wallet keys for parsing the transaction * @param xprivs - The xprivs to use for signing * @param replayProtectionKey - The ECPair for signing replay protection (p2shP2pk) inputs */ function signAllInputs( bitgoPsbt: BitGoPsbt, - rootWalletKeys: RootWalletKeys, xprivs: RootWalletXprivs, replayProtectionKey: ECPair, ): void { - // Parse transaction to get input types - const parsed = bitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { - publicKeys: [replayProtectionKey], - }); - - // Generate MuSig2 nonces for user and backup keys (MuSig2 uses 2-of-2 with user+backup) + // Generate MuSig2 nonces for user and bitgo keys + // Note: We only generate for user + bitgo because the fixture uses keypath signing (user+bitgo) + // Script path inputs (user+backup) would need separate nonce generation bitgoPsbt.generateMusig2Nonces(xprivs.user); bitgoPsbt.generateMusig2Nonces(xprivs.bitgo); - // First pass: sign with user key (skip p2shP2pk inputs) - parsed.inputs.forEach((input, index) => { - switch (input.scriptType) { - case "p2shP2pk": - break; - default: - signInputAndVerify(bitgoPsbt, index, xprivs.user, "user", input.scriptType); - break; - } - }); + // Sign all wallet inputs (ECDSA + MuSig2) with user key + // The new API handles both ECDSA and MuSig2 in one call + bitgoPsbt.sign(xprivs.user); - // Second pass: sign with appropriate second key - parsed.inputs.forEach((input, index) => { - switch (input.scriptType) { - case "p2shP2pk": - signInputAndVerify( - bitgoPsbt, - index, - replayProtectionKey, - "replayProtection", - input.scriptType, - ); - break; - case "p2trMusig2ScriptPath": - // MuSig2 script path inputs use backup key for second signature - signInputAndVerify(bitgoPsbt, index, xprivs.backup, "backup", input.scriptType); - break; - default: - // Regular multisig uses bitgo key - signInputAndVerify(bitgoPsbt, index, xprivs.bitgo, "bitgo", input.scriptType); - break; - } - }); + // Sign all wallet inputs with bitgo key (ECDSA + MuSig2 keypath) + bitgoPsbt.sign(xprivs.bitgo); + + // Sign all replay protection inputs with raw privkey + bitgoPsbt.sign(replayProtectionKey); } /** @@ -275,7 +238,7 @@ function runTestsForFixture( // Sign inputs (if not already fully signed) if (signatureStage !== "unsigned") { - signAllInputs(bitgoPsbt, rootWalletKeys, xprivs, replayProtectionKey); + signAllInputs(bitgoPsbt, xprivs, replayProtectionKey); } } diff --git a/packages/wasm-utxo/test/fixedScript/singleInputSigning.ts b/packages/wasm-utxo/test/fixedScript/singleInputSigning.ts new file mode 100644 index 00000000..e13d110f --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/singleInputSigning.ts @@ -0,0 +1,334 @@ +/** + * Tests for single-input signing behavior + * + * Verifies that signInput() truly only signs the specified input and + * does not add signatures to other inputs. + */ + +import assert from "node:assert"; +import { BIP32 } from "../../js/bip32.js"; +import { BitGoPsbt, RootWalletKeys } from "../../js/fixedScriptWallet/index.js"; +import type { BIP32Interface } from "../../js/bip32.js"; +import type { IWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; +import type { NetworkName } from "../../js/fixedScriptWallet/BitGoPsbt.js"; + +type Triple = [T, T, T]; + +function createTestWalletKeys(): { keys: RootWalletKeys; xprivs: Triple } { + const seeds = [ + Buffer.alloc(32, 0x01), // user + Buffer.alloc(32, 0x02), // backup + Buffer.alloc(32, 0x03), // bitgo + ]; + + const xprivs = seeds.map((seed) => BIP32.fromSeed(seed)) as Triple; + const xpubs = xprivs.map((k) => k.neutered()) as unknown as Triple; + + const walletKeysLike: IWalletKeys = { + triple: xpubs, + derivationPrefixes: ["0/0", "0/0", "0/0"], + }; + + return { + keys: RootWalletKeys.from(walletKeysLike), + xprivs, + }; +} + +type SignPath = { signer: "user" | "backup" | "bitgo"; cosigner: "user" | "backup" | "bitgo" }; + +function createPsbtWithInputs( + inputCount: number, + chain: number, + walletKeys: RootWalletKeys, + signPath?: SignPath, +): BitGoPsbt { + const network: NetworkName = "bitcoin"; + + const psbt = BitGoPsbt.createEmpty(network, walletKeys, { + version: 2, + lockTime: 0, + }); + + for (let i = 0; i < inputCount; i++) { + const txidBytes = Buffer.alloc(32); + txidBytes.writeUInt32BE(i, 0); + const txid = txidBytes.toString("hex"); + + const walletOptions: { scriptId: { chain: number; index: number }; signPath?: SignPath } = { + scriptId: { chain, index: i }, + }; + + // p2tr and p2trMusig2 require signPath + if (signPath) { + walletOptions.signPath = signPath; + } + + psbt.addWalletInput( + { + txid, + vout: 0, + value: BigInt(100000), + sequence: 0xfffffffe, + }, + walletKeys, + walletOptions, + ); + } + + // Add output + psbt.addWalletOutput(walletKeys, { + chain: chain + 1, + index: 0, + value: BigInt(inputCount * 100000 - 10000), + }); + + return psbt; +} + +/** + * Count signatures on a specific input by checking each key + */ +function countInputSignatures(psbt: BitGoPsbt, inputIndex: number, xprivs: Triple): number { + let count = 0; + for (const xpriv of xprivs) { + if (psbt.verifySignature(inputIndex, xpriv.neutered())) { + count++; + } + } + return count; +} + +/** + * Check if any input other than the specified one has signatures from any key + */ +function hasSignaturesOnOtherInputs( + psbt: BitGoPsbt, + inputCount: number, + excludeIndex: number, + xprivs: Triple, +): boolean { + for (let i = 0; i < inputCount; i++) { + if (i === excludeIndex) continue; + + for (const xpriv of xprivs) { + if (psbt.verifySignature(i, xpriv.neutered())) { + return true; + } + } + } + + return false; +} + +describe("Single-input signing", function () { + let walletKeys: RootWalletKeys; + let xprivs: Triple; + + before(function () { + const testKeys = createTestWalletKeys(); + walletKeys = testKeys.keys; + xprivs = testKeys.xprivs; + }); + + describe("p2sh (ECDSA)", function () { + it("should sign only the specified input, not others", function () { + const psbt = createPsbtWithInputs(5, 0, walletKeys); // chain 0 = p2sh + + // Sign only input 2 with user key + psbt.signInput(2, xprivs[0]); + + // Verify input 2 has a signature from user + assert.strictEqual( + psbt.verifySignature(2, xprivs[0].neutered()), + true, + "Input 2 should have user signature", + ); + + // Verify no other inputs have signatures + assert.strictEqual( + hasSignaturesOnOtherInputs(psbt, 5, 2, xprivs), + false, + "Other inputs should not have signatures", + ); + }); + + it("should allow signing different inputs with different keys", function () { + const psbt = createPsbtWithInputs(5, 0, walletKeys); + + // Sign input 1 with user key + psbt.signInput(1, xprivs[0]); + + // Sign input 3 with bitgo key + psbt.signInput(3, xprivs[2]); + + // Verify input 1 has user's signature only + assert.strictEqual( + psbt.verifySignature(1, xprivs[0].neutered()), + true, + "Input 1 should have user sig", + ); + assert.strictEqual( + psbt.verifySignature(1, xprivs[2].neutered()), + false, + "Input 1 should not have bitgo sig", + ); + + // Verify input 3 has bitgo's signature only + assert.strictEqual( + psbt.verifySignature(3, xprivs[2].neutered()), + true, + "Input 3 should have bitgo sig", + ); + assert.strictEqual( + psbt.verifySignature(3, xprivs[0].neutered()), + false, + "Input 3 should not have user sig", + ); + + // Verify inputs 0, 2, 4 have no signatures + assert.strictEqual(countInputSignatures(psbt, 0, xprivs), 0, "Input 0 should have no sigs"); + assert.strictEqual(countInputSignatures(psbt, 2, xprivs), 0, "Input 2 should have no sigs"); + assert.strictEqual(countInputSignatures(psbt, 4, xprivs), 0, "Input 4 should have no sigs"); + }); + + it("should allow completing a single input with both signers", function () { + const psbt = createPsbtWithInputs(5, 0, walletKeys); + + // Sign input 2 with user key + psbt.signInput(2, xprivs[0]); + + // Sign input 2 with bitgo key + psbt.signInput(2, xprivs[2]); + + // Verify input 2 has 2 signatures (user + bitgo) + assert.strictEqual( + countInputSignatures(psbt, 2, xprivs), + 2, + "Input 2 should have 2 signatures", + ); + + // Verify no other inputs have signatures + assert.strictEqual( + hasSignaturesOnOtherInputs(psbt, 5, 2, xprivs), + false, + "Other inputs should not have signatures", + ); + }); + }); + + describe("p2wsh (SegWit ECDSA)", function () { + it("should sign only the specified input, not others", function () { + const psbt = createPsbtWithInputs(5, 20, walletKeys); // chain 20 = p2wsh + + // Sign only input 0 with user key + psbt.signInput(0, xprivs[0]); + + // Verify input 0 has a signature + assert.strictEqual( + psbt.verifySignature(0, xprivs[0].neutered()), + true, + "Input 0 should have signature", + ); + + // Verify no other inputs have signatures + assert.strictEqual( + hasSignaturesOnOtherInputs(psbt, 5, 0, xprivs), + false, + "Other inputs should not have signatures", + ); + }); + }); + + describe("p2trMusig2KeyPath (MuSig2)", function () { + it("should sign only the specified input, not others", function () { + // chain 40 = p2trMusig2, requires signPath with signer and cosigner + const psbt = createPsbtWithInputs(5, 40, walletKeys, { signer: "user", cosigner: "bitgo" }); + + // Generate nonces for all inputs (required for MuSig2) + psbt.generateMusig2Nonces(xprivs[0]); + psbt.generateMusig2Nonces(xprivs[2]); + + // Sign only input 1 with user key + psbt.signInput(1, xprivs[0]); + + // Verify input 1 has a partial signature from user + assert.strictEqual( + psbt.verifySignature(1, xprivs[0].neutered()), + true, + "Input 1 should have user signature", + ); + + // Verify no other inputs have signatures + assert.strictEqual( + hasSignaturesOnOtherInputs(psbt, 5, 1, xprivs), + false, + "Other inputs should not have signatures", + ); + }); + + it("should allow completing a single input with both signers", function () { + const psbt = createPsbtWithInputs(5, 40, walletKeys, { signer: "user", cosigner: "bitgo" }); + + // Generate nonces + psbt.generateMusig2Nonces(xprivs[0]); + psbt.generateMusig2Nonces(xprivs[2]); + + // Sign input 3 with user key + psbt.signInput(3, xprivs[0]); + + // Sign input 3 with bitgo key + psbt.signInput(3, xprivs[2]); + + // Verify input 3 has 2 partial signatures + assert.strictEqual( + countInputSignatures(psbt, 3, xprivs), + 2, + "Input 3 should have 2 signatures", + ); + + // Verify no other inputs have signatures + assert.strictEqual( + hasSignaturesOnOtherInputs(psbt, 5, 3, xprivs), + false, + "Other inputs should not have signatures", + ); + }); + }); + + describe("bulk vs single-input comparison", function () { + it("bulk sign should sign all inputs, single-input should sign one", function () { + // Create two identical PSBTs + const psbtBulk = createPsbtWithInputs(5, 0, walletKeys); + const psbtSingle = createPsbtWithInputs(5, 0, walletKeys); + + // Bulk sign all inputs + const bulkSigned = psbtBulk.sign(xprivs[0]); + assert.strictEqual(bulkSigned.length, 5, "Bulk sign should sign all 5 inputs"); + + // Verify all inputs have signatures + for (let i = 0; i < 5; i++) { + assert.strictEqual( + psbtBulk.verifySignature(i, xprivs[0].neutered()), + true, + `Bulk: Input ${i} should have signature`, + ); + } + + // Single-input sign only input 2 + psbtSingle.signInput(2, xprivs[0]); + + // Verify only input 2 has a signature + assert.strictEqual( + psbtSingle.verifySignature(2, xprivs[0].neutered()), + true, + "Single: Input 2 should have signature", + ); + assert.strictEqual( + hasSignaturesOnOtherInputs(psbtSingle, 5, 2, xprivs), + false, + "Single: Other inputs should not have signatures", + ); + }); + }); +});