Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b29a501
test: skip Vector256-dependent leading-zero tests on unsupported plat…
unsafePtr Apr 22, 2026
58663d6
feat: add zero-alloc Encode/Decode buffer-writing overloads
unsafePtr Apr 22, 2026
2ba3bb7
chore: mark Base58Alphabet internal
unsafePtr Apr 22, 2026
81efd2c
perf: extract ArrayPool slow-path to *Large helpers on generic encode…
unsafePtr Apr 22, 2026
f85083f
refactor: consolidate CountLeadingCharacters into Base58.CountLeading.cs
unsafePtr Apr 22, 2026
1481241
perf: add [SkipLocalsInit] to all hot-path encode/decode methods
unsafePtr Apr 22, 2026
3a16a09
refactor: seal Base58 and Base58Alphabet, lower MaxStackallocByte to 256
unsafePtr Apr 22, 2026
caa9343
refactor: replace instance Base58 with generic Base58<TAlphabet>
unsafePtr Apr 22, 2026
f1418b5
docs: update benchmark results for .NET 10.0.7
unsafePtr Apr 22, 2026
373f761
perf: use ReadOnlySpan<byte> for Characters and FirstCharacter in IBa…
unsafePtr Apr 22, 2026
ec98703
docs: restore algorithm comments in Bitcoin fast-path decode
unsafePtr Apr 22, 2026
9d30877
style: minor cleanup in encode and benchmark files
unsafePtr Apr 22, 2026
8ac2d33
docs: document zero-allocation encode/decode API
unsafePtr Apr 22, 2026
3dc13fa
refactor: rename GetEncodedLength to GetMaxEncodedLength
unsafePtr Apr 22, 2026
8707fa9
docs: Vector128 not used anymore
unsafePtr Apr 22, 2026
dccc581
chore: migrate test project to Microsoft.Testing.Platform v2 native mode
unsafePtr Apr 22, 2026
227e177
chore: switch to xunit.v3.mtp-v2 and Microsoft.Testing.Extensions.Cod…
unsafePtr Apr 22, 2026
a5cc23d
chore: drop UseMicrosoftTestingPlatformRunner, implicit in xunit.v3.m…
unsafePtr Apr 22, 2026
c991bf2
chore: remove xunit.runner.json and its Content item, v2-only setting…
unsafePtr Apr 22, 2026
bee9ba3
docs: add PACKAGE.md as NuGet readme, separate from repo README
unsafePtr Apr 22, 2026
6b13234
chore: add solana to package tags, reformat csproj
unsafePtr Apr 22, 2026
86aa832
chore: migrate to NuGet Central Package Management
unsafePtr Apr 22, 2026
b3e3fe7
perf: optimize build times via Directory.Build.props and singular Tar…
unsafePtr Apr 22, 2026
a2dee82
chore: hoist common properties to Directory.Build.props, add repo-sco…
unsafePtr Apr 22, 2026
5d7c92e
chore: add Directory.Build.props, Directory.Packages.props, NuGet.Con…
unsafePtr Apr 22, 2026
d085d13
chore: group solution items under Solution Items folder
unsafePtr Apr 22, 2026
b198549
chore: move solution files into src/, group under Solution Items fold…
unsafePtr Apr 22, 2026
b5ba44b
docs: expand PACKAGE.md with full API reference
unsafePtr Apr 22, 2026
47414be
docs: remove custom alphabet section from PACKAGE.md
unsafePtr Apr 22, 2026
dd4b787
docs: add Monero to Bitcoin alphabet supported list
unsafePtr Apr 22, 2026
122ebb3
build: optimize MSBuild — artifacts output, SourceLink on CI only, au…
unsafePtr Apr 22, 2026
424feb7
bench: add ZeroAllocBenchmark for Encode/Decode span overloads
unsafePtr Apr 22, 2026
1a17ef2
bench: trim ZeroAllocBenchmark params to BitcoinAddress and SolanaAdd…
unsafePtr Apr 22, 2026
2e44b43
ci: fix path triggers, remove redundant negations, align setup-dotnet…
unsafePtr Apr 22, 2026
8c674ab
ci: bump setup-dotnet to v5
unsafePtr Apr 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions .github/workflows/publish-nuget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@ on:
branches: [ master, main ]
paths:
- 'src/Base58Encoding/**'
- '!src/Base58Encoding.Tests/**'
- '!src/Base58Encoding.Benchmarks/**'
- 'src/Directory.Build.props'
- 'src/Directory.Packages.props'
- 'src/NuGet.Config'
- 'src/PACKAGE.md'
- 'global.json'
pull_request:
branches: [ master, main ]
paths:
- 'src/Base58Encoding/**'
- '!src/Base58Encoding.Tests/**'
- '!src/Base58Encoding.Benchmarks/**'
- 'src/Directory.Build.props'
- 'src/Directory.Packages.props'
- 'src/NuGet.Config'
- 'src/PACKAGE.md'
- 'global.json'
workflow_dispatch:
inputs:
version:
Expand Down Expand Up @@ -43,7 +49,7 @@ jobs:
run: echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV

- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x

Expand Down
84 changes: 57 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,56 @@ A .NET 10.0 Base58 encoding and decoding library with support for multiple alpha
- **Multiple Alphabets**: Built-in support for Bitcoin(IFPS/Sui/Solana), Ripple, and Flickr alphabets
- **Memory Efficient**: Uses stackalloc operations when possible to minimize allocations
- **Type Safe**: Leverages ReadOnlySpan and ReadOnlyMemory for safe memory operations
- **Intrinsics**: Uses SIMD `Vector128/Vector256` and unrolled loop for counting leading zeros
- **Intrinsics**: Uses SIMD `Vector256` and unrolled loop for counting leading zeros
- **Optimized Hot Paths**: Fast fixed-length encode/decode for 32-byte and 64-byte inputs using Firedancer-like optimizations

## Usage

### Allocating API

```csharp
using Base58Encoding;

// Encode bytes to Base58 Bitcoin(IFPS/Sui) alphabet
// Encode bytes to Base58 string (Bitcoin / IPFS / Sui / Solana alphabet)
byte[] data = { 0x01, 0x02, 0x03, 0x04 };
string encoded = Base58.Bitcoin.Encode(data);

// Decode Base58 string back to bytes
byte[] decoded = Base58.Bitcoin.Decode(encoded);

// Ripple / Flickr
// Ripple / Flickr alphabets
Base58.Ripple.Encode(data);
Base58.Flickr.Encode(data);
```

### Zero-allocation API

Encode or decode directly into a caller-owned buffer — no heap allocations on the hot path.

```csharp
using Base58Encoding;

byte[] data = { 0x01, 0x02, 0x03, 0x04 };

// Size the output buffer using the helper
int maxLen = Base58.GetMaxEncodedLength(data.Length);
Span<byte> encodedBytes = stackalloc byte[maxLen]; // or rent from ArrayPool

int written = Base58.Bitcoin.Encode(data, encodedBytes);
ReadOnlySpan<byte> result = encodedBytes[..written]; // ASCII bytes

// Decode from a char span or ASCII byte span into a caller-owned buffer
Span<byte> decodedBytes = stackalloc byte[Base58.GetTypicalDecodedLength(written)];
int decodedLen = Base58.Bitcoin.Decode(result, decodedBytes);

// Both Decode overloads are supported:
// int Decode(ReadOnlySpan<char> encoded, Span<byte> destination)
// int Decode(ReadOnlySpan<byte> encoded, Span<byte> destination)
```

`GetMaxEncodedLength(byteCount)` returns a safe upper bound for the encoded output size.
`GetTypicalDecodedLength(encodedLength)` returns a typical upper bound for decoded output (see its XML doc for the edge case around leading `'1'` characters).

## Performance

The library automatically uses optimized fast paths for common fixed-size inputs:
Expand All @@ -51,41 +81,41 @@ These optimizations are based on Firedancer's specialized Base58 algorithms and

