From a9d717bc1c05fd6d91124b030fd1ae1bbebc025f Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 26 Nov 2025 13:20:20 +0100 Subject: [PATCH 1/4] feat(wasm-utxo): implement MuSig2 nonce generation - Add FirstRound HashMap storage to WASM BitGoPsbt struct - Add generate_musig2_nonces WASM method with security checks - Add generateMusig2Nonces() TypeScript method - Add to_xpriv() method to WasmBIP32 for internal use - Create walletKeys.util.ts test helper - Create musig2Nonces.ts test file with comprehensive test cases - Security: Custom session IDs only allowed on testnets Issue: BTC-2786 --- .../js/fixedScriptWallet/BitGoPsbt.ts | 42 +++++++ packages/wasm-utxo/src/wasm/bip32.rs | 8 ++ .../wasm-utxo/src/wasm/fixed_script_wallet.rs | 111 +++++++++++++++++- .../test/fixedScript/musig2Nonces.ts | 56 +++++++++ .../test/fixedScript/walletKeys.util.ts | 26 ++++ 5 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 packages/wasm-utxo/test/fixedScript/musig2Nonces.ts create mode 100644 packages/wasm-utxo/test/fixedScript/walletKeys.util.ts diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 47103e5..c8155d3 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -179,6 +179,48 @@ export class BitGoPsbt { return this.wasm.serialize(); } + /** + * Generate and store MuSig2 nonces for all MuSig2 inputs + * + * This method generates nonces using the State-Machine API and stores them in the PSBT. + * The nonces are stored as proprietary fields in the PSBT and will be included when serialized. + * After ALL participants have generated their nonces, you can sign MuSig2 inputs using + * sign(). + * + * @param key - The extended private key (xpriv) for signing. Can be a base58 string, BIP32 instance, or WasmBIP32 + * @param sessionId - Optional 32-byte session ID for nonce generation. **Only allowed on testnets**. + * On mainnets, a secure random session ID is always generated automatically. + * Must be unique per signing session. + * @throws Error if nonce generation fails, sessionId length is invalid, or custom sessionId is + * provided on a mainnet (security restriction) + * + * @security The sessionId MUST be cryptographically random and unique for each signing session. + * Never reuse a sessionId with the same key! On mainnets, sessionId is always randomly + * generated for security. Custom sessionId is only allowed on testnets for testing purposes. + * + * @example + * ```typescript + * // Phase 1: Both parties generate nonces (with auto-generated session ID) + * psbt.generateMusig2Nonces(userXpriv); + * // Nonces are stored in the PSBT + * // Send PSBT to counterparty + * + * // Phase 2: After receiving counterparty PSBT with their nonces + * psbt.combine(counterpartyPsbtBytes); + * // Sign MuSig2 key path inputs + * const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, replayProtection); + * for (let i = 0; i < parsed.inputs.length; i++) { + * if (parsed.inputs[i].scriptType === "p2trMusig2KeyPath") { + * psbt.sign(i, userXpriv); + * } + * } + * ``` + */ + generateMusig2Nonces(key: BIP32Arg, sessionId?: Uint8Array): void { + const wasmKey = BIP32.from(key); + this.wasm.generate_musig2_nonces(wasmKey.wasm, sessionId); + } + /** * Finalize all inputs in the PSBT * diff --git a/packages/wasm-utxo/src/wasm/bip32.rs b/packages/wasm-utxo/src/wasm/bip32.rs index 0090bc1..c03934b 100644 --- a/packages/wasm-utxo/src/wasm/bip32.rs +++ b/packages/wasm-utxo/src/wasm/bip32.rs @@ -334,4 +334,12 @@ impl WasmBIP32 { pub(crate) fn to_xpub(&self) -> Result { Ok(self.0.to_xpub()) } + + /// Convert to Xpriv (for internal Rust use, not exposed to JS) + pub(crate) fn to_xpriv(&self) -> Result { + match &self.0 { + BIP32Key::Private(xpriv) => Ok(*xpriv), + BIP32Key::Public(_) => Err(WasmUtxoError::new("Cannot get xpriv from public key")), + } + } } diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs index 4406bb0..fbd0b8c 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use wasm_bindgen::prelude::*; use wasm_bindgen::JsValue; @@ -83,6 +84,9 @@ impl FixedScriptWalletNamespace { #[wasm_bindgen] pub struct BitGoPsbt { psbt: crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt, + // Store FirstRound states per (input_index, xpub_string) + #[wasm_bindgen(skip)] + first_rounds: HashMap<(usize, String), musig2::FirstRound>, } #[wasm_bindgen] @@ -95,7 +99,10 @@ impl BitGoPsbt { crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::deserialize(bytes, network) .map_err(|e| WasmUtxoError::new(&format!("Failed to deserialize PSBT: {}", e)))?; - Ok(BitGoPsbt { psbt }) + Ok(BitGoPsbt { + psbt, + first_rounds: HashMap::new(), + }) } /// Get the unsigned transaction ID @@ -262,6 +269,108 @@ impl BitGoPsbt { .map_err(|e| WasmUtxoError::new(&format!("Failed to serialize PSBT: {}", e))) } + /// Generate and store MuSig2 nonces for all MuSig2 inputs + /// + /// This method generates nonces using the State-Machine API and stores them in the PSBT. + /// The nonces are stored as proprietary fields in the PSBT and will be included when serialized. + /// After ALL participants have generated their nonces, they can sign MuSig2 inputs using + /// sign_with_xpriv(). + /// + /// # Arguments + /// * `xpriv` - The extended private key (xpriv) for signing + /// * `session_id_bytes` - Optional 32-byte session ID for nonce generation. **Only allowed on testnets**. + /// On mainnets, a secure random session ID is always generated automatically. + /// Must be unique per signing session. + /// + /// # Returns + /// Ok(()) if nonces were successfully generated and stored + /// + /// # Errors + /// Returns error if: + /// - Nonce generation fails + /// - session_id length is invalid + /// - Custom session_id is provided on a mainnet (security restriction) + /// + /// # Security + /// The session_id MUST be cryptographically random and unique for each signing session. + /// Never reuse a session_id with the same key! On mainnets, session_id is always randomly + /// generated for security. Custom session_id is only allowed on testnets for testing purposes. + pub fn generate_musig2_nonces( + &mut self, + xpriv: &WasmBIP32, + session_id_bytes: Option>, + ) -> Result<(), WasmUtxoError> { + // Extract Xpriv from WasmBIP32 + let xpriv = xpriv.to_xpriv()?; + + // Get the network from the PSBT to check if custom session_id is allowed + let network = self.psbt.network(); + + // Get or generate session ID + let session_id = match session_id_bytes { + Some(bytes) => { + // Only allow custom session_id on testnets for security + if !network.is_testnet() { + return Err(WasmUtxoError::new( + "Custom session_id is only allowed on testnets. On mainnets, session_id is always randomly generated for security." + )); + } + if bytes.len() != 32 { + return Err(WasmUtxoError::new(&format!( + "Session ID must be 32 bytes, got {}", + bytes.len() + ))); + } + let mut session_id = [0u8; 32]; + session_id.copy_from_slice(&bytes); + session_id + } + None => { + // Generate secure random session ID + use getrandom::getrandom; + let mut session_id = [0u8; 32]; + getrandom(&mut session_id).map_err(|e| { + WasmUtxoError::new(&format!("Failed to generate random session ID: {}", e)) + })?; + session_id + } + }; + + // Derive xpub from xpriv to use as key + let secp = miniscript::bitcoin::secp256k1::Secp256k1::new(); + let xpub = miniscript::bitcoin::bip32::Xpub::from_priv(&secp, &xpriv); + let xpub_str = xpub.to_string(); + + // Iterate over all inputs and generate nonces for MuSig2 inputs + let input_count = self.psbt.psbt().unsigned_tx.input.len(); + for input_index in 0..input_count { + // Check if this input is a MuSig2 input + let psbt = self.psbt.psbt(); + if !crate::fixed_script_wallet::bitgo_psbt::p2tr_musig2_input::Musig2Input::is_musig2_input(&psbt.inputs[input_index]) { + continue; + } + + // Generate nonce and get the FirstRound + // The nonce is automatically stored in the PSBT + let (first_round, _pub_nonce) = self + .psbt + .generate_nonce_first_round(input_index, &xpriv, session_id) + .map_err(|e| { + WasmUtxoError::new(&format!( + "Failed to generate nonce for input {}: {}", + input_index, e + )) + })?; + + // Store the FirstRound for later use in signing + // Use (input_index, xpub) as key so multiple parties can store their FirstRounds + self.first_rounds + .insert((input_index, xpub_str.clone()), first_round); + } + + Ok(()) + } + /// Finalize all inputs in the PSBT /// /// This method attempts to finalize all inputs in the PSBT, computing the final diff --git a/packages/wasm-utxo/test/fixedScript/musig2Nonces.ts b/packages/wasm-utxo/test/fixedScript/musig2Nonces.ts new file mode 100644 index 0000000..0e659f9 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/musig2Nonces.ts @@ -0,0 +1,56 @@ +import assert from "assert"; +import { BitGoPsbt } from "../../js/fixedScriptWallet/index.js"; +import { BIP32 } from "../../js/bip32.js"; +import { + loadPsbtFixture, + getBitGoPsbt, + type Fixture, +} from "./fixtureUtil.js"; + +describe("MuSig2 nonce management", function () { + describe("Bitcoin mainnet", function () { + const networkName = "bitcoin"; + let fixture: Fixture; + let unsignedBitgoPsbt: BitGoPsbt; + let userKey: BIP32; + + before(function () { + fixture = loadPsbtFixture(networkName, "unsigned"); + unsignedBitgoPsbt = getBitGoPsbt(fixture, networkName); + userKey = BIP32.fromBase58(fixture.walletKeys[0]); + }); + + it("should generate nonces for MuSig2 inputs with auto-generated session ID", function () { + // Generate nonces with auto-generated session ID (no second parameter) + assert.doesNotThrow(() => { + unsignedBitgoPsbt.generateMusig2Nonces(userKey); + }); + + // Verify nonces were stored by serializing and deserializing + const serialized = unsignedBitgoPsbt.serialize(); + assert.ok(serialized.length > getBitGoPsbt(fixture, networkName).serialize().length); + }); + + it("should reject invalid session ID length", function () { + // Invalid session ID (wrong length) + const invalidSessionId = new Uint8Array(16); // Should be 32 bytes + + assert.throws(() => { + unsignedBitgoPsbt.generateMusig2Nonces(userKey, invalidSessionId); + }, "Should throw error for invalid session ID length"); + }); + + it("should reject custom session ID on mainnet (security)", function () { + // Custom session ID should be rejected on mainnet for security + const customSessionId = new Uint8Array(32).fill(1); + + assert.throws( + () => { + unsignedBitgoPsbt.generateMusig2Nonces(userKey, customSessionId); + }, + /Custom session_id is only allowed on testnets/, + "Should throw error when providing custom session_id on mainnet", + ); + }); + }); +}); diff --git a/packages/wasm-utxo/test/fixedScript/walletKeys.util.ts b/packages/wasm-utxo/test/fixedScript/walletKeys.util.ts new file mode 100644 index 0000000..9172ac5 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/walletKeys.util.ts @@ -0,0 +1,26 @@ +import * as utxolib from "@bitgo/utxo-lib"; +import type { Triple } from "../../js/triple.js"; + +/** + * Convert utxolib BIP32 keys to WASM wallet keys format (Triple) + */ +export function toWasmWalletKeys( + keys: [utxolib.BIP32Interface, utxolib.BIP32Interface, utxolib.BIP32Interface], +): Triple { + return [ + keys[0].neutered().toBase58(), + keys[1].neutered().toBase58(), + keys[2].neutered().toBase58(), + ]; +} + +/** + * Get standard replay protection configuration + */ +export function getStandardReplayProtection(): { outputScripts: Uint8Array[] } { + const replayProtectionScript = Buffer.from( + "a91420b37094d82a513451ff0ccd9db23aba05bc5ef387", + "hex", + ); + return { outputScripts: [replayProtectionScript] }; +} From a4b730d31bc546666faa528472ce2f4b751520b6 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 26 Nov 2025 14:09:45 +0100 Subject: [PATCH 2/4] feat(wasm-utxo): implement single input signing methods Add sign methods to BitGoPsbt to support signing individual inputs by index: - Implement `sign` in JS that dispatches based on key type - Add Rust `sign_with_xpriv` method for wallet inputs (BIP32) - Add Rust `sign_with_privkey` method for raw private keys (ECPair) - Handle MuSig2 inputs with existing first round state - Support replay protection inputs with proper P2SH sighashing - Add comprehensive tests and documentation Issue: BTC-2786 Co-authored-by: llm-git --- .../js/fixedScriptWallet/BitGoPsbt.ts | 60 ++++++ .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 140 ++++++++++++ packages/wasm-utxo/src/wasm/ecpair.rs | 7 + .../wasm-utxo/src/wasm/fixed_script_wallet.rs | 133 ++++++++++++ .../wasm-utxo/test/fixedScript/fixtureUtil.ts | 12 ++ ...Signature.ts => signAndVerifySignature.ts} | 200 ++++++++++++++---- 6 files changed, 515 insertions(+), 37 deletions(-) rename packages/wasm-utxo/test/fixedScript/{verifySignature.ts => signAndVerifySignature.ts} (64%) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index c8155d3..a146b59 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -142,6 +142,66 @@ export class BitGoPsbt { return this.wasm.verify_signature_with_pub(inputIndex, wasmECPair); } + /** + * 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 + * + * 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 + * + * @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 + * + * @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 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(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") + ) { + // It's a BIP32Arg + const wasmKey = BIP32.from(key as BIP32Arg); + this.wasm.sign_with_xpriv(inputIndex, wasmKey.wasm); + } else { + // It's an ECPairArg + const wasmKey = ECPair.from(key as ECPairArg); + this.wasm.sign_with_privkey(inputIndex, wasmKey.wasm); + } + } + /** * @deprecated - use verifySignature with the replay protection key instead * 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 477b533..7b74b7c 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 @@ -426,6 +426,146 @@ impl BitGoPsbt { .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 + /// detects the input type and uses the appropriate signing method: + /// - Replay protection inputs (P2SH-P2PK): Signs with legacy P2SH sighash + /// - Regular inputs: Uses standard PSBT signing + /// - MuSig2 inputs: Returns error (requires FirstRound state, use sign_with_first_round) + /// + /// # Arguments + /// - `input_index`: The index of the input to sign + /// - `privkey`: The private key to sign with + /// + /// # Returns + /// - `Ok(())` if signing was successful + /// - `Err(String)` if signing fails or input type is not supported + pub fn sign_with_privkey( + &mut self, + input_index: usize, + privkey: &secp256k1::SecretKey, + ) -> Result<(), String> { + use miniscript::bitcoin::{ + ecdsa::Signature as EcdsaSignature, hashes::Hash, sighash::SighashCache, PublicKey, + }; + + // Get network before mutable borrow + let network = self.network(); + let is_testnet = network.is_testnet(); + + let psbt = self.psbt_mut(); + + // Check bounds + 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(), + ); + } + + let secp = secp256k1::Secp256k1::new(); + + // Derive public key from private key + let public_key = PublicKey::from_slice( + &secp256k1::PublicKey::from_secret_key(&secp, privkey).serialize(), + ) + .map_err(|e| format!("Failed to derive public key: {}", e))?; + + // Check if this is a replay protection input (P2SH-P2PK) + if let Some(redeem_script) = &psbt.inputs[input_index].redeem_script.clone() { + // Try to extract pubkey from redeem script + if let Ok(redeem_pubkey) = Self::extract_pubkey_from_p2pk_redeem_script(redeem_script) { + // This is a replay protection input - verify the derived pubkey matches + if public_key != redeem_pubkey { + return Err( + "Public key mismatch: derived pubkey does not match redeem_script pubkey" + .to_string(), + ); + } + + // Sign the replay protection input with legacy P2SH sighash + let sighash_type = miniscript::bitcoin::sighash::EcdsaSighashType::All; + let cache = SighashCache::new(&psbt.unsigned_tx); + let sighash = cache + .legacy_signature_hash(input_index, redeem_script, sighash_type.to_u32()) + .map_err(|e| format!("Failed to compute sighash: {}", e))?; + + // Create ECDSA signature + let message = secp256k1::Message::from_digest(sighash.to_byte_array()); + let signature = secp.sign_ecdsa(&message, privkey); + let ecdsa_sig = EcdsaSignature { + signature, + sighash_type, + }; + + // Add signature to partial_sigs + psbt.inputs[input_index] + .partial_sigs + .insert(public_key, ecdsa_sig); + + return Ok(()); + } + } + + // For regular inputs (non-RP, non-MuSig2), use standard signing via miniscript + // This will handle legacy, SegWit, and Taproot script path inputs + match self { + BitGoPsbt::BitcoinLike(ref mut psbt, _network) => { + // Create a key provider that returns our single key + // Convert SecretKey to PrivateKey for the GetKey trait + // Note: The network parameter is only used for WIF serialization, not for signing + let bitcoin_network = if is_testnet { + miniscript::bitcoin::Network::Testnet + } else { + miniscript::bitcoin::Network::Bitcoin + }; + let private_key = miniscript::bitcoin::PrivateKey::new(*privkey, bitcoin_network); + let key_map = std::collections::BTreeMap::from_iter([(public_key, private_key)]); + + // Sign the PSBT + let result = psbt.sign(&key_map, &secp); + + // Check if our specific input was signed + match result { + Ok(signing_keys) => { + if signing_keys.contains_key(&input_index) { + Ok(()) + } else { + Err(format!( + "Input {} was not signed (no key found or already signed)", + input_index + )) + } + } + Err((partial_success, errors)) => { + // Check if there's an error for our specific input + if let Some(error) = errors.get(&input_index) { + Err(format!("Failed to sign input {}: {:?}", input_index, error)) + } else if partial_success.contains_key(&input_index) { + // Input was signed successfully despite other errors + Ok(()) + } else { + Err(format!("Input {} was not signed", input_index)) + } + } + } + } + BitGoPsbt::Zcash(_zcash_psbt, _network) => { + Err("Zcash signing not yet implemented".to_string()) + } + } + } + /// Sign the PSBT with the provided key. /// Wraps the underlying PSBT's sign method from miniscript::psbt::PsbtExt. /// diff --git a/packages/wasm-utxo/src/wasm/ecpair.rs b/packages/wasm-utxo/src/wasm/ecpair.rs index 71ef81a..eacbf1a 100644 --- a/packages/wasm-utxo/src/wasm/ecpair.rs +++ b/packages/wasm-utxo/src/wasm/ecpair.rs @@ -41,6 +41,13 @@ impl WasmECPair { pub(crate) fn get_public_key(&self) -> PublicKey { self.key.public_key() } + + /// Get the private key as a secp256k1::SecretKey (for internal Rust use) + pub(crate) fn get_private_key(&self) -> Result { + self.key + .secret_key() + .ok_or_else(|| WasmUtxoError::new("Cannot get private key from public-only ECPair")) + } } #[wasm_bindgen] diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs index fbd0b8c..bbc92b8 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs @@ -371,6 +371,139 @@ impl BitGoPsbt { Ok(()) } + /// Sign a single input with an extended private key (xpriv) + /// + /// This method signs a specific input using the provided xpriv. It accepts: + /// - An xpriv (WasmBIP32) for wallet inputs - derives the key and signs + /// + /// 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 generate_musig2_nonces() + /// - For replay protection inputs: returns error (use sign_with_privkey 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 + pub fn sign_with_xpriv( + &mut self, + input_index: usize, + xpriv: &WasmBIP32, + ) -> Result<(), WasmUtxoError> { + // Extract Xpriv from WasmBIP32 + let xpriv = xpriv.to_xpriv()?; + + let secp = miniscript::bitcoin::secp256k1::Secp256k1::new(); + + // Check if this is a MuSig2 input + 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], + ) { + // This is a MuSig2 input - use FirstRound signing + let xpub = miniscript::bitcoin::bip32::Xpub::from_priv(&secp, &xpriv); + let xpub_str = xpub.to_string(); + + // Remove the stored FirstRound for this (input, xpub) pair (it can only be used once) + 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 + )))?; + + // Sign with the FirstRound + 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(()) + } else { + // This is a regular input - use standard signing + // Sign the PSBT - this will attempt to sign all inputs but we only care about the result + // The miniscript sign method returns (SigningKeysMap, SigningErrors) on error + let result = self.psbt.sign(&xpriv, &secp); + + // Check if this specific input was signed successfully + match result { + Ok(signing_keys) => { + // Check if our input_index was in the successfully signed keys + if signing_keys.contains_key(&input_index) { + Ok(()) + } else { + Err(WasmUtxoError::new(&format!( + "Input {} was not signed (no key found or already signed)", + input_index + ))) + } + } + Err((partial_success, errors)) => { + // Check if there's an error for our specific input + if let Some(error) = errors.get(&input_index) { + Err(WasmUtxoError::new(&format!( + "Failed to sign input {}: {:?}", + input_index, error + ))) + } else if partial_success.contains_key(&input_index) { + // Input was signed successfully despite other errors + Ok(()) + } else { + Err(WasmUtxoError::new(&format!( + "Input {} was not signed", + input_index + ))) + } + } + } + } + } + + /// Sign a single input with a raw private key + /// + /// This method signs a specific input using the provided ECPair. It accepts: + /// - A raw privkey (WasmECPair) for replay protection inputs - signs directly + /// + /// This method automatically detects and handles different input types: + /// - For replay protection inputs: signs with legacy P2SH sighash + /// - For regular inputs: uses standard PSBT signing + /// - For MuSig2 inputs: returns error (requires FirstRound, use sign_with_xpriv instead) + /// + /// # Arguments + /// - `input_index`: The index of the input to sign (0-based) + /// - `ecpair`: The ECPair containing the private key + /// + /// # Returns + /// - `Ok(())` if signing was successful + /// - `Err(WasmUtxoError)` if signing fails + pub fn sign_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_with_privkey(input_index, &privkey) + .map_err(|e| WasmUtxoError::new(&format!("Failed to sign input: {}", e))) + } + /// Finalize all inputs in the PSBT /// /// This method attempts to finalize all inputs in the PSBT, computing the final diff --git a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts index 4423ca8..75013cb 100644 --- a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts +++ b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts @@ -7,6 +7,8 @@ import type { IWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; import { BIP32, type BIP32Interface } from "../../js/bip32.js"; import { RootWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; import { ECPair } from "../../js/ecpair.js"; +import { fixedScriptWallet } from "../../js/index.js"; +import type { BitGoPsbt, NetworkName } from "../../js/fixedScriptWallet/index.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -103,6 +105,16 @@ export function getPsbtBuffer(fixture: Fixture): Buffer { return Buffer.from(fixture.psbtBase64, "base64"); } +/** + * Get BitGoPsbt from a fixture + * @param fixture - The test fixture + * @param networkName - The network name for deserializing the PSBT + * @returns A BitGoPsbt instance + */ +export function getBitGoPsbt(fixture: Fixture, networkName: NetworkName): BitGoPsbt { + return fixedScriptWallet.BitGoPsbt.fromBytes(getPsbtBuffer(fixture), networkName); +} + /** * Load a PSBT fixture from JSON file */ diff --git a/packages/wasm-utxo/test/fixedScript/verifySignature.ts b/packages/wasm-utxo/test/fixedScript/signAndVerifySignature.ts similarity index 64% rename from packages/wasm-utxo/test/fixedScript/verifySignature.ts rename to packages/wasm-utxo/test/fixedScript/signAndVerifySignature.ts index a6b5b96..394c457 100644 --- a/packages/wasm-utxo/test/fixedScript/verifySignature.ts +++ b/packages/wasm-utxo/test/fixedScript/signAndVerifySignature.ts @@ -1,11 +1,16 @@ import assert from "node:assert"; import * as utxolib from "@bitgo/utxo-lib"; -import { fixedScriptWallet, BIP32, ECPair } from "../../js/index.js"; -import { BitGoPsbt, RootWalletKeys, ParsedTransaction } from "../../js/fixedScriptWallet/index.js"; +import { BIP32, ECPair } from "../../js/index.js"; +import { + BitGoPsbt, + RootWalletKeys, + ParsedTransaction, + type NetworkName, +} from "../../js/fixedScriptWallet/index.js"; import { loadPsbtFixture, loadWalletKeysFromFixture, - getPsbtBuffer, + getBitGoPsbt, type Fixture, loadReplayProtectionKeyFromFixture, } from "./fixtureUtil.js"; @@ -16,9 +21,31 @@ type ExpectedSignatures = | { hasReplayProtectionSignature: boolean } | { user: boolean; backup: boolean; bitgo: boolean }; +type RootWalletXprivs = { + user: BIP32; + backup: BIP32; + bitgo: BIP32; +}; + +/** + * Load xprivs from a fixture + * @param fixture - The test fixture + * @returns The xprivs for user, backup, and bitgo keys + */ +function loadXprivsFromFixture(fixture: Fixture): RootWalletXprivs { + const [userXpriv, backupXpriv, bitgoXpriv] = fixture.walletKeys.map((xprv) => + BIP32.fromBase58(xprv), + ); + return { + user: userXpriv, + backup: backupXpriv, + bitgo: bitgoXpriv, + }; +} + /** * Get expected signature state for an input based on type and signing stage - * @param inputType - The type of input (e.g., "p2shP2pk", "p2trMusig2") + * @param inputType - The type of input (e.g., "p2shP2pk", "p2trMusig2", "taprootKeyPathSpend") * @param signatureStage - The signing stage (unsigned, halfsigned, fullsigned) * @returns Expected signature state for replay protection OR multi-key signatures */ @@ -100,20 +127,22 @@ function verifyInputSignatures( const hasBackupSig = bitgoPsbt.verifySignature(inputIndex, rootWalletKeys.backupKey()); const hasBitGoSig = bitgoPsbt.verifySignature(inputIndex, rootWalletKeys.bitgoKey()); + const scriptType = parsed.inputs[inputIndex].scriptType; + assert.strictEqual( hasUserSig, expectedSignatures.user, - `Input ${inputIndex} user key signature mismatch`, + `Input ${inputIndex} user key signature mismatch type=${scriptType}`, ); assert.strictEqual( hasBackupSig, expectedSignatures.backup, - `Input ${inputIndex} backup key signature mismatch`, + `Input ${inputIndex} backup key signature mismatch type=${scriptType}`, ); assert.strictEqual( hasBitGoSig, expectedSignatures.bitgo, - `Input ${inputIndex} BitGo key signature mismatch`, + `Input ${inputIndex} BitGo key signature mismatch type=${scriptType}`, ); } @@ -148,6 +177,107 @@ 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 + * @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) + 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; + } + }); + + // 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; + } + }); +} + +/** + * Run tests for a fixture: load PSBT, verify, sign, and verify again + * @param fixture - The test fixture + * @param networkName - The network name for deserializing the PSBT + * @param rootWalletKeys - Wallet keys for verification + * @param replayProtectionKey - Key for replay protection inputs + * @param xprivs - The xprivs to use for signing + * @param signatureStage - The current signing stage + */ +function runTestsForFixture( + fixture: Fixture, + networkName: NetworkName, + rootWalletKeys: RootWalletKeys, + replayProtectionKey: ECPair, + xprivs: RootWalletXprivs, + signatureStage: SignatureStage, +): void { + // Load PSBT from fixture + const bitgoPsbt = getBitGoPsbt(fixture, networkName); + + // Verify current state + verifyAllInputSignatures(bitgoPsbt, fixture, rootWalletKeys, replayProtectionKey, signatureStage); + + // Sign inputs (if not already fully signed) + if (signatureStage !== "unsigned") { + signAllInputs(bitgoPsbt, rootWalletKeys, xprivs, replayProtectionKey); + } +} + describe("verifySignature", function () { const supportedNetworks = utxolib.getNetworkList().filter((network) => { return ( @@ -166,12 +296,10 @@ describe("verifySignature", function () { describe(`network: ${networkName}`, function () { let rootWalletKeys: RootWalletKeys; let replayProtectionKey: ECPair; + let xprivs: RootWalletXprivs; let unsignedFixture: Fixture; let halfsignedFixture: Fixture; let fullsignedFixture: Fixture; - let unsignedBitgoPsbt: BitGoPsbt; - let halfsignedBitgoPsbt: BitGoPsbt; - let fullsignedBitgoPsbt: BitGoPsbt; before(function () { unsignedFixture = loadPsbtFixture(networkName, "unsigned"); @@ -179,39 +307,30 @@ describe("verifySignature", function () { fullsignedFixture = loadPsbtFixture(networkName, "fullsigned"); rootWalletKeys = loadWalletKeysFromFixture(fullsignedFixture); replayProtectionKey = loadReplayProtectionKeyFromFixture(fullsignedFixture); - unsignedBitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes( - getPsbtBuffer(unsignedFixture), - networkName, - ); - halfsignedBitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes( - getPsbtBuffer(halfsignedFixture), - networkName, - ); - fullsignedBitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes( - getPsbtBuffer(fullsignedFixture), - networkName, - ); + xprivs = loadXprivsFromFixture(fullsignedFixture); }); describe("unsigned PSBT", function () { - it("should return false for unsigned inputs", function () { - verifyAllInputSignatures( - unsignedBitgoPsbt, + it("should return false for unsigned inputs, then sign and verify", function () { + runTestsForFixture( unsignedFixture, + networkName, rootWalletKeys, replayProtectionKey, + xprivs, "unsigned", ); }); }); describe("half-signed PSBT", function () { - it("should return true for signed xpubs and false for unsigned", function () { - verifyAllInputSignatures( - halfsignedBitgoPsbt, + it("should return true for signed xpubs and false for unsigned, then sign and verify", function () { + runTestsForFixture( halfsignedFixture, + networkName, rootWalletKeys, replayProtectionKey, + xprivs, "halfsigned", ); }); @@ -219,11 +338,12 @@ describe("verifySignature", function () { describe("fully signed PSBT", function () { it("should have 2 signatures (2-of-3 multisig)", function () { - verifyAllInputSignatures( - fullsignedBitgoPsbt, + runTestsForFixture( fullsignedFixture, + networkName, rootWalletKeys, replayProtectionKey, + xprivs, "fullsigned", ); }); @@ -231,9 +351,10 @@ describe("verifySignature", function () { describe("error handling", function () { it("should throw error for out of bounds input index", function () { + const psbt = getBitGoPsbt(fullsignedFixture, networkName); assert.throws( () => { - fullsignedBitgoPsbt.verifySignature(999, rootWalletKeys.userKey()); + psbt.verifySignature(999, rootWalletKeys.userKey()); }, (error: Error) => { return error.message.includes("Input index 999 out of bounds"); @@ -243,9 +364,10 @@ describe("verifySignature", function () { }); it("should throw error for invalid xpub", function () { + const psbt = getBitGoPsbt(fullsignedFixture, networkName); assert.throws( () => { - fullsignedBitgoPsbt.verifySignature(0, "invalid-xpub"); + psbt.verifySignature(0, "invalid-xpub"); }, (error: Error) => { return error.message.includes("Invalid"); @@ -255,13 +377,14 @@ describe("verifySignature", function () { }); it("should return false for xpub not in derivation path", function () { + const psbt = getBitGoPsbt(fullsignedFixture, networkName); // Create a different xpub that's not in the wallet // Use a proper 32-byte seed (256 bits) const differentSeed = Buffer.alloc(32, 0xaa); // 32 bytes filled with 0xaa const differentKey = BIP32.fromSeed(differentSeed); const differentXpub = differentKey.neutered(); - const result = fullsignedBitgoPsbt.verifySignature(0, differentXpub); + const result = psbt.verifySignature(0, differentXpub); assert.strictEqual( result, false, @@ -270,9 +393,10 @@ describe("verifySignature", function () { }); it("should verify signature with raw public key (Uint8Array)", function () { + const psbt = getBitGoPsbt(fullsignedFixture, networkName); // Verify that xpub-based verification works const userKey = rootWalletKeys.userKey(); - const hasXpubSig = fullsignedBitgoPsbt.verifySignature(0, userKey); + const hasXpubSig = psbt.verifySignature(0, userKey); // This test specifically checks that raw public key verification works // We test the underlying WASM API by ensuring both xpub and raw pubkey @@ -284,7 +408,7 @@ describe("verifySignature", function () { const randomPubkey = randomKey.publicKey; // This should return false (no signature for this key) - const result = fullsignedBitgoPsbt.verifySignature(0, randomPubkey); + const result = psbt.verifySignature(0, randomPubkey); assert.strictEqual(result, false, "Should return false for public key not in PSBT"); // Verify the xpub check still works (regression test) @@ -292,12 +416,13 @@ describe("verifySignature", function () { }); it("should return false for raw public key with no signature", function () { + const psbt = getBitGoPsbt(fullsignedFixture, networkName); // Create a random public key that's not in the PSBT const randomSeed = Buffer.alloc(32, 0xbb); const randomKey = BIP32.fromSeed(randomSeed); const randomPubkey = randomKey.publicKey; - const result = fullsignedBitgoPsbt.verifySignature(0, randomPubkey); + const result = psbt.verifySignature(0, randomPubkey); assert.strictEqual( result, false, @@ -306,11 +431,12 @@ describe("verifySignature", function () { }); it("should throw error for invalid key length", function () { + const psbt = getBitGoPsbt(fullsignedFixture, networkName); const invalidKey = Buffer.alloc(31); // Invalid length (should be 32 for private key or 33 for public key) assert.throws( () => { - fullsignedBitgoPsbt.verifySignature(0, invalidKey); + psbt.verifySignature(0, invalidKey); }, (error: Error) => { return error.message.includes("Invalid key length"); From 322a745015b7387ed2a0adfe62445bb71967a703 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 26 Nov 2025 13:24:59 +0100 Subject: [PATCH 3/4] feat(wasm-utxo): add combineMusig2Nonces method Implements new combineMusig2Nonces method to merge MuSig2 nonces from different PSBTs. This enables proper nonce exchange between cosigners during the MuSig2 signing workflow. The method copies nonces and partial signatures between PSBTs, validates network compatibility, and handles input count matching. Includes comprehensive tests and updated documentation with examples showing the complete MuSig2 signing workflow. Issue: BTC-2786 Co-authored-by: llm-git --- .../js/fixedScriptWallet/BitGoPsbt.ts | 26 ++++++- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 65 ++++++++++++++++ .../wasm-utxo/src/wasm/fixed_script_wallet.rs | 20 +++++ .../test/fixedScript/musig2Nonces.ts | 75 ++++++++++++++++--- 4 files changed, 174 insertions(+), 12 deletions(-) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index a146b59..80cb5a2 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -266,7 +266,8 @@ export class BitGoPsbt { * // Send PSBT to counterparty * * // Phase 2: After receiving counterparty PSBT with their nonces - * psbt.combine(counterpartyPsbtBytes); + * const counterpartyPsbt = BitGoPsbt.fromBytes(counterpartyPsbtBytes, network); + * psbt.combineMusig2Nonces(counterpartyPsbt); * // Sign MuSig2 key path inputs * const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, replayProtection); * for (let i = 0; i < parsed.inputs.length; i++) { @@ -281,6 +282,29 @@ export class BitGoPsbt { this.wasm.generate_musig2_nonces(wasmKey.wasm, sessionId); } + /** + * Combine/merge data from another PSBT into this one + * + * This method copies MuSig2 nonces and signatures (proprietary key-value pairs) from the + * source PSBT to this PSBT. This is useful for merging PSBTs during the nonce exchange + * and signature collection phases. + * + * @param sourcePsbt - The source PSBT containing data to merge + * @throws Error if networks don't match + * + * @example + * ```typescript + * // After receiving counterparty's PSBT with their nonces + * const counterpartyPsbt = BitGoPsbt.fromBytes(counterpartyPsbtBytes, network); + * psbt.combineMusig2Nonces(counterpartyPsbt); + * // Now can sign with all nonces present + * psbt.sign(0, userXpriv); + * ``` + */ + combineMusig2Nonces(sourcePsbt: BitGoPsbt): void { + this.wasm.combine_musig2_nonces(sourcePsbt.wasm); + } + /** * Finalize all inputs in the PSBT * 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 7b74b7c..33a8a7c 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 @@ -204,6 +204,71 @@ impl BitGoPsbt { } } + /// Combine/merge data from another PSBT into this one + /// + /// This method copies MuSig2 nonces and signatures (proprietary key-value pairs) from the + /// source PSBT to this PSBT. This is useful for merging PSBTs during the nonce exchange + /// and signature collection phases. + /// + /// # Arguments + /// * `source_psbt` - The source PSBT containing data to merge + /// + /// # Returns + /// Ok(()) if data was successfully merged + /// + /// # Errors + /// Returns error if networks don't match + pub fn combine_musig2_nonces(&mut self, source_psbt: &BitGoPsbt) -> Result<(), String> { + // Check network match + if self.network() != source_psbt.network() { + return Err(format!( + "Network mismatch: destination is {}, source is {}", + self.network(), + source_psbt.network() + )); + } + + let source = source_psbt.psbt(); + let dest = self.psbt_mut(); + + // Check that both PSBTs have the same number of inputs + if source.inputs.len() != dest.inputs.len() { + return Err(format!( + "PSBT input count mismatch: source has {} inputs, destination has {}", + source.inputs.len(), + dest.inputs.len() + )); + } + + // Copy MuSig2 nonces and partial signatures (proprietary key-values with BITGO identifier) + for (source_input, dest_input) in source.inputs.iter().zip(dest.inputs.iter_mut()) { + // Only process if the input is a MuSig2 input + if !p2tr_musig2_input::Musig2Input::is_musig2_input(source_input) { + continue; + } + + // Parse nonces from source input using native Musig2 functions + let nonces = p2tr_musig2_input::parse_musig2_nonces(source_input) + .map_err(|e| format!("Failed to parse MuSig2 nonces from source: {}", e))?; + + // Copy each nonce to the destination input + for nonce in nonces { + let (key, value) = nonce.to_key_value().to_key_value(); + dest_input.proprietary.insert(key, value); + } + + // Also copy partial signatures if present + // Partial sigs are stored as tap_script_sigs in the PSBT input + for (control_block, leaf_script) in &source_input.tap_script_sigs { + dest_input + .tap_script_sigs + .insert(*control_block, *leaf_script); + } + } + + Ok(()) + } + /// Serialize the PSBT to bytes, using network-specific logic pub fn serialize(&self) -> Result, SerializeError> { match self { diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs index bbc92b8..c65daa3 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs @@ -504,6 +504,26 @@ impl BitGoPsbt { .map_err(|e| WasmUtxoError::new(&format!("Failed to sign input: {}", e))) } + /// Combine/merge data from another PSBT into this one + /// + /// This method copies MuSig2 nonces and signatures (proprietary key-value pairs) from the + /// source PSBT to this PSBT. This is useful for merging PSBTs during the nonce exchange + /// and signature collection phases. + /// + /// # Arguments + /// * `source_psbt` - The source PSBT containing data to merge + /// + /// # Returns + /// Ok(()) if data was successfully merged + /// + /// # Errors + /// Returns error if networks don't match + pub fn combine_musig2_nonces(&mut self, source_psbt: &BitGoPsbt) -> Result<(), WasmUtxoError> { + self.psbt + .combine_musig2_nonces(&source_psbt.psbt) + .map_err(|e| WasmUtxoError::new(&format!("Failed to combine PSBTs: {}", e))) + } + /// Finalize all inputs in the PSBT /// /// This method attempts to finalize all inputs in the PSBT, computing the final diff --git a/packages/wasm-utxo/test/fixedScript/musig2Nonces.ts b/packages/wasm-utxo/test/fixedScript/musig2Nonces.ts index 0e659f9..156bae1 100644 --- a/packages/wasm-utxo/test/fixedScript/musig2Nonces.ts +++ b/packages/wasm-utxo/test/fixedScript/musig2Nonces.ts @@ -1,37 +1,89 @@ import assert from "assert"; -import { BitGoPsbt } from "../../js/fixedScriptWallet/index.js"; import { BIP32 } from "../../js/bip32.js"; -import { - loadPsbtFixture, - getBitGoPsbt, - type Fixture, -} from "./fixtureUtil.js"; +import { loadPsbtFixture, getBitGoPsbt, type Fixture } from "./fixtureUtil.js"; describe("MuSig2 nonce management", function () { describe("Bitcoin mainnet", function () { const networkName = "bitcoin"; let fixture: Fixture; - let unsignedBitgoPsbt: BitGoPsbt; let userKey: BIP32; + let backupKey: BIP32; + let bitgoKey: BIP32; before(function () { fixture = loadPsbtFixture(networkName, "unsigned"); - unsignedBitgoPsbt = getBitGoPsbt(fixture, networkName); userKey = BIP32.fromBase58(fixture.walletKeys[0]); + backupKey = BIP32.fromBase58(fixture.walletKeys[1]); + bitgoKey = BIP32.fromBase58(fixture.walletKeys[2]); }); it("should generate nonces for MuSig2 inputs with auto-generated session ID", function () { + const unsignedBitgoPsbt = getBitGoPsbt(fixture, networkName); // Generate nonces with auto-generated session ID (no second parameter) assert.doesNotThrow(() => { unsignedBitgoPsbt.generateMusig2Nonces(userKey); }); - // Verify nonces were stored by serializing and deserializing - const serialized = unsignedBitgoPsbt.serialize(); - assert.ok(serialized.length > getBitGoPsbt(fixture, networkName).serialize().length); + const serializedWithUserNonces = unsignedBitgoPsbt.serialize(); + assert.ok( + serializedWithUserNonces.length > getBitGoPsbt(fixture, networkName).serialize().length, + ); + + assert.doesNotThrow(() => { + unsignedBitgoPsbt.generateMusig2Nonces(bitgoKey); + }); + + const serializedWithBitgoNonces = unsignedBitgoPsbt.serialize(); + assert.ok(serializedWithBitgoNonces.length > serializedWithUserNonces.length); + + assert.throws(() => { + unsignedBitgoPsbt.generateMusig2Nonces(backupKey); + }, "Should throw error when generating nonces for backup key"); + }); + + it("implements combineMusig2Nonces", function () { + const unsignedBitgoPsbtWithUserNonces = getBitGoPsbt(fixture, networkName); + unsignedBitgoPsbtWithUserNonces.generateMusig2Nonces(userKey); + + const unsignedBitgoPsbtWithBitgoNonces = getBitGoPsbt(fixture, networkName); + unsignedBitgoPsbtWithBitgoNonces.generateMusig2Nonces(bitgoKey); + + const unsignedBitgoPsbtWithBothNonces = getBitGoPsbt(fixture, networkName); + unsignedBitgoPsbtWithBothNonces.combineMusig2Nonces(unsignedBitgoPsbtWithUserNonces); + unsignedBitgoPsbtWithBothNonces.combineMusig2Nonces(unsignedBitgoPsbtWithBitgoNonces); + + { + const psbt = getBitGoPsbt(fixture, networkName); + psbt.combineMusig2Nonces(unsignedBitgoPsbtWithUserNonces); + assert.strictEqual( + psbt.serialize().length, + unsignedBitgoPsbtWithUserNonces.serialize().length, + ); + } + + { + const psbt = getBitGoPsbt(fixture, networkName); + psbt.combineMusig2Nonces(unsignedBitgoPsbtWithBitgoNonces); + assert.strictEqual( + psbt.serialize().length, + unsignedBitgoPsbtWithBitgoNonces.serialize().length, + ); + } + + { + const psbt = getBitGoPsbt(fixture, networkName); + psbt.combineMusig2Nonces(unsignedBitgoPsbtWithUserNonces); + psbt.combineMusig2Nonces(unsignedBitgoPsbtWithBitgoNonces); + assert.strictEqual( + psbt.serialize().length, + unsignedBitgoPsbtWithBothNonces.serialize().length, + ); + } }); it("should reject invalid session ID length", function () { + const unsignedBitgoPsbt = getBitGoPsbt(fixture, networkName); + // Invalid session ID (wrong length) const invalidSessionId = new Uint8Array(16); // Should be 32 bytes @@ -41,6 +93,7 @@ describe("MuSig2 nonce management", function () { }); it("should reject custom session ID on mainnet (security)", function () { + const unsignedBitgoPsbt = getBitGoPsbt(fixture, "bitcoin"); // Custom session ID should be rejected on mainnet for security const customSessionId = new Uint8Array(32).fill(1); From 014e05155fcd5858b3aa9d27f4017a7cb2155cd1 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 27 Nov 2025 09:32:23 +0100 Subject: [PATCH 4/4] feat(wasm-utxo): add clippy allow annotations for boxed parameters Add clippy::boxed_local allow annotations for methods that use Box<[T]> parameters, which are required by wasm-bindgen for passing JavaScript arrays. Issue: BTC-2786 Co-authored-by: llm-git --- packages/wasm-utxo/src/wasm/replay_protection.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/wasm-utxo/src/wasm/replay_protection.rs b/packages/wasm-utxo/src/wasm/replay_protection.rs index 6f749b5..e50e506 100644 --- a/packages/wasm-utxo/src/wasm/replay_protection.rs +++ b/packages/wasm-utxo/src/wasm/replay_protection.rs @@ -14,6 +14,8 @@ pub struct WasmReplayProtection { impl WasmReplayProtection { /// Create from output scripts directly #[wasm_bindgen] + // Box<[T]> is required by wasm-bindgen for passing JavaScript arrays + #[allow(clippy::boxed_local)] pub fn from_output_scripts(output_scripts: Box<[js_sys::Uint8Array]>) -> WasmReplayProtection { let scripts = output_scripts .iter() @@ -29,6 +31,8 @@ impl WasmReplayProtection { /// Create from addresses (requires network for decoding) #[wasm_bindgen] + // Box<[T]> is required by wasm-bindgen for passing JavaScript arrays + #[allow(clippy::boxed_local)] pub fn from_addresses( addresses: Box<[JsValue]>, network: &str, @@ -68,6 +72,8 @@ impl WasmReplayProtection { /// Create from public keys (derives P2SH-P2PK output scripts) #[wasm_bindgen] + // Box<[T]> is required by wasm-bindgen for passing JavaScript arrays + #[allow(clippy::boxed_local)] pub fn from_public_keys( public_keys: Box<[js_sys::Uint8Array]>, ) -> Result {