From 9515d049d3219768830c6fcb0352d7d465c466f1 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 3 Nov 2025 14:24:10 +0100 Subject: [PATCH] feat(utxo-lib): add cross-validation tests for MuSig2 implementation Add Python test script that validates the TypeScript MuSig2 implementation against the BIP-327 reference implementation. The tests use JSON fixtures generated from the TypeScript test suite to ensure both implementations produce identical results at every step of the MuSig2 protocol. Validation includes key aggregation, taproot tweaking, deterministic nonce generation, partial signing, signature verification, and aggregation. Tests use fixed session IDs to ensure bit-for-bit identical outputs between both implementations. Issue: BTC-2652 Co-authored-by: llm-git --- modules/utxo-lib/bip-0327/.gitignore | 1 + modules/utxo-lib/bip-0327/README.md | 31 ++ .../bip-0327/test_typescript_fixtures.py | 412 +++++++++++++++ modules/utxo-lib/bip-0327/tests.sh | 1 + modules/utxo-lib/bip-0327/utxolibMusig2 | 1 + .../fixtures/musig2/createAggregateNonce.json | 11 + .../musig2/createMusig2SigningSession.json | 22 + .../fixtures/musig2/createTapInternalKey.json | 11 + .../fixtures/musig2/createTapOutputKey.json | 9 + .../bitgo/fixtures/musig2/createTapTweak.json | 9 + .../fixtures/musig2/fullSigningFlow.json | 45 ++ .../fixtures/musig2/musig2AggregateSigs.json | 20 + .../musig2/musig2PartialSignAndVerify.json | 29 ++ .../utxo-lib/test/bitgo/psbt/Musig2Methods.ts | 489 ++++++++++++++++++ 14 files changed, 1091 insertions(+) create mode 100644 modules/utxo-lib/bip-0327/.gitignore create mode 100644 modules/utxo-lib/bip-0327/test_typescript_fixtures.py create mode 120000 modules/utxo-lib/bip-0327/utxolibMusig2 create mode 100644 modules/utxo-lib/test/bitgo/fixtures/musig2/createAggregateNonce.json create mode 100644 modules/utxo-lib/test/bitgo/fixtures/musig2/createMusig2SigningSession.json create mode 100644 modules/utxo-lib/test/bitgo/fixtures/musig2/createTapInternalKey.json create mode 100644 modules/utxo-lib/test/bitgo/fixtures/musig2/createTapOutputKey.json create mode 100644 modules/utxo-lib/test/bitgo/fixtures/musig2/createTapTweak.json create mode 100644 modules/utxo-lib/test/bitgo/fixtures/musig2/fullSigningFlow.json create mode 100644 modules/utxo-lib/test/bitgo/fixtures/musig2/musig2AggregateSigs.json create mode 100644 modules/utxo-lib/test/bitgo/fixtures/musig2/musig2PartialSignAndVerify.json create mode 100644 modules/utxo-lib/test/bitgo/psbt/Musig2Methods.ts diff --git a/modules/utxo-lib/bip-0327/.gitignore b/modules/utxo-lib/bip-0327/.gitignore new file mode 100644 index 0000000000..c18dd8d83c --- /dev/null +++ b/modules/utxo-lib/bip-0327/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/modules/utxo-lib/bip-0327/README.md b/modules/utxo-lib/bip-0327/README.md index 5e8a99e865..9b336642a9 100644 --- a/modules/utxo-lib/bip-0327/README.md +++ b/modules/utxo-lib/bip-0327/README.md @@ -171,6 +171,37 @@ The test suite runs: 4. BitGo legacy p2tr tests 5. BitGo standard p2trMusig2 tests +### TypeScript Cross-Validation Tests + +The `test_typescript_fixtures.py` script validates that the Python reference implementation produces identical results to the TypeScript implementation: + +```bash +cd modules/utxo-lib/bip-0327 +python3 test_typescript_fixtures.py +``` + +These tests read JSON fixtures from the `musig2/` directory, which are generated by the TypeScript test suite in `test/bitgo/psbt/Musig2Methods.ts`. The fixtures contain: + +- **createTapInternalKey.json** - Key aggregation results +- **createTapOutputKey.json** - Tweaked aggregate key results +- **createTapTweak.json** - Taproot tweak computation +- **createAggregateNonce.json** - Nonce aggregation results +- **createMusig2SigningSession.json** - Session context creation +- **musig2PartialSignAndVerify.json** - Partial signature verification +- **musig2AggregateSigs.json** - Signature aggregation and final verification +- **fullSigningFlow.json** - Complete end-to-end signing workflow + +The cross-validation tests ensure that: +1. Key aggregation produces identical aggregate public keys +2. Taproot tweaking produces identical output keys +3. Deterministic nonce generation produces identical nonces (using session IDs `0x0101...` and `0x0202...`) +4. Partial signature generation produces identical signatures +5. Partial signature verification works identically +6. Signature aggregation produces identical results +7. Final Schnorr signature verification succeeds in both implementations + +The tests use fully deterministic nonce generation with fixed session IDs to ensure bit-for-bit identical outputs between Python and TypeScript. This provides strong confidence that the TypeScript and Python implementations are compatible and produce identical cryptographic outputs at every step of the MuSig2 protocol. + ## References - [BIP327 Specification](https://github.com/bitcoin/bips/blob/master/bip-0327.mediawiki) diff --git a/modules/utxo-lib/bip-0327/test_typescript_fixtures.py b/modules/utxo-lib/bip-0327/test_typescript_fixtures.py new file mode 100644 index 0000000000..fdd831c8c9 --- /dev/null +++ b/modules/utxo-lib/bip-0327/test_typescript_fixtures.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +""" +Cross-validation tests for TypeScript MuSig2 implementation. + +This test suite validates that the BIP-0327 reference implementation produces +identical results to the TypeScript implementation by comparing against +fixtures generated from TypeScript tests. + +The tests use deterministic nonce generation with session IDs (Buffer.alloc(32, 1) +and Buffer.alloc(32, 2)) to ensure that both Python and TypeScript implementations +generate identical nonces and partial signatures, providing comprehensive +cross-language validation. +""" + +import json +import os +from typing import Any, Dict + +from reference import ( + key_agg, + get_xonly_pk, + apply_tweak, + nonce_agg, + nonce_gen_internal, + SessionContext, + get_session_values, + sign, + partial_sig_verify_internal, + partial_sig_agg, + schnorr_verify, + tagged_hash, + PlainPk, + individual_pk, +) + + +def load_fixture(filename: str) -> Dict[str, Any]: + """Load a JSON fixture file from the musig2 directory.""" + fixture_path = os.path.join(os.path.dirname(__file__), 'utxolibMusig2', filename) + with open(fixture_path, 'r') as f: + return json.load(f) + + +def hex_to_bytes(hex_str: str) -> bytes: + """Convert a hex string to bytes.""" + return bytes.fromhex(hex_str) + + +def test_create_tap_internal_key() -> None: + """Test that key aggregation matches TypeScript implementation.""" + print("Testing createTapInternalKey...") + fixture = load_fixture('createTapInternalKey.json') + + # Get inputs from fixture + pubkeys = [hex_to_bytes(pk) for pk in fixture['inputs']['pubKeys']] + + # Perform key aggregation + keyagg_ctx = key_agg(pubkeys) + tap_internal_key = get_xonly_pk(keyagg_ctx) + + # Verify against fixture + expected = hex_to_bytes(fixture['output']['tapInternalKey']) + assert tap_internal_key == expected, f"Expected {expected.hex()}, got {tap_internal_key.hex()}" + print(" ✓ tapInternalKey matches") + + +def test_create_tap_output_key() -> None: + """Test that tweaked key aggregation matches TypeScript implementation.""" + print("Testing createTapOutputKey...") + fixture = load_fixture('createTapOutputKey.json') + + # Get inputs from fixture + internal_pubkey = hex_to_bytes(fixture['inputs']['internalPubKey']) + tap_tree_root = hex_to_bytes(fixture['inputs']['tapTreeRoot']) + + # Compute tap tweak as defined in BIP-0341 (Taproot) + # BIP-0327 (MuSig2) provides apply_tweak(), but the tweak calculation itself + # is specific to Taproot and defined in BIP-0341 + tweak = tagged_hash("TapTweak", internal_pubkey + tap_tree_root) + + # Apply tweak to get tap output key + # First, create a keyagg context with the internal key + # The internal key is already an x-only key, so we need to lift it + from reference import lift_x, KeyAggContext + P = lift_x(internal_pubkey) + assert P is not None, "Failed to lift internal pubkey" + keyagg_ctx = KeyAggContext(P, 1, 0) + + # Apply the tweak using BIP-0327's apply_tweak() function + tweaked_ctx = apply_tweak(keyagg_ctx, tweak, is_xonly=True) + tap_output_key = get_xonly_pk(tweaked_ctx) + + # Verify against fixture + expected = hex_to_bytes(fixture['output']['tapOutputKey']) + assert tap_output_key == expected, f"Expected {expected.hex()}, got {tap_output_key.hex()}" + print(" ✓ tapOutputKey matches") + + +def test_create_tap_tweak() -> None: + """Test that tap tweak computation matches TypeScript implementation.""" + print("Testing createTapTweak...") + fixture = load_fixture('createTapTweak.json') + + # Get inputs from fixture + tap_internal_key = hex_to_bytes(fixture['inputs']['tapInternalKey']) + tap_merkle_root = hex_to_bytes(fixture['inputs']['tapMerkleRoot']) + + # Compute tap tweak as defined in BIP-0341 (Taproot) + tap_tweak = tagged_hash("TapTweak", tap_internal_key + tap_merkle_root) + + # Verify against fixture + expected = hex_to_bytes(fixture['output']['tapTweak']) + assert tap_tweak == expected, f"Expected {expected.hex()}, got {tap_tweak.hex()}" + print(" ✓ tapTweak matches") + + +def test_create_aggregate_nonce() -> None: + """Test that nonce aggregation matches TypeScript implementation.""" + print("Testing createAggregateNonce...") + fixture = load_fixture('createAggregateNonce.json') + + # Get inputs from fixture + pub_nonces = [hex_to_bytes(nonce) for nonce in fixture['inputs']['pubNonces']] + + # Aggregate nonces + agg_nonce = nonce_agg(pub_nonces) + + # Verify against fixture + expected = hex_to_bytes(fixture['output']['aggregateNonce']) + assert agg_nonce == expected, f"Expected {expected.hex()}, got {agg_nonce.hex()}" + print(" ✓ aggregateNonce matches") + + +def test_create_musig2_signing_session() -> None: + """Test that session creation matches TypeScript implementation.""" + print("Testing createMusig2SigningSession...") + fixture = load_fixture('createMusig2SigningSession.json') + + # Get inputs from fixture + pub_nonces = [hex_to_bytes(nonce) for nonce in fixture['inputs']['pubNonces']] + tx_hash = hex_to_bytes(fixture['inputs']['txHash']) + pubkeys = [PlainPk(hex_to_bytes(pk)) for pk in fixture['inputs']['pubKeys']] + internal_pubkey = hex_to_bytes(fixture['inputs']['internalPubKey']) + tap_tree_root = hex_to_bytes(fixture['inputs']['tapTreeRoot']) + + # Aggregate nonces + agg_nonce = nonce_agg(pub_nonces) + + # Compute tap tweak as defined in BIP-0341 (Taproot) + tweak = tagged_hash("TapTweak", internal_pubkey + tap_tree_root) + + # Create session context + session_ctx = SessionContext(agg_nonce, pubkeys, [tweak], [True], tx_hash) + + # Get session values + Q, gacc, tacc, b, R, e = get_session_values(session_ctx) + + # Verify against fixture + expected_agg_nonce = hex_to_bytes(fixture['output']['sessionKey']['aggNonce']) + assert agg_nonce == expected_agg_nonce, f"Expected aggNonce {expected_agg_nonce.hex()}, got {agg_nonce.hex()}" + + expected_msg = hex_to_bytes(fixture['output']['sessionKey']['msg']) + assert tx_hash == expected_msg, f"Expected msg {expected_msg.hex()}, got {tx_hash.hex()}" + + expected_pubkey = hex_to_bytes(fixture['output']['sessionKey']['publicKey']) + # The public key in the fixture is a full 65-byte uncompressed key (04 + x + y) + # We need to compare with the tweaked aggregate key + from reference import cbytes, has_even_y + # Get the tweaked key from session context + actual_pubkey_bytes = b'\x04' + Q[0].to_bytes(32, 'big') + Q[1].to_bytes(32, 'big') + assert actual_pubkey_bytes == expected_pubkey, f"Expected publicKey {expected_pubkey.hex()}, got {actual_pubkey_bytes.hex()}" + + print(" ✓ sessionKey matches") + + +def test_partial_sign_and_verify() -> None: + """Test that partial signing and verification matches TypeScript implementation.""" + print("Testing musig2PartialSignAndVerify...") + fixture = load_fixture('musig2PartialSignAndVerify.json') + + # Get inputs from fixture + private_keys = [hex_to_bytes(sk) for sk in fixture['inputs']['privateKeys']] + pubkeys = [PlainPk(hex_to_bytes(pk)) for pk in fixture['inputs']['pubKeys']] + tx_hash = hex_to_bytes(fixture['inputs']['txHash']) + internal_pubkey = hex_to_bytes(fixture['inputs']['internalPubKey']) + tap_tree_root = hex_to_bytes(fixture['inputs']['tapTreeRoot']) + + # Compute tap output key (needed for nonce generation) + from reference import lift_x, KeyAggContext + P = lift_x(internal_pubkey) + assert P is not None + keyagg_ctx = KeyAggContext(P, 1, 0) + # Compute tap tweak as defined in BIP-0341 (Taproot) + tweak = tagged_hash("TapTweak", internal_pubkey + tap_tree_root) + tweaked_ctx = apply_tweak(keyagg_ctx, tweak, is_xonly=True) + tap_output_key = get_xonly_pk(tweaked_ctx) + + # Generate deterministic nonces using the same session IDs as TypeScript + # TypeScript uses Buffer.alloc(32, 1) and Buffer.alloc(32, 2) + session_id_1 = bytes([1] * 32) + session_id_2 = bytes([2] * 32) + + secnonce_1, pubnonce_1 = nonce_gen_internal(session_id_1, private_keys[0], pubkeys[0], tap_output_key, tx_hash, None) + secnonce_2, pubnonce_2 = nonce_gen_internal(session_id_2, private_keys[1], pubkeys[1], tap_output_key, tx_hash, None) + + # Verify generated nonces match fixture + expected_pubnonce_1 = hex_to_bytes(fixture['inputs']['pubNonces'][0]) + expected_pubnonce_2 = hex_to_bytes(fixture['inputs']['pubNonces'][1]) + assert pubnonce_1 == expected_pubnonce_1, f"Generated nonce 1 doesn't match: {pubnonce_1.hex()} vs {expected_pubnonce_1.hex()}" + assert pubnonce_2 == expected_pubnonce_2, f"Generated nonce 2 doesn't match: {pubnonce_2.hex()} vs {expected_pubnonce_2.hex()}" + + # Aggregate nonces + agg_nonce = nonce_agg([pubnonce_1, pubnonce_2]) + + # Create session context + session_ctx = SessionContext(agg_nonce, pubkeys, [tweak], [True], tx_hash) + + # Generate partial signatures + partial_sig_1 = sign(secnonce_1, private_keys[0], session_ctx) + partial_sig_2 = sign(secnonce_2, private_keys[1], session_ctx) + + # Verify generated partial signatures match fixture + expected_partial_sig_1 = hex_to_bytes(fixture['output']['partialSigs'][0]) + expected_partial_sig_2 = hex_to_bytes(fixture['output']['partialSigs'][1]) + assert partial_sig_1 == expected_partial_sig_1, f"Generated partial sig 1 doesn't match: {partial_sig_1.hex()} vs {expected_partial_sig_1.hex()}" + assert partial_sig_2 == expected_partial_sig_2, f"Generated partial sig 2 doesn't match: {partial_sig_2.hex()} vs {expected_partial_sig_2.hex()}" + + # Verify partial signatures + is_valid_1 = partial_sig_verify_internal(partial_sig_1, pubnonce_1, pubkeys[0], session_ctx) + is_valid_2 = partial_sig_verify_internal(partial_sig_2, pubnonce_2, pubkeys[1], session_ctx) + + verification_results = [is_valid_1, is_valid_2] + expected_results = fixture['output']['verificationResults'] + assert verification_results == expected_results, f"Expected {expected_results}, got {verification_results}" + print(" ✓ nonces match") + print(" ✓ partial signatures match") + print(" ✓ partial signature verification matches") + + +def test_aggregate_sigs() -> None: + """Test that signature aggregation matches TypeScript implementation.""" + print("Testing musig2AggregateSigs...") + fixture = load_fixture('musig2AggregateSigs.json') + + # Get inputs from fixture + partial_sigs = [hex_to_bytes(sig) for sig in fixture['inputs']['partialSigs']] + pubkeys = [PlainPk(hex_to_bytes(pk)) for pk in fixture['inputs']['pubKeys']] + tx_hash = hex_to_bytes(fixture['inputs']['txHash']) + internal_pubkey = hex_to_bytes(fixture['inputs']['internalPubKey']) + tap_tree_root = hex_to_bytes(fixture['inputs']['tapTreeRoot']) + + # We need to recreate the session context to aggregate signatures + # First, we need the aggregate nonce, which we don't have directly + # But we can infer it from the partial signatures verification in the previous test + # For now, let's load it from the other fixture + partial_sign_fixture = load_fixture('musig2PartialSignAndVerify.json') + pub_nonces = [hex_to_bytes(nonce) for nonce in partial_sign_fixture['inputs']['pubNonces']] + agg_nonce = nonce_agg(pub_nonces) + + # Compute tap tweak as defined in BIP-0341 (Taproot) + tweak = tagged_hash("TapTweak", internal_pubkey + tap_tree_root) + + # Create session context + session_ctx = SessionContext(agg_nonce, pubkeys, [tweak], [True], tx_hash) + + # Aggregate partial signatures + aggregated_sig = partial_sig_agg(partial_sigs, session_ctx) + + # Verify against fixture + expected = hex_to_bytes(fixture['output']['aggregatedSig']) + assert aggregated_sig == expected, f"Expected {expected.hex()}, got {aggregated_sig.hex()}" + + # Verify the aggregated signature + tap_output_key = hex_to_bytes(fixture['output']['tapOutputKey']) + is_valid = schnorr_verify(tx_hash, tap_output_key, aggregated_sig) + + expected_valid = fixture['output']['isValidAggregated'] + assert is_valid == expected_valid, f"Expected signature validity {expected_valid}, got {is_valid}" + print(" ✓ aggregated signature matches and is valid") + + +def test_full_signing_flow() -> None: + """Test the complete signing flow matches TypeScript implementation.""" + print("Testing fullSigningFlow...") + fixture = load_fixture('fullSigningFlow.json') + + # Step 1: Verify tap keys + pubkeys = [PlainPk(hex_to_bytes(pk)) for pk in fixture['staticInputs']['pubKeys']] + + # Key aggregation + keyagg_ctx = key_agg([bytes(pk) for pk in pubkeys]) + internal_pubkey = get_xonly_pk(keyagg_ctx) + + expected_internal = hex_to_bytes(fixture['step1_tapKeys']['internalPubKey']) + assert internal_pubkey == expected_internal, f"Step 1: Expected internal key {expected_internal.hex()}, got {internal_pubkey.hex()}" + + # Compute tap tweak as defined in BIP-0341 (Taproot) + tap_tree_root = hex_to_bytes(fixture['staticInputs']['tapTreeRoot']) + tweak = tagged_hash("TapTweak", internal_pubkey + tap_tree_root) + + expected_tweak = hex_to_bytes(fixture['step1_tapKeys']['tapTweak']) + assert tweak == expected_tweak, f"Step 1: Expected tweak {expected_tweak.hex()}, got {tweak.hex()}" + + # Apply tweak + tweaked_ctx = apply_tweak(keyagg_ctx, tweak, is_xonly=True) + tap_output_key = get_xonly_pk(tweaked_ctx) + + expected_output = hex_to_bytes(fixture['step1_tapKeys']['tapOutputKey']) + assert tap_output_key == expected_output, f"Step 1: Expected output key {expected_output.hex()}, got {tap_output_key.hex()}" + print(" ✓ Step 1: tap keys match") + + # Step 2: Generate deterministic nonces + private_keys = [hex_to_bytes(sk) for sk in fixture['staticInputs']['privateKeys']] + tx_hash = hex_to_bytes(fixture['staticInputs']['txHash']) + session_id_1 = bytes([1] * 32) + session_id_2 = bytes([2] * 32) + + secnonce_1, pub_nonce1 = nonce_gen_internal(session_id_1, private_keys[0], pubkeys[0], tap_output_key, tx_hash, None) + secnonce_2, pub_nonce2 = nonce_gen_internal(session_id_2, private_keys[1], pubkeys[1], tap_output_key, tx_hash, None) + + expected_nonce1 = hex_to_bytes(fixture['step2_nonces']['pubNonce1']) + expected_nonce2 = hex_to_bytes(fixture['step2_nonces']['pubNonce2']) + assert pub_nonce1 == expected_nonce1, f"Step 2: Expected nonce 1 {expected_nonce1.hex()}, got {pub_nonce1.hex()}" + assert pub_nonce2 == expected_nonce2, f"Step 2: Expected nonce 2 {expected_nonce2.hex()}, got {pub_nonce2.hex()}" + print(" ✓ Step 2: nonces generated and match") + + pub_nonces = [pub_nonce1, pub_nonce2] + + # Step 3: Aggregate nonces + agg_nonce = nonce_agg(pub_nonces) + + expected_agg_nonce = hex_to_bytes(fixture['step3_aggregateNonce']['aggregateNonce']) + assert agg_nonce == expected_agg_nonce, f"Step 3: Expected {expected_agg_nonce.hex()}, got {agg_nonce.hex()}" + print(" ✓ Step 3: aggregate nonce matches") + + # Step 4: Create session + session_ctx = SessionContext(agg_nonce, pubkeys, [tweak], [True], tx_hash) + + # Verify session key values + expected_session_agg_nonce = hex_to_bytes(fixture['step4_sessionKey']['aggNonce']) + assert agg_nonce == expected_session_agg_nonce, f"Step 4: Session aggNonce mismatch" + + expected_session_msg = hex_to_bytes(fixture['step4_sessionKey']['msg']) + assert tx_hash == expected_session_msg, f"Step 4: Session msg mismatch" + print(" ✓ Step 4: session key matches") + + # Step 5: Generate partial signatures + partial_sig1 = sign(secnonce_1, private_keys[0], session_ctx) + partial_sig2 = sign(secnonce_2, private_keys[1], session_ctx) + + expected_partial_sig1 = hex_to_bytes(fixture['step5_partialSigs']['partialSig1']) + expected_partial_sig2 = hex_to_bytes(fixture['step5_partialSigs']['partialSig2']) + assert partial_sig1 == expected_partial_sig1, f"Step 5: Expected sig1 {expected_partial_sig1.hex()}, got {partial_sig1.hex()}" + assert partial_sig2 == expected_partial_sig2, f"Step 5: Expected sig2 {expected_partial_sig2.hex()}, got {partial_sig2.hex()}" + print(" ✓ Step 5: partial signatures generated and match") + + # Step 6: Verify partial signatures + is_valid1 = partial_sig_verify_internal(partial_sig1, pub_nonce1, pubkeys[0], session_ctx) + is_valid2 = partial_sig_verify_internal(partial_sig2, pub_nonce2, pubkeys[1], session_ctx) + + expected_valid1 = fixture['step6_verification']['isValid1'] + expected_valid2 = fixture['step6_verification']['isValid2'] + assert is_valid1 == expected_valid1, f"Step 6: Expected sig1 validity {expected_valid1}, got {is_valid1}" + assert is_valid2 == expected_valid2, f"Step 6: Expected sig2 validity {expected_valid2}, got {is_valid2}" + print(" ✓ Step 6: partial signatures verified") + + # Step 7: Aggregate signatures + aggregated_sig = partial_sig_agg([partial_sig1, partial_sig2], session_ctx) + + expected_aggregated = hex_to_bytes(fixture['step7_aggregation']['aggregatedSig']) + assert aggregated_sig == expected_aggregated, f"Step 7: Expected {expected_aggregated.hex()}, got {aggregated_sig.hex()}" + print(" ✓ Step 7: aggregated signature matches") + + # Step 8: Final verification + is_valid_final = schnorr_verify(tx_hash, tap_output_key, aggregated_sig) + + expected_valid_final = fixture['step8_finalVerification']['isValidFinal'] + assert is_valid_final == expected_valid_final, f"Step 8: Expected {expected_valid_final}, got {is_valid_final}" + print(" ✓ Step 8: final verification matches") + + +def main() -> None: + """Run all cross-validation tests.""" + print("\n" + "="*60) + print("TypeScript MuSig2 Cross-Validation Tests") + print("="*60 + "\n") + + try: + test_create_tap_internal_key() + test_create_tap_output_key() + test_create_tap_tweak() + test_create_aggregate_nonce() + test_create_musig2_signing_session() + test_partial_sign_and_verify() + test_aggregate_sigs() + test_full_signing_flow() + + print("\n" + "="*60) + print("✓ All cross-validation tests passed!") + print("="*60 + "\n") + + except AssertionError as e: + print(f"\n✗ Test failed: {e}") + raise + except Exception as e: + print(f"\n✗ Unexpected error: {e}") + raise + + +if __name__ == "__main__": + main() + diff --git a/modules/utxo-lib/bip-0327/tests.sh b/modules/utxo-lib/bip-0327/tests.sh index b363f40bbd..b432620758 100755 --- a/modules/utxo-lib/bip-0327/tests.sh +++ b/modules/utxo-lib/bip-0327/tests.sh @@ -5,4 +5,5 @@ set -e cd "$(dirname "$0")" mypy --no-error-summary reference.py python3 reference.py +python3 test_typescript_fixtures.py python3 gen_vectors_helper.py > /dev/null diff --git a/modules/utxo-lib/bip-0327/utxolibMusig2 b/modules/utxo-lib/bip-0327/utxolibMusig2 new file mode 120000 index 0000000000..cef42a276a --- /dev/null +++ b/modules/utxo-lib/bip-0327/utxolibMusig2 @@ -0,0 +1 @@ +../test/bitgo/fixtures/musig2 \ No newline at end of file diff --git a/modules/utxo-lib/test/bitgo/fixtures/musig2/createAggregateNonce.json b/modules/utxo-lib/test/bitgo/fixtures/musig2/createAggregateNonce.json new file mode 100644 index 0000000000..a8d777ff88 --- /dev/null +++ b/modules/utxo-lib/test/bitgo/fixtures/musig2/createAggregateNonce.json @@ -0,0 +1,11 @@ +{ + "inputs": { + "pubNonces": [ + "027389d7aa456cbe418467f42928601462d77000c3b260e5127f1111b57588b35e036f13a501e1708aa7288a313d71975324e0de1d129acff61e40e1c96ae5976c29", + "02949960a953f58a5bad40d8c6161bac10a52a971a37cfe858ba4a1452524b1691026dca4ba06a76389621d0d5a6965976120eb9dac6b861cf32d674c65e080a7c13" + ] + }, + "output": { + "aggregateNonce": "0233086bdf4150163179ef61beed827f55b073563e8bde58755ed8bcddc00635a902418bed8a65d3ee02007694fb18b5a1ec202d8b69ece07e2d54dd193ef7373b88" + } +} diff --git a/modules/utxo-lib/test/bitgo/fixtures/musig2/createMusig2SigningSession.json b/modules/utxo-lib/test/bitgo/fixtures/musig2/createMusig2SigningSession.json new file mode 100644 index 0000000000..3f92633589 --- /dev/null +++ b/modules/utxo-lib/test/bitgo/fixtures/musig2/createMusig2SigningSession.json @@ -0,0 +1,22 @@ +{ + "inputs": { + "pubNonces": [ + "027389d7aa456cbe418467f42928601462d77000c3b260e5127f1111b57588b35e036f13a501e1708aa7288a313d71975324e0de1d129acff61e40e1c96ae5976c29", + "02949960a953f58a5bad40d8c6161bac10a52a971a37cfe858ba4a1452524b1691026dca4ba06a76389621d0d5a6965976120eb9dac6b861cf32d674c65e080a7c13" + ], + "txHash": "d11d3b06a83bb14c95fad947da2192ad9f6faee3c0fb36ad197a05f943aa7acd", + "pubKeys": [ + "03e9fc8f605f59d6a4f5ceb0d27852151013f2bf1965f9adf44ce9ba9821c7f106", + "030710ec162d2199bb828e5d8760f16d65412bbb2c85ec13fe027f77d83a349c62" + ], + "internalPubKey": "6d767a69e03d4611485407d777711ee4d1321a84cf82f591db0cd3e179c49821", + "tapTreeRoot": "63a3684763dca2d4bb5d412d01f5ce73214af3d7816b52d7ad549562cc4cd890" + }, + "output": { + "sessionKey": { + "aggNonce": "0233086bdf4150163179ef61beed827f55b073563e8bde58755ed8bcddc00635a902418bed8a65d3ee02007694fb18b5a1ec202d8b69ece07e2d54dd193ef7373b88", + "msg": "d11d3b06a83bb14c95fad947da2192ad9f6faee3c0fb36ad197a05f943aa7acd", + "publicKey": "04e5406bdcc45b02e86b30c446fd57375e8bfc8144238d61033d353c8a3641a43d5bc152f290f28255baf6bf18c5de0d67f81921fc4e1c05c3c5625eb7784eab01" + } + } +} diff --git a/modules/utxo-lib/test/bitgo/fixtures/musig2/createTapInternalKey.json b/modules/utxo-lib/test/bitgo/fixtures/musig2/createTapInternalKey.json new file mode 100644 index 0000000000..3a8d09fb91 --- /dev/null +++ b/modules/utxo-lib/test/bitgo/fixtures/musig2/createTapInternalKey.json @@ -0,0 +1,11 @@ +{ + "inputs": { + "pubKeys": [ + "03e9fc8f605f59d6a4f5ceb0d27852151013f2bf1965f9adf44ce9ba9821c7f106", + "030710ec162d2199bb828e5d8760f16d65412bbb2c85ec13fe027f77d83a349c62" + ] + }, + "output": { + "tapInternalKey": "6d767a69e03d4611485407d777711ee4d1321a84cf82f591db0cd3e179c49821" + } +} diff --git a/modules/utxo-lib/test/bitgo/fixtures/musig2/createTapOutputKey.json b/modules/utxo-lib/test/bitgo/fixtures/musig2/createTapOutputKey.json new file mode 100644 index 0000000000..2e9ee5a74b --- /dev/null +++ b/modules/utxo-lib/test/bitgo/fixtures/musig2/createTapOutputKey.json @@ -0,0 +1,9 @@ +{ + "inputs": { + "internalPubKey": "6d767a69e03d4611485407d777711ee4d1321a84cf82f591db0cd3e179c49821", + "tapTreeRoot": "63a3684763dca2d4bb5d412d01f5ce73214af3d7816b52d7ad549562cc4cd890" + }, + "output": { + "tapOutputKey": "e5406bdcc45b02e86b30c446fd57375e8bfc8144238d61033d353c8a3641a43d" + } +} diff --git a/modules/utxo-lib/test/bitgo/fixtures/musig2/createTapTweak.json b/modules/utxo-lib/test/bitgo/fixtures/musig2/createTapTweak.json new file mode 100644 index 0000000000..90b488ecf1 --- /dev/null +++ b/modules/utxo-lib/test/bitgo/fixtures/musig2/createTapTweak.json @@ -0,0 +1,9 @@ +{ + "inputs": { + "tapInternalKey": "6d767a69e03d4611485407d777711ee4d1321a84cf82f591db0cd3e179c49821", + "tapMerkleRoot": "63a3684763dca2d4bb5d412d01f5ce73214af3d7816b52d7ad549562cc4cd890" + }, + "output": { + "tapTweak": "c990a77a31b07eea9d1a616bfe80bd33ba2a7e2a22db0e900a168d87a5e01a2c" + } +} diff --git a/modules/utxo-lib/test/bitgo/fixtures/musig2/fullSigningFlow.json b/modules/utxo-lib/test/bitgo/fixtures/musig2/fullSigningFlow.json new file mode 100644 index 0000000000..e3a87278cc --- /dev/null +++ b/modules/utxo-lib/test/bitgo/fixtures/musig2/fullSigningFlow.json @@ -0,0 +1,45 @@ +{ + "staticInputs": { + "pubKeys": [ + "03e9fc8f605f59d6a4f5ceb0d27852151013f2bf1965f9adf44ce9ba9821c7f106", + "030710ec162d2199bb828e5d8760f16d65412bbb2c85ec13fe027f77d83a349c62" + ], + "privateKeys": [ + "210169d3bfff26ee7e1f3a22ba61e3d37186b195a2445a66c36adcc987c80e1c", + "b88806edcd0d7446c8d12f8566874ec1ab38fc4a051a477275086800809245da" + ], + "txHash": "d11d3b06a83bb14c95fad947da2192ad9f6faee3c0fb36ad197a05f943aa7acd", + "tapTreeRoot": "63a3684763dca2d4bb5d412d01f5ce73214af3d7816b52d7ad549562cc4cd890" + }, + "step1_tapKeys": { + "internalPubKey": "6d767a69e03d4611485407d777711ee4d1321a84cf82f591db0cd3e179c49821", + "tapOutputKey": "e5406bdcc45b02e86b30c446fd57375e8bfc8144238d61033d353c8a3641a43d", + "tapTweak": "c990a77a31b07eea9d1a616bfe80bd33ba2a7e2a22db0e900a168d87a5e01a2c" + }, + "step2_nonces": { + "pubNonce1": "027389d7aa456cbe418467f42928601462d77000c3b260e5127f1111b57588b35e036f13a501e1708aa7288a313d71975324e0de1d129acff61e40e1c96ae5976c29", + "pubNonce2": "02949960a953f58a5bad40d8c6161bac10a52a971a37cfe858ba4a1452524b1691026dca4ba06a76389621d0d5a6965976120eb9dac6b861cf32d674c65e080a7c13" + }, + "step3_aggregateNonce": { + "aggregateNonce": "0233086bdf4150163179ef61beed827f55b073563e8bde58755ed8bcddc00635a902418bed8a65d3ee02007694fb18b5a1ec202d8b69ece07e2d54dd193ef7373b88" + }, + "step4_sessionKey": { + "aggNonce": "0233086bdf4150163179ef61beed827f55b073563e8bde58755ed8bcddc00635a902418bed8a65d3ee02007694fb18b5a1ec202d8b69ece07e2d54dd193ef7373b88", + "msg": "d11d3b06a83bb14c95fad947da2192ad9f6faee3c0fb36ad197a05f943aa7acd", + "publicKey": "04e5406bdcc45b02e86b30c446fd57375e8bfc8144238d61033d353c8a3641a43d5bc152f290f28255baf6bf18c5de0d67f81921fc4e1c05c3c5625eb7784eab01" + }, + "step5_partialSigs": { + "partialSig1": "218f2cb5d0b24927b11094d2c78da7a4e1d0de9ba29d59e49312c48f216570c4", + "partialSig2": "53d90faeb634bc5f2f9c3bceb303b958b8f4cf5054b3fb55c8b35dc662bd15e4" + }, + "step6_verification": { + "isValid1": true, + "isValid2": true + }, + "step7_aggregation": { + "aggregatedSig": "1d6242dc3116fbb280482a2ad57f4167c3843d397df5da02fc6235030e0bbe24758f2be5356e937c5f4ecee1b0dd632f0a1615974065e4a89bdc385006f41b9b" + }, + "step8_finalVerification": { + "isValidFinal": true + } +} diff --git a/modules/utxo-lib/test/bitgo/fixtures/musig2/musig2AggregateSigs.json b/modules/utxo-lib/test/bitgo/fixtures/musig2/musig2AggregateSigs.json new file mode 100644 index 0000000000..5694196d45 --- /dev/null +++ b/modules/utxo-lib/test/bitgo/fixtures/musig2/musig2AggregateSigs.json @@ -0,0 +1,20 @@ +{ + "inputs": { + "partialSigs": [ + "218f2cb5d0b24927b11094d2c78da7a4e1d0de9ba29d59e49312c48f216570c4", + "53d90faeb634bc5f2f9c3bceb303b958b8f4cf5054b3fb55c8b35dc662bd15e4" + ], + "pubKeys": [ + "03e9fc8f605f59d6a4f5ceb0d27852151013f2bf1965f9adf44ce9ba9821c7f106", + "030710ec162d2199bb828e5d8760f16d65412bbb2c85ec13fe027f77d83a349c62" + ], + "txHash": "d11d3b06a83bb14c95fad947da2192ad9f6faee3c0fb36ad197a05f943aa7acd", + "internalPubKey": "6d767a69e03d4611485407d777711ee4d1321a84cf82f591db0cd3e179c49821", + "tapTreeRoot": "63a3684763dca2d4bb5d412d01f5ce73214af3d7816b52d7ad549562cc4cd890" + }, + "output": { + "aggregatedSig": "1d6242dc3116fbb280482a2ad57f4167c3843d397df5da02fc6235030e0bbe24758f2be5356e937c5f4ecee1b0dd632f0a1615974065e4a89bdc385006f41b9b", + "tapOutputKey": "e5406bdcc45b02e86b30c446fd57375e8bfc8144238d61033d353c8a3641a43d", + "isValidAggregated": true + } +} diff --git a/modules/utxo-lib/test/bitgo/fixtures/musig2/musig2PartialSignAndVerify.json b/modules/utxo-lib/test/bitgo/fixtures/musig2/musig2PartialSignAndVerify.json new file mode 100644 index 0000000000..29e8d64068 --- /dev/null +++ b/modules/utxo-lib/test/bitgo/fixtures/musig2/musig2PartialSignAndVerify.json @@ -0,0 +1,29 @@ +{ + "inputs": { + "privateKeys": [ + "210169d3bfff26ee7e1f3a22ba61e3d37186b195a2445a66c36adcc987c80e1c", + "b88806edcd0d7446c8d12f8566874ec1ab38fc4a051a477275086800809245da" + ], + "pubNonces": [ + "027389d7aa456cbe418467f42928601462d77000c3b260e5127f1111b57588b35e036f13a501e1708aa7288a313d71975324e0de1d129acff61e40e1c96ae5976c29", + "02949960a953f58a5bad40d8c6161bac10a52a971a37cfe858ba4a1452524b1691026dca4ba06a76389621d0d5a6965976120eb9dac6b861cf32d674c65e080a7c13" + ], + "pubKeys": [ + "03e9fc8f605f59d6a4f5ceb0d27852151013f2bf1965f9adf44ce9ba9821c7f106", + "030710ec162d2199bb828e5d8760f16d65412bbb2c85ec13fe027f77d83a349c62" + ], + "txHash": "d11d3b06a83bb14c95fad947da2192ad9f6faee3c0fb36ad197a05f943aa7acd", + "internalPubKey": "6d767a69e03d4611485407d777711ee4d1321a84cf82f591db0cd3e179c49821", + "tapTreeRoot": "63a3684763dca2d4bb5d412d01f5ce73214af3d7816b52d7ad549562cc4cd890" + }, + "output": { + "partialSigs": [ + "218f2cb5d0b24927b11094d2c78da7a4e1d0de9ba29d59e49312c48f216570c4", + "53d90faeb634bc5f2f9c3bceb303b958b8f4cf5054b3fb55c8b35dc662bd15e4" + ], + "verificationResults": [ + true, + true + ] + } +} diff --git a/modules/utxo-lib/test/bitgo/psbt/Musig2Methods.ts b/modules/utxo-lib/test/bitgo/psbt/Musig2Methods.ts new file mode 100644 index 0000000000..88f2fd411f --- /dev/null +++ b/modules/utxo-lib/test/bitgo/psbt/Musig2Methods.ts @@ -0,0 +1,489 @@ +import * as assert from 'assert'; +import * as crypto from 'crypto'; +import { + createTapOutputKey, + createAggregateNonce, + createTapTweak, + musig2PartialSign, + musig2PartialSigVerify, + musig2AggregateSigs, + createMusig2SigningSession, + Musig2NonceStore, + createTapInternalKey, +} from '../../../src/bitgo/Musig2'; +import { getKeyTriple } from '../../../src/testutil/keys'; +import { RootWalletKeys } from '../../../src/bitgo'; +import { Tuple } from '../../../src/bitgo/types'; +import { getFixture } from '../../fixture.util'; +import { ecc } from '@bitgo/secp256k1'; + +// Use static keys for deterministic tests +const keyTriple = getKeyTriple('musig2-fixture-test'); +const rootWalletKeys = new RootWalletKeys(keyTriple); + +// Derive consistent keys for testing +const walletKeys = rootWalletKeys.deriveForChainAndIndex(11, 0); +const pubKeys: Tuple = [walletKeys.user.publicKey, walletKeys.backup.publicKey]; + +// Static tap tree root for testing (32 bytes) +const tapTreeRoot = crypto.createHash('sha256').update('test-tap-tree-root').digest(); + +// Static transaction hash for testing (32 bytes) +const txHash = crypto.createHash('sha256').update('test-transaction-hash').digest(); + +// Helper to create deterministic session IDs for nonce generation +// Session IDs must be unique per signing session to ensure nonce uniqueness +function getSessionId(n: number): Buffer { + return Buffer.alloc(32, n); +} + +// normalize buffers to hex +function toFixture(obj: unknown): unknown { + if (obj === null || obj === undefined) { + return obj; + } + if (typeof obj === 'bigint') { + return obj.toString(); + } + if (Buffer.isBuffer(obj)) { + return obj.toString('hex'); + } + if (Array.isArray(obj)) { + return obj.map(toFixture); + } + if (typeof obj === 'object') { + return Object.fromEntries( + Object.entries(obj).flatMap(([key, value]) => (value === undefined ? [] : [[key, toFixture(value)]])) + ); + } + return obj; +} + +async function assertEqualsFixture(value: T, fixtureName: string): Promise { + const normalized = toFixture(value); + const fixturePath = `${__dirname}/../fixtures/musig2/${fixtureName}.json`; + const fixture = await getFixture(fixturePath, normalized); + assert.deepStrictEqual(normalized, fixture); +} + +describe('Musig2 Methods Fixture Tests', function () { + describe('createTapInternalKey', function () { + it('matches fixture', async function () { + const result = createTapInternalKey(pubKeys); + + await assertEqualsFixture( + { + inputs: { + pubKeys, + }, + output: { + tapInternalKey: result, + }, + }, + 'createTapInternalKey' + ); + }); + }); + + describe('createTapOutputKey', function () { + it('matches fixture', async function () { + const internalPubKey = createTapInternalKey(pubKeys); + const result = createTapOutputKey(internalPubKey, tapTreeRoot); + + await assertEqualsFixture( + { + inputs: { + internalPubKey, + tapTreeRoot, + }, + output: { + tapOutputKey: result, + }, + }, + 'createTapOutputKey' + ); + }); + }); + + describe('createTapTweak', function () { + it('matches fixture', async function () { + const tapInternalKey = createTapInternalKey(pubKeys); + const result = createTapTweak(tapInternalKey, tapTreeRoot); + + await assertEqualsFixture( + { + inputs: { + tapInternalKey, + tapMerkleRoot: tapTreeRoot, + }, + output: { + tapTweak: result, + }, + }, + 'createTapTweak' + ); + }); + }); + + describe('createAggregateNonce', function () { + it('matches fixture', async function () { + // Generate deterministic nonces using the nonce store + const nonceStore = new Musig2NonceStore(); + const internalPubKey = createTapInternalKey(pubKeys); + const tapOutputKey = createTapOutputKey(internalPubKey, tapTreeRoot); + + const pubNonce1 = nonceStore.createMusig2Nonce( + walletKeys.user.privateKey as Buffer, + walletKeys.user.publicKey, + tapOutputKey, + txHash, + getSessionId(1) + ); + + const pubNonce2 = nonceStore.createMusig2Nonce( + walletKeys.backup.privateKey as Buffer, + walletKeys.backup.publicKey, + tapOutputKey, + txHash, + getSessionId(2) + ); + + const pubNonces: Tuple = [Buffer.from(pubNonce1), Buffer.from(pubNonce2)]; + const result = createAggregateNonce(pubNonces); + + await assertEqualsFixture( + { + inputs: { + pubNonces, + }, + output: { + aggregateNonce: result, + }, + }, + 'createAggregateNonce' + ); + }); + }); + + describe('createMusig2SigningSession', function () { + it('matches fixture', async function () { + const nonceStore = new Musig2NonceStore(); + const internalPubKey = createTapInternalKey(pubKeys); + const tapOutputKey = createTapOutputKey(internalPubKey, tapTreeRoot); + + const pubNonce1 = nonceStore.createMusig2Nonce( + walletKeys.user.privateKey as Buffer, + walletKeys.user.publicKey, + tapOutputKey, + txHash, + getSessionId(1) + ); + + const pubNonce2 = nonceStore.createMusig2Nonce( + walletKeys.backup.privateKey as Buffer, + walletKeys.backup.publicKey, + tapOutputKey, + txHash, + getSessionId(2) + ); + + const pubNonces: Tuple = [Buffer.from(pubNonce1), Buffer.from(pubNonce2)]; + + const sessionKey = createMusig2SigningSession({ + pubNonces, + txHash, + pubKeys, + internalPubKey, + tapTreeRoot, + }); + + await assertEqualsFixture( + { + inputs: { + pubNonces, + txHash, + pubKeys, + internalPubKey, + tapTreeRoot, + }, + output: { + sessionKey: { + aggNonce: Buffer.from(sessionKey.aggNonce), + msg: Buffer.from(sessionKey.msg), + publicKey: Buffer.from(sessionKey.publicKey), + }, + }, + }, + 'createMusig2SigningSession' + ); + }); + }); + + describe('musig2PartialSign and musig2PartialSigVerify', function () { + it('matches fixture for partial signing and verification', async function () { + const nonceStore = new Musig2NonceStore(); + const internalPubKey = createTapInternalKey(pubKeys); + const tapOutputKey = createTapOutputKey(internalPubKey, tapTreeRoot); + + const pubNonce1 = nonceStore.createMusig2Nonce( + walletKeys.user.privateKey as Buffer, + walletKeys.user.publicKey, + tapOutputKey, + txHash, + getSessionId(1) + ); + + const pubNonce2 = nonceStore.createMusig2Nonce( + walletKeys.backup.privateKey as Buffer, + walletKeys.backup.publicKey, + tapOutputKey, + txHash, + getSessionId(2) + ); + + const pubNonces: Tuple = [Buffer.from(pubNonce1), Buffer.from(pubNonce2)]; + + const sessionKey = createMusig2SigningSession({ + pubNonces, + txHash, + pubKeys, + internalPubKey, + tapTreeRoot, + }); + + // Sign with user key + const partialSig1 = musig2PartialSign( + walletKeys.user.privateKey as Buffer, + Buffer.from(pubNonce1), + sessionKey, + nonceStore + ); + + // Sign with backup key + const partialSig2 = musig2PartialSign( + walletKeys.backup.privateKey as Buffer, + Buffer.from(pubNonce2), + sessionKey, + nonceStore + ); + + // Verify signatures + const isValid1 = musig2PartialSigVerify(partialSig1, pubKeys[0], Buffer.from(pubNonce1), sessionKey); + + const isValid2 = musig2PartialSigVerify(partialSig2, pubKeys[1], Buffer.from(pubNonce2), sessionKey); + + await assertEqualsFixture( + { + inputs: { + privateKeys: [walletKeys.user.privateKey, walletKeys.backup.privateKey], + pubNonces, + pubKeys, + txHash, + internalPubKey, + tapTreeRoot, + }, + output: { + partialSigs: [partialSig1, partialSig2], + verificationResults: [isValid1, isValid2], + }, + }, + 'musig2PartialSignAndVerify' + ); + + // Assert all signatures are valid + assert.strictEqual(isValid1, true); + assert.strictEqual(isValid2, true); + }); + }); + + describe('musig2AggregateSigs', function () { + it('matches fixture', async function () { + const nonceStore = new Musig2NonceStore(); + const internalPubKey = createTapInternalKey(pubKeys); + const tapOutputKey = createTapOutputKey(internalPubKey, tapTreeRoot); + + const pubNonce1 = nonceStore.createMusig2Nonce( + walletKeys.user.privateKey as Buffer, + walletKeys.user.publicKey, + tapOutputKey, + txHash, + getSessionId(1) + ); + + const pubNonce2 = nonceStore.createMusig2Nonce( + walletKeys.backup.privateKey as Buffer, + walletKeys.backup.publicKey, + tapOutputKey, + txHash, + getSessionId(2) + ); + + const pubNonces: Tuple = [Buffer.from(pubNonce1), Buffer.from(pubNonce2)]; + + const sessionKey = createMusig2SigningSession({ + pubNonces, + txHash, + pubKeys, + internalPubKey, + tapTreeRoot, + }); + + // Create partial signatures + const partialSig1 = musig2PartialSign( + walletKeys.user.privateKey as Buffer, + Buffer.from(pubNonce1), + sessionKey, + nonceStore + ); + + const partialSig2 = musig2PartialSign( + walletKeys.backup.privateKey as Buffer, + Buffer.from(pubNonce2), + sessionKey, + nonceStore + ); + + // Aggregate signatures + const aggregatedSig = musig2AggregateSigs([partialSig1, partialSig2], sessionKey); + + // Verify the aggregated signature against the tap output key + const isValidAggregated = ecc.verifySchnorr(txHash, tapOutputKey, aggregatedSig); + + await assertEqualsFixture( + { + inputs: { + partialSigs: [partialSig1, partialSig2], + pubKeys, + txHash, + internalPubKey, + tapTreeRoot, + }, + output: { + aggregatedSig, + tapOutputKey, + isValidAggregated, + }, + }, + 'musig2AggregateSigs' + ); + + // Assert the aggregated signature is valid + assert.strictEqual(isValidAggregated, true); + }); + }); + + describe('Full signing flow', function () { + it('matches fixture for complete signing process', async function () { + const nonceStore = new Musig2NonceStore(); + + // Step 1: Create tap keys first + const internalPubKey = createTapInternalKey(pubKeys); + const tapOutputKey = createTapOutputKey(internalPubKey, tapTreeRoot); + const tapTweak = createTapTweak(internalPubKey, tapTreeRoot); + + // Step 2: Generate nonces + const pubNonce1 = nonceStore.createMusig2Nonce( + walletKeys.user.privateKey as Buffer, + walletKeys.user.publicKey, + tapOutputKey, + txHash, + getSessionId(1) + ); + + const pubNonce2 = nonceStore.createMusig2Nonce( + walletKeys.backup.privateKey as Buffer, + walletKeys.backup.publicKey, + tapOutputKey, + txHash, + getSessionId(2) + ); + + const pubNonces: Tuple = [Buffer.from(pubNonce1), Buffer.from(pubNonce2)]; + + // Step 3: Create aggregate nonce + const aggregateNonce = createAggregateNonce(pubNonces); + + // Step 4: Create signing session + const sessionKey = createMusig2SigningSession({ + pubNonces, + txHash, + pubKeys, + internalPubKey, + tapTreeRoot, + }); + + // Step 5: Create partial signatures + const partialSig1 = musig2PartialSign( + walletKeys.user.privateKey as Buffer, + Buffer.from(pubNonce1), + sessionKey, + nonceStore + ); + + const partialSig2 = musig2PartialSign( + walletKeys.backup.privateKey as Buffer, + Buffer.from(pubNonce2), + sessionKey, + nonceStore + ); + + // Step 6: Verify partial signatures + const isValid1 = musig2PartialSigVerify(partialSig1, pubKeys[0], Buffer.from(pubNonce1), sessionKey); + const isValid2 = musig2PartialSigVerify(partialSig2, pubKeys[1], Buffer.from(pubNonce2), sessionKey); + + // Step 7: Aggregate signatures + const aggregatedSig = musig2AggregateSigs([partialSig1, partialSig2], sessionKey); + + // Step 8: Verify final signature + const isValidFinal = ecc.verifySchnorr(txHash, tapOutputKey, aggregatedSig); + + await assertEqualsFixture( + { + staticInputs: { + pubKeys, + privateKeys: [walletKeys.user.privateKey, walletKeys.backup.privateKey], + txHash, + tapTreeRoot, + }, + step1_tapKeys: { + internalPubKey, + tapOutputKey, + tapTweak, + }, + step2_nonces: { + pubNonce1: Buffer.from(pubNonce1), + pubNonce2: Buffer.from(pubNonce2), + }, + step3_aggregateNonce: { + aggregateNonce, + }, + step4_sessionKey: { + aggNonce: Buffer.from(sessionKey.aggNonce), + msg: Buffer.from(sessionKey.msg), + publicKey: Buffer.from(sessionKey.publicKey), + }, + step5_partialSigs: { + partialSig1, + partialSig2, + }, + step6_verification: { + isValid1, + isValid2, + }, + step7_aggregation: { + aggregatedSig, + }, + step8_finalVerification: { + isValidFinal, + }, + }, + 'fullSigningFlow' + ); + + // Assert everything is valid + assert.strictEqual(isValid1, true); + assert.strictEqual(isValid2, true); + assert.strictEqual(isValidFinal, true); + }); + }); +});