```

BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7462/25H2/2025Update/HudsonValley2)
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8246/25H2/2025Update/HudsonValley2)
13th Gen Intel Core i7-13700KF 3.40GHz, 1 CPU, 24 logical and 16 physical cores
.NET SDK 10.0.101
[Host] : .NET 10.0.1 (10.0.1, 10.0.125.57005), X64 RyuJIT x86-64-v3
DefaultJob : .NET 10.0.1 (10.0.1, 10.0.125.57005), X64 RyuJIT x86-64-v3
.NET SDK 10.0.203
[Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3
DefaultJob : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3

Job=DefaultJob

```
| Method | VectorType | Mean | Ratio | Gen0 | Allocated | Alloc Ratio |
|--------------------------- |--------------- |------------:|------:|-------:|----------:|------------:|
| **&#39;Our Base58 Encode&#39;** | **BitcoinAddress** | **537.07 ns** | **1.00** | **0.0057** | **96 B** | **1.00** |
| &#39;SimpleBase Base58 Encode&#39; | BitcoinAddress | 782.31 ns | 1.46 | 0.0057 | 96 B | 1.00 |
| &#39;Our Base58 Decode&#39; | BitcoinAddress | 168.95 ns | 0.31 | 0.0033 | 56 B | 0.58 |
| &#39;SimpleBase Base58 Decode&#39; | BitcoinAddress | 352.63 ns | 0.66 | 0.0033 | 56 B | 0.58 |
| **&#39;Our Base58 Encode&#39;** | **BitcoinAddress** | **537.17 ns** | **1.00** | **0.0057** | **96 B** | **1.00** |
| &#39;SimpleBase Base58 Encode&#39; | BitcoinAddress | 776.69 ns | 1.45 | 0.0057 | 96 B | 1.00 |
| &#39;Our Base58 Decode&#39; | BitcoinAddress | 160.88 ns | 0.30 | 0.0033 | 56 B | 0.58 |
| &#39;SimpleBase Base58 Decode&#39; | BitcoinAddress | 353.19 ns | 0.66 | 0.0033 | 56 B | 0.58 |
| | | | | | | |
| **&#39;Our Base58 Encode&#39;** | **SolanaAddress** | **93.41 ns** | **1.00** | **0.0070** | **112 B** | **1.00** |
| &#39;SimpleBase Base58 Encode&#39; | SolanaAddress | 1,430.37 ns | 15.31 | 0.0057 | 112 B | 1.00 |
| &#39;Our Base58 Decode&#39; | SolanaAddress | 181.71 ns | 1.95 | 0.0035 | 56 B | 0.50 |
| &#39;SimpleBase Base58 Decode&#39; | SolanaAddress | 837.03 ns | 8.96 | 0.0019 | 56 B | 0.50 |
| **&#39;Our Base58 Encode&#39;** | **SolanaAddress** | **94.07 ns** | **1.00** | **0.0070** | **112 B** | **1.00** |
| &#39;SimpleBase Base58 Encode&#39; | SolanaAddress | 1,433.92 ns | 15.24 | 0.0057 | 112 B | 1.00 |
| &#39;Our Base58 Decode&#39; | SolanaAddress | 104.19 ns | 1.11 | 0.0035 | 56 B | 0.50 |
| &#39;SimpleBase Base58 Decode&#39; | SolanaAddress | 703.66 ns | 7.48 | 0.0029 | 56 B | 0.50 |
| | | | | | | |
| **&#39;Our Base58 Encode&#39;** | **SolanaTx** | **252.31 ns** | **1.00** | **0.0124** | **200 B** | **1.00** |
| &#39;SimpleBase Base58 Encode&#39; | SolanaTx | 7,247.09 ns | 28.73 | 0.0076 | 200 B | 1.00 |
| &#39;Our Base58 Decode&#39; | SolanaTx | 178.05 ns | 0.71 | 0.0055 | 88 B | 0.44 |
| &#39;SimpleBase Base58 Decode&#39; | SolanaTx | 2,379.54 ns | 9.43 | 0.0038 | 88 B | 0.44 |
| **&#39;Our Base58 Encode&#39;** | **SolanaTx** | **239.21 ns** | **1.00** | **0.0124** | **200 B** | **1.00** |
| &#39;SimpleBase Base58 Encode&#39; | SolanaTx | 7,166.10 ns | 29.96 | 0.0076 | 200 B | 1.00 |
| &#39;Our Base58 Decode&#39; | SolanaTx | 180.37 ns | 0.75 | 0.0055 | 88 B | 0.44 |
| &#39;SimpleBase Base58 Decode&#39; | SolanaTx | 2,957.77 ns | 12.36 | 0.0038 | 88 B | 0.44 |
| | | | | | | |
| **&#39;Our Base58 Encode&#39;** | **IPFSHash** | **1,096.58 ns** | **1.00** | **0.0076** | **120 B** | **1.00** |
| &#39;SimpleBase Base58 Encode&#39; | IPFSHash | 1,644.83 ns | 1.50 | 0.0076 | 120 B | 1.00 |
| &#39;Our Base58 Decode&#39; | IPFSHash | 287.87 ns | 0.26 | 0.0038 | 64 B | 0.53 |
| &#39;SimpleBase Base58 Decode&#39; | IPFSHash | 643.63 ns | 0.59 | 0.0038 | 64 B | 0.53 |
| **&#39;Our Base58 Encode&#39;** | **IPFSHash** | **1,084.69 ns** | **1.00** | **0.0076** | **120 B** | **1.00** |
| &#39;SimpleBase Base58 Encode&#39; | IPFSHash | 1,617.11 ns | 1.49 | 0.0076 | 120 B | 1.00 |
| &#39;Our Base58 Decode&#39; | IPFSHash | 318.15 ns | 0.29 | 0.0038 | 64 B | 0.53 |
| &#39;SimpleBase Base58 Decode&#39; | IPFSHash | 854.47 ns | 0.79 | 0.0038 | 64 B | 0.53 |
| | | | | | | |
| **&#39;Our Base58 Encode&#39;** | **MoneroAddress** | **4,998.35 ns** | **1.00** | **0.0076** | **216 B** | **1.00** |
| &#39;SimpleBase Base58 Encode&#39; | MoneroAddress | 8,585.92 ns | 1.72 | - | 216 B | 1.00 |
| &#39;Our Base58 Decode&#39; | MoneroAddress | 1,173.48 ns | 0.23 | 0.0057 | 96 B | 0.44 |
| &#39;SimpleBase Base58 Decode&#39; | MoneroAddress | 3,716.38 ns | 0.74 | 0.0038 | 96 B | 0.44 |
| **&#39;Our Base58 Encode&#39;** | **MoneroAddress** | **4,917.65 ns** | **1.00** | **0.0076** | **216 B** | **1.00** |
| &#39;SimpleBase Base58 Encode&#39; | MoneroAddress | 8,621.98 ns | 1.75 | - | 216 B | 1.00 |
| &#39;Our Base58 Decode&#39; | MoneroAddress | 1,198.92 ns | 0.24 | 0.0057 | 96 B | 0.44 |
| &#39;SimpleBase Base58 Decode&#39; | MoneroAddress | 3,844.43 ns | 0.78 | - | 96 B | 0.44 |


