Repository: bitcoin/bitcoin
Pull request #32305: test: add test for decoding PSBT with MuSig2 PSBT key types (BIP 373)
Author: login: theStack
Labels: name: Tests
Created: 2025-04-18T21:35:07Z | Merged: 2025-05-14T20:19:32Z

This PR is a follow-up to #31247 (see https://github.com/bitcoin/bitcoin/pull/31247#pullrequestreview-2427834909) and adds a functional test for decoding PSBTs (using the `decodepsbt` RPC) with MuSig2 per-input and per-output types. The first commit adds the new MuSig2 key types to the test frameworks and extends the PSBT serialization to cope with lists of bytestrings.


Diff:
diff --git a/test/functional/rpc_psbt.py b/test/functional/rpc_psbt.py
index deb519803cf4..e50691f453fd 100755
--- a/test/functional/rpc_psbt.py
+++ b/test/functional/rpc_psbt.py
@@ -29,8 +29,12 @@
     PSBT_IN_SHA256,
     PSBT_IN_HASH160,
     PSBT_IN_HASH256,
+    PSBT_IN_MUSIG2_PARTIAL_SIG,
+    PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS,
+    PSBT_IN_MUSIG2_PUB_NONCE,
     PSBT_IN_NON_WITNESS_UTXO,
     PSBT_IN_WITNESS_UTXO,
+    PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS,
     PSBT_OUT_TAP_TREE,
 )
 from test_framework.script import CScript, OP_TRUE
@@ -199,6 +203,75 @@ def test_input_confs_control(self):
 
         wallet.unloadwallet()
 
+    def test_decodepsbt_musig2_input_output_types(self):
+        self.log.info("Test decoding PSBT with MuSig2 per-input and per-output types")
+        # create 2-of-2 musig2 using fake aggregate key, leaf hash, pubnonce, and partial sig
+        # TODO: actually implement MuSig2 aggregation (for decoding only it doesn't matter though)
+        _, in_pubkey1 = generate_keypair()
+        _, in_pubkey2 = generate_keypair()
+        _, in_fake_agg_pubkey = generate_keypair()
+        fake_leaf_hash = randbytes(32)
+        fake_pubnonce = randbytes(66)
+        fake_partialsig = randbytes(32)
+        tx = CTransaction()
+        tx.vin = [CTxIn(outpoint=COutPoint(hash=int('ee' * 32, 16), n=0), scriptSig=b"")]
+        tx.vout = [CTxOut(nValue=0, scriptPubKey=b"")]
+        psbt = PSBT()
+        psbt.g = PSBTMap({PSBT_GLOBAL_UNSIGNED_TX: tx.serialize()})
+        participant1_keydata = in_pubkey1 + in_fake_agg_pubkey + fake_leaf_hash
+        psbt.i = [PSBTMap({
+                    bytes([PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS]) + in_fake_agg_pubkey: [in_pubkey1, in_pubkey2],
+                    bytes([PSBT_IN_MUSIG2_PUB_NONCE]) + participant1_keydata: fake_pubnonce,
+                    bytes([PSBT_IN_MUSIG2_PARTIAL_SIG]) + participant1_keydata: fake_partialsig,
+                 })]
+        _, out_pubkey1 = generate_keypair()
+        _, out_pubkey2 = generate_keypair()
+        _, out_fake_agg_pubkey = generate_keypair()
+        psbt.o = [PSBTMap({
+                    bytes([PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS]) + out_fake_agg_pubkey: [out_pubkey1, out_pubkey2],
+                 })]
+        res = self.nodes[0].decodepsbt(psbt.to_base64())
+        assert_equal(len(res["inputs"]), 1)
+        res_input = res["inputs"][0]
+        assert_equal(len(res["outputs"]), 1)
+        res_output = res["outputs"][0]
+
+        assert "musig2_participant_pubkeys" in res_input
+        in_participant_pks = res_input["musig2_participant_pubkeys"][0]
+        assert "aggregate_pubkey" in in_participant_pks
+        assert_equal(in_participant_pks["aggregate_pubkey"], in_fake_agg_pubkey.hex())
+        assert "participant_pubkeys" in in_participant_pks
+        assert_equal(in_participant_pks["participant_pubkeys"], [in_pubkey1.hex(), in_pubkey2.hex()])
+
+        assert "musig2_pubnonces" in res_input
+        in_pubnonce = res_input["musig2_pubnonces"][0]
+        assert "participant_pubkey" in in_pubnonce
+        assert_equal(in_pubnonce["participant_pubkey"], in_pubkey1.hex())
+        assert "aggregate_pubkey" in in_pubnonce
+        assert_equal(in_pubnonce["aggregate_pubkey"], in_fake_agg_pubkey.hex())
+        assert "leaf_hash" in in_pubnonce
+        assert_equal(in_pubnonce["leaf_hash"], fake_leaf_hash.hex())
+        assert "pubnonce" in in_pubnonce
+        assert_equal(in_pubnonce["pubnonce"], fake_pubnonce.hex())
+
+        assert "musig2_partial_sigs" in res_input
+        in_partialsig = res_input["musig2_partial_sigs"][0]
+        assert "participant_pubkey" in in_partialsig
+        assert_equal(in_partialsig["participant_pubkey"], in_pubkey1.hex())
+        assert "aggregate_pubkey" in in_partialsig
+        assert_equal(in_partialsig["aggregate_pubkey"], in_fake_agg_pubkey.hex())
+        assert "leaf_hash" in in_partialsig
+        assert_equal(in_partialsig["leaf_hash"], fake_leaf_hash.hex())
+        assert "partial_sig" in in_partialsig
+        assert_equal(in_partialsig["partial_sig"], fake_partialsig.hex())
+
+        assert "musig2_participant_pubkeys" in res_output
+        out_participant_pks = res_output["musig2_participant_pubkeys"][0]
+        assert "aggregate_pubkey" in out_participant_pks
+        assert_equal(out_participant_pks["aggregate_pubkey"], out_fake_agg_pubkey.hex())
+        assert "participant_pubkeys" in out_participant_pks
+        assert_equal(out_participant_pks["participant_pubkeys"], [out_pubkey1.hex(), out_pubkey2.hex()])
+
     def assert_change_type(self, psbtx, expected_type):
         """Assert that the given PSBT has a change output with the given type."""
 
