Guide

Verify a Bitcoin descriptor checksum offline

Add and verify the eight-character checksum on a synthetic Bitcoin output descriptor with a small offline Python program. The exercise uses no wallet, keys or network.

9 min readWallet Standards
Verify a Bitcoin descriptor checksum offline

What you will do

A copied wallet policy can look correct while one character is wrong. This guide builds the checksum used by Bitcoin output descriptors, then verifies one published test vector and rejects seven broken versions.

An output descriptor is a text recipe for the scripts a wallet watches. It can include script types, public keys, key origins and derivation paths. BIP 380 lets that recipe end with # and an eight-character checksum. The checksum helps detect copying mistakes. It does not prove that the descriptor belongs to you or that its policy is safe.

The exercise uses only raw(deadbeef), a synthetic descriptor from the BIP 380 test vectors. It has no private key, extended public key, wallet address or live balance. Do not replace it with a real wallet export while learning from this program.

Before you start

You need Python 3 and a text editor in a disposable folder. The program below was run with Python 3.12.14 on 23 October 2026. It uses only the standard language and makes no network requests.

BIP 380 defines the descriptor character set, checksum alphabet, generator values and test vectors. Bitcoin Core 31.1 was the latest stable release when checked. Its descriptor implementation uses the same checksum construction, and its descriptor documentation explains the wider expression language.

Keep the boundary clear before running anything:

  • a checksum detects many text errors, not a malicious or mistaken policy
  • a valid checksum does not check key ownership, derivation paths or address type
  • a descriptor can contain an xprv or another private key form, so real exports may be secret
  • this program checks the descriptor checksum only and does not parse the script expression

Create the offline checker

  1. Create a disposable folder and enter it:
mkdir descriptor-checksum-practice
cd descriptor-checksum-practice
  1. Save this program as descriptor_checksum.py. Read it before running it. The program accepts no files or command-line values. Its test data is fixed in the source.
#!/usr/bin/env python3

INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "
CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
GENERATOR = [0xF5DEE51989, 0xA9FDCA3312, 0x1BAB10E32D, 0x3706B1677A, 0x644D626FFD]


def polymod(symbols):
    checksum = 1
    for value in symbols:
        top = checksum >> 35
        checksum = ((checksum & 0x7FFFFFFFF) << 5) ^ value
        for index, generator in enumerate(GENERATOR):
            if (top >> index) & 1:
                checksum ^= generator
    return checksum


def expand(text):
    groups = []
    symbols = []
    for character in text:
        position = INPUT_CHARSET.find(character)
        if position == -1:
            raise ValueError("invalid_character")
        symbols.append(position & 31)
        groups.append(position >> 5)
        if len(groups) == 3:
            symbols.append(groups[0] * 9 + groups[1] * 3 + groups[2])
            groups = []
    if len(groups) == 1:
        symbols.append(groups[0])
    elif len(groups) == 2:
        symbols.append(groups[0] * 3 + groups[1])
    return symbols


def add_checksum(payload):
    symbols = expand(payload) + [0] * 8
    value = polymod(symbols) ^ 1
    suffix = "".join(
        CHECKSUM_CHARSET[(value >> (5 * (7 - index))) & 31]
        for index in range(8)
    )
    return f"{payload}#{suffix}"


def verify(descriptor):
    if len(descriptor) < 9 or descriptor[-9] != "#":
        return False
    suffix = descriptor[-8:]
    if any(character not in CHECKSUM_CHARSET for character in suffix):
        return False
    try:
        symbols = expand(descriptor[:-9])
    except ValueError:
        return False
    symbols.extend(CHECKSUM_CHARSET.find(character) for character in suffix)
    return polymod(symbols) == 1


def main():
    payload = "raw(deadbeef)"
    expected = "raw(deadbeef)#89f8spxm"
    if add_checksum(payload) != expected:
        raise RuntimeError("published_vector_creation_failed")
    if not verify(expected):
        raise RuntimeError("published_vector_verification_failed")

    invalid = [
        payload,
        "raw(deadbeef)#",
        "raw(deadbeef)#89f8spxmx",
        "raw(deadbeef)#89f8spx",
        "raw(deedbeef)#89f8spxm",
        "raw(deadbeef)#89f8spxq",
        "raw(Ü)#00000000",
    ]
    if any(verify(candidate) for candidate in invalid):
        raise RuntimeError("invalid_vector_accepted")

    print(f"created={expected}")
    print("valid_accepted=1 invalid_rejected=7")


if __name__ == "__main__":
    main()
  1. Run it:
python3 descriptor_checksum.py

The expected output is:

created=raw(deadbeef)#89f8spxm
valid_accepted=1 invalid_rejected=7

The first line must match the BIP 380 vector exactly. The second line confirms that the valid vector passed and all seven negative controls failed.

Follow the calculation

The descriptor before # is the payload. BIP 380 gives every allowed payload character a position in a 96-character list. The expand function turns those positions into small symbols.

Each character contributes its low five bits. The higher group number is collected separately. After every three characters, the three group numbers become one extra symbol. This arrangement makes common substitutions and case mistakes easier for the code to detect.

The polymod function then moves a 40-bit state through those symbols. Five fixed generator values change that state when selected high bits are set. To create a checksum, add_checksum appends eight zero symbols, runs the state and applies the final constant. The result is written with the same 32-character alphabet used by Bech32.

Verification takes a different route to the same boundary. It expands the payload, converts the eight supplied checksum characters to symbols and runs the complete sequence. A valid descriptor finishes with a polymod value of 1.

This is an error-detecting code, not encryption and not a password hash. Anyone who can change the descriptor can calculate a new matching checksum. The checksum protects against accidental changes. It does not authenticate who created the text.

Read the negative controls

The seven rejected inputs exercise separate mistakes:

  1. no checksum separator or suffix
  2. a separator with no checksum
  3. nine checksum characters instead of eight
  4. seven checksum characters instead of eight
  5. one changed payload character with the old checksum
  6. one changed checksum character
  7. a payload character outside the allowed set

The payload mutation is important. deadbeef becomes deedbeef, but the suffix stays 89f8spxm. A visual scan might miss the change. The checksum calculation does not.

The program does not claim to find every imaginable set of errors. BIP 380 specifies stronger guarantees for short descriptors and states that random errors have a one-in-2-to-the-power-of-40 chance of going undetected. That probability applies to accidental errors under the checksum model. It is not a security estimate against someone deliberately replacing the policy and its checksum together.

Compare it with Bitcoin Core

Bitcoin Core can add and inspect descriptor checksums through its descriptor tools. The relevant released implementation is in src/script/descriptor.cpp. Its checksum section uses the same input alphabet, output alphabet and five generator values used here.

The Python program was compared with that source and the BIP 380 vectors. No Bitcoin Core binary or RPC was executed in this run. The exercise also does not prove that raw(deadbeef) is useful, spendable or appropriate for a wallet. It only proves that the published checksum vector is reproduced and that the selected malformed strings are rejected.

For a real descriptor, use maintained wallet or node software to parse the full expression. A checksum checker cannot tell whether wpkh, tr, multi or a derivation path matches the wallet you meant to restore. Compare the policy, key fingerprints, paths and network with a trusted backup before importing anything.

If something goes wrong

Python reports a syntax error. Copy the entire program as plain text. Keep the backslash and quote characters inside INPUT_CHARSET unchanged. Typographic quotes will change the source and stop it from running.

The created checksum differs. Confirm that the payload is exactly raw(deadbeef). Case, spaces and punctuation are part of the payload. A trailing space before # produces a different checksum.

A broken vector is accepted. Stop using the copy. Compare the constants and alphabets with BIP 380. Do not patch the expected output to make the test pass.

A wallet rejects a checksum-valid descriptor. That is possible. The expression may use an unsupported function, an invalid key, a hardened derivation without private material or a policy the wallet cannot import. Checksum validity is only one layer.

The descriptor contains xprv, WIF or another private key. Do not paste it into this exercise, a website, chat or issue report. Work from a public-only export or a synthetic vector in an offline environment. A checksum does not hide the secret.

What was and was not verified

The program ran with Python 3.12.14. It recreated BIP 380's raw(deadbeef)#89f8spxm vector, accepted that vector and rejected seven negative controls. Its source was compared with BIP 380 and Bitcoin Core 31.1's released descriptor checksum implementation.

No wallet, node, RPC, descriptor import, address derivation, key, network request or funds were used. The program does not parse descriptor semantics and must not be used as the only check before restoring or importing a real wallet policy.

Sources

Newsletter

Bitcoin, without the noise

What happened in Bitcoin, what it actually changes, and the sources so you can check us. One issue at a time, straight to your inbox.

  • One email per issue, never a drip campaign
  • No tracking pixels and no shared addresses
  • Unsubscribe from any issue in one click

Get the next issue

One email per issue, no tracking pixels, and unsubscribe from any of them. We do not share your address. Privacy policy