## License
Expand Down
5 changes: 5 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,11 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
<PackageReference Include="SimpleBase" Version="5.6.0" />
<PackageReference Include="BenchmarkDotNet" />
<PackageReference Include="SimpleBase" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ public class JaggedVsMultidimensionalArrayBenchmark
private static readonly uint[,] MultidimensionalEncodeTable32 = ConvertToMultidimensional(Base58BitcoinTables.EncodeTable32);
private static readonly uint[,] MultidimensionalDecodeTable32 = ConvertToMultidimensional(Base58BitcoinTables.DecodeTable32);

private readonly ref struct FastEncodeState
{
public readonly ReadOnlySpan<byte> RawBase58;
public readonly int InLeadingZeros;
public readonly int RawLeadingZeros;
public readonly int OutputLength;

public FastEncodeState(ReadOnlySpan<byte> rawBase58, int inLeadingZeros, int rawLeadingZeros, int outputLength)
{
RawBase58 = rawBase58;
InLeadingZeros = inLeadingZeros;
RawLeadingZeros = rawLeadingZeros;
OutputLength = outputLength;
}
}

[GlobalSetup]
public void Setup()
{
Expand Down Expand Up @@ -131,7 +147,7 @@ private static string EncodeBitcoin32FastJagged(ReadOnlySpan<byte> data)
// Calculate skip and final length
int skip = rawLeadingZeros - inLeadingZeros;
int outputLength = Base58BitcoinTables.Raw58Sz32 - skip;
var state = new Base58.EncodeFastState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength);
var state = new FastEncodeState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength);
return string.Create(outputLength, state, static (span, state) =>
{
if (state.InLeadingZeros > 0)
Expand All @@ -143,7 +159,7 @@ private static string EncodeBitcoin32FastJagged(ReadOnlySpan<byte> data)
for (int i = 0; i < state.OutputLength - state.InLeadingZeros; i++)
{
byte digit = state.RawBase58[state.RawLeadingZeros + i];
span[state.InLeadingZeros + i] = bitcoinChars[digit];
span[state.InLeadingZeros + i] = (char)bitcoinChars[digit];
}
});
}
Expand Down Expand Up @@ -208,7 +224,7 @@ private static string EncodeBitcoin32FastMultidimensional(ReadOnlySpan<byte> dat
int skip = rawLeadingZeros - inLeadingZeros;
int outputLength = Base58BitcoinTables.Raw58Sz32 - skip;

var state = new Base58.EncodeFastState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength);
var state = new FastEncodeState(rawBase58, inLeadingZeros, rawLeadingZeros, outputLength);
return string.Create(outputLength, state, static (span, state) =>
{
if (state.InLeadingZeros > 0)
Expand All @@ -220,7 +236,7 @@ private static string EncodeBitcoin32FastMultidimensional(ReadOnlySpan<byte> dat
for (int i = 0; i < state.OutputLength - state.InLeadingZeros; i++)
{
byte digit = state.RawBase58[state.RawLeadingZeros + i];
span[state.InLeadingZeros + i] = bitcoinChars[digit];
span[state.InLeadingZeros + i] = (char)bitcoinChars[digit];
}
});
}
Expand All @@ -232,7 +248,7 @@ private static string EncodeBitcoin32FastMultidimensional(ReadOnlySpan<byte> dat

// Validate characters and create raw array using JAGGED ARRAY lookup
Span<byte> rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32];
var bitcoinDecodeTable = Base58Alphabet.Bitcoin.DecodeTable.Span;
var bitcoinDecodeTable = BitcoinAlphabet.DecodeTable;

