// BOLT11 invoice decoding, in-process — no node round-trip. // // Ported from Zeus's `utils/Bolt11Utils.ts`, which is itself derived from **light-bolt11-decoder**: // Copyright (c) 2021 bitcoinjs contributors, fiatjaf. MIT licence. // The bech32 walk, the human-readable-part amount grammar and the signature preimage construction are // that upstream's work and are preserved verbatim in behaviour. // // INTENTIONAL DIFFERENCES from Zeus's port: // // 1. ERRORS. Zeus mixes `throw new Error(...)` with silent `undefined` returns (a bad multiplier sets // `satoshis = null` and carries on; a `fromWordsUnsafe` failure yields `undefined`). Every failure // here is a `BackendError(msg, 400, 'BAD_INVOICE')`, so routes.ts answers 400 rather than leaking a // half-decoded invoice with null amounts into a payment flow. // 2. NO bignumber.js. All amount arithmetic is BigInt: msat tops out at 2.1e18, which a double cannot // hold, and BigInt does the same job exactly with one less dependency. // 3. NO @noble/hashes. The single sha256 comes from `node:crypto`, which Bun provides natively. // 4. EAGER signature recovery. Zeus installs lazy getters for `destination`/`signature` because its // Activity list decodes hundreds of invoices to read only the timestamp. Here `DecodedInvoice` // requires a non-null `destination` on every call, so laziness would never pay off; the LRU cache // (which is what actually collapses repeat recoveries) is kept and sized for a server. // 5. NO `sections` array. Zeus keeps light-bolt11-decoder's positional `{name, letters, value?: any}` // list for back-compat. Nothing here consumes it, and it was the source of the `any`. Every tag is // exposed as a named, typed field instead, with anything unrecognised in `unknownTags`. // 6. MORE TAGS PARSED. Zeus leaves `r` (route hints), `9` (features) and `f` (fallback address) as // unknown tags. All three are parsed, because the contract's `routeHints` and `features` fields need // them. Feature bits are expanded to the indices of the set bits, matching how backends/clnrest.ts // renders CLN's bitmap. // 7. SPEC-CONFORMANT LENIENCY/STRICTNESS. Known tags whose `data_length` is wrong are skipped and // unknown tags ignored (BOLT 11 requires both). An all-uppercase invoice — the QR form — is accepted // and lowercased; a MIXED-case one is rejected, as the spec requires and Zeus's blanket // `.toLowerCase()` did not. An explicit `n` payee is checked against the recovered key rather than // trusted blindly, which is the check Zeus skips. // 8. NO SIMNET. Zeus carries the btcd-only `sb` prefix; `BitcoinNetwork` has no member for it. import { createHash } from 'node:crypto'; import { bech32 } from 'bech32'; import { recoverPublicKey } from '@noble/secp256k1'; import { BackendError, type BitcoinNetwork, type DecodedInvoice } from './types'; // ── shape ──────────────────────────────────────────────────────────────────────────────────────── export type Bolt11NetworkParams = { /** The bech32 human-readable part that follows `ln`. */ readonly bech32: string; readonly network: BitcoinNetwork; readonly pubKeyHash: number; readonly scriptHash: number; readonly validWitnessVersions: readonly number[]; }; /** One hop of an `r` field. Multiple `r` fields each describe a separate route. */ export type Bolt11RouteHop = { readonly pubkey: string; /** `block x tx x output`, the standard short_channel_id rendering. */ readonly shortChannelId: string; readonly feeBaseMsat: number; readonly feeProportionalMillionths: number; readonly cltvExpiryDelta: number; }; /** An `f` field. Left as witness-version + program rather than re-encoded to an address string. */ export type Bolt11Fallback = { /** 0-16 are segwit witness versions; 17 is P2PKH and 18 is P2SH. */ readonly version: number; readonly programHex: string; }; export type Bolt11UnknownTag = { readonly tagCode: number; readonly words: readonly number[] }; /** The full decode. `decodeBolt11` projects this onto the narrower `DecodedInvoice` contract. */ export type Bolt11Invoice = { /** The invoice, lowercased. */ readonly paymentRequest: string; /** The whole human-readable part, e.g. `lnbc2500u`. */ readonly prefix: string; readonly network: BitcoinNetwork; readonly networkParams: Bolt11NetworkParams; readonly timestamp: number; /** Decimal msat string, or null for a zero-amount ("any amount") invoice. Never a number. */ readonly millisatoshis: string | null; /** Convenience only, and null whenever the amount is not a whole number of sats. */ readonly satoshis: number | null; readonly paymentHash: string; readonly paymentSecret: string | null; readonly description: string | null; readonly descriptionHash: string | null; /** The explicit `n` payee field, when the invoice carries one. */ readonly payee: string | null; /** `payee` when present, else recovered from the signature. 33-byte compressed pubkey, hex. */ readonly destination: string; /** The raw `x` field; null when absent. `expirySeconds` applies the spec's 3600 default. */ readonly expiry: number | null; readonly expirySeconds: number; readonly expiresAt: number; readonly cltvExpiry: number | null; readonly metadata: string | null; /** Indices of the set feature bits, as decimal strings. */ readonly featureBits: readonly string[]; readonly routes: readonly (readonly Bolt11RouteHop[])[]; readonly fallbacks: readonly Bolt11Fallback[]; /** 64-byte compact signature, hex. */ readonly signature: string; readonly recoveryFlag: number; readonly unknownTags: readonly Bolt11UnknownTag[]; }; // ── constants ──────────────────────────────────────────────────────────────────────────────────── /** * Zeus caches 256 entries for a phone's Activity list. A server decodes the same invoice from several * call sites (list → detail → pay), so the cache earns its keep the same way; 1024 entries of a few * hundred bytes each is a rounding error against a Bun heap. */ const CACHE_LIMIT = 1024; const cache = new Map(); const MSAT_PER_BTC = 100_000_000_000n; const MAX_MSAT = 2_100_000_000_000_000_000n; const DIVISORS: Readonly> = { m: 1_000n, u: 1_000_000n, n: 1_000_000_000n, p: 1_000_000_000_000n, }; const NETWORKS: readonly Bolt11NetworkParams[] = [ { bech32: 'bc', network: 'bitcoin', pubKeyHash: 0x00, scriptHash: 0x05, validWitnessVersions: [0, 1] }, { bech32: 'tb', network: 'testnet', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] }, { bech32: 'tbs', network: 'signet', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] }, { bech32: 'bcrt', network: 'regtest', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] }, ]; const TAG_NAMES = { 1: 'payment_hash', 3: 'route_hint', 5: 'feature_bits', 6: 'expiry', 9: 'fallback_address', 13: 'description', 16: 'payment_secret', 19: 'payee', 23: 'description_hash', 24: 'min_final_cltv_expiry', 27: 'metadata', } as const satisfies Record; /** BOLT 11: a reader MUST skip a `p`, `s`, `h` or `n` field whose data_length is not the fixed size. */ const FIXED_TAG_WORDS: Readonly> = { 1: 52, 16: 52, 23: 52, 19: 53 }; /** The signature occupies the last 104 words — 65 bytes: 64-byte compact sig + 1 recovery byte. */ const SIGNATURE_WORDS = 104; const TIMESTAMP_WORDS = 7; /** BOLT 11 default when no `x` field is present. */ const DEFAULT_EXPIRY_SECONDS = 3600; const ROUTE_HOP_BYTES = 51; // ── helpers ────────────────────────────────────────────────────────────────────────────────────── function badInvoice(message: string): never { throw new BackendError(`invalid bolt11 invoice: ${message}`, 400, 'BAD_INVOICE'); } /** Big-endian base-32. BigInt because a hostile tag can claim far more than 53 bits. */ function wordsToInt(words: readonly number[]): number { let value = 0n; for (const word of words) value = value * 32n + BigInt(word); if (value > BigInt(Number.MAX_SAFE_INTEGER)) badInvoice('a numeric field is out of range'); return Number(value); } function wordsToBytes(words: readonly number[]): Uint8Array { const bytes = bech32.fromWordsUnsafe(words); if (bytes == null) badInvoice('a field is not a whole number of bytes'); return Uint8Array.from(bytes); } const wordsToHex = (words: readonly number[]): string => Buffer.from(wordsToBytes(words)).toString('hex'); const wordsToUtf8 = (words: readonly number[]): string => Buffer.from(wordsToBytes(words)).toString('utf8'); /** * The `9` field is a big-endian bit vector whose LAST bit is feature 0, so a word's position from the * end fixes the base index. Rendered as decimal strings to match backends/clnrest.ts's `featureBits`. */ function wordsToFeatureBits(words: readonly number[]): string[] { const bits: string[] = []; for (let i = words.length - 1; i >= 0; i--) { const word = words[i] ?? 0; const base = (words.length - 1 - i) * 5; for (let bit = 0; bit < 5; bit++) { if (word & (1 << bit)) bits.push(String(base + bit)); } } return bits.sort((a, b) => Number(a) - Number(b)); } function wordsToRoute(words: readonly number[]): Bolt11RouteHop[] { const bytes = Buffer.from(wordsToBytes(words)); if (bytes.length === 0 || bytes.length % ROUTE_HOP_BYTES !== 0) badInvoice('a route hint is malformed'); const hops: Bolt11RouteHop[] = []; for (let offset = 0; offset < bytes.length; offset += ROUTE_HOP_BYTES) { const hop = bytes.subarray(offset, offset + ROUTE_HOP_BYTES); const scid = hop.subarray(33, 41); const block = scid.readUIntBE(0, 3); const tx = scid.readUIntBE(3, 3); const output = scid.readUInt16BE(6); hops.push({ pubkey: hop.subarray(0, 33).toString('hex'), shortChannelId: `${block}x${tx}x${output}`, feeBaseMsat: hop.readUInt32BE(41), feeProportionalMillionths: hop.readUInt32BE(45), cltvExpiryDelta: hop.readUInt16BE(49), }); } return hops; } function wordsToFallback(words: readonly number[]): Bolt11Fallback | null { const version = words[0]; // A fallback with no program is meaningless; the spec says to ignore an unparseable one. if (version === undefined || words.length < 2) return null; return { version, programHex: wordsToHex(words.slice(1)) }; } /** * The amount grammar from the human-readable part: ``, where the multiplier * divides one bitcoin. Integer arithmetic throughout — a `p` amount that is not a multiple of 10 would * be sub-millisatoshi and is invalid rather than rounded. */ function hrpToMsat(amount: string, multiplier: string): string { if (!/^\d+$/.test(amount)) badInvoice(`"${amount}" is not a valid amount`); if (multiplier && !(multiplier in DIVISORS)) badInvoice(`"${multiplier}" is not a valid amount multiplier`); const value = BigInt(amount); const divisor = multiplier ? DIVISORS[multiplier] : undefined; const msat = divisor === undefined ? value * MSAT_PER_BTC : (value * MSAT_PER_BTC) / divisor; if (multiplier === 'p' && value % 10n !== 0n) badInvoice('amount has sub-millisatoshi precision'); if (msat > MAX_MSAT) badInvoice('amount is outside of valid range'); return msat.toString(); } /** 5-bit → 8-bit, right-padded with zeros: the preimage the signature covers. */ function convertBits(words: readonly number[]): Uint8Array { let value = 0; let bits = 0; const result: number[] = []; for (const word of words) { value = (value << 5) | word; bits += 5; while (bits >= 8) { bits -= 8; result.push((value >> bits) & 0xff); } } if (bits > 0) result.push((value << (8 - bits)) & 0xff); return Uint8Array.from(result); } type Recovered = { destination: string; signature: string; recoveryFlag: number }; /** The signed message is sha256( utf8(prefix) || convertBits(dataWords) ). SEC1 recovery from there. */ function recoverPayee(prefix: string, sigWords: readonly number[], signedWords: readonly number[]): Recovered { const sigBytes = wordsToBytes(sigWords); const recoveryFlag = sigBytes[64]; if (sigBytes.length !== 65 || recoveryFlag === undefined || recoveryFlag > 3) { badInvoice('signature is malformed'); } const preimage = Buffer.concat([Buffer.from(prefix, 'utf8'), Buffer.from(convertBits(signedWords))]); const hash = createHash('sha256').update(preimage).digest(); // @noble/secp256k1 v3 wants the recovery byte FIRST; bech32 carries it last. const recoverable = Buffer.concat([Buffer.from([recoveryFlag]), Buffer.from(sigBytes.subarray(0, 64))]); let pubkey: Uint8Array; try { pubkey = recoverPublicKey(recoverable, hash, { prehash: false }); } catch { badInvoice('signature is not recoverable'); } return { destination: Buffer.from(pubkey).toString('hex'), signature: Buffer.from(sigBytes.subarray(0, 64)).toString('hex'), recoveryFlag, }; } /** `lnbc2500u` → the network params and the amount. */ function parsePrefix(prefix: string): { params: Bolt11NetworkParams; millisatoshis: string | null } { // Non-greedy on the hrp so the trailing digits+multiplier win: `lnbcrt500u` → bcrt / 500 / u. A prefix // with no amount ends in a letter the first pattern would eat, hence the amount-less second attempt. let matches = /^ln(\S+?)(\d+)([a-z]?)$/.exec(prefix); let amount = matches?.[2] ?? ''; let multiplier = matches?.[3] ?? ''; if (!matches) { matches = /^ln(\S+)$/.exec(prefix); amount = ''; multiplier = ''; } const hrp = matches?.[1]; if (!hrp) badInvoice('not a lightning payment request'); const params = NETWORKS.find((candidate) => candidate.bech32 === hrp); if (!params) badInvoice(`unknown network prefix "ln${hrp}"`); return { params, millisatoshis: amount ? hrpToMsat(amount, multiplier) : null }; } type Tags = { paymentHash: string | null; paymentSecret: string | null; description: string | null; descriptionHash: string | null; payee: string | null; expiry: number | null; cltvExpiry: number | null; metadata: string | null; featureBits: string[]; routes: Bolt11RouteHop[][]; fallbacks: Bolt11Fallback[]; unknownTags: Bolt11UnknownTag[]; }; /** Walks the tagged fields. First occurrence of a field wins; unknown and wrong-length fields are skipped. */ function parseTags(dataWords: readonly number[]): Tags { const tags: Tags = { paymentHash: null, paymentSecret: null, description: null, descriptionHash: null, payee: null, expiry: null, cltvExpiry: null, metadata: null, featureBits: [], routes: [], fallbacks: [], unknownTags: [], }; let words = dataWords; while (words.length > 0) { const tagCode = words[0]; if (tagCode === undefined) break; if (words.length < 3) badInvoice('a tagged field is truncated'); const length = wordsToInt(words.slice(1, 3)); words = words.slice(3); if (length > words.length) badInvoice('a tagged field overruns the invoice'); const tagWords = words.slice(0, length); words = words.slice(length); const expected = FIXED_TAG_WORDS[tagCode]; if (expected !== undefined && length !== expected) continue; const name: string | undefined = TAG_NAMES[tagCode as keyof typeof TAG_NAMES]; switch (name) { case 'payment_hash': tags.paymentHash ??= wordsToHex(tagWords); break; case 'payment_secret': tags.paymentSecret ??= wordsToHex(tagWords); break; case 'description': tags.description ??= wordsToUtf8(tagWords); break; case 'description_hash': tags.descriptionHash ??= wordsToHex(tagWords); break; case 'payee': tags.payee ??= wordsToHex(tagWords); break; case 'expiry': tags.expiry ??= wordsToInt(tagWords); break; case 'min_final_cltv_expiry': tags.cltvExpiry ??= wordsToInt(tagWords); break; case 'metadata': tags.metadata ??= wordsToHex(tagWords); break; case 'feature_bits': if (tags.featureBits.length === 0) tags.featureBits = wordsToFeatureBits(tagWords); break; case 'route_hint': tags.routes.push(wordsToRoute(tagWords)); break; case 'fallback_address': { const fallback = wordsToFallback(tagWords); if (fallback) tags.fallbacks.push(fallback); break; } default: tags.unknownTags.push({ tagCode, words: tagWords }); } } return tags; } // ── the decoder ────────────────────────────────────────────────────────────────────────────────── /** * Full decode, cached. The returned object is shared with every other caller for the same invoice and is * typed readonly throughout — treat it as frozen. */ export function decodeBolt11Invoice(paymentRequest: string): Bolt11Invoice { if (typeof paymentRequest !== 'string') badInvoice('expected a string'); const trimmed = paymentRequest.trim(); // The QR form is all-uppercase and legal; a mixed-case string is not (BOLT 11 / BIP-173). if (/[a-z]/.test(trimmed) && /[A-Z]/.test(trimmed)) badInvoice('mixed-case payment request'); const normalized = trimmed.toLowerCase(); const cached = cache.get(normalized); if (cached) { // Re-insert to move to the MRU end of the Map's insertion order. cache.delete(normalized); cache.set(normalized, cached); return cached; } if (!normalized.startsWith('ln')) badInvoice('not a lightning payment request'); let decoded: { prefix: string; words: number[] }; try { decoded = bech32.decode(normalized, Number.MAX_SAFE_INTEGER); } catch (err) { badInvoice(err instanceof Error ? err.message.toLowerCase() : 'bech32 decode failed'); } if (decoded.words.length < SIGNATURE_WORDS + TIMESTAMP_WORDS) badInvoice('payment request is too short'); const { params, millisatoshis } = parsePrefix(decoded.prefix); const signedWords = decoded.words.slice(0, -SIGNATURE_WORDS); const sigWords = decoded.words.slice(-SIGNATURE_WORDS); const timestamp = wordsToInt(signedWords.slice(0, TIMESTAMP_WORDS)); const tags = parseTags(signedWords.slice(TIMESTAMP_WORDS)); if (!tags.paymentHash) badInvoice('no payment hash'); const recovered = recoverPayee(decoded.prefix, sigWords, signedWords); // BOLT 11: when an `n` field is present a reader MUST still check the signature against it. A correct // signer always recovers to its own key, so a mismatch means the invoice was tampered with. if (tags.payee && tags.payee !== recovered.destination) badInvoice('signature does not match the payee'); const msat = millisatoshis === null ? null : BigInt(millisatoshis); const expirySeconds = tags.expiry ?? DEFAULT_EXPIRY_SECONDS; const invoice: Bolt11Invoice = { paymentRequest: normalized, prefix: decoded.prefix, network: params.network, networkParams: params, timestamp, millisatoshis, satoshis: msat !== null && msat % 1000n === 0n ? Number(msat / 1000n) : null, paymentHash: tags.paymentHash, paymentSecret: tags.paymentSecret, description: tags.description, descriptionHash: tags.descriptionHash, payee: tags.payee, destination: tags.payee ?? recovered.destination, expiry: tags.expiry, expirySeconds, expiresAt: timestamp + expirySeconds, cltvExpiry: tags.cltvExpiry, metadata: tags.metadata, featureBits: tags.featureBits, routes: tags.routes, fallbacks: tags.fallbacks, signature: recovered.signature, recoveryFlag: recovered.recoveryFlag, unknownTags: tags.unknownTags, }; if (cache.size >= CACHE_LIMIT) { const oldest = cache.keys().next(); if (!oldest.done) cache.delete(oldest.value); } cache.set(normalized, invoice); return invoice; } /** * The contract surface: a BOLT11 payment request as the backends report it. Throws * `BackendError(…, 400, 'BAD_INVOICE')` for anything that does not decode. */ export function decodeBolt11(bolt11: string): DecodedInvoice { const invoice = decodeBolt11Invoice(bolt11); return { bolt11: invoice.paymentRequest, paymentHash: invoice.paymentHash, amountMsat: invoice.millisatoshis, description: invoice.description, destination: invoice.destination, timestamp: invoice.timestamp, expiry: invoice.expirySeconds, cltvExpiry: invoice.cltvExpiry, routeHints: invoice.routes.length > 0, features: [...invoice.featureBits], }; } /** Test seam: the LRU is process-global, so a test that measures recovery cost needs to reset it. */ export function clearBolt11Cache(): void { cache.clear(); }