/** * pnpm --filter ./api test:lnurl * * The LNURL ecosystem, checked against the two things it has to agree with: the * responses recorded in `NOTES-LNURL.md`, and the rules written down in * `docs/KIND-LNURL-MINT.md`. Those two documents are the specification; this file is * what stops the code and the documents drifting apart in silence. * * Four groups: * * 1. **Parsers**, against verbatim captures of the live reference instance and of a * locally run mint with no funding source. Not hand-written approximations — the * bytes that were actually on the wire. * 2. **The `d` rules**, including the sticky-identity case that the kind document * spends a section on and that a naive implementation gets wrong. * 3. **The features vocabulary**, including the two negative cases that matter: * nothing invents `lud21`, and nothing invents `rotate`/`split`/`merge`. * 4. **A full round trip** — announcement and review published to a real relay by this * site's own publisher, read back by the indexer's own parsers, resolved to a row. * The relay is `test-relay.ts`, in-process and on an ephemeral port, so this runs * in CI with nothing installed and never touches a public relay. */ import assert from 'node:assert/strict'; import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import { SimplePool } from 'nostr-tools/pool'; import * as nip19 from 'nostr-tools/nip19'; import { ANNOUNCEMENT_KINDS, FEATURE_VOCABULARY, KIND_LNURL_ANNOUNCEMENT, KIND_REVIEW, addressFromPayLink, baseUrlFromKey, ecosystemForKind, featureStates, getMintWarnings, hasFeature, hostIdentifier, isMintPubkey, lnurlIdentifier, lnurlIdentifiers, lnurlKey, mintChip, msatToSat, normalizeLnurlNetwork, normalizeNetwork, onionFromHtml, otherFeatures, parseAdvertisement, parseFeatures, parseLnurlAnnouncement, parsePayInfo, parseRating, parseSoftware, reviewEcosystem, type LnurlFields, type NostrEventLike, } from '@cashumints/shared'; import { announcedFeatures, announcementTemplate, parseAnnounceKey } from './announce.ts'; import { announceConfig } from './config.ts'; import type { MintRow } from './mints.ts'; import { startTestRelay } from './test-relay.ts'; let checks = 0; function check(name: string, fn: () => void): void { try { fn(); checks++; } catch (err) { console.error(`FAIL: ${name}`); throw err; } } async function checkAsync(name: string, fn: () => Promise): Promise { try { await fn(); checks++; } catch (err) { console.error(`FAIL: ${name}`); throw err; } } /* ------------------------------------------------------------------ * * Captures. Verbatim, from NOTES-LNURL.md. * ------------------------------------------------------------------ */ /** `GET https://lnurl.21mint.me/.well-known/lnurlw/_`, 2026-08-21. */ const LIVE_WITHDRAW = { tag: 'withdrawRequest', callback: 'https://lnurl.21mint.me/w', minWithdrawable: 5000, maxWithdrawable: 999899000, defaultDescription: 'lnurlcash bearer note on lnurl.21mint.me', mintPubkey: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555', payLink: 'https://lnurl.21mint.me/.well-known/lnurlp/mint', nodeAlias: 'Azzamo', nodeUri: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555@145.239.92.138:9736', nodeColor: '#68f442', nodeCapacity: 30027500000, nodeNumChannels: 8, nodeNumPeers: 18, }; /** `GET https://lnurl.21mint.me/.well-known/lnurlp/_`, same run. */ const LIVE_PAY = { tag: 'payRequest', callback: 'https://lnurl.21mint.me/p/cb', minSendable: 6000, maxSendable: 1000000000, metadata: '[["text/plain", "Mint an lnurlcash bearer note on lnurl.21mint.me"], ' + '["text/identifier", "_@lnurl.21mint.me"], ["text/plain", "Mint fees: 1000,100"]]', withdrawLink: 'https://lnurl.21mint.me/w', }; /** * The same endpoint on a locally run mint with no `FUNDINGSOURCE_*` configured at all. * * Every node field is *absent* rather than null, because the software runs with * `response_model_exclude_none`. This object is the degraded state, and it is the reason * the probe can report one. */ const NO_FUNDING_WITHDRAW = { tag: 'withdrawRequest', callback: 'https://lnurl.test/w', minWithdrawable: 10000, maxWithdrawable: 999999000, defaultDescription: 'lnurlcash bearer note on lnurl.test', payLink: 'https://lnurl.test/.well-known/lnurlp/mint', }; /* ------------------------------------------------------------------ * * 1. Parsers * ------------------------------------------------------------------ */ check('the live mint advertisement parses into every field the site renders', () => { const ad = parseAdvertisement(LIVE_WITHDRAW); assert.ok(ad, 'the live advertisement must parse'); assert.equal(ad.minWithdrawableMsat, 5000); assert.equal(ad.maxWithdrawableMsat, 999899000); assert.equal(ad.defaultDescription, 'lnurlcash bearer note on lnurl.21mint.me'); assert.equal(ad.mintPubkey, '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555'); assert.equal(ad.nodeAlias, 'Azzamo'); assert.equal(ad.nodeCapacityMsat, 30027500000); assert.equal(ad.nodeChannels, 8); assert.equal(ad.nodePeers, 18); assert.equal(ad.fundingAvailable, true); }); check('millisatoshi limits become the sat figures the verdict strip shows', () => { assert.equal(msatToSat(5000), 5); assert.equal(msatToSat(999899000), 999899); // Floored, not rounded: both numbers are bounds, and rounding a ceiling up would // advertise a note larger than the mint will ever issue. assert.equal(msatToSat(1999), 1); assert.equal(msatToSat(999), 0); assert.equal(msatToSat(null), null); assert.equal(msatToSat(-1), null); }); check('a missing mintPubkey is the degraded state, not a parse failure', () => { const ad = parseAdvertisement(NO_FUNDING_WITHDRAW); assert.ok(ad, 'a mint with no funding source still serves a valid advertisement'); assert.equal(ad.fundingAvailable, false); assert.equal(ad.mintPubkey, null); assert.equal(ad.nodeAlias, null); assert.equal(ad.nodeUri, null); // The limits are real and must still render. This is the whole point of the state. assert.equal(msatToSat(ad.minWithdrawableMsat), 10); assert.equal(msatToSat(ad.maxWithdrawableMsat), 999999); }); check('an LNURL error body is not a mint advertisement, despite arriving as HTTP 200', () => { // These endpoints answer their errors with 200. A probe keying on the status code // would call every one of these "online". assert.equal(parseAdvertisement({ status: 'ERROR', reason: 'Unknown user.' }), null); assert.equal(parseAdvertisement({ status: 'ERROR', reason: 'Unknown note.' }), null); assert.equal(parseAdvertisement({ detail: 'Not Found' }), null); assert.equal(parseAdvertisement(LIVE_PAY), null, 'a payRequest is not a withdrawRequest'); assert.equal(parseAdvertisement(null), null); assert.equal(parseAdvertisement('withdrawRequest'), null); assert.equal(parseAdvertisement([]), null); }); check('an advertisement with inverted or missing bounds is rejected', () => { assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, minWithdrawable: 900, maxWithdrawable: 100 }), null); assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, maxWithdrawable: undefined }), null); assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, maxWithdrawable: 'lots' }), null); }); check('a zero withdraw ceiling parses, because the warning needs to see it', () => { // Rejecting this would turn "withdrawals disabled" into "offline", which is a // different and much less useful thing to tell a reader. const ad = parseAdvertisement({ ...LIVE_WITHDRAW, minWithdrawable: 0, maxWithdrawable: 0 }); assert.ok(ad); assert.equal(ad.maxWithdrawableMsat, 0); }); check('the payRequest metadata is parsed twice and yields fee, description and address', () => { const pay = parsePayInfo(LIVE_PAY); assert.ok(pay); assert.equal(pay.minSendableMsat, 6000); assert.equal(pay.maxSendableMsat, 1000000000); assert.equal(pay.description, 'Mint an lnurlcash bearer note on lnurl.21mint.me'); assert.equal(pay.feeBaseMsat, 1000); assert.equal(pay.feePpm, 100); assert.equal(pay.withdrawLink, 'https://lnurl.21mint.me/w'); }); check('the "Mint fees:" entry never becomes the description', () => { // It shares `text/plain` with the description, so a naive first-match reader shows // "Mint fees: 1000,100" as what the mint is. const pay = parsePayInfo({ ...LIVE_PAY, metadata: '[["text/plain", "Mint fees: 2000,50"], ["text/plain", "A real description"]]', }); assert.equal(pay?.description, 'A real description'); assert.equal(pay?.feeBaseMsat, 2000); assert.equal(pay?.feePpm, 50); }); check('a fee-free mint reports no fee rather than zero', () => { const pay = parsePayInfo({ ...LIVE_PAY, metadata: '[["text/plain", "Just a mint"]]' }); assert.equal(pay?.feeBaseMsat, null); assert.equal(pay?.feePpm, null); }); check('unparseable metadata costs the description, not the whole response', () => { const pay = parsePayInfo({ ...LIVE_PAY, metadata: 'not json at all' }); assert.ok(pay, 'the limits above the metadata are still good'); assert.equal(pay.minSendableMsat, 6000); assert.equal(pay.description, null); }); check('the lightning address comes from payLink, never from the echoed identifier', () => { // `text/identifier` echoes whichever username was queried, so probing `_` gets back // `_@host` — LUD-16's bare-domain form, and not a name to show a reader. assert.equal(parsePayInfo(LIVE_PAY)?.identifier, '_@lnurl.21mint.me'); assert.equal(addressFromPayLink(LIVE_WITHDRAW.payLink), 'mint@lnurl.21mint.me'); assert.equal(addressFromPayLink('https://x.example/.well-known/lnurlp/_'), null); assert.equal(addressFromPayLink('https://x.example/somewhere/else'), null); assert.equal(addressFromPayLink(null), null); assert.equal(addressFromPayLink('not a url'), null); }); check('an onion address is found in the one-pager and nowhere else', () => { const html = '

Also via Tor

'; assert.equal( onionFromHtml(html), 'abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion', ); assert.equal(onionFromHtml('

a mint with no tor section

'), null); }); check('the version comes from openapi, and the unknown sentinel is not a version', () => { assert.equal( parseSoftware({ info: { title: 'lnurl-mint', version: '0.1.0' } }), 'lnurl-mint/0.1.0', ); // What a source checkout with no installed package metadata reports. It is the // library saying "I do not know", not a release, and must not reach a reader. assert.equal(parseSoftware({ info: { title: 'lnurl-mint', version: '0.0.0+unknown' } }), null); assert.equal(parseSoftware({ info: { version: '1.2.3' } }), null); assert.equal(parseSoftware({}), null); assert.equal(parseSoftware(null), null); }); /* ------------------------------------------------------------------ * * 2. Identity: the `d` rules * ------------------------------------------------------------------ */ check('a mint pubkey is 66 hex characters beginning 02 or 03', () => { assert.equal(isMintPubkey(LIVE_WITHDRAW.mintPubkey), true); assert.equal(isMintPubkey('03' + 'a'.repeat(64)), true); // A Cashu mint pubkey or a federation id is 64 characters, and must never be // mistaken for one of these. assert.equal(isMintPubkey('a'.repeat(64)), false); assert.equal(isMintPubkey('04' + 'a'.repeat(64)), false); assert.equal(isMintPubkey('021ab8'), false); assert.equal(isMintPubkey(null), false); }); check('the two `d` forms can never be confused for one another', () => { const host = hostIdentifier('https://lnurl.21mint.me'); assert.equal(host, 'lnurl.21mint.me'); assert.equal(isMintPubkey(host), false, 'a host is never pubkey-shaped'); assert.ok(host.includes('.'), 'a host always contains a dot; a pubkey never does'); }); check('the `d` fallback drops the scheme and the trailing slash but keeps the path', () => { assert.equal(hostIdentifier('https://mint.example.com/lnurl/'), 'mint.example.com/lnurl'); assert.equal(hostIdentifier('https://Mint.Example.com'), 'mint.example.com'); }); check('the identifier prefers the pubkey and falls back to the host', () => { assert.equal( lnurlIdentifier('https://lnurl.21mint.me', LIVE_WITHDRAW.mintPubkey), LIVE_WITHDRAW.mintPubkey, ); assert.equal(lnurlIdentifier('https://lnurl.21mint.me', null), 'lnurl.21mint.me'); // Junk in the pubkey position falls back rather than being published as a `d`. assert.equal(lnurlIdentifier('https://lnurl.21mint.me', 'nonsense'), 'lnurl.21mint.me'); }); check('a mint that gained a pubkey is still reviewable under its old host identifier', () => { // The case the kind document spends a section on. Reviews written before the mint had // a funding source carry the host `d`; asking only for the current identifier would // silently strand every one of them. const both = lnurlIdentifiers('https://lnurl.21mint.me', LIVE_WITHDRAW.mintPubkey); assert.deepEqual(both, [LIVE_WITHDRAW.mintPubkey, 'lnurl.21mint.me']); const hostOnly = lnurlIdentifiers('https://lnurl.21mint.me', null); assert.deepEqual(hostOnly, ['lnurl.21mint.me']); }); check('the row key round-trips the base URL', () => { const key = lnurlKey('https://lnurl.21mint.me'); assert.equal(key, 'lnurl:https://lnurl.21mint.me'); assert.equal(baseUrlFromKey(key), 'https://lnurl.21mint.me'); // Not an LNURL key, and must not be mistaken for one. assert.equal(baseUrlFromKey('https://mint.example.com'), null); assert.equal(baseUrlFromKey('fedimint:' + 'a'.repeat(64)), null); }); /* ------------------------------------------------------------------ * * 3. The features vocabulary * ------------------------------------------------------------------ */ check('the features tag splits like a modules tag, and tolerates what publishers write', () => { assert.deepEqual(parseFeatures('mint,melt,rotate'), ['mint', 'melt', 'rotate']); assert.deepEqual(parseFeatures('mint, melt, rotate'), ['mint', 'melt', 'rotate']); assert.deepEqual(parseFeatures('MINT,Melt'), ['mint', 'melt']); assert.deepEqual(parseFeatures('mint,mint,melt'), ['mint', 'melt']); assert.deepEqual(parseFeatures(''), []); assert.deepEqual(parseFeatures(null), []); // Junk tokens are dropped, not carried into a chip. assert.deepEqual(parseFeatures('mint,