DEV Community

Aturo Phil
Aturo Phil

Posted on

BOLT 11 Invoice Decoding: What's Actually Inside a Lightning Invoice

Every Lightning payment starts with an invoice. You have seen the long strings starting with lnbc or lntb, but most developers treat them as opaque blobs that their Lightning node handles. That abstraction breaks the moment something goes wrong: an invoice expires mid-payment, a payment fails with a route hint error, or you need to build tooling that inspects invoices programmatically.

This post decodes what is actually inside a BOLT 11 invoice, field by field, with working code.


The Structure

A BOLT 11 invoice is a bech32-encoded string. Bech32 was designed for SegWit addresses but BOLT 11 adopted it for invoices. The structure is:

lnbc      1        p        v        k        t      ...  <data>       <checksum>
 ↑         ↑        ↑
prefix   amount   multiplier
Enter fullscreen mode Exit fullscreen mode

Breaking it into sections:

  1. Human-readable part (HRP): Everything before the separator (1)
  2. Data part: Everything after the separator, base32-encoded
  3. Checksum: Last 6 characters, a bech32 checksum over the entire string

The human-readable part encodes:

  • Network prefix: lnbc (mainnet), lntb (testnet), lnbcrt (regtest), lnsb (signet)
  • Amount: optional, in the network's base unit (millisatoshis implied by multiplier)

The amount uses a multiplier suffix:
| Suffix | Multiplier | Example |
|--------|-----------|---------|
| m | milli (0.001 BTC) | lnbc1m = 100,000 sat |
| u | micro (0.000001 BTC) | lnbc1u = 100 sat |
| n | nano (0.000000001 BTC) | lnbc1n = 0.1 sat |
| p | pico (0.000000000001 BTC) | lnbc1p = 0.0001 sat |

No amount suffix means an any-amount invoice (the sender specifies).


Decoding the Data Part

After bech32-decoding the data part, you get a sequence of 5-bit groups. The data is divided into:

  1. Timestamp: 35 bits (7 groups of 5 bits), Unix timestamp in seconds
  2. Tagged fields: Variable-length, each with a type byte, length, and data
  3. Signature: 520 bits (104 groups of 5 bits) = 65 bytes (64-byte compact signature + 1 recovery byte)
import base64
import hashlib
import struct
from typing import Optional

def bech32_decode_data(invoice: str) -> tuple[str, bytes]:
    """
    Decode a bech32 invoice string.
    Returns (hrp, 5-bit data as bytes).
    """
    # Find separator
    sep = invoice.rfind('1')
    hrp = invoice[:sep].lower()
    data_str = invoice[sep+1:].lower()

    CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
    data_5bit = bytes([CHARSET.index(c) for c in data_str[:-6]])  # exclude checksum
    return hrp, data_5bit

def convert_bits(data: bytes, from_bits: int, to_bits: int, pad: bool = True) -> bytes:
    """Convert between bit group sizes."""
    acc = 0
    bits = 0
    result = []
    maxv = (1 << to_bits) - 1

    for value in data:
        acc = ((acc << from_bits) | value) & 0xFFFFFFFF
        bits += from_bits
        while bits >= to_bits:
            bits -= to_bits
            result.append((acc >> bits) & maxv)

    if pad and bits:
        result.append((acc << (to_bits - bits)) & maxv)

    return bytes(result)

def decode_invoice(invoice: str) -> dict:
    hrp, data_5bit = bech32_decode_data(invoice)
    data_8bit = convert_bits(data_5bit[:-104], 5, 8, pad=False)  # exclude signature

    # Parse HRP
    network = 'mainnet' if hrp.startswith('lnbc') else \
              'testnet' if hrp.startswith('lntb') else \
              'regtest' if hrp.startswith('lnbcrt') else 'unknown'

    amount_part = hrp[4:] if network == 'mainnet' else hrp[6:]
    amount_msat = parse_amount(amount_part)

    # Parse timestamp (first 35 bits = 7 5-bit groups)
    timestamp_5bit = data_5bit[:7]
    timestamp = 0
    for b in timestamp_5bit:
        timestamp = (timestamp << 5) | b

    # Parse tagged fields
    fields = parse_tagged_fields(data_5bit[7:-104])

    # Recover signature
    sig_5bit = data_5bit[-104:]
    sig_8bit = convert_bits(sig_5bit, 5, 8, pad=False)

    return {
        'network': network,
        'amount_msat': amount_msat,
        'timestamp': timestamp,
        'fields': fields,
        'signature': sig_8bit.hex(),
    }

def parse_amount(amount_str: str) -> Optional[int]:
    if not amount_str:
        return None  # any-amount invoice
    multipliers = {'m': 100_000_000, 'u': 100_000, 'n': 100, 'p': 1}
    if amount_str[-1] in multipliers:
        return int(amount_str[:-1]) * multipliers[amount_str[-1]]
    return int(amount_str) * 100_000_000_000  # whole BTC to msat
