From b29a501c36e266a8acc3fce35123285ec11adb1d Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 17:36:58 +0300 Subject: [PATCH 01/35] test: skip Vector256-dependent leading-zero tests on unsupported platforms --- src/Base58Encoding.Tests/SimpleLeadingZerosTest.cs | 4 ++++ 1 file changed, 4 insertions(+) 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]; From 58663d6256d48039b9eee544c63fa0cb3553baa9 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 18:02:03 +0300 Subject: [PATCH 02/35] feat: add zero-alloc Encode/Decode buffer-writing overloads --- .../JaggedVsMultidimensionalArrayBenchmark.cs | 20 +- .../Base58ZeroAllocTests.cs | 344 +++++++++++++++ src/Base58Encoding/Base58.Decode.cs | 417 +++++++++++++----- src/Base58Encoding/Base58.Encode.cs | 369 +++++++++++----- src/Base58Encoding/Base58.Length.cs | 58 +++ src/Base58Encoding/ThrowHelper.cs | 12 + 6 files changed, 988 insertions(+), 232 deletions(-) create mode 100644 src/Base58Encoding.Tests/Base58ZeroAllocTests.cs create mode 100644 src/Base58Encoding/Base58.Length.cs diff --git a/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs b/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs index 48d78f8..f472425 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) @@ -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) diff --git a/src/Base58Encoding.Tests/Base58ZeroAllocTests.cs b/src/Base58Encoding.Tests/Base58ZeroAllocTests.cs new file mode 100644 index 0000000..f8327ec --- /dev/null +++ b/src/Base58Encoding.Tests/Base58ZeroAllocTests.cs @@ -0,0 +1,344 @@ +using System.Text; + +namespace Base58Encoding.Tests; + +public class Base58ZeroAllocTests +{ + [Fact] + public void GetEncodedLength_ReturnsUpperBound() + { + Assert.Equal(0, Base58.GetEncodedLength(0)); + Assert.True(Base58.GetEncodedLength(1) >= 2); + Assert.True(Base58.GetEncodedLength(32) >= 44); + Assert.True(Base58.GetEncodedLength(64) >= 88); + } + + [Fact] + public void GetEncodedLength_NegativeThrows() + { + Assert.Throws(() => Base58.GetEncodedLength(-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.GetEncodedLength(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.GetEncodedLength(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.GetEncodedLength(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.GetEncodedLength(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.GetEncodedLength(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.GetEncodedLength(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.GetEncodedLength(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/Base58.Decode.cs b/src/Base58Encoding/Base58.Decode.cs index 6268fbf..ce29fca 100644 --- a/src/Base58Encoding/Base58.Decode.cs +++ b/src/Base58Encoding/Base58.Decode.cs @@ -1,109 +1,255 @@ +using System.Buffers; using System.Buffers.Binary; +using System.Numerics; namespace Base58Encoding; public partial class Base58 { /// - /// 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 + // Bitcoin fast-path dispatch: allocate the fixed-size result up front + // and write directly into it. On fallback we discard and re-decode. if (ReferenceEquals(this, _bitcoin.Value)) { - // 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; + } - int leadingOnes = CountLeadingCharacters(encoded, _firstCharacter); + return DecodeCore(encoded, destination); + } - int outputSize = encoded.Length * 733 / 1000 + 1; + private int DecodeCore(ReadOnlySpan encoded, Span destination) + where TChar : unmanaged, IBinaryInteger + { + if (ReferenceEquals(this, _bitcoin.Value)) + { + 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; + } + } + } - Span decoded = outputSize > MaxStackallocByte - ? new byte[outputSize] - : stackalloc byte[outputSize]; + return DecodeGenericCore(encoded, destination); + } + + private int DecodeGenericCore(ReadOnlySpan encoded, Span destination) + where TChar : unmanaged, IBinaryInteger + { + TChar firstChar = TChar.CreateTruncating((ushort)_firstCharacter); + int leadingOnes = CountLeading(encoded, firstChar); + + int scratchSize = encoded.Length * 733 / 1000 + 1; + byte[]? rented = null; + try + { + Span decoded = scratchSize > MaxStackallocByte + ? (rented = ArrayPool.Shared.Rent(scratchSize)) + : 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; + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented, clearArray: true); + } + } + } + + private byte[] DecodeGenericToArray(ReadOnlySpan encoded) + where TChar : unmanaged, IBinaryInteger + { + TChar firstChar = TChar.CreateTruncating((ushort)_firstCharacter); + int leadingOnes = CountLeading(encoded, firstChar); + + if (leadingOnes == encoded.Length) + { + return new byte[leadingOnes]; + } + + int scratchSize = encoded.Length * 733 / 1000 + 1; + byte[]? rented = null; + try + { + Span decoded = scratchSize > MaxStackallocByte + ? (rented = ArrayPool.Shared.Rent(scratchSize)) + : stackalloc byte[scratchSize]; + + int decodedLength = ComputeGenericDecode(encoded, leadingOnes, decoded); + + // Allocate exact-sized heap result, then emit directly into it — single copy. + byte[] result = new byte[leadingOnes + decodedLength]; + EmitGenericDecode(result, leadingOnes, decoded, decodedLength); + return result; + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented, clearArray: true); + } + } + } + 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 = _decodeTable.Span; 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; + private static int CountLeading(ReadOnlySpan text, TChar target) + where TChar : unmanaged, IBinaryInteger + { + int mismatchIndex = text.IndexOfAnyExcept(target); + return mismatchIndex == -1 ? text.Length : mismatchIndex; } - 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. + /// + private 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 = Base58Alphabet.Bitcoin.DecodeTable.Span; - // Prepend zeros to make exactly Raw58Sz32 characters int prepend0 = Base58BitcoinTables.Raw58Sz32 - charCount; for (int j = 0; j < Base58BitcoinTables.Raw58Sz32; j++) { @@ -113,28 +259,27 @@ internal byte[] DecodeGeneric(ReadOnlySpan encoded) } else { - char c = encoded[j - prepend0]; - // Validate + convert using Bitcoin decode table - if (c >= 128 || bitcoinDecodeTable[c] == 255) - ThrowHelper.ThrowInvalidCharacter(c); + int c = int.CreateTruncating(encoded[j - prepend0]); + if ((uint)c >= 128 || bitcoinDecodeTable[c] == 255) + { + ThrowHelper.ThrowInvalidCharacter((char)c); + } rawBase58[j] = bitcoinDecodeTable[c]; } } - // Convert to intermediate format (base 58^5) Span intermediate = stackalloc ulong[Base58BitcoinTables.IntermediateSz32]; 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 + intermediate[i] = (ulong)rawBase58[5 * i + 0] * 11316496UL + + (ulong)rawBase58[5 * i + 1] * 195112UL + + (ulong)rawBase58[5 * i + 2] * 3364UL + + (ulong)rawBase58[5 * i + 3] * 58UL + + (ulong)rawBase58[5 * i + 4] * 1UL; } - // Convert to overcomplete base 2^32 using decode table Span binary = stackalloc ulong[Base58BitcoinTables.BinarySz32]; for (int j = 0; j < Base58BitcoinTables.BinarySz32; j++) @@ -147,59 +292,62 @@ internal byte[] DecodeGeneric(ReadOnlySpan encoded) binary[j] = acc; } - // 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 32-byte output directly from binary[] + // without materializing the output. 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++) + TChar one = TChar.CreateTruncating((ushort)'1'); + int inputLeadingOnes = CountLeading(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; + 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) + private 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 = Base58Alphabet.Bitcoin.DecodeTable.Span; - // Prepend zeros to make exactly Raw58Sz64 characters int prepend0 = Base58BitcoinTables.Raw58Sz64 - charCount; for (int j = 0; j < Base58BitcoinTables.Raw58Sz64; j++) { @@ -209,28 +357,27 @@ internal byte[] DecodeGeneric(ReadOnlySpan encoded) } else { - char c = encoded[j - prepend0]; - // Validate + convert using Bitcoin decode table - if (c >= 128 || bitcoinDecodeTable[c] == 255) - ThrowHelper.ThrowInvalidCharacter(c); + int c = int.CreateTruncating(encoded[j - prepend0]); + if ((uint)c >= 128 || bitcoinDecodeTable[c] == 255) + { + ThrowHelper.ThrowInvalidCharacter((char)c); + } rawBase58[j] = bitcoinDecodeTable[c]; } } - // Convert to intermediate format (base 58^5) Span intermediate = stackalloc ulong[Base58BitcoinTables.IntermediateSz64]; 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 + intermediate[i] = (ulong)rawBase58[5 * i + 0] * 11316496UL + + (ulong)rawBase58[5 * i + 1] * 195112UL + + (ulong)rawBase58[5 * i + 2] * 3364UL + + (ulong)rawBase58[5 * i + 3] * 58UL + + (ulong)rawBase58[5 * i + 4] * 1UL; } - // Convert to overcomplete base 2^32 using decode table Span binary = stackalloc ulong[Base58BitcoinTables.BinarySz64]; for (int j = 0; j < Base58BitcoinTables.BinarySz64; j++) @@ -243,47 +390,73 @@ internal byte[] DecodeGeneric(ReadOnlySpan encoded) binary[j] = acc; } - // 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; + } + + int outputLeadingZeros = 0; + for (int i = 0; i < Base58BitcoinTables.BinarySz64; i++) + { + uint v = (uint)binary[i]; + if (v != 0) + { + outputLeadingZeros += BitOperations.LeadingZeroCount(v) / 8; + break; + } + outputLeadingZeros += 4; + } + + TChar one = TChar.CreateTruncating((ushort)'1'); + int inputLeadingOnes = CountLeading(encoded, one); + + if (outputLeadingZeros != inputLeadingOnes) + { + return -1; + } + + if (destination.Length < 64) + { + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); + } - // Convert to big-endian byte output - var result = new byte[64]; 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); + BinaryPrimitives.WriteUInt32BigEndian(destination.Slice(offset, sizeof(uint)), value); } - // Count leading zeros in output - int outputLeadingZeros = 0; - for (int i = 0; i < 64; i++) - { - if (result[i] != 0) break; - outputLeadingZeros++; - } + return 64; + } - // Count leading '1's in input - int inputLeadingOnes = 0; - for (int i = 0; i < encoded.Length; i++) + internal byte[] DecodeGeneric(ReadOnlySpan encoded) + { + if (encoded.IsEmpty) { - if (encoded[i] != '1') break; - inputLeadingOnes++; + return []; } - // 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; + return DecodeGenericToArray(encoded); + } - // 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..82ece46 100644 --- a/src/Base58Encoding/Base58.Encode.cs +++ b/src/Base58Encoding/Base58.Encode.cs @@ -1,45 +1,66 @@ +using System.Buffers; using System.Buffers.Binary; using System.Diagnostics; +using System.Numerics; namespace Base58Encoding; public partial class Base58 { /// - /// 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)) { 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; + } + + if (ReferenceEquals(this, _bitcoin.Value)) + { + return data.Length switch + { + 32 => EncodeBitcoin32FastToBytes(data, destination), + 64 => EncodeBitcoin64FastToBytes(data, destination), + _ => EncodeGenericToBytes(data, destination) + }; + } + + return EncodeGenericToBytes(data, destination); + } + private string EncodeGenericToString(ReadOnlySpan data) + { int leadingZeros = CountLeadingZeros(data); if (leadingZeros == data.Length) @@ -47,13 +68,77 @@ internal string EncodeGeneric(ReadOnlySpan data) return new string(_firstCharacter, leadingZeros); } - var inputSpan = data[leadingZeros..]; + ReadOnlySpan inputSpan = data[leadingZeros..]; + int size = inputSpan.Length * 137 / 100 + 1; + byte[]? rented = null; + try + { + Span digits = size > MaxStackallocByte + ? (rented = ArrayPool.Shared.Rent(size)) + : stackalloc byte[size]; - var size = (inputSpan.Length * 137 / 100) + 1; - Span digits = size > MaxStackallocByte - ? new byte[size] - : stackalloc byte[size]; + int digitCount = ComputeGenericDigits(inputSpan, digits); + var state = new EncodeState(digits, 0, digitCount, _characters.Span, _firstCharacter, leadingZeros); + return string.Create(state.OutputLength, state, static (span, s) => s.EmitReverse(span)); + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented, clearArray: true); + } + } + } + + private int EncodeGenericToBytes(ReadOnlySpan data, Span destination) + { + int leadingZeros = CountLeadingZeros(data); + byte firstByte = (byte)_firstCharacter; + + if (leadingZeros == data.Length) + { + if (destination.Length < leadingZeros) + { + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); + } + + destination[..leadingZeros].Fill(firstByte); + return leadingZeros; + } + + ReadOnlySpan inputSpan = data[leadingZeros..]; + int size = inputSpan.Length * 137 / 100 + 1; + byte[]? rented = null; + try + { + Span digits = size > MaxStackallocByte + ? (rented = ArrayPool.Shared.Rent(size)) + : 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, _characters.Span, _firstCharacter, leadingZeros); + state.EmitReverse(destination); + return outputLength; + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented, clearArray: true); + } + } + } + + private static int ComputeGenericDigits(ReadOnlySpan inputSpan, Span digits) + { int digitCount = 1; digits[0] = 0; @@ -75,31 +160,64 @@ 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; + } + + internal static string EncodeBitcoin32FastToString(ReadOnlySpan data) + { + int inLeadingZeros = 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, '1', inLeadingZeros); + return string.Create(state.OutputLength, state, static (span, s) => s.EmitForward(span)); } - internal static string EncodeBitcoin32Fast(ReadOnlySpan data) + private static int EncodeBitcoin32FastToBytes(ReadOnlySpan data, Span destination) { int inLeadingZeros = 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, '1', inLeadingZeros); + state.EmitForward(destination); + return outputLength; + } + + 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 +246,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 +258,72 @@ 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) - int skip = rawLeadingZeros - inLeadingZeros; - Debug.Assert(skip >= 0, "rawLeadingZeros should always be >= inLeadingZeros by Base58 math"); - int outputLength = Base58BitcoinTables.Raw58Sz32 - skip; + return rawLeadingZeros; + } - var state = new EncodeFastState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength); - return string.Create(outputLength, state, static (span, state) => + internal static string EncodeBitcoin64FastToString(ReadOnlySpan data) + { + int inLeadingZeros = CountLeadingZeros(data); + + if (inLeadingZeros == data.Length) { - if (state.InLeadingZeros > 0) - { - span[..state.InLeadingZeros].Fill('1'); - } + return new string('1', inLeadingZeros); + } - // 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]; - } - }); + 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 digitCount = Base58BitcoinTables.Raw58Sz64 - rawLeadingZeros; + + var state = new EncodeState(rawBase58, rawLeadingZeros, digitCount, Base58BitcoinTables.BitcoinChars, '1', inLeadingZeros); + return string.Create(state.OutputLength, state, static (span, s) => s.EmitForward(span)); } - private static string EncodeBitcoin64Fast(ReadOnlySpan data) + private static int EncodeBitcoin64FastToBytes(ReadOnlySpan data, Span destination) { int inLeadingZeros = 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.Raw58Sz64]; + int rawLeadingZeros = ComputeBitcoin64FastRaw(data, rawBase58); + + 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) + { + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); + } + + var state = new EncodeState(rawBase58, rawLeadingZeros, digitCount, Base58BitcoinTables.BitcoinChars, '1', inLeadingZeros); + state.EmitForward(destination); + return outputLength; + } + + 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 +332,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 +366,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 +382,80 @@ 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; + } - var state = new EncodeFastState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength); - return string.Create(outputLength, state, static (span, state) => + private readonly ref struct EncodeState + { + public readonly ReadOnlySpan Digits; + public readonly ReadOnlySpan Alphabet; + public readonly int DigitStart; + public readonly int DigitCount; + public readonly char LeadingFill; + public readonly int LeadingCount; + + public EncodeState( + ReadOnlySpan digits, + int digitStart, + int digitCount, + ReadOnlySpan alphabet, + char leadingFill, + int leadingCount) { - if (state.InLeadingZeros > 0) + Digits = digits; + DigitStart = digitStart; + DigitCount = digitCount; + Alphabet = alphabet; + LeadingFill = leadingFill; + LeadingCount = leadingCount; + } + + public int OutputLength => LeadingCount + DigitCount; + + public void EmitForward(Span destination) + where TChar : unmanaged, IBinaryInteger + { + if (LeadingCount > 0) { - span[..state.InLeadingZeros].Fill('1'); + destination[..LeadingCount].Fill(TChar.CreateTruncating((ushort)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) + public void EmitReverse(Span destination) + where TChar : unmanaged, IBinaryInteger { - RawBase58 = rawBase58; - InLeadingZeros = inLeadingZeros; - RawLeadingZeros = rawLeadingZeros; - OutputLength = outputLength; + if (LeadingCount > 0) + { + destination[..LeadingCount].Fill(TChar.CreateTruncating((ushort)LeadingFill)); + } + + int index = LeadingCount; + for (int i = DigitStart + DigitCount - 1; i >= DigitStart; i--) + { + destination[index++] = TChar.CreateTruncating((ushort)Alphabet[Digits[i]]); + } } } - 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; + internal string EncodeGeneric(ReadOnlySpan data) + => data.IsEmpty ? string.Empty : EncodeGenericToString(data); - public EncodeGenericFinalString(ReadOnlySpan alphabet, ReadOnlySpan digits, char firstCharacter, int leadingZeroes, int digitCount) - { - Alphabet = alphabet; - Digits = digits; - FirstCharacter = firstCharacter; - LeadingZeroes = leadingZeroes; - DigitCount = digitCount; - } - } + internal static string EncodeBitcoin32Fast(ReadOnlySpan data) + => EncodeBitcoin32FastToString(data); + + internal static string EncodeBitcoin64Fast(ReadOnlySpan data) + => EncodeBitcoin64FastToString(data); } diff --git a/src/Base58Encoding/Base58.Length.cs b/src/Base58Encoding/Base58.Length.cs new file mode 100644 index 0000000..299b028 --- /dev/null +++ b/src/Base58Encoding/Base58.Length.cs @@ -0,0 +1,58 @@ +namespace Base58Encoding; + +public 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 GetEncodedLength(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/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."); + } } From 2ba3bb7b2c84c54f396c86e8dfd9f8be2fddd686 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 18:02:32 +0300 Subject: [PATCH 03/35] chore: mark Base58Alphabet internal --- src/Base58Encoding/Base58Alphabet.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Base58Encoding/Base58Alphabet.cs b/src/Base58Encoding/Base58Alphabet.cs index bf908aa..d8a0552 100644 --- a/src/Base58Encoding/Base58Alphabet.cs +++ b/src/Base58Encoding/Base58Alphabet.cs @@ -1,6 +1,6 @@ namespace Base58Encoding; -public class Base58Alphabet +internal class Base58Alphabet { public const string BitcoinAlphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; public const string RippleAlphabet = "rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"; From 81efd2c3c9b615b1027992bbe164df0cdbb7488b Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 18:24:27 +0300 Subject: [PATCH 04/35] perf: extract ArrayPool slow-path to *Large helpers on generic encode/decode Split DecodeGenericCore, DecodeGenericToArray, EncodeGenericToString, and EncodeGenericToBytes so the stackalloc fast-path (<=512 B) is try/finally-free, letting the JIT allocate registers more aggressively. Also add [SkipLocalsInit] to ComputeBitcoin32/64FastRaw, remove redundant clearArray:true on ArrayPool returns, and add AllowUnsafeBlocks to the project. --- src/Base58Encoding/Base58.Decode.cs | 67 +++++++++++++++--------- src/Base58Encoding/Base58.Encode.cs | 64 ++++++++++++++-------- src/Base58Encoding/Base58Encoding.csproj | 1 + 3 files changed, 85 insertions(+), 47 deletions(-) diff --git a/src/Base58Encoding/Base58.Decode.cs b/src/Base58Encoding/Base58.Decode.cs index ce29fca..0fcceba 100644 --- a/src/Base58Encoding/Base58.Decode.cs +++ b/src/Base58Encoding/Base58.Decode.cs @@ -113,34 +113,44 @@ private int DecodeGenericCore(ReadOnlySpan encoded, Span des { TChar firstChar = TChar.CreateTruncating((ushort)_firstCharacter); int leadingOnes = CountLeading(encoded, firstChar); - int scratchSize = encoded.Length * 733 / 1000 + 1; - byte[]? rented = null; - try - { - Span decoded = scratchSize > MaxStackallocByte - ? (rented = ArrayPool.Shared.Rent(scratchSize)) - : stackalloc byte[scratchSize]; + 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; } - finally + + 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 { - if (rented is not null) + int decodedLength = ComputeGenericDecode(encoded, leadingOnes, rented); + int actualDecodedLength = leadingOnes == encoded.Length ? 0 : decodedLength; + int totalLength = leadingOnes + actualDecodedLength; + if (destination.Length < totalLength) { - ArrayPool.Shared.Return(rented, clearArray: true); + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } + EmitGenericDecode(destination, leadingOnes, rented, actualDecodedLength); + return totalLength; + } + finally + { + ArrayPool.Shared.Return(rented); } } @@ -156,26 +166,33 @@ private byte[] DecodeGenericToArray(ReadOnlySpan encoded) } int scratchSize = encoded.Length * 733 / 1000 + 1; - byte[]? rented = null; - try - { - Span decoded = scratchSize > MaxStackallocByte - ? (rented = ArrayPool.Shared.Rent(scratchSize)) - : stackalloc byte[scratchSize]; + if (scratchSize <= MaxStackallocByte) + { + Span decoded = stackalloc byte[scratchSize]; int decodedLength = ComputeGenericDecode(encoded, leadingOnes, decoded); - - // Allocate exact-sized heap result, then emit directly into it — single copy. byte[] result = new byte[leadingOnes + decodedLength]; EmitGenericDecode(result, leadingOnes, decoded, decodedLength); return result; } + + return DecodeGenericToArrayLarge(encoded, leadingOnes, scratchSize); + } + + 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 { - if (rented is not null) - { - ArrayPool.Shared.Return(rented, clearArray: true); - } + ArrayPool.Shared.Return(rented); } } diff --git a/src/Base58Encoding/Base58.Encode.cs b/src/Base58Encoding/Base58.Encode.cs index 82ece46..9d53935 100644 --- a/src/Base58Encoding/Base58.Encode.cs +++ b/src/Base58Encoding/Base58.Encode.cs @@ -2,6 +2,7 @@ using System.Buffers.Binary; using System.Diagnostics; using System.Numerics; +using System.Runtime.CompilerServices; namespace Base58Encoding; @@ -70,24 +71,30 @@ private string EncodeGenericToString(ReadOnlySpan data) ReadOnlySpan inputSpan = data[leadingZeros..]; int size = inputSpan.Length * 137 / 100 + 1; - byte[]? rented = null; - try - { - Span digits = size > MaxStackallocByte - ? (rented = ArrayPool.Shared.Rent(size)) - : stackalloc byte[size]; + if (size <= MaxStackallocByte) + { + Span digits = stackalloc byte[size]; int digitCount = ComputeGenericDigits(inputSpan, digits); - var state = new EncodeState(digits, 0, digitCount, _characters.Span, _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, _characters.Span, _firstCharacter, leadingZeros); + return string.Create(state.OutputLength, state, static (span, s) => s.EmitReverse(span)); + } finally { - if (rented is not null) - { - ArrayPool.Shared.Return(rented, clearArray: true); - } + ArrayPool.Shared.Return(rented); } } @@ -109,31 +116,42 @@ private int EncodeGenericToBytes(ReadOnlySpan data, Span destination ReadOnlySpan inputSpan = data[leadingZeros..]; int size = inputSpan.Length * 137 / 100 + 1; - byte[]? rented = null; - try - { - Span digits = size > MaxStackallocByte - ? (rented = ArrayPool.Shared.Rent(size)) - : stackalloc byte[size]; + 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, _characters.Span, _firstCharacter, leadingZeros); state.EmitReverse(destination); return outputLength; } - finally + + return EncodeGenericToBytesLarge(inputSpan, leadingZeros, size, destination); + } + + private int EncodeGenericToBytesLarge(ReadOnlySpan inputSpan, int leadingZeros, int size, Span destination) + { + byte[] rented = ArrayPool.Shared.Rent(size); + try { - if (rented is not null) + int digitCount = ComputeGenericDigits(inputSpan, rented); + int outputLength = leadingZeros + digitCount; + if (destination.Length < outputLength) { - ArrayPool.Shared.Return(rented, clearArray: true); + ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } + var state = new EncodeState(rented, 0, digitCount, _characters.Span, _firstCharacter, leadingZeros); + state.EmitReverse(destination); + return outputLength; + } + finally + { + ArrayPool.Shared.Return(rented); } } @@ -216,6 +234,7 @@ private static int EncodeBitcoin32FastToBytes(ReadOnlySpan data, Span data, Span rawBase58) { // Convert 32 bytes to 8 uint32 limbs (big-endian) @@ -322,6 +341,7 @@ private static int EncodeBitcoin64FastToBytes(ReadOnlySpan data, Span data, Span rawBase58) { // Convert 64 bytes to 16 uint32 limbs (big-endian) diff --git a/src/Base58Encoding/Base58Encoding.csproj b/src/Base58Encoding/Base58Encoding.csproj index 253c759..6c5a0da 100644 --- a/src/Base58Encoding/Base58Encoding.csproj +++ b/src/Base58Encoding/Base58Encoding.csproj @@ -4,6 +4,7 @@ net10.0 enable enable + true Base58Encoding Nikolay Zdravkov From f85083fb4ea95f292d624b7584c5e3d26a53d5d8 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 18:31:21 +0300 Subject: [PATCH 05/35] refactor: consolidate CountLeadingCharacters into Base58.CountLeading.cs --- src/Base58Encoding/Base58.CountLeading.cs | 4 ++-- src/Base58Encoding/Base58.Decode.cs | 15 ++++----------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/Base58Encoding/Base58.CountLeading.cs b/src/Base58Encoding/Base58.CountLeading.cs index d5b5d7e..72efc3b 100644 --- a/src/Base58Encoding/Base58.CountLeading.cs +++ b/src/Base58Encoding/Base58.CountLeading.cs @@ -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 0fcceba..b707408 100644 --- a/src/Base58Encoding/Base58.Decode.cs +++ b/src/Base58Encoding/Base58.Decode.cs @@ -112,7 +112,7 @@ private int DecodeGenericCore(ReadOnlySpan encoded, Span des where TChar : unmanaged, IBinaryInteger { TChar firstChar = TChar.CreateTruncating((ushort)_firstCharacter); - int leadingOnes = CountLeading(encoded, firstChar); + int leadingOnes = CountLeadingCharacters(encoded, firstChar); int scratchSize = encoded.Length * 733 / 1000 + 1; if (scratchSize <= MaxStackallocByte) @@ -158,7 +158,7 @@ private byte[] DecodeGenericToArray(ReadOnlySpan encoded) where TChar : unmanaged, IBinaryInteger { TChar firstChar = TChar.CreateTruncating((ushort)_firstCharacter); - int leadingOnes = CountLeading(encoded, firstChar); + int leadingOnes = CountLeadingCharacters(encoded, firstChar); if (leadingOnes == encoded.Length) { @@ -247,13 +247,6 @@ private static void EmitGenericDecode(Span destination, int leadingOnes, S } } - private static int CountLeading(ReadOnlySpan text, TChar target) - where TChar : unmanaged, IBinaryInteger - { - int mismatchIndex = text.IndexOfAnyExcept(target); - return mismatchIndex == -1 ? text.Length : mismatchIndex; - } - /// /// 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). @@ -335,7 +328,7 @@ private static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, Sp } TChar one = TChar.CreateTruncating((ushort)'1'); - int inputLeadingOnes = CountLeading(encoded, one); + int inputLeadingOnes = CountLeadingCharacters(encoded, one); if (outputLeadingZeros != inputLeadingOnes) { @@ -431,7 +424,7 @@ private static int TryDecodeBitcoin64Fast(ReadOnlySpan encoded, Sp } TChar one = TChar.CreateTruncating((ushort)'1'); - int inputLeadingOnes = CountLeading(encoded, one); + int inputLeadingOnes = CountLeadingCharacters(encoded, one); if (outputLeadingZeros != inputLeadingOnes) { From 14812412deb5600ce90cb8527117c5996b72e20b Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 18:37:21 +0300 Subject: [PATCH 06/35] perf: add [SkipLocalsInit] to all hot-path encode/decode methods --- src/Base58Encoding/Base58.Decode.cs | 5 +++++ src/Base58Encoding/Base58.Encode.cs | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/src/Base58Encoding/Base58.Decode.cs b/src/Base58Encoding/Base58.Decode.cs index b707408..9147966 100644 --- a/src/Base58Encoding/Base58.Decode.cs +++ b/src/Base58Encoding/Base58.Decode.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.Buffers.Binary; using System.Numerics; +using System.Runtime.CompilerServices; namespace Base58Encoding; @@ -108,6 +109,7 @@ private int DecodeCore(ReadOnlySpan encoded, Span destinatio return DecodeGenericCore(encoded, destination); } + [SkipLocalsInit] private int DecodeGenericCore(ReadOnlySpan encoded, Span destination) where TChar : unmanaged, IBinaryInteger { @@ -154,6 +156,7 @@ private int DecodeGenericCoreLarge(ReadOnlySpan encoded, int leadi } } + [SkipLocalsInit] private byte[] DecodeGenericToArray(ReadOnlySpan encoded) where TChar : unmanaged, IBinaryInteger { @@ -252,6 +255,7 @@ private static void EmitGenericDecode(Span destination, int leadingOnes, S /// represent exactly 32 bytes (caller should fall back to generic decode). /// Throws on invalid character or insufficient destination when fast path matches. /// + [SkipLocalsInit] private static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, Span destination) where TChar : unmanaged, IBinaryInteger { @@ -350,6 +354,7 @@ private static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, Sp return 32; } + [SkipLocalsInit] private static int TryDecodeBitcoin64Fast(ReadOnlySpan encoded, Span destination) where TChar : unmanaged, IBinaryInteger { diff --git a/src/Base58Encoding/Base58.Encode.cs b/src/Base58Encoding/Base58.Encode.cs index 9d53935..e1d662d 100644 --- a/src/Base58Encoding/Base58.Encode.cs +++ b/src/Base58Encoding/Base58.Encode.cs @@ -60,6 +60,7 @@ public int Encode(ReadOnlySpan data, Span destination) return EncodeGenericToBytes(data, destination); } + [SkipLocalsInit] private string EncodeGenericToString(ReadOnlySpan data) { int leadingZeros = CountLeadingZeros(data); @@ -98,6 +99,7 @@ private string EncodeGenericToStringLarge(ReadOnlySpan inputSpan, int lead } } + [SkipLocalsInit] private int EncodeGenericToBytes(ReadOnlySpan data, Span destination) { int leadingZeros = CountLeadingZeros(data); @@ -181,6 +183,7 @@ private static int ComputeGenericDigits(ReadOnlySpan inputSpan, Span return digitCount; } + [SkipLocalsInit] internal static string EncodeBitcoin32FastToString(ReadOnlySpan data) { int inLeadingZeros = CountLeadingZeros(data); @@ -201,6 +204,7 @@ internal static string EncodeBitcoin32FastToString(ReadOnlySpan data) return string.Create(state.OutputLength, state, static (span, s) => s.EmitForward(span)); } + [SkipLocalsInit] private static int EncodeBitcoin32FastToBytes(ReadOnlySpan data, Span destination) { int inLeadingZeros = CountLeadingZeros(data); @@ -288,6 +292,7 @@ private static int ComputeBitcoin32FastRaw(ReadOnlySpan data, Span r return rawLeadingZeros; } + [SkipLocalsInit] internal static string EncodeBitcoin64FastToString(ReadOnlySpan data) { int inLeadingZeros = CountLeadingZeros(data); @@ -308,6 +313,7 @@ internal static string EncodeBitcoin64FastToString(ReadOnlySpan data) return string.Create(state.OutputLength, state, static (span, s) => s.EmitForward(span)); } + [SkipLocalsInit] private static int EncodeBitcoin64FastToBytes(ReadOnlySpan data, Span destination) { int inLeadingZeros = CountLeadingZeros(data); From 3a16a098ecb037259e3b19a1827b6976b2b650f0 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 18:38:18 +0300 Subject: [PATCH 07/35] refactor: seal Base58 and Base58Alphabet, lower MaxStackallocByte to 256 --- src/Base58Encoding/Base58.cs | 4 ++-- src/Base58Encoding/Base58Alphabet.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Base58Encoding/Base58.cs b/src/Base58Encoding/Base58.cs index 32af5f6..d790db7 100644 --- a/src/Base58Encoding/Base58.cs +++ b/src/Base58Encoding/Base58.cs @@ -1,9 +1,9 @@ namespace Base58Encoding; -public partial class Base58 +public sealed partial class Base58 { private const int Base = 58; - private const int MaxStackallocByte = 512; + private const int MaxStackallocByte = 256; private readonly ReadOnlyMemory _characters; private readonly ReadOnlyMemory _decodeTable; diff --git a/src/Base58Encoding/Base58Alphabet.cs b/src/Base58Encoding/Base58Alphabet.cs index d8a0552..0839c3b 100644 --- a/src/Base58Encoding/Base58Alphabet.cs +++ b/src/Base58Encoding/Base58Alphabet.cs @@ -1,6 +1,6 @@ namespace Base58Encoding; -internal class Base58Alphabet +internal sealed class Base58Alphabet { public const string BitcoinAlphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; public const string RippleAlphabet = "rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"; From caa9343d2aac616e629e2dae4e98b4eea66f3a29 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 18:53:30 +0300 Subject: [PATCH 08/35] refactor: replace instance Base58 with generic Base58 typeof(TAlphabet) == typeof(BitcoinAlphabet) replaces ReferenceEquals, letting the JIT constant-fold the fast-path dispatch per specialization. Alphabet data moves from instance fields to static abstract interface members on IBase58Alphabet, making Base58 a zero-field type. --- .../JaggedVsMultidimensionalArrayBenchmark.cs | 4 +- src/Base58Encoding/Base58.CountLeading.cs | 2 +- src/Base58Encoding/Base58.Decode.cs | 41 ++++------ src/Base58Encoding/Base58.Encode.cs | 40 ++++------ src/Base58Encoding/Base58.Length.cs | 2 +- src/Base58Encoding/Base58.cs | 30 +++----- src/Base58Encoding/Base58Alphabet.cs | 77 ++++++------------- src/Base58Encoding/Base58Generic.cs | 21 +++++ src/Base58Encoding/IBase58Alphabet.cs | 8 ++ 9 files changed, 101 insertions(+), 124 deletions(-) create mode 100644 src/Base58Encoding/Base58Generic.cs create mode 100644 src/Base58Encoding/IBase58Alphabet.cs diff --git a/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs b/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs index f472425..c59e70c 100644 --- a/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs @@ -248,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++) @@ -325,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/Base58.CountLeading.cs b/src/Base58Encoding/Base58.CountLeading.cs index 72efc3b..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) { diff --git a/src/Base58Encoding/Base58.Decode.cs b/src/Base58Encoding/Base58.Decode.cs index 9147966..f539512 100644 --- a/src/Base58Encoding/Base58.Decode.cs +++ b/src/Base58Encoding/Base58.Decode.cs @@ -5,7 +5,8 @@ namespace Base58Encoding; -public partial class Base58 +public sealed partial class Base58 + where TAlphabet : struct, IBase58Alphabet { /// /// Decodes a Base58 string to a new byte array. @@ -20,9 +21,7 @@ public byte[] Decode(ReadOnlySpan encoded) return []; } - // Bitcoin fast-path dispatch: allocate the fixed-size result up front - // and write directly into it. On fallback we discard and re-decode. - if (ReferenceEquals(this, _bitcoin.Value)) + if (typeof(TAlphabet) == typeof(BitcoinAlphabet)) { if (encoded.Length is >= 43 and <= 44) { @@ -86,7 +85,7 @@ public int Decode(ReadOnlySpan encoded, Span destination) private int DecodeCore(ReadOnlySpan encoded, Span destination) where TChar : unmanaged, IBinaryInteger { - if (ReferenceEquals(this, _bitcoin.Value)) + if (typeof(TAlphabet) == typeof(BitcoinAlphabet)) { if (encoded.Length is >= 43 and <= 44) { @@ -113,8 +112,8 @@ private int DecodeCore(ReadOnlySpan encoded, Span destinatio private int DecodeGenericCore(ReadOnlySpan encoded, Span destination) where TChar : unmanaged, IBinaryInteger { - TChar firstChar = TChar.CreateTruncating((ushort)_firstCharacter); - int leadingOnes = CountLeadingCharacters(encoded, firstChar); + TChar firstChar = TChar.CreateTruncating((ushort)TAlphabet.FirstCharacter); + int leadingOnes = Base58.CountLeadingCharacters(encoded, firstChar); int scratchSize = encoded.Length * 733 / 1000 + 1; if (scratchSize <= MaxStackallocByte) @@ -160,8 +159,8 @@ private int DecodeGenericCoreLarge(ReadOnlySpan encoded, int leadi private byte[] DecodeGenericToArray(ReadOnlySpan encoded) where TChar : unmanaged, IBinaryInteger { - TChar firstChar = TChar.CreateTruncating((ushort)_firstCharacter); - int leadingOnes = CountLeadingCharacters(encoded, firstChar); + TChar firstChar = TChar.CreateTruncating((ushort)TAlphabet.FirstCharacter); + int leadingOnes = Base58.CountLeadingCharacters(encoded, firstChar); if (leadingOnes == encoded.Length) { @@ -205,7 +204,7 @@ private int ComputeGenericDecode(ReadOnlySpan encoded, int leading int decodedLength = 1; digits[0] = 0; - ReadOnlySpan decodeTable = _decodeTable.Span; + ReadOnlySpan decodeTable = TAlphabet.DecodeTable; for (int i = leadingOnes; i < encoded.Length; i++) { @@ -256,13 +255,13 @@ private static void EmitGenericDecode(Span destination, int leadingOnes, S /// Throws on invalid character or insufficient destination when fast path matches. /// [SkipLocalsInit] - private static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, Span destination) + internal static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, Span destination) where TChar : unmanaged, IBinaryInteger { int charCount = encoded.Length; Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32]; - ReadOnlySpan bitcoinDecodeTable = Base58Alphabet.Bitcoin.DecodeTable.Span; + ReadOnlySpan bitcoinDecodeTable = BitcoinAlphabet.DecodeTable; int prepend0 = Base58BitcoinTables.Raw58Sz32 - charCount; for (int j = 0; j < Base58BitcoinTables.Raw58Sz32; j++) @@ -332,7 +331,7 @@ private static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, Sp } TChar one = TChar.CreateTruncating((ushort)'1'); - int inputLeadingOnes = CountLeadingCharacters(encoded, one); + int inputLeadingOnes = Base58.CountLeadingCharacters(encoded, one); if (outputLeadingZeros != inputLeadingOnes) { @@ -355,13 +354,13 @@ private static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, Sp } [SkipLocalsInit] - private static int TryDecodeBitcoin64Fast(ReadOnlySpan encoded, Span destination) + internal static int TryDecodeBitcoin64Fast(ReadOnlySpan encoded, Span destination) where TChar : unmanaged, IBinaryInteger { int charCount = encoded.Length; Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz64]; - ReadOnlySpan bitcoinDecodeTable = Base58Alphabet.Bitcoin.DecodeTable.Span; + ReadOnlySpan bitcoinDecodeTable = BitcoinAlphabet.DecodeTable; int prepend0 = Base58BitcoinTables.Raw58Sz64 - charCount; for (int j = 0; j < Base58BitcoinTables.Raw58Sz64; j++) @@ -429,7 +428,7 @@ private static int TryDecodeBitcoin64Fast(ReadOnlySpan encoded, Sp } TChar one = TChar.CreateTruncating((ushort)'1'); - int inputLeadingOnes = CountLeadingCharacters(encoded, one); + int inputLeadingOnes = Base58.CountLeadingCharacters(encoded, one); if (outputLeadingZeros != inputLeadingOnes) { @@ -451,16 +450,6 @@ private static int TryDecodeBitcoin64Fast(ReadOnlySpan encoded, Sp return 64; } - internal byte[] DecodeGeneric(ReadOnlySpan encoded) - { - if (encoded.IsEmpty) - { - return []; - } - - return DecodeGenericToArray(encoded); - } - internal static byte[]? DecodeBitcoin32Fast(ReadOnlySpan encoded) { Span buffer = stackalloc byte[32]; diff --git a/src/Base58Encoding/Base58.Encode.cs b/src/Base58Encoding/Base58.Encode.cs index e1d662d..af71d58 100644 --- a/src/Base58Encoding/Base58.Encode.cs +++ b/src/Base58Encoding/Base58.Encode.cs @@ -6,7 +6,8 @@ namespace Base58Encoding; -public partial class Base58 +public sealed partial class Base58 + where TAlphabet : struct, IBase58Alphabet { /// /// Encodes bytes to a Base58 string. @@ -20,7 +21,7 @@ public string Encode(ReadOnlySpan data) return string.Empty; } - if (ReferenceEquals(this, _bitcoin.Value)) + if (typeof(TAlphabet) == typeof(BitcoinAlphabet)) { return data.Length switch { @@ -47,7 +48,7 @@ public int Encode(ReadOnlySpan data, Span destination) return 0; } - if (ReferenceEquals(this, _bitcoin.Value)) + if (typeof(TAlphabet) == typeof(BitcoinAlphabet)) { return data.Length switch { @@ -63,11 +64,11 @@ public int Encode(ReadOnlySpan data, Span destination) [SkipLocalsInit] private string EncodeGenericToString(ReadOnlySpan data) { - int leadingZeros = CountLeadingZeros(data); + int leadingZeros = Base58.CountLeadingZeros(data); if (leadingZeros == data.Length) { - return new string(_firstCharacter, leadingZeros); + return new string(TAlphabet.FirstCharacter, leadingZeros); } ReadOnlySpan inputSpan = data[leadingZeros..]; @@ -77,7 +78,7 @@ private string EncodeGenericToString(ReadOnlySpan data) { Span digits = stackalloc byte[size]; int digitCount = ComputeGenericDigits(inputSpan, digits); - var state = new EncodeState(digits, 0, digitCount, _characters.Span, _firstCharacter, leadingZeros); + var state = new EncodeState(digits, 0, digitCount, TAlphabet.Characters, TAlphabet.FirstCharacter, leadingZeros); return string.Create(state.OutputLength, state, static (span, s) => s.EmitReverse(span)); } @@ -90,7 +91,7 @@ private string EncodeGenericToStringLarge(ReadOnlySpan inputSpan, int lead try { int digitCount = ComputeGenericDigits(inputSpan, rented); - var state = new EncodeState(rented, 0, digitCount, _characters.Span, _firstCharacter, leadingZeros); + 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 @@ -102,8 +103,8 @@ private string EncodeGenericToStringLarge(ReadOnlySpan inputSpan, int lead [SkipLocalsInit] private int EncodeGenericToBytes(ReadOnlySpan data, Span destination) { - int leadingZeros = CountLeadingZeros(data); - byte firstByte = (byte)_firstCharacter; + int leadingZeros = Base58.CountLeadingZeros(data); + byte firstByte = (byte)TAlphabet.FirstCharacter; if (leadingZeros == data.Length) { @@ -128,7 +129,7 @@ private int EncodeGenericToBytes(ReadOnlySpan data, Span destination { ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } - var state = new EncodeState(digits, 0, digitCount, _characters.Span, _firstCharacter, leadingZeros); + var state = new EncodeState(digits, 0, digitCount, TAlphabet.Characters, TAlphabet.FirstCharacter, leadingZeros); state.EmitReverse(destination); return outputLength; } @@ -147,7 +148,7 @@ private int EncodeGenericToBytesLarge(ReadOnlySpan inputSpan, int leadingZ { ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } - var state = new EncodeState(rented, 0, digitCount, _characters.Span, _firstCharacter, leadingZeros); + var state = new EncodeState(rented, 0, digitCount, TAlphabet.Characters, TAlphabet.FirstCharacter, leadingZeros); state.EmitReverse(destination); return outputLength; } @@ -186,7 +187,7 @@ private static int ComputeGenericDigits(ReadOnlySpan inputSpan, Span [SkipLocalsInit] internal static string EncodeBitcoin32FastToString(ReadOnlySpan data) { - int inLeadingZeros = CountLeadingZeros(data); + int inLeadingZeros = Base58.CountLeadingZeros(data); if (inLeadingZeros == data.Length) { @@ -207,7 +208,7 @@ internal static string EncodeBitcoin32FastToString(ReadOnlySpan data) [SkipLocalsInit] private static int EncodeBitcoin32FastToBytes(ReadOnlySpan data, Span destination) { - int inLeadingZeros = CountLeadingZeros(data); + int inLeadingZeros = Base58.CountLeadingZeros(data); if (inLeadingZeros == data.Length) { @@ -295,7 +296,7 @@ private static int ComputeBitcoin32FastRaw(ReadOnlySpan data, Span r [SkipLocalsInit] internal static string EncodeBitcoin64FastToString(ReadOnlySpan data) { - int inLeadingZeros = CountLeadingZeros(data); + int inLeadingZeros = Base58.CountLeadingZeros(data); if (inLeadingZeros == data.Length) { @@ -316,7 +317,7 @@ internal static string EncodeBitcoin64FastToString(ReadOnlySpan data) [SkipLocalsInit] private static int EncodeBitcoin64FastToBytes(ReadOnlySpan data, Span destination) { - int inLeadingZeros = CountLeadingZeros(data); + int inLeadingZeros = Base58.CountLeadingZeros(data); if (inLeadingZeros == data.Length) { @@ -475,13 +476,4 @@ public void EmitReverse(Span destination) } } } - - internal string EncodeGeneric(ReadOnlySpan data) - => data.IsEmpty ? string.Empty : EncodeGenericToString(data); - - internal static string EncodeBitcoin32Fast(ReadOnlySpan data) - => EncodeBitcoin32FastToString(data); - - internal static string EncodeBitcoin64Fast(ReadOnlySpan data) - => EncodeBitcoin64FastToString(data); } diff --git a/src/Base58Encoding/Base58.Length.cs b/src/Base58Encoding/Base58.Length.cs index 299b028..a695d8d 100644 --- a/src/Base58Encoding/Base58.Length.cs +++ b/src/Base58Encoding/Base58.Length.cs @@ -1,6 +1,6 @@ namespace Base58Encoding; -public partial class Base58 +public static partial class Base58 { /// /// Returns a safe upper bound for the number of encoded Base58 characters diff --git a/src/Base58Encoding/Base58.cs b/src/Base58Encoding/Base58.cs index d790db7..9cf8103 100644 --- a/src/Base58Encoding/Base58.cs +++ b/src/Base58Encoding/Base58.cs @@ -1,26 +1,20 @@ namespace Base58Encoding; -public sealed partial class Base58 +public static partial class Base58 { - private const int Base = 58; - private const int MaxStackallocByte = 256; + 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 0839c3b..1c3e533 100644 --- a/src/Base58Encoding/Base58Alphabet.cs +++ b/src/Base58Encoding/Base58Alphabet.cs @@ -1,48 +1,11 @@ namespace Base58Encoding; -internal sealed 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"; + public static char FirstCharacter => '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"; + public static char FirstCharacter => '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"; + public static char FirstCharacter => '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/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..44f3985 --- /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 char FirstCharacter { get; } +} From f1418b587829ee2bacacae33178bfce46ca9311e Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:01:01 +0300 Subject: [PATCH 09/35] docs: update benchmark results for .NET 10.0.7 --- README.md | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index bdaafdf..48a6bdb 100644 --- a/README.md +++ b/README.md @@ -51,41 +51,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 From 373f7616fb37d84fe66248e4bdc3a60ac9951ec2 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:12:01 +0300 Subject: [PATCH 10/35] perf: use ReadOnlySpan for Characters and FirstCharacter in IBase58Alphabet --- .../JaggedVsMultidimensionalArrayBenchmark.cs | 4 ++-- src/Base58Encoding/Base58.Decode.cs | 8 +++---- src/Base58Encoding/Base58.Encode.cs | 24 +++++++++---------- src/Base58Encoding/Base58Alphabet.cs | 12 +++++----- src/Base58Encoding/Base58BitcoinTables.cs | 2 +- src/Base58Encoding/IBase58Alphabet.cs | 4 ++-- 6 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs b/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs index c59e70c..f1725b4 100644 --- a/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs @@ -159,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]; } }); } @@ -236,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]; } }); } diff --git a/src/Base58Encoding/Base58.Decode.cs b/src/Base58Encoding/Base58.Decode.cs index f539512..e5a0ec4 100644 --- a/src/Base58Encoding/Base58.Decode.cs +++ b/src/Base58Encoding/Base58.Decode.cs @@ -112,7 +112,7 @@ private int DecodeCore(ReadOnlySpan encoded, Span destinatio private int DecodeGenericCore(ReadOnlySpan encoded, Span destination) where TChar : unmanaged, IBinaryInteger { - TChar firstChar = TChar.CreateTruncating((ushort)TAlphabet.FirstCharacter); + TChar firstChar = TChar.CreateTruncating(TAlphabet.FirstCharacter); int leadingOnes = Base58.CountLeadingCharacters(encoded, firstChar); int scratchSize = encoded.Length * 733 / 1000 + 1; @@ -159,7 +159,7 @@ private int DecodeGenericCoreLarge(ReadOnlySpan encoded, int leadi private byte[] DecodeGenericToArray(ReadOnlySpan encoded) where TChar : unmanaged, IBinaryInteger { - TChar firstChar = TChar.CreateTruncating((ushort)TAlphabet.FirstCharacter); + TChar firstChar = TChar.CreateTruncating(TAlphabet.FirstCharacter); int leadingOnes = Base58.CountLeadingCharacters(encoded, firstChar); if (leadingOnes == encoded.Length) @@ -330,7 +330,7 @@ internal static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, S outputLeadingZeros += 4; } - TChar one = TChar.CreateTruncating((ushort)'1'); + TChar one = TChar.CreateTruncating((byte)'1'); int inputLeadingOnes = Base58.CountLeadingCharacters(encoded, one); if (outputLeadingZeros != inputLeadingOnes) @@ -427,7 +427,7 @@ internal static int TryDecodeBitcoin64Fast(ReadOnlySpan encoded, S outputLeadingZeros += 4; } - TChar one = TChar.CreateTruncating((ushort)'1'); + TChar one = TChar.CreateTruncating((byte)'1'); int inputLeadingOnes = Base58.CountLeadingCharacters(encoded, one); if (outputLeadingZeros != inputLeadingOnes) diff --git a/src/Base58Encoding/Base58.Encode.cs b/src/Base58Encoding/Base58.Encode.cs index af71d58..e4be3eb 100644 --- a/src/Base58Encoding/Base58.Encode.cs +++ b/src/Base58Encoding/Base58.Encode.cs @@ -68,7 +68,7 @@ private string EncodeGenericToString(ReadOnlySpan data) if (leadingZeros == data.Length) { - return new string(TAlphabet.FirstCharacter, leadingZeros); + return new string((char)TAlphabet.FirstCharacter, leadingZeros); } ReadOnlySpan inputSpan = data[leadingZeros..]; @@ -104,7 +104,6 @@ private string EncodeGenericToStringLarge(ReadOnlySpan inputSpan, int lead private int EncodeGenericToBytes(ReadOnlySpan data, Span destination) { int leadingZeros = Base58.CountLeadingZeros(data); - byte firstByte = (byte)TAlphabet.FirstCharacter; if (leadingZeros == data.Length) { @@ -113,6 +112,7 @@ private int EncodeGenericToBytes(ReadOnlySpan data, Span destination ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } + var firstByte = TAlphabet.FirstCharacter; destination[..leadingZeros].Fill(firstByte); return leadingZeros; } @@ -201,7 +201,7 @@ internal static string EncodeBitcoin32FastToString(ReadOnlySpan data) 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, '1', inLeadingZeros); + var state = new EncodeState(rawBase58, rawLeadingZeros, digitCount, Base58BitcoinTables.BitcoinChars, (byte)'1', inLeadingZeros); return string.Create(state.OutputLength, state, static (span, s) => s.EmitForward(span)); } @@ -234,7 +234,7 @@ private static int EncodeBitcoin32FastToBytes(ReadOnlySpan data, Span data) Debug.Assert(skip >= 0, "rawLeadingZeros should always be >= inLeadingZeros by Base58 math"); int digitCount = Base58BitcoinTables.Raw58Sz64 - rawLeadingZeros; - var state = new EncodeState(rawBase58, rawLeadingZeros, digitCount, Base58BitcoinTables.BitcoinChars, '1', inLeadingZeros); + var state = new EncodeState(rawBase58, rawLeadingZeros, digitCount, Base58BitcoinTables.BitcoinChars, (byte)'1', inLeadingZeros); return string.Create(state.OutputLength, state, static (span, s) => s.EmitForward(span)); } @@ -343,7 +343,7 @@ private static int EncodeBitcoin64FastToBytes(ReadOnlySpan data, Span data, Span r private readonly ref struct EncodeState { public readonly ReadOnlySpan Digits; - public readonly ReadOnlySpan Alphabet; + public readonly ReadOnlySpan Alphabet; public readonly int DigitStart; public readonly int DigitCount; - public readonly char LeadingFill; + public readonly byte LeadingFill; public readonly int LeadingCount; public EncodeState( ReadOnlySpan digits, int digitStart, int digitCount, - ReadOnlySpan alphabet, - char leadingFill, + ReadOnlySpan alphabet, + byte leadingFill, int leadingCount) { Digits = digits; @@ -450,7 +450,7 @@ public void EmitForward(Span destination) { if (LeadingCount > 0) { - destination[..LeadingCount].Fill(TChar.CreateTruncating((ushort)LeadingFill)); + destination[..LeadingCount].Fill(TChar.CreateTruncating(LeadingFill)); } int index = LeadingCount; @@ -466,7 +466,7 @@ public void EmitReverse(Span destination) { if (LeadingCount > 0) { - destination[..LeadingCount].Fill(TChar.CreateTruncating((ushort)LeadingFill)); + destination[..LeadingCount].Fill(TChar.CreateTruncating(LeadingFill)); } int index = LeadingCount; diff --git a/src/Base58Encoding/Base58Alphabet.cs b/src/Base58Encoding/Base58Alphabet.cs index 1c3e533..e5794b9 100644 --- a/src/Base58Encoding/Base58Alphabet.cs +++ b/src/Base58Encoding/Base58Alphabet.cs @@ -2,8 +2,8 @@ namespace Base58Encoding; public readonly struct BitcoinAlphabet : IBase58Alphabet { - public static ReadOnlySpan Characters => "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; - public static char FirstCharacter => '1'; + 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, @@ -19,8 +19,8 @@ namespace Base58Encoding; public readonly struct RippleAlphabet : IBase58Alphabet { - public static ReadOnlySpan Characters => "rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"; - public static char FirstCharacter => 'r'; + 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, @@ -36,8 +36,8 @@ namespace Base58Encoding; public readonly struct FlickrAlphabet : IBase58Alphabet { - public static ReadOnlySpan Characters => "123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; - public static char FirstCharacter => '1'; + 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, 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/IBase58Alphabet.cs b/src/Base58Encoding/IBase58Alphabet.cs index 44f3985..31e406d 100644 --- a/src/Base58Encoding/IBase58Alphabet.cs +++ b/src/Base58Encoding/IBase58Alphabet.cs @@ -2,7 +2,7 @@ namespace Base58Encoding; public interface IBase58Alphabet { - static abstract ReadOnlySpan Characters { get; } + static abstract ReadOnlySpan Characters { get; } static abstract ReadOnlySpan DecodeTable { get; } - static abstract char FirstCharacter { get; } + static abstract byte FirstCharacter { get; } } From ec98703d4e79da13ed74f913597fe211a5512376 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:21:01 +0300 Subject: [PATCH 11/35] docs: restore algorithm comments in Bitcoin fast-path decode --- src/Base58Encoding/Base58.Decode.cs | 54 +++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/src/Base58Encoding/Base58.Decode.cs b/src/Base58Encoding/Base58.Decode.cs index e5a0ec4..572a606 100644 --- a/src/Base58Encoding/Base58.Decode.cs +++ b/src/Base58Encoding/Base58.Decode.cs @@ -26,7 +26,7 @@ public byte[] Decode(ReadOnlySpan encoded) if (encoded.Length is >= 43 and <= 44) { Span buf = stackalloc byte[32]; - if (TryDecodeBitcoin32Fast(encoded, buf) == 32) + if (TryDecodeBitcoin32Fast(encoded, buf) == 32) { return buf.ToArray(); } @@ -34,14 +34,14 @@ public byte[] Decode(ReadOnlySpan encoded) else if (encoded.Length is >= 87 and <= 88) { Span buf = stackalloc byte[64]; - if (TryDecodeBitcoin64Fast(encoded, buf) == 64) + if (TryDecodeBitcoin64Fast(encoded, buf) == 64) { return buf.ToArray(); } } } - return DecodeGenericToArray(encoded); + return DecodeGenericToArray(encoded); } /// @@ -126,6 +126,7 @@ private int DecodeGenericCore(ReadOnlySpan encoded, Span des { ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } + EmitGenericDecode(destination, leadingOnes, decoded, actualDecodedLength); return totalLength; } @@ -146,6 +147,7 @@ private int DecodeGenericCoreLarge(ReadOnlySpan encoded, int leadi { ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } + EmitGenericDecode(destination, leadingOnes, rented, actualDecodedLength); return totalLength; } @@ -260,9 +262,11 @@ internal static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, S { int charCount = encoded.Length; + // Convert to raw base58 digits with validation + conversion in one pass Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32]; ReadOnlySpan bitcoinDecodeTable = BitcoinAlphabet.DecodeTable; + // Prepend zeros to make exactly Raw58Sz32 characters int prepend0 = Base58BitcoinTables.Raw58Sz32 - charCount; for (int j = 0; j < Base58BitcoinTables.Raw58Sz32; j++) { @@ -273,6 +277,7 @@ internal static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, S else { int c = int.CreateTruncating(encoded[j - prepend0]); + // Validate + convert using Bitcoin decode table if ((uint)c >= 128 || bitcoinDecodeTable[c] == 255) { ThrowHelper.ThrowInvalidCharacter((char)c); @@ -282,17 +287,19 @@ internal static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, S } } + // Convert to intermediate format (base 58^5) Span intermediate = stackalloc ulong[Base58BitcoinTables.IntermediateSz32]; for (int i = 0; i < Base58BitcoinTables.IntermediateSz32; i++) { - intermediate[i] = (ulong)rawBase58[5 * i + 0] * 11316496UL + - (ulong)rawBase58[5 * i + 1] * 195112UL + - (ulong)rawBase58[5 * i + 2] * 3364UL + - (ulong)rawBase58[5 * i + 3] * 58UL + - (ulong)rawBase58[5 * i + 4] * 1UL; + 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 } + // Convert to overcomplete base 2^32 using decode table Span binary = stackalloc ulong[Base58BitcoinTables.BinarySz32]; for (int j = 0; j < Base58BitcoinTables.BinarySz32; j++) @@ -305,19 +312,21 @@ internal static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, S binary[j] = acc; } + // 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] &= 0xFFFFFFFFUL; } + // Check if the result is too large for 32 bytes if (binary[0] > 0xFFFFFFFFUL) { return -1; } - // Count leading zero BYTES in the 32-byte output directly from binary[] - // without materializing the output. Each limb is 4 bytes big-endian. + // 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++) { @@ -330,6 +339,8 @@ internal static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, S outputLeadingZeros += 4; } + // 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); @@ -343,6 +354,7 @@ internal static int TryDecodeBitcoin32Fast(ReadOnlySpan encoded, S ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } + // Convert to big-endian byte output for (int i = 0; i < Base58BitcoinTables.BinarySz32; i++) { uint value = (uint)binary[i]; @@ -359,9 +371,11 @@ internal static int TryDecodeBitcoin64Fast(ReadOnlySpan encoded, S { int charCount = encoded.Length; + // Convert to raw base58 digits with validation + conversion in one pass Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz64]; ReadOnlySpan bitcoinDecodeTable = BitcoinAlphabet.DecodeTable; + // Prepend zeros to make exactly Raw58Sz64 characters int prepend0 = Base58BitcoinTables.Raw58Sz64 - charCount; for (int j = 0; j < Base58BitcoinTables.Raw58Sz64; j++) { @@ -372,6 +386,7 @@ internal static int TryDecodeBitcoin64Fast(ReadOnlySpan encoded, S else { int c = int.CreateTruncating(encoded[j - prepend0]); + // Validate + convert using Bitcoin decode table if ((uint)c >= 128 || bitcoinDecodeTable[c] == 255) { ThrowHelper.ThrowInvalidCharacter((char)c); @@ -381,17 +396,19 @@ internal static int TryDecodeBitcoin64Fast(ReadOnlySpan encoded, S } } + // Convert to intermediate format (base 58^5) Span intermediate = stackalloc ulong[Base58BitcoinTables.IntermediateSz64]; for (int i = 0; i < Base58BitcoinTables.IntermediateSz64; i++) { - intermediate[i] = (ulong)rawBase58[5 * i + 0] * 11316496UL + - (ulong)rawBase58[5 * i + 1] * 195112UL + - (ulong)rawBase58[5 * i + 2] * 3364UL + - (ulong)rawBase58[5 * i + 3] * 58UL + - (ulong)rawBase58[5 * i + 4] * 1UL; + 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 } + // Convert to overcomplete base 2^32 using decode table Span binary = stackalloc ulong[Base58BitcoinTables.BinarySz64]; for (int j = 0; j < Base58BitcoinTables.BinarySz64; j++) @@ -404,17 +421,21 @@ internal static int TryDecodeBitcoin64Fast(ReadOnlySpan encoded, S binary[j] = acc; } + // 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] &= 0xFFFFFFFFUL; } + // Check if the result is too large for 64 bytes if (binary[0] > 0xFFFFFFFFUL) { return -1; } + // 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++) { @@ -427,6 +448,8 @@ internal static int TryDecodeBitcoin64Fast(ReadOnlySpan encoded, S outputLeadingZeros += 4; } + // 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); @@ -440,6 +463,7 @@ internal static int TryDecodeBitcoin64Fast(ReadOnlySpan encoded, S ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } + // Convert to big-endian byte output for (int i = 0; i < Base58BitcoinTables.BinarySz64; i++) { uint value = (uint)binary[i]; From 9d308772d4f124003a97d9ce91c3dd0066bcfc1f Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:22:54 +0300 Subject: [PATCH 12/35] style: minor cleanup in encode and benchmark files --- src/Base58Encoding/Base58.Encode.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Base58Encoding/Base58.Encode.cs b/src/Base58Encoding/Base58.Encode.cs index e4be3eb..150cd95 100644 --- a/src/Base58Encoding/Base58.Encode.cs +++ b/src/Base58Encoding/Base58.Encode.cs @@ -112,8 +112,7 @@ private int EncodeGenericToBytes(ReadOnlySpan data, Span destination ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } - var firstByte = TAlphabet.FirstCharacter; - destination[..leadingZeros].Fill(firstByte); + destination[..leadingZeros].Fill(TAlphabet.FirstCharacter); return leadingZeros; } @@ -129,6 +128,7 @@ private int EncodeGenericToBytes(ReadOnlySpan data, Span destination { ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } + var state = new EncodeState(digits, 0, digitCount, TAlphabet.Characters, TAlphabet.FirstCharacter, leadingZeros); state.EmitReverse(destination); return outputLength; @@ -148,6 +148,7 @@ private int EncodeGenericToBytesLarge(ReadOnlySpan inputSpan, int leadingZ { ThrowHelper.ThrowDestinationTooSmall(nameof(destination)); } + var state = new EncodeState(rented, 0, digitCount, TAlphabet.Characters, TAlphabet.FirstCharacter, leadingZeros); state.EmitReverse(destination); return outputLength; From 8ac2d33cf236c5d90274c0fbb2490d709e07cc75 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:23:35 +0300 Subject: [PATCH 13/35] docs: document zero-allocation encode/decode API --- README.md | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 48a6bdb..4db8f91 100644 --- a/README.md +++ b/README.md @@ -12,21 +12,51 @@ A .NET 10.0 Base58 encoding and decoding library with support for multiple alpha ## 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.GetEncodedLength(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) +``` + +`GetEncodedLength(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: From 3dc13fad2da0a235e215bf62cb8f97b0d260d717 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:26:14 +0300 Subject: [PATCH 14/35] refactor: rename GetEncodedLength to GetMaxEncodedLength --- README.md | 4 +-- .../Base58ZeroAllocTests.cs | 28 +++++++++---------- src/Base58Encoding/Base58.Length.cs | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 4db8f91..17ed9a2 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ using Base58Encoding; byte[] data = { 0x01, 0x02, 0x03, 0x04 }; // Size the output buffer using the helper -int maxLen = Base58.GetEncodedLength(data.Length); +int maxLen = Base58.GetMaxEncodedLength(data.Length); Span encodedBytes = stackalloc byte[maxLen]; // or rent from ArrayPool int written = Base58.Bitcoin.Encode(data, encodedBytes); @@ -54,7 +54,7 @@ int decodedLen = Base58.Bitcoin.Decode(result, decodedBytes); // int Decode(ReadOnlySpan encoded, Span destination) ``` -`GetEncodedLength(byteCount)` returns a safe upper bound for the encoded output size. +`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 diff --git a/src/Base58Encoding.Tests/Base58ZeroAllocTests.cs b/src/Base58Encoding.Tests/Base58ZeroAllocTests.cs index f8327ec..3e0cc68 100644 --- a/src/Base58Encoding.Tests/Base58ZeroAllocTests.cs +++ b/src/Base58Encoding.Tests/Base58ZeroAllocTests.cs @@ -5,18 +5,18 @@ namespace Base58Encoding.Tests; public class Base58ZeroAllocTests { [Fact] - public void GetEncodedLength_ReturnsUpperBound() + public void GetMaxEncodedLength_ReturnsUpperBound() { - Assert.Equal(0, Base58.GetEncodedLength(0)); - Assert.True(Base58.GetEncodedLength(1) >= 2); - Assert.True(Base58.GetEncodedLength(32) >= 44); - Assert.True(Base58.GetEncodedLength(64) >= 88); + 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 GetEncodedLength_NegativeThrows() + public void GetMaxEncodedLength_NegativeThrows() { - Assert.Throws(() => Base58.GetEncodedLength(-1)); + Assert.Throws(() => Base58.GetMaxEncodedLength(-1)); } [Fact] @@ -46,7 +46,7 @@ public void Encode_ToBytes_MatchesStringEncode() string expected = Base58.Bitcoin.Encode(data); - Span buffer = stackalloc byte[Base58.GetEncodedLength(data.Length)]; + Span buffer = stackalloc byte[Base58.GetMaxEncodedLength(data.Length)]; int written = Base58.Bitcoin.Encode(data, buffer); string actual = Encoding.ASCII.GetString(buffer[..written]); @@ -58,7 +58,7 @@ public void Encode_ToBytes_MatchesStringEncode() public void Encode_ToBytes_WithLeadingZeros_PreservesOnes() { byte[] data = [0x00, 0x00, 0x00, 0x01, 0x02]; - Span buffer = stackalloc byte[Base58.GetEncodedLength(data.Length)]; + Span buffer = stackalloc byte[Base58.GetMaxEncodedLength(data.Length)]; int written = Base58.Bitcoin.Encode(data, buffer); string actual = Encoding.ASCII.GetString(buffer[..written]); @@ -70,7 +70,7 @@ public void Encode_ToBytes_WithLeadingZeros_PreservesOnes() public void Encode_ToBytes_AllZeros_WritesAllOnes() { byte[] data = new byte[10]; - Span buffer = stackalloc byte[Base58.GetEncodedLength(data.Length)]; + Span buffer = stackalloc byte[Base58.GetMaxEncodedLength(data.Length)]; int written = Base58.Bitcoin.Encode(data, buffer); Assert.Equal(10, written); @@ -104,7 +104,7 @@ public void Encode_ToBytes_Bitcoin32Fast_Works() string expected = Base58.Bitcoin.Encode(data); - Span buffer = stackalloc byte[Base58.GetEncodedLength(32)]; + Span buffer = stackalloc byte[Base58.GetMaxEncodedLength(32)]; int written = Base58.Bitcoin.Encode(data, buffer); string actual = Encoding.ASCII.GetString(buffer[..written]); @@ -119,7 +119,7 @@ public void Encode_ToBytes_Bitcoin64Fast_Works() string expected = Base58.Bitcoin.Encode(data); - Span buffer = stackalloc byte[Base58.GetEncodedLength(64)]; + Span buffer = stackalloc byte[Base58.GetMaxEncodedLength(64)]; int written = Base58.Bitcoin.Encode(data, buffer); string actual = Encoding.ASCII.GetString(buffer[..written]); @@ -280,7 +280,7 @@ public void Encode_Decode_RoundTrip_Ripple() var data = new byte[20]; new Random(5).NextBytes(data); - Span encBuf = stackalloc byte[Base58.GetEncodedLength(data.Length)]; + Span encBuf = stackalloc byte[Base58.GetMaxEncodedLength(data.Length)]; int encWritten = Base58.Ripple.Encode(data, encBuf); Span decBuf = stackalloc byte[data.Length]; @@ -332,7 +332,7 @@ public void Encode_Decode_RoundTrip_Flickr() var data = new byte[20]; new Random(6).NextBytes(data); - Span encBuf = stackalloc byte[Base58.GetEncodedLength(data.Length)]; + Span encBuf = stackalloc byte[Base58.GetMaxEncodedLength(data.Length)]; int encWritten = Base58.Flickr.Encode(data, encBuf); Span decBuf = stackalloc byte[data.Length]; diff --git a/src/Base58Encoding/Base58.Length.cs b/src/Base58Encoding/Base58.Length.cs index a695d8d..666af08 100644 --- a/src/Base58Encoding/Base58.Length.cs +++ b/src/Base58Encoding/Base58.Length.cs @@ -8,7 +8,7 @@ public static partial class Base58 /// /// Length of the input data in bytes. /// Maximum number of characters/bytes written by Encode. - public static int GetEncodedLength(int byteCount) + public static int GetMaxEncodedLength(int byteCount) { if (byteCount < 0) { From 8707fa94b5d59476d8d60f474ca778751d19b32b Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:28:29 +0300 Subject: [PATCH 15/35] docs: Vector128 not used anymore --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 17ed9a2..90cd68f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ 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 From dccc5813c9b186326ab58488b854c3254d0c51ff Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:30:10 +0300 Subject: [PATCH 16/35] chore: migrate test project to Microsoft.Testing.Platform v2 native mode --- global.json | 5 +++++ src/Base58Encoding.Tests/Base58Encoding.Tests.csproj | 6 ------ 2 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 global.json 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.Tests/Base58Encoding.Tests.csproj b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj index bba85c5..1256b29 100644 --- a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj +++ b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj @@ -7,17 +7,11 @@ enable false Base58Encoding.Tests - true true - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - runtime; build; native; contentfiles; analyzers; buildtransitive all From 227e1775b11d2708ab3760429412f1c0644a31b0 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:33:37 +0300 Subject: [PATCH 17/35] chore: switch to xunit.v3.mtp-v2 and Microsoft.Testing.Extensions.CodeCoverage --- src/Base58Encoding.Tests/Base58Encoding.Tests.csproj | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj index 1256b29..4072c18 100644 --- a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj +++ b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj @@ -12,11 +12,10 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive + all - + From a5cc23dd0d79fd971b30c7e254871f90462b746e Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:34:24 +0300 Subject: [PATCH 18/35] chore: drop UseMicrosoftTestingPlatformRunner, implicit in xunit.v3.mtp-v2 --- src/Base58Encoding.Tests/Base58Encoding.Tests.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj index 4072c18..696959d 100644 --- a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj +++ b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj @@ -7,7 +7,6 @@ enable false Base58Encoding.Tests - true From c991bf2e0685cdccad5bea99a16a67294256cd23 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:35:29 +0300 Subject: [PATCH 19/35] chore: remove xunit.runner.json and its Content item, v2-only setting unused in v3 --- src/Base58Encoding.Tests/Base58Encoding.Tests.csproj | 4 ---- src/Base58Encoding.Tests/xunit.runner.json | 4 ---- 2 files changed, 8 deletions(-) delete mode 100644 src/Base58Encoding.Tests/xunit.runner.json diff --git a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj index 696959d..11dfdb6 100644 --- a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj +++ b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj @@ -17,10 +17,6 @@ - - - - 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" -} From bee9ba36fdac6fe250fa00195f3b1f07784dd2d2 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:37:22 +0300 Subject: [PATCH 20/35] docs: add PACKAGE.md as NuGet readme, separate from repo README --- PACKAGE.md | 47 ++++++++++++++++++++++++ src/Base58Encoding/Base58Encoding.csproj | 4 +- 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 PACKAGE.md diff --git a/PACKAGE.md b/PACKAGE.md new file mode 100644 index 0000000..3f9c1ee --- /dev/null +++ b/PACKAGE.md @@ -0,0 +1,47 @@ +# 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 + +## 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 +Base58.Ripple.Encode(data); +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 char span or ASCII byte span into a caller-owned buffer +Span decodedBytes = stackalloc byte[Base58.GetTypicalDecodedLength(written)]; +int decodedLen = Base58.Bitcoin.Decode(encodedBytes[..written], decodedBytes); +``` + +## License + +MIT diff --git a/src/Base58Encoding/Base58Encoding.csproj b/src/Base58Encoding/Base58Encoding.csproj index 6c5a0da..8f74464 100644 --- a/src/Base58Encoding/Base58Encoding.csproj +++ b/src/Base58Encoding/Base58Encoding.csproj @@ -13,7 +13,7 @@ https://github.com/unsafePtr/Base58Encoding https://github.com/unsafePtr/Base58Encoding MIT - README.md + PACKAGE.md false @@ -29,7 +29,7 @@ - + From 6b13234afd5b7e6810250b1f85d6214122d590c5 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:37:41 +0300 Subject: [PATCH 21/35] chore: add solana to package tags, reformat csproj --- src/Base58Encoding/Base58Encoding.csproj | 56 ++++++++++++------------ 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/Base58Encoding/Base58Encoding.csproj b/src/Base58Encoding/Base58Encoding.csproj index 8f74464..d744596 100644 --- a/src/Base58Encoding/Base58Encoding.csproj +++ b/src/Base58Encoding/Base58Encoding.csproj @@ -1,35 +1,35 @@  - - net10.0 - enable - enable - true + + net10.0 + enable + enable + true - 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 - PACKAGE.md - false - + 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 + - - - <_Parameter1>Base58Encoding.Benchmarks - - - - - <_Parameter1>Base58Encoding.Tests - - + + + <_Parameter1>Base58Encoding.Benchmarks + + + + + <_Parameter1>Base58Encoding.Tests + + - - - + + + From 86aa832835775fd2d3cc23de5a85d88c3a42ca8e Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:41:32 +0300 Subject: [PATCH 22/35] chore: migrate to NuGet Central Package Management --- Directory.Packages.props | 14 ++++++++++++++ .../Base58Encoding.Benchmarks.csproj | 4 ++-- .../Base58Encoding.Tests.csproj | 6 +++--- 3 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 Directory.Packages.props diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..67b44f0 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,14 @@ + + + + true + + + + + + + + + + diff --git a/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj b/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj index 04268dd..2ab7f81 100644 --- a/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj +++ b/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj index 11dfdb6..38ade5e 100644 --- a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj +++ b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj @@ -10,11 +10,11 @@ - - + + all - + From b3e3fe70002181f5673154d095360bdb2b055856 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:46:09 +0300 Subject: [PATCH 23/35] perf: optimize build times via Directory.Build.props and singular TargetFramework --- Directory.Build.props | 35 +++++++++++++++++++ .../Base58Encoding.Benchmarks.csproj | 2 +- src/Base58Encoding/Base58Encoding.csproj | 2 +- 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 Directory.Build.props diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..c2a5dc0 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,35 @@ + + + + + + + true + true + true + + + true + + + false + + + false + + + $(NoWarn);NU1507 + + + diff --git a/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj b/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj index 2ab7f81..1c6082d 100644 --- a/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj +++ b/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net10.0 enable enable true diff --git a/src/Base58Encoding/Base58Encoding.csproj b/src/Base58Encoding/Base58Encoding.csproj index d744596..0b679dc 100644 --- a/src/Base58Encoding/Base58Encoding.csproj +++ b/src/Base58Encoding/Base58Encoding.csproj @@ -1,7 +1,7 @@  - net10.0 + net10.0 enable enable true From a2dee82aeac7e20edde927acd4a81d5da520079b Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:49:33 +0300 Subject: [PATCH 24/35] chore: hoist common properties to Directory.Build.props, add repo-scoped NuGet.Config --- Directory.Build.props | 19 ++++++------------- NuGet.Config | 7 +++++++ .../Base58Encoding.Benchmarks.csproj | 4 ---- .../Base58Encoding.Tests.csproj | 3 --- src/Base58Encoding/Base58Encoding.csproj | 5 ----- 5 files changed, 13 insertions(+), 25 deletions(-) create mode 100644 NuGet.Config diff --git a/Directory.Build.props b/Directory.Build.props index c2a5dc0..2d51e72 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,11 +1,11 @@ - - + net10.0 + enable + enable + true + true @@ -21,15 +21,8 @@ so analysis is still enforced on every PR and main-branch build. --> false - + false - - - $(NoWarn);NU1507 diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..4d736c1 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj b/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj index 1c6082d..3d63021 100644 --- a/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj +++ b/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj @@ -2,10 +2,6 @@ Exe - net10.0 - enable - enable - true diff --git a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj index 38ade5e..9ffc9f7 100644 --- a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj +++ b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj @@ -1,10 +1,7 @@  - net10.0 Exe - enable - enable false Base58Encoding.Tests diff --git a/src/Base58Encoding/Base58Encoding.csproj b/src/Base58Encoding/Base58Encoding.csproj index 0b679dc..4eda9ca 100644 --- a/src/Base58Encoding/Base58Encoding.csproj +++ b/src/Base58Encoding/Base58Encoding.csproj @@ -1,11 +1,6 @@  - net10.0 - enable - enable - true - Base58Encoding Nikolay Zdravkov A high-performance Base58 encoding/decoding library for .NET From 5d7c92ef3755889d933e13a3273a9a2344c2a24f Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:50:22 +0300 Subject: [PATCH 25/35] chore: add Directory.Build.props, Directory.Packages.props, NuGet.Config as solution items --- src/Base58Encoding.slnx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Base58Encoding.slnx b/src/Base58Encoding.slnx index 88e2cf3..a167629 100644 --- a/src/Base58Encoding.slnx +++ b/src/Base58Encoding.slnx @@ -4,6 +4,9 @@ + + + From d085d131a8ce519bfe878991107ffe83f84df257 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:50:52 +0300 Subject: [PATCH 26/35] chore: group solution items under Solution Items folder --- src/Base58Encoding.slnx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Base58Encoding.slnx b/src/Base58Encoding.slnx index a167629..b9c7af9 100644 --- a/src/Base58Encoding.slnx +++ b/src/Base58Encoding.slnx @@ -4,9 +4,11 @@ - - - + + + + + From b1985498ec465cda14401563192fbf265bc505c8 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:53:22 +0300 Subject: [PATCH 27/35] chore: move solution files into src/, group under Solution Items folder in slnx --- src/Base58Encoding.slnx | 9 +++++---- src/Base58Encoding/Base58Encoding.csproj | 2 +- Directory.Build.props => src/Directory.Build.props | 0 Directory.Packages.props => src/Directory.Packages.props | 0 NuGet.Config => src/NuGet.Config | 0 PACKAGE.md => src/PACKAGE.md | 0 6 files changed, 6 insertions(+), 5 deletions(-) rename Directory.Build.props => src/Directory.Build.props (100%) rename Directory.Packages.props => src/Directory.Packages.props (100%) rename NuGet.Config => src/NuGet.Config (100%) rename PACKAGE.md => src/PACKAGE.md (100%) diff --git a/src/Base58Encoding.slnx b/src/Base58Encoding.slnx index b9c7af9..f6179d1 100644 --- a/src/Base58Encoding.slnx +++ b/src/Base58Encoding.slnx @@ -4,10 +4,11 @@ - - - - + + + + + diff --git a/src/Base58Encoding/Base58Encoding.csproj b/src/Base58Encoding/Base58Encoding.csproj index 4eda9ca..a6ff8d3 100644 --- a/src/Base58Encoding/Base58Encoding.csproj +++ b/src/Base58Encoding/Base58Encoding.csproj @@ -24,7 +24,7 @@ - + diff --git a/Directory.Build.props b/src/Directory.Build.props similarity index 100% rename from Directory.Build.props rename to src/Directory.Build.props diff --git a/Directory.Packages.props b/src/Directory.Packages.props similarity index 100% rename from Directory.Packages.props rename to src/Directory.Packages.props diff --git a/NuGet.Config b/src/NuGet.Config similarity index 100% rename from NuGet.Config rename to src/NuGet.Config diff --git a/PACKAGE.md b/src/PACKAGE.md similarity index 100% rename from PACKAGE.md rename to src/PACKAGE.md From b5ba44bbb8eec59749201ea19b777af25124dbcd Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:57:30 +0300 Subject: [PATCH 28/35] docs: expand PACKAGE.md with full API reference --- src/PACKAGE.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/src/PACKAGE.md b/src/PACKAGE.md index 3f9c1ee..d62ed34 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -9,6 +9,59 @@ A high-performance .NET 10 Base58 encoding and decoding library with support for - **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 | +| `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 @@ -22,8 +75,8 @@ string encoded = Base58.Bitcoin.Encode(data); byte[] decoded = Base58.Bitcoin.Decode(encoded); // Ripple / Flickr alphabets -Base58.Ripple.Encode(data); -Base58.Flickr.Encode(data); +string ripple = Base58.Ripple.Encode(data); +string flickr = Base58.Flickr.Encode(data); ``` ### Zero-allocation API @@ -37,11 +90,32 @@ byte[] data = { 0x01, 0x02, 0x03, 0x04 }; Span encodedBytes = stackalloc byte[Base58.GetMaxEncodedLength(data.Length)]; int written = Base58.Bitcoin.Encode(data, encodedBytes); -// Decode from char span or ASCII byte span into a caller-owned buffer +// 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); ``` +### Custom alphabet + +Implement `IBase58Alphabet` with a `struct` to define your own character set: + +```csharp +using Base58Encoding; + +public struct MyAlphabet : IBase58Alphabet +{ + private static ReadOnlySpan Chars => + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"u8; + + public static ReadOnlySpan Characters => Chars; + public static ReadOnlySpan DecodeTable { get; } = Base58Alphabet.BuildDecodeTable(Chars); + public static byte FirstCharacter => (byte)'1'; +} + +var codec = new Base58(); +string encoded = codec.Encode(data); +``` + ## License MIT From 47414be07391ca2e0dcd47a3602121cc89000643 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:58:04 +0300 Subject: [PATCH 29/35] docs: remove custom alphabet section from PACKAGE.md --- src/PACKAGE.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/PACKAGE.md b/src/PACKAGE.md index d62ed34..2d3d630 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -95,27 +95,6 @@ Span decodedBytes = stackalloc byte[Base58.GetTypicalDecodedLength(written int decodedLen = Base58.Bitcoin.Decode(encodedBytes[..written], decodedBytes); ``` -### Custom alphabet - -Implement `IBase58Alphabet` with a `struct` to define your own character set: - -```csharp -using Base58Encoding; - -public struct MyAlphabet : IBase58Alphabet -{ - private static ReadOnlySpan Chars => - "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"u8; - - public static ReadOnlySpan Characters => Chars; - public static ReadOnlySpan DecodeTable { get; } = Base58Alphabet.BuildDecodeTable(Chars); - public static byte FirstCharacter => (byte)'1'; -} - -var codec = new Base58(); -string encoded = codec.Encode(data); -``` - ## License MIT From dd4b7879629ad2cb7d7eca433ccd55bc9ba48fdc Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 22:58:43 +0300 Subject: [PATCH 30/35] docs: add Monero to Bitcoin alphabet supported list --- src/PACKAGE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PACKAGE.md b/src/PACKAGE.md index 2d3d630..98b38e8 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -13,7 +13,7 @@ A high-performance .NET 10 Base58 encoding and decoding library with support for | Property | First Character | Used By | |---|---|---| -| `Base58.Bitcoin` | `1` | Bitcoin, IPFS, Solana, Sui | +| `Base58.Bitcoin` | `1` | Bitcoin, IPFS, Solana, Sui, Monero | | `Base58.Ripple` | `r` | Ripple (XRP) | | `Base58.Flickr` | `1` | Flickr short URLs | From 122ebb3f08b19aebdf64cb59a2cf20bfda9b2ebc Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 23:04:51 +0300 Subject: [PATCH 31/35] =?UTF-8?q?build:=20optimize=20MSBuild=20=E2=80=94?= =?UTF-8?q?=20artifacts=20output,=20SourceLink=20on=20CI=20only,=20auto=20?= =?UTF-8?q?CI=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Base58Encoding/Base58Encoding.csproj | 2 -- src/Directory.Build.props | 23 ++++++++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/Base58Encoding/Base58Encoding.csproj b/src/Base58Encoding/Base58Encoding.csproj index a6ff8d3..a5997b0 100644 --- a/src/Base58Encoding/Base58Encoding.csproj +++ b/src/Base58Encoding/Base58Encoding.csproj @@ -16,8 +16,6 @@ <_Parameter1>Base58Encoding.Benchmarks - - <_Parameter1>Base58Encoding.Tests diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 2d51e72..0f41b38 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -6,23 +6,28 @@ enable true - + true true true - + true - - false + + true + + + true - + + true + + + false false + false + false From 424feb7243d377d454e1df254a1a17b5957a209a Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 23:06:05 +0300 Subject: [PATCH 32/35] bench: add ZeroAllocBenchmark for Encode/Decode span overloads --- .../ZeroAllocBenchmark.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/Base58Encoding.Benchmarks/ZeroAllocBenchmark.cs diff --git a/src/Base58Encoding.Benchmarks/ZeroAllocBenchmark.cs b/src/Base58Encoding.Benchmarks/ZeroAllocBenchmark.cs new file mode 100644 index 0000000..3bbbbf2 --- /dev/null +++ b/src/Base58Encoding.Benchmarks/ZeroAllocBenchmark.cs @@ -0,0 +1,39 @@ +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, + TestVectors.VectorType.SolanaTx, + TestVectors.VectorType.MoneroAddress + )] + 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); +} From 1a17ef2fea88940459f5024795f9ad1ad517093e Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 23:09:54 +0300 Subject: [PATCH 33/35] bench: trim ZeroAllocBenchmark params to BitcoinAddress and SolanaAddress --- src/Base58Encoding.Benchmarks/ZeroAllocBenchmark.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Base58Encoding.Benchmarks/ZeroAllocBenchmark.cs b/src/Base58Encoding.Benchmarks/ZeroAllocBenchmark.cs index 3bbbbf2..29818a1 100644 --- a/src/Base58Encoding.Benchmarks/ZeroAllocBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/ZeroAllocBenchmark.cs @@ -15,9 +15,7 @@ public class ZeroAllocBenchmark [Params( TestVectors.VectorType.BitcoinAddress, - TestVectors.VectorType.SolanaAddress, - TestVectors.VectorType.SolanaTx, - TestVectors.VectorType.MoneroAddress + TestVectors.VectorType.SolanaAddress )] public TestVectors.VectorType VectorType { get; set; } From 2e44b43942b44cf253a36b9432e21da92ac443df Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 23:14:00 +0300 Subject: [PATCH 34/35] ci: fix path triggers, remove redundant negations, align setup-dotnet to v4 --- .github/workflows/publish-nuget.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index ce42848..39f2730 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: @@ -84,7 +90,7 @@ jobs: path: ./artifacts - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x From 8c674abb5b74ab469898a4102ba56a9ee9999cbb Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 22 Apr 2026 23:14:51 +0300 Subject: [PATCH 35/35] ci: bump setup-dotnet to v5 --- .github/workflows/publish-nuget.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 39f2730..ca21f0c 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -49,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 @@ -90,7 +90,7 @@ jobs: path: ./artifacts - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x