@@ -959,6 +1032,8 @@ def test_psbt_input_keys(psbt_input, keys):
             assert hash.hex() in res_input[preimage_key]
             assert_equal(res_input[preimage_key][hash.hex()], preimage.hex())
 
+        self.test_decodepsbt_musig2_input_output_types()
+
         self.log.info("Test that combining PSBTs with different transactions fails")
         tx = CTransaction()
         tx.vin = [CTxIn(outpoint=COutPoint(hash=int('aa' * 32, 16), n=0), scriptSig=b"")]
diff --git a/test/functional/test_framework/psbt.py b/test/functional/test_framework/psbt.py
index 1eff4a250ee6..4fe688ec01b5 100644
--- a/test/functional/test_framework/psbt.py
+++ b/test/functional/test_framework/psbt.py
@@ -50,6 +50,9 @@
 PSBT_IN_TAP_BIP32_DERIVATION = 0x16
 PSBT_IN_TAP_INTERNAL_KEY = 0x17
 PSBT_IN_TAP_MERKLE_ROOT = 0x18
+PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS = 0x1a
+PSBT_IN_MUSIG2_PUB_NONCE = 0x1b
+PSBT_IN_MUSIG2_PARTIAL_SIG = 0x1c
 PSBT_IN_PROPRIETARY = 0xfc
 
 # per-output types
@@ -61,6 +64,7 @@
 PSBT_OUT_TAP_INTERNAL_KEY = 0x05
 PSBT_OUT_TAP_TREE = 0x06
 PSBT_OUT_TAP_BIP32_DERIVATION = 0x07
+PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS = 0x08
 PSBT_OUT_PROPRIETARY = 0xfc
 
 
@@ -88,6 +92,9 @@ def serialize(self):
         for k,v in self.map.items():
             if isinstance(k, int) and 0 <= k and k <= 255:
                 k = bytes([k])
+            if isinstance(v, list):
+                assert all(type(elem) is bytes for elem in v)
+                v = b"".join(v)  # simply concatenate the byte-strings w/o size prefixes
             m += ser_compact_size(len(k)) + k
             m += ser_compact_size(len(v)) + v
         m += b"\x00"


