Guide

Verify a Taproot address checksum with Python

Decode a mainnet Taproot address, verify its Bech32m checksum and witness program, then reject four official invalid vectors offline.

10 min readTransactions
Verify a Taproot address checksum with Python

Check an address before you trust what it says

A Bitcoin address is an instruction for constructing an output, not an account and not proof that anyone controls a key. A native Taproot address carries three important pieces of information: the human-readable network prefix, witness version 1 and a 32-byte witness program. It also carries a Bech32m checksum that detects many transcription errors.

This guide decodes one official BIP 350 test vector with Python's standard library. It verifies the checksum, network, witness version, program length and padding. It then rejects four official invalid vectors. The program works entirely offline. It does not create a wallet, derive keys, query a node, broadcast a transaction or move bitcoin.

The implementation follows BIP 350, which specifies Bech32m for witness versions 1 through 16, and BIP 173, which defines the original Bech32 structure. BIP 341 defines Taproot outputs as witness version 1 with a 32-byte program. These sources and Bitcoin Core 31.1's Bech32 implementation were checked on 2 October 2026. Bitcoin Core 31.1 was the current stable release when checked.

Prerequisites and expected result

You need Python 3 and a text editor. No external package or network connection is required. Create a disposable directory, save the program below as taproot_address_check.py, inspect it, and run:

python3 taproot_address_check.py

The tested environment used Python 3.12.14. A successful run prints the decoded 32-byte program:

79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798

It also reports that four negative controls were rejected: a witness version 1 address using the old Bech32 checksum, a checksum containing an invalid character, a one-byte program and a mixed-case string.

The complete program

#!/usr/bin/env python3

CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
CHARSET_MAP = {char: index for index, char in enumerate(CHARSET)}
BECH32_CONST = 1
BECH32M_CONST = 0x2BC830A3


def bech32_polymod(values):
    generators = (
        0x3B6A57B2,
        0x26508E6D,
        0x1EA119FA,
        0x3D4233DD,
        0x2A1462B3,
    )
    checksum = 1
    for value in values:
        top = checksum >> 25
        checksum = ((checksum & 0x1FFFFFF) << 5) ^ value
        for index, generator in enumerate(generators):
            if (top >> index) & 1:
                checksum ^= generator
    return checksum


def hrp_expand(hrp):
    return [ord(char) >> 5 for char in hrp] + [0] + [ord(char) & 31 for char in hrp]


def checksum_encoding(hrp, values):
    result = bech32_polymod(hrp_expand(hrp) + values)
    if result == BECH32_CONST:
        return "bech32"
    if result == BECH32M_CONST:
        return "bech32m"
    return None


def convertbits(values, from_bits, to_bits, pad=False):
    accumulator = 0
    bits = 0
    result = []
    maximum = (1 << to_bits) - 1
    for value in values:
        if value < 0 or value >> from_bits:
            raise ValueError("input value exceeds its bit group")
        accumulator = (accumulator << from_bits) | value
        bits += from_bits
        while bits >= to_bits:
            bits -= to_bits
            result.append((accumulator >> bits) & maximum)
    if pad:
        if bits:
            result.append((accumulator << (to_bits - bits)) & maximum)
    elif bits >= from_bits or ((accumulator << (to_bits - bits)) & maximum):
        raise ValueError("invalid padding")
    return result


def decode_taproot_address(address, expected_hrp="bc"):
    if not 8 <= len(address) <= 90:
        raise ValueError("address length is outside 8..90 characters")
    if any(ord(char) < 33 or ord(char) > 126 for char in address):
        raise ValueError("address contains a character outside printable ASCII")
    if address.lower() != address and address.upper() != address:
        raise ValueError("mixed case is not allowed")

    normalized = address.lower()
    separator = normalized.rfind("1")
    if separator < 1 or separator + 7 > len(normalized):
        raise ValueError("separator or checksum position is invalid")

    hrp = normalized[:separator]
    if hrp != expected_hrp:
        raise ValueError(f"expected HRP {expected_hrp!r}, got {hrp!r}")

    try:
        values = [CHARSET_MAP[char] for char in normalized[separator + 1 :]]
    except KeyError as error:
        raise ValueError(f"invalid Bech32 character {error.args[0]!r}") from None

    encoding = checksum_encoding(hrp, values)
    if encoding is None:
        raise ValueError("checksum does not verify")

    payload = values[:-6]
    if not payload:
        raise ValueError("witness payload is empty")
    witness_version = payload[0]
    if witness_version > 16:
        raise ValueError("witness version is outside 0..16")

    program = bytes(convertbits(payload[1:], 5, 8, pad=False))
    if not 2 <= len(program) <= 40:
        raise ValueError("witness program length is outside 2..40 bytes")
    if witness_version != 1:
        raise ValueError(f"expected Taproot witness version 1, got {witness_version}")
    if len(program) != 32:
        raise ValueError(f"expected a 32-byte Taproot program, got {len(program)}")
    if encoding != "bech32m":
        raise ValueError("Taproot requires a Bech32m checksum")

    return program.hex()


VALID = "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0"
EXPECTED_PROGRAM = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
INVALID = {
    "wrong checksum family": "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqh2y7hd",
    "invalid checksum character": "bc1p38j9r5y49hruaue7wxjce0updqjuyyx0kh56v8s25huc6995vvpql3jow4",
    "program too short": "bc1pw5dgrnzv",
    "mixed case": "tb1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq47Zagq",
}


if __name__ == "__main__":
    decoded = decode_taproot_address(VALID)
    assert decoded == EXPECTED_PROGRAM
    print(f"PASS valid Taproot vector: {decoded}")
    for label, candidate in INVALID.items():
        try:
            decode_taproot_address(candidate)
        except ValueError as error:
            print(f"PASS rejected {label}: {error}")
        else:
            raise AssertionError(f"FAIL accepted {label}")

Follow the decoder one boundary at a time

The address starts with the human-readable part, or HRP. Mainnet SegWit addresses use bc; testnet and signet commonly use tb; regtest uses bcrt. The final 1 before the data separates the HRP from the encoded values. The decoder uses the last separator because the HRP may itself contain 1.

The data alphabet contains 32 characters, so each symbol represents five bits. The final six symbols are the checksum. bech32_polymod feeds the expanded HRP and every data value through the generator constants specified by BIP 173. A remainder of 1 identifies Bech32. A remainder of 0x2bc830a3 identifies Bech32m.

This distinction matters. Witness version 0 addresses use Bech32, while witness versions 1 through 16 use Bech32m. A decoder that merely accepts either constant can approve an address with the wrong checksum family. The first negative control is valid under old Bech32 rules but invalid as a version 1 address.

After removing the six checksum values, the first five-bit value is the witness version. The remaining groups are converted back to bytes. Strict conversion rejects excess or non-zero padding. General SegWit programs may be 2 to 40 bytes, but BIP 341 narrows a Taproot output to version 1 and exactly 32 bytes.

That 32-byte value is an x-only public key for the key-path case, or a tweaked output key that also commits to a script tree. This program does not determine how the key was created, whether a script tree exists, or whether anyone controls the corresponding private key.

Check the tested output

The complete tested output was:

PASS valid Taproot vector: 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
PASS rejected wrong checksum family: Taproot requires a Bech32m checksum
PASS rejected invalid checksum character: invalid Bech32 character 'o'
PASS rejected program too short: witness program length is outside 2..40 bytes
PASS rejected mixed case: mixed case is not allowed

The source file's SHA-256 was b0982a21567485792ccbd08bb58084790a6d51ba2b40dfa61b94816656277efd. The captured output's SHA-256 was b71fb65a4798d7428d850ffd67297df88c0f71c8a7c5d9eb648d32974ba57897. These hashes identify the fixture that was executed. They do not authenticate code copied from an untrusted source.

Troubleshooting

A known address reports the wrong HRP: choose the network explicitly. Passing expected_hrp="bc" should reject a testnet address. Do not silently convert one network prefix to another.

The checksum fails after copying: compare every character. Bech32 deliberately omits visually ambiguous characters such as 1, b, i and o from the data alphabet, but transcription can still alter a valid symbol.

An uppercase address works but mixed case fails: an all-uppercase or all-lowercase Bech32 string can be valid. Mixing cases in one string is invalid. Wallets normally display lowercase.

A version 0 address is rejected: this function is intentionally Taproot-specific. Version 0 uses Bech32 and requires a 20-byte or 32-byte program. Do not weaken the Taproot checks to accept it; use a general SegWit decoder with version-dependent rules.

The bytes do not match a wallet's internal key: the witness program is the Taproot output key after any TapTweak operation. It does not necessarily equal the untweaked internal key.

What the checksum does not prove

A valid checksum establishes that the characters are internally consistent with Bech32m. It does not prove that the address came from the intended recipient, belongs to the intended network in your application, has ever appeared on-chain or can be spent.

For an actual payment, compare the full address over an authenticated channel and let a maintained wallet enforce network and output rules. Malware can replace one complete valid address with another, and a checksum cannot detect that substitution. A QR code reduces manual entry but does not authenticate the source of the QR code.

The offline fixture demonstrates the encoding boundary with one official valid vector and four official invalid vectors. It is useful for understanding and regression checks. It is not a replacement for Bitcoin Core, a wallet's address parser or review of payment details before signing.

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