diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index ce42848..ca21f0c 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -7,14 +7,20 @@ on: branches: [ master, main ] paths: - 'src/Base58Encoding/**' - - '!src/Base58Encoding.Tests/**' - - '!src/Base58Encoding.Benchmarks/**' + - 'src/Directory.Build.props' + - 'src/Directory.Packages.props' + - 'src/NuGet.Config' + - 'src/PACKAGE.md' + - 'global.json' pull_request: branches: [ master, main ] paths: - 'src/Base58Encoding/**' - - '!src/Base58Encoding.Tests/**' - - '!src/Base58Encoding.Benchmarks/**' + - 'src/Directory.Build.props' + - 'src/Directory.Packages.props' + - 'src/NuGet.Config' + - 'src/PACKAGE.md' + - 'global.json' workflow_dispatch: inputs: version: @@ -43,7 +49,7 @@ jobs: run: echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x diff --git a/README.md b/README.md index bdaafdf..90cd68f 100644 --- a/README.md +++ b/README.md @@ -7,26 +7,56 @@ A .NET 10.0 Base58 encoding and decoding library with support for multiple alpha - **Multiple Alphabets**: Built-in support for Bitcoin(IFPS/Sui/Solana), Ripple, and Flickr alphabets - **Memory Efficient**: Uses stackalloc operations when possible to minimize allocations - **Type Safe**: Leverages ReadOnlySpan and ReadOnlyMemory for safe memory operations -- **Intrinsics**: Uses SIMD `Vector128/Vector256` and unrolled loop for counting leading zeros +- **Intrinsics**: Uses SIMD `Vector256` and unrolled loop for counting leading zeros - **Optimized Hot Paths**: Fast fixed-length encode/decode for 32-byte and 64-byte inputs using Firedancer-like optimizations ## Usage +### Allocating API + ```csharp using Base58Encoding; -// Encode bytes to Base58 Bitcoin(IFPS/Sui) alphabet +// Encode bytes to Base58 string (Bitcoin / IPFS / Sui / Solana alphabet) byte[] data = { 0x01, 0x02, 0x03, 0x04 }; string encoded = Base58.Bitcoin.Encode(data); // Decode Base58 string back to bytes byte[] decoded = Base58.Bitcoin.Decode(encoded); -// Ripple / Flickr +// Ripple / Flickr alphabets Base58.Ripple.Encode(data); Base58.Flickr.Encode(data); ``` +### Zero-allocation API + +Encode or decode directly into a caller-owned buffer — no heap allocations on the hot path. + +```csharp +using Base58Encoding; + +byte[] data = { 0x01, 0x02, 0x03, 0x04 }; + +// Size the output buffer using the helper +int maxLen = Base58.GetMaxEncodedLength(data.Length); +Span encodedBytes = stackalloc byte[maxLen]; // or rent from ArrayPool + +int written = Base58.Bitcoin.Encode(data, encodedBytes); +ReadOnlySpan result = encodedBytes[..written]; // ASCII bytes + +// Decode from a char span or ASCII byte span into a caller-owned buffer +Span decodedBytes = stackalloc byte[Base58.GetTypicalDecodedLength(written)]; +int decodedLen = Base58.Bitcoin.Decode(result, decodedBytes); + +// Both Decode overloads are supported: +// int Decode(ReadOnlySpan encoded, Span destination) +// int Decode(ReadOnlySpan encoded, Span destination) +``` + +`GetMaxEncodedLength(byteCount)` returns a safe upper bound for the encoded output size. +`GetTypicalDecodedLength(encodedLength)` returns a typical upper bound for decoded output (see its XML doc for the edge case around leading `'1'` characters). + ## Performance The library automatically uses optimized fast paths for common fixed-size inputs: @@ -51,41 +81,41 @@ These optimizations are based on Firedancer's specialized Base58 algorithms and ``` -BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7462/25H2/2025Update/HudsonValley2) +BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8246/25H2/2025Update/HudsonValley2) 13th Gen Intel Core i7-13700KF 3.40GHz, 1 CPU, 24 logical and 16 physical cores -.NET SDK 10.0.101 - [Host] : .NET 10.0.1 (10.0.1, 10.0.125.57005), X64 RyuJIT x86-64-v3 - DefaultJob : .NET 10.0.1 (10.0.1, 10.0.125.57005), X64 RyuJIT x86-64-v3 +.NET SDK 10.0.203 + [Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3 + DefaultJob : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3 Job=DefaultJob ``` | Method | VectorType | Mean | Ratio | Gen0 | Allocated | Alloc Ratio | |--------------------------- |--------------- |------------:|------:|-------:|----------:|------------:| -| **'Our Base58 Encode'** | **BitcoinAddress** | **537.07 ns** | **1.00** | **0.0057** | **96 B** | **1.00** | -| 'SimpleBase Base58 Encode' | BitcoinAddress | 782.31 ns | 1.46 | 0.0057 | 96 B | 1.00 | -| 'Our Base58 Decode' | BitcoinAddress | 168.95 ns | 0.31 | 0.0033 | 56 B | 0.58 | -| 'SimpleBase Base58 Decode' | BitcoinAddress | 352.63 ns | 0.66 | 0.0033 | 56 B | 0.58 | +| **'Our Base58 Encode'** | **BitcoinAddress** | **537.17 ns** | **1.00** | **0.0057** | **96 B** | **1.00** | +| 'SimpleBase Base58 Encode' | BitcoinAddress | 776.69 ns | 1.45 | 0.0057 | 96 B | 1.00 | +| 'Our Base58 Decode' | BitcoinAddress | 160.88 ns | 0.30 | 0.0033 | 56 B | 0.58 | +| 'SimpleBase Base58 Decode' | BitcoinAddress | 353.19 ns | 0.66 | 0.0033 | 56 B | 0.58 | | | | | | | | | -| **'Our Base58 Encode'** | **SolanaAddress** | **93.41 ns** | **1.00** | **0.0070** | **112 B** | **1.00** | -| 'SimpleBase Base58 Encode' | SolanaAddress | 1,430.37 ns | 15.31 | 0.0057 | 112 B | 1.00 | -| 'Our Base58 Decode' | SolanaAddress | 181.71 ns | 1.95 | 0.0035 | 56 B | 0.50 | -| 'SimpleBase Base58 Decode' | SolanaAddress | 837.03 ns | 8.96 | 0.0019 | 56 B | 0.50 | +| **'Our Base58 Encode'** | **SolanaAddress** | **94.07 ns** | **1.00** | **0.0070** | **112 B** | **1.00** | +| 'SimpleBase Base58 Encode' | SolanaAddress | 1,433.92 ns | 15.24 | 0.0057 | 112 B | 1.00 | +| 'Our Base58 Decode' | SolanaAddress | 104.19 ns | 1.11 | 0.0035 | 56 B | 0.50 | +| 'SimpleBase Base58 Decode' | SolanaAddress | 703.66 ns | 7.48 | 0.0029 | 56 B | 0.50 | | | | | | | | | -| **'Our Base58 Encode'** | **SolanaTx** | **252.31 ns** | **1.00** | **0.0124** | **200 B** | **1.00** | -| 'SimpleBase Base58 Encode' | SolanaTx | 7,247.09 ns | 28.73 | 0.0076 | 200 B | 1.00 | -| 'Our Base58 Decode' | SolanaTx | 178.05 ns | 0.71 | 0.0055 | 88 B | 0.44 | -| 'SimpleBase Base58 Decode' | SolanaTx | 2,379.54 ns | 9.43 | 0.0038 | 88 B | 0.44 | +| **'Our Base58 Encode'** | **SolanaTx** | **239.21 ns** | **1.00** | **0.0124** | **200 B** | **1.00** | +| 'SimpleBase Base58 Encode' | SolanaTx | 7,166.10 ns | 29.96 | 0.0076 | 200 B | 1.00 | +| 'Our Base58 Decode' | SolanaTx | 180.37 ns | 0.75 | 0.0055 | 88 B | 0.44 | +| 'SimpleBase Base58 Decode' | SolanaTx | 2,957.77 ns | 12.36 | 0.0038 | 88 B | 0.44 | | | | | | | | | -| **'Our Base58 Encode'** | **IPFSHash** | **1,096.58 ns** | **1.00** | **0.0076** | **120 B** | **1.00** | -| 'SimpleBase Base58 Encode' | IPFSHash | 1,644.83 ns | 1.50 | 0.0076 | 120 B | 1.00 | -| 'Our Base58 Decode' | IPFSHash | 287.87 ns | 0.26 | 0.0038 | 64 B | 0.53 | -| 'SimpleBase Base58 Decode' | IPFSHash | 643.63 ns | 0.59 | 0.0038 | 64 B | 0.53 | +| **'Our Base58 Encode'** | **IPFSHash** | **1,084.69 ns** | **1.00** | **0.0076** | **120 B** | **1.00** | +| 'SimpleBase Base58 Encode' | IPFSHash | 1,617.11 ns | 1.49 | 0.0076 | 120 B | 1.00 | +| 'Our Base58 Decode' | IPFSHash | 318.15 ns | 0.29 | 0.0038 | 64 B | 0.53 | +| 'SimpleBase Base58 Decode' | IPFSHash | 854.47 ns | 0.79 | 0.0038 | 64 B | 0.53 | | | | | | | | | -| **'Our Base58 Encode'** | **MoneroAddress** | **4,998.35 ns** | **1.00** | **0.0076** | **216 B** | **1.00** | -| 'SimpleBase Base58 Encode' | MoneroAddress | 8,585.92 ns | 1.72 | - | 216 B | 1.00 | -| 'Our Base58 Decode' | MoneroAddress | 1,173.48 ns | 0.23 | 0.0057 | 96 B | 0.44 | -| 'SimpleBase Base58 Decode' | MoneroAddress | 3,716.38 ns | 0.74 | 0.0038 | 96 B | 0.44 | +| **'Our Base58 Encode'** | **MoneroAddress** | **4,917.65 ns** | **1.00** | **0.0076** | **216 B** | **1.00** | +| 'SimpleBase Base58 Encode' | MoneroAddress | 8,621.98 ns | 1.75 | - | 216 B | 1.00 | +| 'Our Base58 Decode' | MoneroAddress | 1,198.92 ns | 0.24 | 0.0057 | 96 B | 0.44 | +| 'SimpleBase Base58 Decode' | MoneroAddress | 3,844.43 ns | 0.78 | - | 96 B | 0.44 | ## License diff --git a/global.json b/global.json new file mode 100644 index 0000000..3140116 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj b/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj index 04268dd..3d63021 100644 --- a/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj +++ b/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj @@ -2,15 +2,11 @@ Exe - net10.0 - enable - enable - true - - + + diff --git a/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs b/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs index 48d78f8..f1725b4 100644 --- a/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs @@ -23,6 +23,22 @@ public class JaggedVsMultidimensionalArrayBenchmark private static readonly uint[,] MultidimensionalEncodeTable32 = ConvertToMultidimensional(Base58BitcoinTables.EncodeTable32); private static readonly uint[,] MultidimensionalDecodeTable32 = ConvertToMultidimensional(Base58BitcoinTables.DecodeTable32); + private readonly ref struct FastEncodeState + { + public readonly ReadOnlySpan RawBase58; + public readonly int InLeadingZeros; + public readonly int RawLeadingZeros; + public readonly int OutputLength; + + public FastEncodeState(ReadOnlySpan rawBase58, int inLeadingZeros, int rawLeadingZeros, int outputLength) + { + RawBase58 = rawBase58; + InLeadingZeros = inLeadingZeros; + RawLeadingZeros = rawLeadingZeros; + OutputLength = outputLength; + } + } + [GlobalSetup] public void Setup() { @@ -131,7 +147,7 @@ private static string EncodeBitcoin32FastJagged(ReadOnlySpan data) // Calculate skip and final length int skip = rawLeadingZeros - inLeadingZeros; int outputLength = Base58BitcoinTables.Raw58Sz32 - skip; - var state = new Base58.EncodeFastState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength); + var state = new FastEncodeState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength); return string.Create(outputLength, state, static (span, state) => { if (state.InLeadingZeros > 0) @@ -143,7 +159,7 @@ private static string EncodeBitcoin32FastJagged(ReadOnlySpan data) for (int i = 0; i < state.OutputLength - state.InLeadingZeros; i++) { byte digit = state.RawBase58[state.RawLeadingZeros + i]; - span[state.InLeadingZeros + i] = bitcoinChars[digit]; + span[state.InLeadingZeros + i] = (char)bitcoinChars[digit]; } }); } @@ -208,7 +224,7 @@ private static string EncodeBitcoin32FastMultidimensional(ReadOnlySpan dat int skip = rawLeadingZeros - inLeadingZeros; int outputLength = Base58BitcoinTables.Raw58Sz32 - skip; - var state = new Base58.EncodeFastState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength); + var state = new FastEncodeState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength); return string.Create(outputLength, state, static (span, state) => { if (state.InLeadingZeros > 0) @@ -220,7 +236,7 @@ private static string EncodeBitcoin32FastMultidimensional(ReadOnlySpan dat for (int i = 0; i < state.OutputLength - state.InLeadingZeros; i++) { byte digit = state.RawBase58[state.RawLeadingZeros + i]; - span[state.InLeadingZeros + i] = bitcoinChars[digit]; + span[state.InLeadingZeros + i] = (char)bitcoinChars[digit]; } }); } @@ -232,7 +248,7 @@ private static string EncodeBitcoin32FastMultidimensional(ReadOnlySpan dat // Validate characters and create raw array using JAGGED ARRAY lookup Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32]; - var bitcoinDecodeTable = Base58Alphabet.Bitcoin.DecodeTable.Span; + var bitcoinDecodeTable = BitcoinAlphabet.DecodeTable; int prepend0 = Base58BitcoinTables.Raw58Sz32 - encoded.Length; for (int j = 0; j < Base58BitcoinTables.Raw58Sz32; j++) @@ -309,7 +325,7 @@ private static string EncodeBitcoin32FastMultidimensional(ReadOnlySpan dat // Validate characters and create raw array using MULTIDIMENSIONAL ARRAY lookup Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32]; - var bitcoinDecodeTable = Base58Alphabet.Bitcoin.DecodeTable.Span; + var bitcoinDecodeTable = BitcoinAlphabet.DecodeTable; int prepend0 = Base58BitcoinTables.Raw58Sz32 - encoded.Length; for (int j = 0; j < Base58BitcoinTables.Raw58Sz32; j++) diff --git a/src/Base58Encoding.Benchmarks/ZeroAllocBenchmark.cs b/src/Base58Encoding.Benchmarks/ZeroAllocBenchmark.cs new file mode 100644 index 0000000..29818a1 --- /dev/null +++ b/src/Base58Encoding.Benchmarks/ZeroAllocBenchmark.cs @@ -0,0 +1,37 @@ +using Base58Encoding.Benchmarks.Common; + +using BenchmarkDotNet.Attributes; + +namespace Base58Encoding.Benchmarks; + +[MemoryDiagnoser(false)] +[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")] +public class ZeroAllocBenchmark +{ + private byte[] _testData = null!; + private byte[] _encodedBytes = null!; + private byte[] _encodeDest = null!; + private byte[] _decodeDest = null!; + + [Params( + TestVectors.VectorType.BitcoinAddress, + TestVectors.VectorType.SolanaAddress + )] + public TestVectors.VectorType VectorType { get; set; } + + [GlobalSetup] + public void Setup() + { + _testData = TestVectors.GetVector(VectorType); + _encodeDest = new byte[Base58.GetMaxEncodedLength(_testData.Length)]; + int written = Base58.Bitcoin.Encode(_testData, _encodeDest); + _encodedBytes = _encodeDest[..written]; + _decodeDest = new byte[_testData.Length]; + } + + [Benchmark] + public int Encode() => Base58.Bitcoin.Encode(_testData, _encodeDest); + + [Benchmark] + public int Decode() => Base58.Bitcoin.Decode(_encodedBytes, _decodeDest); +} diff --git a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj index bba85c5..9ffc9f7 100644 --- a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj +++ b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj @@ -1,32 +1,17 @@  - net10.0 Exe - enable - enable false Base58Encoding.Tests - true - true - - - - runtime; build; native; contentfiles; analyzers; buildtransitive + + all - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - + diff --git a/src/Base58Encoding.Tests/Base58ZeroAllocTests.cs b/src/Base58Encoding.Tests/Base58ZeroAllocTests.cs new file mode 100644 index 0000000..3e0cc68 --- /dev/null +++ b/src/Base58Encoding.Tests/Base58ZeroAllocTests.cs @@ -0,0 +1,344 @@ +using System.Text; + +namespace Base58Encoding.Tests; + +public class Base58ZeroAllocTests +{ + [Fact] + public void GetMaxEncodedLength_ReturnsUpperBound() + { + Assert.Equal(0, Base58.GetMaxEncodedLength(0)); + Assert.True(Base58.GetMaxEncodedLength(1) >= 2); + Assert.True(Base58.GetMaxEncodedLength(32) >= 44); + Assert.True(Base58.GetMaxEncodedLength(64) >= 88); + } + + [Fact] + public void GetMaxEncodedLength_NegativeThrows() + { + Assert.Throws(() => Base58.GetMaxEncodedLength(-1)); + } + + [Fact] + public void GetTypicalDecodedLength_ReturnsTypicalBound() + { + Assert.Equal(0, Base58.GetTypicalDecodedLength(0)); + Assert.True(Base58.GetTypicalDecodedLength(44) >= 32); + Assert.True(Base58.GetTypicalDecodedLength(88) >= 64); + } + + [Fact] + public void GetTypicalDecodedLength_NegativeThrows() + { + Assert.Throws(() => Base58.GetTypicalDecodedLength(-1)); + } + + [Fact] + public void Encode_ToBytes_MatchesStringEncode() + { + var random = new Random(42); + int[] sizes = [0, 1, 5, 20, 32, 33, 64, 100]; + + foreach (int size in sizes) + { + var data = new byte[size]; + random.NextBytes(data); + + string expected = Base58.Bitcoin.Encode(data); + + Span buffer = stackalloc byte[Base58.GetMaxEncodedLength(data.Length)]; + int written = Base58.Bitcoin.Encode(data, buffer); + + string actual = Encoding.ASCII.GetString(buffer[..written]); + Assert.Equal(expected, actual); + } + } + + [Fact] + public void Encode_ToBytes_WithLeadingZeros_PreservesOnes() + { + byte[] data = [0x00, 0x00, 0x00, 0x01, 0x02]; + Span buffer = stackalloc byte[Base58.GetMaxEncodedLength(data.Length)]; + int written = Base58.Bitcoin.Encode(data, buffer); + + string actual = Encoding.ASCII.GetString(buffer[..written]); + Assert.Equal(Base58.Bitcoin.Encode(data), actual); + Assert.StartsWith("111", actual); + } + + [Fact] + public void Encode_ToBytes_AllZeros_WritesAllOnes() + { + byte[] data = new byte[10]; + Span buffer = stackalloc byte[Base58.GetMaxEncodedLength(data.Length)]; + int written = Base58.Bitcoin.Encode(data, buffer); + + Assert.Equal(10, written); + for (int i = 0; i < written; i++) + { + Assert.Equal((byte)'1', buffer[i]); + } + } + + [Fact] + public void Encode_ToBytes_Empty_Returns0() + { + Span buffer = stackalloc byte[4]; + int written = Base58.Bitcoin.Encode([], buffer); + Assert.Equal(0, written); + } + + [Fact] + public void Encode_ToBytes_DestinationTooSmall_Throws() + { + byte[] data = [0xFF, 0xFF, 0xFF, 0xFF]; + byte[] tooSmall = new byte[1]; + Assert.Throws(() => Base58.Bitcoin.Encode(data, tooSmall)); + } + + [Fact] + public void Encode_ToBytes_Bitcoin32Fast_Works() + { + byte[] data = new byte[32]; + new Random(1).NextBytes(data); + + string expected = Base58.Bitcoin.Encode(data); + + Span buffer = stackalloc byte[Base58.GetMaxEncodedLength(32)]; + int written = Base58.Bitcoin.Encode(data, buffer); + string actual = Encoding.ASCII.GetString(buffer[..written]); + + Assert.Equal(expected, actual); + } + + [Fact] + public void Encode_ToBytes_Bitcoin64Fast_Works() + { + byte[] data = new byte[64]; + new Random(2).NextBytes(data); + + string expected = Base58.Bitcoin.Encode(data); + + Span buffer = stackalloc byte[Base58.GetMaxEncodedLength(64)]; + int written = Base58.Bitcoin.Encode(data, buffer); + string actual = Encoding.ASCII.GetString(buffer[..written]); + + Assert.Equal(expected, actual); + } + + [Fact] + public void Decode_FromChars_MatchesByteArrayDecode() + { + var random = new Random(3); + int[] sizes = [1, 5, 20, 32, 64]; + + foreach (int size in sizes) + { + var data = new byte[size]; + random.NextBytes(data); + string encoded = Base58.Bitcoin.Encode(data); + + byte[] expected = Base58.Bitcoin.Decode(encoded); + Span buffer = stackalloc byte[64]; + int written = Base58.Bitcoin.Decode(encoded.AsSpan(), buffer); + + Assert.Equal(expected, buffer[..written].ToArray()); + Assert.Equal(data, buffer[..written].ToArray()); + } + } + + [Fact] + public void Decode_FromUtf8Bytes_MatchesFromChars() + { + var random = new Random(4); + int[] sizes = [1, 5, 20, 32, 64]; + + foreach (int size in sizes) + { + var data = new byte[size]; + random.NextBytes(data); + string encoded = Base58.Bitcoin.Encode(data); + byte[] utf8 = Encoding.ASCII.GetBytes(encoded); + + Span charBuf = stackalloc byte[64]; + int fromChars = Base58.Bitcoin.Decode(encoded.AsSpan(), charBuf); + + Span byteBuf = stackalloc byte[64]; + int fromBytes = Base58.Bitcoin.Decode((ReadOnlySpan)utf8, byteBuf); + + Assert.Equal(fromChars, fromBytes); + Assert.Equal(charBuf[..fromChars].ToArray(), byteBuf[..fromBytes].ToArray()); + Assert.Equal(data, byteBuf[..fromBytes].ToArray()); + } + } + + [Fact] + public void Decode_FromChars_DestinationTooSmall_Throws() + { + string encoded = Base58.Bitcoin.Encode(new byte[32]); + byte[] tooSmall = new byte[1]; + Assert.Throws(() => Base58.Bitcoin.Decode(encoded.AsSpan(), tooSmall)); + } + + [Fact] + public void Decode_FromBytes_DestinationTooSmall_Throws() + { + byte[] encoded = Encoding.ASCII.GetBytes(Base58.Bitcoin.Encode(new byte[32])); + byte[] tooSmall = new byte[1]; + Assert.Throws(() => Base58.Bitcoin.Decode((ReadOnlySpan)encoded, tooSmall)); + } + + [Theory] + [InlineData("0abc")] + [InlineData("Iabc")] + [InlineData("Oabc")] + [InlineData("labc")] + public void Decode_FromChars_InvalidChar_Throws(string encoded) + { + byte[] buffer = new byte[64]; + Assert.Throws(() => Base58.Bitcoin.Decode(encoded.AsSpan(), buffer)); + } + + [Theory] + [InlineData("0abc")] + [InlineData("Iabc")] + [InlineData("Oabc")] + [InlineData("labc")] + public void Decode_FromBytes_InvalidByte_Throws(string encoded) + { + byte[] utf8 = Encoding.ASCII.GetBytes(encoded); + byte[] buffer = new byte[64]; + Assert.Throws(() => Base58.Bitcoin.Decode((ReadOnlySpan)utf8, buffer)); + } + + [Fact] + public void Decode_AllOnes_ReturnsAllZeroBytes() + { + string encoded = new('1', 10); + Span buffer = stackalloc byte[10]; + int written = Base58.Bitcoin.Decode(encoded.AsSpan(), buffer); + + Assert.Equal(10, written); + for (int i = 0; i < written; i++) + { + Assert.Equal(0, buffer[i]); + } + } + + [Fact] + public void Decode_Empty_Returns0() + { + Span buffer = stackalloc byte[4]; + int written = Base58.Bitcoin.Decode(ReadOnlySpan.Empty, buffer); + Assert.Equal(0, written); + + written = Base58.Bitcoin.Decode(ReadOnlySpan.Empty, buffer); + Assert.Equal(0, written); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(5)] + [InlineData(16)] + [InlineData(31)] + public void Decode_Bitcoin32Fast_VariousLeadingZeros(int leadingZeros) + { + var data = new byte[32]; + new Random(leadingZeros).NextBytes(data.AsSpan(leadingZeros)); + string encoded = Base58.Bitcoin.Encode(data); + + Span buffer = stackalloc byte[32]; + int written = Base58.Bitcoin.Decode(encoded.AsSpan(), buffer); + + Assert.Equal(32, written); + Assert.Equal(data, buffer.ToArray()); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(8)] + [InlineData(32)] + [InlineData(63)] + public void Decode_Bitcoin64Fast_VariousLeadingZeros(int leadingZeros) + { + var data = new byte[64]; + new Random(leadingZeros).NextBytes(data.AsSpan(leadingZeros)); + string encoded = Base58.Bitcoin.Encode(data); + + Span buffer = stackalloc byte[64]; + int written = Base58.Bitcoin.Decode(encoded.AsSpan(), buffer); + + Assert.Equal(64, written); + Assert.Equal(data, buffer.ToArray()); + } + + [Fact] + public void Encode_Decode_RoundTrip_Ripple() + { + var data = new byte[20]; + new Random(5).NextBytes(data); + + Span encBuf = stackalloc byte[Base58.GetMaxEncodedLength(data.Length)]; + int encWritten = Base58.Ripple.Encode(data, encBuf); + + Span decBuf = stackalloc byte[data.Length]; + int decWritten = Base58.Ripple.Decode(encBuf[..encWritten], decBuf); + + Assert.Equal(data.Length, decWritten); + Assert.Equal(data, decBuf[..decWritten].ToArray()); + } + + [Fact] + public void Decode_FastPathFallback_LeavesSentinelBeyondWritten() + { + // Construct an encoded string that will trigger the 32-byte fast path + // (encoded.Length in [32..44]) but actually represents fewer than 32 bytes. + // This forces the leading-zero mismatch check to return -1, falling back + // to the generic decoder which writes fewer bytes than the fast path did. + + // 24 bytes of non-zero data encodes to ~32-33 chars → triggers fast path. + var data = new byte[24]; + data[0] = 0xFF; + new Random(42).NextBytes(data.AsSpan(1)); + + string encoded = Base58.Bitcoin.Encode(data); + Assert.InRange(encoded.Length, 32, 44); // confirms fast-path range + + // Oversize destination, pre-filled with sentinel to detect fast-path stale writes. + Span destination = stackalloc byte[64]; + destination.Fill(0xAA); + + int written = Base58.Bitcoin.Decode(encoded.AsSpan(), destination); + + // Returned data is correct. + Assert.Equal(24, written); + Assert.Equal(data, destination[..written].ToArray()); + + // Bytes beyond `written` should still be the sentinel. + // This passes with the current temp-buffer implementation (fast path never + // touches destination on the -1 fallback path). If the temp buffer is + // removed, bytes [24..32] will contain fast-path leftover = data[16..24]. + for (int i = written; i < destination.Length; i++) + { + Assert.Equal(0xAA, destination[i]); + } + } + + [Fact] + public void Encode_Decode_RoundTrip_Flickr() + { + var data = new byte[20]; + new Random(6).NextBytes(data); + + Span encBuf = stackalloc byte[Base58.GetMaxEncodedLength(data.Length)]; + int encWritten = Base58.Flickr.Encode(data, encBuf); + + Span decBuf = stackalloc byte[data.Length]; + int decWritten = Base58.Flickr.Decode(encBuf[..encWritten], decBuf); + + Assert.Equal(data.Length, decWritten); + Assert.Equal(data, decBuf[..decWritten].ToArray()); + } +} diff --git a/src/Base58Encoding.Tests/SimpleLeadingZerosTest.cs b/src/Base58Encoding.Tests/SimpleLeadingZerosTest.cs index 6904301..7ef2cdb 100644 --- a/src/Base58Encoding.Tests/SimpleLeadingZerosTest.cs +++ b/src/Base58Encoding.Tests/SimpleLeadingZerosTest.cs @@ -38,6 +38,8 @@ public void BitcoinAddress_CountLeadingZerosMultipleWays_SameResult() [InlineData(31)] public void CountLeadingZeros_32Size_ReturnsCorrectNumber(int zerosCount) { + Assert.SkipUnless(Vector256.IsHardwareAccelerated, "Requires Vector256 hardware acceleration"); + // Arrange var data = new byte[32]; data.AsSpan(0, zerosCount).Fill(0x00); @@ -53,6 +55,8 @@ public void CountLeadingZeros_32Size_ReturnsCorrectNumber(int zerosCount) [Fact] public void CountLeadingZeros_512Size_ReturnsCorrectNumber() { + Assert.SkipUnless(Vector256.IsHardwareAccelerated, "Requires Vector256 hardware acceleration"); + // Arrange var zerosCount = 123; var data = new byte[512]; diff --git a/src/Base58Encoding.Tests/xunit.runner.json b/src/Base58Encoding.Tests/xunit.runner.json deleted file mode 100644 index 503b748..0000000 --- a/src/Base58Encoding.Tests/xunit.runner.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", - "parallelAlgorithm": "aggressive" -} diff --git a/src/Base58Encoding.slnx b/src/Base58Encoding.slnx index 88e2cf3..f6179d1 100644 --- a/src/Base58Encoding.slnx +++ b/src/Base58Encoding.slnx @@ -4,6 +4,12 @@ + + + + + + diff --git a/src/Base58Encoding/Base58.CountLeading.cs b/src/Base58Encoding/Base58.CountLeading.cs index d5b5d7e..f3e4c47 100644 --- a/src/Base58Encoding/Base58.CountLeading.cs +++ b/src/Base58Encoding/Base58.CountLeading.cs @@ -5,7 +5,7 @@ namespace Base58Encoding; -public partial class Base58 +public static partial class Base58 { internal static int CountLeadingZeros(ReadOnlySpan data) { @@ -84,10 +84,10 @@ internal static int CountLeadingZerosScalar(ReadOnlySpan data) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static int CountLeadingCharacters(ReadOnlySpan text, char target) + internal static int CountLeadingCharacters(ReadOnlySpan text, TChar target) + where TChar : unmanaged, IBinaryInteger { int mismatchIndex = text.IndexOfAnyExcept(target); - return mismatchIndex == -1 ? text.Length : mismatchIndex; } } diff --git a/src/Base58Encoding/Base58.Decode.cs b/src/Base58Encoding/Base58.Decode.cs index 6268fbf..572a606 100644 --- a/src/Base58Encoding/Base58.Decode.cs +++ b/src/Base58Encoding/Base58.Decode.cs @@ -1,107 +1,270 @@ +using System.Buffers; using System.Buffers.Binary; +using System.Numerics; +using System.Runtime.CompilerServices; namespace Base58Encoding; -public partial class Base58 +public sealed partial class Base58 + where TAlphabet : struct, IBase58Alphabet { /// - /// Decode Base58 string to byte array + /// Decodes a Base58 string to a new byte array. /// - /// Base58 encoded string - /// Decoded byte array - /// Invalid Base58 character + /// Base58 encoded input. + /// Decoded byte array. + /// Invalid Base58 character. public byte[] Decode(ReadOnlySpan encoded) { if (encoded.IsEmpty) + { return []; + } - // Hot path for Bitcoin alphabet + common expected output sizes - if (ReferenceEquals(this, _bitcoin.Value)) + if (typeof(TAlphabet) == typeof(BitcoinAlphabet)) { - // Only use fast decode for lengths that STRONGLY suggest fixed sizes - // These are the maximum-length encodings that are very likely to be exactly 32/64 bytes - return encoded.Length switch + if (encoded.Length is >= 43 and <= 44) + { + Span buf = stackalloc byte[32]; + if (TryDecodeBitcoin32Fast(encoded, buf) == 32) + { + return buf.ToArray(); + } + } + else if (encoded.Length is >= 87 and <= 88) { - >= 43 and <= 44 => DecodeBitcoin32Fast(encoded) ?? DecodeGeneric(encoded), // Very likely 32 bytes - >= 87 and <= 88 => DecodeBitcoin64Fast(encoded) ?? DecodeGeneric(encoded), // Very likely 64 bytes - _ => DecodeGeneric(encoded) - }; + Span buf = stackalloc byte[64]; + if (TryDecodeBitcoin64Fast(encoded, buf) == 64) + { + return buf.ToArray(); + } + } } - // Fallback for other alphabets - return DecodeGeneric(encoded); + return DecodeGenericToArray(encoded); } /// - /// Decode Base58 string to byte array using generic algorithm + /// Decodes Base58 chars into . /// - /// Base58 encoded string - /// Decoded byte array - /// Invalid Base58 character - internal byte[] DecodeGeneric(ReadOnlySpan encoded) + /// Base58 encoded input. + /// Destination buffer for decoded bytes. + /// Number of bytes written to . + /// + /// Thrown on invalid Base58 character or when is too small. + /// + public int Decode(ReadOnlySpan encoded, Span destination) { if (encoded.IsEmpty) - return []; + { + return 0; + } + + return DecodeCore(encoded, destination); + } + + /// + /// Decodes Base58 ASCII bytes into . + /// + /// Base58 encoded input as ASCII bytes. + /// Destination buffer for decoded bytes. + /// Number of bytes written to . + /// + /// Thrown on invalid Base58 character or when is too small. + /// + public int Decode(ReadOnlySpan encoded, Span destination) + { + if (encoded.IsEmpty) + { + return 0; + } + + return DecodeCore(encoded, destination); + } + + private int DecodeCore(ReadOnlySpan encoded, Span destination) + where TChar : unmanaged, IBinaryInteger + { + if (typeof(TAlphabet) == typeof(BitcoinAlphabet)) + { + if (encoded.Length is >= 43 and <= 44) + { + int r = TryDecodeBitcoin32Fast(encoded, destination); + if (r >= 0) + { + return r; + } + } + else if (encoded.Length is >= 87 and <= 88) + { + int r = TryDecodeBitcoin64Fast(encoded, destination); + if (r >= 0) + { + return r; + } + } + } + + return DecodeGenericCore(encoded, destination); + } - int leadingOnes = CountLeadingCharacters(encoded, _firstCharacter); + [SkipLocalsInit] + private int DecodeGenericCore(ReadOnlySpan encoded, Span destination) + where TChar : unmanaged, IBinaryInteger + { + TChar firstChar = TChar.CreateTruncating(TAlphabet.FirstCharacter); + int leadingOnes = Base58.CountLeadingCharacters(encoded, firstChar); + int scratchSize = encoded.Length * 733 / 1000 + 1; + + if (scratchSize <= MaxStackallocByte) + { + Span decoded = stackalloc byte[scratchSize]; + int decodedLength = ComputeGenericDecode(encoded, leadingOnes, decoded); + int actualDecodedLength = leadingOnes == encoded.Length ? 0 : decodedLength; + int totalLength = leadingOnes + actualDecodedLength; + if (destination.Length < totalLength) + { + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); + } + + EmitGenericDecode(destination, leadingOnes, decoded, actualDecodedLength); + return totalLength; + } - int outputSize = encoded.Length * 733 / 1000 + 1; + return DecodeGenericCoreLarge(encoded, leadingOnes, scratchSize, destination); + } + + private int DecodeGenericCoreLarge(ReadOnlySpan encoded, int leadingOnes, int scratchSize, Span destination) + where TChar : unmanaged, IBinaryInteger + { + byte[] rented = ArrayPool.Shared.Rent(scratchSize); + try + { + int decodedLength = ComputeGenericDecode(encoded, leadingOnes, rented); + int actualDecodedLength = leadingOnes == encoded.Length ? 0 : decodedLength; + int totalLength = leadingOnes + actualDecodedLength; + if (destination.Length < totalLength) + { + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); + } + + EmitGenericDecode(destination, leadingOnes, rented, actualDecodedLength); + return totalLength; + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + [SkipLocalsInit] + private byte[] DecodeGenericToArray(ReadOnlySpan encoded) + where TChar : unmanaged, IBinaryInteger + { + TChar firstChar = TChar.CreateTruncating(TAlphabet.FirstCharacter); + int leadingOnes = Base58.CountLeadingCharacters(encoded, firstChar); + + if (leadingOnes == encoded.Length) + { + return new byte[leadingOnes]; + } + + int scratchSize = encoded.Length * 733 / 1000 + 1; + + if (scratchSize <= MaxStackallocByte) + { + Span decoded = stackalloc byte[scratchSize]; + int decodedLength = ComputeGenericDecode(encoded, leadingOnes, decoded); + byte[] result = new byte[leadingOnes + decodedLength]; + EmitGenericDecode(result, leadingOnes, decoded, decodedLength); + return result; + } + + return DecodeGenericToArrayLarge(encoded, leadingOnes, scratchSize); + } - Span decoded = outputSize > MaxStackallocByte - ? new byte[outputSize] - : stackalloc byte[outputSize]; + private byte[] DecodeGenericToArrayLarge(ReadOnlySpan encoded, int leadingOnes, int scratchSize) + where TChar : unmanaged, IBinaryInteger + { + byte[] rented = ArrayPool.Shared.Rent(scratchSize); + try + { + int decodedLength = ComputeGenericDecode(encoded, leadingOnes, rented); + byte[] result = new byte[leadingOnes + decodedLength]; + EmitGenericDecode(result, leadingOnes, rented, decodedLength); + return result; + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + private int ComputeGenericDecode(ReadOnlySpan encoded, int leadingOnes, Span digits) + where TChar : unmanaged, IBinaryInteger + { int decodedLength = 1; - decoded[0] = 0; + digits[0] = 0; - var decodeTable = _decodeTable.Span; + ReadOnlySpan decodeTable = TAlphabet.DecodeTable; for (int i = leadingOnes; i < encoded.Length; i++) { - char c = encoded[i]; + int c = int.CreateTruncating(encoded[i]); - if (c >= 128 || decodeTable[c] == 255) - ThrowHelper.ThrowInvalidCharacter(c); + if ((uint)c >= 128 || decodeTable[c] == 255) + { + ThrowHelper.ThrowInvalidCharacter((char)c); + } int carry = decodeTable[c]; for (int j = 0; j < decodedLength; j++) { - carry += decoded[j] * Base; - decoded[j] = (byte)(carry & 0xFF); + carry += digits[j] * Base; + digits[j] = (byte)(carry & 0xFF); carry >>= 8; } while (carry > 0) { - decoded[decodedLength++] = (byte)(carry & 0xFF); + digits[decodedLength++] = (byte)(carry & 0xFF); carry >>= 8; } } - // If we only have leading ones and no other digits were processed, - // we should only return the leading zeros (not add an extra byte) - int actualDecodedLength = (leadingOnes == encoded.Length) ? 0 : decodedLength; + return decodedLength; + } - var result = new byte[leadingOnes + actualDecodedLength]; + private static void EmitGenericDecode(Span destination, int leadingOnes, Span digits, int decodedLength) + { + if (leadingOnes > 0) + { + destination[..leadingOnes].Clear(); + } - if (actualDecodedLength > 0) + if (decodedLength > 0) { - var finalDecoded = decoded.Slice(0, decodedLength); + Span finalDecoded = digits[..decodedLength]; finalDecoded.Reverse(); - finalDecoded.CopyTo(result.AsSpan(leadingOnes)); + finalDecoded.CopyTo(destination[leadingOnes..]); } - - return result; } - internal static byte[]? DecodeBitcoin32Fast(ReadOnlySpan encoded) + /// + /// Returns bytes written (32) on success, or -1 if the encoded input doesn't + /// represent exactly 32 bytes (caller should fall back to generic decode). + /// Throws on invalid character or insufficient destination when fast path matches. + /// + [SkipLocalsInit] + internal static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, Span destination) + where TChar : unmanaged, IBinaryInteger { int charCount = encoded.Length; // Convert to raw base58 digits with validation + conversion in one pass - Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32]; // 45 bytes - var bitcoinDecodeTable = Base58Alphabet.Bitcoin.DecodeTable.Span; + Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32]; + ReadOnlySpan bitcoinDecodeTable = BitcoinAlphabet.DecodeTable; // Prepend zeros to make exactly Raw58Sz32 characters int prepend0 = Base58BitcoinTables.Raw58Sz32 - charCount; @@ -113,10 +276,12 @@ internal byte[] DecodeGeneric(ReadOnlySpan encoded) } else { - char c = encoded[j - prepend0]; + int c = int.CreateTruncating(encoded[j - prepend0]); // Validate + convert using Bitcoin decode table - if (c >= 128 || bitcoinDecodeTable[c] == 255) - ThrowHelper.ThrowInvalidCharacter(c); + if ((uint)c >= 128 || bitcoinDecodeTable[c] == 255) + { + ThrowHelper.ThrowInvalidCharacter((char)c); + } rawBase58[j] = bitcoinDecodeTable[c]; } @@ -128,10 +293,10 @@ internal byte[] DecodeGeneric(ReadOnlySpan encoded) for (int i = 0; i < Base58BitcoinTables.IntermediateSz32; i++) { intermediate[i] = (ulong)rawBase58[5 * i + 0] * 11316496UL + // 58^4 - (ulong)rawBase58[5 * i + 1] * 195112UL + // 58^3 - (ulong)rawBase58[5 * i + 2] * 3364UL + // 58^2 - (ulong)rawBase58[5 * i + 3] * 58UL + // 58^1 - (ulong)rawBase58[5 * i + 4] * 1UL; // 58^0 + (ulong)rawBase58[5 * i + 1] * 195112UL + // 58^3 + (ulong)rawBase58[5 * i + 2] * 3364UL + // 58^2 + (ulong)rawBase58[5 * i + 3] * 58UL + // 58^1 + (ulong)rawBase58[5 * i + 4] * 1UL; // 58^0 } // Convert to overcomplete base 2^32 using decode table @@ -150,54 +315,65 @@ internal byte[] DecodeGeneric(ReadOnlySpan encoded) // Reduce each term to less than 2^32 for (int i = Base58BitcoinTables.BinarySz32 - 1; i > 0; i--) { - binary[i - 1] += (binary[i] >> 32); + binary[i - 1] += binary[i] >> 32; binary[i] &= 0xFFFFFFFFUL; } // Check if the result is too large for 32 bytes - if (binary[0] > 0xFFFFFFFFUL) return null; + if (binary[0] > 0xFFFFFFFFUL) + { + return -1; + } - // Convert to big-endian byte output - var result = new byte[32]; + // Count leading zero bytes in the output directly from binary[] without materializing it. + // Each limb is 4 bytes big-endian. + int outputLeadingZeros = 0; for (int i = 0; i < Base58BitcoinTables.BinarySz32; i++) { - uint value = (uint)binary[i]; - int offset = i * sizeof(uint); - BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(offset, sizeof(uint)), value); + uint v = (uint)binary[i]; + if (v != 0) + { + outputLeadingZeros += BitOperations.LeadingZeroCount(v) / 8; + break; + } + outputLeadingZeros += 4; } - // Count leading zeros in output - int outputLeadingZeros = 0; - for (int i = 0; i < 32; i++) + // Leading zeros in output must match leading '1's in input. + // Mismatch means this encoded string doesn't represent exactly 32 bytes. + TChar one = TChar.CreateTruncating((byte)'1'); + int inputLeadingOnes = Base58.CountLeadingCharacters(encoded, one); + + if (outputLeadingZeros != inputLeadingOnes) { - if (result[i] != 0) break; - outputLeadingZeros++; + return -1; } - // Count leading '1's in input - int inputLeadingOnes = 0; - for (int i = 0; i < encoded.Length; i++) + if (destination.Length < 32) { - if (encoded[i] != '1') break; - inputLeadingOnes++; + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } - // Leading zeros in output must match leading '1's in input - // might be edge case since base58 of 32bytes can be between 32 and 44 characters. - // will be handled by generic decoder if lengths don't match - if (outputLeadingZeros != inputLeadingOnes) return null; + // Convert to big-endian byte output + for (int i = 0; i < Base58BitcoinTables.BinarySz32; i++) + { + uint value = (uint)binary[i]; + int offset = i * sizeof(uint); + BinaryPrimitives.WriteUInt32BigEndian(destination.Slice(offset, sizeof(uint)), value); + } - // Return the full 32 bytes - the result should always be 32 bytes for 32-byte decode - return result; + return 32; } - internal static byte[]? DecodeBitcoin64Fast(ReadOnlySpan encoded) + [SkipLocalsInit] + internal static int TryDecodeBitcoin64Fast(ReadOnlySpan encoded, Span destination) + where TChar : unmanaged, IBinaryInteger { int charCount = encoded.Length; // Convert to raw base58 digits with validation + conversion in one pass Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz64]; - var bitcoinDecodeTable = Base58Alphabet.Bitcoin.DecodeTable.Span; + ReadOnlySpan bitcoinDecodeTable = BitcoinAlphabet.DecodeTable; // Prepend zeros to make exactly Raw58Sz64 characters int prepend0 = Base58BitcoinTables.Raw58Sz64 - charCount; @@ -209,10 +385,12 @@ internal byte[] DecodeGeneric(ReadOnlySpan encoded) } else { - char c = encoded[j - prepend0]; + int c = int.CreateTruncating(encoded[j - prepend0]); // Validate + convert using Bitcoin decode table - if (c >= 128 || bitcoinDecodeTable[c] == 255) - ThrowHelper.ThrowInvalidCharacter(c); + if ((uint)c >= 128 || bitcoinDecodeTable[c] == 255) + { + ThrowHelper.ThrowInvalidCharacter((char)c); + } rawBase58[j] = bitcoinDecodeTable[c]; } @@ -224,10 +402,10 @@ internal byte[] DecodeGeneric(ReadOnlySpan encoded) for (int i = 0; i < Base58BitcoinTables.IntermediateSz64; i++) { intermediate[i] = (ulong)rawBase58[5 * i + 0] * 11316496UL + // 58^4 - (ulong)rawBase58[5 * i + 1] * 195112UL + // 58^3 - (ulong)rawBase58[5 * i + 2] * 3364UL + // 58^2 - (ulong)rawBase58[5 * i + 3] * 58UL + // 58^1 - (ulong)rawBase58[5 * i + 4] * 1UL; // 58^0 + (ulong)rawBase58[5 * i + 1] * 195112UL + // 58^3 + (ulong)rawBase58[5 * i + 2] * 3364UL + // 58^2 + (ulong)rawBase58[5 * i + 3] * 58UL + // 58^1 + (ulong)rawBase58[5 * i + 4] * 1UL; // 58^0 } // Convert to overcomplete base 2^32 using decode table @@ -246,44 +424,67 @@ internal byte[] DecodeGeneric(ReadOnlySpan encoded) // Reduce each term to less than 2^32 for (int i = Base58BitcoinTables.BinarySz64 - 1; i > 0; i--) { - binary[i - 1] += (binary[i] >> 32); + binary[i - 1] += binary[i] >> 32; binary[i] &= 0xFFFFFFFFUL; } // Check if the result is too large for 64 bytes - if (binary[0] > 0xFFFFFFFFUL) return null; + if (binary[0] > 0xFFFFFFFFUL) + { + return -1; + } - // Convert to big-endian byte output - var result = new byte[64]; + // Count leading zero bytes in the output directly from binary[] without materializing it. + // Each limb is 4 bytes big-endian. + int outputLeadingZeros = 0; for (int i = 0; i < Base58BitcoinTables.BinarySz64; i++) { - uint value = (uint)binary[i]; - int offset = i * sizeof(uint); - BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(offset, sizeof(uint)), value); + uint v = (uint)binary[i]; + if (v != 0) + { + outputLeadingZeros += BitOperations.LeadingZeroCount(v) / 8; + break; + } + outputLeadingZeros += 4; } - // Count leading zeros in output - int outputLeadingZeros = 0; - for (int i = 0; i < 64; i++) + // Leading zeros in output must match leading '1's in input. + // Mismatch means this encoded string doesn't represent exactly 64 bytes. + TChar one = TChar.CreateTruncating((byte)'1'); + int inputLeadingOnes = Base58.CountLeadingCharacters(encoded, one); + + if (outputLeadingZeros != inputLeadingOnes) { - if (result[i] != 0) break; - outputLeadingZeros++; + return -1; } - // Count leading '1's in input - int inputLeadingOnes = 0; - for (int i = 0; i < encoded.Length; i++) + if (destination.Length < 64) { - if (encoded[i] != '1') break; - inputLeadingOnes++; + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } - // Leading zeros in output must match leading '1's in input - // might be edge case since base58 of 64bytes can be between 64 and 88 characters. - // will be handled by generic decoder if lengths don't match - if (outputLeadingZeros != inputLeadingOnes) return null; + // Convert to big-endian byte output + for (int i = 0; i < Base58BitcoinTables.BinarySz64; i++) + { + uint value = (uint)binary[i]; + int offset = i * sizeof(uint); + BinaryPrimitives.WriteUInt32BigEndian(destination.Slice(offset, sizeof(uint)), value); + } + + return 64; + } - // Return the full 64 bytes - the result should always be 64 bytes for 64-byte decode - return result; + internal static byte[]? DecodeBitcoin32Fast(ReadOnlySpan encoded) + { + Span buffer = stackalloc byte[32]; + int r = TryDecodeBitcoin32Fast(encoded, buffer); + return r < 0 ? null : buffer.ToArray(); + } + + internal static byte[]? DecodeBitcoin64Fast(ReadOnlySpan encoded) + { + Span buffer = stackalloc byte[64]; + int r = TryDecodeBitcoin64Fast(encoded, buffer); + return r < 0 ? null : buffer.ToArray(); } } diff --git a/src/Base58Encoding/Base58.Encode.cs b/src/Base58Encoding/Base58.Encode.cs index 9492d95..150cd95 100644 --- a/src/Base58Encoding/Base58.Encode.cs +++ b/src/Base58Encoding/Base58.Encode.cs @@ -1,59 +1,166 @@ +using System.Buffers; using System.Buffers.Binary; using System.Diagnostics; +using System.Numerics; +using System.Runtime.CompilerServices; namespace Base58Encoding; -public partial class Base58 +public sealed partial class Base58 + where TAlphabet : struct, IBase58Alphabet { /// - /// Encode byte array to Base58 string + /// Encodes bytes to a Base58 string. /// - /// Bytes to encode - /// Base58 encoded string + /// Bytes to encode. + /// Base58 encoded string. public string Encode(ReadOnlySpan data) { if (data.IsEmpty) + { return string.Empty; + } - // Hot path for Bitcoin alphabet + common sizes - if (ReferenceEquals(this, _bitcoin.Value)) + if (typeof(TAlphabet) == typeof(BitcoinAlphabet)) { return data.Length switch { - 32 => EncodeBitcoin32Fast(data), - 64 => EncodeBitcoin64Fast(data), - _ => EncodeGeneric(data) + 32 => EncodeBitcoin32FastToString(data), + 64 => EncodeBitcoin64FastToString(data), + _ => EncodeGenericToString(data) }; } - // Fallback for other alphabets - return EncodeGeneric(data); + return EncodeGenericToString(data); } /// - /// Encode byte array to Base58 string using generic algorithm + /// Encodes bytes to Base58 ASCII bytes written into . /// - /// Bytes to encode - /// Base58 encoded string - internal string EncodeGeneric(ReadOnlySpan data) + /// Bytes to encode. + /// Destination buffer for ASCII-encoded Base58 characters. + /// Number of bytes written to . + /// Thrown if is too small. + public int Encode(ReadOnlySpan data, Span destination) { if (data.IsEmpty) - return string.Empty; + { + return 0; + } - int leadingZeros = CountLeadingZeros(data); + if (typeof(TAlphabet) == typeof(BitcoinAlphabet)) + { + return data.Length switch + { + 32 => EncodeBitcoin32FastToBytes(data, destination), + 64 => EncodeBitcoin64FastToBytes(data, destination), + _ => EncodeGenericToBytes(data, destination) + }; + } + + return EncodeGenericToBytes(data, destination); + } + + [SkipLocalsInit] + private string EncodeGenericToString(ReadOnlySpan data) + { + int leadingZeros = Base58.CountLeadingZeros(data); if (leadingZeros == data.Length) { - return new string(_firstCharacter, leadingZeros); + return new string((char)TAlphabet.FirstCharacter, leadingZeros); + } + + ReadOnlySpan inputSpan = data[leadingZeros..]; + int size = inputSpan.Length * 137 / 100 + 1; + + if (size <= MaxStackallocByte) + { + Span digits = stackalloc byte[size]; + int digitCount = ComputeGenericDigits(inputSpan, digits); + var state = new EncodeState(digits, 0, digitCount, TAlphabet.Characters, TAlphabet.FirstCharacter, leadingZeros); + return string.Create(state.OutputLength, state, static (span, s) => s.EmitReverse(span)); + } + + return EncodeGenericToStringLarge(inputSpan, leadingZeros, size); + } + + private string EncodeGenericToStringLarge(ReadOnlySpan inputSpan, int leadingZeros, int size) + { + byte[] rented = ArrayPool.Shared.Rent(size); + try + { + int digitCount = ComputeGenericDigits(inputSpan, rented); + var state = new EncodeState(rented, 0, digitCount, TAlphabet.Characters, TAlphabet.FirstCharacter, leadingZeros); + return string.Create(state.OutputLength, state, static (span, s) => s.EmitReverse(span)); } + finally + { + ArrayPool.Shared.Return(rented); + } + } - var inputSpan = data[leadingZeros..]; + [SkipLocalsInit] + private int EncodeGenericToBytes(ReadOnlySpan data, Span destination) + { + int leadingZeros = Base58.CountLeadingZeros(data); - var size = (inputSpan.Length * 137 / 100) + 1; - Span digits = size > MaxStackallocByte - ? new byte[size] - : stackalloc byte[size]; + if (leadingZeros == data.Length) + { + if (destination.Length < leadingZeros) + { + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); + } + + destination[..leadingZeros].Fill(TAlphabet.FirstCharacter); + return leadingZeros; + } + ReadOnlySpan inputSpan = data[leadingZeros..]; + int size = inputSpan.Length * 137 / 100 + 1; + + if (size <= MaxStackallocByte) + { + Span digits = stackalloc byte[size]; + int digitCount = ComputeGenericDigits(inputSpan, digits); + int outputLength = leadingZeros + digitCount; + if (destination.Length < outputLength) + { + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); + } + + var state = new EncodeState(digits, 0, digitCount, TAlphabet.Characters, TAlphabet.FirstCharacter, leadingZeros); + state.EmitReverse(destination); + return outputLength; + } + + return EncodeGenericToBytesLarge(inputSpan, leadingZeros, size, destination); + } + + private int EncodeGenericToBytesLarge(ReadOnlySpan inputSpan, int leadingZeros, int size, Span destination) + { + byte[] rented = ArrayPool.Shared.Rent(size); + try + { + int digitCount = ComputeGenericDigits(inputSpan, rented); + int outputLength = leadingZeros + digitCount; + if (destination.Length < outputLength) + { + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); + } + + var state = new EncodeState(rented, 0, digitCount, TAlphabet.Characters, TAlphabet.FirstCharacter, leadingZeros); + state.EmitReverse(destination); + return outputLength; + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + private static int ComputeGenericDigits(ReadOnlySpan inputSpan, Span digits) + { int digitCount = 1; digits[0] = 0; @@ -75,31 +182,67 @@ internal string EncodeGeneric(ReadOnlySpan data) } } - int resultSize = leadingZeros + digitCount; - return string.Create(resultSize, new EncodeGenericFinalString(_characters.Span, digits, _firstCharacter, leadingZeros, digitCount), static (span, state) => + return digitCount; + } + + [SkipLocalsInit] + internal static string EncodeBitcoin32FastToString(ReadOnlySpan data) + { + int inLeadingZeros = Base58.CountLeadingZeros(data); + + if (inLeadingZeros == data.Length) { - if (state.LeadingZeroes > 0) - { - span[..state.LeadingZeroes].Fill(state.FirstCharacter); - } + return new string('1', inLeadingZeros); + } - int index = state.LeadingZeroes; - for (int i = state.DigitCount - 1; i >= 0; i--) - { - span[index++] = state.Alphabet[state.Digits[i]]; - } - }); + Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32]; + int rawLeadingZeros = ComputeBitcoin32FastRaw(data, rawBase58); + + int skip = rawLeadingZeros - inLeadingZeros; + Debug.Assert(skip >= 0, "rawLeadingZeros should always be >= inLeadingZeros by Base58 math"); + int digitCount = Base58BitcoinTables.Raw58Sz32 - rawLeadingZeros; + + var state = new EncodeState(rawBase58, rawLeadingZeros, digitCount, Base58BitcoinTables.BitcoinChars, (byte)'1', inLeadingZeros); + return string.Create(state.OutputLength, state, static (span, s) => s.EmitForward(span)); } - internal static string EncodeBitcoin32Fast(ReadOnlySpan data) + [SkipLocalsInit] + private static int EncodeBitcoin32FastToBytes(ReadOnlySpan data, Span destination) { - int inLeadingZeros = CountLeadingZeros(data); + int inLeadingZeros = Base58.CountLeadingZeros(data); if (inLeadingZeros == data.Length) { - return new string('1', inLeadingZeros); + if (destination.Length < inLeadingZeros) + { + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); + } + + destination[..inLeadingZeros].Fill((byte)'1'); + return inLeadingZeros; } + Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32]; + int rawLeadingZeros = ComputeBitcoin32FastRaw(data, rawBase58); + + int skip = rawLeadingZeros - inLeadingZeros; + Debug.Assert(skip >= 0, "rawLeadingZeros should always be >= inLeadingZeros by Base58 math"); + int digitCount = Base58BitcoinTables.Raw58Sz32 - rawLeadingZeros; + int outputLength = inLeadingZeros + digitCount; + + if (destination.Length < outputLength) + { + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); + } + + var state = new EncodeState(rawBase58, rawLeadingZeros, digitCount, Base58BitcoinTables.BitcoinChars, (byte)'1', inLeadingZeros); + state.EmitForward(destination); + return outputLength; + } + + [SkipLocalsInit] + private static int ComputeBitcoin32FastRaw(ReadOnlySpan data, Span rawBase58) + { // Convert 32 bytes to 8 uint32 limbs (big-endian) Span binary = stackalloc uint[Base58BitcoinTables.BinarySz32]; for (int i = 0; i < Base58BitcoinTables.BinarySz32; i++) @@ -128,8 +271,7 @@ internal static string EncodeBitcoin32Fast(ReadOnlySpan data) intermediate[i] %= Base58BitcoinTables.R1Div; } - // Convert intermediate form to raw base58 digits - Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32]; + // Convert intermediate form to raw base58 digits (5 digits per limb) for (int i = 0; i < Base58BitcoinTables.IntermediateSz32; i++) { uint v = (uint)intermediate[i]; @@ -141,47 +283,75 @@ internal static string EncodeBitcoin32Fast(ReadOnlySpan data) rawBase58[5 * i + 0] = (byte)(v / 11316496U); } - // Count leading zeros in raw output + // Count leading zeros in raw output — some come from input zero bytes, + // some are mathematical padding (45-digit form slightly overshoots 44 chars max). int rawLeadingZeros = 0; for (; rawLeadingZeros < Base58BitcoinTables.Raw58Sz32; rawLeadingZeros++) { if (rawBase58[rawLeadingZeros] != 0) break; } - // Calculate skip and final length (match Firedancer exactly) + return rawLeadingZeros; + } + + [SkipLocalsInit] + internal static string EncodeBitcoin64FastToString(ReadOnlySpan data) + { + int inLeadingZeros = Base58.CountLeadingZeros(data); + + if (inLeadingZeros == data.Length) + { + return new string('1', inLeadingZeros); + } + + Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz64]; + int rawLeadingZeros = ComputeBitcoin64FastRaw(data, rawBase58); + int skip = rawLeadingZeros - inLeadingZeros; Debug.Assert(skip >= 0, "rawLeadingZeros should always be >= inLeadingZeros by Base58 math"); - int outputLength = Base58BitcoinTables.Raw58Sz32 - skip; + int digitCount = Base58BitcoinTables.Raw58Sz64 - rawLeadingZeros; + + var state = new EncodeState(rawBase58, rawLeadingZeros, digitCount, Base58BitcoinTables.BitcoinChars, (byte)'1', inLeadingZeros); + return string.Create(state.OutputLength, state, static (span, s) => s.EmitForward(span)); + } - var state = new EncodeFastState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength); - return string.Create(outputLength, state, static (span, state) => + [SkipLocalsInit] + private static int EncodeBitcoin64FastToBytes(ReadOnlySpan data, Span destination) + { + int inLeadingZeros = Base58.CountLeadingZeros(data); + + if (inLeadingZeros == data.Length) { - if (state.InLeadingZeros > 0) + if (destination.Length < inLeadingZeros) { - span[..state.InLeadingZeros].Fill('1'); + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } - // Convert remaining raw base58 digits to characters - // Read from rawLeadingZeros onwards (where the actual digits are) - var bitcoinChars = Base58BitcoinTables.BitcoinChars; - for (int i = 0; i < state.OutputLength - state.InLeadingZeros; i++) - { - byte digit = state.RawBase58[state.RawLeadingZeros + i]; - Debug.Assert(digit < 58, $"Base58 digit should always be < 58, got {digit}"); - span[state.InLeadingZeros + i] = bitcoinChars[digit]; - } - }); - } + destination[..inLeadingZeros].Fill((byte)'1'); + return inLeadingZeros; + } - private static string EncodeBitcoin64Fast(ReadOnlySpan data) - { - int inLeadingZeros = CountLeadingZeros(data); + Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz64]; + int rawLeadingZeros = ComputeBitcoin64FastRaw(data, rawBase58); - if (inLeadingZeros == data.Length) + int skip = rawLeadingZeros - inLeadingZeros; + Debug.Assert(skip >= 0, "rawLeadingZeros should always be >= inLeadingZeros by Base58 math"); + int digitCount = Base58BitcoinTables.Raw58Sz64 - rawLeadingZeros; + int outputLength = inLeadingZeros + digitCount; + + if (destination.Length < outputLength) { - return new string('1', inLeadingZeros); + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } + var state = new EncodeState(rawBase58, rawLeadingZeros, digitCount, Base58BitcoinTables.BitcoinChars, (byte)'1', inLeadingZeros); + state.EmitForward(destination); + return outputLength; + } + + [SkipLocalsInit] + private static int ComputeBitcoin64FastRaw(ReadOnlySpan data, Span rawBase58) + { // Convert 64 bytes to 16 uint32 limbs (big-endian) Span binary = stackalloc uint[Base58BitcoinTables.BinarySz64]; for (int i = 0; i < Base58BitcoinTables.BinarySz64; i++) @@ -190,12 +360,12 @@ private static string EncodeBitcoin64Fast(ReadOnlySpan data) binary[i] = BinaryPrimitives.ReadUInt32BigEndian(data.Slice(offset, sizeof(uint))); } - // Convert to intermediate format (base 58^5) + // Convert to intermediate format (base 58^5). For 64-byte input we must + // split the matrix multiplication and interleave a mini-reduction to + // keep intermediate limbs from overflowing (matches Firedancer exactly). Span intermediate = stackalloc ulong[Base58BitcoinTables.IntermediateSz64]; intermediate.Clear(); - // Matrix multiplication: intermediate = binary * EncodeTable64 - // For 64-byte, we need to handle potential overflow like Firedancer does for (int i = 0; i < 8; i++) { for (int j = 0; j < Base58BitcoinTables.IntermediateSz64 - 1; j++) @@ -224,8 +394,7 @@ private static string EncodeBitcoin64Fast(ReadOnlySpan data) intermediate[i] %= Base58BitcoinTables.R1Div; } - // Convert intermediate form to raw base58 digits - Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz64]; + // Convert intermediate form to raw base58 digits (5 digits per limb) for (int i = 0; i < Base58BitcoinTables.IntermediateSz64; i++) { uint v = (uint)intermediate[i]; @@ -241,68 +410,71 @@ private static string EncodeBitcoin64Fast(ReadOnlySpan data) $"Invalid base58 digit generated at position {i} - algorithm bug"); } - // Count leading zeros in raw output int rawLeadingZeros = 0; for (; rawLeadingZeros < Base58BitcoinTables.Raw58Sz64; rawLeadingZeros++) { if (rawBase58[rawLeadingZeros] != 0) break; } - int skip = rawLeadingZeros - inLeadingZeros; - Debug.Assert(skip >= 0, "rawLeadingZeros should always be >= inLeadingZeros by Base58 math"); - int outputLength = Base58BitcoinTables.Raw58Sz64 - skip; + return rawLeadingZeros; + } + + private readonly ref struct EncodeState + { + public readonly ReadOnlySpan Digits; + public readonly ReadOnlySpan Alphabet; + public readonly int DigitStart; + public readonly int DigitCount; + public readonly byte LeadingFill; + public readonly int LeadingCount; + + public EncodeState( + ReadOnlySpan digits, + int digitStart, + int digitCount, + ReadOnlySpan alphabet, + byte leadingFill, + int leadingCount) + { + Digits = digits; + DigitStart = digitStart; + DigitCount = digitCount; + Alphabet = alphabet; + LeadingFill = leadingFill; + LeadingCount = leadingCount; + } + + public int OutputLength => LeadingCount + DigitCount; - var state = new EncodeFastState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength); - return string.Create(outputLength, state, static (span, state) => + public void EmitForward(Span destination) + where TChar : unmanaged, IBinaryInteger { - if (state.InLeadingZeros > 0) + if (LeadingCount > 0) { - span[..state.InLeadingZeros].Fill('1'); + destination[..LeadingCount].Fill(TChar.CreateTruncating(LeadingFill)); } - // Convert remaining raw base58 digits to characters - // Read from rawLeadingZeros onwards (where the actual digits are) - var bitcoinChars = Base58BitcoinTables.BitcoinChars; - for (int i = 0; i < state.OutputLength - state.InLeadingZeros; i++) + int index = LeadingCount; + int end = DigitStart + DigitCount; + for (int i = DigitStart; i < end; i++) { - byte digit = state.RawBase58[state.RawLeadingZeros + i]; - Debug.Assert(digit < 58, $"Base58 digit should always be < 58, got {digit}"); - span[state.InLeadingZeros + i] = bitcoinChars[digit]; + destination[index++] = TChar.CreateTruncating((ushort)Alphabet[Digits[i]]); } - }); - } - - internal readonly ref struct EncodeFastState - { - public readonly ReadOnlySpan RawBase58; - public readonly int InLeadingZeros; - public readonly int RawLeadingZeros; - public readonly int OutputLength; - - public EncodeFastState(ReadOnlySpan rawBase58, int inLeadingZeros, int rawLeadingZeros, int outputLength) - { - RawBase58 = rawBase58; - InLeadingZeros = inLeadingZeros; - RawLeadingZeros = rawLeadingZeros; - OutputLength = outputLength; } - } - - internal readonly ref struct EncodeGenericFinalString - { - public readonly ReadOnlySpan Alphabet; - public readonly ReadOnlySpan Digits; - public readonly char FirstCharacter; - public readonly int LeadingZeroes; - public readonly int DigitCount; - public EncodeGenericFinalString(ReadOnlySpan alphabet, ReadOnlySpan digits, char firstCharacter, int leadingZeroes, int digitCount) + public void EmitReverse(Span destination) + where TChar : unmanaged, IBinaryInteger { - Alphabet = alphabet; - Digits = digits; - FirstCharacter = firstCharacter; - LeadingZeroes = leadingZeroes; - DigitCount = digitCount; + if (LeadingCount > 0) + { + destination[..LeadingCount].Fill(TChar.CreateTruncating(LeadingFill)); + } + + int index = LeadingCount; + for (int i = DigitStart + DigitCount - 1; i >= DigitStart; i--) + { + destination[index++] = TChar.CreateTruncating((ushort)Alphabet[Digits[i]]); + } } } } diff --git a/src/Base58Encoding/Base58.Length.cs b/src/Base58Encoding/Base58.Length.cs new file mode 100644 index 0000000..666af08 --- /dev/null +++ b/src/Base58Encoding/Base58.Length.cs @@ -0,0 +1,58 @@ +namespace Base58Encoding; + +public static partial class Base58 +{ + /// + /// Returns a safe upper bound for the number of encoded Base58 characters + /// produced from an input of the given byte length. + /// + /// Length of the input data in bytes. + /// Maximum number of characters/bytes written by Encode. + public static int GetMaxEncodedLength(int byteCount) + { + if (byteCount < 0) + { + ThrowHelper.ThrowNegativeLength(nameof(byteCount)); + } + + if (byteCount == 0) + { + return 0; + } + + return byteCount * 138 / 100 + 1; + } + + /// + /// Returns a typical upper bound for the number of decoded bytes produced + /// from a Base58 input of the given length. Suitable for sizing destination + /// buffers for the common case where the input has no leading '1' characters. + /// + /// Length of the encoded input (chars or ASCII bytes). + /// Typical maximum number of bytes written by Decode. + /// + /// Base58 expansion is asymmetric: ordinary content decodes at about 0.733 bytes + /// per character, but each leading '1' decodes 1:1 to a zero byte. This method + /// returns the typical-case bound (encodedLength * 733 / 1000 + 1). + /// For inputs containing leading '1' characters the actual decoded length can + /// exceed this bound — up to in the degenerate + /// all-'1's case. If you need a safe bound for arbitrary inputs, size the + /// destination at . If you already have the + /// input and want a tight bound, count leading '1's (L) and use + /// L + (encodedLength - L) * 733 / 1000 + 1. + /// + public static int GetTypicalDecodedLength(int encodedLength) + { + if (encodedLength < 0) + { + ThrowHelper.ThrowNegativeLength(nameof(encodedLength)); + } + + if (encodedLength == 0) + { + return 0; + } + + return encodedLength * 733 / 1000 + 1; + } +} diff --git a/src/Base58Encoding/Base58.cs b/src/Base58Encoding/Base58.cs index 32af5f6..9cf8103 100644 --- a/src/Base58Encoding/Base58.cs +++ b/src/Base58Encoding/Base58.cs @@ -1,26 +1,20 @@ namespace Base58Encoding; -public partial class Base58 +public static partial class Base58 { - private const int Base = 58; - private const int MaxStackallocByte = 512; + public static Base58 Bitcoin { get; } = new(); + public static Base58 Ripple { get; } = new(); + public static Base58 Flickr { get; } = new(); - private readonly ReadOnlyMemory _characters; - private readonly ReadOnlyMemory _decodeTable; - private readonly char _firstCharacter; + internal static string EncodeBitcoin32Fast(ReadOnlySpan data) + => Base58.EncodeBitcoin32FastToString(data); - private static readonly Lazy _bitcoin = new(() => new(Base58Alphabet.Bitcoin)); - private static readonly Lazy _ripple = new(() => new(Base58Alphabet.Ripple)); - private static readonly Lazy _flickr = new(() => new(Base58Alphabet.Flickr)); + internal static string EncodeBitcoin64Fast(ReadOnlySpan data) + => Base58.EncodeBitcoin64FastToString(data); - public static Base58 Bitcoin => _bitcoin.Value; - public static Base58 Ripple => _ripple.Value; - public static Base58 Flickr => _flickr.Value; + internal static byte[]? DecodeBitcoin32Fast(ReadOnlySpan encoded) + => Base58.DecodeBitcoin32Fast(encoded); - private Base58(Base58Alphabet alphabet) - { - _characters = alphabet.Characters; - _decodeTable = alphabet.DecodeTable; - _firstCharacter = alphabet.FirstCharacter; - } + internal static byte[]? DecodeBitcoin64Fast(ReadOnlySpan encoded) + => Base58.DecodeBitcoin64Fast(encoded); } diff --git a/src/Base58Encoding/Base58Alphabet.cs b/src/Base58Encoding/Base58Alphabet.cs index bf908aa..e5794b9 100644 --- a/src/Base58Encoding/Base58Alphabet.cs +++ b/src/Base58Encoding/Base58Alphabet.cs @@ -1,48 +1,11 @@ namespace Base58Encoding; -public class Base58Alphabet +public readonly struct BitcoinAlphabet : IBase58Alphabet { - public const string BitcoinAlphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; - public const string RippleAlphabet = "rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"; - public const string FlickrAlphabet = "123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; - - public readonly ReadOnlyMemory Characters; - public readonly ReadOnlyMemory DecodeTable; - public readonly char FirstCharacter; - - private Base58Alphabet(ReadOnlyMemory characters, ReadOnlyMemory decodeTable, char firstCharacter) - { - Characters = characters; - DecodeTable = decodeTable; - FirstCharacter = firstCharacter; - } - - // Cached static instances for common alphabets - private static readonly Lazy _bitcoin = new(() => new( - BitcoinAlphabet.AsMemory(), - BitcoinDecodeTable, - '1' - )); - - private static readonly Lazy _ripple = new(() => new( - RippleAlphabet.AsMemory(), - RippleDecodeTable, - 'r' - )); - - private static readonly Lazy _flickr = new(() => new( - FlickrAlphabet.AsMemory(), - FlickrDecodeTable, - '1' - )); - - public static Base58Alphabet Bitcoin => _bitcoin.Value; - public static Base58Alphabet Ripple => _ripple.Value; - public static Base58Alphabet Flickr => _flickr.Value; - - // Static decode tables - using ReadOnlyMemory for better performance - private static readonly ReadOnlyMemory BitcoinDecodeTable = new byte[] - { + public static ReadOnlySpan Characters => "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"u8; + public static byte FirstCharacter => (byte)'1'; + public static ReadOnlySpan DecodeTable => + [ 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, @@ -50,11 +13,16 @@ private Base58Alphabet(ReadOnlyMemory characters, ReadOnlyMemory dec 255, 9, 10, 11, 12, 13, 14, 15, 16, 255, 17, 18, 19, 20, 21, 255, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 255, 255, 255, 255, 255, 255, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 255, 44, 45, 46, - 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 255, 255, 255, 255, 255 - }; + 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 255, 255, 255, 255, 255, + ]; +} - private static readonly ReadOnlyMemory RippleDecodeTable = new byte[] - { +public readonly struct RippleAlphabet : IBase58Alphabet +{ + public static ReadOnlySpan Characters => "rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"u8; + public static byte FirstCharacter => (byte)'r'; + public static ReadOnlySpan DecodeTable => + [ 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, @@ -62,11 +30,16 @@ private Base58Alphabet(ReadOnlyMemory characters, ReadOnlyMemory dec 255, 54, 10, 38, 12, 14, 47, 15, 16, 255, 17, 18, 19, 20, 13, 255, 22, 23, 24, 25, 26, 11, 28, 29, 30, 31, 32, 255, 255, 255, 255, 255, 255, 5, 34, 35, 36, 37, 6, 39, 3, 49, 42, 43, 255, 44, 4, 46, - 1, 48, 0, 2, 51, 52, 53, 9, 55, 56, 57, 255, 255, 255, 255, 255 - }; + 1, 48, 0, 2, 51, 52, 53, 9, 55, 56, 57, 255, 255, 255, 255, 255, + ]; +} - private static readonly ReadOnlyMemory FlickrDecodeTable = new byte[] - { +public readonly struct FlickrAlphabet : IBase58Alphabet +{ + public static ReadOnlySpan Characters => "123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"u8; + public static byte FirstCharacter => (byte)'1'; + public static ReadOnlySpan DecodeTable => + [ 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, @@ -74,6 +47,6 @@ private Base58Alphabet(ReadOnlyMemory characters, ReadOnlyMemory dec 255, 34, 35, 36, 37, 38, 39, 40, 41, 255, 42, 43, 44, 45, 46, 255, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 255, 255, 255, 255, 255, 255, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 255, 20, 21, 22, - 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 255, 255, 255, 255, 255 - }; + 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 255, 255, 255, 255, 255, + ]; } diff --git a/src/Base58Encoding/Base58BitcoinTables.cs b/src/Base58Encoding/Base58BitcoinTables.cs index b3ac9e4..14ed12b 100644 --- a/src/Base58Encoding/Base58BitcoinTables.cs +++ b/src/Base58Encoding/Base58BitcoinTables.cs @@ -9,7 +9,7 @@ internal static class Base58BitcoinTables internal const byte InverseTableOffset = (byte)'1'; // Characters are offset by '1' // Bitcoin alphabet for fast character mapping - internal static ReadOnlySpan BitcoinChars => "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + internal static ReadOnlySpan BitcoinChars => "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"u8; // Constants for 32-byte encoding/decoding (from Firedancer) internal const int BinarySz32 = 8; diff --git a/src/Base58Encoding/Base58Encoding.csproj b/src/Base58Encoding/Base58Encoding.csproj index 253c759..a5997b0 100644 --- a/src/Base58Encoding/Base58Encoding.csproj +++ b/src/Base58Encoding/Base58Encoding.csproj @@ -1,34 +1,28 @@  - - net10.0 - enable - enable + + Base58Encoding + Nikolay Zdravkov + A high-performance Base58 encoding/decoding library for .NET + base58;encoding;bitcoin;solana;cryptocurrency + https://github.com/unsafePtr/Base58Encoding + https://github.com/unsafePtr/Base58Encoding + MIT + PACKAGE.md + false + - Base58Encoding - Nikolay Zdravkov - A high-performance Base58 encoding/decoding library for .NET - base58;encoding;bitcoin;cryptocurrency - https://github.com/unsafePtr/Base58Encoding - https://github.com/unsafePtr/Base58Encoding - MIT - README.md - false - + + + <_Parameter1>Base58Encoding.Benchmarks + + + <_Parameter1>Base58Encoding.Tests + + - - - <_Parameter1>Base58Encoding.Benchmarks - - - - - <_Parameter1>Base58Encoding.Tests - - - - - - + + + diff --git a/src/Base58Encoding/Base58Generic.cs b/src/Base58Encoding/Base58Generic.cs new file mode 100644 index 0000000..0b9fe36 --- /dev/null +++ b/src/Base58Encoding/Base58Generic.cs @@ -0,0 +1,21 @@ +namespace Base58Encoding; + +public sealed partial class Base58 + where TAlphabet : struct, IBase58Alphabet +{ + private const int Base = 58; + private const int MaxStackallocByte = 256; + + internal string EncodeGeneric(ReadOnlySpan data) + => data.IsEmpty ? string.Empty : EncodeGenericToString(data); + + internal byte[] DecodeGeneric(ReadOnlySpan encoded) + { + if (encoded.IsEmpty) + { + return []; + } + + return DecodeGenericToArray(encoded); + } +} diff --git a/src/Base58Encoding/IBase58Alphabet.cs b/src/Base58Encoding/IBase58Alphabet.cs new file mode 100644 index 0000000..31e406d --- /dev/null +++ b/src/Base58Encoding/IBase58Alphabet.cs @@ -0,0 +1,8 @@ +namespace Base58Encoding; + +public interface IBase58Alphabet +{ + static abstract ReadOnlySpan Characters { get; } + static abstract ReadOnlySpan DecodeTable { get; } + static abstract byte FirstCharacter { get; } +} diff --git a/src/Base58Encoding/ThrowHelper.cs b/src/Base58Encoding/ThrowHelper.cs index 642b310..3d4fb5d 100644 --- a/src/Base58Encoding/ThrowHelper.cs +++ b/src/Base58Encoding/ThrowHelper.cs @@ -9,4 +9,16 @@ public static void ThrowInvalidCharacter(char character) { throw new ArgumentException($"Invalid Base58 character: '{character}'"); } + + [DoesNotReturn] + public static void ThrowDestinationTooSmall(string paramName) + { + throw new ArgumentException("Destination buffer is too small.", paramName); + } + + [DoesNotReturn] + public static void ThrowNegativeLength(string paramName) + { + throw new ArgumentOutOfRangeException(paramName, "Length must be non-negative."); + } } diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..0f41b38 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + true + + + true + true + true + + + true + + + true + + + true + + + true + + + false + false + false + false + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props new file mode 100644 index 0000000..67b44f0 --- /dev/null +++ b/src/Directory.Packages.props @@ -0,0 +1,14 @@ + + + + true + + + + + + + + + + diff --git a/src/NuGet.Config b/src/NuGet.Config new file mode 100644 index 0000000..4d736c1 --- /dev/null +++ b/src/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/PACKAGE.md b/src/PACKAGE.md new file mode 100644 index 0000000..98b38e8 --- /dev/null +++ b/src/PACKAGE.md @@ -0,0 +1,100 @@ +# Base58Encoding + +A high-performance .NET 10 Base58 encoding and decoding library with support for multiple alphabet variants. + +## Features + +- **Multiple Alphabets**: Built-in support for Bitcoin (IPFS/Sui/Solana), Ripple, and Flickr alphabets +- **Optimized Hot Paths**: Firedancer-based fast paths for 32-byte and 64-byte inputs (up to 15x faster than SimpleBase) +- **Zero-allocation API**: Encode and decode directly into caller-owned buffers +- **SIMD**: Uses `Vector256` for counting leading zeros + +## Alphabets + +| Property | First Character | Used By | +|---|---|---| +| `Base58.Bitcoin` | `1` | Bitcoin, IPFS, Solana, Sui, Monero | +| `Base58.Ripple` | `r` | Ripple (XRP) | +| `Base58.Flickr` | `1` | Flickr short URLs | + +## API + +### Allocating API + +```csharp +string Encode(ReadOnlySpan data) +``` +Encodes `data` and returns a new Base58 string. Returns `""` for empty input. + +```csharp +byte[] Decode(ReadOnlySpan encoded) +``` +Decodes a Base58 string and returns a new byte array. Throws `ArgumentException` on invalid characters. + +### Zero-allocation API + +```csharp +int Encode(ReadOnlySpan data, Span destination) +``` +Encodes `data` as ASCII Base58 bytes into `destination`. Returns the number of bytes written. +Throws `ArgumentException` if `destination` is too small. Use `Base58.GetMaxEncodedLength` to size the buffer. + +```csharp +int Decode(ReadOnlySpan encoded, Span destination) +int Decode(ReadOnlySpan encoded, Span destination) +``` +Decodes Base58 chars (or ASCII bytes) into `destination`. Returns the number of bytes written. +Throws `ArgumentException` on invalid characters or if `destination` is too small. +Use `Base58.GetTypicalDecodedLength` to size the buffer for typical inputs. + +### Buffer sizing helpers + +```csharp +static int Base58.GetMaxEncodedLength(int byteCount) +``` +Returns a safe upper bound for the number of Base58 characters produced from `byteCount` bytes. +Formula: `byteCount * 138 / 100 + 1`. Use this to size the `destination` buffer for `Encode`. + +```csharp +static int Base58.GetTypicalDecodedLength(int encodedLength) +``` +Returns a typical upper bound for the decoded byte count from an encoded input of `encodedLength` characters. +Formula: `encodedLength * 733 / 1000 + 1`. Suitable for inputs without leading `1` characters. +For inputs that may contain leading `1`s, size the destination at `encodedLength` (safe upper bound). + +## Usage + +### Allocating API + +```csharp +using Base58Encoding; + +byte[] data = { 0x01, 0x02, 0x03, 0x04 }; + +string encoded = Base58.Bitcoin.Encode(data); +byte[] decoded = Base58.Bitcoin.Decode(encoded); + +// Ripple / Flickr alphabets +string ripple = Base58.Ripple.Encode(data); +string flickr = Base58.Flickr.Encode(data); +``` + +### Zero-allocation API + +```csharp +using Base58Encoding; + +byte[] data = { 0x01, 0x02, 0x03, 0x04 }; + +// Encode into a caller-owned buffer +Span encodedBytes = stackalloc byte[Base58.GetMaxEncodedLength(data.Length)]; +int written = Base58.Bitcoin.Encode(data, encodedBytes); + +// Decode from a byte span into a caller-owned buffer +Span decodedBytes = stackalloc byte[Base58.GetTypicalDecodedLength(written)]; +int decodedLen = Base58.Bitcoin.Decode(encodedBytes[..written], decodedBytes); +``` + +## License + +MIT