From d0b7525ebee1b4e548f73cdd2d28931c1775e52a Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 8 Nov 2024 13:20:33 +0900 Subject: [PATCH] Implement Musig PSBT (BIP373) --- NBitcoin/BIP174/PSBTInput.cs | 105 +++++++++++++++++- NBitcoin/BIP174/PSBTOutput.cs | 50 ++++++++- NBitcoin/BIP174/PartiallySignedTransaction.cs | 4 + NBitcoin/BIP373/MusigParticipantPubKeys.cs | 56 ++++++++++ NBitcoin/BIP373/MusigTarget.cs | 40 +++++++ NBitcoin/BIP373/PSBTInput.cs | 18 +++ NBitcoin/BIP373/PSBTOutput.cs | 16 +++ NBitcoin/LegacyShims.cs | 6 + NBitcoin/NBitcoin.csproj | 4 +- 9 files changed, 291 insertions(+), 8 deletions(-) create mode 100644 NBitcoin/BIP373/MusigParticipantPubKeys.cs create mode 100644 NBitcoin/BIP373/MusigTarget.cs create mode 100644 NBitcoin/BIP373/PSBTInput.cs create mode 100644 NBitcoin/BIP373/PSBTOutput.cs create mode 100644 NBitcoin/LegacyShims.cs diff --git a/NBitcoin/BIP174/PSBTInput.cs b/NBitcoin/BIP174/PSBTInput.cs index fbc23190e..b2d49f5fa 100644 --- a/NBitcoin/BIP174/PSBTInput.cs +++ b/NBitcoin/BIP174/PSBTInput.cs @@ -9,10 +9,11 @@ using System.Diagnostics.CodeAnalysis; using NBitcoin.Crypto; using System.Text; +using Newtonsoft.Json.Linq; namespace NBitcoin { - public class PSBTInput : PSBTCoin + public partial class PSBTInput : PSBTCoin { // Those fields are not saved, but can be used as hint to solve more info for the PSBT internal Script originalScriptSig = Script.Empty; @@ -159,6 +160,32 @@ internal PSBTInput(BitcoinStream stream, PSBT parent, uint index, TxIn input) : throw new FormatException("Invalid PSBTInput. Unexpected value length for PSBT_IN_TAP_MERKLE_ROOT"); TaprootMerkleRoot = new uint256(v); break; +#if HAS_SPAN + case PSBTConstants.PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS: + if (k.Length != 34) + throw new FormatException("Invalid PSBTInput. Unexpected key length for PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS"); + if (v.Length % 33 != 0 || v.Length == 0) + throw new FormatException("Invalid PSBTInput. Unexpected value length for PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS"); + var pk = NBitcoin.MusigParticipantPubKeys.Parse(k, v); + this.MusigParticipantPubKeys.Add(pk.Aggregated, pk.PubKeys); + break; + case PSBTConstants.PSBT_IN_MUSIG2_PUB_NONCE: + if (k.Length is not (1 + 33 + 33 or 1 + 33 + 33 + 32)) + throw new FormatException("Invalid PSBTInput. Unexpected key length for PSBT_IN_MUSIG2_PUB_NONCE"); + if (k.Length is not 66) + throw new FormatException("Invalid PSBTInput. Unexpected value length for PSBT_IN_MUSIG2_PUB_NONCE"); + var musigNonceKey = MusigTarget.Parse(k); + this.MusigPubNonces.Add(musigNonceKey, v); + break; + case PSBTConstants.PSBT_IN_MUSIG2_PARTIAL_SIG: + if (k.Length is not (1 + 33 + 33 or 1 + 33 + 33 + 32)) + throw new FormatException("Invalid PSBTInput. Unexpected key length for PSBT_IN_MUSIG2_PARTIAL_SIG"); + if (k.Length is not 32) + throw new FormatException("Invalid PSBTInput. Unexpected value length for PSBT_IN_MUSIG2_PARTIAL_SIG"); + var musigSigKey = MusigTarget.Parse(k); + this.MusigPartialSigs.Add(musigSigKey, v); + break; +#endif case PSBTConstants.PSBT_IN_SCRIPTSIG: if (k.Length != 1) throw new FormatException("Invalid PSBTInput. Contains illegal value in key for final scriptsig"); @@ -416,7 +443,14 @@ public void UpdateFrom(PSBTInput other) foreach (var keyPath in other.HDTaprootKeyPaths) HDTaprootKeyPaths.TryAdd(keyPath.Key, keyPath.Value); - +#if HAS_SPAN + foreach (var o in other.MusigParticipantPubKeys) + MusigParticipantPubKeys.TryAdd(o.Key, o.Value); + foreach (var o in other.MusigPartialSigs) + MusigPartialSigs.TryAdd(o.Key, o.Value); + foreach (var o in other.MusigPubNonces) + MusigPubNonces.TryAdd(o.Key, o.Value); +#endif TaprootInternalKey ??= other.TaprootInternalKey; TaprootKeySignature ??= other.TaprootKeySignature; TaprootMerkleRoot ??= other.TaprootMerkleRoot; @@ -728,7 +762,32 @@ public void Serialize(BitcoinStream stream) var value = merkleRoot.ToBytes(); stream.ReadWriteAsVarString(ref value); } - +#if HAS_SPAN + foreach (var mpk in MusigParticipantPubKeys) + { + var key = new byte[] { PSBTConstants.PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS }.Concat(mpk.Key.ToBytes()); + stream.ReadWriteAsVarString(ref key); + foreach (var pk in mpk.Value) + { + var b = pk.ToBytes(); + stream.ReadWriteAsVarString(ref b); + } + } + foreach (var mpn in MusigPubNonces) + { + var key = mpn.Key.ToBytes(PSBTConstants.PSBT_IN_MUSIG2_PUB_NONCE); + stream.ReadWriteAsVarString(ref key); + var v = mpn.Value; + stream.ReadWriteAsVarString(ref v); + } + foreach (var mpn in MusigPartialSigs) + { + var key = mpn.Key.ToBytes(PSBTConstants.PSBT_IN_MUSIG2_PARTIAL_SIG); + stream.ReadWriteAsVarString(ref key); + var v = mpn.Value; + stream.ReadWriteAsVarString(ref v); + } +#endif if (this.TaprootInternalKey is TaprootInternalPubKey tp) { stream.ReadWriteAsVarInt(ref defaultKeyLen); @@ -856,6 +915,7 @@ internal void Write(JsonTextWriter jsonWriter) { jsonWriter.WritePropertyValue("taproot_key_signature", tsig.ToString()); } + jsonWriter.WritePropertyName("partial_signatures"); jsonWriter.WriteStartObject(); foreach (var sig in partial_sigs) @@ -863,6 +923,45 @@ internal void Write(JsonTextWriter jsonWriter) jsonWriter.WritePropertyValue(sig.Key.ToString(), Encoders.Hex.EncodeData(sig.Value.ToBytes())); } jsonWriter.WriteEndObject(); +#if HAS_SPAN + if (MusigParticipantPubKeys.Count != 0) + { + jsonWriter.WritePropertyName("musig_participant_pubkeys"); + jsonWriter.WriteStartObject(); + foreach (var o in MusigParticipantPubKeys) + { + jsonWriter.WritePropertyName(o.Key.ToHex()); + jsonWriter.WriteStartArray(); + foreach (var k in o.Value) + { + jsonWriter.WriteValue(k.ToHex()); + } + jsonWriter.WriteEndArray(); + } + jsonWriter.WriteEndObject(); + } + + if (MusigPartialSigs.Count != 0) + { + jsonWriter.WritePropertyName("musig_partial_signatures"); + jsonWriter.WriteStartObject(); + foreach (var sig in MusigPartialSigs) + { + jsonWriter.WritePropertyValue(Encoders.Hex.EncodeData(sig.Key.ToBytes(0)[1..]), Encoders.Hex.EncodeData(sig.Value)); + } + jsonWriter.WriteEndObject(); + } + if (MusigPartialSigs.Count != 0) + { + jsonWriter.WritePropertyName("musig_pub_nonces"); + jsonWriter.WriteStartObject(); + foreach (var sig in MusigPubNonces) + { + jsonWriter.WritePropertyValue(Encoders.Hex.EncodeData(sig.Key.ToBytes(0)[1..]), Encoders.Hex.EncodeData(sig.Value)); + } + jsonWriter.WriteEndObject(); + } +#endif if (sighash_type is uint s) jsonWriter.WritePropertyValue("sighash", GetName(s)); if (this.FinalScriptSig != null) diff --git a/NBitcoin/BIP174/PSBTOutput.cs b/NBitcoin/BIP174/PSBTOutput.cs index a1457843c..f7a1e55bd 100644 --- a/NBitcoin/BIP174/PSBTOutput.cs +++ b/NBitcoin/BIP174/PSBTOutput.cs @@ -15,7 +15,7 @@ namespace NBitcoin { - public class PSBTOutput : PSBTCoin + public partial class PSBTOutput : PSBTCoin { internal TxOut TxOut { get; } public Script ScriptPubKey => TxOut.ScriptPubKey; @@ -108,9 +108,19 @@ internal PSBTOutput(BitcoinStream stream, PSBT parent, uint index, TxOut txOut) throw new FormatException("Invalid PSBTOutput. Contains invalid internal taproot pubkey"); TaprootInternalKey = tpk; break; +#if HAS_SPAN + case PSBTConstants.PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS: + if (k.Length != 34) + throw new FormatException("Invalid PSBTOutput. Unexpected key length for PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS"); + if (v.Length % 33 != 0 || v.Length == 0) + throw new FormatException("Invalid PSBTOutput. Unexpected value length for PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS"); + var pk = NBitcoin.MusigParticipantPubKeys.Parse(k, v); + this.MusigParticipantPubKeys.Add(pk.Aggregated, pk.PubKeys); + break; +#endif default: if (unknown.ContainsKey(k)) - throw new FormatException("Invalid PSBTInput, duplicate key for unknown value"); + throw new FormatException("Invalid PSBTOutput, duplicate key for unknown value"); unknown.Add(k, v); break; } @@ -137,6 +147,11 @@ public void UpdateFrom(PSBTOutput other) foreach (var uk in other.Unknown) unknown.TryAdd(uk.Key, uk.Value); + +#if HAS_SPAN + foreach (var o in other.MusigParticipantPubKeys) + MusigParticipantPubKeys.TryAdd(o.Key, o.Value); +#endif } #region IBitcoinSerializable Members @@ -194,7 +209,18 @@ public void Serialize(BitcoinStream stream) b = ((MemoryStream)bs.Inner).ToArrayEfficient(); stream.ReadWriteAsVarString(ref b); } - +#if HAS_SPAN + foreach (var mpk in MusigParticipantPubKeys) + { + var key = new byte[] { PSBTConstants.PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS }.Concat(mpk.Key.ToBytes()); + stream.ReadWriteAsVarString(ref key); + foreach (var pk in mpk.Value) + { + var b = pk.ToBytes(); + stream.ReadWriteAsVarString(ref b); + } + } +#endif foreach (var entry in unknown) { var k = entry.Key; @@ -277,6 +303,24 @@ internal void Write(JsonTextWriter jsonWriter) { jsonWriter.WritePropertyValue("witness_script", witness_script.ToString()); } +#if HAS_SPAN + if (MusigParticipantPubKeys.Count != 0) + { + jsonWriter.WritePropertyName("musig_participant_pubkeys"); + jsonWriter.WriteStartObject(); + foreach (var o in MusigParticipantPubKeys) + { + jsonWriter.WritePropertyName(o.Key.ToHex()); + jsonWriter.WriteStartArray(); + foreach (var k in o.Value) + { + jsonWriter.WriteValue(k.ToHex()); + } + jsonWriter.WriteEndArray(); + } + jsonWriter.WriteEndObject(); + } +#endif jsonWriter.WriteBIP32Derivations(this.hd_keypaths); jsonWriter.WriteBIP32Derivations(this.hd_taprootkeypaths); jsonWriter.WriteEndObject(); diff --git a/NBitcoin/BIP174/PartiallySignedTransaction.cs b/NBitcoin/BIP174/PartiallySignedTransaction.cs index 59690fb22..ea5c0139d 100644 --- a/NBitcoin/BIP174/PartiallySignedTransaction.cs +++ b/NBitcoin/BIP174/PartiallySignedTransaction.cs @@ -58,6 +58,10 @@ static PSBTConstants() public const byte PSBT_IN_TAP_BIP32_DERIVATION = 0x16; public const byte PSBT_OUT_TAP_BIP32_DERIVATION = 0x07; public const byte PSBT_IN_TAP_MERKLE_ROOT = 0x18; + public const byte PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS = 0x1a; + public const byte PSBT_IN_MUSIG2_PUB_NONCE = 0x1b; + public const byte PSBT_IN_MUSIG2_PARTIAL_SIG = 0x1c; + public const byte PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS = 0x08; // Output types public const byte PSBT_OUT_REDEEMSCRIPT = 0x00; diff --git a/NBitcoin/BIP373/MusigParticipantPubKeys.cs b/NBitcoin/BIP373/MusigParticipantPubKeys.cs new file mode 100644 index 000000000..ea46f626a --- /dev/null +++ b/NBitcoin/BIP373/MusigParticipantPubKeys.cs @@ -0,0 +1,56 @@ +#if HAS_SPAN +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace NBitcoin +{ + internal class MusigParticipantPubKeys + { + public MusigParticipantPubKeys(PubKey aggregated, PubKey[] pubKeys) + { + if (aggregated is null) + throw new ArgumentNullException(nameof(aggregated)); + if (pubKeys is null) + throw new ArgumentNullException(nameof(pubKeys)); + if (pubKeys.Length is 0) + throw new ArgumentException("pubKeys cannot be an empty collection.", nameof(pubKeys)); + if (aggregated.IsCompressed) + throw new ArgumentException("The aggregated key must be uncompressed.", nameof(aggregated)); + foreach (var pk in pubKeys) + { + if (pk is null) + throw new ArgumentNullException(nameof(pubKeys), "pubKeys cannot contain null elements."); + if (!pk.IsCompressed) + throw new ArgumentException("All public keys must be compressed.", nameof(pubKeys)); + } + Aggregated = aggregated; + PubKeys = pubKeys; + } + /// + /// The MuSig2 aggregate plain public key[1] from the KeyAgg algorithm. This key may or may not be in the script directly (as x-only). It may instead be a parent public key from which the public keys in the script were derived. + /// + public PubKey Aggregated { get; private set; } + + /// + /// A list of the compressed public keys of the participants in the MuSig2 aggregate key in the order required for aggregation. If sorting was done, then the keys must be in the sorted order. + /// + public PubKey[] PubKeys { get; private set; } + + internal static MusigParticipantPubKeys Parse(ReadOnlySpan key, ReadOnlySpan value) + { + var agg = new PubKey(key[1..]); + var pubKeys = new PubKey[value.Length / 33]; + int index = 0; + for (int i = 0; i < value.Length; i += 33) + { + pubKeys[index++] = new PubKey(value.Slice(i, 33)); + } + return new MusigParticipantPubKeys(agg, pubKeys); + } + } +} +#endif diff --git a/NBitcoin/BIP373/MusigTarget.cs b/NBitcoin/BIP373/MusigTarget.cs new file mode 100644 index 000000000..27fe40ef2 --- /dev/null +++ b/NBitcoin/BIP373/MusigTarget.cs @@ -0,0 +1,40 @@ +#if HAS_SPAN +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace NBitcoin +{ + public record MusigTarget(PubKey ParticipantPubKey, PubKey AggregatePubKey, uint256? TapLeaf) : IComparable + { + internal static MusigTarget Parse(ReadOnlySpan k) + { + var participant = new PubKey(k[1..]); + if (!participant.IsCompressed) + throw new FormatException("The participant public key must be compressed."); + var agg = new PubKey(k[(1 + 33)..]); + if (!participant.IsCompressed) + throw new FormatException("The aggregate public key must be compressed."); + var tapleaf = k[(1 + 33 + 33)..]; + var h = tapleaf.Length is 0 ? null : new uint256(tapleaf); + return new MusigTarget(participant, agg, h); + } + + public int CompareTo(MusigTarget? other) => other is null ? 1 : PubKeyComparer.Instance.Compare(ParticipantPubKey, other?.ParticipantPubKey); + + public byte[] ToBytes(byte key) + { + var result = new byte[1 + 33 + 33 + (TapLeaf is null ? 0 : 32)]; + result[0] = key; + ParticipantPubKey.ToBytes(result.AsSpan(1), out _); + ParticipantPubKey.ToBytes(result.AsSpan(1 + 33), out _); + if (TapLeaf is not null) + TapLeaf.ToBytes(result.AsSpan(1 + 33 + 33)); + return result; + } + } +} +#endif diff --git a/NBitcoin/BIP373/PSBTInput.cs b/NBitcoin/BIP373/PSBTInput.cs new file mode 100644 index 000000000..c5e678ab9 --- /dev/null +++ b/NBitcoin/BIP373/PSBTInput.cs @@ -0,0 +1,18 @@ +#if HAS_SPAN +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace NBitcoin +{ + public partial class PSBTInput + { + public SortedDictionary MusigParticipantPubKeys { get; } = new SortedDictionary(PubKeyComparer.Instance); + public SortedDictionary MusigPubNonces { get; } = new SortedDictionary(); + public SortedDictionary MusigPartialSigs { get; } = new SortedDictionary(); + } +} +#endif diff --git a/NBitcoin/BIP373/PSBTOutput.cs b/NBitcoin/BIP373/PSBTOutput.cs new file mode 100644 index 000000000..7c708fb8a --- /dev/null +++ b/NBitcoin/BIP373/PSBTOutput.cs @@ -0,0 +1,16 @@ +#if HAS_SPAN +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace NBitcoin +{ + public partial class PSBTOutput + { + public SortedDictionary MusigParticipantPubKeys { get; } = new SortedDictionary(PubKeyComparer.Instance); + } +} +#endif diff --git a/NBitcoin/LegacyShims.cs b/NBitcoin/LegacyShims.cs new file mode 100644 index 000000000..143709170 --- /dev/null +++ b/NBitcoin/LegacyShims.cs @@ -0,0 +1,6 @@ +#if LEGACY_SHIMS +namespace System.Runtime.CompilerServices +{ + internal static class IsExternalInit { } +} +#endif diff --git a/NBitcoin/NBitcoin.csproj b/NBitcoin/NBitcoin.csproj index 716cb6110..137ebfe3f 100644 --- a/NBitcoin/NBitcoin.csproj +++ b/NBitcoin/NBitcoin.csproj @@ -31,7 +31,7 @@ bin\Release\NBitcoin.XML - $(DefineConstants);CLASSICDOTNET;NO_ARRAY_FILL;NULLABLE_SHIMS;NO_SOCKETASYNC + $(DefineConstants);CLASSICDOTNET;NO_ARRAY_FILL;NULLABLE_SHIMS;LEGACY_SHIMS;NO_SOCKETASYNC $(DefineConstants);NOCUSTOMSSLVALIDATION;NO_NATIVERIPEMD160 @@ -41,7 +41,7 @@ true - $(DefineConstants);NO_SOCKETASYNC + $(DefineConstants);NO_SOCKETASYNC;LEGACY_SHIMS $(DefineConstants);NETSTANDARD;NO_ARRAY_FILL;NULLABLE_SHIMS;NO_NATIVE_RFC2898_HMACSHA512;NO_NATIVERIPEMD160;NO_SOCKETASYNC