Enter fullscreen mode Exit fullscreen mode

Tagged Fields

Tagged fields carry the invoice's metadata. Each field has:

  • 1 group (5 bits): type
  • 2 groups (10 bits): length in 5-bit groups
  • N groups: data
def parse_tagged_fields(data_5bit: bytes) -> dict:
    fields = {}
    i = 0

    while i < len(data_5bit) - 2:
        field_type = data_5bit[i]
        field_len = (data_5bit[i+1] << 5) | data_5bit[i+2]
        field_data_5bit = data_5bit[i+3:i+3+field_len]
        field_data = convert_bits(field_data_5bit, 5, 8, pad=False)
        i += 3 + field_len

        if field_type == 1:    # p: payment hash
            fields['payment_hash'] = field_data.hex()

        elif field_type == 16:  # s: payment secret
            fields['payment_secret'] = field_data.hex()

        elif field_type == 13:  # d: description
            fields['description'] = field_data.decode('utf-8')

        elif field_type == 23:  # h: description hash
            fields['description_hash'] = field_data.hex()

        elif field_type == 6:   # x: expiry (seconds)
            expiry = 0
            for b in field_data_5bit:
                expiry = (expiry << 5) | b
            fields['expiry_seconds'] = expiry

        elif field_type == 24:  # c: min_final_cltv_expiry
            cltv = 0
            for b in field_data_5bit:
                cltv = (cltv << 5) | b
            fields['min_final_cltv_expiry'] = cltv

        elif field_type == 9:   # f: fallback on-chain address
            fields['fallback_address'] = parse_fallback(field_data)

        elif field_type == 3:   # r: route hints
            fields.setdefault('route_hints', []).append(parse_route_hint(field_data))

        elif field_type == 5:   # n: node public key
            fields['payee_pubkey'] = field_data.hex()

        elif field_type == 10:  # 9: feature bits
            fields['feature_bits'] = parse_features(field_data)

    return fields
Enter fullscreen mode Exit fullscreen mode

Key Fields in Detail

Payment Hash (p, type 1)

32 bytes. The SHA256 hash of the payment preimage. The sender locks their HTLC to this hash. Only the recipient (who knows the preimage) can claim the payment.

payment_hash = fields['payment_hash']
# This is what flows through the HTLC chain
# The preimage that hashes to this unlocks the payment
Enter fullscreen mode Exit fullscreen mode

Payment Secret (s, type 16)

32 bytes. Added in 2020 to prevent probing attacks. The recipient generates a random secret and includes it in the invoice. The sender must include it in the final HTLC. An intermediate hop that knows the payment hash cannot claim the payment without also knowing the secret, because the secret is only in the last HTLC.

payment_secret = fields.get('payment_secret')
# Required for modern invoices
# Included in the TLV onion payload to the recipient
Enter fullscreen mode Exit fullscreen mode

Route Hints (r, type 3)

Each route hint is a sequence of channel hops that lead to the recipient. Used by nodes that are not well-connected or are behind a private channel. The LSPS2 JIT channel mechanism uses fake SCIDs in route hints.

def parse_route_hint(data: bytes) -> list[dict]:
    """
    A route hint is a list of hop_extra_data entries.
    Each entry: 33-byte pubkey + 8-byte short_channel_id +
                4-byte fee_base_msat + 4-byte fee_proportional_millionths +
                2-byte cltv_expiry_delta
    = 51 bytes per hop
    """
    hops = []
    for i in range(0, len(data), 51):
        hop_data = data[i:i+51]
        if len(hop_data) < 51:
            break
        hop = {
            'pubkey': hop_data[:33].hex(),
            'short_channel_id': int.from_bytes(hop_data[33:41], 'big'),
            'fee_base_msat': int.from_bytes(hop_data[41:45], 'big'),
            'fee_proportional_millionths': int.from_bytes(hop_data[45:49], 'big'),
            'cltv_expiry_delta': int.from_bytes(hop_data[49:51], 'big'),
        }
        hops.append(hop)
    return hops
Enter fullscreen mode Exit fullscreen mode

Feature Bits (9, type 10)

A bitfield indicating which Lightning features the recipient supports. Bit positions are defined in BOLT 9. Even bits are required (sender must support them), odd bits are optional (sender can ignore them).

FEATURE_BITS = {
    8:  'var_onion_optin',           # required: TLV onion format
    14: 'payment_secret',            # required: payment secret
    16: 'basic_mpp',                 # optional: multipart payments
    48: 'payment_metadata',          # optional: payment metadata in onion
    30: 'amp',                       # optional: atomic multipath
}

