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>
936 lines
36 KiB
TypeScript
936 lines
36 KiB
TypeScript
/**
|
|
* 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">⚡</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`);
|