Index, probe, and announce LNURL mints in the API.

Wire discovery and probing for LNURL mints, add rate-limited POST /api/index
for user submissions, and optionally announce confirmed state to relays.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-22 03:44:35 +02:00
co-authored by Cursor
parent c97b44018d
commit 2a9444942b
19 changed files with 4383 additions and 72 deletions
+935
View File
@@ -0,0 +1,935 @@
/**
* 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<void>): Promise<void> {
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 =
'<h2>Also via Tor</h2><button class="copy" ' +
'data-copy="mint@abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion" ' +
'title="Copy lightning address">&#9889;</button>';
assert.equal(
onionFromHtml(html),
'abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion',
);
assert.equal(onionFromHtml('<h1>a mint with no tor section</h1>'), 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,<script>,melt'), ['mint', 'melt']);
});
check('an unrecognised feature is kept and shown, never dropped', () => {
// The kind document requires consumers to ignore tokens they do not know rather than
// reject the event, which is what lets the vocabulary grow without a new kind.
const features = parseFeatures('mint,melt,quantum-notes');
assert.deepEqual(features, ['mint', 'melt', 'quantum-notes']);
assert.deepEqual(otherFeatures(features), ['quantum-notes']);
});
check('the vocabulary is exactly what the kind document lists', () => {
assert.deepEqual([...FEATURE_VOCABULARY], [
'mint', 'melt', 'rotate', 'split', 'merge',
'lud06', 'lud03', 'lud16', 'lud21',
'signed-notes', 'onion',
]);
});
check('the notes row is satisfied by any one of rotate, split or merge', () => {
assert.equal(hasFeature(['rotate'], 'notes'), true);
assert.equal(hasFeature(['split'], 'notes'), true);
assert.equal(hasFeature(['merge'], 'notes'), true);
assert.equal(hasFeature(['mint', 'melt'], 'notes'), false);
});
check('a funding outage marks mint, melt and signed-notes unavailable, and nothing else', () => {
const features = ['mint', 'melt', 'rotate', 'split', 'merge', 'lud16', 'lud21', 'signed-notes'];
const down = featureStates(features, false);
assert.equal(down.mint, 'unavailable');
assert.equal(down.melt, 'unavailable');
assert.equal(down['signed-notes'], 'unavailable');
// Exactly what still works with no Lightning node, and the reason this state is
// rendered rather than collapsed into "offline".
assert.equal(down.notes, 'ok');
assert.equal(down.lud16, 'ok');
assert.equal(down.lud21, 'ok');
assert.equal(down.onion, 'none');
const up = featureStates(features, true);
assert.equal(up.mint, 'ok');
assert.equal(up['signed-notes'], 'ok');
// Nothing probed yet is not a reason to doubt an operator's claim.
const unknown = featureStates(features, null);
assert.equal(unknown.mint, 'ok');
});
check('a feature that was never claimed stays absent even when funding is down', () => {
const states = featureStates(['rotate'], false);
assert.equal(states.mint, 'none', 'absent, not "unavailable" — it was never claimed');
assert.equal(states.notes, 'ok');
});
check('the network tag agrees with the Fedimint side, bitcoin included', () => {
for (const value of ['bitcoin', 'mainnet', 'MAIN', 'signet', 'regtest', 'testnet4', '', null]) {
assert.equal(
normalizeLnurlNetwork(value),
normalizeNetwork(value),
`the two normalizers disagree about ${JSON.stringify(value)}`,
);
}
});
/* ------------------------------------------------------------------ *
* 4. The announcement
* ------------------------------------------------------------------ */
const ANNOUNCER = 'f'.repeat(64);
function announcement(tags: string[][], content = ''): NostrEventLike {
return {
id: 'e'.repeat(64),
pubkey: ANNOUNCER,
kind: KIND_LNURL_ANNOUNCEMENT,
created_at: 1_780_000_000,
content,
tags,
};
}
check('kind 38174 is wired into the one table every ecosystem is read from', () => {
assert.equal(ANNOUNCEMENT_KINDS.lnurl, 38174);
assert.equal(ecosystemForKind(38174), 'lnurl');
assert.equal(ecosystemForKind('38174'), 'lnurl');
// The neighbours are untouched.
assert.equal(ecosystemForKind(38172), 'cashu');
assert.equal(ecosystemForKind(38173), 'fedimint');
});
check('a full announcement parses into every field a row needs', () => {
const parsed = parseLnurlAnnouncement(
announcement(
[
['d', LIVE_WITHDRAW.mintPubkey],
['u', 'https://lnurl.21mint.me'],
['features', 'mint,melt,rotate,split,merge,lud06,lud03,lud16,lud21,signed-notes'],
['n', 'mainnet'],
],
JSON.stringify({ name: '21 Mint', about: 'Bearer notes.' }),
),
);
assert.ok(parsed);
assert.equal(parsed.identifier, LIVE_WITHDRAW.mintPubkey);
assert.equal(parsed.mintPubkey, LIVE_WITHDRAW.mintPubkey);
assert.equal(parsed.baseUrl, 'https://lnurl.21mint.me');
assert.equal(parsed.slug, 'lnurl.21mint.me');
assert.equal(parsed.network, 'mainnet');
assert.equal(parsed.name, '21 Mint');
assert.equal(parsed.about, 'Bearer notes.');
assert.equal(parsed.announcerPubkey, ANNOUNCER);
assert.equal(parsed.features.length, 10);
});
check('an announcement by host parses, and carries no pubkey', () => {
const parsed = parseLnurlAnnouncement(
announcement([['d', 'lnurl.example.com'], ['u', 'https://lnurl.example.com']]),
);
assert.ok(parsed);
assert.equal(parsed.identifier, 'lnurl.example.com');
assert.equal(parsed.mintPubkey, null, 'a host `d` is not a pubkey and must not read as one');
});
check('an announcement with no usable `u` is rejected', () => {
// `u` is REQUIRED for this kind, unlike 38172: a host-form `d` has no scheme and is
// not fetchable, so without `u` there is no address at all.
assert.equal(parseLnurlAnnouncement(announcement([['d', 'lnurl.example.com']])), null);
assert.equal(
parseLnurlAnnouncement(announcement([['d', 'lnurl.example.com'], ['u', 'not a url']])),
null,
);
// An onion cannot be the canonical `u`; the normalizer refuses it for every ecosystem.
assert.equal(
parseLnurlAnnouncement(
announcement([['d', 'lnurl.example.com'], ['u', 'http://abcdefghijklmnop.onion']]),
),
null,
);
});
check('an announcement with an unusable `d` is rejected', () => {
assert.equal(parseLnurlAnnouncement(announcement([['u', 'https://lnurl.example.com']])), null);
assert.equal(
parseLnurlAnnouncement(
announcement([['d', 'not an identifier'], ['u', 'https://lnurl.example.com']]),
),
null,
);
});
check('a `d` naming one mint and a `u` naming another is accepted, deliberately', () => {
// Not a validation failure: a mint's `d` is its funding node's pubkey, which has no
// relationship to its hostname at all. Cross-checking them would reject exactly the
// events the identity rule exists to allow.
const parsed = parseLnurlAnnouncement(
announcement([['d', LIVE_WITHDRAW.mintPubkey], ['u', 'https://somewhere.else.example']]),
);
assert.ok(parsed);
assert.equal(parsed.baseUrl, 'https://somewhere.else.example');
});
check('hostile announcement content degrades to no metadata', () => {
for (const content of ['not json', '[]', 'null', '{"name": 12345}', '""']) {
const parsed = parseLnurlAnnouncement(
announcement([['d', 'lnurl.example.com'], ['u', 'https://lnurl.example.com']], content),
);
assert.ok(parsed, `content ${content} must not reject the announcement`);
assert.equal(parsed.name, null);
}
// A `javascript:` picture never reaches an img src.
const parsed = parseLnurlAnnouncement(
announcement(
[['d', 'lnurl.example.com'], ['u', 'https://lnurl.example.com']],
JSON.stringify({ picture: 'javascript:alert(1)' }),
),
);
assert.equal(parsed?.picture, null);
});
/* ------------------------------------------------------------------ *
* 5. Reviews
* ------------------------------------------------------------------ */
function review(tags: string[][], content = '[5/5] Good mint.'): NostrEventLike {
return {
id: 'd'.repeat(64),
pubkey: 'c'.repeat(64),
kind: KIND_REVIEW,
created_at: 1_780_000_100,
content,
tags,
};
}
check('a k=38174 review is filed under lnurl and nothing else', () => {
assert.equal(reviewEcosystem(review([['k', '38174']])), 'lnurl');
assert.equal(reviewEcosystem(review([['k', '38172']])), 'cashu');
assert.equal(reviewEcosystem(review([['k', '38173']])), 'fedimint');
// A review with no `k` is Cashu, because every one of those predates everything else.
assert.equal(reviewEcosystem(review([])), 'cashu');
// A kind this build has no ecosystem for belongs to nobody.
assert.equal(reviewEcosystem(review([['k', '39999']])), null);
});
check('the rating convention is identical across all three ecosystems', () => {
// A reader comparing a Cashu mint to an LNURL one must be comparing the same scale,
// so this uses the site's existing parser with no LNURL-specific path at all.
assert.equal(parseRating(review([['k', '38174']], '[4/5] Fast melts')), 4);
assert.equal(parseRating(review([['k', '38174'], ['rating', '2']])), 2);
assert.equal(parseRating(review([['k', '38174'], ['rating', '0.8']])), 4);
assert.equal(parseRating(review([['k', '38174']], 'no rating here')), null);
});
/* ------------------------------------------------------------------ *
* 6. Warnings
* ------------------------------------------------------------------ */
const onlineMint = {
type: 'lnurl',
status: 'online' as const,
last_online: 1_786_999_910,
first_seen: 1_769_720_000,
};
const NOW = 1_787_000_000;
check('a healthy LNURL mint gets no banner', () => {
const warnings = getMintWarnings(
{ ...onlineMint, max_withdrawable_msat: 999899000, funding_available: true },
{ now: NOW },
);
assert.deepEqual(warnings, []);
});
check('a zero withdraw ceiling is critical and says withdrawals are disabled', () => {
const warnings = getMintWarnings(
{ ...onlineMint, max_withdrawable_msat: 0, funding_available: true },
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'lnurl-withdrawals-disabled');
assert.equal(warnings[0]?.severity, 'critical');
assert.match(warnings[0]!.lead, /Withdrawals disabled/);
assert.equal(mintChip(warnings)?.label, 'No withdrawals');
});
check('never having probed is not "withdrawals disabled"', () => {
// `=== 0`, not falsy. null means nothing has looked yet.
const warnings = getMintWarnings(
{ ...onlineMint, max_withdrawable_msat: null, funding_available: null },
{ now: NOW },
);
assert.deepEqual(warnings, []);
});
check('an unreachable funding source is a warning that says what still works', () => {
const warnings = getMintWarnings(
{ ...onlineMint, max_withdrawable_msat: 999899000, funding_available: false },
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'lnurl-no-funding');
assert.equal(warnings[0]?.severity, 'warning');
assert.match(warnings[0]!.body, /rotated, split, or merged/);
assert.match(warnings[0]!.body, /nothing moves in or out/);
assert.equal(mintChip(warnings)?.label, 'No mint / melt');
});
check('a host responding with junk says so, rather than saying offline', () => {
const warnings = getMintWarnings(
{
...onlineMint,
status: 'degraded',
max_withdrawable_msat: 999899000,
invalid_reason: 'mint replied: Unknown user.',
},
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'lnurl-invalid');
assert.match(warnings[0]!.lead, /Endpoint responding but invalid/);
assert.match(warnings[0]!.body, /withdrawals may not work/);
});
check('the offline tiers are exactly the Cashu ones', () => {
const day = 86400;
const tiers: Array<[number, string]> = [
[2 * day, 'offline'],
[10 * day, 'offline-long'],
[40 * day, 'gone'],
];
for (const [age, kind] of tiers) {
const warnings = getMintWarnings(
{ type: 'lnurl', status: 'offline', last_online: NOW - age, first_seen: NOW - 200 * day },
{ now: NOW },
);
assert.equal(warnings[0]?.kind, kind, `${age / day} days offline should be ${kind}`);
// The same input with `type: cashu` reaches the same tier, which is what makes
// "identical to Cashu" a checked claim rather than a comment.
const cashu = getMintWarnings(
{ type: 'cashu', status: 'offline', last_online: NOW - age, first_seen: NOW - 200 * day },
{ now: NOW },
);
assert.equal(cashu[0]?.kind, kind);
assert.equal(cashu[0]?.lead, warnings[0]?.lead, 'and says the same words');
}
});
check('a mint that never answered once lands in the top tier', () => {
const warnings = getMintWarnings(
{ type: 'lnurl', status: 'offline', last_online: null, first_seen: NOW - 40 * 86400 },
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'gone');
});
check('withdrawals disabled outranks an offline banner, and still says it is offline', () => {
const warnings = getMintWarnings(
{
type: 'lnurl',
status: 'offline',
last_online: NOW - 3 * 86400,
first_seen: NOW - 200 * 86400,
max_withdrawable_msat: 0,
},
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'lnurl-withdrawals-disabled');
assert.match(warnings[0]!.body, /also been offline/);
});
check('an offline banner rescues the funding fact into its last sentence', () => {
const warnings = getMintWarnings(
{
type: 'lnurl',
status: 'offline',
last_online: NOW - 40 * 86400,
first_seen: NOW - 200 * 86400,
funding_available: false,
},
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'gone');
assert.match(warnings[0]!.body, /Lightning node was also unreachable/);
});
check('no LNURL warning is ever derived from the features tag', () => {
// `features` is the operator's claim about what they built. A banner derived from a
// claim rather than an observation is the invention the Fedimint branch refuses to
// make, and this branch refuses it too.
const claimed = getMintWarnings(
{ ...onlineMint, features: [], max_withdrawable_msat: 5000, funding_available: true } as never,
{ now: NOW },
);
assert.deepEqual(claimed, []);
});
/* ------------------------------------------------------------------ *
* 7. The publisher
* ------------------------------------------------------------------ */
const probedRow: MintRow = {
url: 'lnurl:https://lnurl.21mint.me',
host: 'lnurl.21mint.me',
type: 'lnurl',
name: '21 Mint',
description: null,
icon_url: null,
icon_file: null,
pubkey: null,
info_json: null,
ecosystem_json: null,
nuts_json: null,
version: 'lnurl-mint/0.1.0',
status: 'online',
consecutive_fails: 0,
last_online: 1_786_999_910,
last_probe: 1_786_999_910,
first_seen: 1_769_720_000,
updated_at: 1_786_999_910,
};
const probedFields: LnurlFields = {
lnurl_id: LIVE_WITHDRAW.mintPubkey,
base_url: 'https://lnurl.21mint.me',
features: [],
network: 'mainnet',
announced_at: null,
announcer_pubkey: null,
mint_pubkey: LIVE_WITHDRAW.mintPubkey,
funding_available: true,
probe_endpoint: '/.well-known/lnurlw/_',
invalid_reason: null,
min_withdrawable_msat: 5000,
max_withdrawable_msat: 999899000,
min_sendable_msat: 6000,
max_sendable_msat: 1000000000,
fee_base_msat: 1000,
fee_ppm: 100,
lightning_address: 'mint@lnurl.21mint.me',
onion_url: null,
node_alias: 'Azzamo',
node_uri: LIVE_WITHDRAW.nodeUri,
node_capacity_msat: 30027500000,
node_channels: 8,
node_peers: 18,
observed_features: ['mint', 'melt', 'lud06', 'lud03', 'lud16', 'signed-notes'],
};
check('announcing is off unless all three switches are set', () => {
assert.equal(announceConfig({}), null);
assert.equal(announceConfig({ ANNOUNCE_LNURL: 'true' }), null, 'no key, no publish');
assert.equal(
announceConfig({ ANNOUNCE_LNURL: 'true', ANNOUNCE_KEY: 'nsec1x' }),
null,
'no relays, no publish — there is deliberately no default',
);
assert.equal(
announceConfig({ ANNOUNCE_LNURL: 'false', ANNOUNCE_KEY: 'k', ANNOUNCE_RELAYS: 'wss://r' }),
null,
);
const on = announceConfig({
ANNOUNCE_LNURL: 'true',
ANNOUNCE_KEY: 'nsec1x',
ANNOUNCE_RELAYS: 'wss://one, wss://two, http://not-a-relay',
});
assert.deepEqual(on, { secretKey: 'nsec1x', relays: ['wss://one', 'wss://two'] });
});
check('the signing key accepts an nsec or hex, and refuses anything else', () => {
const secret = generateSecretKey();
const hex = Buffer.from(secret).toString('hex');
assert.deepEqual(parseAnnounceKey(hex), secret);
assert.deepEqual(parseAnnounceKey(nip19.nsecEncode(secret)), secret);
assert.equal(parseAnnounceKey('npub1abc'), null);
assert.equal(parseAnnounceKey('nsec1notvalid'), null);
assert.equal(parseAnnounceKey('deadbeef'), null);
assert.equal(parseAnnounceKey(''), null);
});
check('this site announces only what it observed', () => {
const features = announcedFeatures(probedFields);
assert.deepEqual(features, ['mint', 'melt', 'lud06', 'lud03', 'lud16', 'signed-notes']);
// The four it must never publish, and the reasons, from the kind document:
assert.ok(!features.includes('lud21'), 'verify is undetectable over HTTP');
for (const operation of ['rotate', 'split', 'merge']) {
assert.ok(
!features.includes(operation),
`${operation} is only provable by calling /w/cb, which mutates a stranger's note`,
);
}
});
check('a mint with no reachable node is announced without the funded capabilities', () => {
const features = announcedFeatures({ ...probedFields, funding_available: false });
assert.deepEqual(features, ['lud06', 'lud03', 'lud16']);
assert.ok(!features.includes('signed-notes'), 'note signing needs the same node');
});
check('a zero withdraw ceiling is never announced as melt', () => {
const features = announcedFeatures({ ...probedFields, max_withdrawable_msat: 0 });
assert.ok(!features.includes('melt'));
assert.ok(features.includes('mint'), 'the pay side is unaffected');
});
check('the announcement template is shaped exactly as the kind document says', () => {
const template = announcementTemplate(probedRow, probedFields, 1_787_000_000);
assert.equal(template.kind, 38174);
assert.deepEqual(template.tags[0], ['d', LIVE_WITHDRAW.mintPubkey]);
assert.deepEqual(template.tags[1], ['u', 'https://lnurl.21mint.me']);
assert.deepEqual(template.tags[2], ['features', 'mint,melt,lud06,lud03,lud16,signed-notes']);
assert.deepEqual(template.tags[3], ['n', 'mainnet']);
assert.equal(template.content, JSON.stringify({ name: '21 Mint' }));
});
check('a mint that never named a network is not assigned one', () => {
// Absent reads as mainnet, so asserting it would be this site inventing the one fact
// that decides whether the money is real.
const template = announcementTemplate(probedRow, { ...probedFields, network: null }, 1);
assert.equal(template.tags.some((t) => t[0] === 'n'), false);
});
check('an announcement this site wrote defers to its own kind 0 when there is no name', () => {
const template = announcementTemplate({ ...probedRow, name: null }, probedFields, 1);
assert.equal(template.content, '');
});
/* ------------------------------------------------------------------ *
* 8. The round trip, against a real relay
* ------------------------------------------------------------------ */
await checkAsync('a 38174 announcement and a k=38174 review round-trip a relay', async () => {
const relay = await startTestRelay();
const pool = new SimplePool();
try {
const siteKey = generateSecretKey();
const sitePubkey = getPublicKey(siteKey);
const reviewerKey = generateSecretKey();
/* --- publish the announcement, using the publisher's own template builder --- */
const created = 1_787_000_000;
const announcementEvent = finalizeEvent(
announcementTemplate(probedRow, probedFields, created),
siteKey,
);
await Promise.allSettled(pool.publish([relay.url], announcementEvent));
/* --- publish a review of it, shaped as the kind document specifies --- */
const reviewEvent = finalizeEvent(
{
kind: KIND_REVIEW,
created_at: created + 60,
content: '[5/5] Rotations are instant and melts have never failed me.',
tags: [
['k', String(KIND_LNURL_ANNOUNCEMENT)],
['u', probedFields.base_url, 'lnurl'],
['d', probedFields.lnurl_id],
['a', `${KIND_LNURL_ANNOUNCEMENT}:${sitePubkey}:${probedFields.lnurl_id}`, relay.url],
],
},
reviewerKey,
);
await Promise.allSettled(pool.publish([relay.url], reviewEvent));
/* --- read both back the way the indexer does --- */
const announcements = await pool.querySync(
[relay.url],
{ kinds: [KIND_LNURL_ANNOUNCEMENT] },
{ maxWait: 4000 },
);
assert.equal(announcements.length, 1, 'the relay should hold exactly one announcement');
const parsed = parseLnurlAnnouncement(announcements[0]!);
assert.ok(parsed, 'the indexer must be able to parse what the site published');
assert.equal(parsed.identifier, LIVE_WITHDRAW.mintPubkey);
assert.equal(parsed.baseUrl, 'https://lnurl.21mint.me');
assert.equal(parsed.mintPubkey, LIVE_WITHDRAW.mintPubkey);
assert.equal(parsed.network, 'mainnet');
assert.equal(parsed.announcerPubkey, sitePubkey);
assert.deepEqual(parsed.features, ['mint', 'melt', 'lud06', 'lud03', 'lud16', 'signed-notes']);
assert.equal(parsed.name, '21 Mint');
/* --- the review, asked for exactly as the targeted pass asks --- */
const reviews = await pool.querySync(
[relay.url],
{
kinds: [KIND_REVIEW],
'#k': [String(KIND_LNURL_ANNOUNCEMENT)],
'#d': lnurlIdentifiers(parsed.baseUrl, parsed.mintPubkey),
},
{ maxWait: 4000 },
);
assert.equal(reviews.length, 1, 'the #d/#k filter the indexer uses must find it');
assert.equal(reviewEcosystem(reviews[0]!), 'lnurl');
assert.equal(parseRating(reviews[0]!), 5);
/* --- and by `u`, which is how a client that does not know this kind writes one --- */
const byUrl = await pool.querySync(
[relay.url],
{ kinds: [KIND_REVIEW], '#u': [probedFields.base_url] },
{ maxWait: 4000 },
);
assert.equal(byUrl.length, 1, 'the #u filter must find it too');
/* --- replacement: a second announcement for the same `d` replaces the first --- */
const updated = finalizeEvent(
announcementTemplate(
probedRow,
{ ...probedFields, funding_available: false },
created + 3600,
),
siteKey,
);
await Promise.allSettled(pool.publish([relay.url], updated));
const after = relay.byKind(KIND_LNURL_ANNOUNCEMENT);
assert.equal(after.length, 1, '38174 is addressable: the relay holds one, not two');
assert.equal(
after[0]?.tags.find((t) => t[0] === 'features')?.[1],
'lud06,lud03,lud16',
'and it is the newer one, with the funded capabilities dropped',
);
} finally {
pool.close([relay.url]);
await relay.close();
}
});
console.log(`ok, ${checks} LNURL checks passed`);