Reviews:
user: login: rkrux
body: tACK 23ee07dd0f81930f46d2f4910cc92cb0eab61cc3

I believe this is a value add as it sets the base for the MuSig2 functional testing. I left few minor suggestions, I would be quick to re-ack if they are implemented. 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T13:41:42Z
user: login: theStack
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T14:22:20Z
user: login: theStack
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T14:22:40Z
user: login: theStack
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T14:22:45Z
user: login: theStack
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T14:22:51Z
user: login: rkrux
body: re-ACK 4b24186

```
git range-diff 23ee07d...4b24186
```

Thanks for addressing the comments.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T10:01:41Z

Review comments:
diff_hunk: @@ -88,6 +92,9 @@ def serialize(self):
         for k,v in self.map.items():
             if isinstance(k, int) and 0 <= k and k <= 255:
                 k = bytes([k])
+            if isinstance(v, list):
+                assert any(type(elem) is bytes for elem in v)
path: test/functional/test_framework/psbt.py
user: login: rkrux
body: Since this is a new check that has been added & the current use case is of a list of only bytes (in participant pubkeys), maybe we can assert this on all instead of any to keep the checking strict to start with that can be eased in the future if needed?
created_at: 2025-04-21T13:33:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -977,6 +981,74 @@ def test_psbt_input_keys(psbt_input, keys):
             assert hash.hex() in res_input[preimage_key]
             assert_equal(res_input[preimage_key][hash.hex()], preimage.hex())
 
+        self.log.info("Test decoding PSBT with MuSig2 per-input and per-output types")
+        # create 2-of-2 musig2 using fake aggregate key, leaf hash, pubnonce, and partial sig
+        # TODO: actually implement MuSig2 aggregation (for decoding only it doesn't matter though)
+        _, in_pubkey1 = generate_keypair()
+        _, in_pubkey2 = generate_keypair()
+        _, in_fake_agg_pubkey = generate_keypair()
+        fake_leaf_hash = randbytes(32)
+        fake_pubnonce = randbytes(66)
+        fake_partialsig = randbytes(32)
+        tx = CTransaction()
+        tx.vin = [CTxIn(outpoint=COutPoint(hash=int('ee' * 32, 16), n=0), scriptSig=b"")]
+        tx.vout = [CTxOut(nValue=0, scriptPubKey=b"")]
+        psbt = PSBT()
+        psbt.g = PSBTMap({PSBT_GLOBAL_UNSIGNED_TX: tx.serialize()})
+        participant1_keydata = in_pubkey1 + in_fake_agg_pubkey + fake_leaf_hash
+        psbt.i = [PSBTMap({
+                    bytes([PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS]) + in_fake_agg_pubkey: [in_pubkey1, in_pubkey2],
+                    bytes([PSBT_IN_MUSIG2_PUB_NONCE]) + participant1_keydata: fake_pubnonce,
+                    bytes([PSBT_IN_MUSIG2_PARTIAL_SIG]) + participant1_keydata: fake_partialsig,
+                 })]
+        _, out_pubkey1 = generate_keypair()
+        _, out_pubkey2 = generate_keypair()
+        _, out_fake_agg_pubkey = generate_keypair()
+        psbt.o = [PSBTMap({
+                    bytes([PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS]) + out_fake_agg_pubkey: [out_pubkey1, out_pubkey2],
+                 })]
+        res = self.nodes[0].decodepsbt(psbt.to_base64())
+        assert_equal(len(res["inputs"]), 1)
+        res_input = res["inputs"][0]
+        assert_equal(len(res["outputs"]), 1)
+        res_output = res["outputs"][0]
+
+        assert "musig2_participant_pubkeys" in res_input
+        in_participant_pks = res_input["musig2_participant_pubkeys"][0]
+        assert "aggregate_pubkey" in in_participant_pks
+        assert_equal(in_participant_pks["aggregate_pubkey"], in_fake_agg_pubkey.hex())
+        assert "participant_pubkeys" in in_participant_pks
path: test/functional/rpc_psbt.py
user: login: rkrux
body: Are asserts like on this line really needed? If the decoded psbt is malformed then the test would fail in the following assert anyway.
created_at: 2025-04-21T13:34:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -977,6 +981,74 @@ def test_psbt_input_keys(psbt_input, keys):
             assert hash.hex() in res_input[preimage_key]
             assert_equal(res_input[preimage_key][hash.hex()], preimage.hex())
 
