Skip to content

Base58.Bitcoin.Decode drops the most-significant byte for certain input lengths (round-trip failure) #83

Description

@unsafePtr

Summary

Base58.Bitcoin.Decode(Base58.Bitcoin.Encode(x)) does not round-trip for some inputs: the decoded array comes back one byte short, missing the leading (most-significant) byte. Encode is correct — the failure is in Decode, which appears to under-allocate its output buffer by one byte when the decoded length lands on a size-estimate boundary.

Environment

  • SimpleBase 5.6.2 (also reproduces on 5.6.0)
  • .NET 10.0.9, x64
  • Windows 11

Minimal reproduction

dotnet new console -o SimpleBaseRepro
cd SimpleBaseRepro
dotnet add package SimpleBase --version 5.6.2
# replace Program.cs with the code below, then:
dotnet run
using SimpleBase;

// Value = 256^134 — the smallest 135-byte integer: 0x01 followed by 134 zero bytes.
byte[] input = new byte[135];
input[0] = 1;

string encoded = Base58.Bitcoin.Encode(input);
byte[] decoded = Base58.Bitcoin.Decode(encoded);

Console.WriteLine($"input length    : {input.Length}");
Console.WriteLine($"encoded length  : {encoded.Length}");
Console.WriteLine($"decoded length  : {decoded.Length}");
Console.WriteLine($"round-trips     : {decoded.AsSpan().SequenceEqual(input)}");

Expected output

input length    : 135
encoded length  : 183
decoded length  : 135
round-trips     : True

Actual output

input length    : 135
encoded length  : 183
decoded length  : 134      <-- one byte short
round-trips     : False    <-- leading 0x01 dropped

The decoded array is 134 bytes of all zeros; the leading 0x01 is gone.

Decode-only reproduction

The encoding is correct, so the bug can also be shown by decoding the string directly:

using SimpleBase;

const string encoded =
    "zaZ9rvaCSMeX4tS1qfMDz5EUZd6uPZCCxRfbCsps3ozwjAUCfxioFEwfH8jECXa66kNrJu6Ntm6Gy1jq45DFasDNCoDZC5VpFGgJ27SpdZ8kmPyjAaynZ4538PvNhei1bqMbo9vnfG7fNtuLKMy9Ee7p61vdazm5WwwUGSyTawfkFemsUjUbLwR";

byte[] decoded = Base58.Bitcoin.Decode(encoded);
Console.WriteLine(decoded.Length); // 134, should be 135

It is periodic

The same construction (x[0] = 1, rest zero) fails at lengths 135, 176, 217, … — a period of +41 bytes:

using SimpleBase;

for (int len = 1; len <= 256; len++)
{
    var x = new byte[len];
    x[0] = 1;
    var y = Base58.Bitcoin.Decode(Base58.Bitcoin.Encode(x));
    if (!y.AsSpan().SequenceEqual(x))
        Console.WriteLine($"FAIL at length {len}: decoded {y.Length} bytes (expected {len})");
}
FAIL at length 135: decoded 134 bytes (expected 135)
FAIL at length 176: decoded 175 bytes (expected 176)
FAIL at length 217: decoded 216 bytes (expected 217)

Only the smallest value at each of these lengths (i.e. 256^(len-1)) triggers it — larger values at the same length decode fine — which is why random round-trip testing rarely hits it.

Cross-verification (the encoding is correct)

The produced Base58 string was checked against two independent implementations, both of which agree with SimpleBase's Encode and decode the string back to the full 135 bytes:

  • A BigInteger reference implementation (repeated divide-by-58).
  • The Python base58 package (base58.b58decode(encoded) → 135 bytes, equal to the input).

So the string is a valid encoding of a 135-byte value; only SimpleBase's Decode truncates it.

Likely cause

Decode estimates the output byte count from the encoded string length and appears to under-allocate by one for encoded lengths that sit exactly on the estimate boundary (the shortest encodings for a given byte length). The high-order byte is then dropped.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions