Skip to content

Commit 43e8df1

Browse files
feat(wasm-utxo): add musig2 participants field for BIP322 p2trMusig2 signing
Write Musig2Participants proprietary field when creating BIP322 p2trMusig2 keypath inputs. This enables is_musig2_input() to return true, triggering nonce generation and the musig2 protocol for signing. Add comprehensive test coverage for p2trMusig2 BIP322 workflows: - Participants field is set correctly - Keypath signing and verification with musig2 protocol - Backup flows correctly use script path (no musig2) - Multiple input verification - Invalid message detection - Legacy p2tr signing compatibility Co-authored-by: llm-git <llm-git@ttll.de> Issue: BTC-0
1 parent 75ccf91 commit 43e8df1

1 file changed

Lines changed: 372 additions & 0 deletions

File tree

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

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! This module contains the business logic for BIP-0322 message signing
44
//! with BitGo fixed-script wallets.
55
6+
use crate::fixed_script_wallet::bitgo_psbt::p2tr_musig2_input::Musig2Participants;
67
use crate::fixed_script_wallet::bitgo_psbt::{
78
create_bip32_derivation, create_tap_bip32_derivation, find_kv, BitGoKeyValue, BitGoPsbt,
89
ProprietaryKeySubtype,
@@ -129,6 +130,8 @@ pub fn add_bip322_input(
129130
inner_psbt.inputs[input_index].bip32_derivation =
130131
create_bip32_derivation(wallet_keys, chain, index);
131132
inner_psbt.inputs[input_index].redeem_script = Some(script.redeem_script.clone());
133+
// Legacy P2SH sighash requires the full previous transaction, not just the output.
134+
inner_psbt.inputs[input_index].non_witness_utxo = Some(to_spend);
132135
}
133136
WalletScripts::P2shP2wsh(script) => {
134137
inner_psbt.inputs[input_index].bip32_derivation =
@@ -231,6 +234,18 @@ pub fn add_bip322_input(
231234
&[signer_idx, cosigner_idx],
232235
None,
233236
);
237+
// Write Musig2Participants so is_musig2_input() returns true and the
238+
// nonce-generation and signing routines engage the musig2 protocol.
239+
let tap_output_key = script.spend_info.output_key().to_x_only_public_key();
240+
let musig2_participants = Musig2Participants {
241+
tap_output_key,
242+
tap_internal_key: internal_key,
243+
participant_pub_keys: [pub_triple[0], pub_triple[2]],
244+
};
245+
let (key, value) = musig2_participants.to_key_value().to_key_value();
246+
inner_psbt.inputs[input_index]
247+
.proprietary
248+
.insert(key, value);
234249
}
235250
}
236251
}
@@ -712,3 +727,360 @@ pub fn verify_bip322_tx_input_with_pubkeys(
712727
// TODO: Parse witness to determine actual signers if needed
713728
Ok(vec![0, 1, 2])
714729
}
730+
731+
#[cfg(test)]
732+
mod tests {
733+
use super::*;
734+
use crate::fixed_script_wallet::bitgo_psbt::p2tr_musig2_input::{Musig2Context, Musig2Input};
735+
use crate::fixed_script_wallet::test_utils::fixtures::XprvTriple;
736+
use crate::Network;
737+
use miniscript::bitcoin::bip32::Xpriv;
738+
use miniscript::bitcoin::hashes::{sha256, Hash};
739+
740+
fn make_xprv_triple(seed: &str) -> XprvTriple {
741+
let get_xpriv = |s: &str| {
742+
let seed_hash = sha256::Hash::hash(s.as_bytes()).to_byte_array();
743+
Xpriv::new_master(miniscript::bitcoin::Network::Testnet, &seed_hash)
744+
.expect("could not create xpriv from seed")
745+
};
746+
XprvTriple::new([
747+
get_xpriv(&format!("{}.0", seed)),
748+
get_xpriv(&format!("{}.1", seed)),
749+
get_xpriv(&format!("{}.2", seed)),
750+
])
751+
}
752+
753+
fn make_wallet_keys(seed: &str) -> (XprvTriple, RootWalletKeys) {
754+
let xprivs = make_xprv_triple(seed);
755+
let wallet_keys = xprivs.to_root_wallet_keys();
756+
(xprivs, wallet_keys)
757+
}
758+
759+
fn make_bip322_psbt(wallet_keys: &RootWalletKeys) -> BitGoPsbt {
760+
BitGoPsbt::new(Network::Bitcoin, wallet_keys, Some(0), None)
761+
}
762+
763+
// Sign a p2trMusig2 keypath BIP322 PSBT input using the Musig2Context state machine.
764+
fn sign_musig2_bip322(
765+
psbt: &mut BitGoPsbt,
766+
input_index: usize,
767+
xprivs: &XprvTriple,
768+
) -> Result<(), String> {
769+
let user_session_id: [u8; 32] = [1u8; 32];
770+
let bitgo_session_id: [u8; 32] = [2u8; 32];
771+
772+
let mut ctx =
773+
Musig2Context::new(psbt.psbt_mut(), input_index).map_err(|e| e.to_string())?;
774+
let (user_first_round, _) = ctx
775+
.generate_nonce_first_round(xprivs.user_key(), user_session_id)
776+
.map_err(|e| e.to_string())?;
777+
778+
let mut ctx =
779+
Musig2Context::new(psbt.psbt_mut(), input_index).map_err(|e| e.to_string())?;
780+
let (bitgo_first_round, _) = ctx
781+
.generate_nonce_first_round(xprivs.bitgo_key(), bitgo_session_id)
782+
.map_err(|e| e.to_string())?;
783+
784+
let mut ctx =
785+
Musig2Context::new(psbt.psbt_mut(), input_index).map_err(|e| e.to_string())?;
786+
ctx.sign_with_first_round(user_first_round, xprivs.user_key())
787+
.map_err(|e| e.to_string())?;
788+
789+
let mut ctx =
790+
Musig2Context::new(psbt.psbt_mut(), input_index).map_err(|e| e.to_string())?;
791+
ctx.sign_with_first_round(bitgo_first_round, xprivs.bitgo_key())
792+
.map_err(|e| e.to_string())?;
793+
794+
Ok(())
795+
}
796+
797+
#[test]
798+
fn test_add_bip322_input_musig2_sets_participants_field() {
799+
let (_, wallet_keys) = make_wallet_keys("bip322_musig2_test");
800+
let mut psbt = make_bip322_psbt(&wallet_keys);
801+
let idx = add_bip322_input(
802+
&mut psbt,
803+
"test message",
804+
40,
805+
0,
806+
&wallet_keys,
807+
Some((0, 2)),
808+
None,
809+
)
810+
.unwrap();
811+
assert!(
812+
Musig2Input::is_musig2_input(&psbt.psbt().inputs[idx]),
813+
"Musig2Participants proprietary field must be present"
814+
);
815+
}
816+
817+
#[test]
818+
fn test_bip322_musig2_keypath_sign_and_verify() {
819+
let (xprivs, wallet_keys) = make_wallet_keys("bip322_musig2_sign_verify");
820+
let message = "BIP322 p2trMusig2 keypath test";
821+
let chain = 40; // p2trMusig2 external
822+
let index = 0;
823+
824+
let mut psbt = make_bip322_psbt(&wallet_keys);
825+
add_bip322_input(
826+
&mut psbt,
827+
message,
828+
chain,
829+
index,
830+
&wallet_keys,
831+
Some((0, 2)),
832+
None,
833+
)
834+
.unwrap();
835+
sign_musig2_bip322(&mut psbt, 0, &xprivs).unwrap();
836+
837+
let signers =
838+
verify_bip322_psbt_input(&psbt, 0, message, chain, index, &wallet_keys, None).unwrap();
839+
assert!(signers.contains(&"user".to_string()));
840+
assert!(signers.contains(&"bitgo".to_string()));
841+
}
842+
843+
#[test]
844+
fn test_bip322_musig2_backup_flow_uses_script_path() {
845+
// backup+user and backup+bitgo pairs must use script path, not musig2 keypath
846+
let (_, wallet_keys) = make_wallet_keys("bip322_musig2_backup");
847+
let chain = 40;
848+
let index = 0;
849+
850+
for (signer, cosigner) in [(0usize, 1usize), (1, 2)] {
851+
let mut psbt = make_bip322_psbt(&wallet_keys);
852+
add_bip322_input(
853+
&mut psbt,
854+
"backup flow",
855+
chain,
856+
index,
857+
&wallet_keys,
858+
Some((signer, cosigner)),
859+
None,
860+
)
861+
.unwrap();
862+
assert!(
863+
!Musig2Input::is_musig2_input(&psbt.psbt().inputs[0]),
864+
"Backup flow must not set Musig2Participants (should use script path)"
865+
);
866+
}
867+
}
868+
869+
#[test]
870+
fn test_bip322_musig2_multiple_inputs_verify_each() {
871+
let (xprivs, wallet_keys) = make_wallet_keys("bip322_musig2_multi");
872+
let messages = ["msg one", "msg two"];
873+
let chain = 40;
874+
875+
let mut psbt = make_bip322_psbt(&wallet_keys);
876+
for (i, msg) in messages.iter().enumerate() {
877+
add_bip322_input(
878+
&mut psbt,
879+
msg,
880+
chain,
881+
i as u32,
882+
&wallet_keys,
883+
Some((0, 2)),
884+
None,
885+
)
886+
.unwrap();
887+
sign_musig2_bip322(&mut psbt, i, &xprivs).unwrap();
888+
}
889+
890+
for (i, msg) in messages.iter().enumerate() {
891+
let signers =
892+
verify_bip322_psbt_input(&psbt, i, msg, chain, i as u32, &wallet_keys, None)
893+
.unwrap();
894+
assert!(signers.contains(&"user".to_string()));
895+
assert!(signers.contains(&"bitgo".to_string()));
896+
}
897+
}
898+
899+
#[test]
900+
fn test_bip322_musig2_wrong_message_fails_verification() {
901+
let (xprivs, wallet_keys) = make_wallet_keys("bip322_musig2_wrong_msg");
902+
let message = "correct message";
903+
let chain = 40;
904+
let index = 0;
905+
906+
let mut psbt = make_bip322_psbt(&wallet_keys);
907+
add_bip322_input(
908+
&mut psbt,
909+
message,
910+
chain,
911+
index,
912+
&wallet_keys,
913+
Some((0, 2)),
914+
None,
915+
)
916+
.unwrap();
917+
sign_musig2_bip322(&mut psbt, 0, &xprivs).unwrap();
918+
919+
let result =
920+
verify_bip322_psbt_input(&psbt, 0, "wrong message", chain, index, &wallet_keys, None);
921+
assert!(result.is_err(), "Verification with wrong message must fail");
922+
}
923+
924+
#[test]
925+
fn test_bip322_p2tr_legacy_sign_and_verify() {
926+
let (xprivs, wallet_keys) = make_wallet_keys("bip322_p2tr_legacy");
927+
let message = "BIP322 p2tr legacy test";
928+
let chain = 30; // p2tr external
929+
let index = 0;
930+
let secp = miniscript::bitcoin::secp256k1::Secp256k1::new();
931+
932+
let mut psbt = make_bip322_psbt(&wallet_keys);
933+
add_bip322_input(
934+
&mut psbt,
935+
message,
936+
chain,
937+
index,
938+
&wallet_keys,
939+
Some((0, 2)),
940+
None,
941+
)
942+
.unwrap();
943+
944+
psbt.sign(xprivs.user_key(), &secp).ok();
945+
psbt.sign(xprivs.bitgo_key(), &secp).ok();
946+
947+
let signers =
948+
verify_bip322_psbt_input(&psbt, 0, message, chain, index, &wallet_keys, None).unwrap();
949+
assert!(signers.contains(&"user".to_string()));
950+
assert!(signers.contains(&"bitgo".to_string()));
951+
}
952+
953+
// --- helpers ---
954+
955+
fn xprv_for_index<'a>(xprivs: &'a XprvTriple, idx: usize) -> &'a Xpriv {
956+
match idx {
957+
0 => xprivs.user_key(),
958+
1 => xprivs.backup_key(),
959+
2 => xprivs.bitgo_key(),
960+
_ => panic!("invalid key index {}", idx),
961+
}
962+
}
963+
964+
fn sign_script_path_pair(
965+
psbt: &mut BitGoPsbt,
966+
xprivs: &XprvTriple,
967+
signer: usize,
968+
cosigner: usize,
969+
) {
970+
let secp = miniscript::bitcoin::secp256k1::Secp256k1::new();
971+
psbt.sign(xprv_for_index(xprivs, signer), &secp).ok();
972+
psbt.sign(xprv_for_index(xprivs, cosigner), &secp).ok();
973+
}
974+
975+
fn assert_signers_include(signers: &[String], expected: &[&str]) {
976+
for name in expected {
977+
assert!(
978+
signers.contains(&name.to_string()),
979+
"expected signer '{}' not found in {:?}",
980+
name,
981+
signers
982+
);
983+
}
984+
}
985+
986+
// --- P2sh, P2shP2wsh, P2wsh ---
987+
988+
fn test_bip322_ecdsa_sign_and_verify(chain: u32, seed: &str) {
989+
let (xprivs, wallet_keys) = make_wallet_keys(seed);
990+
let message = format!("BIP322 chain-{} test", chain);
991+
let index = 0;
992+
let secp = miniscript::bitcoin::secp256k1::Secp256k1::new();
993+
994+
let mut psbt = make_bip322_psbt(&wallet_keys);
995+
add_bip322_input(&mut psbt, &message, chain, index, &wallet_keys, None, None).unwrap();
996+
997+
psbt.sign(xprivs.user_key(), &secp).ok();
998+
psbt.sign(xprivs.bitgo_key(), &secp).ok();
999+
1000+
let signers =
1001+
verify_bip322_psbt_input(&psbt, 0, &message, chain, index, &wallet_keys, None).unwrap();
1002+
assert_signers_include(&signers, &["user", "bitgo"]);
1003+
}
1004+
1005+
#[test]
1006+
fn test_bip322_p2sh_sign_and_verify() {
1007+
test_bip322_ecdsa_sign_and_verify(0, "bip322_p2sh");
1008+
}
1009+
1010+
#[test]
1011+
fn test_bip322_p2sh_p2wsh_sign_and_verify() {
1012+
test_bip322_ecdsa_sign_and_verify(10, "bip322_p2sh_p2wsh");
1013+
}
1014+
1015+
#[test]
1016+
fn test_bip322_p2wsh_sign_and_verify() {
1017+
test_bip322_ecdsa_sign_and_verify(20, "bip322_p2wsh");
1018+
}
1019+
1020+
// --- P2trLegacy backup flows ---
1021+
1022+
#[test]
1023+
fn test_bip322_p2tr_legacy_backup_sign_and_verify() {
1024+
const SIGNER_NAMES: [&str; 3] = ["user", "backup", "bitgo"];
1025+
let chain = 30;
1026+
let index = 0;
1027+
1028+
for (signer, cosigner) in [(0usize, 1usize), (1, 2)] {
1029+
let (xprivs, wallet_keys) = make_wallet_keys(&format!(
1030+
"bip322_p2tr_legacy_backup_{}_{}",
1031+
signer, cosigner
1032+
));
1033+
let message = format!("p2tr legacy backup {}-{}", signer, cosigner);
1034+
let mut psbt = make_bip322_psbt(&wallet_keys);
1035+
add_bip322_input(
1036+
&mut psbt,
1037+
&message,
1038+
chain,
1039+
index,
1040+
&wallet_keys,
1041+
Some((signer, cosigner)),
1042+
None,
1043+
)
1044+
.unwrap();
1045+
sign_script_path_pair(&mut psbt, &xprivs, signer, cosigner);
1046+
let signers =
1047+
verify_bip322_psbt_input(&psbt, 0, &message, chain, index, &wallet_keys, None)
1048+
.unwrap();
1049+
assert_signers_include(&signers, &[SIGNER_NAMES[signer], SIGNER_NAMES[cosigner]]);
1050+
}
1051+
}
1052+
1053+
// --- P2trMusig2 backup flow sign+verify ---
1054+
1055+
#[test]
1056+
fn test_bip322_p2trmusig2_backup_sign_and_verify() {
1057+
const SIGNER_NAMES: [&str; 3] = ["user", "backup", "bitgo"];
1058+
let chain = 40;
1059+
let index = 0;
1060+
1061+
for (signer, cosigner) in [(0usize, 1usize), (1, 2)] {
1062+
let (xprivs, wallet_keys) = make_wallet_keys(&format!(
1063+
"bip322_musig2_backup_sign_{}_{}",
1064+
signer, cosigner
1065+
));
1066+
let message = format!("p2trMusig2 backup {}-{}", signer, cosigner);
1067+
let mut psbt = make_bip322_psbt(&wallet_keys);
1068+
add_bip322_input(
1069+
&mut psbt,
1070+
&message,
1071+
chain,
1072+
index,
1073+
&wallet_keys,
1074+
Some((signer, cosigner)),
1075+
None,
1076+
)
1077+
.unwrap();
1078+
// Backup pairs use script-path: standard ECDSA signing works
1079+
sign_script_path_pair(&mut psbt, &xprivs, signer, cosigner);
1080+
let signers =
1081+
verify_bip322_psbt_input(&psbt, 0, &message, chain, index, &wallet_keys, None)
1082+
.unwrap();
1083+
assert_signers_include(&signers, &[SIGNER_NAMES[signer], SIGNER_NAMES[cosigner]]);
1084+
}
1085+
}
1086+
}

0 commit comments

Comments
 (0)