Base58Check Encoding

Introduction

Cryptographic data is fundamentally binary—sequences of bytes with values from 0 to 255. But humans don't work well with binary data. We mistype it, confuse similar characters, and struggle to verify it. Base58Check encoding solves this problem by converting binary data into human-friendly strings that are easier to read, type, and verify.

This chapter explores how XRPL uses Base58Check encoding to create readable addresses, why certain characters are excluded, and how checksums provide error detection.

The Problem with Raw Binary

###Consider an account ID in different formats:

Binary (20 bytes):
10001011 10001010 01101100 01010011 00111111 ...

Hexadecimal:
8B8A6C533F09CA0E5E00E7C32AA7EC323485ED3F

Base58Check:
rN7n7otQDd6FczFgLdlqtyMVrn3LNU8B4C

Problems with hex:

  • Easy to mistype: 8B8A vs 8B8B

  • Visually similar characters: 0 (zero) vs O (letter O)

  • No error detection: One wrong character, wrong address

  • Not compact: 40 characters for 20 bytes

Base58Check solutions:

  • Excludes confusing characters

  • Includes checksum (detects errors)

  • More compact: 34 characters for 20 bytes + checksum

  • URL-safe (no special characters)

The Base58 Alphabet

// Base58 alphabet - 58 unambiguous characters
static const char* BASE58_ALPHABET =
    "123456789"                    // Digits (no 0)
    "ABCDEFGHJKLMNPQRSTUVWXYZ"    // Uppercase (no I, O)
    "abcdefghijkmnopqrstuvwxyz";  // Lowercase (no l)

Excluded characters:

0 (zero)        - Looks like O (letter O)
O (letter O)    - Looks like 0 (zero)
I (letter I)    - Looks like l (lowercase L) or 1
l (lowercase L) - Looks like I (letter I) or 1

These exclusions prevent human transcription errors.

Included: 58 characters

Digits:     1 2 3 4 5 6 7 8 9 (9 characters)
Uppercase:  A B C D E F G H J K L M N P Q R S T U V W X Y Z (24 characters)
Lowercase:  a b c d e f g h i j k m n o p q r s t u v w x y z (25 characters)
Total:      58 characters

Base58 Encoding Algorithm

Base58 is like converting a number to a different base (like hexadecimal is base 16):

Decimal:    255 = 2×100 + 5×10 + 5×1
Hex:        FF  = 15×16 + 15×1
Base58:     4k  = 4×58 + 45×1

The Mathematics

// Conceptually: treat byte array as big integer
std::vector<uint8_t> input = {0x8B, 0x8A, ...};

// Convert to big integer
BigInt value = 0;
for (uint8_t byte : input)
    value = value * 256 + byte;

// Convert to base58
std::string result;
while (value > 0) {
    int remainder = value % 58;
    result = BASE58_ALPHABET[remainder] + result;
    value = value / 58;
}

Handling Leading Zeros

// Special case: preserve leading zero bytes as '1' characters
for (uint8_t byte : input) {
    if (byte == 0)
        result = '1' + result;
    else
        break;
}

This ensures the encoding is one-to-one: every distinct byte sequence produces a distinct string.

Implementation

// From src/libxrpl/protocol/tokens.cpp (simplified)
std::string base58Encode(std::vector<uint8_t> const& input)
{
    // Skip leading zeros, but count them
    int leadingZeros = 0;
    for (auto byte : input) {
        if (byte == 0)
            ++leadingZeros;
        else
            break;
    }

    // Allocate output buffer (worst case size)
    std::vector<uint8_t> b58(input.size() * 138 / 100 + 1);

    // Process the bytes
    for (auto byte : input) {
        int carry = byte;
        for (auto it = b58.rbegin(); it != b58.rend(); ++it) {
            carry += 256 * (*it);
            *it = carry % 58;
            carry /= 58;
        }
    }

    // Convert to string, skipping leading zeros in b58
    std::string result;
    for (int i = 0; i < leadingZeros; ++i)
        result += '1';

    for (auto value : b58) {
        if (value != 0 || !result.empty())
            result += BASE58_ALPHABET[value];
    }

    return result.empty() ? "1" : result;
}

Base58Check: Adding Error Detection

Base58 alone doesn't detect errors. Base58Check adds a checksum:

Structure:
[Type Byte] [Payload] [Checksum (4 bytes)]
     ↓          ↓           ↓
   0x00     20 bytes    SHA256(SHA256(prefix + payload))

Encoding Process