+        self.log.info("Test decoding PSBT with MuSig2 per-input and per-output types")
path: test/functional/rpc_psbt.py
user: login: rkrux
body: Nit: Maybe consider putting this in a separate subtest as the current `run_test` is quite long already.
created_at: 2025-04-21T13:35:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -977,6 +981,74 @@ def test_psbt_input_keys(psbt_input, keys):
             assert hash.hex() in res_input[preimage_key]
             assert_equal(res_input[preimage_key][hash.hex()], preimage.hex())
 
+        self.log.info("Test decoding PSBT with MuSig2 per-input and per-output types")
+        # create 2-of-2 musig2 using fake aggregate key, leaf hash, pubnonce, and partial sig
+        # TODO: actually implement MuSig2 aggregation (for decoding only it doesn't matter though)
+        _, in_pubkey1 = generate_keypair()
+        _, in_pubkey2 = generate_keypair()
+        _, in_fake_agg_pubkey = generate_keypair()
+        fake_leaf_hash = randbytes(32)
+        fake_pubnonce = randbytes(66)
+        fake_partialsig = randbytes(32)
+        tx = CTransaction()
+        tx.vin = [CTxIn(outpoint=COutPoint(hash=int('ee' * 32, 16), n=0), scriptSig=b"")]
+        tx.vout = [CTxOut(nValue=0, scriptPubKey=b"")]
+        psbt = PSBT()
+        psbt.g = PSBTMap({PSBT_GLOBAL_UNSIGNED_TX: tx.serialize()})
+        participant1_keydata = in_pubkey1 + in_fake_agg_pubkey + fake_leaf_hash
+        psbt.i = [PSBTMap({
+                    bytes([PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS]) + in_fake_agg_pubkey: [in_pubkey1, in_pubkey2],
+                    bytes([PSBT_IN_MUSIG2_PUB_NONCE]) + participant1_keydata: fake_pubnonce,
+                    bytes([PSBT_IN_MUSIG2_PARTIAL_SIG]) + participant1_keydata: fake_partialsig,
+                 })]
+        _, out_pubkey1 = generate_keypair()
+        _, out_pubkey2 = generate_keypair()
+        _, out_fake_agg_pubkey = generate_keypair()
+        psbt.o = [PSBTMap({
+                    bytes([PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS]) + out_fake_agg_pubkey: [out_pubkey1, out_pubkey2],
+                 })]
+        res = self.nodes[0].decodepsbt(psbt.to_base64())
+        assert_equal(len(res["inputs"]), 1)
+        res_input = res["inputs"][0]
+        assert_equal(len(res["outputs"]), 1)
+        res_output = res["outputs"][0]
+
+        assert "musig2_participant_pubkeys" in res_input
+        in_participant_pks = res_input["musig2_participant_pubkeys"][0]
+        assert "aggregate_pubkey" in in_participant_pks
+        assert_equal(in_participant_pks["aggregate_pubkey"], in_fake_agg_pubkey.hex())
+        assert "participant_pubkeys" in in_participant_pks
+        assert_equal(in_participant_pks["participant_pubkeys"], [in_pubkey1.hex(), in_pubkey2.hex()])
+
+        assert "musig2_pubnonces" in res_input
+        in_pubnonce = res_input["musig2_pubnonces"][0]
+        assert "participant_pubkey" in in_pubnonce
+        assert_equal(in_pubnonce["participant_pubkey"], in_pubkey1.hex())
+        assert "aggregate_pubkey" in in_pubnonce
+        assert_equal(in_pubnonce["aggregate_pubkey"], in_fake_agg_pubkey.hex())
+        assert "leaf_hash" in in_pubnonce
+        assert_equal(in_pubnonce["leaf_hash"], fake_leaf_hash.hex())
+        assert "pubnonce" in in_pubnonce
+        assert_equal(in_pubnonce["pubnonce"], fake_pubnonce.hex())
+
+        assert "musig2_partial_sigs" in res_input
+        in_partialsig = res_input["musig2_partial_sigs"][0]
+        assert "participant_pubkey" in in_partialsig
+        assert_equal(in_partialsig["participant_pubkey"], in_pubkey1.hex())
+        assert "aggregate_pubkey" in in_pubnonce
+        assert_equal(in_partialsig["aggregate_pubkey"], in_fake_agg_pubkey.hex())
+        assert "leaf_hash" in in_partialsig
+        assert_equal(in_partialsig["leaf_hash"], fake_leaf_hash.hex())
+        assert "partial_sig" in in_partialsig
+        assert_equal(in_partialsig["partial_sig"], fake_partialsig.hex())
+
+        assert "musig2_participant_pubkeys" in res_output
+        out_participant_pks = res_output["musig2_participant_pubkeys"][0]
+        assert "aggregate_pubkey" in out_participant_pks
+        assert_equal(out_participant_pks["aggregate_pubkey"], out_fake_agg_pubkey.hex())
+        assert "participant_pubkeys" in out_participant_pks
+        assert_equal(out_participant_pks["participant_pubkeys"], [out_pubkey1.hex(), out_pubkey2.hex()])
path: test/functional/rpc_psbt.py
user: login: rkrux
body: Fine for now but maybe we can consider making an `assert_musig2_fields()` later when more musig2 specific tests are added - if needed though.
created_at: 2025-04-21T13:38:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -88,6 +92,9 @@ def serialize(self):
         for k,v in self.map.items():
             if isinstance(k, int) and 0 <= k and k <= 255:
                 k = bytes([k])
