One Wrong Character: How Bunker Catches Typos in Crypto Addresses

A crypto address is a long string of characters that look random. People copy it, read it aloud and type it by hand. One wrong character is easy to miss.

For each address, Bunker gives advice: move the funds, keep them, or ask a question. If a typo got a rating, that advice would be about an address that does not exist. It could also name the wrong network.

So Bunker follows one strict rule. A typo never gets a rating. In this article, I show how the code enforces that rule, how a test proves it, and what the test found.


What a checksum is

A checksum is a set of extra characters that lets software find a typo. The software computes the checksum again from the rest of the text. If the two values differ, the text has an error.

Here is a simple example with digits. Take the number 4721. Add its digits: 4 + 7 + 2 + 1 = 14. Keep the last digit, 4, and write the number as 4721-4.

Now someone types 4731-4, with a wrong third digit. The digits add up to 15, so the check digit must be 5. The stored check digit is 4, so the software knows that the number is wrong.

Real address formats use stronger math, such as hash functions and error-detecting codes. The idea stays the same. The decision record for checksums lists 13 methods that Bunker verifies:

EncodingChecksumUsed by, for example
Base58CheckDouble SHA-256Bitcoin legacy, Tron, Dogecoin
Bech32, Bech32mBCH codeBitcoin SegWit and Taproot, Cosmos, Cardano
Blech3212-character BCH codeLiquid confidential
CashAddr40-bit BCH codeBitcoin Cash, eCash, Kaspa
EIP-55Keccak-256 in the capital lettersEthereum and every EVM network
SS58BLAKE2b-512Polkadot, Kusama, Bittensor
Monero Base58Keccak-256Monero
c32checkDouble SHA-256Stacks
FilecoinBLAKE2b-32Filecoin
ICPCRC-32Internet Computer
Stellar, TONCRC-16/XMODEMStellar, TON
AlgorandSHA-512/256Algorand
XRPBase58Check with the Ripple alphabetXRP Ledger

Bunker has its own code for each method, with no outside library. The tests compare SHA-256, SHA-512 and BLAKE2b with the crypto library of Node.js. The other functions use published test values.

Why Bunker verifies every checksum

Every important choice in Bunker has a decision record. A decision record lists the options, the choice and the reasons. Decision record ADR-009 compares three ways to handle addresses:

OptionForAgainst
A. Shape only (regular expressions)Little codeA typo gets a rating; the user may send funds to a wrong address later
B. An external validation serviceNo codeSends addresses away
C. Verify each checksum on the deviceTypos show as typosMore code: one decoder for each encoding

I chose option C, for every format that has a checksum. The record gives the reasons:

From docs/decisions/ADR-009-checksums.md:

The plan tells people to move funds. A wrong address in the plan is dangerous.

The typo test found two real gaps (P-19 and P-20). It proves that this rule works.

Option B also conflicts with ADR-001, the first decision of the project. Bunker sends no address to any server.

Example 1: Base58Check, the checksum of Bitcoin legacy addresses

A Bitcoin legacy address, such as the genesis address 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa, uses Base58Check. The function in src/core/base58.js has seven lines:

export function base58CheckDecode(text) {
  const raw = base58Decode(text);
  if (!raw || raw.length < 5) return null;
  const payload = raw.slice(0, -4);
  if (!bytesEqual(sha256d(payload).slice(0, 4), raw.slice(-4))) return null;
  return payload;
}

The function works in four steps:

  1. It turns the text into bytes. Base58 is an alphabet of 58 characters without 0, O, I and l.
  2. It splits the bytes. The last 4 bytes are the stored checksum. The rest is the payload.
  3. It hashes the payload twice with SHA-256. The helper sha256d does this in one line: sha256(sha256(message)).
  4. It compares the first 4 bytes of that hash with the stored 4 bytes. If they differ, it returns null, which means “not valid”.

For a Bitcoin address, the bytes are 25 in total: one version byte, a 20-byte hash, and the 4-byte checksum. The version byte tells the network and the address type.

Four bytes are 32 bits. A random change matches the stored checksum by chance about once in 4.3 billion tries. I scanned the genesis address, and then the same address with the last letter changed from a to b:

InputTypeExplanation from Bunker
1A1zP1eP...DivfNaLegacy (1…)Only a hash of the key is on chain until this address spends.
1A1zP1eP...DivfNbChecksum failedThis looks like a Bitcoin-style address, but the checksum is wrong. Look for a typo.

