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:
@@ -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">⚡</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`);
|
||||
Reference in New Issue
Block a user