Repository: rust-bitcoin/rust-bitcoin
Pull request #4440 (2025-05-04 22:21:08)

Add support for de/serializing PSBT_{IN,OUT}_MUSIG2_PARTICIPANT_PUBKEYS
This change adds support for serializing and deserializing two PSBT keys from BIP-373: `PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS` and `PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS`

This is a part of #4207 that can be implemented independently of the rest (which depends on https://github.com/rust-bitcoin/rust-secp256k1/pull/716). I believe this satisfactorily avoids changing things multiple times on end users, *however* it's not *completely* transparent to end users, since any code that currently accesses these fields through `unknown` will need to be updated. Later, when `PSBT_IN_MUSIG2_PUB_NONCE` and `PSBT_IN_MUSIG2_PARTIAL_SIG` are supported, code will need to be updated a second time to retrieve them from the correct place instead of `unknown`. I'm of the opinion that this imposes a very minor maintenance burden, only consisting of *removing* deserialization code.

### Notes/Requests for feedback

- For the most part I used my judgement rather than `cargo fmt` since `cargo fmt` already had a lot of other complaints, but of course I'll update if I need to.
- To satisfy the requirement that every commit pass tests, the commit updating the psbt serde regression test should probably be squashed into the first commit, but I just wanted to confirm that before I did it. I suppose similarly, the test commit could be squashed as well?
- I waffled between `musig2_participants` and `musig2_participant_pubkeys`, but I've decided to go with `musig2_participant_pubkeys` because that is consistent with Bitcoin Core

github-actions[bot]: :rotating_light: API BREAKING CHANGE DETECTED

To see the changes click details on "Check semver breaks / PR Semver - stable toolchain" job then expand "Run semver checker script" and scroll to the end of the section.
coveralls: ## Pull Request Test Coverage Report for [Build 14825732342](https://coveralls.io/builds/73504681)


### Details

