Skip to content

Commit 9dfaf8b

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): add MuSig2 State-Machine API for p2tr-keypath
Adds a safer State-Machine API for MuSig2 operations that properly encapsulates secret nonces, preventing accidental reuse. This includes methods for nonce generation, counterparty nonce setting, and signature creation. The implementation adds BitGoPsbt helper methods that expose this functionality with proper validation. The original Functional API is kept in a separate test- only module for compatibility with existing test fixtures. Issue: BTC-2652 Co-authored-by: llm-git <[email protected]>
1 parent 293247e commit 9dfaf8b

File tree

6 files changed

+1542
-105
lines changed

6 files changed

+1542
-105
lines changed

packages/wasm-utxo/cli/src/parse/node.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use bitcoin::hashes::Hash;
44
use bitcoin::psbt::Psbt;
55
use bitcoin::{Network, ScriptBuf, Transaction};
66
use wasm_utxo::bitgo_psbt::{
7-
BitGoKeyValue, Musig2PartialSig, Musig2Participants, Musig2PubNonce, ProprietaryKeySubtype,
8-
BITGO,
7+
p2tr_musig2_input::{Musig2PartialSig, Musig2Participants, Musig2PubNonce},
8+
BitGoKeyValue, ProprietaryKeySubtype, BITGO,
99
};
1010

1111
pub use crate::node::{Node, Primitive};

packages/wasm-utxo/src/bitgo_psbt/mod.rs

Lines changed: 174 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,19 @@
33
//! This module provides PSBT deserialization that works across different
44
//! bitcoin-like networks, including those with non-standard transaction formats.
55
6-
mod p2tr_musig2_input;
6+
pub mod p2tr_musig2_input;
7+
#[cfg(test)]
8+
mod p2tr_musig2_input_utxolib;
79
mod propkv;
810
mod sighash;
911
mod zcash_psbt;
1012

11-
pub use p2tr_musig2_input::{
12-
parse_musig2_nonces, parse_musig2_partial_sigs, parse_musig2_participants, Musig2Error,
13-
Musig2Input, Musig2PartialSig, Musig2Participants, Musig2PubNonce,
14-
};
13+
use crate::{bitgo_psbt::zcash_psbt::ZcashPsbt, networks::Network};
14+
15+
use miniscript::bitcoin::{psbt::Psbt, secp256k1, CompressedPublicKey};
1516
pub use propkv::{BitGoKeyValue, ProprietaryKeySubtype, BITGO};
1617
pub use sighash::validate_sighash_type;
1718

18-
use crate::{bitgo_psbt::zcash_psbt::ZcashPsbt, networks::Network};
19-
use miniscript::bitcoin::{psbt::Psbt, secp256k1};
20-
2119
#[derive(Debug)]
2220
pub enum DeserializeError {
2321
/// Standard bitcoin consensus decoding error
@@ -133,6 +131,13 @@ impl BitGoPsbt {
133131
}
134132
}
135133