+            if isinstance(v, list):
+                assert any(type(elem) is bytes for elem in v)
path: test/functional/test_framework/psbt.py
user: login: theStack
body: Good point, I agree that the `all` predicate makes much more sense than `any` here, not sure what motivated me to use the latter back then. Fixed.
created_at: 2025-05-07T14:22:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -977,6 +981,74 @@ def test_psbt_input_keys(psbt_input, keys):
             assert hash.hex() in res_input[preimage_key]
             assert_equal(res_input[preimage_key][hash.hex()], preimage.hex())
 
+        self.log.info("Test decoding PSBT with MuSig2 per-input and per-output types")
+        # create 2-of-2 musig2 using fake aggregate key, leaf hash, pubnonce, and partial sig
+        # TODO: actually implement MuSig2 aggregation (for decoding only it doesn't matter though)
+        _, in_pubkey1 = generate_keypair()
+        _, in_pubkey2 = generate_keypair()
+        _, in_fake_agg_pubkey = generate_keypair()
+        fake_leaf_hash = randbytes(32)
+        fake_pubnonce = randbytes(66)
+        fake_partialsig = randbytes(32)
+        tx = CTransaction()
+        tx.vin = [CTxIn(outpoint=COutPoint(hash=int('ee' * 32, 16), n=0), scriptSig=b"")]
+        tx.vout = [CTxOut(nValue=0, scriptPubKey=b"")]
+        psbt = PSBT()
+        psbt.g = PSBTMap({PSBT_GLOBAL_UNSIGNED_TX: tx.serialize()})
+        participant1_keydata = in_pubkey1 + in_fake_agg_pubkey + fake_leaf_hash
+        psbt.i = [PSBTMap({
+                    bytes([PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS]) + in_fake_agg_pubkey: [in_pubkey1, in_pubkey2],
+                    bytes([PSBT_IN_MUSIG2_PUB_NONCE]) + participant1_keydata: fake_pubnonce,
+                    bytes([PSBT_IN_MUSIG2_PARTIAL_SIG]) + participant1_keydata: fake_partialsig,
+                 })]
+        _, out_pubkey1 = generate_keypair()
+        _, out_pubkey2 = generate_keypair()
+        _, out_fake_agg_pubkey = generate_keypair()
+        psbt.o = [PSBTMap({
+                    bytes([PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS]) + out_fake_agg_pubkey: [out_pubkey1, out_pubkey2],
+                 })]
+        res = self.nodes[0].decodepsbt(psbt.to_base64())
+        assert_equal(len(res["inputs"]), 1)
+        res_input = res["inputs"][0]
+        assert_equal(len(res["outputs"]), 1)
+        res_output = res["outputs"][0]
+
+        assert "musig2_participant_pubkeys" in res_input
+        in_participant_pks = res_input["musig2_participant_pubkeys"][0]
+        assert "aggregate_pubkey" in in_participant_pks
+        assert_equal(in_participant_pks["aggregate_pubkey"], in_fake_agg_pubkey.hex())
+        assert "participant_pubkeys" in in_participant_pks
path: test/functional/rpc_psbt.py
user: login: theStack
body: Right, strictly speaking they are not needed. I added them because we seem to do this also in many other functional tests, and I think it's nice to differentiate between "attribute is not available" and "attribute doesn't match the expected value" more explicitly by failing in different lines. Happy to drop them though if others feel strongly.
created_at: 2025-05-07T14:22:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -977,6 +981,74 @@ def test_psbt_input_keys(psbt_input, keys):
             assert hash.hex() in res_input[preimage_key]
             assert_equal(res_input[preimage_key][hash.hex()], preimage.hex())
 
