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.
// Base58 alphabet - 58 unambiguous characters
static const char* BASE58_ALPHABET =
"123456789" // Digits (no 0)
"ABCDEFGHJKLMNPQRSTUVWXYZ" // Uppercase (no I, O)
"abcdefghijkmnopqrstuvwxyz"; // Lowercase (no l)
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
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
// 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;
}
// Special case: preserve leading zero bytes as '1' characters
for (uint8_t byte : input) {
if (byte == 0)
result = '1' + result;
else
break;
}
// 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;
}
// 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);
}
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'
};
Type 0 (AccountID) → starts with 'r'
Type 33 (FamilySeed) → starts with 's'
Type 28 (NodePublic) → starts with 'n'
Type 35 (AccountPublic) → starts with 'a'
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);
}
Probability of random error passing checksum:
1 / 2^32 = 1 / 4,294,967,296
Approximately: 1 in 4.3 billion
// 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
// 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