From 290e47ce89db913bba2eb48c7297e8659af9be11 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 7 Jan 2026 21:53:26 +0200 Subject: [PATCH 01/13] feat: firedancer optimizations --- .../FastVsRegularEncodeBenchmark.cs | 45 ++ src/Base58Encoding.Benchmarks/Program.cs | 2 +- src/Base58Encoding.Tests/Base58Tests.cs | 571 +++++++++++++++++- src/Base58Encoding/Base58.cs | 140 +---- src/Base58Encoding/Base58Alphabet.cs | 25 +- src/Base58Encoding/Base58BitcoinTables.cs | 98 +++ src/Base58Encoding/Decode.Base58.cs | 293 +++++++++ src/Base58Encoding/Encode.Base58.cs | 321 ++++++++++ 8 files changed, 1318 insertions(+), 177 deletions(-) create mode 100644 src/Base58Encoding.Benchmarks/FastVsRegularEncodeBenchmark.cs create mode 100644 src/Base58Encoding/Base58BitcoinTables.cs create mode 100644 src/Base58Encoding/Decode.Base58.cs create mode 100644 src/Base58Encoding/Encode.Base58.cs diff --git a/src/Base58Encoding.Benchmarks/FastVsRegularEncodeBenchmark.cs b/src/Base58Encoding.Benchmarks/FastVsRegularEncodeBenchmark.cs new file mode 100644 index 0000000..f630e63 --- /dev/null +++ b/src/Base58Encoding.Benchmarks/FastVsRegularEncodeBenchmark.cs @@ -0,0 +1,45 @@ +using BenchmarkDotNet.Attributes; + +namespace Base58Encoding.Benchmarks; + +[MemoryDiagnoser] +public class FastVsRegularEncodeBenchmark +{ + public byte[] _data; + private string _encodedBase58; + + [GlobalSetup] + public void Setup() + { + _data = new byte[32]; + + Random.Shared.NextBytes(_data); + + _encodedBase58 = SimpleBase.Base58.Bitcoin.Encode(_data); + } + + [Benchmark] + public string RegularEncode() + { + return Base58.Bitcoin.EncodeGeneric(_data); + } + + [Benchmark] + public string FastEncode() + { + return Base58.EncodeBitcoin32Fast(_data); + } + + + [Benchmark] + public byte[] RegularDecode() + { + return Base58.Bitcoin.DecodeGeneric(_encodedBase58); + } + + [Benchmark] + public byte[] FastDecode() + { + return Base58.DecodeBitcoin32Fast(_encodedBase58)!; + } +} diff --git a/src/Base58Encoding.Benchmarks/Program.cs b/src/Base58Encoding.Benchmarks/Program.cs index e48a046..6fc7b70 100644 --- a/src/Base58Encoding.Benchmarks/Program.cs +++ b/src/Base58Encoding.Benchmarks/Program.cs @@ -2,5 +2,5 @@ using Base58Encoding.Benchmarks; //BenchmarkRunner.Run(); -BenchmarkRunner.Run(); +BenchmarkRunner.Run(); //BenchmarkRunner.Run(); diff --git a/src/Base58Encoding.Tests/Base58Tests.cs b/src/Base58Encoding.Tests/Base58Tests.cs index 25f4d2e..3cc4564 100644 --- a/src/Base58Encoding.Tests/Base58Tests.cs +++ b/src/Base58Encoding.Tests/Base58Tests.cs @@ -271,27 +271,566 @@ public void Encode_WithLeadingZeroPatterns_PreservesCorrectly() } [Fact] - public void DecodeTable_Generation_ProducesCorrectMapping() + public void BitcoinAlphabet_EncodeGeneric_Generates_SameResultAsFast32() { - // Test that we can generate correct decode tables using the Custom method - const string rippleAlphabet = "rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"; - const string flickrAlphabet = "123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; + Span buffer = stackalloc byte[32]; + Random.Shared.NextBytes(buffer); - var rippleCustom = Base58Alphabet.Custom(rippleAlphabet); - var flickrCustom = Base58Alphabet.Custom(flickrAlphabet); + var genericResult = Base58.Bitcoin.EncodeGeneric(buffer); + var fastResult = Base58.EncodeBitcoin32Fast(buffer); - // Test simple single character encoding/decoding - var testByte = new byte[] { 1 }; + Assert.Equal(genericResult, fastResult); + } + + [Fact] + public void Encode32Fast_TableDimensions_AreCorrect() + { + // Verify table dimensions + Assert.Equal(8, Base58BitcoinTables.BinarySz32); + Assert.Equal(9, Base58BitcoinTables.IntermediateSz32); + Assert.Equal(45, Base58BitcoinTables.Raw58Sz32); + + // Verify table bounds + var encodeTable = Base58BitcoinTables.EncodeTable32; + Assert.Equal(8, encodeTable.Length); // Should be BinarySz32 + Assert.Equal(8, encodeTable[0].Length); // Should be IntermediateSz32 - 1 + } + + [Fact] + public void Encode32Fast_WithAllZeros_ReturnsCorrectOnes() + { + // Arrange + var allZeros = new byte[32]; + + // Act + var result = Base58.Bitcoin.Encode(allZeros); + + // Assert - Should be 32 '1's for 32 zero bytes + Assert.Equal(new string('1', 32), result); + } + + [Fact] + public void Encode32Fast_WithAllOnes_ProducesCorrectResult() + { + // Arrange + var allOnes = Enumerable.Repeat((byte)0xFF, 32).ToArray(); + + // Act + var fastResult = Base58.Bitcoin.Encode(allOnes); + var genericResult = Base58.Bitcoin.EncodeGeneric(allOnes); + + // Assert + Assert.Equal(genericResult, fastResult); + Assert.NotEmpty(fastResult); + } + + [Fact] + public void Encode32Fast_WithLeadingZeros_HandlesCorrectly() + { + // Test one simple case first to isolate the issue + var testCase = new byte[] { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFE, 0xFD, 0xFC }; // 28 leading zeros + + // Act + var genericResult = Base58.Bitcoin.EncodeGeneric(testCase); + var simpleBase = SimpleBase.Base58.Bitcoin.Encode(testCase); + var fastResult = Base58.Bitcoin.Encode(testCase); + + // Assert + Assert.Equal(genericResult, fastResult); + + // Verify leading zeros are preserved as '1's + int leadingZeros = testCase.TakeWhile(b => b == 0).Count(); + Assert.StartsWith(new string('1', leadingZeros), fastResult); + } + + [Theory] + [InlineData(1000)] + public void Encode32Fast_WithRandomData_MatchesGeneric(int testCount) + { + var random = new Random(42); // Fixed seed for reproducibility + + for (int i = 0; i < testCount; i++) + { + // Arrange + var testData = new byte[32]; + random.NextBytes(testData); + + // Act + var fastResult = Base58.Bitcoin.Encode(testData); + var genericResult = Base58.Bitcoin.EncodeGeneric(testData); + + // Assert + Assert.Equal(genericResult, fastResult); + + // Verify it round-trips correctly + var decoded = Base58.Bitcoin.Decode(fastResult); + Assert.Equal(testData, decoded); + } + } + + [Fact] + public void FastPath_OnlyTriggersForBitcoinAlphabet() + { + // Arrange + var testData = new byte[32]; + Random.Shared.NextBytes(testData); + + // Act + var bitcoinResult = Base58.Bitcoin.Encode(testData); + var rippleResult = Base58.Ripple.Encode(testData); + + // Assert - Both should work but produce different results + Assert.NotEqual(bitcoinResult, rippleResult); + + // Verify round-trips + var bitcoinDecoded = Base58.Bitcoin.Decode(bitcoinResult); + var rippleDecoded = Base58.Ripple.Decode(rippleResult); + + Assert.Equal(testData, bitcoinDecoded); + Assert.Equal(testData, rippleDecoded); + } + + [Fact] + public void Encode32Fast_WithRealBitcoinAddressData_WorksCorrectly() + { + // Arrange - Real Bitcoin address hash160 (20 bytes padded to 32) + var hash160 = new byte[] + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 12 zero padding + 0x76, 0xa9, 0x14, 0x89, 0xab, 0xcd, 0xef, 0xab, 0xba, 0xab, 0xba, 0xab, + 0xba, 0xab, 0xba, 0xab, 0xba, 0xab, 0xba, 0xab, 0xba, 0x88, 0xac, 0x00 + }; + + // Act + var fastResult = Base58.Bitcoin.Encode(hash160); + var genericResult = Base58.Bitcoin.EncodeGeneric(hash160); + + // Assert + Assert.Equal(genericResult, fastResult); + + // Should start with 12 '1's due to leading zeros + Assert.StartsWith(new string('1', 12), fastResult); + } + + [Fact] + public void Encode_WithNon32ByteInputs_UsesGenericPath() + { + var testCases = new[] + { + new byte[31], // 31 bytes + new byte[33], // 33 bytes + new byte[20], // 20 bytes (Bitcoin hash160) + new byte[64], // 64 bytes (will use generic until we implement 64-byte fast path) + }; + + foreach (var testCase in testCases) + { + Random.Shared.NextBytes(testCase); + + // Act + var result = Base58.Bitcoin.Encode(testCase); + + // Assert - Should work correctly via generic path + var decoded = Base58.Bitcoin.Decode(result); + Assert.Equal(testCase, decoded); + } + } + + [Fact] + public void Encode64Fast_TableDimensions_AreCorrect() + { + // Verify table dimensions + Assert.Equal(16, Base58BitcoinTables.BinarySz64); + Assert.Equal(18, Base58BitcoinTables.IntermediateSz64); + Assert.Equal(90, Base58BitcoinTables.Raw58Sz64); + + // Verify table bounds + var encodeTable = Base58BitcoinTables.EncodeTable64; + Assert.Equal(16, encodeTable.Length); // Should be BinarySz64 + Assert.Equal(17, encodeTable[0].Length); // Should be IntermediateSz64 - 1 + } + + [Fact] + public void Encode64Fast_WithAllZeros_ReturnsCorrectOnes() + { + // Arrange + var allZeros = new byte[64]; + + // Act + var result = Base58.Bitcoin.Encode(allZeros); + + // Assert - Should be 64 '1's for 64 zero bytes + Assert.Equal(new string('1', 64), result); + } + + [Fact] + public void Encode64Fast_WithRandomData_MatchesGeneric() + { + var random = new Random(42); // Fixed seed for reproducibility + + for (int i = 0; i < 100; i++) + { + // Arrange + var testData = new byte[64]; + random.NextBytes(testData); + + // Act + var fastResult = Base58.Bitcoin.Encode(testData); + var genericResult = Base58.Bitcoin.EncodeGeneric(testData); + + // Assert + Assert.Equal(genericResult, fastResult); + + // Verify it round-trips correctly + var decoded = Base58.Bitcoin.Decode(fastResult); + Assert.Equal(testData, decoded); + } + } + + [Fact] + public void Encode64Fast_WithLeadingZeros_HandlesCorrectly() + { + // Arrange - 64-byte data with leading zeros + var testCases = new[] + { + new byte[64], // All zeros + Enumerable.Range(0, 64).Select(i => i < 32 ? (byte)0x00 : (byte)0xFF).ToArray(), // 32 leading zeros + }; + + foreach (var testCase in testCases) + { + // Act + var fastResult = Base58.Bitcoin.Encode(testCase); + var genericResult = Base58.Bitcoin.EncodeGeneric(testCase); + + // Assert + Assert.Equal(genericResult, fastResult); + + // Verify leading zeros are preserved as '1's + int leadingZeros = testCase.TakeWhile(b => b == 0).Count(); + Assert.StartsWith(new string('1', leadingZeros), fastResult); + } + } + + [Fact] + public void Encode64Fast_WithSolanaTransactionSignature_WorksCorrectly() + { + // Arrange - Simulate a 64-byte Solana transaction signature + var signatureData = new byte[64]; + var random = new Random(123); + random.NextBytes(signatureData); + + // Act + var fastResult = Base58.Bitcoin.Encode(signatureData); + var genericResult = Base58.Bitcoin.EncodeGeneric(signatureData); + + // Assert + Assert.Equal(genericResult, fastResult); + + // Verify round-trip + var decoded = Base58.Bitcoin.Decode(fastResult); + Assert.Equal(signatureData, decoded); + + // Should be between 64 and 88 characters + Assert.InRange(fastResult.Length, 64, 88); + } + + [Fact] + public void Decode32Fast_WithValidInput_MatchesGeneric() + { + var random = new Random(42); + + for (int i = 0; i < 100; i++) + { + // Arrange + var testData = new byte[32]; + random.NextBytes(testData); + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var fastDecoded = Base58.Bitcoin.Decode(encoded); + var genericDecoded = Base58.Bitcoin.DecodeGeneric(encoded); + + // Assert + Assert.Equal(genericDecoded, fastDecoded); + Assert.Equal(testData, fastDecoded); + } + } + + [Fact] + public void Decode64Fast_WithValidInput_MatchesGeneric() + { + var random = new Random(42); + + for (int i = 0; i < 100; i++) + { + // Arrange + var testData = new byte[64]; + random.NextBytes(testData); - var rippleEncoded = new Base58(rippleCustom).Encode(testByte); - var rippleDecoded = new Base58(rippleCustom).Decode(rippleEncoded); - Assert.Equal(testByte, rippleDecoded); + var encoded = Base58.Bitcoin.Encode(testData); - var flickrEncoded = new Base58(flickrCustom).Encode(testByte); - var flickrDecoded = new Base58(flickrCustom).Decode(flickrEncoded); - Assert.Equal(testByte, flickrDecoded); + // Act + var fastDecoded = Base58.Bitcoin.Decode(encoded); + var genericDecoded = Base58.Bitcoin.DecodeGeneric(encoded); + + // Assert + Assert.Equal(genericDecoded, fastDecoded); + Assert.Equal(testData, fastDecoded); + } + } + + [Fact] + public void Decode32Fast_WithAllZeros_WorksCorrectly() + { + // Arrange + var allZeros = new byte[32]; + var encoded = SimpleBase.Base58.Bitcoin.Encode(allZeros); + + // Act + var decoded = Base58.Bitcoin.Decode(encoded); + var genericDecoded = Base58.Bitcoin.DecodeGeneric(encoded); + + // Assert - Debug info + Console.WriteLine($"Encoded: {encoded}"); + Console.WriteLine($"Fast decoded length: {decoded.Length}"); + Console.WriteLine($"Generic decoded length: {genericDecoded.Length}"); + + // Debug output first + Console.WriteLine($"Encoded: '{encoded}' (length: {encoded.Length})"); + Console.WriteLine($"Generic decoded: length {genericDecoded.Length}"); + Console.WriteLine($"Fast decoded: length {decoded.Length}"); + + Assert.Equal(genericDecoded, decoded); + } + + [Fact] + public void Decode64Fast_WithAllZeros_WorksCorrectly() + { + // Arrange + var allZeros = new byte[64]; + var encoded = SimpleBase.Base58.Bitcoin.Encode(allZeros); + + // Act + var decoded = Base58.Bitcoin.Decode(encoded); + + // Assert + Assert.Equal(allZeros, decoded); + Assert.Equal(new string('1', 64), encoded); + } + + [Theory] + [InlineData("invalid0chars")] // Invalid character '0' + public void Decode32Fast_WithInvalidInput_FallsBackToGeneric(string input) + { + // Act & Assert - Should throw exception via generic path + Assert.Throws(() => Base58.Bitcoin.Decode(input)); + } + + [Fact] + public void Decode32Fast_WithLeadingOnes_HandlesCorrectly() + { + // Arrange - 28 leading zeros + some data + var testData = new byte[32]; + testData[28] = 0xAB; + testData[29] = 0xCD; + testData[30] = 0xEF; + testData[31] = 0x12; + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var decoded = Base58.Bitcoin.Decode(encoded); + + // Assert + Assert.Equal(testData, decoded); + Assert.StartsWith(new string('1', 28), encoded); + } + + [Fact] + public void Decode64Fast_WithLeadingOnes_HandlesCorrectly() + { + // Arrange - 32 leading zeros + data + var testData = new byte[64]; + for (int i = 32; i < 64; i++) + { + testData[i] = (byte)(i - 32); + } + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var decoded = Base58.Bitcoin.Decode(encoded); + + // Assert + Assert.Equal(testData, decoded); + Assert.StartsWith(new string('1', 32), encoded); + } + + [Fact] + public void Decode32Fast_WithKnownTestVectors_WorksCorrectly() + { + // Arrange - Create known 32-byte inputs and their expected Base58 outputs + var testCases = new[] + { + new byte[32], // All zeros + Enumerable.Repeat((byte)0xFF, 32).ToArray(), // All 255s + }; + + foreach (var testCase in testCases) + { + // Act + var encoded = SimpleBase.Base58.Bitcoin.Encode(testCase); + var decodedsimpleBase = SimpleBase.Base58.Bitcoin.Decode(encoded); + var decoded = Base58.DecodeBitcoin32Fast(encoded); + + var genericDecoded = Base58.Bitcoin.DecodeGeneric(encoded); + Console.WriteLine($"Generic decoded: length {genericDecoded.Length}"); + + // Assert + Assert.Equal(testCase, decoded); + + // Verify round-trip with generic + Assert.Equal(genericDecoded, decoded); + } + } + + [Fact] + public void Decode64Fast_WithKnownTestVectors_WorksCorrectly() + { + // Arrange - Create known 64-byte inputs + var testCases = new[] + { + new byte[64], // All zeros + Enumerable.Repeat((byte)0xFF, 64).ToArray(), // All 255s + }; + + foreach (var testCase in testCases) + { + // Act + var encoded = Base58.Bitcoin.Encode(testCase); + var decoded = Base58.Bitcoin.Decode(encoded); + + // Assert + Assert.Equal(testCase, decoded); + + // Verify round-trip with generic + var genericDecoded = Base58.Bitcoin.DecodeGeneric(encoded); + Assert.Equal(genericDecoded, decoded); + } + } + + [Theory] + [InlineData(0)] // No leading zeros + [InlineData(1)] // 1 leading zero + [InlineData(5)] // 5 leading zeros + [InlineData(16)] // Half the array + [InlineData(31)] // Almost all zeros + public void Decode32Fast_WithVariousLeadingZeros_HandlesCorrectly(int leadingZeros) + { + // Arrange + var testData = new byte[32]; + Random.Shared.NextBytes(testData.AsSpan(leadingZeros)); + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var decoded = Base58.Bitcoin.Decode(encoded); + + // Assert + Assert.Equal(testData, decoded); + + // Verify leading zeros preservation + if (leadingZeros > 0) + { + Assert.StartsWith(new string('1', leadingZeros), encoded); + } + } + + [Theory] + [InlineData(0)] // No leading zeros + [InlineData(1)] // 1 leading zero + [InlineData(8)] // 8 leading zeros + [InlineData(32)] // Half the array + [InlineData(63)] // Almost all zeros + public void Decode64Fast_WithVariousLeadingZeros_HandlesCorrectly(int leadingZeros) + { + // Arrange + var testData = new byte[64]; + Random.Shared.NextBytes(testData.AsSpan(leadingZeros)); + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var decoded = Base58.Bitcoin.Decode(encoded); + + // Assert + Assert.Equal(testData, decoded); + + // Verify leading zeros preservation + if (leadingZeros > 0) + { + Assert.StartsWith(new string('1', leadingZeros), encoded); + } + } + + [Fact] + public void Decode32Fast_WithMaximumValues_WorksCorrectly() + { + // Arrange - Create data that will generate maximum Base58 length + var testData = new byte[32]; + Array.Fill(testData, (byte)0xFF); + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var decoded = Base58.Bitcoin.Decode(encoded); + + // Assert + Assert.Equal(testData, decoded); + Assert.InRange(encoded.Length, 43, 44); // Maximum Base58 length for 32 bytes + } + + [Fact] + public void Decode64Fast_WithMaximumValues_WorksCorrectly() + { + // Arrange - Create data that will generate maximum Base58 length + var testData = new byte[64]; + Array.Fill(testData, (byte)0xFF); + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var decoded = Base58.Bitcoin.Decode(encoded); + + // Assert + Assert.Equal(testData, decoded); + Assert.InRange(encoded.Length, 87, 88); // Maximum Base58 length for 64 bytes + } + + [Fact] + public void DecodeFast_OnlyTriggersForCorrectInputLengths() + { + // Arrange - Various input sizes that should NOT trigger fast paths + var testCases = new[] + { + new byte[31], // 31 bytes - too small for 32-byte fast path + new byte[33], // 33 bytes - too big for 32-byte, too small for 64-byte + new byte[63], // 63 bytes - too small for 64-byte fast path + new byte[65], // 65 bytes - too big for 64-byte fast path + }; - // The encoded results should be different character sets - Assert.NotEqual(rippleEncoded, flickrEncoded); + foreach (var testCase in testCases) + { + Random.Shared.NextBytes(testCase); + + // Act + var encoded = Base58.Bitcoin.Encode(testCase); + var decoded = Base58.Bitcoin.Decode(encoded); + + // Assert - Should work correctly via generic path + Assert.Equal(testCase, decoded); + } } } \ No newline at end of file diff --git a/src/Base58Encoding/Base58.cs b/src/Base58Encoding/Base58.cs index d0fc14d..30affde 100644 --- a/src/Base58Encoding/Base58.cs +++ b/src/Base58Encoding/Base58.cs @@ -1,3 +1,5 @@ +using System.Buffers.Binary; +using System.Diagnostics; using System.Runtime.CompilerServices; namespace Base58Encoding; @@ -19,146 +21,10 @@ public partial class Base58 public static Base58 Ripple => _ripple.Value; public static Base58 Flickr => _flickr.Value; - public Base58(Base58Alphabet alphabet) + private Base58(Base58Alphabet alphabet) { _characters = alphabet.Characters; _decodeTable = alphabet.DecodeTable; _firstCharacter = alphabet.FirstCharacter; } - - /// - /// Encode byte array to Base58 string - /// - /// Bytes to encode - /// Base58 encoded string - public string Encode(ReadOnlySpan data) - { - if (data.IsEmpty) - return string.Empty; - - int leadingZeros = CountLeadingZeros(data); - - if (leadingZeros == data.Length) - { - return new string(_firstCharacter, leadingZeros); - } - - var inputSpan = data[leadingZeros..]; - - var size = (inputSpan.Length * 137 / 100) + 1; - Span digits = size > MaxStackallocByte - ? new byte[size] - : stackalloc byte[size]; - - int digitCount = 1; - digits[0] = 0; - - foreach (byte b in inputSpan) - { - int carry = b; - - for (int i = 0; i < digitCount; i++) - { - carry += digits[i] << 8; - carry = Math.DivRem(carry, Base, out int remainder); - digits[i] = (byte)remainder; - } - - while (carry > 0) - { - carry = Math.DivRem(carry, Base, out int remainder); - digits[digitCount++] = (byte)remainder; - } - } - - int resultSize = leadingZeros + digitCount; - return string.Create(resultSize, new FinalString(_characters.Span, digits, _firstCharacter, leadingZeros, digitCount), static (span, state) => - { - if (state.LeadingZeroes > 0) - { - span[..state.LeadingZeroes].Fill(state.FirstCharacter); - } - - int index = state.LeadingZeroes; - for (int i = state.DigitCount - 1; i >= 0; i--) - { - span[index++] = state.Alphabet[state.Digits[i]]; - } - }); - } - - /// - /// Decode Base58 string to byte array - /// - /// Base58 encoded string - /// Decoded byte array - /// Invalid Base58 character - public byte[] Decode(ReadOnlySpan encoded) - { - if (encoded.IsEmpty) - return []; - - int leadingOnes = CountLeadingCharacters(encoded, _firstCharacter); - - int outputSize = encoded.Length * 733 / 1000 + 1; - - Span decoded = outputSize > MaxStackallocByte - ? new byte[outputSize] - : stackalloc byte[outputSize]; - - int decodedLength = 1; - decoded[0] = 0; - - var decodeTable = _decodeTable.Span; - - for (int i = leadingOnes; i < encoded.Length; i++) - { - char c = encoded[i]; - - if (c >= 128 || decodeTable[c] == 255) - ThrowHelper.ThrowInvalidCharacter(c); - - int carry = decodeTable[c]; - - for (int j = 0; j < decodedLength; j++) - { - carry += decoded[j] * Base; - decoded[j] = (byte)(carry & 0xFF); - carry >>= 8; - } - - while (carry > 0) - { - decoded[decodedLength++] = (byte)(carry & 0xFF); - carry >>= 8; - } - } - - var result = new byte[leadingOnes + decodedLength]; - - var finalDecoded = decoded.Slice(0, decodedLength); - finalDecoded.Reverse(); - - finalDecoded.CopyTo(result.AsSpan(leadingOnes)); - - return result; - } - - private readonly ref struct FinalString - { - public readonly ReadOnlySpan Alphabet; - public readonly ReadOnlySpan Digits; - public readonly char FirstCharacter; - public readonly int LeadingZeroes; - public readonly int DigitCount; - - public FinalString(ReadOnlySpan alphabet, ReadOnlySpan digits, char firstCharacter, int leadingZeroes, int digitCount) - { - Alphabet = alphabet; - Digits = digits; - FirstCharacter = firstCharacter; - LeadingZeroes = leadingZeroes; - DigitCount = digitCount; - } - } } \ No newline at end of file diff --git a/src/Base58Encoding/Base58Alphabet.cs b/src/Base58Encoding/Base58Alphabet.cs index b2acd68..77884c4 100644 --- a/src/Base58Encoding/Base58Alphabet.cs +++ b/src/Base58Encoding/Base58Alphabet.cs @@ -42,7 +42,7 @@ private Base58Alphabet(ReadOnlyMemory characters, ReadOnlyMemory dec // Static decode tables - using ReadOnlyMemory for better performance private static readonly ReadOnlyMemory BitcoinDecodeTable = new byte[] -{ + { 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, @@ -51,7 +51,7 @@ private Base58Alphabet(ReadOnlyMemory characters, ReadOnlyMemory dec 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 -}; + }; private static readonly ReadOnlyMemory RippleDecodeTable = new byte[] { @@ -76,25 +76,4 @@ private Base58Alphabet(ReadOnlyMemory characters, ReadOnlyMemory dec 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 }; - - public static Base58Alphabet Custom(string characters) - { - if (characters.Length != 58) - { - ThrowHelper.ThrowNotExactLength(); - } - - var decodeTable = new byte[128]; - for (int i = 0; i < 128; i++) - { - decodeTable[i] = 255; - } - - for (int i = 0; i < characters.Length; i++) - { - decodeTable[characters[i]] = (byte)i; - } - - return new Base58Alphabet(characters.AsMemory(), decodeTable, characters[0]); - } } \ No newline at end of file diff --git a/src/Base58Encoding/Base58BitcoinTables.cs b/src/Base58Encoding/Base58BitcoinTables.cs new file mode 100644 index 0000000..57b5bbc --- /dev/null +++ b/src/Base58Encoding/Base58BitcoinTables.cs @@ -0,0 +1,98 @@ +using System; + +namespace Base58Encoding; + +internal static class Base58BitcoinTables +{ + internal const uint R1Div = 656356768U; // 58^5 + internal const byte InvalidChar = 255; + internal const byte InverseTableOffset = (byte)'1'; // Characters are offset by '1' + + // Bitcoin alphabet for fast character mapping + internal static ReadOnlySpan BitcoinChars => "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + + // Constants for 32-byte encoding/decoding (from Firedancer) + internal const int BinarySz32 = 8; + internal const int IntermediateSz32 = 9; + internal const int Raw58Sz32 = IntermediateSz32 * 5; // 45 + + // Contains the unique values less than 58^5 such that: + // 2^(32*(7-j)) = sum_k EncodeTable32[j][k]*58^(5*(7-k)) + internal static readonly uint[][] EncodeTable32 = + [ + [513735U, 77223048U, 437087610U, 300156666U, 605448490U, 214625350U, 141436834U, 379377856U], + [0U, 78508U, 646269101U, 118408823U, 91512303U, 209184527U, 413102373U, 153715680U], + [0U, 0U, 11997U, 486083817U, 3737691U, 294005210U, 247894721U, 289024608U], + [0U, 0U, 0U, 1833U, 324463681U, 385795061U, 551597588U, 21339008U], + [0U, 0U, 0U, 0U, 280U, 127692781U, 389432875U, 357132832U], + [0U, 0U, 0U, 0U, 0U, 42U, 537767569U, 410450016U], + [0U, 0U, 0U, 0U, 0U, 0U, 6U, 356826688U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 1U] + ]; + + // Contains the unique values less than 2^32 such that: + // 58^(5*(8-j)) = sum_k DecodeTable32[j][k]*2^(32*(7-k)) + internal static readonly uint[][] DecodeTable32 = + [ + [1277U, 2650397687U, 3801011509U, 2074386530U, 3248244966U, 687255411U, 2959155456U, 0U], + [0U, 8360U, 1184754854U, 3047609191U, 3418394749U, 132556120U, 1199103528U, 0U], + [0U, 0U, 54706U, 2996985344U, 1834629191U, 3964963911U, 485140318U, 1073741824U], + [0U, 0U, 0U, 357981U, 1476998812U, 3337178590U, 1483338760U, 4194304000U], + [0U, 0U, 0U, 0U, 2342503U, 3052466824U, 2595180627U, 17825792U], + [0U, 0U, 0U, 0U, 0U, 15328518U, 1933902296U, 4063920128U], + [0U, 0U, 0U, 0U, 0U, 0U, 100304420U, 3355157504U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 656356768U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 1U] + ]; + + // Constants for 64-byte encoding/decoding (from Firedancer) + internal const int BinarySz64 = 16; + internal const int IntermediateSz64 = 18; + internal const int Raw58Sz64 = IntermediateSz64 * 5; // 90 + + // Contains the unique values less than 58^5 such that: + // 2^(32*(15-j)) = sum_k EncodeTable64[j][k]*58^(5*(17-k)) + internal static readonly uint[][] EncodeTable64 = + [ + [2631U, 149457141U, 577092685U, 632289089U, 81912456U, 221591423U, 502967496U, 403284731U, 377738089U, 492128779U, 746799U, 366351977U, 190199623U, 38066284U, 526403762U, 650603058U, 454901440U], + [0U, 402U, 68350375U, 30641941U, 266024478U, 208884256U, 571208415U, 337765723U, 215140626U, 129419325U, 480359048U, 398051646U, 635841659U, 214020719U, 136986618U, 626219915U, 49699360U], + [0U, 0U, 61U, 295059608U, 141201404U, 517024870U, 239296485U, 527697587U, 212906911U, 453637228U, 467589845U, 144614682U, 45134568U, 184514320U, 644355351U, 104784612U, 308625792U], + [0U, 0U, 0U, 9U, 256449755U, 500124311U, 479690581U, 372802935U, 413254725U, 487877412U, 520263169U, 176791855U, 78190744U, 291820402U, 74998585U, 496097732U, 59100544U], + [0U, 0U, 0U, 0U, 1U, 285573662U, 455976778U, 379818553U, 100001224U, 448949512U, 109507367U, 117185012U, 347328982U, 522665809U, 36908802U, 577276849U, 64504928U], + [0U, 0U, 0U, 0U, 0U, 0U, 143945778U, 651677945U, 281429047U, 535878743U, 264290972U, 526964023U, 199595821U, 597442702U, 499113091U, 424550935U, 458949280U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 21997789U, 294590275U, 148640294U, 595017589U, 210481832U, 404203788U, 574729546U, 160126051U, 430102516U, 44963712U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 3361701U, 325788598U, 30977630U, 513969330U, 194569730U, 164019635U, 136596846U, 626087230U, 503769920U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 513735U, 77223048U, 437087610U, 300156666U, 605448490U, 214625350U, 141436834U, 379377856U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 78508U, 646269101U, 118408823U, 91512303U, 209184527U, 413102373U, 153715680U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 11997U, 486083817U, 3737691U, 294005210U, 247894721U, 289024608U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 1833U, 324463681U, 385795061U, 551597588U, 21339008U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 280U, 127692781U, 389432875U, 357132832U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 42U, 537767569U, 410450016U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 6U, 356826688U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 1U] + ]; + + // Contains the unique values less than 2^32 such that: + // 58^(5*(17-j)) = sum_k DecodeTable64[j][k]*2^(32*(15-k)) + internal static readonly uint[][] DecodeTable64 = + [ + [249448U, 3719864065U, 173911550U, 4021557284U, 3115810883U, 2498525019U, 1035889824U, 627529458U, 3840888383U, 3728167192U, 2901437456U, 3863405776U, 1540739182U, 1570766848U, 0U, 0U], + [0U, 1632305U, 1882780341U, 4128706713U, 1023671068U, 2618421812U, 2005415586U, 1062993857U, 3577221846U, 3960476767U, 1695615427U, 2597060712U, 669472826U, 104923136U, 0U, 0U], + [0U, 0U, 10681231U, 1422956801U, 2406345166U, 4058671871U, 2143913881U, 4169135587U, 2414104418U, 2549553452U, 997594232U, 713340517U, 2290070198U, 1103833088U, 0U, 0U], + [0U, 0U, 0U, 69894212U, 1038812943U, 1785020643U, 1285619000U, 2301468615U, 3492037905U, 314610629U, 2761740102U, 3410618104U, 1699516363U, 910779968U, 0U, 0U], + [0U, 0U, 0U, 0U, 457363084U, 927569770U, 3976106370U, 1389513021U, 2107865525U, 3716679421U, 1828091393U, 2088408376U, 439156799U, 2579227194U, 0U, 0U], + [0U, 0U, 0U, 0U, 0U, 2992822783U, 383623235U, 3862831115U, 112778334U, 339767049U, 1447250220U, 486575164U, 3495303162U, 2209946163U, 268435456U, 0U], + [0U, 0U, 0U, 0U, 0U, 4U, 2404108010U, 2962826229U, 3998086794U, 1893006839U, 2266258239U, 1429430446U, 307953032U, 2361423716U, 176160768U, 0U], + [0U, 0U, 0U, 0U, 0U, 0U, 29U, 3596590989U, 3044036677U, 1332209423U, 1014420882U, 868688145U, 4264082837U, 3688771808U, 2485387264U, 0U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 195U, 1054003707U, 3711696540U, 582574436U, 3549229270U, 1088536814U, 2338440092U, 1468637184U, 0U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 1277U, 2650397687U, 3801011509U, 2074386530U, 3248244966U, 687255411U, 2959155456U, 0U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 8360U, 1184754854U, 3047609191U, 3418394749U, 132556120U, 1199103528U, 0U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 54706U, 2996985344U, 1834629191U, 3964963911U, 485140318U, 1073741824U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 357981U, 1476998812U, 3337178590U, 1483338760U, 4194304000U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 2342503U, 3052466824U, 2595180627U, 17825792U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 15328518U, 1933902296U, 4063920128U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 100304420U, 3355157504U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 656356768U], + [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 1U] + ]; +} \ No newline at end of file diff --git a/src/Base58Encoding/Decode.Base58.cs b/src/Base58Encoding/Decode.Base58.cs new file mode 100644 index 0000000..4e9e7bc --- /dev/null +++ b/src/Base58Encoding/Decode.Base58.cs @@ -0,0 +1,293 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Base58Encoding; + +public partial class Base58 +{ + /// + /// Decode Base58 string to byte array + /// + /// Base58 encoded string + /// Decoded byte array + /// Invalid Base58 character + public byte[] Decode(ReadOnlySpan encoded) + { + if (encoded.IsEmpty) + return []; + + // Hot path for Bitcoin alphabet + common expected output sizes + if (ReferenceEquals(this, _bitcoin.Value)) + { + // 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 + { + >= 43 and <= 44 => DecodeBitcoin32Fast(encoded) ?? DecodeGeneric(encoded), // Very likely 32 bytes + >= 87 and <= 88 => DecodeBitcoin64Fast(encoded) ?? DecodeGeneric(encoded), // Very likely 64 bytes + _ => DecodeGeneric(encoded) + }; + } + + // Fallback for other alphabets + return DecodeGeneric(encoded); + } + + /// + /// Decode Base58 string to byte array using generic algorithm + /// + /// Base58 encoded string + /// Decoded byte array + /// Invalid Base58 character + internal byte[] DecodeGeneric(ReadOnlySpan encoded) + { + if (encoded.IsEmpty) + return []; + + int leadingOnes = CountLeadingCharacters(encoded, _firstCharacter); + + int outputSize = encoded.Length * 733 / 1000 + 1; + + Span decoded = outputSize > MaxStackallocByte + ? new byte[outputSize] + : stackalloc byte[outputSize]; + + int decodedLength = 1; + decoded[0] = 0; + + var decodeTable = _decodeTable.Span; + + for (int i = leadingOnes; i < encoded.Length; i++) + { + char c = encoded[i]; + + if (c >= 128 || decodeTable[c] == 255) + ThrowHelper.ThrowInvalidCharacter(c); + + int carry = decodeTable[c]; + + for (int j = 0; j < decodedLength; j++) + { + carry += decoded[j] * Base; + decoded[j] = (byte)(carry & 0xFF); + carry >>= 8; + } + + while (carry > 0) + { + decoded[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; + + var result = new byte[leadingOnes + actualDecodedLength]; + + if (actualDecodedLength > 0) + { + var finalDecoded = decoded.Slice(0, decodedLength); + finalDecoded.Reverse(); + finalDecoded.CopyTo(result.AsSpan(leadingOnes)); + } + + return result; + } + + internal static byte[]? DecodeBitcoin32Fast(ReadOnlySpan encoded) + { + 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; + + // Prepend zeros to make exactly Raw58Sz32 characters + int prepend0 = Base58BitcoinTables.Raw58Sz32 - charCount; + for (int j = 0; j < Base58BitcoinTables.Raw58Sz32; j++) + { + if (j < prepend0) + { + rawBase58[j] = 0; + } + else + { + char c = encoded[j - prepend0]; + // Validate + convert using Bitcoin decode table (return null for invalid chars) + if (c >= 128 || bitcoinDecodeTable[c] == 255) + return null; + + rawBase58[j] = bitcoinDecodeTable[c]; + } + } + + // Convert to intermediate format (base 58^5) + Span intermediate = stackalloc ulong[Base58BitcoinTables.IntermediateSz32]; // 9 elements + + 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 + } + + // Convert to overcomplete base 2^32 using decode table + Span binary = stackalloc ulong[Base58BitcoinTables.BinarySz32]; // 8 elements + + for (int j = 0; j < Base58BitcoinTables.BinarySz32; j++) + { + ulong acc = 0UL; + for (int i = 0; i < Base58BitcoinTables.IntermediateSz32; i++) + { + acc += intermediate[i] * Base58BitcoinTables.DecodeTable32[i][j]; + } + 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 null; + + // Convert to big-endian byte output + var result = new byte[32]; + 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); + } + + // Count leading zeros in output + int outputLeadingZeros = 0; + for (int i = 0; i < 32; i++) + { + if (result[i] != 0) break; + outputLeadingZeros++; + } + + // Count leading '1's in input + int inputLeadingOnes = 0; + for (int i = 0; i < encoded.Length; i++) + { + if (encoded[i] != '1') break; + inputLeadingOnes++; + } + + // Leading zeros in output must match leading '1's in input + if (outputLeadingZeros != inputLeadingOnes) return null; + + // Return the full 32 bytes - the result should always be 32 bytes for 32-byte decode + return result; + } + + internal static byte[]? DecodeBitcoin64Fast(ReadOnlySpan encoded) + { + // Validate string length - should be between 1 and 88 chars for 64-byte output + if (encoded.Length > 88) return null; + + int charCount = encoded.Length; + + // Convert to raw base58 digits with validation + conversion in one pass + Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz64]; // 90 bytes + var 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++) + { + if (j < prepend0) + { + rawBase58[j] = 0; + } + else + { + char c = encoded[j - prepend0]; + // Validate + convert using Bitcoin decode table (return null for invalid chars) + if (c >= 128 || bitcoinDecodeTable[c] == 255) + return null; + + rawBase58[j] = bitcoinDecodeTable[c]; + } + } + + // Convert to intermediate format (base 58^5) + Span intermediate = stackalloc ulong[Base58BitcoinTables.IntermediateSz64]; // 18 elements + + 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 + } + + // Convert to overcomplete base 2^32 using decode table + Span binary = stackalloc ulong[Base58BitcoinTables.BinarySz64]; // 16 elements + + for (int j = 0; j < Base58BitcoinTables.BinarySz64; j++) + { + ulong acc = 0UL; + for (int i = 0; i < Base58BitcoinTables.IntermediateSz64; i++) + { + acc += intermediate[i] * Base58BitcoinTables.DecodeTable64[i][j]; + } + 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 null; + + // 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); + } + + // Count leading zeros in output + int outputLeadingZeros = 0; + for (int i = 0; i < 64; i++) + { + if (result[i] != 0) break; + outputLeadingZeros++; + } + + // Count leading '1's in input + int inputLeadingOnes = 0; + for (int i = 0; i < encoded.Length; i++) + { + if (encoded[i] != '1') break; + inputLeadingOnes++; + } + + // Leading zeros in output must match leading '1's in input + if (outputLeadingZeros != inputLeadingOnes) return null; + + // Return the full 64 bytes - the result should always be 64 bytes for 64-byte decode + return result; + } +} diff --git a/src/Base58Encoding/Encode.Base58.cs b/src/Base58Encoding/Encode.Base58.cs new file mode 100644 index 0000000..b4018c5 --- /dev/null +++ b/src/Base58Encoding/Encode.Base58.cs @@ -0,0 +1,321 @@ +using System.Buffers.Binary; +using System.Diagnostics; + +namespace Base58Encoding; + +public partial class Base58 +{ + /// + /// Encode byte array to Base58 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) + }; + } + + // Fallback for other alphabets + return EncodeGeneric(data); + } + + /// + /// Encode byte array to Base58 string using generic algorithm + /// + /// Bytes to encode + /// Base58 encoded string + internal string EncodeGeneric(ReadOnlySpan data) + { + if (data.IsEmpty) + return string.Empty; + + int leadingZeros = CountLeadingZeros(data); + + if (leadingZeros == data.Length) + { + return new string(_firstCharacter, leadingZeros); + } + + var inputSpan = data[leadingZeros..]; + + var size = (inputSpan.Length * 137 / 100) + 1; + Span digits = size > MaxStackallocByte + ? new byte[size] + : stackalloc byte[size]; + + int digitCount = 1; + digits[0] = 0; + + foreach (byte b in inputSpan) + { + int carry = b; + + for (int i = 0; i < digitCount; i++) + { + carry += digits[i] << 8; + carry = Math.DivRem(carry, Base, out int remainder); + digits[i] = (byte)remainder; + } + + while (carry > 0) + { + carry = Math.DivRem(carry, Base, out int remainder); + digits[digitCount++] = (byte)remainder; + } + } + + int resultSize = leadingZeros + digitCount; + return string.Create(resultSize, new EncodeGenericFinalString(_characters.Span, digits, _firstCharacter, leadingZeros, digitCount), static (span, state) => + { + if (state.LeadingZeroes > 0) + { + span[..state.LeadingZeroes].Fill(state.FirstCharacter); + } + + int index = state.LeadingZeroes; + for (int i = state.DigitCount - 1; i >= 0; i--) + { + span[index++] = state.Alphabet[state.Digits[i]]; + } + }); + } + + internal static string EncodeBitcoin32Fast(ReadOnlySpan data) + { + // Count leading zeros (needed for final output) + int inLeadingZeros = CountLeadingZeros(data); + + if (inLeadingZeros == data.Length) + { + return new string('1', inLeadingZeros); + } + + // Convert 32 bytes to 8 uint32 limbs (big-endian) + Span binary = stackalloc uint[Base58BitcoinTables.BinarySz32]; + for (int i = 0; i < Base58BitcoinTables.BinarySz32; i++) + { + int offset = i * sizeof(uint); + binary[i] = BinaryPrimitives.ReadUInt32BigEndian(data.Slice(offset, sizeof(uint))); + } + + // Convert to intermediate format (base 58^5) + Span intermediate = stackalloc ulong[Base58BitcoinTables.IntermediateSz32]; + intermediate.Clear(); + + // Matrix multiplication: intermediate = binary * EncodeTable32 + for (int i = 0; i < Base58BitcoinTables.BinarySz32; i++) + { + for (int j = 0; j < Base58BitcoinTables.IntermediateSz32 - 1; j++) + { + intermediate[j + 1] += (ulong)binary[i] * Base58BitcoinTables.EncodeTable32[i][j]; + } + } + + // Reduce each term to be less than 58^5 + for (int i = Base58BitcoinTables.IntermediateSz32 - 1; i > 0; i--) + { + intermediate[i - 1] += intermediate[i] / Base58BitcoinTables.R1Div; + intermediate[i] %= Base58BitcoinTables.R1Div; + } + + // Convert intermediate form to raw base58 digits + Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32]; + for (int i = 0; i < Base58BitcoinTables.IntermediateSz32; i++) + { + uint v = (uint)intermediate[i]; + + rawBase58[5 * i + 4] = (byte)((v / 1U) % 58U); + rawBase58[5 * i + 3] = (byte)((v / 58U) % 58U); + rawBase58[5 * i + 2] = (byte)((v / 3364U) % 58U); + rawBase58[5 * i + 1] = (byte)((v / 195112U) % 58U); + rawBase58[5 * i + 0] = (byte)(v / 11316496U); + + // Continue processing all values + } + + // Count leading zeros in raw output + 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; + + // Create state for string.Create + var state = new EncodeFastState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength); + + return string.Create(outputLength, state, static (span, state) => + { + // Fill leading '1's for input leading zeros + if (state.InLeadingZeros > 0) + { + span[..state.InLeadingZeros].Fill('1'); + } + + // 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]; + } + }); + } + + private static string EncodeBitcoin64Fast(ReadOnlySpan data) + { + // Count leading zeros (needed for final output) + int inLeadingZeros = CountLeadingZeros(data); + + if (inLeadingZeros == data.Length) + { + return new string('1', inLeadingZeros); + } + + // Convert 64 bytes to 16 uint32 limbs (big-endian) + Span binary = stackalloc uint[Base58BitcoinTables.BinarySz64]; + for (int i = 0; i < Base58BitcoinTables.BinarySz64; i++) + { + int offset = i * sizeof(uint); + binary[i] = BinaryPrimitives.ReadUInt32BigEndian(data.Slice(offset, sizeof(uint))); + } + + // Convert to intermediate format (base 58^5) + 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++) + { + intermediate[j + 1] += (ulong)binary[i] * Base58BitcoinTables.EncodeTable64[i][j]; + } + } + + // Mini-reduction to prevent overflow (like Firedancer) + intermediate[15] += intermediate[16] / Base58BitcoinTables.R1Div; + intermediate[16] %= Base58BitcoinTables.R1Div; + + // Finish remaining iterations + for (int i = 8; i < Base58BitcoinTables.BinarySz64; i++) + { + for (int j = 0; j < Base58BitcoinTables.IntermediateSz64 - 1; j++) + { + intermediate[j + 1] += (ulong)binary[i] * Base58BitcoinTables.EncodeTable64[i][j]; + } + } + + // Reduce each term to be less than 58^5 + for (int i = Base58BitcoinTables.IntermediateSz64 - 1; i > 0; i--) + { + intermediate[i - 1] += intermediate[i] / Base58BitcoinTables.R1Div; + intermediate[i] %= Base58BitcoinTables.R1Div; + } + + // Convert intermediate form to raw base58 digits + Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz64]; + for (int i = 0; i < Base58BitcoinTables.IntermediateSz64; i++) + { + uint v = (uint)intermediate[i]; + rawBase58[5 * i + 4] = (byte)((v / 1U) % 58U); + rawBase58[5 * i + 3] = (byte)((v / 58U) % 58U); + rawBase58[5 * i + 2] = (byte)((v / 3364U) % 58U); + rawBase58[5 * i + 1] = (byte)((v / 195112U) % 58U); + rawBase58[5 * i + 0] = (byte)(v / 11316496U); + + // Defensive check - ensure all values are valid Base58 digits + if (rawBase58[5 * i + 0] >= 58 || rawBase58[5 * i + 1] >= 58 || + rawBase58[5 * i + 2] >= 58 || rawBase58[5 * i + 3] >= 58 || rawBase58[5 * i + 4] >= 58) + { + throw new InvalidOperationException($"Invalid base58 digit generated at position {i}: {rawBase58[5 * i + 0]}, {rawBase58[5 * i + 1]}, {rawBase58[5 * i + 2]}, {rawBase58[5 * i + 3]}, {rawBase58[5 * i + 4]}"); + } + } + + // Count leading zeros in raw output + int rawLeadingZeros = 0; + for (; rawLeadingZeros < Base58BitcoinTables.Raw58Sz64; rawLeadingZeros++) + { + if (rawBase58[rawLeadingZeros] != 0) break; + } + + // Calculate skip and final length + int skip = rawLeadingZeros - inLeadingZeros; + Debug.Assert(skip >= 0, "rawLeadingZeros should always be >= inLeadingZeros by Base58 math"); + int outputLength = Base58BitcoinTables.Raw58Sz64 - skip; + + // Create state for string.Create + var state = new EncodeFastState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength); + + return string.Create(outputLength, state, static (span, state) => + { + // Fill leading '1's for input leading zeros + if (state.InLeadingZeros > 0) + { + span[..state.InLeadingZeros].Fill('1'); + } + + // 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]; + } + }); + } + + private readonly ref struct EncodeFastState + { + public readonly ReadOnlySpan RawBase58; + public readonly int InLeadingZeros; + public readonly int RawLeadingZeros; + public readonly int OutputLength; + + public EncodeFastState(ReadOnlySpan rawBase58, int inLeadingZeros, int rawLeadingZeros, int outputLength) + { + RawBase58 = rawBase58; + InLeadingZeros = inLeadingZeros; + RawLeadingZeros = rawLeadingZeros; + OutputLength = outputLength; + } + } + + private readonly ref struct EncodeGenericFinalString + { + public readonly ReadOnlySpan Alphabet; + public readonly ReadOnlySpan Digits; + public readonly char FirstCharacter; + public readonly int LeadingZeroes; + public readonly int DigitCount; + + public EncodeGenericFinalString(ReadOnlySpan alphabet, ReadOnlySpan digits, char firstCharacter, int leadingZeroes, int digitCount) + { + Alphabet = alphabet; + Digits = digits; + FirstCharacter = firstCharacter; + LeadingZeroes = leadingZeroes; + DigitCount = digitCount; + } + } +} From 196885995291dd0d774c9e9e1bb0b38a4e595806 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 7 Jan 2026 22:34:41 +0200 Subject: [PATCH 02/13] feat: add defensive check --- src/Base58Encoding/Encode.Base58.cs | 11 +++++------ src/Base58Encoding/ThrowHelper.cs | 7 +------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/Base58Encoding/Encode.Base58.cs b/src/Base58Encoding/Encode.Base58.cs index b4018c5..b5500da 100644 --- a/src/Base58Encoding/Encode.Base58.cs +++ b/src/Base58Encoding/Encode.Base58.cs @@ -242,12 +242,11 @@ private static string EncodeBitcoin64Fast(ReadOnlySpan data) rawBase58[5 * i + 1] = (byte)((v / 195112U) % 58U); rawBase58[5 * i + 0] = (byte)(v / 11316496U); - // Defensive check - ensure all values are valid Base58 digits - if (rawBase58[5 * i + 0] >= 58 || rawBase58[5 * i + 1] >= 58 || - rawBase58[5 * i + 2] >= 58 || rawBase58[5 * i + 3] >= 58 || rawBase58[5 * i + 4] >= 58) - { - throw new InvalidOperationException($"Invalid base58 digit generated at position {i}: {rawBase58[5 * i + 0]}, {rawBase58[5 * i + 1]}, {rawBase58[5 * i + 2]}, {rawBase58[5 * i + 3]}, {rawBase58[5 * i + 4]}"); - } + // Debug.Assert - ensure all values are valid Base58 digits (algorithm correctness check) + Debug.Assert(rawBase58[5 * i + 0] < 58 && rawBase58[5 * i + 1] < 58 && + rawBase58[5 * i + 2] < 58 && rawBase58[5 * i + 3] < 58 && + rawBase58[5 * i + 4] < 58, + $"Invalid base58 digit generated at position {i} - algorithm bug"); } // Count leading zeros in raw output diff --git a/src/Base58Encoding/ThrowHelper.cs b/src/Base58Encoding/ThrowHelper.cs index 97cf10b..04273fa 100644 --- a/src/Base58Encoding/ThrowHelper.cs +++ b/src/Base58Encoding/ThrowHelper.cs @@ -1,14 +1,9 @@ using System.Diagnostics.CodeAnalysis; namespace Base58Encoding; + internal static class ThrowHelper { - [DoesNotReturn] - public static void ThrowNotExactLength() - { - throw new InvalidOperationException("Alphabet must be exactly 58 characters long."); - } - [DoesNotReturn] public static void ThrowInvalidCharacter(char character) { From e6c021b7170a62017a3b81f5140f2963aab3af0c Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 7 Jan 2026 22:39:18 +0200 Subject: [PATCH 03/13] feat: upgrade to xunit.v3 --- src/Base58Encoding.Tests/Base58Encoding.Tests.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj index 68ae08a..5f97942 100644 --- a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj +++ b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -9,10 +9,9 @@ - + - - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -20,6 +19,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + From 6d795e20ab54215d0f3ace6a1f168f11d7494153 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 7 Jan 2026 23:35:58 +0200 Subject: [PATCH 04/13] tests: separate tests to different files --- src/Base58Encoding.Tests/Base58DecodeFast.cs | 263 ++++++++ src/Base58Encoding.Tests/Base58EncodeFast.cs | 248 ++++++++ .../Base58Encoding.Tests.csproj | 13 +- src/Base58Encoding.Tests/Base58Tests.cs | 563 ------------------ src/Base58Encoding.Tests/GlobalUsings.cs | 1 - src/Base58Encoding.Tests/xunit.runner.json | 4 + src/Base58Encoding/CountLeading.Base58.cs | 3 - 7 files changed, 527 insertions(+), 568 deletions(-) create mode 100644 src/Base58Encoding.Tests/Base58DecodeFast.cs create mode 100644 src/Base58Encoding.Tests/Base58EncodeFast.cs delete mode 100644 src/Base58Encoding.Tests/GlobalUsings.cs create mode 100644 src/Base58Encoding.Tests/xunit.runner.json diff --git a/src/Base58Encoding.Tests/Base58DecodeFast.cs b/src/Base58Encoding.Tests/Base58DecodeFast.cs new file mode 100644 index 0000000..6de5ce3 --- /dev/null +++ b/src/Base58Encoding.Tests/Base58DecodeFast.cs @@ -0,0 +1,263 @@ +namespace Base58Encoding.Tests; + +public class Base58DecodeFast +{ + [Fact] + public void Decode32Fast_WithValidInput_MatchesGeneric() + { + for (int i = 0; i < 100; i++) + { + // Arrange + var testData = new byte[32]; + Random.Shared.NextBytes(testData); + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var fastDecoded = Base58.Bitcoin.Decode(encoded); + var genericDecoded = Base58.Bitcoin.DecodeGeneric(encoded); + + // Assert + Assert.Equal(genericDecoded, fastDecoded); + Assert.Equal(testData, fastDecoded); + } + } + + [Fact] + public void Decode64Fast_WithValidInput_MatchesGeneric() + { + var random = new Random(42); + + for (int i = 0; i < 100; i++) + { + // Arrange + var testData = new byte[64]; + random.NextBytes(testData); + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var fastDecoded = Base58.Bitcoin.Decode(encoded); + var genericDecoded = Base58.Bitcoin.DecodeGeneric(encoded); + + // Assert + Assert.Equal(genericDecoded, fastDecoded); + Assert.Equal(testData, fastDecoded); + } + } + + [Fact] + public void Decode32Fast_WithAllZeros_ReturnsNull() + { + // Arrange + var allZeros = new byte[32]; + var encoded = SimpleBase.Base58.Bitcoin.Encode(allZeros); + + // Act + var decoded = Base58.DecodeBitcoin64Fast(encoded); + + // Assert + Assert.Null(decoded); + } + + [Fact] + public void Decode64Fast_WithAllZeros_WorksCorrectly() + { + // Arrange + var allZeros = new byte[64]; + var encoded = SimpleBase.Base58.Bitcoin.Encode(allZeros); + + // Act + var decoded = Base58.DecodeBitcoin64Fast(encoded); + + // Assert + Assert.Equal(allZeros, decoded); + Assert.Equal(new string('1', 64), encoded); + } + + [Theory] + [InlineData("invalid0chars")] // Invalid character '0' + public void Decode32Fast_WithInvalidInput_ReturnsNull(string input) + { + Assert.Null(Base58.DecodeBitcoin64Fast(input)); + } + + [Fact] + public void Decode32Fast_WithLeadingOnes_HandlesCorrectly() + { + // Arrange - 28 leading zeros + some data + var testData = new byte[32]; + testData[28] = 0xAB; + testData[29] = 0xCD; + testData[30] = 0xEF; + testData[31] = 0x12; + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var decoded = Base58.Bitcoin.Decode(encoded); + + // Assert + Assert.Equal(testData, decoded); + Assert.StartsWith(new string('1', 28), encoded); + } + + [Fact] + public void Decode64Fast_WithLeadingOnes_HandlesCorrectly() + { + // Arrange - 32 leading zeros + data + var testData = new byte[64]; + for (int i = 32; i < 64; i++) + { + testData[i] = (byte)(i - 32); + } + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var decoded = Base58.DecodeBitcoin64Fast(encoded); + + // Assert + Assert.Equal(testData, decoded); + Assert.StartsWith(new string('1', 32), encoded); + } + + [Fact] + public void Decode32Fast_WithKnownTestVectors_WorksCorrectly() + { + // Arrange - Create known 32-byte inputs and their expected Base58 outputs + var testCases = new[] + { + new byte[32], // All zeros + Enumerable.Repeat((byte)0xFF, 32).ToArray(), // All 255s + }; + + foreach (var testCase in testCases) + { + // Act + var encoded = SimpleBase.Base58.Bitcoin.Encode(testCase); + var decoded = Base58.DecodeBitcoin32Fast(encoded); + + var genericDecoded = Base58.Bitcoin.DecodeGeneric(encoded); + + // Assert + Assert.Equal(testCase, decoded); + + // Verify round-trip with generic + Assert.Equal(genericDecoded, decoded); + } + } + + [Fact] + public void Decode64Fast_WithKnownTestVectors_WorksCorrectly() + { + // Arrange - Create known 64-byte inputs + var testCases = new[] + { + new byte[64], // All zeros + Enumerable.Repeat((byte)0xFF, 64).ToArray(), // All 255s + }; + + foreach (var testCase in testCases) + { + // Act + var encoded = Base58.Bitcoin.Encode(testCase); + var decoded = Base58.DecodeBitcoin64Fast(encoded); + + // Assert + Assert.Equal(testCase, decoded); + + // Verify round-trip with generic + var genericDecoded = Base58.Bitcoin.DecodeGeneric(encoded); + Assert.Equal(genericDecoded, decoded); + } + } + + [Theory] + [InlineData(0)] // No leading zeros + [InlineData(1)] // 1 leading zero + [InlineData(5)] // 5 leading zeros + [InlineData(16)] // Half the array + [InlineData(31)] // Almost all zeros + public void Decode32Fast_WithVariousLeadingZeros_HandlesCorrectly(int leadingZeros) + { + // Arrange + var testData = new byte[32]; + Random.Shared.NextBytes(testData.AsSpan(leadingZeros)); + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var decoded = Base58.Bitcoin.Decode(encoded); + + // Assert + Assert.Equal(testData, decoded); + + // Verify leading zeros preservation + if (leadingZeros > 0) + { + Assert.StartsWith(new string('1', leadingZeros), encoded); + } + } + + [Theory] + [InlineData(0)] // No leading zeros + [InlineData(1)] // 1 leading zero + [InlineData(8)] // 8 leading zeros + [InlineData(32)] // Half the array + [InlineData(63)] // Almost all zeros + public void Decode64Fast_WithVariousLeadingZeros_HandlesCorrectly(int leadingZeros) + { + // Arrange + var testData = new byte[64]; + Random.Shared.NextBytes(testData.AsSpan(leadingZeros)); + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var decoded = Base58.DecodeBitcoin64Fast(encoded); + + // Assert + Assert.Equal(testData, decoded); + + // Verify leading zeros preservation + if (leadingZeros > 0) + { + Assert.StartsWith(new string('1', leadingZeros), encoded); + } + } + + [Fact] + public void Decode32Fast_WithMaximumValues_WorksCorrectly() + { + // Arrange - Create data that will generate maximum Base58 length + var testData = new byte[32]; + Array.Fill(testData, (byte)0xFF); + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var decoded = Base58.Bitcoin.Decode(encoded); + + // Assert + Assert.Equal(testData, decoded); + Assert.InRange(encoded.Length, 43, 44); // Maximum Base58 length for 32 bytes + } + + [Fact] + public void Decode64Fast_WithMaximumValues_WorksCorrectly() + { + // Arrange - Create data that will generate maximum Base58 length + var testData = new byte[64]; + Array.Fill(testData, (byte)0xFF); + + var encoded = Base58.Bitcoin.Encode(testData); + + // Act + var decoded = Base58.DecodeBitcoin64Fast(encoded); + + // Assert + Assert.Equal(testData, decoded); + Assert.InRange(encoded.Length, 87, 88); // Maximum Base58 length for 64 bytes + } +} diff --git a/src/Base58Encoding.Tests/Base58EncodeFast.cs b/src/Base58Encoding.Tests/Base58EncodeFast.cs new file mode 100644 index 0000000..6d2d5ee --- /dev/null +++ b/src/Base58Encoding.Tests/Base58EncodeFast.cs @@ -0,0 +1,248 @@ +namespace Base58Encoding.Tests; + +public class Base58EncodeFast +{ + + [Fact] + public void BitcoinAlphabet_EncodeGeneric_Generates_SameResultAsFast32() + { + Span buffer = stackalloc byte[32]; + Random.Shared.NextBytes(buffer); + + var genericResult = Base58.Bitcoin.EncodeGeneric(buffer); + var fastResult = Base58.EncodeBitcoin32Fast(buffer); + + Assert.Equal(genericResult, fastResult); + } + + [Fact] + public void Encode32Fast_TableDimensions_AreCorrect() + { + // Verify table dimensions + Assert.Equal(8, Base58BitcoinTables.BinarySz32); + Assert.Equal(9, Base58BitcoinTables.IntermediateSz32); + Assert.Equal(45, Base58BitcoinTables.Raw58Sz32); + + // Verify table bounds + var encodeTable = Base58BitcoinTables.EncodeTable32; + Assert.Equal(8, encodeTable.Length); // Should be BinarySz32 + Assert.Equal(8, encodeTable[0].Length); // Should be IntermediateSz32 - 1 + } + + [Fact] + public void Encode32Fast_WithAllZeros_ReturnsCorrectOnes() + { + // Arrange + var allZeros = new byte[32]; + + // Act + var result = Base58.Bitcoin.Encode(allZeros); + + // Assert - Should be 32 '1's for 32 zero bytes + Assert.Equal(new string('1', 32), result); + } + + [Fact] + public void Encode32Fast_WithAllOnes_ProducesCorrectResult() + { + // Arrange + var allOnes = Enumerable.Repeat((byte)0xFF, 32).ToArray(); + + // Act + var fastResult = Base58.Bitcoin.Encode(allOnes); + var genericResult = Base58.Bitcoin.EncodeGeneric(allOnes); + + // Assert + Assert.Equal(genericResult, fastResult); + Assert.NotEmpty(fastResult); + } + + [Fact] + public void Encode32Fast_WithLeadingZeros_HandlesCorrectly() + { + // Test one simple case first to isolate the issue + var testCase = new byte[] { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFE, 0xFD, 0xFC }; // 28 leading zeros + + // Act + var genericResult = Base58.Bitcoin.EncodeGeneric(testCase); + var simpleBase = SimpleBase.Base58.Bitcoin.Encode(testCase); + var fastResult = Base58.Bitcoin.Encode(testCase); + + // Assert + Assert.Equal(genericResult, fastResult); + + // Verify leading zeros are preserved as '1's + int leadingZeros = testCase.TakeWhile(b => b == 0).Count(); + Assert.StartsWith(new string('1', leadingZeros), fastResult); + } + + [Theory] + [InlineData(1000)] + public void Encode32Fast_WithRandomData_MatchesGeneric(int testCount) + { + var random = new Random(42); // Fixed seed for reproducibility + + for (int i = 0; i < testCount; i++) + { + // Arrange + var testData = new byte[32]; + random.NextBytes(testData); + + // Act + var fastResult = Base58.Bitcoin.Encode(testData); + var genericResult = Base58.Bitcoin.EncodeGeneric(testData); + + // Assert + Assert.Equal(genericResult, fastResult); + + // Verify it round-trips correctly + var decoded = Base58.Bitcoin.Decode(fastResult); + Assert.Equal(testData, decoded); + } + } + + [Fact] + public void Encode32Fast_WithRealBitcoinAddressData_WorksCorrectly() + { + // Arrange - Real Bitcoin address hash160 (20 bytes padded to 32) + var hash160 = new byte[] + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 12 zero padding + 0x76, 0xa9, 0x14, 0x89, 0xab, 0xcd, 0xef, 0xab, 0xba, 0xab, 0xba, 0xab, + 0xba, 0xab, 0xba, 0xab, 0xba, 0xab, 0xba, 0xab, 0xba, 0x88, 0xac, 0x00 + }; + + // Act + var fastResult = Base58.Bitcoin.Encode(hash160); + var genericResult = Base58.Bitcoin.EncodeGeneric(hash160); + + // Assert + Assert.Equal(genericResult, fastResult); + + // Should start with 12 '1's due to leading zeros + Assert.StartsWith(new string('1', 12), fastResult); + } + + [Fact] + public void Encode_WithNon32ByteInputs_UsesGenericPath() + { + var testCases = new[] + { + new byte[31], // 31 bytes + new byte[33], // 33 bytes + new byte[20], // 20 bytes (Bitcoin hash160) + new byte[64], // 64 bytes (will use generic until we implement 64-byte fast path) + }; + + foreach (var testCase in testCases) + { + Random.Shared.NextBytes(testCase); + + // Act + var result = Base58.Bitcoin.Encode(testCase); + + // Assert - Should work correctly via generic path + var decoded = Base58.Bitcoin.Decode(result); + Assert.Equal(testCase, decoded); + } + } + + [Fact] + public void Encode64Fast_TableDimensions_AreCorrect() + { + // Verify table dimensions + Assert.Equal(16, Base58BitcoinTables.BinarySz64); + Assert.Equal(18, Base58BitcoinTables.IntermediateSz64); + Assert.Equal(90, Base58BitcoinTables.Raw58Sz64); + + // Verify table bounds + var encodeTable = Base58BitcoinTables.EncodeTable64; + Assert.Equal(16, encodeTable.Length); // Should be BinarySz64 + Assert.Equal(17, encodeTable[0].Length); // Should be IntermediateSz64 - 1 + } + + [Fact] + public void Encode64Fast_WithAllZeros_ReturnsCorrectOnes() + { + // Arrange + var allZeros = new byte[64]; + + // Act + var result = Base58.Bitcoin.Encode(allZeros); + + // Assert - Should be 64 '1's for 64 zero bytes + Assert.Equal(new string('1', 64), result); + } + + [Fact] + public void Encode64Fast_WithRandomData_MatchesGeneric() + { + var random = new Random(42); // Fixed seed for reproducibility + + for (int i = 0; i < 100; i++) + { + // Arrange + var testData = new byte[64]; + random.NextBytes(testData); + + // Act + var fastResult = Base58.Bitcoin.Encode(testData); + var genericResult = Base58.Bitcoin.EncodeGeneric(testData); + + // Assert + Assert.Equal(genericResult, fastResult); + + // Verify it round-trips correctly + var decoded = Base58.Bitcoin.Decode(fastResult); + Assert.Equal(testData, decoded); + } + } + + [Fact] + public void Encode64Fast_WithLeadingZeros_HandlesCorrectly() + { + // Arrange - 64-byte data with leading zeros + var testCases = new[] + { + new byte[64], // All zeros + Enumerable.Range(0, 64).Select(i => i < 32 ? (byte)0x00 : (byte)0xFF).ToArray(), // 32 leading zeros + }; + + foreach (var testCase in testCases) + { + // Act + var fastResult = Base58.Bitcoin.Encode(testCase); + var genericResult = Base58.Bitcoin.EncodeGeneric(testCase); + + // Assert + Assert.Equal(genericResult, fastResult); + + // Verify leading zeros are preserved as '1's + int leadingZeros = testCase.TakeWhile(b => b == 0).Count(); + Assert.StartsWith(new string('1', leadingZeros), fastResult); + } + } + + [Fact] + public void Encode64Fast_WithSolanaTransactionSignature_WorksCorrectly() + { + // Arrange - Simulate a 64-byte Solana transaction signature + var signatureData = new byte[64]; + var random = new Random(123); + random.NextBytes(signatureData); + + // Act + var fastResult = Base58.Bitcoin.Encode(signatureData); + var genericResult = Base58.Bitcoin.EncodeGeneric(signatureData); + + // Assert + Assert.Equal(genericResult, fastResult); + + // Verify round-trip + var decoded = Base58.Bitcoin.Decode(fastResult); + Assert.Equal(signatureData, decoded); + + // Should be between 64 and 88 characters + Assert.InRange(fastResult.Length, 64, 88); + } +} diff --git a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj index 5f97942..6fc5901 100644 --- a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj +++ b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj @@ -2,10 +2,13 @@ net9.0 + Exe enable enable false - true + Base58Encoding.Tests + true + true @@ -22,6 +25,14 @@ + + + + + + + + diff --git a/src/Base58Encoding.Tests/Base58Tests.cs b/src/Base58Encoding.Tests/Base58Tests.cs index 3cc4564..b935b03 100644 --- a/src/Base58Encoding.Tests/Base58Tests.cs +++ b/src/Base58Encoding.Tests/Base58Tests.cs @@ -270,567 +270,4 @@ public void Encode_WithLeadingZeroPatterns_PreservesCorrectly() } } - [Fact] - public void BitcoinAlphabet_EncodeGeneric_Generates_SameResultAsFast32() - { - Span buffer = stackalloc byte[32]; - Random.Shared.NextBytes(buffer); - - var genericResult = Base58.Bitcoin.EncodeGeneric(buffer); - var fastResult = Base58.EncodeBitcoin32Fast(buffer); - - Assert.Equal(genericResult, fastResult); - } - - [Fact] - public void Encode32Fast_TableDimensions_AreCorrect() - { - // Verify table dimensions - Assert.Equal(8, Base58BitcoinTables.BinarySz32); - Assert.Equal(9, Base58BitcoinTables.IntermediateSz32); - Assert.Equal(45, Base58BitcoinTables.Raw58Sz32); - - // Verify table bounds - var encodeTable = Base58BitcoinTables.EncodeTable32; - Assert.Equal(8, encodeTable.Length); // Should be BinarySz32 - Assert.Equal(8, encodeTable[0].Length); // Should be IntermediateSz32 - 1 - } - - [Fact] - public void Encode32Fast_WithAllZeros_ReturnsCorrectOnes() - { - // Arrange - var allZeros = new byte[32]; - - // Act - var result = Base58.Bitcoin.Encode(allZeros); - - // Assert - Should be 32 '1's for 32 zero bytes - Assert.Equal(new string('1', 32), result); - } - - [Fact] - public void Encode32Fast_WithAllOnes_ProducesCorrectResult() - { - // Arrange - var allOnes = Enumerable.Repeat((byte)0xFF, 32).ToArray(); - - // Act - var fastResult = Base58.Bitcoin.Encode(allOnes); - var genericResult = Base58.Bitcoin.EncodeGeneric(allOnes); - - // Assert - Assert.Equal(genericResult, fastResult); - Assert.NotEmpty(fastResult); - } - - [Fact] - public void Encode32Fast_WithLeadingZeros_HandlesCorrectly() - { - // Test one simple case first to isolate the issue - var testCase = new byte[] { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFE, 0xFD, 0xFC }; // 28 leading zeros - - // Act - var genericResult = Base58.Bitcoin.EncodeGeneric(testCase); - var simpleBase = SimpleBase.Base58.Bitcoin.Encode(testCase); - var fastResult = Base58.Bitcoin.Encode(testCase); - - // Assert - Assert.Equal(genericResult, fastResult); - - // Verify leading zeros are preserved as '1's - int leadingZeros = testCase.TakeWhile(b => b == 0).Count(); - Assert.StartsWith(new string('1', leadingZeros), fastResult); - } - - [Theory] - [InlineData(1000)] - public void Encode32Fast_WithRandomData_MatchesGeneric(int testCount) - { - var random = new Random(42); // Fixed seed for reproducibility - - for (int i = 0; i < testCount; i++) - { - // Arrange - var testData = new byte[32]; - random.NextBytes(testData); - - // Act - var fastResult = Base58.Bitcoin.Encode(testData); - var genericResult = Base58.Bitcoin.EncodeGeneric(testData); - - // Assert - Assert.Equal(genericResult, fastResult); - - // Verify it round-trips correctly - var decoded = Base58.Bitcoin.Decode(fastResult); - Assert.Equal(testData, decoded); - } - } - - [Fact] - public void FastPath_OnlyTriggersForBitcoinAlphabet() - { - // Arrange - var testData = new byte[32]; - Random.Shared.NextBytes(testData); - - // Act - var bitcoinResult = Base58.Bitcoin.Encode(testData); - var rippleResult = Base58.Ripple.Encode(testData); - - // Assert - Both should work but produce different results - Assert.NotEqual(bitcoinResult, rippleResult); - - // Verify round-trips - var bitcoinDecoded = Base58.Bitcoin.Decode(bitcoinResult); - var rippleDecoded = Base58.Ripple.Decode(rippleResult); - - Assert.Equal(testData, bitcoinDecoded); - Assert.Equal(testData, rippleDecoded); - } - - [Fact] - public void Encode32Fast_WithRealBitcoinAddressData_WorksCorrectly() - { - // Arrange - Real Bitcoin address hash160 (20 bytes padded to 32) - var hash160 = new byte[] - { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 12 zero padding - 0x76, 0xa9, 0x14, 0x89, 0xab, 0xcd, 0xef, 0xab, 0xba, 0xab, 0xba, 0xab, - 0xba, 0xab, 0xba, 0xab, 0xba, 0xab, 0xba, 0xab, 0xba, 0x88, 0xac, 0x00 - }; - - // Act - var fastResult = Base58.Bitcoin.Encode(hash160); - var genericResult = Base58.Bitcoin.EncodeGeneric(hash160); - - // Assert - Assert.Equal(genericResult, fastResult); - - // Should start with 12 '1's due to leading zeros - Assert.StartsWith(new string('1', 12), fastResult); - } - - [Fact] - public void Encode_WithNon32ByteInputs_UsesGenericPath() - { - var testCases = new[] - { - new byte[31], // 31 bytes - new byte[33], // 33 bytes - new byte[20], // 20 bytes (Bitcoin hash160) - new byte[64], // 64 bytes (will use generic until we implement 64-byte fast path) - }; - - foreach (var testCase in testCases) - { - Random.Shared.NextBytes(testCase); - - // Act - var result = Base58.Bitcoin.Encode(testCase); - - // Assert - Should work correctly via generic path - var decoded = Base58.Bitcoin.Decode(result); - Assert.Equal(testCase, decoded); - } - } - - [Fact] - public void Encode64Fast_TableDimensions_AreCorrect() - { - // Verify table dimensions - Assert.Equal(16, Base58BitcoinTables.BinarySz64); - Assert.Equal(18, Base58BitcoinTables.IntermediateSz64); - Assert.Equal(90, Base58BitcoinTables.Raw58Sz64); - - // Verify table bounds - var encodeTable = Base58BitcoinTables.EncodeTable64; - Assert.Equal(16, encodeTable.Length); // Should be BinarySz64 - Assert.Equal(17, encodeTable[0].Length); // Should be IntermediateSz64 - 1 - } - - [Fact] - public void Encode64Fast_WithAllZeros_ReturnsCorrectOnes() - { - // Arrange - var allZeros = new byte[64]; - - // Act - var result = Base58.Bitcoin.Encode(allZeros); - - // Assert - Should be 64 '1's for 64 zero bytes - Assert.Equal(new string('1', 64), result); - } - - [Fact] - public void Encode64Fast_WithRandomData_MatchesGeneric() - { - var random = new Random(42); // Fixed seed for reproducibility - - for (int i = 0; i < 100; i++) - { - // Arrange - var testData = new byte[64]; - random.NextBytes(testData); - - // Act - var fastResult = Base58.Bitcoin.Encode(testData); - var genericResult = Base58.Bitcoin.EncodeGeneric(testData); - - // Assert - Assert.Equal(genericResult, fastResult); - - // Verify it round-trips correctly - var decoded = Base58.Bitcoin.Decode(fastResult); - Assert.Equal(testData, decoded); - } - } - - [Fact] - public void Encode64Fast_WithLeadingZeros_HandlesCorrectly() - { - // Arrange - 64-byte data with leading zeros - var testCases = new[] - { - new byte[64], // All zeros - Enumerable.Range(0, 64).Select(i => i < 32 ? (byte)0x00 : (byte)0xFF).ToArray(), // 32 leading zeros - }; - - foreach (var testCase in testCases) - { - // Act - var fastResult = Base58.Bitcoin.Encode(testCase); - var genericResult = Base58.Bitcoin.EncodeGeneric(testCase); - - // Assert - Assert.Equal(genericResult, fastResult); - - // Verify leading zeros are preserved as '1's - int leadingZeros = testCase.TakeWhile(b => b == 0).Count(); - Assert.StartsWith(new string('1', leadingZeros), fastResult); - } - } - - [Fact] - public void Encode64Fast_WithSolanaTransactionSignature_WorksCorrectly() - { - // Arrange - Simulate a 64-byte Solana transaction signature - var signatureData = new byte[64]; - var random = new Random(123); - random.NextBytes(signatureData); - - // Act - var fastResult = Base58.Bitcoin.Encode(signatureData); - var genericResult = Base58.Bitcoin.EncodeGeneric(signatureData); - - // Assert - Assert.Equal(genericResult, fastResult); - - // Verify round-trip - var decoded = Base58.Bitcoin.Decode(fastResult); - Assert.Equal(signatureData, decoded); - - // Should be between 64 and 88 characters - Assert.InRange(fastResult.Length, 64, 88); - } - - [Fact] - public void Decode32Fast_WithValidInput_MatchesGeneric() - { - var random = new Random(42); - - for (int i = 0; i < 100; i++) - { - // Arrange - var testData = new byte[32]; - random.NextBytes(testData); - - var encoded = Base58.Bitcoin.Encode(testData); - - // Act - var fastDecoded = Base58.Bitcoin.Decode(encoded); - var genericDecoded = Base58.Bitcoin.DecodeGeneric(encoded); - - // Assert - Assert.Equal(genericDecoded, fastDecoded); - Assert.Equal(testData, fastDecoded); - } - } - - [Fact] - public void Decode64Fast_WithValidInput_MatchesGeneric() - { - var random = new Random(42); - - for (int i = 0; i < 100; i++) - { - // Arrange - var testData = new byte[64]; - random.NextBytes(testData); - - var encoded = Base58.Bitcoin.Encode(testData); - - // Act - var fastDecoded = Base58.Bitcoin.Decode(encoded); - var genericDecoded = Base58.Bitcoin.DecodeGeneric(encoded); - - // Assert - Assert.Equal(genericDecoded, fastDecoded); - Assert.Equal(testData, fastDecoded); - } - } - - [Fact] - public void Decode32Fast_WithAllZeros_WorksCorrectly() - { - // Arrange - var allZeros = new byte[32]; - var encoded = SimpleBase.Base58.Bitcoin.Encode(allZeros); - - // Act - var decoded = Base58.Bitcoin.Decode(encoded); - var genericDecoded = Base58.Bitcoin.DecodeGeneric(encoded); - - // Assert - Debug info - Console.WriteLine($"Encoded: {encoded}"); - Console.WriteLine($"Fast decoded length: {decoded.Length}"); - Console.WriteLine($"Generic decoded length: {genericDecoded.Length}"); - - // Debug output first - Console.WriteLine($"Encoded: '{encoded}' (length: {encoded.Length})"); - Console.WriteLine($"Generic decoded: length {genericDecoded.Length}"); - Console.WriteLine($"Fast decoded: length {decoded.Length}"); - - Assert.Equal(genericDecoded, decoded); - } - - [Fact] - public void Decode64Fast_WithAllZeros_WorksCorrectly() - { - // Arrange - var allZeros = new byte[64]; - var encoded = SimpleBase.Base58.Bitcoin.Encode(allZeros); - - // Act - var decoded = Base58.Bitcoin.Decode(encoded); - - // Assert - Assert.Equal(allZeros, decoded); - Assert.Equal(new string('1', 64), encoded); - } - - [Theory] - [InlineData("invalid0chars")] // Invalid character '0' - public void Decode32Fast_WithInvalidInput_FallsBackToGeneric(string input) - { - // Act & Assert - Should throw exception via generic path - Assert.Throws(() => Base58.Bitcoin.Decode(input)); - } - - [Fact] - public void Decode32Fast_WithLeadingOnes_HandlesCorrectly() - { - // Arrange - 28 leading zeros + some data - var testData = new byte[32]; - testData[28] = 0xAB; - testData[29] = 0xCD; - testData[30] = 0xEF; - testData[31] = 0x12; - - var encoded = Base58.Bitcoin.Encode(testData); - - // Act - var decoded = Base58.Bitcoin.Decode(encoded); - - // Assert - Assert.Equal(testData, decoded); - Assert.StartsWith(new string('1', 28), encoded); - } - - [Fact] - public void Decode64Fast_WithLeadingOnes_HandlesCorrectly() - { - // Arrange - 32 leading zeros + data - var testData = new byte[64]; - for (int i = 32; i < 64; i++) - { - testData[i] = (byte)(i - 32); - } - - var encoded = Base58.Bitcoin.Encode(testData); - - // Act - var decoded = Base58.Bitcoin.Decode(encoded); - - // Assert - Assert.Equal(testData, decoded); - Assert.StartsWith(new string('1', 32), encoded); - } - - [Fact] - public void Decode32Fast_WithKnownTestVectors_WorksCorrectly() - { - // Arrange - Create known 32-byte inputs and their expected Base58 outputs - var testCases = new[] - { - new byte[32], // All zeros - Enumerable.Repeat((byte)0xFF, 32).ToArray(), // All 255s - }; - - foreach (var testCase in testCases) - { - // Act - var encoded = SimpleBase.Base58.Bitcoin.Encode(testCase); - var decodedsimpleBase = SimpleBase.Base58.Bitcoin.Decode(encoded); - var decoded = Base58.DecodeBitcoin32Fast(encoded); - - var genericDecoded = Base58.Bitcoin.DecodeGeneric(encoded); - Console.WriteLine($"Generic decoded: length {genericDecoded.Length}"); - - // Assert - Assert.Equal(testCase, decoded); - - // Verify round-trip with generic - Assert.Equal(genericDecoded, decoded); - } - } - - [Fact] - public void Decode64Fast_WithKnownTestVectors_WorksCorrectly() - { - // Arrange - Create known 64-byte inputs - var testCases = new[] - { - new byte[64], // All zeros - Enumerable.Repeat((byte)0xFF, 64).ToArray(), // All 255s - }; - - foreach (var testCase in testCases) - { - // Act - var encoded = Base58.Bitcoin.Encode(testCase); - var decoded = Base58.Bitcoin.Decode(encoded); - - // Assert - Assert.Equal(testCase, decoded); - - // Verify round-trip with generic - var genericDecoded = Base58.Bitcoin.DecodeGeneric(encoded); - Assert.Equal(genericDecoded, decoded); - } - } - - [Theory] - [InlineData(0)] // No leading zeros - [InlineData(1)] // 1 leading zero - [InlineData(5)] // 5 leading zeros - [InlineData(16)] // Half the array - [InlineData(31)] // Almost all zeros - public void Decode32Fast_WithVariousLeadingZeros_HandlesCorrectly(int leadingZeros) - { - // Arrange - var testData = new byte[32]; - Random.Shared.NextBytes(testData.AsSpan(leadingZeros)); - - var encoded = Base58.Bitcoin.Encode(testData); - - // Act - var decoded = Base58.Bitcoin.Decode(encoded); - - // Assert - Assert.Equal(testData, decoded); - - // Verify leading zeros preservation - if (leadingZeros > 0) - { - Assert.StartsWith(new string('1', leadingZeros), encoded); - } - } - - [Theory] - [InlineData(0)] // No leading zeros - [InlineData(1)] // 1 leading zero - [InlineData(8)] // 8 leading zeros - [InlineData(32)] // Half the array - [InlineData(63)] // Almost all zeros - public void Decode64Fast_WithVariousLeadingZeros_HandlesCorrectly(int leadingZeros) - { - // Arrange - var testData = new byte[64]; - Random.Shared.NextBytes(testData.AsSpan(leadingZeros)); - - var encoded = Base58.Bitcoin.Encode(testData); - - // Act - var decoded = Base58.Bitcoin.Decode(encoded); - - // Assert - Assert.Equal(testData, decoded); - - // Verify leading zeros preservation - if (leadingZeros > 0) - { - Assert.StartsWith(new string('1', leadingZeros), encoded); - } - } - - [Fact] - public void Decode32Fast_WithMaximumValues_WorksCorrectly() - { - // Arrange - Create data that will generate maximum Base58 length - var testData = new byte[32]; - Array.Fill(testData, (byte)0xFF); - - var encoded = Base58.Bitcoin.Encode(testData); - - // Act - var decoded = Base58.Bitcoin.Decode(encoded); - - // Assert - Assert.Equal(testData, decoded); - Assert.InRange(encoded.Length, 43, 44); // Maximum Base58 length for 32 bytes - } - - [Fact] - public void Decode64Fast_WithMaximumValues_WorksCorrectly() - { - // Arrange - Create data that will generate maximum Base58 length - var testData = new byte[64]; - Array.Fill(testData, (byte)0xFF); - - var encoded = Base58.Bitcoin.Encode(testData); - - // Act - var decoded = Base58.Bitcoin.Decode(encoded); - - // Assert - Assert.Equal(testData, decoded); - Assert.InRange(encoded.Length, 87, 88); // Maximum Base58 length for 64 bytes - } - - [Fact] - public void DecodeFast_OnlyTriggersForCorrectInputLengths() - { - // Arrange - Various input sizes that should NOT trigger fast paths - var testCases = new[] - { - new byte[31], // 31 bytes - too small for 32-byte fast path - new byte[33], // 33 bytes - too big for 32-byte, too small for 64-byte - new byte[63], // 63 bytes - too small for 64-byte fast path - new byte[65], // 65 bytes - too big for 64-byte fast path - }; - - foreach (var testCase in testCases) - { - Random.Shared.NextBytes(testCase); - - // Act - var encoded = Base58.Bitcoin.Encode(testCase); - var decoded = Base58.Bitcoin.Decode(encoded); - - // Assert - Should work correctly via generic path - Assert.Equal(testCase, decoded); - } - } } \ No newline at end of file diff --git a/src/Base58Encoding.Tests/GlobalUsings.cs b/src/Base58Encoding.Tests/GlobalUsings.cs deleted file mode 100644 index 8c927eb..0000000 --- a/src/Base58Encoding.Tests/GlobalUsings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; \ No newline at end of file diff --git a/src/Base58Encoding.Tests/xunit.runner.json b/src/Base58Encoding.Tests/xunit.runner.json new file mode 100644 index 0000000..503b748 --- /dev/null +++ b/src/Base58Encoding.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelAlgorithm": "aggressive" +} diff --git a/src/Base58Encoding/CountLeading.Base58.cs b/src/Base58Encoding/CountLeading.Base58.cs index ade5ea8..d8b7f43 100644 --- a/src/Base58Encoding/CountLeading.Base58.cs +++ b/src/Base58Encoding/CountLeading.Base58.cs @@ -7,7 +7,6 @@ namespace Base58Encoding; public partial class Base58 { - [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static int CountLeadingZeros(ReadOnlySpan data) { if (data.Length < 64) @@ -20,7 +19,6 @@ internal static int CountLeadingZeros(ReadOnlySpan data) return count + CountLeadingZerosScalar(data.Slice(count)); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static int CountLeadingZerosSimd(ReadOnlySpan data, out int processed) { int count = 0; @@ -72,7 +70,6 @@ internal static int CountLeadingZerosSimd(ReadOnlySpan data, out int proce return count; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static int CountLeadingZerosScalar(ReadOnlySpan data) { int count = 0; From cc0fa25c3d4e734497e40fa2d06776a88dca5d2e Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 7 Jan 2026 23:50:45 +0200 Subject: [PATCH 05/13] feat: update packages --- .../Base58ComparisonBenchmark.cs | 4 +- .../Base58Encoding.Benchmarks.csproj | 6 +-- .../FastVsRegularEncodeBenchmark.cs | 4 +- src/Base58Encoding.Benchmarks/Program.cs | 4 +- .../Base58Encoding.Tests.csproj | 4 +- src/Base58Encoding.Tests/Base58Tests.cs | 32 +++++++++++ src/Base58Encoding/Base58Encoding.csproj | 54 +++++++++---------- src/Base58Encoding/CountLeading.Base58.cs | 2 +- src/Base58Encoding/Decode.Base58.cs | 7 +-- 9 files changed, 72 insertions(+), 45 deletions(-) diff --git a/src/Base58Encoding.Benchmarks/Base58ComparisonBenchmark.cs b/src/Base58Encoding.Benchmarks/Base58ComparisonBenchmark.cs index 189b880..9214225 100644 --- a/src/Base58Encoding.Benchmarks/Base58ComparisonBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/Base58ComparisonBenchmark.cs @@ -6,10 +6,8 @@ namespace Base58Encoding.Benchmarks; -[SimpleJob(RuntimeMoniker.Net90)] -[SimpleJob(RuntimeMoniker.Net10_0)] [MemoryDiagnoser] -[DisassemblyDiagnoser(exportCombinedDisassemblyReport: true)] +//[DisassemblyDiagnoser(exportCombinedDisassemblyReport: true)] [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")] public class Base58ComparisonBenchmark { diff --git a/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj b/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj index ecdccfb..04268dd 100644 --- a/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj +++ b/src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj @@ -2,15 +2,15 @@ Exe - net9.0;net10.0 + net10.0 enable enable true - - + + diff --git a/src/Base58Encoding.Benchmarks/FastVsRegularEncodeBenchmark.cs b/src/Base58Encoding.Benchmarks/FastVsRegularEncodeBenchmark.cs index f630e63..331e672 100644 --- a/src/Base58Encoding.Benchmarks/FastVsRegularEncodeBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/FastVsRegularEncodeBenchmark.cs @@ -5,8 +5,8 @@ namespace Base58Encoding.Benchmarks; [MemoryDiagnoser] public class FastVsRegularEncodeBenchmark { - public byte[] _data; - private string _encodedBase58; + private byte[] _data = default!; + private string _encodedBase58 = default!; [GlobalSetup] public void Setup() diff --git a/src/Base58Encoding.Benchmarks/Program.cs b/src/Base58Encoding.Benchmarks/Program.cs index 6fc7b70..f6e20b5 100644 --- a/src/Base58Encoding.Benchmarks/Program.cs +++ b/src/Base58Encoding.Benchmarks/Program.cs @@ -1,6 +1,6 @@ using BenchmarkDotNet.Running; using Base58Encoding.Benchmarks; -//BenchmarkRunner.Run(); -BenchmarkRunner.Run(); +BenchmarkRunner.Run(); +//BenchmarkRunner.Run(); //BenchmarkRunner.Run(); diff --git a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj index 6fc5901..bba85c5 100644 --- a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj +++ b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 Exe enable enable @@ -13,7 +13,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Base58Encoding.Tests/Base58Tests.cs b/src/Base58Encoding.Tests/Base58Tests.cs index b935b03..11d0317 100644 --- a/src/Base58Encoding.Tests/Base58Tests.cs +++ b/src/Base58Encoding.Tests/Base58Tests.cs @@ -270,4 +270,36 @@ public void Encode_WithLeadingZeroPatterns_PreservesCorrectly() } } + [Theory] + [InlineData(new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, "11111111111111111111111111111112")] // All zeros + 1 + [InlineData(new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1 }, "1111111111111111111111111111115S")] // 257 case + [InlineData(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }, "JEKNVnkbo3jma5nREBBJCDoXFVeKkD56V3xKrvRmWxFG")] // All 0xFF + [InlineData(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE }, "JEKNVnkbo3jma5nREBBJCDoXFVeKkD56V3xKrvRmWxFF")] // All 0xFF - 1 + public void DecodeBitcoin32Fast_WithFireDancerTestVectors_WorksCorrectly(byte[] expectedBytes, string encoded) + { + // Act - Test the fast decode method directly + var decoded = Base58.DecodeBitcoin32Fast(encoded); + + // Assert + Assert.NotNull(decoded); + Assert.Equal(expectedBytes, decoded); + Assert.Equal(32, decoded.Length); + } + + [Theory] + [InlineData(new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, "1111111111111111111111111111111111111111111111111111111111111112")] // All zeros + 1 + [InlineData(new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1 }, "111111111111111111111111111111111111111111111111111111111111115S")] // 257 case + [InlineData(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }, "67rpwLCuS5DGA8KGZXKsVQ7dnPb9goRLoKfgGbLfQg9WoLUgNY77E2jT11fem3coV9nAkguBACzrU1iyZM4B8roQ")] // All 0xFF + [InlineData(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE }, "67rpwLCuS5DGA8KGZXKsVQ7dnPb9goRLoKfgGbLfQg9WoLUgNY77E2jT11fem3coV9nAkguBACzrU1iyZM4B8roP")] // All 0xFF - 1 + public void DecodeBitcoin64Fast_WithFireDancerTestVectors_WorksCorrectly(byte[] expectedBytes, string encoded) + { + // Act - Test the fast decode method directly + var decoded = Base58.DecodeBitcoin64Fast(encoded); + + // Assert + Assert.NotNull(decoded); + Assert.Equal(expectedBytes, decoded); + Assert.Equal(64, decoded.Length); + } + } \ No newline at end of file diff --git a/src/Base58Encoding/Base58Encoding.csproj b/src/Base58Encoding/Base58Encoding.csproj index 94be1cb..253c759 100644 --- a/src/Base58Encoding/Base58Encoding.csproj +++ b/src/Base58Encoding/Base58Encoding.csproj @@ -1,34 +1,34 @@  - - net9.0;net10.0 - enable - enable + + net10.0 + enable + enable - Base58Encoding - Nikolay Zdravkov - A high-performance Base58 encoding/decoding library for .NET - base58;encoding;bitcoin;cryptocurrency - https://github.com/unsafePtr/Base58Encoding - https://github.com/unsafePtr/Base58Encoding - MIT - README.md - false - + Base58Encoding + Nikolay Zdravkov + A high-performance Base58 encoding/decoding library for .NET + base58;encoding;bitcoin;cryptocurrency + https://github.com/unsafePtr/Base58Encoding + https://github.com/unsafePtr/Base58Encoding + MIT + README.md + false + - - - <_Parameter1>Base58Encoding.Benchmarks - - - - - <_Parameter1>Base58Encoding.Tests - - + + + <_Parameter1>Base58Encoding.Benchmarks + + + + + <_Parameter1>Base58Encoding.Tests + + - - - + + + diff --git a/src/Base58Encoding/CountLeading.Base58.cs b/src/Base58Encoding/CountLeading.Base58.cs index d8b7f43..6354f84 100644 --- a/src/Base58Encoding/CountLeading.Base58.cs +++ b/src/Base58Encoding/CountLeading.Base58.cs @@ -9,7 +9,7 @@ public partial class Base58 { internal static int CountLeadingZeros(ReadOnlySpan data) { - if (data.Length < 64) + if (data.Length < 32) return CountLeadingZerosScalar(data); int count = CountLeadingZerosSimd(data, out int processed); diff --git a/src/Base58Encoding/Decode.Base58.cs b/src/Base58Encoding/Decode.Base58.cs index 4e9e7bc..3892ba3 100644 --- a/src/Base58Encoding/Decode.Base58.cs +++ b/src/Base58Encoding/Decode.Base58.cs @@ -122,7 +122,7 @@ internal byte[] DecodeGeneric(ReadOnlySpan encoded) // Validate + convert using Bitcoin decode table (return null for invalid chars) if (c >= 128 || bitcoinDecodeTable[c] == 255) return null; - + rawBase58[j] = bitcoinDecodeTable[c]; } } @@ -196,9 +196,6 @@ internal byte[] DecodeGeneric(ReadOnlySpan encoded) internal static byte[]? DecodeBitcoin64Fast(ReadOnlySpan encoded) { - // Validate string length - should be between 1 and 88 chars for 64-byte output - if (encoded.Length > 88) return null; - int charCount = encoded.Length; // Convert to raw base58 digits with validation + conversion in one pass @@ -219,7 +216,7 @@ internal byte[] DecodeGeneric(ReadOnlySpan encoded) // Validate + convert using Bitcoin decode table (return null for invalid chars) if (c >= 128 || bitcoinDecodeTable[c] == 255) return null; - + rawBase58[j] = bitcoinDecodeTable[c]; } } From 154c9879129be5971690e4fe10f10979f87f514a Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Wed, 7 Jan 2026 23:59:52 +0200 Subject: [PATCH 06/13] docs: update readme benches --- README.md | 63 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 9eb7a54..7a25cc7 100644 --- a/README.md +++ b/README.md @@ -32,40 +32,41 @@ new Base58(Base58Alphabet.Custom("")); ## Benchmarks ``` -BenchmarkDotNet v0.15.2, Windows 11 (10.0.26100.4946/24H2/2024Update/HudsonValley) +BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7462/25H2/2025Update/HudsonValley2) 13th Gen Intel Core i7-13700KF 3.40GHz, 1 CPU, 24 logical and 16 physical cores -.NET SDK 9.0.304 - [Host] : .NET 9.0.8 (9.0.825.36511), X64 RyuJIT AVX2 - .NET 9.0 : .NET 9.0.8 (9.0.825.36511), X64 RyuJIT AVX2 +.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 + +Job=DefaultJob -Job=.NET 9.0 Runtime=.NET 9.0 ``` -| Method | VectorType | Mean | Error | StdDev | Median | -|--------------------------- |--------------- |------------:|----------:|----------:|------------:| -| **'Our Base58 Encode'** | **BitcoinAddress** | **519.2 ns** | **1.99 ns** | **1.76 ns** | **519.2 ns** | -| 'SimpleBase Base58 Encode' | BitcoinAddress | 770.1 ns | 4.93 ns | 4.61 ns | 769.8 ns | -| 'Our Base58 Decode' | BitcoinAddress | 187.5 ns | 2.06 ns | 1.93 ns | 187.8 ns | -| 'SimpleBase Base58 Decode' | BitcoinAddress | 502.1 ns | 6.95 ns | 5.43 ns | 502.4 ns | -| | | | | | | -| **'Our Base58 Encode'** | **SolanaAddress** | **1,375.9 ns** | **36.83 ns** | **108.59 ns** | **1,410.7 ns** | -| 'SimpleBase Base58 Encode' | SolanaAddress | 2,546.5 ns | 112.91 ns | 329.36 ns | 2,661.5 ns | -| 'Our Base58 Decode' | SolanaAddress | 536.5 ns | 24.89 ns | 73.39 ns | 564.3 ns | -| 'SimpleBase Base58 Decode' | SolanaAddress | 1,210.2 ns | 31.14 ns | 90.33 ns | 1,236.8 ns | -| | | | | | | -| **'Our Base58 Encode'** | **SolanaTx** | **4,185.4 ns** | **48.51 ns** | **37.87 ns** | **4,173.0 ns** | -| 'SimpleBase Base58 Encode' | SolanaTx | 10,844.0 ns | 337.01 ns | 988.40 ns | 11,158.2 ns | -| 'Our Base58 Decode' | SolanaTx | 2,159.0 ns | 116.99 ns | 344.95 ns | 2,294.0 ns | -| 'SimpleBase Base58 Decode' | SolanaTx | 5,357.9 ns | 177.28 ns | 494.18 ns | 5,506.1 ns | -| | | | | | | -| **'Our Base58 Encode'** | **IPFSHash** | **1,285.9 ns** | **80.51 ns** | **237.37 ns** | **1,081.6 ns** | -| 'SimpleBase Base58 Encode' | IPFSHash | 1,654.3 ns | 4.14 ns | 3.88 ns | 1,653.9 ns | -| 'Our Base58 Decode' | IPFSHash | 347.0 ns | 1.19 ns | 0.99 ns | 347.0 ns | -| 'SimpleBase Base58 Decode' | IPFSHash | 883.4 ns | 16.93 ns | 15.01 ns | 883.2 ns | -| | | | | | | -| **'Our Base58 Encode'** | **MoneroAddress** | **4,907.0 ns** | **13.36 ns** | **11.84 ns** | **4,907.9 ns** | -| 'SimpleBase Base58 Encode' | MoneroAddress | 8,998.7 ns | 25.07 ns | 20.94 ns | 8,998.8 ns | -| 'Our Base58 Decode' | MoneroAddress | 1,367.1 ns | 4.38 ns | 3.89 ns | 1,366.5 ns | -| 'SimpleBase Base58 Decode' | MoneroAddress | 3,809.8 ns | 59.58 ns | 55.73 ns | 3,797.3 ns | +| 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'** | **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'** | **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'** | **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'** | **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 | ## License From b1e0abb09a059ef1d8ec08532268bb10bf19155b Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Thu, 8 Jan 2026 00:20:19 +0200 Subject: [PATCH 07/13] bench: benchmark jagged vs multidimensional access --- .../Base58ComparisonBenchmark.cs | 3 - .../JaggedVsMultidimensionalArrayBenchmark.cs | 380 ++++++++++++++++++ src/Base58Encoding/Encode.Base58.cs | 8 +- 3 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs diff --git a/src/Base58Encoding.Benchmarks/Base58ComparisonBenchmark.cs b/src/Base58Encoding.Benchmarks/Base58ComparisonBenchmark.cs index 9214225..d2f12d4 100644 --- a/src/Base58Encoding.Benchmarks/Base58ComparisonBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/Base58ComparisonBenchmark.cs @@ -1,13 +1,10 @@ using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Columns; using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; using Base58Encoding.Benchmarks.Common; namespace Base58Encoding.Benchmarks; [MemoryDiagnoser] -//[DisassemblyDiagnoser(exportCombinedDisassemblyReport: true)] [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")] public class Base58ComparisonBenchmark { diff --git a/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs b/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs new file mode 100644 index 0000000..849ecd1 --- /dev/null +++ b/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs @@ -0,0 +1,380 @@ +using BenchmarkDotNet.Attributes; +using System.Buffers.Binary; + +namespace Base58Encoding.Benchmarks; + +/// +/// Benchmark comparing jagged arrays (uint[][]) vs multidimensional arrays (uint[,]) +/// for Base58 table lookup performance using the same Fast32 encode/decode logic +/// Verdict - on encoding both are the same, but on decoding jagged arrays are 5-10% faster. +/// +[MemoryDiagnoser] +public class JaggedVsMultidimensionalArrayBenchmark +{ + private byte[] _data = default!; + private string _encodedBase58 = default!; + + // Jagged arrays (current implementation) + private static readonly uint[][] JaggedEncodeTable32 = Base58BitcoinTables.EncodeTable32; + private static readonly uint[][] JaggedDecodeTable32 = Base58BitcoinTables.DecodeTable32; + + // Multidimensional arrays (alternative implementation) + private static readonly uint[,] MultidimensionalEncodeTable32 = ConvertToMultidimensional(Base58BitcoinTables.EncodeTable32); + private static readonly uint[,] MultidimensionalDecodeTable32 = ConvertToMultidimensional(Base58BitcoinTables.DecodeTable32); + + [GlobalSetup] + public void Setup() + { + _data = new byte[32]; + Random.Shared.NextBytes(_data); + _encodedBase58 = SimpleBase.Base58.Bitcoin.Encode(_data); + } + + [Benchmark(Baseline = true)] + public string EncodeWithJaggedArray() + { + return EncodeBitcoin32FastJagged(_data); + } + + [Benchmark] + public string EncodeWithMultidimensionalArray() + { + return EncodeBitcoin32FastMultidimensional(_data); + } + + [Benchmark] + public byte[] DecodeWithJaggedArray() + { + return DecodeBitcoin32FastJagged(_encodedBase58)!; + } + + [Benchmark] + public byte[] DecodeWithMultidimensionalArray() + { + return DecodeBitcoin32FastMultidimensional(_encodedBase58)!; + } + + private static uint[,] ConvertToMultidimensional(uint[][] jaggedArray) + { + int rows = jaggedArray.Length; + int cols = jaggedArray[0].Length; + var result = new uint[rows, cols]; + + for (int i = 0; i < rows; i++) + { + for (int j = 0; j < cols; j++) + { + result[i, j] = jaggedArray[i][j]; + } + } + + return result; + } + + private static string EncodeBitcoin32FastJagged(ReadOnlySpan data) + { + // Count leading zeros + int inLeadingZeros = Base58.CountLeadingZeros(data); + + if (inLeadingZeros == data.Length) + { + return new string('1', inLeadingZeros); + } + + // Convert 32 bytes to 8 uint32 limbs (big-endian) + Span binary = stackalloc uint[Base58BitcoinTables.BinarySz32]; + for (int i = 0; i < Base58BitcoinTables.BinarySz32; i++) + { + int offset = i * sizeof(uint); + binary[i] = BinaryPrimitives.ReadUInt32BigEndian(data.Slice(offset, sizeof(uint))); + } + + // Convert to intermediate format (base 58^5) using JAGGED ARRAY + Span intermediate = stackalloc ulong[Base58BitcoinTables.IntermediateSz32]; + intermediate.Clear(); + + for (int i = 0; i < Base58BitcoinTables.BinarySz32; i++) + { + for (int j = 0; j < Base58BitcoinTables.IntermediateSz32 - 1; j++) + { + intermediate[j + 1] += (ulong)binary[i] * JaggedEncodeTable32[i][j]; + } + } + + // Reduce each term to be less than 58^5 + for (int i = Base58BitcoinTables.IntermediateSz32 - 1; i > 0; i--) + { + intermediate[i - 1] += intermediate[i] / Base58BitcoinTables.R1Div; + intermediate[i] %= Base58BitcoinTables.R1Div; + } + + // Convert intermediate form to raw base58 digits + Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32]; + for (int i = 0; i < Base58BitcoinTables.IntermediateSz32; i++) + { + uint v = (uint)intermediate[i]; + rawBase58[5 * i + 4] = (byte)((v / 1U) % 58U); + rawBase58[5 * i + 3] = (byte)((v / 58U) % 58U); + rawBase58[5 * i + 2] = (byte)((v / 3364U) % 58U); + rawBase58[5 * i + 1] = (byte)((v / 195112U) % 58U); + rawBase58[5 * i + 0] = (byte)(v / 11316496U); + } + + // Count leading zeros in raw output + int rawLeadingZeros = 0; + for (; rawLeadingZeros < Base58BitcoinTables.Raw58Sz32; rawLeadingZeros++) + { + if (rawBase58[rawLeadingZeros] != 0) break; + } + + // Calculate skip and final length + int skip = rawLeadingZeros - inLeadingZeros; + int outputLength = Base58BitcoinTables.Raw58Sz32 - skip; + var state = new Base58.EncodeFastState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength); + return string.Create(outputLength, state, static (span, state) => + { + if (state.InLeadingZeros > 0) + { + span[..state.InLeadingZeros].Fill('1'); + } + + var bitcoinChars = Base58BitcoinTables.BitcoinChars; + for (int i = 0; i < state.OutputLength - state.InLeadingZeros; i++) + { + byte digit = state.RawBase58[state.RawLeadingZeros + i]; + span[state.InLeadingZeros + i] = bitcoinChars[digit]; + } + }); + } + + private static string EncodeBitcoin32FastMultidimensional(ReadOnlySpan data) + { + // Count leading zeros + int inLeadingZeros = Base58.CountLeadingZeros(data); + + if (inLeadingZeros == data.Length) + { + return new string('1', inLeadingZeros); + } + + // Convert 32 bytes to 8 uint32 limbs (big-endian) + Span binary = stackalloc uint[Base58BitcoinTables.BinarySz32]; + for (int i = 0; i < Base58BitcoinTables.BinarySz32; i++) + { + int offset = i * sizeof(uint); + binary[i] = BinaryPrimitives.ReadUInt32BigEndian(data.Slice(offset, sizeof(uint))); + } + + // Convert to intermediate format (base 58^5) using MULTIDIMENSIONAL ARRAY + Span intermediate = stackalloc ulong[Base58BitcoinTables.IntermediateSz32]; + intermediate.Clear(); + + for (int i = 0; i < Base58BitcoinTables.BinarySz32; i++) + { + for (int j = 0; j < Base58BitcoinTables.IntermediateSz32 - 1; j++) + { + intermediate[j + 1] += (ulong)binary[i] * MultidimensionalEncodeTable32[i, j]; + } + } + + // Reduce each term to be less than 58^5 + for (int i = Base58BitcoinTables.IntermediateSz32 - 1; i > 0; i--) + { + intermediate[i - 1] += intermediate[i] / Base58BitcoinTables.R1Div; + intermediate[i] %= Base58BitcoinTables.R1Div; + } + + // Convert intermediate form to raw base58 digits + Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32]; + for (int i = 0; i < Base58BitcoinTables.IntermediateSz32; i++) + { + uint v = (uint)intermediate[i]; + rawBase58[5 * i + 4] = (byte)((v / 1U) % 58U); + rawBase58[5 * i + 3] = (byte)((v / 58U) % 58U); + rawBase58[5 * i + 2] = (byte)((v / 3364U) % 58U); + rawBase58[5 * i + 1] = (byte)((v / 195112U) % 58U); + rawBase58[5 * i + 0] = (byte)(v / 11316496U); + } + + // Count leading zeros in raw output + int rawLeadingZeros = 0; + for (; rawLeadingZeros < Base58BitcoinTables.Raw58Sz32; rawLeadingZeros++) + { + if (rawBase58[rawLeadingZeros] != 0) break; + } + + // Calculate skip and final length + int skip = rawLeadingZeros - inLeadingZeros; + int outputLength = Base58BitcoinTables.Raw58Sz32 - skip; + + var state = new Base58.EncodeFastState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength); + return string.Create(outputLength, state, static (span, state) => + { + if (state.InLeadingZeros > 0) + { + span[..state.InLeadingZeros].Fill('1'); + } + + var bitcoinChars = Base58BitcoinTables.BitcoinChars; + for (int i = 0; i < state.OutputLength - state.InLeadingZeros; i++) + { + byte digit = state.RawBase58[state.RawLeadingZeros + i]; + span[state.InLeadingZeros + i] = bitcoinChars[digit]; + } + }); + } + + private static byte[]? DecodeBitcoin32FastJagged(string encoded) + { + // Early validation and length check + if (encoded.Length > Base58BitcoinTables.Raw58Sz32) return null; + + // Validate characters and create raw array using JAGGED ARRAY lookup + Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32]; + var bitcoinDecodeTable = Base58Alphabet.Bitcoin.DecodeTable.Span; + + int prepend0 = Base58BitcoinTables.Raw58Sz32 - encoded.Length; + for (int j = 0; j < Base58BitcoinTables.Raw58Sz32; j++) + { + if (j < prepend0) + { + rawBase58[j] = 0; + } + else + { + char c = encoded[j - prepend0]; + if (c >= 128 || bitcoinDecodeTable[c] == 255) + return null; + + rawBase58[j] = bitcoinDecodeTable[c]; + } + } + + // Convert to intermediate format + 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; + } + + // Convert to binary using JAGGED ARRAY + Span binary = stackalloc ulong[Base58BitcoinTables.BinarySz32]; + for (int j = 0; j < Base58BitcoinTables.BinarySz32; j++) + { + ulong acc = 0UL; + for (int i = 0; i < Base58BitcoinTables.IntermediateSz32; i++) + { + acc += intermediate[i] * JaggedDecodeTable32[i][j]; + } + binary[j] = acc; + } + + // Reduce to proper uint32 values + for (int i = Base58BitcoinTables.BinarySz32 - 1; i > 0; i--) + { + binary[i - 1] += binary[i] >> 32; + binary[i] &= 0xFFFFFFFFUL; + } + + if (binary[0] > 0xFFFFFFFFUL) return null; + + // Convert to output bytes + var result = new byte[32]; + for (int i = 0; i < Base58BitcoinTables.BinarySz32; i++) + { + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(i * 4, 4), (uint)binary[i]); + } + + // Validate leading zeros match leading '1's + int leadingZeroCnt = 0; + for (; leadingZeroCnt < 32; leadingZeroCnt++) + { + if (result[leadingZeroCnt] != 0) break; + if (encoded.Length <= leadingZeroCnt || encoded[leadingZeroCnt] != '1') return null; + } + if (leadingZeroCnt < encoded.Length && encoded[leadingZeroCnt] == '1') return null; + + return result; + } + + private static byte[]? DecodeBitcoin32FastMultidimensional(string encoded) + { + // Early validation and length check + if (encoded.Length > Base58BitcoinTables.Raw58Sz32) return null; + + // Validate characters and create raw array using MULTIDIMENSIONAL ARRAY lookup + Span rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32]; + var bitcoinDecodeTable = Base58Alphabet.Bitcoin.DecodeTable.Span; + + int prepend0 = Base58BitcoinTables.Raw58Sz32 - encoded.Length; + for (int j = 0; j < Base58BitcoinTables.Raw58Sz32; j++) + { + if (j < prepend0) + { + rawBase58[j] = 0; + } + else + { + char c = encoded[j - prepend0]; + if (c >= 128 || bitcoinDecodeTable[c] == 255) + return null; + + rawBase58[j] = bitcoinDecodeTable[c]; + } + } + + // Convert to intermediate format + 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; + } + + // Convert to binary using MULTIDIMENSIONAL ARRAY + Span binary = stackalloc ulong[Base58BitcoinTables.BinarySz32]; + for (int j = 0; j < Base58BitcoinTables.BinarySz32; j++) + { + ulong acc = 0UL; + for (int i = 0; i < Base58BitcoinTables.IntermediateSz32; i++) + { + acc += intermediate[i] * MultidimensionalDecodeTable32[i, j]; + } + binary[j] = acc; + } + + // Reduce to proper uint32 values + for (int i = Base58BitcoinTables.BinarySz32 - 1; i > 0; i--) + { + binary[i - 1] += binary[i] >> 32; + binary[i] &= 0xFFFFFFFFUL; + } + + if (binary[0] > 0xFFFFFFFFUL) return null; + + // Convert to output bytes + var result = new byte[32]; + for (int i = 0; i < Base58BitcoinTables.BinarySz32; i++) + { + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(i * 4, 4), (uint)binary[i]); + } + + // Validate leading zeros match leading '1's + int leadingZeroCnt = 0; + for (; leadingZeroCnt < 32; leadingZeroCnt++) + { + if (result[leadingZeroCnt] != 0) break; + if (encoded.Length <= leadingZeroCnt || encoded[leadingZeroCnt] != '1') return null; + } + if (leadingZeroCnt < encoded.Length && encoded[leadingZeroCnt] == '1') return null; + + return result; + } +} \ No newline at end of file diff --git a/src/Base58Encoding/Encode.Base58.cs b/src/Base58Encoding/Encode.Base58.cs index b5500da..a7a9f6b 100644 --- a/src/Base58Encoding/Encode.Base58.cs +++ b/src/Base58Encoding/Encode.Base58.cs @@ -244,8 +244,8 @@ private static string EncodeBitcoin64Fast(ReadOnlySpan data) // Debug.Assert - ensure all values are valid Base58 digits (algorithm correctness check) Debug.Assert(rawBase58[5 * i + 0] < 58 && rawBase58[5 * i + 1] < 58 && - rawBase58[5 * i + 2] < 58 && rawBase58[5 * i + 3] < 58 && - rawBase58[5 * i + 4] < 58, + rawBase58[5 * i + 2] < 58 && rawBase58[5 * i + 3] < 58 && + rawBase58[5 * i + 4] < 58, $"Invalid base58 digit generated at position {i} - algorithm bug"); } @@ -284,7 +284,7 @@ private static string EncodeBitcoin64Fast(ReadOnlySpan data) }); } - private readonly ref struct EncodeFastState + internal readonly ref struct EncodeFastState { public readonly ReadOnlySpan RawBase58; public readonly int InLeadingZeros; @@ -300,7 +300,7 @@ public EncodeFastState(ReadOnlySpan rawBase58, int inLeadingZeros, int raw } } - private readonly ref struct EncodeGenericFinalString + internal readonly ref struct EncodeGenericFinalString { public readonly ReadOnlySpan Alphabet; public readonly ReadOnlySpan Digits; From 77ca18e18865fa062d5fe9ed08321c5feee331a0 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Thu, 8 Jan 2026 00:20:43 +0200 Subject: [PATCH 08/13] refactor: drop unsused usings --- .../BoundsCheckComparisonBenchmark.cs | 3 --- .../CountLeadingCharactersBenchmark.cs | 3 --- src/Base58Encoding.Benchmarks/CountLeadingZerosBenchmark.cs | 2 -- 3 files changed, 8 deletions(-) diff --git a/src/Base58Encoding.Benchmarks/BoundsCheckComparisonBenchmark.cs b/src/Base58Encoding.Benchmarks/BoundsCheckComparisonBenchmark.cs index 9fea631..5627f07 100644 --- a/src/Base58Encoding.Benchmarks/BoundsCheckComparisonBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/BoundsCheckComparisonBenchmark.cs @@ -1,5 +1,4 @@ using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Columns; using BenchmarkDotNet.Diagnosers; using BenchmarkDotNet.Jobs; using System.Runtime.CompilerServices; @@ -17,7 +16,6 @@ public class BoundsCheckComparisonBenchmark private const int Base = 58; private const int MaxStackallocByte = 512; private byte[] _testData = null!; - private Base58 _base58 = null!; [Params(TestVectors.VectorType.BitcoinAddress, TestVectors.VectorType.SolanaAddress, TestVectors.VectorType.SolanaTx, TestVectors.VectorType.IPFSHash, TestVectors.VectorType.MoneroAddress, TestVectors.VectorType.FlickrTestData, TestVectors.VectorType.RippleTestData)] public TestVectors.VectorType VectorType { get; set; } @@ -26,7 +24,6 @@ public class BoundsCheckComparisonBenchmark public void Setup() { _testData = TestVectors.GetVector(VectorType); - _base58 = Base58.Bitcoin; } [Benchmark(Baseline = true)] diff --git a/src/Base58Encoding.Benchmarks/CountLeadingCharactersBenchmark.cs b/src/Base58Encoding.Benchmarks/CountLeadingCharactersBenchmark.cs index 3341af6..a52fb26 100644 --- a/src/Base58Encoding.Benchmarks/CountLeadingCharactersBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/CountLeadingCharactersBenchmark.cs @@ -1,10 +1,7 @@ using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Columns; using BenchmarkDotNet.Jobs; -using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using static System.Net.Mime.MediaTypeNames; namespace Base58Encoding.Benchmarks; diff --git a/src/Base58Encoding.Benchmarks/CountLeadingZerosBenchmark.cs b/src/Base58Encoding.Benchmarks/CountLeadingZerosBenchmark.cs index 73ecac0..c78c063 100644 --- a/src/Base58Encoding.Benchmarks/CountLeadingZerosBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/CountLeadingZerosBenchmark.cs @@ -1,7 +1,5 @@ using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Columns; using BenchmarkDotNet.Jobs; -using System.Collections.Generic; using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; From 66d43d520c158bac26e2867b4ab4dcfb3e3f6a36 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Thu, 8 Jan 2026 00:47:42 +0200 Subject: [PATCH 09/13] feat: gitattributes & editorconfig --- .editorconfig | 18 ++++++++++++++++++ .gitattributes | 3 +++ .../Base58ComparisonBenchmark.cs | 5 +++-- .../BoundsCheckComparisonBenchmark.cs | 10 ++++++---- .../Common/TestVectors.cs | 2 +- .../CountLeadingCharactersBenchmark.cs | 7 ++++--- .../CountLeadingZerosBenchmark.cs | 7 ++++--- .../FastVsRegularEncodeBenchmark.cs | 2 +- .../JaggedVsMultidimensionalArrayBenchmark.cs | 5 +++-- src/Base58Encoding.Benchmarks/Program.cs | 3 ++- src/Base58Encoding.Tests/Base58DecodeFast.cs | 2 +- src/Base58Encoding.Tests/Base58EncodeFast.cs | 2 +- src/Base58Encoding.Tests/Base58Tests.cs | 2 +- .../SimpleLeadingZerosTest.cs | 2 +- src/Base58Encoding/Base58.cs | 6 +----- src/Base58Encoding/Base58Alphabet.cs | 2 +- src/Base58Encoding/Base58BitcoinTables.cs | 2 +- src/Base58Encoding/CountLeading.Base58.cs | 2 +- src/Base58Encoding/Decode.Base58.cs | 2 +- src/Base58Encoding/Encode.Base58.cs | 2 +- src/Base58Encoding/ThrowHelper.cs | 2 +- 21 files changed, 56 insertions(+), 32 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bc2dd9e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.cs] +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true + +dotnet_separate_import_directive_groups = true +dotnet_sort_system_usings_first = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..92d168b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto + +*.cs text diff=csharp \ No newline at end of file diff --git a/src/Base58Encoding.Benchmarks/Base58ComparisonBenchmark.cs b/src/Base58Encoding.Benchmarks/Base58ComparisonBenchmark.cs index d2f12d4..f5635ca 100644 --- a/src/Base58Encoding.Benchmarks/Base58ComparisonBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/Base58ComparisonBenchmark.cs @@ -1,6 +1,7 @@ +using Base58Encoding.Benchmarks.Common; + using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Diagnosers; -using Base58Encoding.Benchmarks.Common; namespace Base58Encoding.Benchmarks; @@ -50,4 +51,4 @@ public byte[] Decode_SimpleBase58() { return SimpleBase.Base58.Bitcoin.Decode(_base58Encoded); } -} \ No newline at end of file +} diff --git a/src/Base58Encoding.Benchmarks/BoundsCheckComparisonBenchmark.cs b/src/Base58Encoding.Benchmarks/BoundsCheckComparisonBenchmark.cs index 5627f07..cc3e2f7 100644 --- a/src/Base58Encoding.Benchmarks/BoundsCheckComparisonBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/BoundsCheckComparisonBenchmark.cs @@ -1,10 +1,12 @@ -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; + using Base58Encoding.Benchmarks.Common; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Jobs; + namespace Base58Encoding.Benchmarks; [SimpleJob(RuntimeMoniker.Net90)] @@ -143,4 +145,4 @@ public unsafe byte[] EncodeFixed() digits.Slice(0, digitCount).CopyTo(result); return result; } -} \ No newline at end of file +} diff --git a/src/Base58Encoding.Benchmarks/Common/TestVectors.cs b/src/Base58Encoding.Benchmarks/Common/TestVectors.cs index b299fd4..149c44b 100644 --- a/src/Base58Encoding.Benchmarks/Common/TestVectors.cs +++ b/src/Base58Encoding.Benchmarks/Common/TestVectors.cs @@ -39,4 +39,4 @@ public static byte[] GetVector(VectorType type) _ => BitcoinAddress }; } -} \ No newline at end of file +} diff --git a/src/Base58Encoding.Benchmarks/CountLeadingCharactersBenchmark.cs b/src/Base58Encoding.Benchmarks/CountLeadingCharactersBenchmark.cs index a52fb26..4087db0 100644 --- a/src/Base58Encoding.Benchmarks/CountLeadingCharactersBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/CountLeadingCharactersBenchmark.cs @@ -1,8 +1,9 @@ -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Jobs; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + namespace Base58Encoding.Benchmarks; [SimpleJob(RuntimeMoniker.Net90)] @@ -125,4 +126,4 @@ internal static int CountLeadingCharacters(ReadOnlySpan text, char target) return count; } -} \ No newline at end of file +} diff --git a/src/Base58Encoding.Benchmarks/CountLeadingZerosBenchmark.cs b/src/Base58Encoding.Benchmarks/CountLeadingZerosBenchmark.cs index c78c063..e0f4073 100644 --- a/src/Base58Encoding.Benchmarks/CountLeadingZerosBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/CountLeadingZerosBenchmark.cs @@ -1,10 +1,11 @@ -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Jobs; using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Intrinsics; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + namespace Base58Encoding.Benchmarks; [SimpleJob(RuntimeMoniker.Net90)] @@ -217,4 +218,4 @@ private static int CountLeadingZerosCombinedImpl(ReadOnlySpan data) return count + CountLeadingZerosScalarImpl(data.Slice(count)); } -} \ No newline at end of file +} diff --git a/src/Base58Encoding.Benchmarks/FastVsRegularEncodeBenchmark.cs b/src/Base58Encoding.Benchmarks/FastVsRegularEncodeBenchmark.cs index 331e672..1f9e388 100644 --- a/src/Base58Encoding.Benchmarks/FastVsRegularEncodeBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/FastVsRegularEncodeBenchmark.cs @@ -1,4 +1,4 @@ -using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Attributes; namespace Base58Encoding.Benchmarks; diff --git a/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs b/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs index 849ecd1..48d78f8 100644 --- a/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs +++ b/src/Base58Encoding.Benchmarks/JaggedVsMultidimensionalArrayBenchmark.cs @@ -1,6 +1,7 @@ -using BenchmarkDotNet.Attributes; using System.Buffers.Binary; +using BenchmarkDotNet.Attributes; + namespace Base58Encoding.Benchmarks; /// @@ -377,4 +378,4 @@ private static string EncodeBitcoin32FastMultidimensional(ReadOnlySpan dat return result; } -} \ No newline at end of file +} diff --git a/src/Base58Encoding.Benchmarks/Program.cs b/src/Base58Encoding.Benchmarks/Program.cs index f6e20b5..a534ab4 100644 --- a/src/Base58Encoding.Benchmarks/Program.cs +++ b/src/Base58Encoding.Benchmarks/Program.cs @@ -1,6 +1,7 @@ -using BenchmarkDotNet.Running; using Base58Encoding.Benchmarks; +using BenchmarkDotNet.Running; + BenchmarkRunner.Run(); //BenchmarkRunner.Run(); //BenchmarkRunner.Run(); diff --git a/src/Base58Encoding.Tests/Base58DecodeFast.cs b/src/Base58Encoding.Tests/Base58DecodeFast.cs index 6de5ce3..9ccbac7 100644 --- a/src/Base58Encoding.Tests/Base58DecodeFast.cs +++ b/src/Base58Encoding.Tests/Base58DecodeFast.cs @@ -1,4 +1,4 @@ -namespace Base58Encoding.Tests; +namespace Base58Encoding.Tests; public class Base58DecodeFast { diff --git a/src/Base58Encoding.Tests/Base58EncodeFast.cs b/src/Base58Encoding.Tests/Base58EncodeFast.cs index 6d2d5ee..56a0584 100644 --- a/src/Base58Encoding.Tests/Base58EncodeFast.cs +++ b/src/Base58Encoding.Tests/Base58EncodeFast.cs @@ -1,4 +1,4 @@ -namespace Base58Encoding.Tests; +namespace Base58Encoding.Tests; public class Base58EncodeFast { diff --git a/src/Base58Encoding.Tests/Base58Tests.cs b/src/Base58Encoding.Tests/Base58Tests.cs index 11d0317..4c418a0 100644 --- a/src/Base58Encoding.Tests/Base58Tests.cs +++ b/src/Base58Encoding.Tests/Base58Tests.cs @@ -302,4 +302,4 @@ public void DecodeBitcoin64Fast_WithFireDancerTestVectors_WorksCorrectly(byte[] Assert.Equal(64, decoded.Length); } -} \ No newline at end of file +} diff --git a/src/Base58Encoding.Tests/SimpleLeadingZerosTest.cs b/src/Base58Encoding.Tests/SimpleLeadingZerosTest.cs index ff65c32..1df5cdc 100644 --- a/src/Base58Encoding.Tests/SimpleLeadingZerosTest.cs +++ b/src/Base58Encoding.Tests/SimpleLeadingZerosTest.cs @@ -27,4 +27,4 @@ public void BitcoinAddress_CountLeadingZerosMultipleWays_SameResult() Assert.Equal(simdScalarCount, manualCount); } -} \ No newline at end of file +} diff --git a/src/Base58Encoding/Base58.cs b/src/Base58Encoding/Base58.cs index 30affde..32af5f6 100644 --- a/src/Base58Encoding/Base58.cs +++ b/src/Base58Encoding/Base58.cs @@ -1,7 +1,3 @@ -using System.Buffers.Binary; -using System.Diagnostics; -using System.Runtime.CompilerServices; - namespace Base58Encoding; public partial class Base58 @@ -27,4 +23,4 @@ private Base58(Base58Alphabet alphabet) _decodeTable = alphabet.DecodeTable; _firstCharacter = alphabet.FirstCharacter; } -} \ No newline at end of file +} diff --git a/src/Base58Encoding/Base58Alphabet.cs b/src/Base58Encoding/Base58Alphabet.cs index 77884c4..bf908aa 100644 --- a/src/Base58Encoding/Base58Alphabet.cs +++ b/src/Base58Encoding/Base58Alphabet.cs @@ -76,4 +76,4 @@ private Base58Alphabet(ReadOnlyMemory characters, ReadOnlyMemory dec 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 }; -} \ No newline at end of file +} diff --git a/src/Base58Encoding/Base58BitcoinTables.cs b/src/Base58Encoding/Base58BitcoinTables.cs index 57b5bbc..b3ac9e4 100644 --- a/src/Base58Encoding/Base58BitcoinTables.cs +++ b/src/Base58Encoding/Base58BitcoinTables.cs @@ -95,4 +95,4 @@ internal static class Base58BitcoinTables [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 656356768U], [0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 1U] ]; -} \ No newline at end of file +} diff --git a/src/Base58Encoding/CountLeading.Base58.cs b/src/Base58Encoding/CountLeading.Base58.cs index 6354f84..11001bf 100644 --- a/src/Base58Encoding/CountLeading.Base58.cs +++ b/src/Base58Encoding/CountLeading.Base58.cs @@ -1,4 +1,4 @@ -using System.Numerics; +using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Intrinsics; diff --git a/src/Base58Encoding/Decode.Base58.cs b/src/Base58Encoding/Decode.Base58.cs index 3892ba3..991176f 100644 --- a/src/Base58Encoding/Decode.Base58.cs +++ b/src/Base58Encoding/Decode.Base58.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers.Binary; using System.Collections.Generic; using System.Linq; diff --git a/src/Base58Encoding/Encode.Base58.cs b/src/Base58Encoding/Encode.Base58.cs index a7a9f6b..12d6f20 100644 --- a/src/Base58Encoding/Encode.Base58.cs +++ b/src/Base58Encoding/Encode.Base58.cs @@ -1,4 +1,4 @@ -using System.Buffers.Binary; +using System.Buffers.Binary; using System.Diagnostics; namespace Base58Encoding; diff --git a/src/Base58Encoding/ThrowHelper.cs b/src/Base58Encoding/ThrowHelper.cs index 04273fa..642b310 100644 --- a/src/Base58Encoding/ThrowHelper.cs +++ b/src/Base58Encoding/ThrowHelper.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; namespace Base58Encoding; From eb1482389bd1b94db4f93734464e07f900d38b81 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Thu, 8 Jan 2026 01:20:46 +0200 Subject: [PATCH 10/13] refactor: re-name files --- .../{CountLeading.Base58.cs => Base58.CountLeading.cs} | 0 src/Base58Encoding/{Decode.Base58.cs => Base58.Decode.cs} | 0 src/Base58Encoding/{Encode.Base58.cs => Base58.Encode.cs} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename src/Base58Encoding/{CountLeading.Base58.cs => Base58.CountLeading.cs} (100%) rename src/Base58Encoding/{Decode.Base58.cs => Base58.Decode.cs} (100%) rename src/Base58Encoding/{Encode.Base58.cs => Base58.Encode.cs} (100%) diff --git a/src/Base58Encoding/CountLeading.Base58.cs b/src/Base58Encoding/Base58.CountLeading.cs similarity index 100% rename from src/Base58Encoding/CountLeading.Base58.cs rename to src/Base58Encoding/Base58.CountLeading.cs diff --git a/src/Base58Encoding/Decode.Base58.cs b/src/Base58Encoding/Base58.Decode.cs similarity index 100% rename from src/Base58Encoding/Decode.Base58.cs rename to src/Base58Encoding/Base58.Decode.cs diff --git a/src/Base58Encoding/Encode.Base58.cs b/src/Base58Encoding/Base58.Encode.cs similarity index 100% rename from src/Base58Encoding/Encode.Base58.cs rename to src/Base58Encoding/Base58.Encode.cs From 8448f1a0849087a77cf59ef49ba33f320db5405e Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Thu, 8 Jan 2026 01:36:35 +0200 Subject: [PATCH 11/13] tests: verify correctness on big-endian systems --- src/Base58Encoding.Tests/BigEndianTests.cs | 62 ++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/Base58Encoding.Tests/BigEndianTests.cs diff --git a/src/Base58Encoding.Tests/BigEndianTests.cs b/src/Base58Encoding.Tests/BigEndianTests.cs new file mode 100644 index 0000000..abaaf58 --- /dev/null +++ b/src/Base58Encoding.Tests/BigEndianTests.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; + +namespace Base58Encoding.Tests; + +public class BigEndianTests +{ + private readonly ITestOutputHelper _output; + + public BigEndianTests(ITestOutputHelper output) + { + _output = output; + } + + /// + /// Alternatively can be run manually to verify compatibility on big-endian systems using below command + /// docker run -it --rm --platform linux/s390x -v ProjectPath/src:/src registry.access.redhat.com/dotnet/sdk:10.0 sh -c "cd /src/Base58Encoding.Tests && dotnet run" + /// + [Fact(Skip = "For local usage only")] + public void Base58Encoding_Works_On_BigEndian_ViaProcess() + { + if (!BitConverter.IsLittleEndian) + { + _output.WriteLine("Skipping big-endian verification as already running on big-endian environment."); + return; + } + + var path = FindSrcPath(); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "docker", + Arguments = $"run --rm --platform linux/s390x -v {path}:/src registry.access.redhat.com/dotnet/sdk:10.0 sh -c \"cd /src/Base58Encoding.Tests && dotnet run --no-restore --no-build\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + string output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + Assert.Equal(0, process.ExitCode); + Assert.Contains("Passed!", output); + + _output.Write(output); + } + + private static string FindSrcPath() + { + var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (dir != null && dir.Name != "src" && !dir.GetFiles("*.slnx").Any()) + { + dir = dir.Parent; + } + + if (dir == null) throw new Exception("Src path not found."); + return Path.GetFullPath(dir.FullName); + } +} From 5eba6aedd6b9d435e41b409712201d122f54fc87 Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Thu, 8 Jan 2026 01:57:27 +0200 Subject: [PATCH 12/13] docs: update readme --- README.md | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7a25cc7..5dbb280 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # Base58 Encoding Library -A .NET 9.0 Base58 encoding and decoding library with support for multiple alphabet variants. +A .NET 10.0 Base58 encoding and decoding library with support for multiple alphabet variants. ## Features -- **Multiple Alphabets**: Built-in support for Bitcoin(IFPS/Sui), Ripple, and Flickr alphabets +- **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 +- **Optimized Hot Paths**: Fast fixed-length encode/decode for 32-byte and 64-byte inputs using Firedancer-like optimizations ## Usage @@ -24,9 +25,27 @@ byte[] decoded = Base58.Bitcoin.Decode(encoded); // Ripple / Flickr Base58.Ripple.Encode(data); Base58.Flickr.Encode(data); +``` + +## Performance + +The library automatically uses optimized fast paths for common fixed-size inputs: +- **32-byte inputs** (Bitcoin/Solana addresses, SHA-256 hashes): 8.5x faster encoding +- **64-byte inputs** (SHA-512 hashes): Similar performance improvements + +These optimizations are based on Firedancer's specialized Base58 algorithms and are transparent to the user. Unlike Firedancer however, we fallback to the generic approach in case of edge-cases. + +**Algorithm Details:** +- Uses **Mixed Radix Conversion (MRC)** with intermediate base 58^5 representation +- Precomputed multiplication tables replace expensive division operations +- Converts binary data to base 58^5 limbs, then to raw base58 digits +- Matrix multiplication approach processes 5 base58 digits simultaneously +- Separate encode/decode tables for 32-byte and 64-byte fixed sizes +- Achieves ~2.5x speedup through table-based optimizations vs iterative division + +**References:** +- [Firedancer C implementation](https://github.com/firedancer-io/firedancer/tree/main/src/ballet/base58) -// Custom -new Base58(Base58Alphabet.Custom("")); ``` ## Benchmarks @@ -71,4 +90,4 @@ Job=DefaultJob ## License -This project is available under the MIT License. \ No newline at end of file +This project is available under the MIT License. From 02c7b6406bd218a7c8a4a213d606550b85dd785e Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Thu, 8 Jan 2026 02:06:28 +0200 Subject: [PATCH 13/13] feat: update publish nuget file --- .github/workflows/publish-nuget.yml | 18 ++++++++++++++---- src/Base58Encoding/Base58.Decode.cs | 5 ----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 787c307..ce42848 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -22,6 +22,9 @@ on: required: false type: string +permissions: + id-token: write # required for GitHub OIDC + jobs: build: runs-on: ubuntu-latest @@ -42,7 +45,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Restore dependencies run: dotnet restore src/Base58Encoding.slnx @@ -71,6 +74,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && github.event.inputs.version != '') permissions: contents: write + id-token: write # enable GitHub OIDC token issuance for this job steps: - name: Download artifacts @@ -80,12 +84,18 @@ jobs: path: ./artifacts - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: NuGet login (OIDC → temp API key) + uses: NuGet/login@v1 + id: login with: - dotnet-version: 9.0.x + user: ${{ secrets.NUGET_USER }} - name: Publish to NuGet - run: dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} --skip-duplicate + run: dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --skip-duplicate - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/src/Base58Encoding/Base58.Decode.cs b/src/Base58Encoding/Base58.Decode.cs index 991176f..b534926 100644 --- a/src/Base58Encoding/Base58.Decode.cs +++ b/src/Base58Encoding/Base58.Decode.cs @@ -1,9 +1,4 @@ -using System; using System.Buffers.Binary; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Base58Encoding;