+        self.log.info("Test decoding PSBT with MuSig2 per-input and per-output types")
path: test/functional/rpc_psbt.py
user: login: theStack
body: Good idea, done.
created_at: 2025-05-07T14:22:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -977,6 +981,74 @@ def test_psbt_input_keys(psbt_input, keys):
             assert hash.hex() in res_input[preimage_key]
             assert_equal(res_input[preimage_key][hash.hex()], preimage.hex())
 
+        self.log.info("Test decoding PSBT with MuSig2 per-input and per-output types")
+        # create 2-of-2 musig2 using fake aggregate key, leaf hash, pubnonce, and partial sig
+        # TODO: actually implement MuSig2 aggregation (for decoding only it doesn't matter though)
+        _, in_pubkey1 = generate_keypair()
+        _, in_pubkey2 = generate_keypair()
+        _, in_fake_agg_pubkey = generate_keypair()
+        fake_leaf_hash = randbytes(32)
+        fake_pubnonce = randbytes(66)
+        fake_partialsig = randbytes(32)
+        tx = CTransaction()
+        tx.vin = [CTxIn(outpoint=COutPoint(hash=int('ee' * 32, 16), n=0), scriptSig=b"")]
+        tx.vout = [CTxOut(nValue=0, scriptPubKey=b"")]
+        psbt = PSBT()
+        psbt.g = PSBTMap({PSBT_GLOBAL_UNSIGNED_TX: tx.serialize()})
+        participant1_keydata = in_pubkey1 + in_fake_agg_pubkey + fake_leaf_hash
+        psbt.i = [PSBTMap({
+                    bytes([PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS]) + in_fake_agg_pubkey: [in_pubkey1, in_pubkey2],
+                    bytes([PSBT_IN_MUSIG2_PUB_NONCE]) + participant1_keydata: fake_pubnonce,
+                    bytes([PSBT_IN_MUSIG2_PARTIAL_SIG]) + participant1_keydata: fake_partialsig,
+                 })]
+        _, out_pubkey1 = generate_keypair()
+        _, out_pubkey2 = generate_keypair()
+        _, out_fake_agg_pubkey = generate_keypair()
+        psbt.o = [PSBTMap({
+                    bytes([PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS]) + out_fake_agg_pubkey: [out_pubkey1, out_pubkey2],
+                 })]
+        res = self.nodes[0].decodepsbt(psbt.to_base64())
+        assert_equal(len(res["inputs"]), 1)
+        res_input = res["inputs"][0]
+        assert_equal(len(res["outputs"]), 1)
+        res_output = res["outputs"][0]
+
+        assert "musig2_participant_pubkeys" in res_input
+        in_participant_pks = res_input["musig2_participant_pubkeys"][0]
+        assert "aggregate_pubkey" in in_participant_pks
+        assert_equal(in_participant_pks["aggregate_pubkey"], in_fake_agg_pubkey.hex())
+        assert "participant_pubkeys" in in_participant_pks
+        assert_equal(in_participant_pks["participant_pubkeys"], [in_pubkey1.hex(), in_pubkey2.hex()])
+
+        assert "musig2_pubnonces" in res_input
+        in_pubnonce = res_input["musig2_pubnonces"][0]
+        assert "participant_pubkey" in in_pubnonce
+        assert_equal(in_pubnonce["participant_pubkey"], in_pubkey1.hex())
+        assert "aggregate_pubkey" in in_pubnonce
+        assert_equal(in_pubnonce["aggregate_pubkey"], in_fake_agg_pubkey.hex())
+        assert "leaf_hash" in in_pubnonce
+        assert_equal(in_pubnonce["leaf_hash"], fake_leaf_hash.hex())
+        assert "pubnonce" in in_pubnonce
+        assert_equal(in_pubnonce["pubnonce"], fake_pubnonce.hex())
+
+        assert "musig2_partial_sigs" in res_input
+        in_partialsig = res_input["musig2_partial_sigs"][0]
+        assert "participant_pubkey" in in_partialsig
+        assert_equal(in_partialsig["participant_pubkey"], in_pubkey1.hex())
+        assert "aggregate_pubkey" in in_pubnonce
+        assert_equal(in_partialsig["aggregate_pubkey"], in_fake_agg_pubkey.hex())
+        assert "leaf_hash" in in_partialsig
+        assert_equal(in_partialsig["leaf_hash"], fake_leaf_hash.hex())
+        assert "partial_sig" in in_partialsig
+        assert_equal(in_partialsig["partial_sig"], fake_partialsig.hex())
+
+        assert "musig2_participant_pubkeys" in res_output
+        out_participant_pks = res_output["musig2_participant_pubkeys"][0]
+        assert "aggregate_pubkey" in out_participant_pks
+        assert_equal(out_participant_pks["aggregate_pubkey"], out_fake_agg_pubkey.hex())
+        assert "participant_pubkeys" in out_participant_pks
+        assert_equal(out_participant_pks["participant_pubkeys"], [out_pubkey1.hex(), out_pubkey2.hex()])
path: test/functional/rpc_psbt.py
user: login: theStack
body: Leaving that one for a follow-up.
created_at: 2025-05-07T14:22:51Z
author_association: CONTRIBUTOR