The project tests use the same pair. The test “Base58 round trip and Base58Check” expects the first address to pass and the second one to fail.

Example 2: EIP-55, a checksum in the capital letters

An Ethereum address is 0x plus 40 hexadecimal characters. It has no extra checksum characters. EIP-55 hides the checksum in the case of the letters a to f. The function is in src/networks/evm.js:

export function eip55Valid(addr) {
  const hex = addr.slice(2);
  if (hex === hex.toLowerCase() || hex === hex.toUpperCase()) return true;
  const hash = keccak256(utf8(hex.toLowerCase()));
  for (let i = 0; i < 40; i++) {
    const nibble = (hash[i >> 1] >> (i % 2 ? 0 : 4)) & 15;
    const ch = hex[i];
    if (/[a-f]/.test(ch) && nibble >= 8) return false;
    if (/[A-F]/.test(ch) && nibble < 8) return false;
  }
  return true;
}

Read it line by line:

  1. The function removes 0x and keeps the 40 characters.
  2. If all letters are lowercase, or all are capitals, there is no checksum to verify. The function accepts the address.
  3. It computes the Keccak-256 hash of the address in lowercase.
  4. For each character, it takes the matching nibble of the hash. A nibble is half a byte, a number from 0 to 15.
  5. A letter must be a capital when its nibble is 8 or more. It must be lowercase when its nibble is less than 8.
  6. Digits have no case, so they carry no checksum information.

I scanned three spellings of the EIP-55 test vector, one at a time. The second spelling changes one capital A to a lowercase a:

InputResult
0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAedEVM account
0x5aaeb6053F3E94C9b9A09f33669435E7Ef1BeAedChecksum failed: “the capital letters do not match”
0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaedEVM account

The third row shows a limit of the format. An address in all lowercase carries no checksum, so no tool can find a typo in it. Bunker accepts it, and the documentation lists it as a known limit.

Early versions of Bunker did not verify EIP-55. Any 0x plus 40 hex characters passed. The project log records this as problem P-13, and the EIP-55 check is the fix.

The typo test

A checksum function needs a test that proves it works. The project documentation calls this test the most important one. It is in test/networks.test.js:

test('one changed character is never accepted as the same valid address', () => {
  const swap = ch => (ch === 'q' ? 'p' : ch === 'p' ? 'q' : ch === 'a' ? 'b' : ch === 'A' ? 'B' : ch === '2' ? '3' : '2');
  for (const c of [...fixtures.filter(f => f.model !== 'unknown'), { address: liquid.p2wpkh, source: 'liquid' }]) {
    const a = c.address;
    if (/^(0x|xdc|ronin:)[0-9a-f]+$/.test(a) || /^0x[0-9a-fA-F]{16}$/.test(a)) continue; // lowercase hex has no checksum by design
    if (/^03[0-9a-f]{64}$/.test(a)) continue; // a node ID is a raw key, any hex is a possible key
    const i = a.length - 3;
    const changed = a.slice(0, i) + swap(a[i]) + a.slice(i + 1);
    const r = classify(changed);
    assert.ok(r.model === 'unknown' || r.type === 'Checksum failed', c.source + ': typo not detected (' + r.type + ')');
  }
});

The test does five things:

  1. It takes every real address in the test fixtures that has a rating, plus one Liquid reference address.
  2. It skips two kinds of address whose format has no checksum: lowercase hex, such as 0x... addresses, and Lightning node IDs.
  3. It picks the character at position “length minus 3”, near the end of the address.
  4. The swap function replaces that character with a different one. For example, q becomes p, and a becomes b.
  5. It runs the changed address through the full pipeline. The result must be “Checksum failed” or not rated.

The fixtures hold 57 real public addresses. Each one has a source field, such as “BIP-173 test vector” or “XRP Ledger genesis account”. None belongs to a private person.

I ran the network tests on version 1.2.1. All ten passed, and the typo test is number 3:

$ node --test test/networks.test.js
ok 1 - real public addresses get the expected network and model
ok 2 - Liquid confidential addresses from the Elements reference library
ok 3 - one changed character is never accepted as the same valid address
ok 4 - sample addresses for formats without a public example
ok 5 - Stellar muxed account with a valid CRC-16
ok 6 - Filecoin f3 and SS58 two-byte prefixes
ok 7 - ICP user principal and the 64-hex rule
ok 8 - inputs that are not wallets are not rated
ok 9 - each detector answers for the expected inputs (pipeline order)
ok 10 - wrong encodings and bad headers are reported as checksum failures, never as another network
# tests 10
# pass 10
# fail 0

