// Vectors: the BOLT 11 spec examples (lightning/bolts, 11-payment-encoding.md § Examples), plus the // regtest and signet invoices Zeus uses in its own utils/Bolt11Utils.test.ts. Every spec invoice below is // signed with priv_key e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734. import { describe, expect, it } from 'bun:test'; import { clearBolt11Cache, decodeBolt11, decodeBolt11Invoice } from './bolt11'; import { BackendError } from './types'; // ── spec vectors ───────────────────────────────────────────────────────────────────────────────── /** no amount, description "Please consider supporting this project" */ const NO_AMOUNT = 'lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql'; /** 2500u, description "1 cup coffee", expiry 60 */ const COFFEE_250U = 'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh'; /** 2500u, UTF-8 description, expiry 60 */ const NONSENSE_250U = 'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpu9qrsgqhtjpauu9ur7fw2thcl4y9vfvh4m9wlfyz2gem29g5ghe2aak2pm3ps8fdhtceqsaagty2vph7utlgj48u0ged6a337aewvraedendscp573dxr'; /** 20m, description_hash instead of a description */ const HASHED_20M = 'lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp7ynn44'; /** the same on testnet, with a P2PKH fallback address */ const TESTNET_20M_FALLBACK = 'lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8'; /** 20m with a fallback address and a two-hop route hint */ const MAINNET_20M_ROUTES = 'lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85fr9yq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqpqqqqq9qqqvpeuqafqxu92d8lr6fvg0r5gv0heeeqgcrqlnm6jhphu9y00rrhy4grqszsvpcgpy9qqqqqqgqqqqq7qqzq9qrsgqdfjcdk6w3ak5pca9hwfwfh63zrrz06wwfya0ydlzpgzxkn5xagsqz7x9j4jwe7yj7vaf2k9lqsdk45kts2fd0fkr28am0u4w95tt2nsq76cqw0'; /** 9678785340p — a msat amount that is not a whole number of sats */ const PICO_9678785340P = 'lnbc9678785340p1pwmna7lpp5gc3xfm08u9qy06djf8dfflhugl6p7lgza6dsjxq454gxhj9t7a0sd8dgfkx7cmtwd68yetpd5s9xar0wfjn5gpc8qhrsdfq24f5ggrxdaezqsnvda3kkum5wfjkzmfqf3jkgem9wgsyuctwdus9xgrcyqcjcgpzgfskx6eqf9hzqnteypzxz7fzypfhg6trddjhygrcyqezcgpzfysywmm5ypxxjemgw3hxjmn8yptk7untd9hxwg3q2d6xjcmtv4ezq7pqxgsxzmnyyqcjqmt0wfjjq6t5v4khxsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsxqyjw5qcqp2rzjq0gxwkzc8w6323m55m4jyxcjwmy7stt9hwkwe2qxmy8zpsgg7jcuwz87fcqqeuqqqyqqqqlgqqqqn3qq9q9qrsgqrvgkpnmps664wgkp43l22qsgdw4ve24aca4nymnxddlnp8vh9v2sdxlu5ywdxefsfvm0fq3sesf08uf6q9a2ke0hc9j6z6wlxg5z5kqpu2v9wz'; /** 25m advertising features 8, 14 and 99 */ const FEATURES_25M = 'lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgq2a25dxl5hrntdtn6zvydt7d66hyzsyhqs4wdynavys42xgl6sgx9c4g7me86a27t07mdtfry458rtjr0v92cnmswpsjscgt2vcse3sgpz3uapa'; /** the same invoice in the all-uppercase QR form */ const FEATURES_25M_UPPER = 'LNBC25M1PVJLUEZPP5QQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQYPQDQ5VDHKVEN9V5SXYETPDEESSP5ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYGS9Q5SQQQQQQQQQQQQQQQQSGQ2A25DXL5HRNTDTN6ZVYDT7D66HYZSYHQS4WDYNAVYS42XGL6SGX9C4G7ME86A27T07MDTFRY458RTJR0V92CNMSWPSJSCGT2VCSE3SGPZ3UAPA'; /** 10m carrying payment metadata 0x01fafaf0 */ const METADATA_10M = 'lnbc10m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdp9wpshjmt9de6zqmt9w3skgct5vysxjmnnd9jx2mq8q8a04uqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q2gqqqqqqsgq7hf8he7ecf7n4ffphs6awl9t6676rrclv9ckg3d3ncn7fct63p6s365duk5wrk202cfy3aj5xnnp5gs3vrdvruverwwq7yzhkf5a3xqpd05wjc'; /** a valid invoice whose signature is high-S */ const HIGH_S_SIGNATURE = 'lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap2r09nt4ndd0unm3z9u5t48y6ucv4r5sg7lk98c77ctvjczkspk5qprc90gx'; /** a high-S signature that does not match the invoice's own `n` payee field */ const PAYEE_MISMATCH = 'lnbc25m1p70xwfzpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaqnp4q0n326hr8v9zprg8gsvezcch06gfaqqhde2aj730yg0durunfhv66sp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsp5cfzp9ugllvk03rltd6hvndxj26ux6gcxc5azyxk060rj9tzghct5zvjlps76gx8wpq5yuu79688k8gnm2c0al6v608s96l0xzrrlqqwnzxmu'; /** bech32 checksum is invalid */ const BAD_CHECKSUM = 'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrnt'; /** signature is not recoverable */ const UNRECOVERABLE_SIGNATURE = 'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqwgt7mcn5yqw3yx0w94pswkpq6j9uh6xfqqqtsk4tnarugeektd4hg5975x9am52rz4qskukxdmjemg92vvqz8nvmsye63r5ykel43pgz7zq0g2'; /** string is too short */ const TOO_SHORT = 'lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6na6hlh'; /** invalid amount multiplier */ const BAD_MULTIPLIER = 'lnbc2500x1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqrrzc4cvfue4zp3hggxp47ag7xnrlr8vgcmkjxk3j5jqethnumgkpqp23z9jclu3v0a7e0aruz366e9wqdykw6dxhdzcjjhldxq0w6wgqcnu43j'; /** invalid sub-millisatoshi precision */ const SUB_MSAT_PRECISION = 'lnbc2500000001p1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgq0lzc236j96a95uv0m3umg28gclm5lqxtqqwk32uuk4k6673k6n5kfvx3d2h8s295fad45fdhmusm8sjudfhlf6dcsxmfvkeywmjdkxcp99202x'; // ── Zeus fixtures (utils/Bolt11Utils.test.ts) ──────────────────────────────────────────────────── /** regtest, 1230n = 123 sat, no expiry field */ const REGTEST_1230N = 'lnbcrt1230n1pj429x7pp57t97q4awqj3f529snr0pa6senk83sq5pp760qf5a4jzvd7xgwcksdqqcqzzsxqrrsssp57eqtv7vxr46arupna3w4ct0lkf2mqmz9wt044cwkks0rwlnhfr5s9qyyssqragwpwav7nfwv2xyuuamxxj4pnnpzv2hlw7j473repd3sq7st698ta9kmzmygt0w7tmncl56a6mnma0w7e5dlpqd0wy6x3v35rssldspjhh8p0'; /** signet, 567780n = 56778 sat */ const SIGNET_567780N = 'lntbs567780n1pnqr26ypp5c0wcrpzwxwqnwu2nld5q36dfc9yjrfdp87nn9d5y093jjncvqresdq0w3jhxar8v3n8xeccqzpuxqrrsssp5r94e3nwnw63gjaxc8wex38ufv2m6442vnrw49m7dad9jdum3tdsq9qyyssqk4dvvuk7zhhju8ztf7nfc2hzqq9gqtzuyc0ljz8nl93laxwv4869lt9fsxkxacje6eh4ur5ymg83hvakn4tfpzdu6fq49705sar7fxspga8qjp'; /** Every spec vector shares this payee, payment hash and timestamp. */ const SPEC_PAYEE = '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad'; const SPEC_PAYMENT_HASH = '0001020304050607080900010203040506070809000102030405060708090102'; const SPEC_TIMESTAMP = 1496314658; // ── amounts ────────────────────────────────────────────────────────────────────────────────────── describe('decodeBolt11 amounts', () => { it('returns null for a zero-amount invoice', () => { expect(decodeBolt11(NO_AMOUNT).amountMsat).toBeNull(); }); it('parses every multiplier as an exact msat string', () => { // 20m = 0.02 BTC, 2500u = 0.0025 BTC, 1230n = 123 sat, 9678785340p = 967878534 msat. expect(decodeBolt11(HASHED_20M).amountMsat).toBe('2000000000'); expect(decodeBolt11(COFFEE_250U).amountMsat).toBe('250000000'); expect(decodeBolt11(REGTEST_1230N).amountMsat).toBe('123000'); expect(decodeBolt11(PICO_9678785340P).amountMsat).toBe('967878534'); }); it('keeps amountMsat a string and never widens it to a number', () => { const amount = decodeBolt11(PICO_9678785340P).amountMsat; expect(typeof amount).toBe('string'); // A pico amount need not be a whole sat: the msat string is exact where `satoshis` cannot be. expect(decodeBolt11Invoice(PICO_9678785340P).satoshis).toBeNull(); expect(decodeBolt11Invoice(REGTEST_1230N).satoshis).toBe(123); }); it('rejects an unknown multiplier', () => { expect(() => decodeBolt11(BAD_MULTIPLIER)).toThrow(BackendError); }); it('rejects sub-millisatoshi precision instead of rounding it', () => { expect(() => decodeBolt11(SUB_MSAT_PRECISION)).toThrow(BackendError); }); }); // ── fields ─────────────────────────────────────────────────────────────────────────────────────── describe('decodeBolt11 fields', () => { it('extracts the payment hash, destination and timestamp', () => { const decoded = decodeBolt11(NO_AMOUNT); expect(decoded.paymentHash).toBe(SPEC_PAYMENT_HASH); expect(decoded.destination).toBe(SPEC_PAYEE); expect(decoded.timestamp).toBe(SPEC_TIMESTAMP); }); it('recovers a key from a high-S signature instead of rejecting it', () => { // The spec's high-S vector is the donation invoice re-signed with s negated while keeping recovery // flag 1, so it recovers a *different* (valid) key. What matters is that recovery runs at all — // implementations that enforce low-S during recovery fail here. expect(decodeBolt11(HIGH_S_SIGNATURE).destination).toMatch(/^0[23][0-9a-f]{64}$/); expect(decodeBolt11(HIGH_S_SIGNATURE).destination).not.toBe(SPEC_PAYEE); expect(decodeBolt11(HIGH_S_SIGNATURE).paymentHash).toBe(SPEC_PAYMENT_HASH); }); it('returns the description for a `d` invoice and null for an `h` one', () => { expect(decodeBolt11(NO_AMOUNT).description).toBe('Please consider supporting this project'); expect(decodeBolt11(COFFEE_250U).description).toBe('1 cup coffee'); expect(decodeBolt11(NONSENSE_250U).description).toBe('ナンセンス 1杯'); const hashed = decodeBolt11Invoice(HASHED_20M); expect(decodeBolt11(HASHED_20M).description).toBeNull(); expect(hashed.descriptionHash).toBe('3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1'); }); it('defaults expiry to 3600 when there is no `x` field', () => { expect(decodeBolt11(NO_AMOUNT).expiry).toBe(3600); expect(decodeBolt11Invoice(NO_AMOUNT).expiry).toBeNull(); expect(decodeBolt11(REGTEST_1230N).expiry).toBe(3600); }); it('reads an explicit expiry', () => { expect(decodeBolt11(COFFEE_250U).expiry).toBe(60); expect(decodeBolt11(PICO_9678785340P).expiry).toBe(604800); expect(decodeBolt11Invoice(COFFEE_250U).expiresAt).toBe(SPEC_TIMESTAMP + 60); }); it('expands the feature bitmap to the indices of the set bits', () => { expect(decodeBolt11(NO_AMOUNT).features).toEqual(['8', '14']); expect(decodeBolt11(FEATURES_25M).features).toEqual(['8', '14', '99']); }); it('reports route hints and parses their hops', () => { expect(decodeBolt11(NO_AMOUNT).routeHints).toBe(false); const decoded = decodeBolt11Invoice(MAINNET_20M_ROUTES); expect(decodeBolt11(MAINNET_20M_ROUTES).routeHints).toBe(true); expect(decoded.routes).toHaveLength(1); expect(decoded.routes[0]).toEqual([ { pubkey: '029e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255', shortChannelId: '66051x263430x1800', feeBaseMsat: 1, feeProportionalMillionths: 20, cltvExpiryDelta: 3, }, { pubkey: '039e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255', shortChannelId: '197637x395016x2314', feeBaseMsat: 2, feeProportionalMillionths: 30, cltvExpiryDelta: 4, }, ]); }); it('parses payment secret, metadata and fallback addresses', () => { expect(decodeBolt11Invoice(NO_AMOUNT).paymentSecret).toBe( '1111111111111111111111111111111111111111111111111111111111111111', ); expect(decodeBolt11Invoice(METADATA_10M).metadata).toBe('01fafaf0'); const fallback = decodeBolt11Invoice(TESTNET_20M_FALLBACK).fallbacks[0]; // 17 is the P2PKH marker; the program is the 20-byte hash160 of mk2QpYatsKicvFVuTAQLBryyccRXMUaGHP. expect(fallback?.version).toBe(17); expect(fallback?.programHex).toHaveLength(40); }); it('leaves cltvExpiry null when the invoice omits `c`', () => { expect(decodeBolt11(NO_AMOUNT).cltvExpiry).toBeNull(); }); }); // ── networks ───────────────────────────────────────────────────────────────────────────────────── describe('decodeBolt11 networks', () => { it('resolves every bech32 network prefix', () => { expect(decodeBolt11Invoice(NO_AMOUNT).network).toBe('bitcoin'); expect(decodeBolt11Invoice(TESTNET_20M_FALLBACK).network).toBe('testnet'); expect(decodeBolt11Invoice(SIGNET_567780N).network).toBe('signet'); expect(decodeBolt11Invoice(REGTEST_1230N).network).toBe('regtest'); }); it('parses the amount out of a multi-letter network prefix', () => { // `lnbcrt1230n` and `lntbs567780n` are the cases where a greedy hrp match eats the amount. expect(decodeBolt11(REGTEST_1230N).amountMsat).toBe('123000'); expect(decodeBolt11(SIGNET_567780N).amountMsat).toBe('56778000'); }); it('rejects an unknown network prefix', () => { expect(() => decodeBolt11(NO_AMOUNT.replace(/^lnbc/, 'lnxyz'))).toThrow(BackendError); }); }); // ── malformed input ────────────────────────────────────────────────────────────────────────────── describe('decodeBolt11 rejections', () => { const expectBadInvoice = (invoice: string) => { let caught: unknown; try { decodeBolt11(invoice); } catch (err) { caught = err; } expect(caught).toBeInstanceOf(BackendError); expect((caught as BackendError).status).toBe(400); expect((caught as BackendError).code).toBe('BAD_INVOICE'); }; it('throws BackendError(400, BAD_INVOICE) on a corrupted checksum', () => { expectBadInvoice(BAD_CHECKSUM); // The same invoice with one data character flipped. expectBadInvoice(`${COFFEE_250U.slice(0, -1)}${COFFEE_250U.endsWith('h') ? 'w' : 'h'}`); }); it('throws on a signature that cannot be recovered', () => { expectBadInvoice(UNRECOVERABLE_SIGNATURE); }); it('throws when the recovered key contradicts an explicit `n` payee field', () => { expectBadInvoice(PAYEE_MISMATCH); }); it('throws on a truncated payment request', () => { expectBadInvoice(TOO_SHORT); }); it('throws on input that is not a payment request at all', () => { expectBadInvoice(''); expectBadInvoice('not an invoice'); expectBadInvoice(NO_AMOUNT.replace(/^ln/, '')); }); it('throws on a mixed-case payment request but accepts the uppercase QR form', () => { expectBadInvoice(`LNBC${NO_AMOUNT.slice(4)}`); expect(decodeBolt11(FEATURES_25M_UPPER)).toEqual(decodeBolt11(FEATURES_25M)); // The contract's `bolt11` is always the canonical lowercase form. expect(decodeBolt11(FEATURES_25M_UPPER).bolt11).toBe(FEATURES_25M); }); }); // ── cache ──────────────────────────────────────────────────────────────────────────────────────── describe('the LRU cache', () => { it('returns the same object for a repeat decode and survives a clear', () => { clearBolt11Cache(); const first = decodeBolt11Invoice(COFFEE_250U); expect(decodeBolt11Invoice(COFFEE_250U)).toBe(first); expect(decodeBolt11Invoice(COFFEE_250U.toUpperCase())).toBe(first); clearBolt11Cache(); const afterClear = decodeBolt11Invoice(COFFEE_250U); expect(afterClear).not.toBe(first); expect(afterClear).toEqual(first); }); it('hands every decodeBolt11 caller its own object', () => { const first = decodeBolt11(COFFEE_250U); const second = decodeBolt11(COFFEE_250U); expect(second).not.toBe(first); expect(second).toEqual(first); }); });