PR comments:
user: login: DrahtBot
created_at: 2025-04-18T21:35:10Z
body: <!--e57a25ab6845829454e8d69fc972939a-->

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

<!--006a51241073e994b41acfe9ec718e94-->
### Code Coverage & Benchmarks
For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/32305.
<!--021abf342d371248e50ceaed478a90ca-->
### Reviews
See [the guideline](https://github.com/bitcoin/bitcoin/blob/master/CONTRIBUTING.md#code-review) for information on the review process.
| Type | Reviewers |
| ---- | --------- |
| ACK | [achow101](https://github.com/bitcoin/bitcoin/pull/32305#issuecomment-2860468523), [rkrux](https://github.com/bitcoin/bitcoin/pull/32305#pullrequestreview-2824580145) |

If your review is incorrectly listed, please react with 👎 to this comment and the bot will ignore it on the next update.
<!--174a7506f384e20aa4161008e828411d-->
### Conflicts
Reviewers, this pull request conflicts with the following ones:

* [#31622](https://github.com/bitcoin/bitcoin/pull/31622) (psbt: add non-default sighash types to PSBTs and unify sighash type match checking by achow101)
* [#29675](https://github.com/bitcoin/bitcoin/pull/29675) (wallet: Be able to receive and spend inputs involving MuSig2 aggregate keys by achow101)

If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.
<!--5faf32d7da4f0f540f40219e4f7537a3-->
author_association: CONTRIBUTOR
user: login: maflcko
created_at: 2025-04-21T12:06:43Z
body: (fresh CI)
author_association: MEMBER
user: login: theStack
created_at: 2025-05-07T14:24:37Z
body: Thanks for the review @rkrux, I rebased on master and addressed most of your review comments.
author_association: CONTRIBUTOR
user: login: theStack
created_at: 2025-05-07T17:33:51Z
body: Force-pushed again, fixing a small bug in an assertion (kudos to LLM linter for detecting this 👌 )
```ranged-diff
    -+        assert "aggregate_pubkey" in in_pubnonce
    ++        assert "aggregate_pubkey" in in_partialsig
     +        assert_equal(in_partialsig["aggregate_pubkey"], in_fake_agg_pubkey.hex())
```
author_association: CONTRIBUTOR
user: login: achow101
created_at: 2025-05-07T21:47:18Z
body: ACK 4b241867567203b204823a4558c2aa5767acf028
author_association: MEMBER