From 54702d2fde0cf9abdc8e65725725a1b974805a6a Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Tue, 14 Jan 2025 17:59:58 +0900 Subject: [PATCH] Add SquashBytes format to Bech32 --- NBitcoin.Tests/Bech32Test.cs | 30 ++++++++++++ NBitcoin/DataEncoders/Bech32Encoder.cs | 65 +++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/NBitcoin.Tests/Bech32Test.cs b/NBitcoin.Tests/Bech32Test.cs index 4b45f3ebb1..8c962d0f6f 100644 --- a/NBitcoin.Tests/Bech32Test.cs +++ b/NBitcoin.Tests/Bech32Test.cs @@ -93,6 +93,36 @@ public void DetectInvalidChecksum() } } + [Fact] + public void GenericDataTests() + { + var bech32Test = + "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS"; + var lnurlBech32 = Bech32Encoder.ExtractEncoderFromString(bech32Test); + lnurlBech32.StrictLength = false; + lnurlBech32.SquashBytes = true; + var lnurlData = lnurlBech32.DecodeDataRaw(bech32Test, out var bech32EncodingType); + Assert.Equal(Bech32EncodingType.BECH32, bech32EncodingType); + Assert.Equal("https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df", + Encoding.UTF8.GetString(lnurlData)); + var encoded = lnurlBech32.EncodeRaw(Encoding.UTF8.GetBytes("https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df"), Bech32EncodingType.BECH32); + Assert.Equal(bech32Test, encoded.ToUpperInvariant()); + + var bechm32Test = + "tark1x0lm8hhr2wc6n6lyemtyh9rz8rg2ftpkfun46aca56kjg3ws0tsztfpuanaquxc6faedvjk3tax0575y6perapg3e95654pk8r4fjecs5fyd2"; + var arkBech32m = Bech32Encoder.ExtractEncoderFromString(bechm32Test); + arkBech32m.StrictLength = false; + arkBech32m.SquashBytes = true; + var arkData = arkBech32m.DecodeDataRaw(bechm32Test, out var bech32mEncodingType); + Assert.Equal(Bech32EncodingType.BECH32M, bech32mEncodingType); + Assert.Equal(64, arkData.Length); + + var key1 = arkData.Take(32).ToArray(); + var key2 = arkData.Skip(32).ToArray(); + Assert.Equal("33ffb3dee353b1a9ebe4ced64b946238d0a4ac364f275d771da6ad2445d07ae0", Encoders.Hex.EncodeData(key1)); + Assert.Equal("25a43cecfa0e1b1a4f72d64ad15f4cfa7a84d0723e8511c969aa543638ea9967", Encoders.Hex.EncodeData(key2)); + } + [Fact] public void ValidAddress() { diff --git a/NBitcoin/DataEncoders/Bech32Encoder.cs b/NBitcoin/DataEncoders/Bech32Encoder.cs index b72391bd79..0df0b513be 100644 --- a/NBitcoin/DataEncoders/Bech32Encoder.cs +++ b/NBitcoin/DataEncoders/Bech32Encoder.cs @@ -419,6 +419,13 @@ public virtual string EncodeData(ReadOnlySpan data, Bech32EncodingType enc { if (encodingType == null) throw new ArgumentNullException(nameof(encodingType)); +#if HAS_SPAN + if (SquashBytes) + data = ByteSquasher(data, 8, 5).AsSpan(); +#else + data = ByteSquasher(data, 8, 5); +#endif + #if HAS_SPAN Span combined = _Hrp.Length + 1 + data.Length + 6 is int v && v > 256 ? new byte[v] : stackalloc byte[v]; @@ -434,6 +441,7 @@ public virtual string EncodeData(ReadOnlySpan data, Bech32EncodingType enc combinedOffset += _Hrp.Length; combined[combinedOffset] = 49; combinedOffset++; + #if HAS_SPAN data.CopyTo(combined.Slice(combinedOffset)); #else @@ -446,9 +454,11 @@ public virtual string EncodeData(ReadOnlySpan data, Bech32EncodingType enc #else var checkSum = CreateChecksum(data, offset, count, encodingType); #endif + #if HAS_SPAN checkSum.CopyTo(combined.Slice(combinedOffset, 6)); combinedOffset += 6; + for (int i = 0; i < data.Length + 6; i++) #else Array.Copy(checkSum, 0, combined, combinedOffset, 6); @@ -486,6 +496,8 @@ public byte[] DecodeDataRaw(string encoded, out Bech32EncodingType encodingType) return DecodeDataCore(encoded, out encodingType); } public bool StrictLength { get; set; } = true; + public bool SquashBytes { get; set; } = false; + protected virtual byte[] DecodeDataCore(string encoded, out Bech32EncodingType encodingType) { if (encoded == null) @@ -543,11 +555,60 @@ protected virtual byte[] DecodeDataCore(string encoded, out Bech32EncodingType e throw new Bech32FormatException($"Error in Bech32 string at {String.Join(",", error)}", error); } #if HAS_SPAN - return data.Slice(0, data.Length - 6).ToArray(); + var arr = data.Slice(0, data.Length - 6).ToArray(); #else - return data.Take(data.Length - 6).ToArray(); + var arr = data.Take(data.Length - 6).ToArray(); #endif + if (SquashBytes) + { + arr = ByteSquasher(arr, 5, 8); + if (arr is null) + throw new FormatException("Invalid squashed bech32"); + } + return arr; + } +#if HAS_SPAN + private static byte[] ByteSquasher(ReadOnlySpan input, int inputWidth, int outputWidth) +#else + private static byte[] ByteSquasher(byte[] input, int inputWidth, int outputWidth) +#endif + { + var bitstash = 0; + var accumulator = 0; + var output = new List(); + var maxOutputValue = (1 << outputWidth) - 1; + + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + if (c >> inputWidth != 0) + { + return null; + } + + accumulator = (accumulator << inputWidth) | c; + bitstash += inputWidth; + while (bitstash >= outputWidth) + { + bitstash -= outputWidth; + output.Add((byte)((accumulator >> bitstash) & maxOutputValue)); + } + } + + // pad if going from 8 to 5 + if (inputWidth == 8 && outputWidth == 5) + { + if (bitstash != 0) output.Add((byte)((accumulator << (outputWidth - bitstash)) & maxOutputValue)); + } + else if (bitstash >= inputWidth || ((accumulator << (outputWidth - bitstash)) & maxOutputValue) != 0) + { + // no pad from 5 to 8 allowed + return null; + } + + return output.ToArray(); } + #if HAS_SPAN // We don't use this one, but old version of NBitcoin.Altcoins does, we prefer not causing problems if there is a mismatch of // assembly between NBitcoin.Altcoins and NBitcoin.