int prepend0 = Base58BitcoinTables.Raw58Sz32 - encoded.Length;
for (int j = 0; j < Base58BitcoinTables.Raw58Sz32; j++)
Expand Down Expand Up @@ -309,7 +325,7 @@ private static string EncodeBitcoin32FastMultidimensional(ReadOnlySpan<byte> dat

// Validate characters and create raw array using MULTIDIMENSIONAL ARRAY lookup
Span<byte> rawBase58 = stackalloc byte[Base58BitcoinTables.Raw58Sz32];
var bitcoinDecodeTable = Base58Alphabet.Bitcoin.DecodeTable.Span;
var bitcoinDecodeTable = BitcoinAlphabet.DecodeTable;

int prepend0 = Base58BitcoinTables.Raw58Sz32 - encoded.Length;
for (int j = 0; j < Base58BitcoinTables.Raw58Sz32; j++)
Expand Down
37 changes: 37 additions & 0 deletions src/Base58Encoding.Benchmarks/ZeroAllocBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Base58Encoding.Benchmarks.Common;

using BenchmarkDotNet.Attributes;

namespace Base58Encoding.Benchmarks;

[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class ZeroAllocBenchmark
{
private byte[] _testData = null!;
private byte[] _encodedBytes = null!;
private byte[] _encodeDest = null!;
private byte[] _decodeDest = null!;

[Params(
TestVectors.VectorType.BitcoinAddress,
TestVectors.VectorType.SolanaAddress
)]
public TestVectors.VectorType VectorType { get; set; }

[GlobalSetup]
public void Setup()
{
_testData = TestVectors.GetVector(VectorType);
_encodeDest = new byte[Base58.GetMaxEncodedLength(_testData.Length)];
int written = Base58.Bitcoin.Encode(_testData, _encodeDest);
_encodedBytes = _encodeDest[..written];
_decodeDest = new byte[_testData.Length];
}

[Benchmark]
public int Encode() => Base58.Bitcoin.Encode(_testData, _encodeDest);

[Benchmark]
public int Decode() => Base58.Bitcoin.Decode(_encodedBytes, _decodeDest);
}
21 changes: 3 additions & 18 deletions src/Base58Encoding.Tests/Base58Encoding.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,32 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Base58Encoding.Tests</RootNamespace>
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="SimpleBase" Version="5.6.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PackageReference Include="SimpleBase" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.1" />
</ItemGroup>

<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
<PackageReference Include="xunit.v3.mtp-v2" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading