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
Breaking it into sections:
-
Human-readable part (HRP): Everything before the separator (
1) - Data part: Everything after the separator, base32-encoded
- 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:
- Timestamp: 35 bits (7 groups of 5 bits), Unix timestamp in seconds
- Tagged fields: Variable-length, each with a type byte, length, and data
- 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
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
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
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
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
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
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
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
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
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())
}
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
- BOLT 11 specification — https://github.com/lightning/bolts/blob/master/11-payment-encoding.md
- BOLT 9 feature bits — https://github.com/lightning/bolts/blob/master/09-features.md
- bech32 specification — https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki
- pyln-client (Core Lightning Python library) — https://github.com/ElementsProject/lightning/tree/master/contrib/pyln-client
- lnd zpay32 package — https://pkg.go.dev/github.com/lightningnetwork/lnd/zpay32
Top comments (0)