What the typo test found

The typo test found real gaps in the code. The project documentation shows one failure that it printed:

not ok 16 - one changed character is never accepted as the same valid address
  error: 'XRP Ledger genesis account: typo not detected (XRP account)'

The test changed one character of the XRP genesis account. The tool still said “XRP account”. Each gap got an entry in the problem log.

P-19: four detectors checked only the shape

The detectors for XRP, Stellar, Algorand and TON matched the prefix, the length and the characters. They did not verify the checksum. The fix added four validators:

NetworkChecksum
XRPBase58Check with the Ripple alphabet
StellarCRC-16/XMODEM, little-endian
AlgorandLast 4 bytes of SHA-512/256 of the key
TONCRC-16/XMODEM, big-endian

Today the XRP genesis account passes, and the same account with its last letter changed from h to j fails:

InputResult
rHb9CJAWyB4rj91VRWn96DkukG4bwdtyThXRP account
rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTjChecksum failed: “This looks like an XRP address, but the checksum is wrong.”

P-20: two unused bits in the last character

After the P-19 fix, an Algorand address with a changed last character still passed. The cause is in the arithmetic of the format. Follow it step by step:

  1. An Algorand address has 58 characters in Base32. Each Base32 character carries 5 bits, so the address holds 290 bits.
  2. The data is 36 bytes: a 32-byte public key and a 4-byte checksum. That is 288 bits.
  3. So 2 bits are left over. They are the last 2 bits of the last character, and they carry no data.
  4. If a typo changes only those 2 bits, the 36 bytes stay the same. The checksum is computed from the same bytes, so it passes.

The Algorand documentation example ends in A. In Base32, A is the value 0. This table shows the last character and its 5 bits. The last 2 bits are the unused ones:

Last characterValueBits (used + unused)Same bytes as A?
A0000 + 00It is the real address.
B1000 + 01Yes
C2000 + 10Yes
D3000 + 11Yes
E4001 + 00No, the last byte changes

Before the fix, B, C and D gave three more spellings of one valid address. The fix is one rule in src/core/base32.js: the unused bits must be zero. The code line is if (acc !== 0) return null;, and the variable acc holds the leftover bits.

I called the decoder directly with each last character. A gave 36 bytes, with 72 as the last byte. B, C and D gave null, which means “not valid”. E gave 36 bytes with 73 as the last byte, so the checksum fails. The full pipeline now reports “Checksum failed” for B, C, D and E.

A small test keeps this rule in place. The test “Base32 accepts only the canonical form” expects base32Decode('ab') to return null. The second letter, b, has a non-zero unused bit.

P-15 and P-17: a rule too strict, and a rule too loose

Two more entries show that a length rule cannot replace a checksum.

  • P-15, too strict. The Stacks burn address SP000000000000000000002Q6VF78 has 29 characters. The rule required at least 30, so a real address failed. The rule now accepts 7 to 43 characters, and the checksum does the real work.
  • P-17, too loose. Any Base58 text of 35 or 36 bytes got the label “Polkadot”, with no checksum check. The fix verifies the SS58 checksum, a BLAKE2b-512 hash over the text SS58PRE and the data.

I tested both fixes. The Polkadot development account “Alice” passes, and the same account with its last digit changed from 5 to 6 gives “Checksum failed”. The Stacks burn address passes, and its typo is not rated.

The critical case: a Bitcoin typo that looked like Solana

Version 1.2.1 came from a review with four separate reviewers, one each for the network rules, security, Spanish and the documentation. Each reviewer got the code and the specifications, not my conclusions. The network review found one critical problem, P-37:

From docs/06-problems-and-fixes.md:

The network review tried a bc1q... address with one wrong character. It failed the Bech32 checksum, as it should. But the detector then returned nothing, and the next detector in the pipeline saw a string of 43 Base58 characters.

A string of 43 Base58 characters has a valid Solana shape. Solana addresses have no checksum, so the shape is all that the Solana rule can check. The page reported “Solana account, lock visible” for a Bitcoin typo. The network was wrong, and the result was wrong.

The fix is in the Bech32 detector, in src/networks/bech32-family.js:

