#!/usr/bin/env python3 """Decode a BOLT11 invoice and (optionally) check it against an LNURL-pay quote. Stdlib only. Does not pay anyone. Does not verify the secp256k1 signature (that needs a curve library); it does check structure, amount, expiry, and the LNURL-pay description_hash commitment from LUD-06. Usage: python3 verify_ln.py lnbc... python3 verify_ln.py lnbc... --expect-sats 1000 python3 verify_ln.py --lnurl name@domain --sats 1000 python3 verify_ln.py --lnurl lnurl1... --sats 1000 Companion (resolve-only LNURL-pay): https://paste.rs/xzXLM """ from __future__ import annotations import argparse import hashlib import json import sys import time import urllib.parse import urllib.request CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" GEN = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3) # tagged-field types from BOLT11 TAG = { 1: "payment_hash", # p, 256 bits 9: "description_hash", # h, 256 bits 13: "description", # d, variable 16: "payment_secret", # s, 256 bits 19: "payee_node", # n, 264 bits 23: "purpose_commit", # h-like, rare 6: "expiry", # x, seconds 24: "min_final_cltv", # c 9 * 0: None, } def polymod(values: list[int]) -> int: chk = 1 for v in values: b = chk >> 25 chk = ((chk & 0x1FFFFFF) << 5) ^ v for i in range(5): if (b >> i) & 1: chk ^= GEN[i] return chk def hrp_expand(hrp: str) -> list[int]: return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] def bech32_decode(invoice: str) -> tuple[str, list[int]]: inv = invoice.strip().lower() if inv.startswith("lightning:"): inv = inv.split(":", 1)[1] if "1" not in inv: raise ValueError("not bech32 (missing separator 1)") hrp, data = inv.rsplit("1", 1) if not hrp.startswith("ln"): raise ValueError(f"HRP {hrp!r} is not a Lightning invoice") try: vals = [CHARSET.index(c) for c in data] except ValueError as e: raise ValueError("invalid bech32 character") from e if polymod(hrp_expand(hrp) + vals) != 1: raise ValueError("bech32 checksum failed (truncated or corrupted invoice)") return hrp, vals[:-6] # drop checksum def convertbits(data: list[int], frombits: int, tobits: int, pad: bool = True) -> list[int]: acc = 0 bits = 0 ret: list[int] = [] maxv = (1 << tobits) - 1 for v in data: acc = (acc << frombits) | v bits += frombits while bits >= tobits: bits -= tobits ret.append((acc >> bits) & maxv) if pad and bits: ret.append((acc << (tobits - bits)) & maxv) if not pad and bits >= frombits: raise ValueError("excess padding in bit conversion") return ret def parse_amount(hrp: str) -> tuple[str, int | None]: """Return (network_prefix, amount_msat or None if amount-less).""" # hrp is ln + currency + optional amount. currency is 'bc', 'tb', or 'bcrt'. rest = hrp[2:] for cur in ("bcrt", "bc", "tb"): if rest.startswith(cur): amt = rest[len(cur):] net = "ln" + cur break else: return hrp, None if not amt: return net, None mult = {"m": 0.001, "u": 1e-6, "n": 1e-9, "p": 1e-12} if amt[-1] in mult: btc = float(amt[:-1]) * mult[amt[-1]] else: btc = float(amt) msats = int(round(btc * 1e11)) # 1 BTC = 10^11 msat return net, msats def read_int(five: list[int]) -> int: n = 0 for v in five: n = (n << 5) | v return n def parse_tags(words: list[int]) -> dict: # first 7 words (35 bits) = unix timestamp if len(words) < 7: raise ValueError("invoice data too short") ts = read_int(words[:7]) i = 7 fields: dict = {"timestamp": ts, "description": None, "description_hash": None, "payment_hash": None, "expiry": 3600, "payment_secret": None, "payee_node": None, "min_final_cltv": None, "unknown": []} while i + 3 <= len(words): # last 104 words are signature; stop when remaining is the sig remaining = len(words) - i if remaining == 104: break typ = words[i] dlen = (words[i + 1] << 5) | words[i + 2] i += 3 data = words[i:i + dlen] i += dlen if typ == 1: # p payment_hash fields["payment_hash"] = bytes(convertbits(data, 5, 8, False)).hex() elif typ == 13: # d description fields["description"] = bytes(convertbits(data, 5, 8, False)).decode("utf-8", "replace") elif typ == 23: # h description_hash fields["description_hash"] = bytes(convertbits(data, 5, 8, False)).hex() elif typ == 16: # s payment_secret fields["payment_secret"] = bytes(convertbits(data, 5, 8, False)).hex() elif typ == 19: # n payee fields["payee_node"] = bytes(convertbits(data, 5, 8, False)).hex() elif typ == 6: # x expiry fields["expiry"] = read_int(data) elif typ == 24: # c min_final_cltv_expiry fields["min_final_cltv"] = read_int(data) else: fields["unknown"].append({"type": typ, "len": dlen}) if len(words) - i != 104: # some invoices have extra words; still try fields["sig_words"] = len(words) - i else: fields["sig_words"] = 104 return fields def decode_bolt11(invoice: str) -> dict: hrp, words = bech32_decode(invoice) net, msats = parse_amount(hrp) fields = parse_tags(words) now = int(time.time()) expires_at = fields["timestamp"] + int(fields["expiry"]) out = { "network": net, "hrp": hrp, "amount_msat": msats, "amount_sat": None if msats is None else msats / 1000, "timestamp": fields["timestamp"], "expiry_seconds": fields["expiry"], "expires_at": expires_at, "expired": now >= expires_at, "seconds_left": max(0, expires_at - now), "payment_hash": fields["payment_hash"], "description": fields["description"], "description_hash": fields["description_hash"], "has_payment_secret": bool(fields["payment_secret"]), "payee_node": fields["payee_node"], "min_final_cltv": fields["min_final_cltv"], "ok_structure": bool(fields["payment_hash"]) and len(fields["payment_hash"] or "") == 64, } return out def fetch_json(url: str, timeout: int = 20) -> dict: req = urllib.request.Request(url, headers={"accept": "application/json", "user-agent": "verify_ln.py/1.0"}) with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read().decode()) def lnurl_decode(s: str) -> str: s = s.strip() if s.lower().startswith("lightning:"): s = s.split(":", 1)[1] if "@" in s and s.count("@") == 1 and not s.lower().startswith("lnurl"): name, domain = s.split("@", 1) return f"https://{domain}/.well-known/lnurlp/{urllib.parse.quote(name)}" raw = s if raw.lower().startswith("lnurl"): hrp, words = bech32_decode(raw) # reuse, but lnurl1 uses bech32 too if hrp not in ("lnurl",): # bech32_decode requires hrp startswith ln — "lnurl" does pass url_bytes = bytes(convertbits(words, 5, 8, False)) return url_bytes.decode("utf-8") if s.startswith("http"): return s raise ValueError("expected lightning address, lnurl1..., or https URL") def lnurl_pay_and_verify(target: str, amount_sats: int) -> dict: meta_url = lnurl_decode(target) meta = fetch_json(meta_url) checks = [] if meta.get("tag") != "payRequest": checks.append({"check": "tag", "ok": False, "detail": meta.get("tag")}) return {"meta_url": meta_url, "checks": checks, "ok": False} checks.append({"check": "tag", "ok": True, "detail": "payRequest"}) min_m = int(meta.get("minSendable", 0)) max_m = int(meta.get("maxSendable", 0) or 10**15) msats = amount_sats * 1000 in_range = min_m <= msats <= max_m checks.append({ "check": "amount_in_range", "ok": in_range, "detail": f"{amount_sats} sat vs [{min_m//1000}, {max_m//1000}] sat", }) if not in_range: return {"meta_url": meta_url, "metadata": meta.get("metadata"), "checks": checks, "ok": False} cb = meta["callback"] sep = "&" if "?" in cb else "?" inv = fetch_json(f"{cb}{sep}amount={msats}") pr = inv.get("pr") if not pr: checks.append({"check": "invoice_returned", "ok": False, "detail": list(inv.keys())}) return {"meta_url": meta_url, "checks": checks, "ok": False, "callback": inv} checks.append({"check": "invoice_returned", "ok": True}) dec = decode_bolt11(pr) amt_ok = dec["amount_msat"] == msats checks.append({ "check": "invoice_amount", "ok": amt_ok, "detail": f"invoice {dec['amount_msat']} msat, requested {msats} msat", }) # LUD-06: description_hash must be sha256(metadata) metadata = meta.get("metadata") or "" if isinstance(metadata, list): metadata = json.dumps(metadata, separators=(",", ":")) expect_h = hashlib.sha256(metadata.encode("utf-8")).hexdigest() if metadata else None if dec["description_hash"]: h_ok = (expect_h is not None) and dec["description_hash"] == expect_h checks.append({ "check": "description_hash", "ok": h_ok, "detail": "sha256(lnurl metadata) matches invoice h-field" if h_ok else "mismatch or missing metadata", }) elif dec["description"] is not None: checks.append({ "check": "description_plaintext", "ok": True, "detail": "invoice used d-field (plaintext); hash check skipped", }) else: checks.append({"check": "description", "ok": False, "detail": "invoice has neither d nor h"}) checks.append({ "check": "not_expired", "ok": not dec["expired"], "detail": f"{dec['seconds_left']}s left", }) checks.append({ "check": "structure", "ok": dec["ok_structure"], "detail": "32-byte payment_hash present", }) ok = all(c["ok"] for c in checks) return { "ok": ok, "meta_url": meta_url, "min_sats": min_m // 1000, "max_sats": max_m // 1000, "invoice": pr, "decoded": dec, "checks": checks, } def main() -> int: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("invoice", nargs="?", help="BOLT11 string") p.add_argument("--expect-sats", type=int, help="fail if invoice amount != this") p.add_argument("--lnurl", help="lightning address or lnurl1... to quote + verify") p.add_argument("--sats", type=int, help="sats for --lnurl (or alias of --expect-sats)") args = p.parse_args() if args.lnurl: sats = args.sats or args.expect_sats if not sats: p.error("--lnurl needs --sats N") result = lnurl_pay_and_verify(args.lnurl, sats) print(json.dumps(result, indent=2)) return 0 if result.get("ok") else 1 if not args.invoice: p.print_help() return 2 dec = decode_bolt11(args.invoice) extra = [] if args.expect_sats is not None: got = dec["amount_sat"] ok = got is not None and abs(got - args.expect_sats) < 0.001 extra.append({"check": "expect_sats", "ok": ok, "detail": f"got {got}"}) dec["checks"] = extra dec["ok"] = dec["ok_structure"] and not dec["expired"] and ok else: dec["ok"] = dec["ok_structure"] and not dec["expired"] print(json.dumps(dec, indent=2)) return 0 if dec.get("ok") else 1 if __name__ == "__main__": raise SystemExit(main())