Skip to content

Commit 48660b1

Browse files
Merge pull request #37 from BitGo/BTC-2652.p2tr-keypath
feat(wasm-utxo): implement MuSig2 for p2tr-keypath
2 parents f8589c4 + d08c5b8 commit 48660b1

File tree

8 files changed

+2379
-110
lines changed

8 files changed

+2379
-110
lines changed

.github/workflows/ci.yml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ jobs:
5252
run: |
5353
echo "node $(node --version)"
5454
echo "npm $(npm --version)"
55+
echo "npx $(npx --version)"
5556
echo "rustc $(rustc --version)"
5657
echo "wasm-pack $(wasm-pack --version)"
5758
echo "wasm-opt $(wasm-opt --version)"
@@ -71,20 +72,21 @@ jobs:
7172
run: cargo deny check
7273
working-directory: packages/wasm-utxo
7374

74-
- name: test
75-
run: npx --version
76-
7775
- name: build packages
7876
run: npm --workspaces run build
7977

8078
- name: Check Source Code Formatting
8179
run: npm run check-fmt
8280

83-
- name: Wasm-Pack Test (Node)
81+
- name: wasm-utxo / cargo test
82+
run: cargo test --workspace
83+
working-directory: packages/wasm-utxo
84+
85+
- name: wasm-utxo / Wasm-Pack Test (Node)
8486
run: npm run test:wasm-pack-node
8587
working-directory: packages/wasm-utxo
8688

87-
- name: Wasm-Pack Test (Chrome)
89+
- name: wasm-utxo / Wasm-Pack Test (Chrome)
8890
run: npm run test:wasm-pack-chrome
8991
working-directory: packages/wasm-utxo
9092

packages/wasm-utxo/bips/bip-0327/bip-0327.mediawiki

Lines changed: 830 additions & 0 deletions
Large diffs are not rendered by default.

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)