Checksums
Most encodings will decode a typo without blinking. Swap two base64 characters and you get different bytes, no error, no warning. Fine for a data URL. Not fine for an address you're about to send money to.
Three encodings here carry a checksum and check it every time you decode.
Base58Check
Bitcoin's form for addresses, WIF keys and extended keys. The payload, then the first four bytes of its double SHA-256, all in base58.
import { base58check, hex } from "@agntn/encodings";
hex.encode(base58check.decode("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"));
// "0062e907b15cbf27d5425399ebf6f0fb50ebb88f18"
That's the genesis block's address. One byte of version, 0x00 for mainnet P2PKH. Then the 20-byte HASH160 of Satoshi's key. Now change the last character from a to b. What happens?
base58check: checksum does not match
It throws ChecksumError, a subclass of DecodeError. Catch the one you care about. The version bytes stay in the payload. This codec checks the checksum and nothing else. Which version means which network? That's @agntn/keys territory.
A WIF key decodes the same way. Private key 1, compressed, is 80, then 31 zero bytes and 01, then one more 01 that says "compressed". Thirty-four bytes. An xpub is seventy-eight.
Bech32 and bech32m
A prefix, a 1, then 5-bit words and six characters of BCH checksum. Lowercase or uppercase, never mixed. The checksum catches any four wrong characters, which is better than base58 ever did.
import { bech32 } from "@agntn/encodings";
bech32.encode("test", new TextEncoder().encode("gm"));
// "test1vaks69jerm"
bech32.decode("test1vaks69jerm");
// { prefix: "test", bytes: Uint8Array [103, 109] }
Bech32m is the same thing with a different constant in the checksum. Why change it? Bech32 had a quiet flaw. When the last character is p, you can add or drop q characters right before it. The old checksum still passes. Feed one variant's text to the other and the error tells you.
bech32: checksum is bech32m's
Strings over 90 characters are rejected by default, like BIP173 says. Lightning invoices are longer. Pass a limit when you mean it.
Segwit addresses
A segwit address is bech32 with one extra rule. The first word is the witness version, the rest is the program. Version 0 uses bech32 and a 20 or 32-byte program. Versions 1 to 16 use bech32m. Taproot is version 1.
import { segwit, hex } from "@agntn/encodings";
const address = segwit.decode("bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0");
address.version; // 1
hex.encode(address.program); // "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
Recognize that program? It's the x coordinate of the secp256k1 generator, and the whole address is one of BIP350's test vectors. A real Taproot address tweaks its key first, so don't go looking for coins there.
The registry does this for you. decode("bech32", …) on a valid segwit address returns the program as bytes and witnessVersion in details. On anything else, a Nostr npub say, it returns the bytes the words carry. A version 0 address handed to bech32m fails, because that's exactly the mix-up bech32m exists to catch.