Guide

Checksums

Base58Check and bech32 and bech32m verify themselves on decode. A segwit address comes back as its version and program

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.

ts
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?

text
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.

ts
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.

text
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.

ts
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.