Skip to content

Commit 329906c

Browse files
Merge pull request #27 from BitGo/BTC-2652.cosign-btc
feat(wasm-utxo): implement partial PSBT signing and improve test infrastructure
2 parents 8bb7d59 + 587f006 commit 329906c

File tree

5 files changed

+367
-115
lines changed

5 files changed

+367
-115
lines changed

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

Lines changed: 168 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -229,16 +229,56 @@ impl BitGoPsbt {
229229
)),
230230
}
231231
}
232+
233+
/// Sign the PSBT with the provided key.
234+
/// Wraps the underlying PSBT's sign method from miniscript::psbt::PsbtExt.
235+
///
236+
/// # Type Parameters
237+
/// - `C`: Signing context from secp256k1
238+
/// - `K`: Key type that implements `psbt::GetKey` trait
239+
///
240+
/// # Returns
241+
/// - `Ok(SigningKeysMap)` on success, mapping input index to keys used for signing
242+
/// - `Err((SigningKeysMap, SigningErrors))` on failure, containing both partial success info and errors
243+
pub fn sign<C, K>(
244+
&mut self,
245+
k: &K,
246+
secp: &secp256k1::Secp256k1<C>,
247+
) -> Result<
248+
miniscript::bitcoin::psbt::SigningKeysMap,
249+
(
250+
miniscript::bitcoin::psbt::SigningKeysMap,
251+
miniscript::bitcoin::psbt::SigningErrors,
252+
),
253+
>
254+
where
255+
C: secp256k1::Signing + secp256k1::Verification,
256+
K: miniscript::bitcoin::psbt::GetKey,
257+
{
258+
match self {
259+
BitGoPsbt::BitcoinLike(ref mut psbt, _network) => psbt.sign(k, secp),
260+
BitGoPsbt::Zcash(_zcash_psbt, _network) => {
261+
// Return an error indicating Zcash signing is not implemented
262+
Err((
263+
Default::default(),
264+
std::collections::BTreeMap::from_iter([(
265+
0,
266+
miniscript::bitcoin::psbt::SignError::KeyNotFound,
267+
)]),
268+
))
269+
}
270+
}
271+
}
232272
}
233273