* **59** of **59**   **(100.0%)**  changed or added relevant lines in **4** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.03%**) to **84.317%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/73504681/badge)](https://coveralls.io/builds/73504681) |
| :-- | --: |
| Change from base [Build 14815517035](https://coveralls.io/builds/73499872): |  0.03% |
| Covered Lines: | 23026 |
| Relevant Lines: | 27309 |

---
##### 💛  - [Coveralls](https://coveralls.io)

github-actions[bot]: :rotating_light: API BREAKING CHANGE DETECTED

To see the changes click details on "Check semver breaks / PR Semver - stable toolchain" job then expand "Run semver checker script" and scroll to the end of the section.
apoelstra: The code in 71dd95feb977642f3d9e23d91c3ec9675d66e997 looks good to me. But can you explain why we need to change the regression test?
Ademan: > The code in [71dd95f](https://github.com/rust-bitcoin/rust-bitcoin/commit/71dd95feb977642f3d9e23d91c3ec9675d66e997) looks good to me. But can you explain why we need to change the regression test?

Sure, both `psbt::Input` and `psbt::Output` both have derived `serde::Serialize` and `serde::Deserialize` implementations. Since this change adds a new field to both `Input` and `Output` the result of serde serializing a `Psbt` changed, which caused the regression test to fail.

I read up a bit on the bincode format used in the test and can 80% explain the change in data. [bincode](https://github.com/bincode-org/bincode/blob/trunk/docs/spec.md#intencoding) serializes maps (such as the added `musig2_participants` field) as an array of pairs. Arrays are encoded as a count, followed by the items. Lengths, by default, in bincode are encoded as u64s. In the regression test, `musig2_participants` was empty, therefore it was serialized (both on `psbt::Input` and `psbt::Output`) as just 0x0000000000000000. If you use a binary diff tool you'll see that in two places, 8 new bytes of 0 are inserted, assuredly one for the single input, and one for the single output in the test. This is the only change to the serialized psbt. The only thing I can't explain is the exact location of these new 0 bytes, since I was already satisfied by my understanding of the change in serialization.

Maybe there's some merit in using a human readable regression test, come to think of it, although in serde that would exercise a different code path.

I have to admit I'm slightly unsure why Psbts have a serde serialization at all, since Psbts have "canonical" binary and string (base64) serializations already, but I suppose it can't hurt? 00c34663638a28cbae534a8f2effe4d64bc5565e doesn't seem to provide much clues.
apoelstra: I believe the serde serialization is there for JSON encoding over RPC.

If we have a binary serialization format it ought to satisfy the same forward-compat guarantee that the standard base64 encoding does ... when we add fields it shouldn't break.

cc @tcharding should we just delete the serde serialization? Do you use it in corepc?
tcharding: >I waffled between musig2_participants and musig2_participant_pubkeys, but I've decided to go with musig2_participant_pubkeys because that is consistent with Bitcoin Core

Why is it a separate patch? 
tcharding: > I changed my mind on the field naming, Bitcoin Core uses `musig2_participant_pubkeys` so I think that settles the naming question in favor of `musig2_participant_pubkeys`

Please squash this into the initial patch so the rename is no longer part of the PR.
tcharding: > should we just delete the serde serialization?

Yes I think so. 

 Do you use it in corepc?

To the best of my knowledge Core RPC accepts PSBTs as base64 strings (produced by `Display` impl on `Psbt`). I actually got bitten by this when I tried to pass in a serde serialization of a PSBT. It took me a minute to work out why.
tcharding: If the test code was removed this would be a trivial PR. I re-read #4207 and if the decision was to hold off on adding the fields I don't know what is the motivation for this PR - I only re-read 4207 after reviewing this when I wondered "why not add all 4 fields". I have no opinion on whether to add all 4 fields or not but I don't see any reason to add just 2 of them.
Ademan: > If the test code was removed this would be a trivial PR. I re-read #4207 and if the decision was to hold off on adding the fields I don't know what is the motivation for this PR - I only re-read 4207 after reviewing this when I wondered "why not add all 4 fields". I have no opinion on whether to add all 4 fields or not but I don't see any reason to add just 2 of them.

Maybe it's weak motivation, but I figured two is better than none, especially because merging of the secp changes seemed potentially very far off (until now). I'm currently doing this de/serialization outside of rust-bitcoin and the less I need to do there, the better.

I'm OK reworking this to:

1. De/serialize the pubnonce and partial signature fields
2. Update tests to use BIP-provided vectors

Would it be better to make a new PR, or repurpose this one? Obviously the reworked PR wouldn't be mergeable until the secp changes are merged, and in a secp release, and rust-bitcoin updates to the new version (which is fine, I'm not as impatient as this PR might suggest, I am just trying to get the ball rolling on what I could).
apoelstra: Definite concept ACK adding two fields that have a simple serialization and standard names. It reduces ad-hoc code and aids debuggability for users.

Also concept ACK adding tests we can't read .... such tests helped us find a problem with our serde serialization *in this very PR*.

Sounds like we want to delete the serde impls for `Psbt` though. I don't mind doing that in this PR.
tcharding: > Definite concept ACK adding two fields that have a simple serialization and standard names. It reduces ad-hoc code and aids debuggability for users.
> 
> Also concept ACK adding tests we can't read .... such tests helped us find a problem with our serde serialization _in this very PR_.

No sweat, I'm good to help review it then.

> Sounds like we want to delete the serde impls for `Psbt` though. I don't mind doing that in this PR.

Can we have the 'remove serde from PSBT' as a separate PR so that later on we have an easy PR to find and point at? 
Ademan: Ok, squashed the field rename, changed the deserialization to eliminate the dubious error variant choice.

* Should I squash the regression test binary update into the first commit? (Otherwise the first commit will fail tests, contribution guide says every individual commit should pass tests)
* Should I squash my *new* test into the first commit?
  * Is it helpful at all if I publish the code I used to generate the test vectors? It's kinda janky and embarrassing code I just hacked into a test I had written for my own purposes, but I'm happy to if you'd like to see it.

I also think having a separate PR for removing the Psbt serde de/serialization probably makes sense, I have this gut feeling somebody *somewhere* is relying on it so it could end up being a problem.
github-actions[bot]: :rotating_light: API BREAKING CHANGE DETECTED

To see the changes click details on "Check semver breaks / PR Semver - stable toolchain" job then expand "Run semver checker script" and scroll to the end of the section.
Ademan: It does look like serde Psbt de/serialization gets exercised by the derived implementation here https://github.com/rust-bitcoin/corepc/blob/master/types/src/model/raw_transactions.rs#L82 and possibly elsewhere.

Does it make sense, rather than removing the support for serde Psbt, that the regression test be changed to use json, since that's what corepc actually uses?

I'm actually not sure if changing the regression test to json will even address the problem of adding fields breaking the test. At least the updated test vector would be human readable, otoh it'd be sensitive to serde_json changing anything about its serialization.
tcharding: Oh you are correct, if we want to support serde traits for model types that include a `Psbt` we can still do so without support from `rust-bitcoin`. I just remembered we have #3454 that we might want to do instead of removing them. That would make life easier in `corepc-types`.
apoelstra: >I'm actually not sure if changing the regression test to json will even address the problem of adding fields breaking the test

No, it wouldn't ... it would hide the issue, but it would still be true that we expose a "binary serde encoding" of PSBT and that this PR would break it.

I am tempted to move away from this and just make the serde encoding just match the standard base64 encoding (maybe for the non-human-readable one we'd just encode raw bytes ... though maybe we should use base64 even there).

Alternately maybe corepc should be updated to not use this.
Ademan: I'm a bit confused by the idea of making serde de/serialization use base64, isn't corepc using it to parse the result of (in this example) `decodepsbt`? It's kind of a nonsense RPC for any `rust-bitcoin` adjacent software to call though, and afaict `decodepsbt` is unique in actually breaking down the psbt into json, the rest do use base64 (I think).

For instance https://github.com/rust-bitcoin/corepc/blob/master/types/src/model/raw_transactions.rs#L60 seems wrong right now since the response for `combinepsbt` is base64 (unless I'm missing some context). I had some trouble running the tests and didn't want to take too long of a detour there.

If `decodepsbt` is actually unique in using this broken down json format, and we agree it's kind of nonsensical to use generally, it could even make sense to have the DeserializePsbt field be a serde_json::Value instead of a `Psbt` ?

I'm trying to think of a case someone would actually call `decodepsbt` using `corepc` and there really aren't any, but to the extent any exist, maybe returning the json verbatim is the right thing to do?
tcharding: > I'm a bit confused by the idea of making serde de/serialization use base64, isn't corepc using it to parse the result of (in this example) decodepsbt

No its not. In `corepc-types`, in relation to PSBTS, the only use of `serde` stuff is to derive traits on structs that include a `Psbt`. And these structs are not directly passed to or from Core. We provide the `serde` trait impls only so that folk can use the `model` types more ergonomically (e.g., if they want to throw them in a database).

That is why I originally claimed `corepc` does not require `Psbt` to implement `serde` traits.
tcharding: This discussion is unrelated to this PR, right? We should discuss it over here #3454

Patch:
diff --git a/bitcoin/src/blockdata/transaction.rs b/bitcoin/src/blockdata/transaction.rs
index b521f2cd..9cc1c593 100644
--- a/bitcoin/src/blockdata/transaction.rs
+++ b/bitcoin/src/blockdata/transaction.rs
@@ -928,7 +928,7 @@ pub const fn predict_weight_from_slices(
 /// This helper type collects information about an input to be used in [`predict_weight`] function.
 /// It can only be created using the [`new`](InputWeightPrediction::new) function or using other
 /// associated constants/methods.
-#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+#[derive(Copy, Clone, Debug)]
 pub struct InputWeightPrediction {
     script_size: usize,
     witness_size: usize,
diff --git a/bitcoin/src/psbt/map/input.rs b/bitcoin/src/psbt/map/input.rs
index 287c7f36..2422c57d 100644
--- a/bitcoin/src/psbt/map/input.rs
+++ b/bitcoin/src/psbt/map/input.rs
@@ -59,6 +59,8 @@
 const PSBT_IN_TAP_INTERNAL_KEY: u64 = 0x17;
 /// Type: Taproot Merkle Root PSBT_IN_TAP_MERKLE_ROOT = 0x18
 const PSBT_IN_TAP_MERKLE_ROOT: u64 = 0x18;
+/// Type: MuSig2 Public Keys Participating in Aggregate Input PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS = 0x1a
+const PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS: u64 = 0x1a;
 /// Type: Proprietary Use Type PSBT_IN_PROPRIETARY = 0xFC
 const PSBT_IN_PROPRIETARY: u64 = 0xFC;
 
@@ -113,6 +115,8 @@ pub struct Input {
     pub tap_internal_key: Option<XOnlyPublicKey>,
     /// Taproot Merkle root.
     pub tap_merkle_root: Option<TapNodeHash>,
+    /// Mapping from MuSig2 aggregate keys to the participant keys from which they were aggregated.
+    pub musig2_participant_pubkeys: BTreeMap<secp256k1::PublicKey, Vec<secp256k1::PublicKey>>,
     /// Proprietary key-value pairs for this input.
     pub proprietary: BTreeMap<raw::ProprietaryKey, Vec<u8>>,
     /// Unknown key-value pairs for this input.
@@ -352,6 +356,11 @@ pub(super) fn insert_pair(&mut self, pair: raw::Pair) -> Result<(), Error> {
                     self.tap_merkle_root <= <raw_key: _>|< raw_value: TapNodeHash>
                 }
             }
+            PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS => {
+                impl_psbt_insert_pair! {
+                    self.musig2_participant_pubkeys <= <raw_key: secp256k1::PublicKey>|< raw_value: Vec<secp256k1::PublicKey> >
+                }
+            }
             PSBT_IN_PROPRIETARY => {
                 let key = raw::ProprietaryKey::try_from(raw_key.clone())?;
                 match self.proprietary.entry(key) {
@@ -390,6 +399,7 @@ pub fn combine(&mut self, other: Self) {
         self.tap_script_sigs.extend(other.tap_script_sigs);
         self.tap_scripts.extend(other.tap_scripts);
         self.tap_key_origins.extend(other.tap_key_origins);
+        self.musig2_participant_pubkeys.extend(other.musig2_participant_pubkeys);
         self.proprietary.extend(other.proprietary);
         self.unknown.extend(other.unknown);
 
@@ -482,6 +492,11 @@ fn get_pairs(&self) -> Vec<raw::Pair> {
         impl_psbt_get_pair! {
             rv.push(self.tap_merkle_root, PSBT_IN_TAP_MERKLE_ROOT)
         }
+
+        impl_psbt_get_pair! {
+            rv.push_map(self.musig2_participant_pubkeys, PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS)
+        }
+
         for (key, value) in self.proprietary.iter() {
             rv.push(raw::Pair { key: key.to_key(), value: value.clone() });
         }
diff --git a/bitcoin/src/psbt/map/output.rs b/bitcoin/src/psbt/map/output.rs
index 7f86bdb6..37e853f8 100644
--- a/bitcoin/src/psbt/map/output.rs
+++ b/bitcoin/src/psbt/map/output.rs
@@ -20,6 +20,8 @@
 const PSBT_OUT_TAP_TREE: u64 = 0x06;
 /// Type: Taproot Key BIP 32 Derivation Path PSBT_OUT_TAP_BIP32_DERIVATION = 0x07
 const PSBT_OUT_TAP_BIP32_DERIVATION: u64 = 0x07;
+/// Type: MuSig2 Public Keys Participating in Aggregate Output PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS = 0x08
+const PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS: u64 = 0x08;
 /// Type: Proprietary Use Type PSBT_IN_PROPRIETARY = 0xFC
 const PSBT_OUT_PROPRIETARY: u64 = 0xFC;
 
@@ -40,6 +42,8 @@ pub struct Output {
     pub tap_tree: Option<TapTree>,
     /// Map of tap root x only keys to origin info and leaf hashes contained in it.
     pub tap_key_origins: BTreeMap<XOnlyPublicKey, (Vec<TapLeafHash>, KeySource)>,
+    /// Mapping from MuSig2 aggregate keys to the participant keys from which they were aggregated.
+    pub musig2_participant_pubkeys: BTreeMap<secp256k1::PublicKey, Vec<secp256k1::PublicKey>>,
     /// Proprietary key-value pairs for this output.
     pub proprietary: BTreeMap<raw::ProprietaryKey, Vec<u8>>,
     /// Unknown key-value pairs for this output.
@@ -90,6 +94,11 @@ pub(super) fn insert_pair(&mut self, pair: raw::Pair) -> Result<(), Error> {
                     self.tap_key_origins <= <raw_key: XOnlyPublicKey>|< raw_value: (Vec<TapLeafHash>, KeySource)>
                 }
             }
+            PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS => {
+                impl_psbt_insert_pair! {
+                    self.musig2_participant_pubkeys <= <raw_key: secp256k1::PublicKey>|< raw_value: Vec<secp256k1::PublicKey> >
+                }
+            }
             _ => match self.unknown.entry(raw_key) {
                 btree_map::Entry::Vacant(empty_key) => {
                     empty_key.insert(raw_value);
@@ -107,6 +116,7 @@ pub fn combine(&mut self, other: Self) {
         self.proprietary.extend(other.proprietary);
         self.unknown.extend(other.unknown);
         self.tap_key_origins.extend(other.tap_key_origins);
+        self.musig2_participant_pubkeys.extend(other.musig2_participant_pubkeys);
 
         combine!(redeem_script, self, other);
         combine!(witness_script, self, other);
@@ -143,6 +153,10 @@ fn get_pairs(&self) -> Vec<raw::Pair> {
             rv.push_map(self.tap_key_origins, PSBT_OUT_TAP_BIP32_DERIVATION)
         }
 
+        impl_psbt_get_pair! {
+            rv.push_map(self.musig2_participant_pubkeys, PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS)
+        }
+
         for (key, value) in self.proprietary.iter() {
             rv.push(raw::Pair { key: key.to_key(), value: value.clone() });
         }
diff --git a/bitcoin/src/psbt/mod.rs b/bitcoin/src/psbt/mod.rs
index 32532281..1c3e7368 100644
--- a/bitcoin/src/psbt/mod.rs
+++ b/bitcoin/src/psbt/mod.rs
@@ -749,11 +749,12 @@ fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Err
 impl<'de> serde::Deserialize<'de> for Psbt {
     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     where
-        D: serde::Deserializer<'de>,
+        D: serde::Deserializer<'de>
     {
         struct Visitor;
 
-        impl<'de> serde::de::Visitor<'de> for Visitor {
+        impl<'de> serde::de::Visitor<'de> for Visitor
+        {
             type Value = Psbt;
 
             fn expecting(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
@@ -761,7 +762,8 @@ fn expecting(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
             }
 
             fn visit_bytes<E: serde::de::Error>(self, bytes: &[u8]) -> Result<Self::Value, E> {
-                Psbt::deserialize(bytes).map_err(|e| serde::de::Error::custom(e))
+                Psbt::deserialize(bytes)
+                    .map_err(|e| serde::de::Error::custom(e))
             }
 
             fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<Self::Value, E> {
@@ -898,11 +900,11 @@ fn get_key<C: Signing>(
             KeyRequest::XOnlyPubkey(xonly) => {
                 let pubkey_even = xonly.public_key(secp256k1::Parity::Even);
                 let key = self.get(&pubkey_even).cloned();
-              
+               
                 if key.is_some() {
                     return Ok(key);
                 }
-              
+               
                 let pubkey_odd = xonly.public_key(secp256k1::Parity::Odd);
                 if let Some(priv_key) = self.get(&pubkey_odd).copied() {
                     let negated_priv_key  = priv_key.negate();
@@ -935,18 +937,18 @@ fn get_key<C: Signing>(
             KeyRequest::XOnlyPubkey(xonly) => Ok(self.get(xonly).cloned()),
             KeyRequest::Pubkey(pk) => {
                 let (xonly, parity) = pk.inner.x_only_public_key();
-              
+               
                 if let Some(mut priv_key) = self.get(&XOnlyPublicKey::from(xonly)).cloned() {
                     let computed_pk = priv_key.public_key(&secp);
                     let (_, computed_parity) = computed_pk.inner.x_only_public_key();
-                  
+                   
                     if computed_parity != parity {
                         priv_key = priv_key.negate();
                     }
-                  
+                   
                     return Ok(Some(priv_key));
                 }
-              
+               
                 Ok(None)
             },
             KeyRequest::Bip32(_) => Err(GetKeyError::NotSupported),
@@ -1339,6 +1341,8 @@ mod tests {
         secp256k1::{All, SecretKey},
     };
 
+    use std::str::FromStr;
+
     use super::*;
     use crate::address::script_pubkey::ScriptExt as _;
     use crate::bip32::ChildNumber;
@@ -1454,15 +1458,17 @@ fn psbt_high_fee_checks() {
             Err(ABSURD_FEE_RATE)
         );
         assert_eq!(
-            psbt.clone().extract_tx_with_fee_rate_limit(JUST_BELOW_ABSURD_FEE_RATE).map_err(|e| {
-                match e {
+            psbt.clone()
+                .extract_tx_with_fee_rate_limit(JUST_BELOW_ABSURD_FEE_RATE)
+                .map_err(|e| match e {
                     ExtractTxError::AbsurdFeeRate { fee_rate, .. } => fee_rate,
                     _ => panic!(""),
-                }
-            }),
+                }),
             Err(ABSURD_FEE_RATE)
         );
-        assert!(psbt.extract_tx_with_fee_rate_limit(ABSURD_FEE_RATE).is_ok());
+        assert!(psbt
+            .extract_tx_with_fee_rate_limit(ABSURD_FEE_RATE)
+            .is_ok());
 
         // Testing that extract_tx will error at 25k sat/vbyte (6250000 sat/kwu)
         assert_eq!(
@@ -2233,6 +2239,49 @@ fn serialize_and_deserialize_proprietary() {
         assert!(!rtt.proprietary.is_empty());
     }
 
+    // Deserialize MuSig2 PSBT participant keys according to BIP-373
+    #[test]
+    fn serialize_and_deserialize_musig2_participants() {
+        // XXX: Does not cover PSBT_IN_MUSIG2_PUB_NONCE, PSBT_IN_MUSIG2_PARTIAL_SIG (yet)
+
+        let expected_in_agg_pk  = secp256k1::PublicKey::from_str("021401301810a46a4e3f39e4603ec228ed301d9f2079767fda758dee7224b32e00").unwrap();
+        let expected_in_pubkeys = vec![
+            secp256k1::PublicKey::from_str("02bebd7a1cef20283444b96e9ce78137e951ce48705390933896311a9abc75736a").unwrap(),
+            secp256k1::PublicKey::from_str("0355212dff7b3d7e8126687a62fd0435a3fb4de56d9af9ae23a1c9ca05b349c8e2").unwrap(),
+        ];
+
+        let expected_out_agg_pk = secp256k1::PublicKey::from_str("0364934a64831bd917a2667b886671650846f021e1c025e4b2bb65e49ab3e7cba5").unwrap();
+
+        let expected_out_pubkeys = vec![
+            secp256k1::PublicKey::from_str("02841d69a8b80ae23a8090e6f3765540ea5efd8c287b1307c983a6e2a3a171b525").unwrap(),
+            secp256k1::PublicKey::from_str("02bad833849a98cdfb0a0749609ddccab16ad54485ecc67f828df4bdc4f2b90d4c").unwrap(),
+        ];
+
+        const PSBT_HEX: &str = "70736274ff01005e02000000017b42be5ea467afe0d0571dc4a91bef97ff9605a590c0b8d5892323946414d1810000000000ffffffff01f0b9f50500000000225120bc7e18f55e2c7a28d78cadac1bc72c248372375d269bafe6b315bc40505d07e5000000000001012b00e1f50500000000225120de564ebf8ff7bd9bb41bd88264c04b1713ebb9dc8df36319091d2eabb16cda6221161401301810a46a4e3f39e4603ec228ed301d9f2079767fda758dee7224b32e000500eb4cbe62211655212dff7b3d7e8126687a62fd0435a3fb4de56d9af9ae23a1c9ca05b349c8e20500755abbf92116bebd7a1cef20283444b96e9ce78137e951ce48705390933896311a9abc75736a05002a33dfd90117201401301810a46a4e3f39e4603ec228ed301d9f2079767fda758dee7224b32e00221a021401301810a46a4e3f39e4603ec228ed301d9f2079767fda758dee7224b32e004202bebd7a1cef20283444b96e9ce78137e951ce48705390933896311a9abc75736a0355212dff7b3d7e8126687a62fd0435a3fb4de56d9af9ae23a1c9ca05b349c8e20001052064934a64831bd917a2667b886671650846f021e1c025e4b2bb65e49ab3e7cba5210764934a64831bd917a2667b886671650846f021e1c025e4b2bb65e49ab3e7cba50500fa4c6afa22080364934a64831bd917a2667b886671650846f021e1c025e4b2bb65e49ab3e7cba54202841d69a8b80ae23a8090e6f3765540ea5efd8c287b1307c983a6e2a3a171b52502bad833849a98cdfb0a0749609ddccab16ad54485ecc67f828df4bdc4f2b90d4c00";
+
+        let psbt = hex_psbt(PSBT_HEX).unwrap();
+
+        assert_eq!(psbt.inputs[0].musig2_participant_pubkeys.len(), 1);
+        assert_eq!(
+            psbt.inputs[0].musig2_participant_pubkeys.iter().next().unwrap(),
+            (&expected_in_agg_pk, &expected_in_pubkeys)
+        );
+
+        assert_eq!(psbt.outputs[0].musig2_participant_pubkeys.len(), 1);
+        assert_eq!(
+            psbt.outputs[0].musig2_participant_pubkeys.iter().next().unwrap(),
+            (&expected_out_agg_pk, &expected_out_pubkeys)
+        );
+
+        // Check round trip de/serialization
+        assert_eq!(psbt.serialize_hex(), PSBT_HEX);
+
+        const PSBT_TRUNCATED_MUSIG_PARTICIPANTS_HEX: &str = "70736274ff01005e0200000001f034711ce319b1db76ce73440f2cb64a7e3a02e75c936b8d8a4958a024ea8d870000000000ffffffff01f0b9f50500000000225120bc7e18f55e2c7a28d78cadac1bc72c248372375d269bafe6b315bc40505d07e5000000000001012b00e1f50500000000225120de564ebf8ff7bd9bb41bd88264c04b1713ebb9dc8df36319091d2eabb16cda6221161401301810a46a4e3f39e4603ec228ed301d9f2079767fda758dee7224b32e000500eb4cbe62211655212dff7b3d7e8126687a62fd0435a3fb4de56d9af9ae23a1c9ca05b349c8e20500755abbf92116bebd7a1cef20283444b96e9ce78137e951ce48705390933896311a9abc75736a05002a33dfd90117201401301810a46a4e3f39e4603ec228ed301d9f2079767fda758dee7224b32e00221a021401301810a46a4e3f39e4603ec228ed301d9f2079767fda758dee7224b32e002a02bebd7a1cef20283444b96e9ce78137e951ce48705390933896311a9abc75736a0355212dff7b3d7e810001052064934a64831bd917a2667b886671650846f021e1c025e4b2bb65e49ab3e7cba5210764934a64831bd917a2667b886671650846f021e1c025e4b2bb65e49ab3e7cba50500fa4c6afa22080364934a64831bd917a2667b886671650846f021e1c025e4b2bb65e49ab3e7cba52a02841d69a8b80ae23a8090e6f3765540ea5efd8c287b1307c983a6e2a3a171b52502bad833849a98cdfb00";
+
+        hex_psbt(PSBT_TRUNCATED_MUSIG_PARTICIPANTS_HEX)
+            .expect_err("Deserializing PSBT with truncated musig participants should error");
+    }
+
     // PSBTs taken from BIP 174 test vectors.
     #[test]
     fn combine_psbts() {
diff --git a/bitcoin/src/psbt/serialize.rs b/bitcoin/src/psbt/serialize.rs
index 8f6193fd..270f8a75 100644
--- a/bitcoin/src/psbt/serialize.rs
+++ b/bitcoin/src/psbt/serialize.rs
@@ -172,6 +172,28 @@ fn deserialize(bytes: &[u8]) -> Result<Self, Error> {
     }
 }
 
+impl Serialize for Vec<secp256k1::PublicKey> {
+    fn serialize(&self) -> Vec<u8> {
+        let mut result: Vec<u8> = Vec::with_capacity(secp256k1::constants::PUBLIC_KEY_SIZE * self.len());
+
+        for pubkey in self.iter() {
+            result.extend(Serialize::serialize(pubkey));
+        }
+
+        result
+    }
+}
+
+impl Deserialize for Vec<secp256k1::PublicKey> {
+    fn deserialize(bytes: &[u8]) -> Result<Self, Error> {
+        bytes.chunks(secp256k1::constants::PUBLIC_KEY_SIZE)
+            .map(|pubkey_bytes| {
+                secp256k1::PublicKey::deserialize(pubkey_bytes)
+            })
+            .collect()
+    }
+}
+
 impl Serialize for ecdsa::Signature {
     fn serialize(&self) -> Vec<u8> { self.to_vec() }
 }
diff --git a/fuzz/fuzz_targets/bitcoin/p2p_address_roundtrip.rs b/fuzz/fuzz_targets/bitcoin/p2p_address_roundtrip.rs
index 669c5ea9..f5dd65a6 100644
--- a/fuzz/fuzz_targets/bitcoin/p2p_address_roundtrip.rs
+++ b/fuzz/fuzz_targets/bitcoin/p2p_address_roundtrip.rs
@@ -19,17 +19,26 @@ fn do_test(data: &[u8]) {
 
     if let Ok(ip_addr) = IpAddr::try_from(addr_v2.clone()) {
         let round_trip: AddrV2 = AddrV2::from(ip_addr);
-        assert_eq!(addr_v2, round_trip, "AddrV2 -> IpAddr -> AddrV2 should round-trip correctly");
+        assert_eq!(
+            addr_v2, round_trip,
+            "AddrV2 -> IpAddr -> AddrV2 should round-trip correctly"
+        );
     }
 
     if let Ok(ip_addr) = Ipv4Addr::try_from(addr_v2.clone()) {
         let round_trip: AddrV2 = AddrV2::from(ip_addr);
-        assert_eq!(addr_v2, round_trip, "AddrV2 -> Ipv4Addr -> AddrV2 should round-trip correctly");
+        assert_eq!(
+            addr_v2, round_trip,
+            "AddrV2 -> Ipv4Addr -> AddrV2 should round-trip correctly"
+        );
     }
 
     if let Ok(ip_addr) = Ipv6Addr::try_from(addr_v2.clone()) {
         let round_trip: AddrV2 = AddrV2::from(ip_addr);
-        assert_eq!(addr_v2, round_trip, "AddrV2 -> Ipv6Addr -> AddrV2 should round-trip correctly");
+        assert_eq!(
+            addr_v2, round_trip,
+            "AddrV2 -> Ipv6Addr -> AddrV2 should round-trip correctly"
+        );
     }
 
     if let Ok(socket_addr) = SocketAddr::try_from(addr_v2.clone()) {
diff --git a/internals/src/array_vec.rs b/internals/src/array_vec.rs
index 98bb3ea8..76d0490d 100644
--- a/internals/src/array_vec.rs
+++ b/internals/src/array_vec.rs
@@ -212,7 +212,7 @@ fn no_out_of_bounds_less_than_cap() {
         }
     }
 
-    #[kani::unwind(16)] // One greater than 15.
+    #[kani::unwind(16)] // One grater than 15.
     #[kani::proof]
     fn no_out_of_bounds_upto_cap() {
         const CAP: usize = 15;
diff --git a/units/src/amount/error.rs b/units/src/amount/error.rs
index e0946d15..fbb1cfdf 100644
--- a/units/src/amount/error.rs
+++ b/units/src/amount/error.rs
@@ -347,6 +347,7 @@ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
 #[cfg(feature = "std")]
 impl std::error::Error for ParseDenominationError {
     fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+
         match *self {
             Self::Unknown(_) | Self::PossiblyConfusing(_) => None,
         }
diff --git a/units/src/locktime/absolute.rs b/units/src/locktime/absolute.rs
index 46e8a740..ee50262e 100644
--- a/units/src/locktime/absolute.rs
+++ b/units/src/locktime/absolute.rs
@@ -377,6 +377,7 @@ fn display(
     ) -> fmt::Result {
         use core::num::IntErrorKind;
 
+
         match self {
             Self::ParseInt(ParseIntError { input, bits: _, is_signed: _, source })
                 if *source.kind() == IntErrorKind::PosOverflow =>
@@ -424,6 +425,7 @@ fn display(
     fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
         use core::num::IntErrorKind;
 
+
         match self {
             Self::ParseInt(ParseIntError { source, .. })
                 if *source.kind() == IntErrorKind::PosOverflow =>


Test patch:
diff --git a/bitcoin/tests/serde.rs b/bitcoin/tests/serde.rs
index dce8faf3..136d7241 100644
--- a/bitcoin/tests/serde.rs
+++ b/bitcoin/tests/serde.rs
@@ -30,7 +30,8 @@
 use bitcoin::hashes::{hash160, ripemd160, sha256, sha256d};
 use bitcoin::hex::FromHex;
 use bitcoin::locktime::{absolute, relative};
-use bitcoin::psbt::{raw, Input, Output, Psbt, PsbtSighashType};
+use bitcoin::psbt::raw;
+use bitcoin::psbt::{Input, Output, Psbt, PsbtSighashType};
 use bitcoin::script::ScriptBufExt as _;
 use bitcoin::sighash::{EcdsaSighashType, TapSighashType};
 use bitcoin::taproot::{self, ControlBlock, LeafVersion, TapTree, TaprootBuilder};