/** * Minimal, dependency-free BOLT11 Lightning invoice amount decoder. * * WHY THIS EXISTS * --------------- * nostr-tools removed `nip57.getSatoshisAmountFromBolt11` in a newer minor, so * we decode the amount ourselves. The amount is encoded in the invoice's * human-readable prefix (HRP), NOT the bech32 data — so we don't need a full * bech32 decode, just to parse the leading `ln`. * * BOLT11 amount format (from the spec): * - HRP looks like: lnbc1 * - is an integer; is one of: * m (milli) = 0.001 BTC * u (micro) = 0.000001 BTC * n (nano) = 0.000000001 BTC * p (pico) = 0.000000000001 BTC * - No amount at all → a "donation" invoice (0 / unspecified). * * 1 BTC = 100_000_000 sats. */ const MULTIPLIERS: Record = { m: 0.001, u: 0.000001, n: 0.000000001, p: 0.000000000001, }; const SATS_PER_BTC = 100_000_000; /** * Decode the amount (in whole satoshis) from a BOLT11 invoice string. * Returns 0 for amountless invoices. Throws on a clearly malformed prefix so * callers can fall back to other amount sources (e.g. the zap `amount` tag). */ export function getSatoshisFromBolt11(invoice: string): number { if (!invoice || typeof invoice !== 'string') { throw new Error('Invalid BOLT11 invoice'); } const lower = invoice.trim().toLowerCase(); // The HRP is everything before the bech32 separator '1' that precedes the // data part. BOLT11 HRPs start with "ln". Grab from the start up to (but not // including) the final separator between HRP and data. In practice the amount // block sits right after the network prefix (lnbc / lntb / lntbs / lnbcrt). const match = lower.match(/^ln(?:bc|tb|tbs|bcrt|sb)(\d+)?([munp])?1/); if (!match) { throw new Error('Not a recognizable BOLT11 invoice'); } const amountStr = match[1]; const multiplier = match[2]; // No amount encoded → amountless invoice. if (!amountStr) return 0; const amount = parseInt(amountStr, 10); if (Number.isNaN(amount)) { throw new Error('Invalid amount in BOLT11 invoice'); } // btc value = amount * multiplier (multiplier absent means whole BTC units). const btc = multiplier ? amount * MULTIPLIERS[multiplier] : amount; return Math.round(btc * SATS_PER_BTC); } /** bech32 charset (the data-part alphabet). */ const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; /** * Extract the **payment hash** (hex) from a BOLT11 invoice — the `p` tagged * field. Used to verify a Lightning **preimage receipt**: `sha256(preimage)` * must equal this hash. Dependency-free (we don't verify the bech32 checksum — * a bad invoice just yields a hash that fails the preimage check). */ export function getPaymentHashFromBolt11(invoice: string): string | null { try { const lower = invoice.trim().toLowerCase(); const sep = lower.lastIndexOf('1'); if (sep < 1 || sep + 7 > lower.length) return null; // Data words = everything after the separator, minus the 6-word checksum. const data = lower.slice(sep + 1, lower.length - 6); const words = [...data].map((c) => CHARSET.indexOf(c)); if (words.some((w) => w < 0)) return null; // Tagged fields sit after the 35-bit timestamp (7 five-bit words). let i = 7; while (i + 3 <= words.length) { const tag = words[i]; const len = (words[i + 1] << 5) | words[i + 2]; const start = i + 3; if (start + len > words.length) break; if (tag === 1) { // `p` = payment hash: 32 bytes packed from `len` five-bit words. const bytes: number[] = []; let acc = 0; let bits = 0; for (let k = start; k < start + len; k += 1) { acc = (acc << 5) | words[k]; bits += 5; if (bits >= 8) { bits -= 8; bytes.push((acc >>> bits) & 0xff); } } return bytes.slice(0, 32).map((b) => b.toString(16).padStart(2, '0')).join(''); } i = start + len; } return null; } catch { return null; } }