234274
#[cfg(test)]
235275
mod tests {
236276
use super::*;
237277
use crate::fixed_script_wallet::Chain;
238-
use crate::fixed_script_wallet::{RootWalletKeys, WalletScripts};
278+
use crate::fixed_script_wallet::WalletScripts;
239279
use crate::test_utils::fixtures;
280+
use crate::test_utils::fixtures::assert_hex_eq;
240281
use base64::engine::{general_purpose::STANDARD as BASE64_STANDARD, Engine};
241-
use miniscript::bitcoin::bip32::Xpub;
242282
use miniscript::bitcoin::consensus::Decodable;
243283
use miniscript::bitcoin::Transaction;
244284

@@ -386,18 +426,95 @@ mod tests {
386426
output.script_pubkey.to_hex_string()
387427
}
388428

389-
fn assert_matches_wallet_scripts(
429+
type PartialSignatures =
430+
std::collections::BTreeMap<crate::bitcoin::PublicKey, crate::bitcoin::ecdsa::Signature>;
431+
432+
fn assert_eq_partial_signatures(
433+
actual: &PartialSignatures,
434+
expected: &PartialSignatures,
435+
) -> Result<(), String> {
436+
assert_eq!(
437+
actual.len(),
438+
expected.len(),
439+
"Partial signatures should match"
440+
);
441+
for (actual_sig, expected_sig) in actual.iter().zip(expected.iter()) {
442+
assert_eq!(actual_sig.0, expected_sig.0, "Public key should match");
443+
assert_hex_eq(
444+
&hex::encode(actual_sig.1.serialize()),
445+
&hex::encode(expected_sig.1.serialize()),
446+
"Signature",
447+
)?;
448+
}
449+
Ok(())
450+
}
451+
452+
// ensure we can put the first signature (user signature) on an unsigned PSBT
453+
fn assert_half_sign(
454+
script_type: fixtures::ScriptType,
455+
unsigned_bitgo_psbt: &BitGoPsbt,
456+
halfsigned_bitgo_psbt: &BitGoPsbt,
457+
wallet_keys: &fixtures::XprvTriple,
458+
input_index: usize,
459+
) -> Result<(), String> {
460+
let user_key = wallet_keys.user_key();
461+
462+
// Clone the unsigned PSBT and sign with user key
463+
let mut signed_psbt = unsigned_bitgo_psbt.clone();
464+
let secp = secp256k1::Secp256k1::new();
465+
466+
// Sign with user key using the new sign method
467+
signed_psbt
468+
.sign(user_key, &secp)
469+
.map_err(|(_num_keys, errors)| format!("Failed to sign PSBT: {:?}", errors))?;
470+
471+
// Extract partial signatures from the signed input
472+
let signed_input = match &signed_psbt {
473+
BitGoPsbt::BitcoinLike(psbt, _) => &psbt.inputs[input_index],
474+
BitGoPsbt::Zcash(_, _) => {
475+
return Err("Zcash signing not yet implemented".to_string());
476+
}
477+
};
478+
479+
match script_type {
480+
fixtures::ScriptType::P2trLegacyScriptPath
481+
| fixtures::ScriptType::P2trMusig2ScriptPath => {
482+
assert_eq!(signed_input.tap_script_sigs.len(), 1);
483+
// Get expected tap script sig from halfsigned fixture
484+
let expected_tap_script_sig = halfsigned_bitgo_psbt.clone().into_psbt().inputs
485+
[input_index]
486+
.tap_script_sigs
487+
.clone();
488+
assert_eq!(signed_input.tap_script_sigs, expected_tap_script_sig);
489+
}
490+
_ => {
491+
let actual_partial_sigs = signed_input.partial_sigs.clone();
492+
// Get expected partial signatures from halfsigned fixture
493+
let expected_partial_sigs = halfsigned_bitgo_psbt.clone().into_psbt().inputs
494+
[input_index]
495+
.partial_sigs
496+
.clone();
497+
498+
assert_eq!(actual_partial_sigs.len(), 1);
499+
assert_eq_partial_signatures(&actual_partial_sigs, &expected_partial_sigs)?;
500+
}
501+
}
502+
503+
Ok(())
504+
}
505+
506+
fn assert_full_signed_matches_wallet_scripts(
390507
network: Network,
391508
tx_format: fixtures::TxFormat,
392509
fixture: &fixtures::PsbtFixture,
393-
wallet_keys: &RootWalletKeys,
510+
wallet_keys: &fixtures::XprvTriple,
394511
input_index: usize,
395512
input_fixture: &fixtures::PsbtInputFixture,
396513
) -> Result<(), String> {
397514
let (chain, index) =
398515
parse_fixture_paths(input_fixture).expect("Failed to parse fixture paths");
399516
let scripts = WalletScripts::from_wallet_keys(
400-
wallet_keys,
517+
&wallet_keys.to_root_wallet_keys(),
401518
chain,
402519
index,
403520
&network.output_script_support(),
@@ -497,58 +614,59 @@ mod tests {
497614
network: Network,
498615
tx_format: fixtures::TxFormat,
499616
) -> Result<(), String> {
500-
let fixture = fixtures::load_psbt_fixture_with_format(
501-
network.to_utxolib_name(),
502-
fixtures::SignatureState::Fullsigned,
503-
tx_format,
504-
)
505-
.expect("Failed to load fixture");
506-
let wallet_keys =
507-
fixtures::parse_wallet_keys(&fixture).expect("Failed to parse wallet keys");
508-
let secp = crate::bitcoin::secp256k1::Secp256k1::new();
509-
let wallet_keys = RootWalletKeys::new(
510-
wallet_keys
511-
.iter()
512-
.map(|x| Xpub::from_priv(&secp, x))
513-
.collect::<Vec<_>>()
514-
.try_into()
515-
.expect("Failed to convert to XpubTriple"),
516-
);
617+
let psbt_stages = fixtures::PsbtStages::load(network, tx_format)?;
618+
let psbt_input_stages =
619+
fixtures::PsbtInputStages::from_psbt_stages(&psbt_stages, script_type);
517620

518621
// Check if the script type is supported by the network
519622
let output_script_support = network.output_script_support();
520-
let input_fixture = fixture.find_input_with_script_type(script_type);
521623
if !script_type.is_supported_by(&output_script_support) {
522624
// Script type not supported by network - skip test (no fixture expected)
523625
assert!(
524-
input_fixture.is_err(),
626+
psbt_input_stages.is_err(),
525627
"Expected error for unsupported script type"
526628
);
527629
return Ok(());
528630
}
529631

530-
let (input_index, input_fixture) = input_fixture.unwrap();
632+
let psbt_input_stages = psbt_input_stages.unwrap();
633+
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+
}
531649

532-
assert_matches_wallet_scripts(
650+
assert_full_signed_matches_wallet_scripts(
533651
network,
534652
tx_format,
535-
&fixture,
536-
&wallet_keys,
537-
input_index,
538-
input_fixture,
653+
&psbt_stages.fullsigned,
654+
&psbt_input_stages.wallet_keys,
655+
psbt_input_stages.input_index,
656+
&psbt_input_stages.input_fixture_fullsigned,
539657
)?;
540658

541659
assert_finalize_input(
542-
fixture.to_bitgo_psbt(network).unwrap(),
543-
input_index,
660+
psbt_stages.fullsigned.to_bitgo_psbt(network).unwrap(),
661+
psbt_input_stages.input_index,
544662
network,
545663
tx_format,
546664
)?;
547665

548666
Ok(())
549667
}
550668

551-
crate::test_psbt_fixtures!(test_p2sh_script_generation_from_fixture, network, format, {
669+
crate::test_psbt_fixtures!(test_p2sh_suite, network, format, {
552670
test_wallet_script_type(fixtures::ScriptType::P2sh, network, format).unwrap();
553671
}, ignore: [
554672
// TODO: sighash support
@@ -558,7 +676,7 @@ mod tests {
558676
]);
559677

560678
crate::test_psbt_fixtures!(
561-
test_p2sh_p2wsh_script_generation_from_fixture,
679+
test_p2sh_p2wsh_suite,
562680
network,
563681
format,
564682
{
@@ -569,7 +687,7 @@ mod tests {
569687
);
570688

571689
crate::test_psbt_fixtures!(
572-
test_p2wsh_script_generation_from_fixture,
690+
test_p2wsh_suite,
573691
network,
574692
format,
575693
{
@@ -579,27 +697,24 @@ mod tests {
579697
ignore: [BitcoinGold]
580698
);
581699

582-
crate::test_psbt_fixtures!(test_p2tr_script_generation_from_fixture, network, format, {
583-
test_wallet_script_type(fixtures::ScriptType::P2tr, network, format).unwrap();
700+
crate::test_psbt_fixtures!(test_p2tr_legacy_script_path_suite, network, format, {
701+
test_wallet_script_type(fixtures::ScriptType::P2trLegacyScriptPath, network, format)
702+
.unwrap();
584703
});
585704

586-
crate::test_psbt_fixtures!(
587-
test_p2tr_musig2_script_path_generation_from_fixture,
588-
network,
589-
format,
590-
{
591-
test_wallet_script_type(fixtures::ScriptType::P2trMusig2, network, format).unwrap();
592-
}
593-
);
705+
crate::test_psbt_fixtures!(test_p2tr_musig2_script_path_suite, network, format, {
706+
test_wallet_script_type(fixtures::ScriptType::P2trMusig2ScriptPath, network, format)
707+
.unwrap();
708+
});
594709

595-
crate::test_psbt_fixtures!(
596-
test_p2tr_musig2_key_path_spend_script_generation_from_fixture,
597-
network,
598-
format,
599-
{
600-
test_wallet_script_type(fixtures::ScriptType::TaprootKeypath, network, format).unwrap();
601-
}
602-
);
710+
crate::test_psbt_fixtures!(test_p2tr_musig2_key_path_suite, network, format, {
711+
test_wallet_script_type(
712+
fixtures::ScriptType::P2trMusig2TaprootKeypath,
713+
network,
714+
format,
715+
)
716+
.unwrap();
717+
});
603718

604719
crate::test_psbt_fixtures!(test_extract_transaction, network, format, {
605720
let fixture = fixtures::load_psbt_fixture_with_format(

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -689,12 +689,12 @@ mod tests {
689689
.expect("Failed to load fixture");
690690

691691
let (input_index, input_fixture) = fixture
692-
.find_input_with_script_type(ScriptType::TaprootKeypath)
692+
.find_input_with_script_type(ScriptType::P2trMusig2TaprootKeypath)
693693
.expect("Failed to find taprootKeyPathSpend input");
694694

695695
let finalized_input_fixture = if signature_state == SignatureState::Fullsigned {
696696
let (finalized_input_index, finalized_input_fixture) = fixture
697-
.find_finalized_input_with_script_type(ScriptType::TaprootKeypath)
697+
.find_finalized_input_with_script_type(ScriptType::P2trMusig2TaprootKeypath)
698698
.expect("Failed to find taprootKeyPathSpend finalized input");
699699
assert_eq!(input_index, finalized_input_index);
700700
Some(finalized_input_fixture)

0 commit comments

Comments
 (0)