// From src/libxrpl/protocol/tokens.cpp
std::string encodeBase58Token(
    TokenType type,
    void const* token,
    std::size_t size)
{
    std::vector<uint8_t> buffer;
    buffer.reserve(1 + size + 4);

    // Step 1: Add type prefix
    buffer.push_back(static_cast<uint8_t>(type));

    // Step 2: Add payload
    auto const* tokenBytes = static_cast<uint8_t const*>(token);
    buffer.insert(buffer.end(), tokenBytes, tokenBytes + size);

    // Step 3: Compute checksum
    // First SHA-256
    auto const hash1 = sha256(makeSlice(buffer));
    // Second SHA-256
    auto const hash2 = sha256(makeSlice(hash1));

    // Step 4: Append first 4 bytes of second hash as checksum
    buffer.insert(buffer.end(), hash2.begin(), hash2.begin() + 4);

    // Step 5: Base58 encode everything
    return base58Encode(buffer);
}

Token Types

enum class TokenType : std::uint8_t {
    None            = 1,
    NodePublic      = 28,   // Node public keys:  starts with 'n'
    NodePrivate     = 32,   // Node private keys
    AccountID       = 0,    // Account addresses: starts with 'r'
    AccountPublic   = 35,   // Account public keys: starts with 'a'
    AccountSecret   = 34,   // Account secret keys (deprecated)
    FamilySeed      = 33,   // Seeds: starts with 's'
};

The type byte determines the first character of the encoded result:

Type 0  (AccountID)     → starts with 'r'
Type 33 (FamilySeed)    → starts with 's'
Type 28 (NodePublic)    → starts with 'n'
Type 35 (AccountPublic) → starts with 'a'

This provides visual identification of what kind of data you're looking at.

Decoding and Validation

std::string decodeBase58Token(
    std::string const& s,
    TokenType type)
{
    // Step 1: Decode from Base58
    auto const decoded = base58Decode(s);
    if (decoded.empty())
        return {};  // Invalid Base58

    // Step 2: Check minimum size (type + checksum = 5 bytes minimum)
    if (decoded.size() < 5)
        return {};

    // Step 3: Verify type byte matches
    if (decoded[0] != static_cast<uint8_t>(type))
        return {};  // Wrong type

    // Step 4: Verify checksum
    auto const dataEnd = decoded.end() - 4;  // Last 4 bytes are checksum
    auto const providedChecksum = Slice{dataEnd, decoded.end()};

    // Recompute checksum
    auto const hash1 = sha256(makeSlice(decoded.begin(), dataEnd));
    auto const hash2 = sha256(makeSlice(hash1));
    auto const computedChecksum = Slice{hash2.begin(), hash2.begin() + 4};

    // Compare
    if (!std::equal(
            providedChecksum.begin(),
            providedChecksum.end(),
            computedChecksum.begin()))
        return {};  // Checksum mismatch

    // Step 5: Return payload (skip type byte and checksum)
    return std::string(decoded.begin() + 1, dataEnd);
}

Error Detection

The 4-byte (32-bit) checksum provides strong error detection:

Probability of random error passing checksum:
1 / 2^32 = 1 / 4,294,967,296

Approximately: 1 in 4.3 billion

Types of errors detected:

  • Single character typos: 100%

  • Transpositions: 100%

  • Missing characters: 100%

  • Extra characters: 100%

  • Random corruption: 99.9999999767%

Complete Example: Account Address

// Start with public key
PublicKey pk = /* ed25519 public key */;
// ED9434799226374926EDA3B54B1B461B4ABF7237962EEB1144C10A7CA6A9D32C64

// Step 1: Calculate account ID (RIPESHA hash)
AccountID accountID = calcAccountID(pk);
// 8B8A6C533F09CA0E5E00E7C32AA7EC323485ED3F (20 bytes)

// Step 2: Encode as Base58Check address
std::string address = toBase58(accountID);
// rN7n7otQDd6FczFgLdlqtyMVrn3LNU8B4C

// Encoding breakdown:
// 1. Prepend type byte 0x00
//    008B8A6C533F09CA0E5E00E7C32AA7EC323485ED3F
//
// 2. Compute checksum:
//    SHA-256: 7C9B2F8F...
//    SHA-256: 3D4B8E9C...
//    Take first 4 bytes: 3D4B8E9C
//
// 3. Append checksum:
//    008B8A6C533F09CA0E5E00E7C32AA7EC323485ED3F3D4B8E9C
//
// 4. Base58 encode:
//    rN7n7otQDd6FczFgLdlqtyMVrn3LNU8B4C

Seeds and Human Readability

Seeds can be encoded in two formats:

Base58Check Format

Seed seed = generateRandomSeed();
std::string b58 = toBase58(seed);
// Example: sp5fghtJtpUorTwvof1NpDXAzNwf5

Properties:

  • Compact (25-28 characters)

  • Checksum for error detection

  • Safe to copy-paste

RFC 1751 Word Format