def parse_features(data: bytes) -> dict:
    features = {}
    bit_array = int.from_bytes(data, 'big')
    for bit, name in FEATURE_BITS.items():
        features[name] = bool(bit_array & (1 << bit))
    return features
Enter fullscreen mode Exit fullscreen mode

Expiry (x, type 6)

Seconds from the timestamp. Default is 3600 (1 hour) if absent. An expired invoice must not be paid — the payment would fail at the recipient who has discarded the preimage.

import time

def is_invoice_expired(decoded: dict) -> bool:
    expiry = decoded['fields'].get('expiry_seconds', 3600)
    expiry_time = decoded['timestamp'] + expiry
    return time.time() > expiry_time
Enter fullscreen mode Exit fullscreen mode

min_final_cltv_expiry (c, type 24)

The minimum CLTV delta the recipient requires on the final HTLC. Defaults to 18 blocks. If your routing algorithm does not respect this, the recipient rejects the payment.


Verifying the Signature

The invoice is signed by the recipient's node key. The signature covers the entire invoice content, proving the invoice was created by the owner of the payee public key.

def verify_invoice_signature(invoice: str, decoded: dict) -> bool:
    """
    Verify the BOLT 11 invoice signature.
    The signed message is: SHA256(hrp_bytes + data_bytes_without_sig)
    """
    from coincurve import PublicKey

    sep = invoice.rfind('1')
    hrp = invoice[:sep].lower().encode()

    _, data_5bit = bech32_decode_data(invoice)
    # Data excluding the 104 5-bit groups of signature
    data_without_sig_5bit = data_5bit[:-104]
    data_without_sig_8bit = convert_bits(data_without_sig_5bit, 5, 8, pad=False)

    # Message is hrp bytes + data bytes
    message = hrp + data_without_sig_8bit
    message_hash = hashlib.sha256(message).digest()

    # Signature: 65 bytes (recovery byte + 64-byte sig)
    sig_bytes = bytes.fromhex(decoded['signature'])
    recovery_byte = sig_bytes[-1]
    compact_sig = sig_bytes[:64]

    # Recover the public key from the signature
    recovered_pubkey = PublicKey.from_signature_and_message(
        compact_sig + bytes([recovery_byte]),
        message_hash,
        hasher=None
    )

    # If n (payee pubkey) is present, compare
    if 'payee_pubkey' in decoded['fields']:
        expected = bytes.fromhex(decoded['fields']['payee_pubkey'])
        return recovered_pubkey.format(compressed=True) == expected

    # Otherwise, the recovered key IS the payee pubkey
    decoded['fields']['payee_pubkey'] = recovered_pubkey.format(compressed=True).hex()
    return True
Enter fullscreen mode Exit fullscreen mode

Using a Library

The above is instructive but you should use a library in production. Python options:

# lnaddr (part of pyln-client from Core Lightning)
from pyln.client import LightningRpc
# or decode directly
import lnaddr
decoded = lnaddr.lndecode("lnbc1pvjluez...")

# bolt11 library
import bolt11
decoded = bolt11.decode("lnbc1pvjluez...")

print(decoded.amount)           # amount in satoshis
print(decoded.paymenthash)      # 32-byte payment hash
print(decoded.date)             # timestamp
print(decoded.tags)             # all tagged fields
Enter fullscreen mode Exit fullscreen mode

Go options:

import "github.com/lightningnetwork/lnd/zpay32"

invoice, err := zpay32.Decode(invoiceString, &chaincfg.MainNetParams)
if err != nil {
    log.Fatal(err)
}

fmt.Printf("Amount: %d msat\n", invoice.MilliSat)
fmt.Printf("Payment hash: %x\n", invoice.PaymentHash)
fmt.Printf("Description: %s\n", *invoice.Description)
fmt.Printf("Expiry: %s\n", invoice.Expiry())
for _, hint := range invoice.RouteHints {
    fmt.Printf("Route hint via: %x\n", hint[0].NodeID.SerializeCompressed())
}
Enter fullscreen mode Exit fullscreen mode

Practical Situations Where You Need This

Validating before payment: Check expiry, verify the signature, confirm the payment hash is 32 bytes, and read the feature bits to confirm compatibility before sending.

Building invoice generation tooling: CLN and LND both expose invoice creation via RPC, but if you are generating invoices in application code or in a service that does not run a full node, you need to construct and sign the invoice yourself.

Debugging routing failures: Route hints in a failed invoice often reveal the problem, an expired or closed private channel, a SCID that no longer exists, or a CLTV delta that your pathfinding algorithm did not respect.

LSPS2 / JIT channel integration: JIT invoices embed fake SCIDs in route hints. Decoding the invoice and inspecting the route hints tells you which LSP the wallet is using and what fee the LSP is charging.


Further Reading

Top comments (0)