134+
pub fn network(&self) -> Network {
135+
match self {
136+
BitGoPsbt::BitcoinLike(_, network) => *network,
137+
BitGoPsbt::Zcash(_, network) => *network,
138+
}
139+
}
140+
136141
/// Serialize the PSBT to bytes, using network-specific logic
137142
pub fn serialize(&self) -> Result<Vec<u8>, SerializeError> {
138143
match self {
@@ -148,6 +153,28 @@ impl BitGoPsbt {
148153
}
149154
}
150155

156+
/// Get a reference to the underlying PSBT
157+
///
158+
/// This works for both BitcoinLike and Zcash PSBTs, returning a reference
159+
/// to the inner Bitcoin-compatible PSBT structure.
160+
pub fn psbt(&self) -> &Psbt {
161+
match self {
162+
BitGoPsbt::BitcoinLike(ref psbt, _network) => psbt,
163+
BitGoPsbt::Zcash(ref zcash_psbt, _network) => &zcash_psbt.psbt,
164+
}
165+
}
166+
167+
/// Get a mutable reference to the underlying PSBT
168+
///
169+
/// This works for both BitcoinLike and Zcash PSBTs, returning a reference
170+
/// to the inner Bitcoin-compatible PSBT structure.
171+
pub fn psbt_mut(&mut self) -> &mut Psbt {
172+
match self {
173+
BitGoPsbt::BitcoinLike(ref mut psbt, _network) => psbt,
174+
BitGoPsbt::Zcash(ref mut zcash_psbt, _network) => &mut zcash_psbt.psbt,
175+
}
176+
}
177+
151178
pub fn finalize_input<C: secp256k1::Verification>(
152179
&mut self,
153180
secp: &secp256k1::Secp256k1<C>,
@@ -158,9 +185,10 @@ impl BitGoPsbt {
158185
match self {
159186
BitGoPsbt::BitcoinLike(ref mut psbt, _network) => {
160187
// Use custom bitgo p2trMusig2 input finalization for MuSig2 inputs
161-
if Musig2Input::is_musig2_input(&psbt.inputs[input_index]) {
162-
Musig2Input::finalize_input(psbt, secp, input_index)
188+
if p2tr_musig2_input::Musig2Input::is_musig2_input(&psbt.inputs[input_index]) {
189+
let mut ctx = p2tr_musig2_input::Musig2Context::new(psbt, input_index)
163190
.map_err(|e| e.to_string())?;
191+
ctx.finalize_input(secp).map_err(|e| e.to_string())?;
164192
return Ok(());
165193
}
166194
// other inputs can be finalized using the standard miniscript::psbt::finalize_input
@@ -188,10 +216,7 @@ impl BitGoPsbt {
188216
&mut self,
189217
secp: &secp256k1::Secp256k1<C>,
190218
) -> Result<(), Vec<String>> {
191-
let num_inputs = match self {
192-
BitGoPsbt::BitcoinLike(psbt, _network) => psbt.inputs.len(),
193-
BitGoPsbt::Zcash(zcash_psbt, _network) => zcash_psbt.psbt.inputs.len(),
194-
};
219+
let num_inputs = self.psbt().inputs.len();
195220

196221
let mut errors = vec![];
197222
for index in 0..num_inputs {
@@ -230,6 +255,108 @@ impl BitGoPsbt {
230255
}
231256
}
232257

258+
/// Helper function to create a MuSig2 context for an input
259+
///
260+
/// This validates that:
261+
/// 1. The PSBT is BitcoinLike (not Zcash)
262+
/// 2. The input index is valid
263+
/// 3. The input is a MuSig2 input
264+
///
265+
/// Returns a Musig2Context for the specified input
266+
fn musig2_context<'a>(
267+
&'a mut self,
268+
input_index: usize,
269+
) -> Result<p2tr_musig2_input::Musig2Context<'a>, String> {
270+
if self.network().mainnet() != Network::Bitcoin {
271+
return Err("MuSig2 not supported for non-Bitcoin networks".to_string());
272+
}
273+
274+
if matches!(self, BitGoPsbt::Zcash(_, _)) {
275+
return Err("MuSig2 not supported for Zcash".to_string());
276+
}
277+
278+
let psbt = self.psbt_mut();
279+
if input_index >= psbt.inputs.len() {
280+
return Err(format!("Input index {} out of bounds", input_index));
281+
}
282+
283+
// Validate this is a MuSig2 input
284+
if !p2tr_musig2_input::Musig2Input::is_musig2_input(&psbt.inputs[input_index]) {
285+
return Err(format!("Input {} is not a MuSig2 input", input_index));
286+
}
287+
288+
// Create and return the context
289+
p2tr_musig2_input::Musig2Context::new(psbt, input_index).map_err(|e| e.to_string())
290+
}
291+
292+
/// Set the counterparty's (BitGo's) nonce in the PSBT
293+
///
294+
/// # Arguments
295+
/// * `input_index` - The index of the MuSig2 input
296+
/// * `participant_pub_key` - The counterparty's public key
297+
/// * `pub_nonce` - The counterparty's public nonce
298+
pub fn set_counterparty_nonce(
299+
&mut self,
300+
input_index: usize,
301+
participant_pub_key: CompressedPublicKey,
302+
pub_nonce: musig2::PubNonce,
303+
) -> Result<(), String> {
304+
let mut ctx = self.musig2_context(input_index)?;
305+
let tap_output_key = ctx.musig2_input().participants.tap_output_key;
306+
307+
// Set the nonce
308+
ctx.set_nonce(participant_pub_key, tap_output_key, pub_nonce)
309+
.map_err(|e| e.to_string())
310+
}
311+
312+
/// Generate and set a user nonce for a MuSig2 input using State-Machine API
313+
///
314+
/// This method uses the State-Machine API from the musig2 crate, which encapsulates
315+
/// the SecNonce internally to prevent accidental reuse. This is the recommended
316+
/// production API.
317+
///
318+
/// # Arguments
319+
/// * `input_index` - The index of the MuSig2 input
320+
/// * `xpriv` - The user's extended private key (will be derived for the input)
321+
/// * `session_id` - 32-byte session ID (use rand::thread_rng().gen() in production)
322+
///
323+
/// # Returns
324+
/// A tuple of (FirstRound, PubNonce) - keep FirstRound secret for signing later,
325+
/// send PubNonce to the counterparty
326+
pub fn generate_nonce_first_round(
327+
&mut self,
328+
input_index: usize,
329+
xpriv: &miniscript::bitcoin::bip32::Xpriv,
330+
session_id: [u8; 32],
331+
) -> Result<(musig2::FirstRound, musig2::PubNonce), String> {
332+
let mut ctx = self.musig2_context(input_index)?;
333+
ctx.generate_nonce_first_round(xpriv, session_id)
334+
.map_err(|e| e.to_string())
335+
}
336+
337+
/// Sign a MuSig2 input using State-Machine API
338+
///
339+
/// This method uses the State-Machine API from the musig2 crate. The FirstRound
340+
/// from nonce generation encapsulates the secret nonce, preventing reuse.
341+
///
342+
/// # Arguments
343+
/// * `input_index` - The index of the MuSig2 input
344+
/// * `first_round` - The FirstRound from generate_nonce_first_round()
345+
/// * `xpriv` - The user's extended private key
346+
///
347+
/// # Returns
348+
/// Ok(()) if the signature was successfully created and added to the PSBT
349+
pub fn sign_with_first_round(
350+
&mut self,
351+
input_index: usize,
352+
first_round: musig2::FirstRound,
353+
xpriv: &miniscript::bitcoin::bip32::Xpriv,
354+
) -> Result<(), String> {
355+
let mut ctx = self.musig2_context(input_index)?;
356+
ctx.sign_with_first_round(first_round, xpriv)
357+
.map_err(|e| e.to_string())
358+
}
359+
233360
/// Sign the PSBT with the provided key.
234361
/// Wraps the underlying PSBT's sign method from miniscript::psbt::PsbtExt.
235362
///
@@ -454,22 +581,36 @@ mod tests {
454581
script_type: fixtures::ScriptType,
455582
unsigned_bitgo_psbt: &BitGoPsbt,
456583
halfsigned_bitgo_psbt: &BitGoPsbt,
457-
wallet_keys: &fixtures::XprvTriple,
584+
xpriv_triple: &fixtures::XprvTriple,
458585
input_index: usize,
459586
) -> Result<(), String> {
460-
let user_key = wallet_keys.user_key();
587+
let user_xpriv = xpriv_triple.user_key();
461588

462589
// Clone the unsigned PSBT and sign with user key
463-
let mut signed_psbt = unsigned_bitgo_psbt.clone();
590+
let mut unsigned_bitgo_psbt = unsigned_bitgo_psbt.clone();
464591
let secp = secp256k1::Secp256k1::new();
465592

593+
if script_type == fixtures::ScriptType::P2trMusig2TaprootKeypath {
594+
// MuSig2 keypath: set nonces and sign with user key
595+
p2tr_musig2_input::assert_set_nonce_and_sign_musig2_keypath(
596+
xpriv_triple,
597+
&mut unsigned_bitgo_psbt,
598+
halfsigned_bitgo_psbt,
599+
input_index,
600+
)?;
601+
602+
// MuSig2 inputs use proprietary key values for partial signatures,
603+
// not standard PSBT partial_sigs, so we're done
604+
return Ok(());
605+
}
606+
466607
// Sign with user key using the new sign method
467-
signed_psbt
468-
.sign(user_key, &secp)
608+
unsigned_bitgo_psbt
609+
.sign(user_xpriv, &secp)
469610
.map_err(|(_num_keys, errors)| format!("Failed to sign PSBT: {:?}", errors))?;
470611

471612
// Extract partial signatures from the signed input
472-
let signed_input = match &signed_psbt {
613+
let signed_input = match &unsigned_bitgo_psbt {
473614
BitGoPsbt::BitcoinLike(psbt, _) => &psbt.inputs[input_index],
474615
BitGoPsbt::Zcash(_, _) => {
475616
return Err("Zcash signing not yet implemented".to_string());
@@ -631,21 +772,19 @@ mod tests {
631772

632773
let psbt_input_stages = psbt_input_stages.unwrap();
633774

634-
if script_type != fixtures::ScriptType::P2trMusig2TaprootKeypath {
635-
assert_half_sign(
636-
script_type,
637-
&psbt_stages
638-
.unsigned
639-
.to_bitgo_psbt(network)
640-
.expect("Failed to convert to BitGo PSBT"),
641-
&psbt_stages
642-
.halfsigned
643-
.to_bitgo_psbt(network)
644-
.expect("Failed to convert to BitGo PSBT"),
645-
&psbt_input_stages.wallet_keys,
646-
psbt_input_stages.input_index,
647-
)?;
648-
}
775+
assert_half_sign(
776+
script_type,
777+
&psbt_stages
778+
.unsigned
779+
.to_bitgo_psbt(network)
780+
.expect("Failed to convert to BitGo PSBT"),
781+
&psbt_stages
782+
.halfsigned
783+
.to_bitgo_psbt(network)
784+
.expect("Failed to convert to BitGo PSBT"),
785+
&psbt_input_stages.wallet_keys,
786+
psbt_input_stages.input_index,
787+
)?;
649788

650789
assert_full_signed_matches_wallet_scripts(
651790
network,

0 commit comments

Comments
 (0)