std::string words = seedAs1751(seed);
// Example: "MAD WARM EVEN SHOW BALK FELT TOY STIR OBOE COST HOPE VAIN"

Properties:

  • 12 words from a dictionary

  • Easier to write down by hand

  • Easier to read aloud (for backup)

  • Checksum built into last word

Practical Usage

Creating an Account

// Generate key pair
auto [publicKey, secretKey] = randomKeyPair(KeyType::ed25519);

// Derive account ID
AccountID accountID = calcAccountID(publicKey);

// Encode as address
std::string address = toBase58(accountID);

std::cout << "Your XRPL address: " << address << "\n";
// Your XRPL address: rN7n7otQDd6FczFgLdlqtyMVrn3LNU8B4C

Validating User Input

bool isValidAddress(std::string const& address)
{
    // Try to decode
    auto decoded = decodeBase58Token(address, TokenType::AccountID);

    // Valid if:
    // 1. Decoding succeeded
    // 2. Payload is correct size (20 bytes)
    return !decoded.empty() && decoded.size() == 20;
}

// Usage
if (!isValidAddress(userInput)) {
    std::cerr << "Invalid XRPL address\n";
    return;
}

Parsing Different Token Types

std::optional<PublicKey> parsePublicKey(std::string const& s)
{
    // Try AccountPublic type (starts with 'a')
    if (s[0] == 'a') {
        auto decoded = decodeBase58Token(s, TokenType::AccountPublic);
        if (!decoded.empty())
            return PublicKey{makeSlice(decoded)};
    }

    // Try NodePublic type (starts with 'n')
    if (s[0] == 'n') {
        auto decoded = decodeBase58Token(s, TokenType::NodePublic);
        if (!decoded.empty())
            return PublicKey{makeSlice(decoded)};
    }

    return std::nullopt;  // Invalid
}

Comparison with Other Encodings

Encoding
Characters
Case-Sensitive
Checksum
Compact
URL-Safe

Hex

16

No

No

No (2×)

Yes

Base64

64

Yes

No

Yes (1.33×)

No (+, /)

Base58

58

Yes

No

Yes (1.37×)

Yes

Base58Check

58

Yes

Yes (4 bytes)

Yes (1.37×)

Yes

Base58Check wins for:

  • Human readability (no confusing characters)

  • Error detection (checksum)

  • URL safety (no special characters)

  • Blockchain addresses

Common Pitfalls

❌ Typos Without Validation

// User types address wrong
std::string userAddress = "rN7n7otQDd6FczFgLdlqtyMVrn3LNU8B4D";  // Last char wrong

// Send funds without validation
sendPayment(userAddress, amount);  // WRONG ADDRESS!

Solution:

if (!isValidAddress(userAddress)) {
    throw std::runtime_error("Invalid address - check for typos");
}

❌ Assuming All Addresses Start with 'r'

// ❌ WRONG
bool isAddress(std::string const& s) {
    return s[0] == 'r';  // Too simplistic
}

Solution:

// ✅ CORRECT
bool isAddress(std::string const& s) {
    return !decodeBase58Token(s, TokenType::AccountID).empty();
}

❌ Manual Base58 Implementation

// ❌ WRONG - Don't implement yourself
std::string myBase58Encode(/* ... */) {
    // Custom implementation - likely has bugs
}

Solution:

// ✅ CORRECT - Use library functions
std::string encoded = encodeBase58Token(type, data, size);

Performance Considerations

// Base58 encoding is relatively slow compared to hex:
// Hex encoding:     ~1 microsecond
// Base58 encoding:  ~10 microseconds

// But this doesn't matter for user-facing operations:
// - Displaying addresses: once per UI render
// - Parsing user input: once per input
// - Not a bottleneck in practice

When performance matters:

// For internal storage and processing, use binary:
AccountID accountID;  // 20 bytes, fast comparisons

// Only encode to Base58 when presenting to users:
std::string address = toBase58(accountID);  // For display only

Summary

Base58Check encoding makes binary cryptographic data human-friendly:

  1. Excludes confusing characters: No 0, O, I, l

  2. Includes checksum: 4-byte SHA-256(SHA-256(...)) checksum

  3. Type prefixes: Different first characters for different data types

  4. Error detection: ~99.9999999767% of errors detected

  5. URL-safe: No special characters

  6. Compact: ~37% overhead vs 100% for hex

Usage in XRPL:

  • Account addresses: starts with 'r'

  • Seeds: starts with 's'

  • Public keys: starts with 'a' or 'n'

Best practices:

  • Always validate before using

  • Use library functions, don't implement yourself

  • Store binary internally, encode only for display

  • Provide clear error messages for invalid input

In the next chapter, we'll explore how cryptography secures peer-to-peer communication in the XRPL network.

Last updated