export function detectBech32(a) {
  const dec = bech32Decode(a, 1023);
  if (!dec) {
    // The shape of a Bech32 address with a wrong checksum. It must not fall through to the
    // Base58 detector, where a 43-character string can pass as a Solana address (P-37).
    const shape = /^[a-z_]{1,83}1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{6,}$/;
    if (a.length <= 1023 && (shape.test(a) || shape.test(a.toLowerCase()) && a === a.toUpperCase())) return checksumFailed('a Bech32 address');
    return null;
  }
// ...

The logic has three parts:

  1. The detector tries a full Bech32 decode, with the checksum. If the decode works, the normal rules continue.
  2. If the decode fails, the detector looks at the shape. The shape is a prefix of letters, the separator 1, and at least six characters from the 32-character Bech32 alphabet.
  3. If the shape matches, the detector returns “Checksum failed”. That is an answer, so the pipeline stops, and no later detector sees the string.

The problem log lists three options. Moving the Solana rule earlier would break the order of the pipeline. Excluding strings that start with bc1 would leave every other Bech32 prefix open, such as ltc1, addr1 and the 45 Cosmos prefixes. One shape rule covers every prefix, now and later.

Here is the typo input from the regression test, a bc1q address with its last character changed from 4 to 5:

$ echo "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5" | node cli/bunker.js --json
      "network": "Unknown",
      "type": "Checksum failed",
      "model": "unknown",
      "status": "unknown",
      "explanation": "This looks like a Bech32 address, but the checksum is wrong. Look for a typo.",

The output shows only the result fields of the JSON report.

Ten formats that accepted the wrong encoding

The same network review checked each format against its specification, one by one. It found ten formats that accepted a wrong encoding, a wrong header or a wrong checksum. The problem log records them as P-41:

FormatWhat was acceptedNow
SegWitVersion 0 in Bech32m, version 1 in Bech32, programs of any sizeChecksum failed, with the reason
Silent PaymentsBech32 and any lengthBech32m and the exact length only
Zcash zs, u, texEither encodingThe encoding each one specifies
Cardano addr, stakeAny header bytePayment headers 0 to 7 and the main network; stake headers 14 and 15
Filecoin f410Any 39 charactersThe BLAKE2b checksum is verified, as for f1 to f3
Ronin, XDCAny capital lettersThe EIP-55 checksum is verified, as for 0x addresses
TON kQ, 0QReported as a main network walletTest network, not rated
DigiByte S..., test network m, n, 2Unknown Base58Named
Solana System Program, Token ProgramA wallet with a visible lockA program, not a wallet, not rated

Each input now reads “Checksum failed” with its reason, or the right label. None reads as another network, and the user learns what to look for. I tested three rows with the command-line tool:

InputResult
A Ronin address with capital letters that do not match EIP-55Checksum failed: “the capital letters do not match”
A SegWit version 0 address built with Bech32mChecksum failed: “the encoding does not match the version”
11111111111111111111111111111111Program: Solana System Program

I built the SegWit input with the project’s own encoder, the same way the test builds it. The last row is a real program address. Explorers show it next to every Solana transaction, so people often paste it instead of their own address. A program has no key to protect, so Bunker tells the user to paste their own address.

Each row of P-41 has a test input in the test suite.

Where no checksum exists

Some formats have no checksum, so no tool can find a typo in them. Bunker can examine only their shape. The documentation lists these limits:

FormatWhy there is no check
SolanaThe address is the public key, with no extra characters.
Lowercase hex (0x, xdc, ronin:)EIP-55 needs mixed case. Lowercase carries no checksum by design.
Lightning node IDsA node ID is a raw key. Any valid hex is a possible key.
NEAR namesA name such as example.near has no checksum.
HederaThe optional checksum is accepted, but the tool does not verify it.
FlowThe error-detection code is not verified yet.

For these formats, the ground rules of the plan matter most. Examine each character of the address. Look it up on a block explorer. Send a small test amount before each large move.

Read the code

In the next article, I turn to the first step of the engine: the guard. You will see how Bunker recognizes 19 kinds of secrets, and why it clears the input instead of only showing a warning.

Have a question or feedback about Bunker? Contact NosisTech.

Keep reading: Bunker by NosisTech

Previous article: From Pasted Text to a Plan: Inside the Five-Step Engine of Bunker

Next article: Never Paste a Seed Phrase: How Bunker Blocks 19 Kinds of Secrets

All articles: NosisTech Writing

This post is general information, not financial, legal or security advice. Bunker is an educational tool with no warranty. Examine each address on a block explorer, and send a small test amount before you move funds.

WEEKLY BUILD NOTES

One documented agent build in your inbox, every week

Real systems, real code, the errors left in. No spam, unsubscribe anytime.