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
+6
View File
@@ -156,6 +156,12 @@ FEDIMINT_OBSERVER_URL=https://observer.fedimint.org/api/federations
# which is the literal BACKEND.md behaviour and ranks noticeably worse.
SCORE_PRIOR_MEAN=3
# How many mints one address may submit to `POST /api/index` in an hour. Ten is several
# times what any honest use of the review dialog needs; raise it while exercising the
# whole flow end to end, which trips ten in about a minute. Behind a proxy, the limiter
# can only tell visitors apart if `X-Forwarded-For` is forwarded (see README, "API").
# INDEX_RATE_LIMIT=10
# ─── Tooling ─────────────────────────────────────────────────────────────────
# Dev server Boneyard captures against (`pnpm bones`). Defaults to WEB_PORT.
+3 -1
View File
@@ -11,7 +11,9 @@
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --env-file-if-exists=../.env --experimental-strip-types src/check.ts",
"test:warnings": "node --env-file-if-exists=../.env --experimental-strip-types src/check-warnings.ts",
"test:offline": "node --env-file-if-exists=../.env --experimental-strip-types src/check-offline.ts"
"test:offline": "node --env-file-if-exists=../.env --experimental-strip-types src/check-offline.ts",
"test:lnurl": "node --env-file-if-exists=../.env --experimental-strip-types src/check-lnurl.ts",
"test:index": "node --experimental-strip-types src/check-index.ts"
},
"dependencies": {
"@cashumints/shared": "workspace:*",
+253
View File
@@ -0,0 +1,253 @@
/**
* Publishing `kind:38174` announcements for LNURL mints this indexer has confirmed.
*
* **This writes to public relays.** Nostr has no delete that anyone is obliged to
* honour, so an event published here is on the network permanently, signed by this
* site's key and attributed to it. That is the whole reason for the gating below, and
* the reason it is two switches rather than one.
*
* Why publish at all: 38174 is a proposed kind (see `docs/KIND-LNURL-MINT.md`) and a
* proposed kind with no events is a document rather than a protocol. An indexer that
* has already probed a mint and confirmed what it serves is the one party in a position
* to put honest announcements on the network before any operator has heard of the kind.
*
* ## What is announced, and what deliberately is not
*
* Only what the probe actually **observed**. The vocabulary has eleven values; this
* publishes at most seven of them, and the four it never publishes are the point:
*
* - `rotate`, `split`, `merge` — these are only provable by calling `/w/cb`, which
* mutates or destroys a note. Not probeable, not published, even though the one
* implementation in existence supports all three unconditionally. Inferring them
* from a version string would be publishing a guess under this site's signature.
* - `lud21` — genuinely undetectable: verify-disabled and unknown-payment-hash return
* byte-identical responses. See NOTES-LNURL.md §5.
*
* An operator's own announcement can and should claim more; theirs is a statement about
* what they built, this is a statement about what was seen. When both exist the newer
* `created_at` wins, which is normal addressable-event behaviour and means an operator
* takes over their own listing simply by publishing one.
*/
import { finalizeEvent, getPublicKey, type EventTemplate } from 'nostr-tools/pure';
import { SimplePool } from 'nostr-tools/pool';
import * as nip19 from 'nostr-tools/nip19';
import {
KIND_LNURL_ANNOUNCEMENT, lnurlIdentifier, observedFeatures, type LnurlFields,
} from '@cashumints/shared';
import { announceConfig } from './config.ts';
import { getDb, getState, setState } from './db.ts';
import { log } from './log.ts';
import { parseEcosystem, type MintRow } from './mints.ts';
/**
* Republish interval.
*
* An addressable event is replaced, not duplicated, so republishing is cheap — but it
* is still traffic to somebody else's relay and a new `created_at` on every cycle would
* make this site's announcement permanently outrank an operator's own. Content changes
* are published immediately; an unchanged announcement is refreshed once a week, which
* keeps it from ageing out of relays that prune.
*/
const REPUBLISH_AFTER_S = 7 * 24 * 60 * 60;
/** State-table key holding what was last published for one mint, and when. */
const stateKey = (url: string): string => `announce:${url}`;
/**
* The secret key to sign with, as 32 bytes.
*
* Accepts an `nsec1…` or bare hex. Returns null — with a loud log line rather than a
* throw — for anything else: a misconfigured key must stop announcements and must not
* stop the indexer, which has a directory to serve either way.
*/
export function parseAnnounceKey(raw: string): Uint8Array | null {
const value = raw.trim();
if (!value) return null;
if (value.startsWith('nsec1')) {
try {
const decoded = nip19.decode(value);
if (decoded.type === 'nsec') return decoded.data;
} catch {
return null;
}
return null;
}
if (!/^[0-9a-f]{64}$/i.test(value)) return null;
return Uint8Array.from(Buffer.from(value, 'hex'));
}
/**
* The `features` this site is willing to sign for, from one mint's stored probe.
*
* Thin, and deliberately: the rule about what a probe may claim lives in
* `observedFeatures` in shared/, next to the vocabulary it draws from, so the list the
* page renders and the list this signs cannot drift apart. See that function for why
* `rotate`, `split`, `merge` and `lud21` are never among them.
*/
export function announcedFeatures(fields: LnurlFields): string[] {
return observedFeatures({
fundingAvailable: fields.funding_available,
maxWithdrawableMsat: fields.max_withdrawable_msat,
maxSendableMsat: fields.max_sendable_msat,
lightningAddress: fields.lightning_address,
mintPubkey: fields.mint_pubkey,
onionUrl: fields.onion_url,
});
}
/** The event this site would publish for one confirmed LNURL mint. */
export function announcementTemplate(
row: MintRow,
fields: LnurlFields,
createdAt: number,
): EventTemplate {
const tags: string[][] = [
['d', lnurlIdentifier(fields.base_url, fields.mint_pubkey)],
['u', fields.base_url],
];
const features = announcedFeatures(fields);
if (features.length > 0) tags.push(['features', features.join(',')]);
// Only when the mint said so. Absent reads as mainnet per the kind document, and
// asserting mainnet on a mint that never claimed a network would be this site
// inventing the one fact that decides whether the money is real.
if (fields.network) tags.push(['n', fields.network]);
/*
* `content` is left empty unless the mint published a name of its own.
*
* Empty content means "use the publisher's kind 0", per NIP-87 and the kind document
* — and the publisher here is this site, whose kind 0 is this site. That is the
* honest default for an announcement this site wrote: it is not the mint speaking.
* A name the mint itself served (its node alias) is worth passing on, and nothing else
* from the row is, because everything else came from a previous announcement.
*/
const content = row.name ? JSON.stringify({ name: row.name }) : '';
return { kind: KIND_LNURL_ANNOUNCEMENT, created_at: createdAt, tags, content };
}
/** Stable fingerprint of an announcement's meaning, for the change detector. */
function fingerprint(template: EventTemplate): string {
return JSON.stringify([template.tags, template.content]);
}
/**
* Which rows are eligible.
*
* Confirmed by probing, and only that: `status = 'online'` with a base URL and a
* withdraw ceiling means an advertisement genuinely parsed at some point. A mint that
* has never answered, is offline, or is only "responding but invalid" is never
* announced — this site does not put its signature on the existence of something it has
* not seen.
*
* A `degraded-funding` mint **is** announced: it is up and serving, and the reduced
* `features` list already tells the whole story.
*/
function eligible(row: MintRow, fields: LnurlFields | null): fields is LnurlFields {
if (row.type !== 'lnurl' || row.status !== 'online') return false;
if (!fields?.base_url) return false;
if (fields.invalid_reason) return false;
return fields.max_withdrawable_msat !== null;
}
export interface AnnounceResult {
eligible: number;
published: number;
skipped: number;
failed: number;
}
/**
* Publish an announcement for every confirmed LNURL mint that needs one.
*
* Returns counts rather than throwing: a relay refusing an event is not a reason for a
* probe cycle to fail. Does nothing at all, and says why once, when announcing is off —
* which is the default and is how every deployment that has not opted in behaves.
*/
export async function announceLnurlMints(
now = Math.floor(Date.now() / 1000),
): Promise<AnnounceResult> {
const empty: AnnounceResult = { eligible: 0, published: 0, skipped: 0, failed: 0 };
const settings = announceConfig();
if (!settings) return empty;
const secret = parseAnnounceKey(settings.secretKey);
if (!secret) {
log.error('announce disabled: ANNOUNCE_KEY is not an nsec or 64 hex characters');
return empty;
}
const db = await getDb();
const rows = await db.all<MintRow>(`SELECT * FROM mints WHERE type = 'lnurl'`);
const pool = new SimplePool();
const result: AnnounceResult = { ...empty };
try {
for (const row of rows) {
const fields = parseEcosystem<LnurlFields>(row);
if (!eligible(row, fields)) continue;
result.eligible++;
const template = announcementTemplate(row, fields, now);
const mark = fingerprint(template);
const previous = await getState(stateKey(row.url));
if (previous) {
try {
const { mark: lastMark, at } = JSON.parse(previous) as { mark: string; at: number };
if (lastMark === mark && now - at < REPUBLISH_AFTER_S) {
result.skipped++;
continue;
}
} catch {
// Unreadable marker: republish, which is the safe direction.
}
}
const event = finalizeEvent(template, secret);
/*
* `Promise.allSettled`, not `Promise.all`: one relay refusing the event (rate
* limits, a paid-relay policy, a write it does not accept) must not stop the
* others from taking it. One acceptance is a successful publish.
*/
const outcomes = await Promise.allSettled(pool.publish(settings.relays, event));
const accepted = outcomes.filter((o) => o.status === 'fulfilled').length;
if (accepted === 0) {
result.failed++;
log.warn('announcement rejected by every relay', {
url: row.url,
d: template.tags[0]?.[1],
relays: settings.relays.length,
});
continue;
}
await setState(stateKey(row.url), JSON.stringify({ mark, at: now }));
result.published++;
log.info('announced lnurl mint', {
url: row.url,
d: template.tags[0]?.[1],
features: template.tags.find((t) => t[0] === 'features')?.[1] ?? '',
relays: `${accepted}/${settings.relays.length}`,
event: event.id,
});
}
} finally {
try {
pool.close(settings.relays);
} catch {
// A relay that is already gone throws on close. Nothing to do about it.
}
}
if (result.eligible > 0) {
log.info('announce cycle', { ...result, pubkey: getPublicKey(secret) });
}
return result;
}
+474
View File
@@ -0,0 +1,474 @@
/**
* pnpm --filter ./api test:index
*
* The checks for on-demand indexing (`POST /api/index`). Same shape as `check.ts`: no
* framework, throws on the first failure, prints a count.
*
* Three things are being defended here, and they are in descending order of how bad it
* would be to get them wrong:
*
* 1. **SSRF.** This is the one endpoint that fetches an address a stranger chose, so
* every refusal it makes is asserted against a resolver and a fetch that this file
* controls. Nothing here touches the network: a rule that can only be exercised by
* pointing the test at a real host is a rule that stops being exercised the first
* time CI runs offline.
* 2. **Slug collapse.** Two spellings of one mint must never become two rows with half
* its reviews on each. That is the bug the normalizer was written for, and this
* endpoint is a new way to reintroduce it — a reader can now type the spelling
* discovery never saw.
* 3. **Invite decoding**, which is what makes a Fedimint submission possible at all.
*/
import assert from 'node:assert/strict';
import {
checkIndexInput,
federationIdFromInviteCode,
isNut06Info,
isPrivateIpAddress,
lnurlKey,
normalizeMintUrl,
typeForPath,
} from '@cashumints/shared';
import { checkDestination, safeFetchText, type FetchDeps } from './safe-fetch.ts';
import { RATE_LIMIT, resetRateLimits, takeToken } from './rate-limit.ts';
/** A real code off the relay pool, the same one `check.ts` parses an announcement from. */
const REAL_INVITE =
'fed11qvqzggnhwden5te0v9cxjtn9vd3jue3wvfkxjmnyva6kzunyd9skutnwv46z7qqqzc28wumn8ghj7' +
'end9e3hgunz9e5k7tmhwvhszqfq4m9xejq0l3fsh5k4fvyks8mwmwdyzhpk9e909l3atczpxuqxlgss2f35eg';
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;
}
}
/* ---------- address rules ---------- */
check('every private, loopback and link-local range is refused', () => {
for (const address of [
'127.0.0.1', '127.1.2.3', '10.0.0.1', '10.255.255.255',
'172.16.0.1', '172.20.10.5', '172.31.255.255',
'192.168.0.1', '192.168.1.5',
'169.254.169.254', // the cloud metadata endpoint, the reason this exists
'0.0.0.0', '100.64.0.1', // this-network and carrier-grade NAT
'224.0.0.1', '255.255.255.255',
'::1', '::', 'fe80::1', 'fc00::1', 'fd12:3456::1', '::ffff:127.0.0.1', '::ffff:10.0.0.1',
]) {
assert.equal(isPrivateIpAddress(address), true, `${address} must be refused`);
}
});
check('ordinary public addresses are not', () => {
for (const address of ['1.1.1.1', '8.8.8.8', '157.245.26.63', '172.15.0.1', '172.32.0.1', '2606:4700::1111']) {
assert.equal(isPrivateIpAddress(address), false, `${address} must be allowed`);
}
});
await checkAsync('a URL whose hostname is a private literal never reaches DNS', async () => {
// If any of these consulted the resolver, this one would throw rather than answer.
const explode: FetchDeps = {
resolve: () => {
throw new Error('a literal address must not be resolved');
},
};
for (const url of [
'https://127.0.0.1/v1/info',
'https://10.0.0.1/v1/info',
'https://172.16.4.4/v1/info',
'https://192.168.1.5/v1/info',
'https://169.254.169.254/latest/meta-data/',
'https://[::1]/v1/info',
'https://localhost/v1/info',
'https://mint.local/v1/info',
'https://abcdefghij234567.onion/v1/info',
]) {
const verdict = await checkDestination(new URL(url), explode);
assert.equal(verdict?.kind, 'blocked', `${url} must be refused`);
}
});
await checkAsync('a public-looking name that resolves privately is refused', async () => {
const deps: FetchDeps = { resolve: async () => ['10.0.0.5'] };
const verdict = await checkDestination(new URL('https://internal.example.com'), deps);
assert.equal(verdict?.kind, 'blocked');
// One private answer among several is enough: the socket would pick one of them.
const mixed: FetchDeps = { resolve: async () => ['93.184.216.34', '127.0.0.1'] };
assert.equal((await checkDestination(new URL('https://mixed.example.com'), mixed))?.kind, 'blocked');
const public_: FetchDeps = { resolve: async () => ['93.184.216.34'] };
assert.equal(await checkDestination(new URL('https://mint.example.com'), public_), null);
});
await checkAsync('a name that does not resolve is unresolved, not blocked', async () => {
// The distinction the rugged-mint case turns on: a mint whose operator let the domain
// lapse must reach the Nostr lookup, not be rejected as an inadmissible address.
const gone: FetchDeps = { resolve: async () => null };
const verdict = await checkDestination(new URL('https://gone.example.com'), gone);
assert.equal(verdict?.kind, 'unresolved');
const outcome = await safeFetchText(
'https://gone.example.com/v1/info',
{ accept: 'application/json' },
{ ...gone, fetchImpl: (() => { throw new Error('must not connect'); }) as unknown as typeof fetch },
);
assert.equal(outcome.state, 'unreachable');
});
await checkAsync('http is refused outright: this endpoint is https only', async () => {
assert.equal((await checkDestination(new URL('http://mint.example.com')))?.kind, 'blocked');
assert.equal((await checkDestination(new URL('ftp://mint.example.com')))?.kind, 'blocked');
});
/* ---------- redirects ---------- */
/** A fetch that answers from a table, and records every URL it was asked for. */
function scriptedFetch(routes: Record<string, Response>): { fetch: typeof fetch; seen: string[] } {
const seen: string[] = [];
const impl = (async (input: unknown): Promise<Response> => {
const url = String(input);
seen.push(url);
const res = routes[url];
if (!res) throw new Error(`unexpected fetch of ${url}`);
return res;
}) as typeof fetch;
return { fetch: impl, seen };
}
await checkAsync('a redirect to a private address is refused before it is fetched', async () => {
const { fetch: impl, seen } = scriptedFetch({
'https://mint.example.com/v1/info': new Response(null, {
status: 302,
headers: { location: 'https://169.254.169.254/latest/meta-data/' },
}),
});
const outcome = await safeFetchText(
'https://mint.example.com/v1/info',
{ accept: 'application/json' },
{ fetchImpl: impl, resolve: async () => ['93.184.216.34'] },
);
assert.equal(outcome.state, 'blocked');
assert.match(outcome.state === 'blocked' ? outcome.reason : '', /redirected/);
// The crux: the metadata endpoint was never connected to, only reasoned about.
assert.deepEqual(seen, ['https://mint.example.com/v1/info']);
});
await checkAsync('a redirect to a name that resolves privately is refused too', async () => {
const { fetch: impl, seen } = scriptedFetch({
'https://mint.example.com/v1/info': new Response(null, {
status: 301,
headers: { location: 'https://internal.example.com/v1/info' },
}),
});
const outcome = await safeFetchText(
'https://mint.example.com/v1/info',
{ accept: 'application/json' },
{
fetchImpl: impl,
resolve: async (host) => (host === 'mint.example.com' ? ['93.184.216.34'] : ['10.1.2.3']),
},
);
assert.equal(outcome.state, 'blocked');
assert.equal(seen.length, 1, 'the private hop must never be fetched');
});
await checkAsync('two redirects are followed, a third is not', async () => {
const ok = { 'content-type': 'application/json' };
const routes: Record<string, Response> = {
'https://a.example.com/v1/info': new Response(null, {
status: 302,
headers: { location: 'https://b.example.com/v1/info' },
}),
'https://b.example.com/v1/info': new Response(null, {
status: 302,
headers: { location: 'https://c.example.com/v1/info' },
}),
'https://c.example.com/v1/info': new Response('{"name":"ok"}', { headers: ok }),
};
const deps = { resolve: async () => ['93.184.216.34'] };
const two = await safeFetchText(
'https://a.example.com/v1/info',
{ accept: 'application/json' },
{ ...deps, fetchImpl: scriptedFetch(routes).fetch },
);
assert.equal(two.state, 'ok', 'two hops are within the cap');
const deeper = {
...routes,
'https://c.example.com/v1/info': new Response(null, {
status: 302,
headers: { location: 'https://d.example.com/v1/info' },
}),
};
const three = scriptedFetch(deeper);
const over = await safeFetchText(
'https://a.example.com/v1/info',
{ accept: 'application/json' },
{ ...deps, fetchImpl: three.fetch },
);
assert.equal(over.state, 'unreachable');
assert.equal(three.seen.length, 3, 'the fourth address is never fetched');
});
await checkAsync('a body over the cap and a body of the wrong type are both refused', async () => {
const deps = { resolve: async () => ['93.184.216.34'] };
const big = scriptedFetch({
'https://mint.example.com/v1/info': new Response('x'.repeat(2048), {
headers: { 'content-type': 'application/json' },
}),
});
const capped = await safeFetchText(
'https://mint.example.com/v1/info',
{ accept: 'application/json', maxBytes: 512 },
{ ...deps, fetchImpl: big.fetch },
);
assert.equal(capped.state, 'unreachable');
const image = scriptedFetch({
'https://mint.example.com/v1/info': new Response('\x89PNG', {
headers: { 'content-type': 'image/png' },
}),
});
const wrongType = await safeFetchText(
'https://mint.example.com/v1/info',
{ accept: 'application/json' },
{ ...deps, fetchImpl: image.fetch },
);
assert.equal(wrongType.state, 'unreachable');
assert.match(wrongType.state === 'unreachable' ? wrongType.reason : '', /content type/);
});
/* ---------- slug collapse ---------- */
check('every spelling of one mint collapses to one row key', () => {
const spellings = [
'https://mint.600.wtf',
'mint.600.wtf',
'mint.600.wtf/',
'https://mint.600.wtf/',
'https://MINT.600.WTF',
'http://mint.600.wtf',
'https://mint.600.wtf:443/',
' https://mint.600.wtf/ ',
].map((raw) => normalizeMintUrl(raw));
const urls = new Set(spellings.map((s) => s?.url));
const hosts = new Set(spellings.map((s) => s?.host));
assert.equal(urls.size, 1, `one canonical URL, got ${[...urls].join(', ')}`);
assert.equal(hosts.size, 1, `one routing slug, got ${[...hosts].join(', ')}`);
assert.equal([...urls][0], 'https://mint.600.wtf');
assert.equal([...hosts][0], 'mint.600.wtf');
// And the LNURL row key derived from it is one value too, since that is what the
// endpoint actually looks the row up by.
assert.equal(new Set(spellings.map((s) => lnurlKey(s!.url))).size, 1);
});
check('a path is still part of a mint identity, and still collapses per path', () => {
const withPath = ['https://mint.example.com/Bitcoin', 'mint.example.com/Bitcoin/'].map((raw) =>
normalizeMintUrl(raw),
);
assert.equal(new Set(withPath.map((s) => s?.url)).size, 1);
assert.notEqual(withPath[0]?.url, normalizeMintUrl('https://mint.example.com')?.url);
});
/* ---------- what the browser checks before it asks ---------- */
check('the client-side pre-check accepts what the normalizer accepts', () => {
assert.deepEqual(checkIndexInput('lnurl', 'mint.600.wtf'), {
ok: true,
value: 'https://mint.600.wtf',
});
assert.deepEqual(checkIndexInput('cashu', ' https://21mint.me/ '), {
ok: true,
value: 'https://21mint.me',
});
assert.deepEqual(checkIndexInput('cashu', ''), { ok: false, reason: 'empty' });
assert.deepEqual(checkIndexInput('cashu', 'not a url at all'), { ok: false, reason: 'bad_url' });
// A private address fails the pre-check for the same reason the server refuses it.
assert.deepEqual(checkIndexInput('cashu', 'http://127.0.0.1:3338'), {
ok: false,
reason: 'bad_url',
});
});
check('an invite code pasted into a URL field is named as an invite code', () => {
const code = REAL_INVITE;
assert.deepEqual(checkIndexInput('cashu', code), { ok: false, reason: 'bad_invite' });
assert.deepEqual(checkIndexInput('lnurl', code), { ok: false, reason: 'bad_invite' });
assert.deepEqual(checkIndexInput('fedimint', code), { ok: true, value: code });
assert.deepEqual(checkIndexInput('fedimint', 'https://mint.example.com'), {
ok: false,
reason: 'bad_invite',
});
});
/* ---------- invite codes ---------- */
check('a real invite code yields the federation id its announcement carries', () => {
// The `d` tag of the announcement this code came in: decoding must agree with it, or
// a federation submitted by code would get a second row beside the announced one.
assert.equal(
federationIdFromInviteCode(REAL_INVITE),
'aeca6cc80ffc530bd2d54b09681f6edb9a415c362e4af2fe3d5e04137006fa21',
);
assert.equal(federationIdFromInviteCode(REAL_INVITE.toUpperCase()), federationIdFromInviteCode(REAL_INVITE));
});
check('anything that is not an invite code decodes to nothing', () => {
for (const junk of [
'',
'fed1',
'fed11',
'https://mint.example.com',
`${REAL_INVITE}x`, // checksum fails
REAL_INVITE.slice(0, -1), // truncated
REAL_INVITE.replace('fed11q', 'fed11p'), // one flipped character
'lnbc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', // valid-ish bech32, wrong hrp
]) {
assert.equal(federationIdFromInviteCode(junk), null, `${junk.slice(0, 24)} must not decode`);
}
});
/* ---------- what counts as a mint ---------- */
check('a NUT-06 document is told apart from an arbitrary JSON endpoint', () => {
assert.ok(isNut06Info({ nuts: { '4': { methods: [] } } }));
assert.ok(isNut06Info({ pubkey: '03c21ef6'.padEnd(66, 'a'), name: 'x' }));
assert.ok(isNut06Info({ name: 'Some mint', version: 'cdk-mintd/0.17.5' }));
// The shapes a host that is not a mint actually answers with.
assert.ok(!isNut06Info({ status: 'ok' }));
assert.ok(!isNut06Info({ detail: 'Not Found' }));
assert.ok(!isNut06Info({ tag: 'withdrawRequest', minWithdrawable: 1 }));
assert.ok(!isNut06Info([]));
assert.ok(!isNut06Info('hello'));
assert.ok(!isNut06Info(null));
assert.ok(!isNut06Info({ nuts: {} }), 'an empty nuts object proves nothing');
});
/* ---------- rate limiting ---------- */
check('the eleventh submission in an hour is refused, and refusals do not extend it', () => {
resetRateLimits();
const now = 1_800_000_000_000;
for (let i = 0; i < RATE_LIMIT; i++) {
assert.equal(takeToken('1.2.3.4', now + i).ok, true, `submission ${i + 1} must be allowed`);
}
const refused = takeToken('1.2.3.4', now + RATE_LIMIT);
assert.equal(refused.ok, false);
assert.ok(refused.retryAfter > 0 && refused.retryAfter <= 3600);
// Pressing the button again must not push the window out.
const again = takeToken('1.2.3.4', now + RATE_LIMIT + 1000);
assert.ok(again.retryAfter <= refused.retryAfter, 'a refusal must not extend the wait');
// A different address has its own budget.
assert.equal(takeToken('5.6.7.8', now + RATE_LIMIT).ok, true);
// An hour later the window has slid past the first submission.
assert.equal(takeToken('1.2.3.4', now + 3_600_001).ok, true);
resetRateLimits();
});
/* ---------- the deep-link routes ---------- */
check('every deep link shape maps to the ecosystem that owns it', () => {
assert.deepEqual(typeForPath('/mint/mint.600.wtf'), { type: 'cashu', host: 'mint.600.wtf' });
assert.deepEqual(typeForPath('/lnurl-mint/mint.600.wtf'), { type: 'lnurl', host: 'mint.600.wtf' });
assert.deepEqual(typeForPath('/fedimint/fed-aeca6cc80ffc530b'), {
type: 'fedimint',
host: 'fed-aeca6cc80ffc530b',
});
// A trailing slash is the same page; anything else is not a mint page at all.
assert.deepEqual(typeForPath('/mint/x.example.com/'), { type: 'cashu', host: 'x.example.com' });
assert.equal(typeForPath('/mints'), null);
assert.equal(typeForPath('/'), null);
assert.equal(typeForPath('/mint/'), null);
});
/* ---------- one submission, one row ---------- */
/*
* The dedup and the write path, against a throwaway in-memory database and with no
* network involved at all: a Fedimint submission is decoded rather than fetched, so it
* exercises `indexSubmission` end to end — the in-flight map, the insert, and the
* payload read back — without touching a relay or a mint.
*
* The import is deferred until after `DATABASE_URL` is set, because the config resolves
* its target on first use and this must not open the real database.
*/
process.env['DATABASE_URL'] = ':memory:';
const { indexSubmission, inFlightCount } = await import('./index-mint.ts');
const { closeDb } = await import('./db.ts');
await checkAsync('two simultaneous submissions of one address share one probe', async () => {
assert.equal(inFlightCount(), 0);
const first = indexSubmission('fedimint', REAL_INVITE);
assert.equal(inFlightCount(), 1, 'the first submission claims the key immediately');
// Deliberately a different spelling of the same code: the key is the decoded
// federation id, so the two collapse before anything is written.
const second = indexSubmission('fedimint', REAL_INVITE.toUpperCase());
assert.equal(inFlightCount(), 1, 'the second submission joins the first, it does not start');
const [a, b] = await Promise.all([first, second]);
assert.equal(a, b, 'both callers get the same answer object');
assert.equal(a.status, 201);
assert.equal(inFlightCount(), 0, 'the entry is released when the work finishes');
const created = a.body as { host?: string; status?: string; invite_codes?: string[] };
assert.equal(created.host, 'fed-aeca6cc80ffc530b');
assert.equal(created.status, 'announced', 'nothing checks a federation, so nothing claims it is up');
assert.deepEqual(created.invite_codes, [REAL_INVITE.toLowerCase()]);
// And once it is a row, submitting it again is a lookup rather than a write.
const again = await indexSubmission('fedimint', REAL_INVITE);
assert.equal(again.status, 200);
assert.equal((again.body as { existing?: boolean }).existing, true);
});
await checkAsync('a submission of the wrong shape never reaches the work at all', async () => {
const junk = await indexSubmission('fedimint', 'fed11not-a-real-code');
assert.equal(junk.status, 422);
assert.equal((junk.body as { error?: string }).error, 'invalid_invite');
assert.equal(inFlightCount(), 0);
const wrongType = await indexSubmission('cashu-ish', 'https://mint.example.com');
assert.equal((wrongType.body as { error?: string }).error, 'bad_type');
// A private address is refused by the normalizer, before DNS and before the map.
const private_ = await indexSubmission('cashu', 'https://192.168.1.5');
assert.equal((private_.body as { error?: string }).error, 'blocked_host');
assert.equal(inFlightCount(), 0);
});
await closeDb();
console.log(`ok, ${checks} index checks passed`);
+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`);
+35
View File
@@ -119,4 +119,39 @@ export const config = {
userAgent: 'cashumints.space-indexer/1.0',
} as const;
/**
* Publishing `kind:38174` announcements, which is **off unless two switches are set**.
*
* One switch would be enough to make it work and is not enough to make it safe. This
* writes signed events to public relays, permanently and under this site's name, so
* turning it on has to be a thing somebody did on purpose rather than a thing that
* happened because a key was left in an env file from a test run:
*
* ANNOUNCE_LNURL=true the intent
* ANNOUNCE_KEY=nsec1… the identity it will be signed with
* ANNOUNCE_RELAYS=wss://… where, explicitly — there is deliberately no default
*
* The missing default on ANNOUNCE_RELAYS is the important one. Falling back to the
* site's read pool would mean the difference between a CI run against a local test relay
* and a permanent write to five public ones was a single unset variable.
*
* Returns null — never throws — when any of the three is missing, because a
* misconfigured publisher must not stop an indexer that still has a directory to serve.
* See the README section "Publishing LNURL mint announcements".
*/
export function announceConfig(
env: NodeJS.ProcessEnv = process.env,
): { secretKey: string; relays: string[] } | null {
if (env['ANNOUNCE_LNURL']?.trim().toLowerCase() !== 'true') return null;
const secretKey = env['ANNOUNCE_KEY']?.trim() ?? '';
const relays = (env['ANNOUNCE_RELAYS'] ?? '')
.split(',')
.map((relay) => relay.trim())
.filter((relay) => relay.startsWith('ws://') || relay.startsWith('wss://'));
if (!secretKey || relays.length === 0) return null;
return { secretKey, relays };
}
export const startedAt = Math.floor(Date.now() / 1000);
+176 -10
View File
@@ -3,25 +3,28 @@ import type { Event as NostrEvent, Filter } from 'nostr-tools';
import {
ANNOUNCEMENT_KINDS,
KIND_FEDIMINT_ANNOUNCEMENT,
KIND_LNURL_ANNOUNCEMENT,
KIND_MINT_ANNOUNCEMENT,
KIND_REVIEW,
isCashuMintReview,
mintPubkeyRef,
mintUrlSpellings,
mintUrlsFromEvent,
normalizeMintUrl,
parseFedimintAnnouncement,
parseLnurlAnnouncement,
parseRating,
reviewEcosystem,
reviewTargetId,
reviewTargetKind,
type FedimintAnnouncement,
type LnurlAnnouncement,
type LnurlFields,
} from '@cashumints/shared';
import { config } from './config.ts';
import { getDb, setState, getStateNumber } from './db.ts';
import type { Sql } from './db-driver.ts';
import { log } from './log.ts';
import { insertMintIfNew, upsertFedimint } from './mints.ts';
import { insertMintIfNew, upsertFedimint, upsertLnurl } from './mints.ts';
const QUERY_LIMIT = 500;
const MAX_WAIT_MS = 12_000;
@@ -40,7 +43,16 @@ export interface DiscoveryResult {
let pool: SimplePool | null = null;
function getPool(): SimplePool {
/**
* The process's one relay pool.
*
* Exported because `relay-lookup.ts` asks the same relays a different question — "has
* anyone ever mentioned this address?", for a mint a reader just submitted that does
* not answer — and opening a second pool for it would mean a second set of sockets to
* the same five relays, with its own reconnect behaviour and its own lifetime to get
* wrong at shutdown.
*/
export function getPool(): SimplePool {
pool ??= new SimplePool();
return pool;
}
@@ -137,6 +149,27 @@ async function fetchReviewsForMint(
'#d': [target.federationId],
'#k': [String(KIND_FEDIMINT_ANNOUNCEMENT)],
});
} else if (target.type === 'lnurl') {
/*
* Asked for by every identifier it has ever had, not just its current one.
*
* An LNURL mint's `d` is its funding node's pubkey when it has one and its host
* otherwise, so a mint that gained a funding source has reviews filed under both
* spellings — the older ones under the host, the newer under the pubkey. One `#d`
* filter carrying both is one round trip and finds all of them; asking for only the
* current identifier would silently strand the earlier half. See the kind document.
*
* `#u` is asked as well, and unlike a federation's invite codes an LNURL mint's URLs
* are short enough that no relay rejects the filter.
*/
if (target.identifiers.length > 0) {
filters.push({
...base,
'#d': target.identifiers,
'#k': [String(KIND_LNURL_ANNOUNCEMENT)],
});
}
filters.push({ ...base, '#u': mintUrlSpellings(target.baseUrl ?? target.url) });
} else {
if (target.pubkey) {
filters.push({ ...base, '#d': [target.pubkey], '#k': [String(KIND_MINT_ANNOUNCEMENT)] });
@@ -164,10 +197,14 @@ async function fetchReviewsForMint(
*/
interface ReviewTarget {
type: string;
/** Row key: the mint URL, or `fedimint:<id>`. */
/** Row key: the mint URL, `fedimint:<id>`, or `lnurl:<base url>`. */
url: string;
pubkey: string | null;
federationId: string | null;
/** LNURL: every `d` this mint could be reviewed under. Empty for the others. */
identifiers: string[];
/** LNURL: the fetchable https URL inside the row key. */
baseUrl: string | null;
}
/**
@@ -201,6 +238,41 @@ async function ingestFedimints(
}
}
/**
* Every LNURL mint the batch announces, inserted or refreshed.
*
* Deduped **by normalized base URL**, not by the `d` tag, which is the one place this
* genuinely differs from `ingestFedimints`. A federation has exactly one identity for
* life; an LNURL mint's identifier is its funding node's pubkey when it has one and its
* host otherwise, so the same mint legitimately announces under two different `d` values
* across its life. Keying on `d` would split it into two pages with half its reviews on
* each. The `u` tag is present in every form of the event, so it is the key.
*
* Newest announcement per URL wins inside the batch, for the same reason the Fedimint
* side does it: the same mint is announced by more than one npub and more than once, and
* writing each of those would be one UPDATE per event to reach the same final state.
*/
async function ingestLnurl(
events: Iterable<NostrEvent>,
now: number,
newRows: Set<string>,
): Promise<void> {
const newest = new Map<string, LnurlAnnouncement>();
for (const event of events) {
const announcement = parseLnurlAnnouncement(event);
if (!announcement) continue;
const existing = newest.get(announcement.baseUrl);
if (existing && existing.announcedAt >= announcement.announcedAt) continue;
newest.set(announcement.baseUrl, announcement);
}
for (const announcement of newest.values()) {
const created = await upsertLnurl(announcement, now);
if (created) newRows.add(created);
}
}
/**
* Every mint URL the batch points at, normalized and inserted if new.
*
@@ -247,6 +319,18 @@ interface MintIndex {
byFederation: Map<string, string>;
/** Invite code to row key, for a review that carries `u` but no usable `d`. */
byInvite: Map<string, string>;
/**
* Every LNURL identifier to its row key: the mint pubkey *and* the normalized host,
* both pointing at the same row.
*
* Kept apart from `byPubkey`, which is the Cashu index over `mints.pubkey`. An LNURL
* mint's key is its funding node's identity, not a Cashu mint pubkey, and mixing the
* two namespaces would let a review of one resolve to the other. Nothing writes
* `mints.pubkey` for an LNURL row, for exactly that reason.
*/
byLnurlId: Map<string, string>;
/** Normalized base URL to row key, for a review that carries `u` but no usable `d`. */
byLnurlUrl: Map<string, string>;
}
async function loadMintIndex(): Promise<MintIndex> {
@@ -263,12 +347,30 @@ async function loadMintIndex(): Promise<MintIndex> {
const byPubkey = new Map<string, string>();
const byFederation = new Map<string, string>();
const byInvite = new Map<string, string>();
const byLnurlId = new Map<string, string>();
const byLnurlUrl = new Map<string, string>();
for (const row of rows) {
byHost.set(row.host, row.url);
// First writer wins, matching the LIMIT 1 the per-event query used.
if (row.pubkey && !byPubkey.has(row.pubkey)) byPubkey.set(row.pubkey, row.url);
if (row.type === 'lnurl' && row.ecosystem_json) {
try {
const fields = JSON.parse(row.ecosystem_json) as Partial<LnurlFields>;
// Both identifiers, always: a mint announced by host and later by pubkey has
// reviews under each, and both belong to this one row.
for (const id of [fields.lnurl_id, fields.mint_pubkey]) {
if (id) byLnurlId.set(id.toLowerCase(), row.url);
}
if (fields.base_url) byLnurlUrl.set(fields.base_url, row.url);
} catch {
// Unparseable column: this row simply cannot be matched by identifier. It still
// has a page and still resolves by its key, which is honest.
}
continue;
}
if (row.type !== 'fedimint' || !row.ecosystem_json) continue;
try {
const fields = JSON.parse(row.ecosystem_json) as {
@@ -283,7 +385,7 @@ async function loadMintIndex(): Promise<MintIndex> {
}
}
return { byHost, byPubkey, byFederation, byInvite };
return { byHost, byPubkey, byFederation, byInvite, byLnurlId, byLnurlUrl };
}
/**
@@ -303,6 +405,7 @@ function resolveReviewTarget(event: NostrEvent, index: MintIndex): string | null
*/
const ecosystem = reviewEcosystem(event);
if (ecosystem === 'fedimint') return resolveFedimintReview(event, index);
if (ecosystem === 'lnurl') return resolveLnurlReview(event, index);
// A `k` naming a kind this build has no ecosystem for: not ours to file.
if (ecosystem === null) return null;
@@ -344,6 +447,39 @@ function resolveFedimintReview(event: NostrEvent, index: MintIndex): string | nu
return null;
}
/**
* Which LNURL mint a review is about.
*
* `d` first, matched against both identifiers a mint can have — its funding node's
* pubkey and its normalized host — because a mint that gained a funding source has
* reviews written under each and both name the same page.
*
* The `u` fallback is not the afterthought it is on the Fedimint side. An LNURL mint's
* `u` tag is a real, normalizable https URL, so a review that carries only an address
* resolves exactly as a Cashu review does, through the same normalizer. That matters
* for reviews written by clients that do not know this kind: `u` is the tag they are
* most likely to get right.
*
* A review of a mint this site has never seen resolves to nothing and is dropped, as it
* is for the other two ecosystems. There is no page to put it on.
*/
function resolveLnurlReview(event: NostrEvent, index: MintIndex): string | null {
const d = reviewTargetId(event);
if (d) {
const found = index.byLnurlId.get(d.trim().toLowerCase());
if (found) return found;
}
for (const raw of mintUrlsFromEvent(event)) {
const normalized = normalizeMintUrl(raw);
if (!normalized) continue;
const found = index.byLnurlUrl.get(normalized.url);
if (found) return found;
}
return null;
}
/** Postgres caps a statement at 65535 bounds parameters; this keeps every batch clear of it. */
const INSERT_BATCH = 500;
@@ -402,8 +538,19 @@ async function ingestReviews(events: Iterable<NostrEvent>, index: MintIndex): Pr
*/
const target = resolveReviewTarget(e, index);
if (!target) continue;
const fedimint = reviewTargetKind(e) === KIND_FEDIMINT_ANNOUNCEMENT;
if (!fedimint && !isCashuMintReview(e) && mintUrlsFromEvent(e).length === 0) continue;
/*
* A review whose `k` names an ecosystem this build knows has already proved itself
* by resolving: `resolveReviewTarget` runs one resolver per ecosystem and returns a
* row only for an event whose `k` matches that resolver, so nothing here has to
* re-test it. Written against `ANNOUNCEMENT_KINDS` rather than against a list of
* kind numbers, so a fourth ecosystem needs no edit here either.
*
* The `u` clause is what keeps the legacy events: a great many kind 38000 on the
* network carry no `k` at all, every one of them is about a Cashu mint, and they are
* accepted on having a resolvable mint URL exactly as they always were.
*/
const kindKnown = reviewTargetKind(e) !== null && reviewEcosystem(e) !== null;
if (!kindKnown && mintUrlsFromEvent(e).length === 0) continue;
const k = reviewTargetKind(e);
@@ -469,10 +616,12 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
const announcements = announcementsByType.get('cashu') ?? [];
const federations = announcementsByType.get('fedimint') ?? [];
events += announcements.length + federations.length;
const lnurlMints = announcementsByType.get('lnurl') ?? [];
events += announcements.length + federations.length + lnurlMints.length;
await ingestMintUrls(announcements, now, newMints);
await ingestFedimints(federations, now, newMints);
await ingestLnurl(lnurlMints, now, newMints);
// Announcements alone miss mints that only ever appear in a review's `u` tag,
// so reviews feed discovery too.
@@ -505,15 +654,32 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
const targets: ReviewTarget[] = rows.map((row) => {
let federationId: string | null = null;
if (row.type === 'fedimint' && row.ecosystem_json) {
let identifiers: string[] = [];
let baseUrl: string | null = null;
if (row.ecosystem_json) {
try {
if (row.type === 'fedimint') {
federationId =
(JSON.parse(row.ecosystem_json) as { federation_id?: string }).federation_id ?? null;
} else if (row.type === 'lnurl') {
const fields = JSON.parse(row.ecosystem_json) as Partial<LnurlFields>;
baseUrl = fields.base_url ?? null;
// Deduped, because a mint with no funding source has `lnurl_id` and
// `mint_pubkey` describing the same thing and a relay filter listing one
// value twice is one wasted slot.
identifiers = [...new Set(
[fields.lnurl_id, fields.mint_pubkey]
.filter((id): id is string => Boolean(id))
.map((id) => id.toLowerCase()),
)];
}
} catch {
// Nothing to ask by. The global sweep above already had its chance.
}
}
return { type: row.type, url: row.url, pubkey: row.pubkey, federationId };
return { type: row.type, url: row.url, pubkey: row.pubkey, federationId, identifiers, baseUrl };
});
const CONCURRENCY = 6;
+536
View File
@@ -0,0 +1,536 @@
/**
* On-demand indexing: `POST /api/index`.
*
* The fifth endpoint, and the first one that writes. Everything else this API serves is
* a read of rows the probe and discovery loops put there on their own schedule; this is
* a reader saying "I have an address you do not know, look at it now" and getting a page
* back in the same request.
*
* It exists because of a specific failure the site had: opening `/lnurl-mint/mint.600.wtf`
* — a live, healthy mint — returned the 404 page, because the only way into the index
* was a Nostr announcement that nobody had published. A directory whose answer to "here
* is a mint you have not got" is a 404 is a directory that can only ever list what other
* people already listed.
*
* The order of the checks below is the whole design, and each step is only reached
* because the one above it did not settle the question:
*
* 1. **Already indexed?** Then there is nothing to do and nothing to fetch. Answering
* from the row is not an optimisation, it is the correct answer: a mint's status is
* the probe loop's business, and a submission is not a reason to re-probe it.
* 2. **Does it answer, as what it claims to be?** One probe, the standard timeout,
* through the guarded fetcher in `safe-fetch.ts`.
* 3. **Does it answer as something else?** A Cashu mint pasted into the LNURL box is
* the single most likely mistake, and "that address answered, but as a Cashu mint"
* with a button to go there is worth far more than "invalid".
* 4. **Did it ever exist?** Silence over HTTPS is not proof of absence. One bounded
* relay lookup separates a mint that rugged from an address nobody ever used —
* see `relay-lookup.ts`, which is where that argument is made at length.
*
* A row written here is an ordinary row the moment it exists. The probe loop owns it
* from the next cycle, discovery will fill in its reviews, and nothing downstream can
* tell how it arrived.
*/
import {
fedimintKey,
federationIdFromInviteCode,
isIndexType,
isNut06Info,
lnurlKey,
normalizeMintUrl,
parseAdvertisement,
parsePayInfo,
PAY_INFO_PATH,
WITHDRAW_INFO_PATH,
type IndexFailure,
type IndexSource,
type IndexSuccess,
type IndexType,
type MintInfo,
} from '@cashumints/shared';
import { getDb } from './db.ts';
import { log } from './log.ts';
import { probeLnurl } from './lnurl-probe.ts';
import {
insertFedimintFromInvite,
insertLnurlIfNew,
insertMintIfNew,
mintByHost,
mintByUrl,
upsertLnurl,
type MintRow,
} from './mints.ts';
import { applyLnurlResult, recordCashuOnline } from './probe.ts';
import { getMintDetail, resetStatsCache } from './queries.ts';
import { traceOnRelays } from './relay-lookup.ts';
import { checkDestination, guardedTextFetcher, MAX_PROBE_BYTES, safeFetchText } from './safe-fetch.ts';
import { cacheIcon } from './icons.ts';
export interface IndexOutcome {
status: 200 | 201 | 404 | 422 | 429;
body: IndexSuccess | IndexFailure;
}
function fail(
status: 404 | 422 | 429,
error: IndexFailure['error'],
message: string,
extra: Partial<IndexFailure> = {},
): IndexOutcome {
return { status, body: { error, message, ...extra } };
}
/**
* The response body for a row, in the shape `GET /api/mints/:host` returns.
*
* Read back through `getMintDetail` rather than assembled here, so the payload a
* freshly indexed mint arrives in is byte for byte the payload it will have on every
* request after this one. The 404 resolver renders the same page shell from both, and
* a second shape to keep in step would be a second shape to get wrong.
*/
async function payload(host: string, existing: boolean, source?: IndexSource): Promise<IndexOutcome> {
const detail = await getMintDetail(host);
if (!detail) {
// The row was written moments ago; a miss here means the write did not land.
return fail(422, 'invalid_response', 'The mint was indexed but could not be read back');
}
// The new row changes the counts three pages print in a sentence, and those are
// memoised for a minute. A reader who has just indexed a mint should see it counted.
if (!existing) resetStatsCache();
return {
status: existing ? 200 : 201,
body: { ...detail, existing, ...(source ? { indexed_from: source } : {}) },
};
}
/* ---------- what kind of bad input is this? ---------- */
/**
* Why `normalizeMintUrl` said no.
*
* It answers null for everything, which is right for a discovery loop reading tags and
* wrong for a person who just typed something: "that is not a URL" and "we will not
* fetch a private address" are different mistakes with different fixes.
*/
function rejectionFor(input: string): IndexFailure['error'] {
const raw = input.trim();
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
try {
const url = new URL(withScheme);
if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'bad_input';
return url.hostname ? 'blocked_host' : 'bad_input';
} catch {
return 'bad_input';
}
}
/* ---------- cashu ---------- */
/** What one guarded `/v1/info` request concluded. */
type InfoProbe =
| { state: 'mint'; info: MintInfo; latencyMs: number }
| { state: 'answered'; detail: string }
| { state: 'blocked'; reason: string }
| { state: 'silent'; reason: string };
async function probeCashuInfo(url: string): Promise<InfoProbe> {
const started = Date.now();
const outcome = await safeFetchText(`${url}/v1/info`, {
accept: 'application/json',
maxBytes: MAX_PROBE_BYTES,
});
if (outcome.state === 'blocked') return { state: 'blocked', reason: outcome.reason };
if (outcome.state === 'unreachable') return { state: 'silent', reason: outcome.reason };
if (outcome.status < 200 || outcome.status >= 300) {
return { state: 'answered', detail: `HTTP ${outcome.status} from /v1/info` };
}
let body: unknown;
try {
body = JSON.parse(outcome.body) as unknown;
} catch {
return { state: 'answered', detail: 'the response was not JSON' };
}
if (!isNut06Info(body)) {
return { state: 'answered', detail: 'the response was not a NUT-06 mint info document' };
}
return { state: 'mint', info: body, latencyMs: Date.now() - started };
}
/* ---------- lnurl ---------- */
/**
* Is this host an LNURL mint, asked cheaply, for wrong-type detection only.
*
* The full `probeLnurl` fetches four endpoints and is what runs when LNURL is what was
* claimed. This is the other direction — a Cashu submission that did not answer as one
* — and only has to answer yes or no, so it reads the two advertisements and stops.
*/
async function looksLikeLnurl(url: string): Promise<boolean> {
const get = guardedTextFetcher();
const [withdraw, pay] = await Promise.all([
get(`${url}${WITHDRAW_INFO_PATH}`, 'application/json', MAX_PROBE_BYTES),
get(`${url}${PAY_INFO_PATH}`, 'application/json', MAX_PROBE_BYTES),
]);
const parsed = (raw: { body: string } | null, parse: (value: unknown) => unknown): boolean => {
if (!raw) return false;
try {
return parse(JSON.parse(raw.body) as unknown) !== null;
} catch {
return false;
}
};
return parsed(withdraw, parseAdvertisement) || parsed(pay, parsePayInfo);
}
/** The same question the other way round, for an LNURL submission that did not parse. */
async function looksLikeCashu(url: string): Promise<boolean> {
const probe = await probeCashuInfo(url);
return probe.state === 'mint';
}
/* ---------- writing a row that nothing answered for ---------- */
/**
* Record a mint that Nostr remembers and HTTPS does not: the rugged-mint case.
*
* `consecutive_fails` is set straight to the offline threshold rather than to the one
* failure that actually happened, and that is deliberate. The ladder in `statusForFails`
* only ever counts upwards, so a row stored as `offline` with one failure behind it
* would be *promoted* to `degraded` by its next failed probe — a mint appearing to
* recover by staying dark. Three failures is what "offline" means everywhere else in
* this database, so that is what an offline row carries.
*/
async function markOfflineFromAnnouncement(
row: MintRow,
meta: { name: string | null; about: string | null; picture: string | null },
now: number,
): Promise<void> {
const db = await getDb();
const iconFile = meta.picture ? await cacheIcon(row, meta.picture) : row.icon_file;
await db.run(
`UPDATE mints SET
name = COALESCE(name, ?),
description = COALESCE(description, ?),
icon_url = COALESCE(icon_url, ?),
icon_file = ?,
status = 'offline',
consecutive_fails = 3,
last_probe = ?,
updated_at = ?
WHERE url = ?`,
meta.name,
meta.about,
meta.picture,
iconFile,
now,
now,
row.url,
);
// One real failed probe, because one real probe really did fail. The uptime figure
// and the sparkline should show that this site looked and got nothing.
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)',
row.url,
now,
);
}
/**
* The last resort for a URL nothing answered at: ask the relays, and index what they
* remember. Returns the outcome to send back, whichever way it went.
*/
async function fromRelays(
type: 'cashu' | 'lnurl',
url: string,
now: number,
): Promise<IndexOutcome> {
const trace = await traceOnRelays(type, url);
if (!trace.found) {
return fail(
404,
'unverifiable',
'Nothing answered at that address, and no announcement or review of it exists on Nostr',
);
}
/*
* An LNURL announcement is written through the same path discovery uses, so the row
* carries its features, network and announcer exactly as it would have — but only when
* the announcement is about *this* address. A `#d` filter can also match an
* announcement whose `u` is a different URL under the same host identifier, and that
* one belongs to a different row.
*/
const useAnnouncement = type === 'lnurl' && trace.lnurl?.baseUrl === url;
const created = useAnnouncement
? await upsertLnurl(trace.lnurl!, now)
: type === 'lnurl'
? await insertLnurlIfNew(url, now)
: await insertMintIfNew(url, now);
const key = created ?? (type === 'lnurl' ? lnurlKey(url) : url);
const row = await mintByUrl(key);
if (!row) return fail(422, 'invalid_response', 'The mint could not be indexed');
/*
* Only a row this call brought into being is marked offline.
*
* The `existing` check upstream means there is almost always one, but an upsert can
* land on a row that was already there — and a row the probe loop has an opinion about
* is not one an unreachable submission gets to overwrite. Its status is the loop's to
* decide; this returns what is on file instead.
*/
if (created === null && row.status !== 'unknown') return payload(row.host, true);
await markOfflineFromAnnouncement(
row,
{ name: trace.name, about: trace.about, picture: trace.picture },
now,
);
log.info('indexed from nostr', {
url: key,
type,
reviews: trace.reviews,
announced: trace.announcedAt !== null,
});
return payload(row.host, false, 'announcement');
}
/* ---------- the three type paths ---------- */
async function indexFedimint(
code: string,
federationId: string,
now: number,
): Promise<IndexOutcome> {
const key = fedimintKey(federationId);
await insertFedimintFromInvite(code, federationId, now);
const row = await mintByUrl(key);
if (!row) return fail(422, 'invalid_invite', 'The federation could not be indexed');
log.info('indexed from invite code', { url: key });
return payload(row.host, false, 'invite');
}
async function indexCashu(url: string, host: string, now: number): Promise<IndexOutcome> {
const probe = await probeCashuInfo(url);
if (probe.state === 'blocked') {
return fail(422, 'blocked_host', `That address cannot be checked: ${probe.reason}`);
}
if (probe.state === 'answered') {
if (await looksLikeLnurl(url)) {
return fail(422, 'wrong_type', 'That address answered as an LNURL mint, not a Cashu mint', {
detected_type: 'lnurl',
});
}
return fail(422, 'invalid_response', `That address answered, but ${probe.detail}`);
}
if (probe.state === 'silent') return fromRelays('cashu', url, now);
// A live mint. Insert, then write exactly what a successful probe writes.
await insertMintIfNew(url, now);
const row = await mintByUrl(url);
if (!row) {
/*
* The URL is fine and the mint answered, so the only way here is a slug already
* held by a different URL — `host/a-b` and `host/a/b` both slug to `host-a-b`. That
* is a real limitation of the routing scheme rather than anything the reader did,
* and it is worth saying so plainly instead of claiming their mint is invalid.
*/
const holder = await mintByHost(host);
return fail(
422,
'invalid_response',
holder
? `That mint's page address is already taken by ${holder.url}`
: 'The mint could not be indexed',
);
}
await recordCashuOnline(row, probe.info, probe.latencyMs, now);
log.info('indexed on demand', { url, type: 'cashu', status: 'online' });
return payload(row.host, false, 'probe');
}
async function indexLnurl(url: string, now: number): Promise<IndexOutcome> {
// The base URL's own address is checked once here, so a blocked host is reported as
// blocked rather than as four endpoints that happened not to answer.
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return fail(422, 'bad_input', 'That is not a URL');
}
const verdict = await checkDestination(parsed);
if (verdict?.kind === 'blocked') {
return fail(422, 'blocked_host', `That address cannot be checked: ${verdict.reason}`);
}
// A host whose name no longer resolves is the rugged case, not a bad submission.
if (verdict) return fromRelays('lnurl', url, now);
let result: Awaited<ReturnType<typeof probeLnurl>>;
try {
result = await probeLnurl(url, guardedTextFetcher());
} catch {
// Neither endpoint answered at all. It may still be a mint that used to be one.
return fromRelays('lnurl', url, now);
}
if (result.outcome === 'invalid') {
if (await looksLikeCashu(url)) {
return fail(422, 'wrong_type', 'That address answered as a Cashu mint, not an LNURL mint', {
detected_type: 'cashu',
});
}
return fail(
422,
'invalid_response',
`That address answered, but not as an LNURL mint: ${result.invalidReason ?? 'unrecognised response'}`,
);
}
const key = lnurlKey(url);
await insertLnurlIfNew(url, now);
const row = await mintByUrl(key);
if (!row) return fail(422, 'invalid_response', 'The mint could not be indexed');
await applyLnurlResult(row, result, now);
log.info('indexed on demand', { url: key, type: 'lnurl', status: 'online' });
return payload(row.host, false, 'probe');
}
/* ---------- the entry point ---------- */
/**
* In-flight submissions, keyed by what they would create.
*
* Two readers pasting the same address at the same moment — which is exactly what
* happens when a link is shared — must produce one probe and one row, not a race
* between two inserts and two visits to a stranger's mint. The second caller awaits the
* first one's promise and gets its answer.
*
* Keyed on the *normalized* identifier rather than the raw input, so `mint.600.wtf` and
* `https://mint.600.wtf/` collapse into one entry for the same reason they collapse
* into one row.
*/
const inFlight = new Map<string, Promise<IndexOutcome>>();
/** How many submissions are being worked on right now. For the checks. */
export function inFlightCount(): number {
return inFlight.size;
}
/** How many probes may be in flight at once, across every submitter. */
const MAX_IN_FLIGHT = 8;
export async function indexSubmission(rawType: string, rawInput: unknown): Promise<IndexOutcome> {
if (!isIndexType(rawType)) {
return fail(422, 'bad_type', 'type must be one of cashu, fedimint or lnurl');
}
const type: IndexType = rawType;
if (typeof rawInput !== 'string' || rawInput.trim() === '') {
return fail(422, 'bad_input', 'input is required');
}
// Long enough for any real invite code (they run to ~400 characters), short enough
// that nothing absurd reaches a parser.
const input = rawInput.trim().slice(0, 2048);
let key: string;
let url = '';
let host = '';
if (type === 'fedimint') {
const federationId = federationIdFromInviteCode(input);
if (!federationId) return fail(422, 'invalid_invite', 'That is not a Fedimint invite code');
key = fedimintKey(federationId);
} else {
const normalized = normalizeMintUrl(input);
if (!normalized) {
const reason = rejectionFor(input);
return fail(
422,
reason,
reason === 'blocked_host'
? 'That address is not a public one this site will check'
: 'That is not a mint URL',
);
}
url = normalized.url;
host = normalized.host;
key = type === 'lnurl' ? lnurlKey(url) : url;
}
/*
* The dedup check comes before every await that follows, and that ordering is the
* whole guarantee. An earlier version looked the row up first and registered the
* in-flight entry afterwards, which left a window: two requests that arrived in the
* same tick both got past the lookup before either had registered, and both probed.
* Nothing may be awaited between computing the key and claiming it.
*/
const pending = inFlight.get(key);
if (pending) return pending;
if (inFlight.size >= MAX_IN_FLIGHT) {
/*
* A global ceiling on outbound probes, under the same reason code as the per-address
* limit because it is the same answer to the reader: not now, try shortly. The
* per-address budget already stops one browser tab from doing this; this stops many
* of them at once from turning this process into a load generator pointed at
* whatever host they picked.
*/
return fail(429, 'rate_limited', 'Too many checks in flight right now', { retry_after: 30 });
}
const now = Math.floor(Date.now() / 1000);
const work = (async (): Promise<IndexOutcome> => {
/*
* Is this identifier already a row? The same lookup for all three types, by key
* first and then by routing slug, because the slug is what the insert path dedupes
* on — `https://mint.example.com/Bitcoin` and its lowercase spelling are one mint
* under two URLs, and answering "not indexed" for the second would create the
* duplicate the normalizer exists to prevent. Nothing is probed on this path.
*/
const existing =
(await mintByUrl(key)) ?? (host ? await rowByHostOfType(host, type) : undefined);
if (existing) return payload(existing.host, true);
if (type === 'fedimint') {
return indexFedimint(input.toLowerCase(), key.slice('fedimint:'.length), now);
}
if (type === 'lnurl') return indexLnurl(url, now);
return indexCashu(url, host, now);
})().finally(() => inFlight.delete(key));
inFlight.set(key, work);
return work;
}
/**
* A row holding this routing slug, but only when it belongs to the ecosystem asked
* about.
*
* An LNURL mint and a Cashu mint can share a hostname, and the LNURL one then takes the
* `lnurl-` prefixed slug (see `insertLnurlIfNew`). Without the type test, submitting
* that LNURL mint's URL would find the *Cashu* row on the bare slug and answer "already
* indexed" with the wrong mint's page.
*/
async function rowByHostOfType(host: string, type: IndexType): Promise<MintRow | undefined> {
const row = await mintByHost(host);
return row && row.type === type ? row : undefined;
}
+15
View File
@@ -1,4 +1,5 @@
import { serve } from '@hono/node-server';
import { announceLnurlMints } from './announce.ts';
import { config } from './config.ts';
import { closeDb, getDb, pruneProbes } from './db.ts';
import { closePool, runDiscovery } from './discovery.ts';
@@ -31,6 +32,20 @@ async function probeCycle(): Promise<void> {
probeRunning = true;
try {
await track(probeAll());
/*
* Announcing runs after probing, in the same cycle, because it publishes only what
* the probe just confirmed — running it on its own timer would mean signing for a
* state that could be an interval old. Off by default; see `announceConfig`.
*
* Its own failures are caught here rather than allowed to mark the probe cycle
* failed: whether relays accepted an event says nothing about whether this site
* successfully checked its mints.
*/
await track(announceLnurlMints()).catch((err: unknown) => {
log.error('announce cycle failed', {
reason: err instanceof Error ? err.message : String(err),
});
});
} catch (err) {
log.error('probe cycle failed', { reason: err instanceof Error ? err.message : String(err) });
} finally {
+274
View File
@@ -0,0 +1,274 @@
/**
* Checking an LNURL mint.
*
* Unlike a federation, which has no public endpoint and is checked by reading somebody
* else's index, an LNURL mint answers over HTTPS and this site checks it itself — the
* same relationship it has with a Cashu mint. What differs is that "answered" and
* "working" are two questions here rather than one, and the endpoint layout is not what
* the software's own README describes. `NOTES-LNURL.md` records what is actually on the
* wire; the three rules that shape this file are:
*
* 1. **There is no bare `/p`.** The mint advertisement — withdraw limits, description,
* mint pubkey, node identity — lives at `/.well-known/lnurlw/_`. The payRequest at
* `/.well-known/lnurlp/_` carries the pay-side limits and the fee, and is the
* fallback when the withdraw side does not answer.
* 2. **HTTP 200 proves nothing.** Every registered route returns 200 with an LNURL
* `{"status":"ERROR"}` body for its errors. Only the parsed body decides.
* 3. **Absence is the signal.** `None` fields are dropped from responses entirely, so
* a missing `mintPubkey` is how "the funding source is not reachable" reaches the
* wire. That is the degraded-but-online state.
*
* Nothing here calls anything that mutates. `/p/cb` would make the mint issue a real
* invoice on its operator's node, and `/w/cb` burns notes; neither is something a
* directory gets to do to a stranger on a ten minute timer. The cost of that restraint
* is that LUD-21 verify cannot be detected at all — see the kind document, which makes
* that normative rather than incidental.
*/
import {
PAY_INFO_PATH,
WITHDRAW_INFO_PATH,
addressFromPayLink,
onionFromHtml,
parseAdvertisement,
parsePayInfo,
parseSoftware,
type LnurlAdvertisement,
type LnurlPayInfo,
} from '@cashumints/shared';
import { config } from './config.ts';
import { readBodyBounded } from './http.ts';
/**
* Body caps, per endpoint.
*
* The JSON documents are a few hundred bytes; 64KB is room for a mint with an unusually
* chatty metadata blob and nothing more. The one-pager is real HTML with an inline QR
* SVG — 13.8KB on the live instance — so it gets its own, larger cap. Both bound an
* untrusted server, in bytes, on top of the abort timer that bounds it in seconds.
*/
const MAX_JSON_BYTES = 64 * 1024;
const MAX_HTML_BYTES = 512 * 1024;
/** What one probe of one LNURL mint concluded. */
export interface LnurlProbeResult {
/**
* `online` — a mint advertisement parsed, and the mint's Lightning node answered.
* `degraded-funding` — an advertisement parsed, but the node behind it did not, so
* minting and melting are unavailable while rotate/split/merge still work. Still
* an `ok` probe: the mint is up, and this is what warnings are for.
* `invalid` — the host answered with something that is not a mint advertisement.
* Neither up nor down, and counted as a failed probe with a reason attached.
*/
outcome: 'online' | 'degraded-funding' | 'invalid';
latencyMs: number;
/** Which path answered. Stored so a page can say what was actually checked. */
endpoint: string;
advertisement: LnurlAdvertisement | null;
pay: LnurlPayInfo | null;
lightningAddress: string | null;
onionUrl: string | null;
software: string | null;
/** Short, human-readable, and only set when `outcome` is `invalid`. */
invalidReason: string | null;
}
/**
* How this module reaches a mint.
*
* A parameter rather than a hard-wired `fetch`, because there are two callers with
* genuinely different threat models. The probe loop checks addresses that reached the
* database through discovery or the seed list, and uses the plain fetcher below.
* `POST /api/index` checks an address a stranger typed thirty seconds ago, and passes
* the guarded one from `safe-fetch.ts`, which resolves DNS and refuses private ranges
* before a socket opens. The parsing, the endpoint layout and every rule about what
* counts as a mint are identical either way, which is the point of the seam.
*/
export type TextFetcher = (
url: string,
accept: string,
maxBytes: number,
) => Promise<{ body: string; status: number } | null>;
/** A fetch that is bounded in time and in bytes, and never throws for a caller. */
const fetchBounded: TextFetcher = async (
url: string,
accept: string,
maxBytes: number,
): Promise<{ body: string; status: number } | null> => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: { Accept: accept, 'User-Agent': config.userAgent },
redirect: 'follow',
});
const body = await readBodyBounded(res, maxBytes);
if (!body) return null;
return { body: body.toString('utf8'), status: res.status };
} catch {
// Timed out, DNS failure, TLS failure, connection reset. All the same to a caller:
// nothing was learned.
return null;
} finally {
clearTimeout(timer);
}
};
/**
* What one JSON endpoint did.
*
* Three outcomes, not two, and the middle one is why this is not simply
* `Promise<unknown | null>`: a host that answered with something that will not parse is
* *reachable*, and the site owes its readers a different sentence for that than for a
* host that is not there at all. Collapsing them would file every misconfigured proxy
* and every parked domain under "offline".
*/
type JsonProbe =
| { state: 'unreachable' }
| { state: 'unparseable'; status: number }
| { state: 'parsed'; value: unknown; status: number };
async function fetchJson(url: string, get: TextFetcher): Promise<JsonProbe> {
const res = await get(url, 'application/json', MAX_JSON_BYTES);
if (!res) return { state: 'unreachable' };
try {
return { state: 'parsed', value: JSON.parse(res.body) as unknown, status: res.status };
} catch {
return { state: 'unparseable', status: res.status };
}
}
/** The parsed body, or null for anything that did not parse. */
function jsonValue(probe: JsonProbe): unknown {
return probe.state === 'parsed' ? probe.value : null;
}
/**
* The reason string for a response that arrived but was not a mint advertisement.
*
* Kept short and specific, because it is what the "responding but invalid" banner shows
* underneath and because the three cases are genuinely different problems: a host that
* is not this software at all, a host that is but is refusing, and a host serving
* something that is not JSON.
*/
function invalidReasonFor(probe: JsonProbe): string {
if (probe.state === 'unreachable') return 'no response';
if (probe.state === 'unparseable') {
// Almost always an HTML error page from a proxy, or a parked domain. The status
// code is the only part of it worth repeating back.
return `HTTP ${probe.status}, and the body was not JSON`;
}
const body = probe.value;
if (body === null || body === undefined) return 'empty JSON response';
if (typeof body !== 'object' || Array.isArray(body)) return 'response was not a JSON object';
const record = body as Record<string, unknown>;
// The LNURL error convention: 200 with a status/reason pair. Common and informative.
if (record['status'] === 'ERROR') {
const reason = typeof record['reason'] === 'string' ? record['reason'].slice(0, 120) : '';
return reason ? `mint replied: ${reason}` : 'mint replied with an LNURL error';
}
if (typeof record['tag'] === 'string') return `unexpected tag "${String(record['tag']).slice(0, 40)}"`;
if (record['detail'] !== undefined) return 'endpoint not found on this host';
return 'response was not a mint advertisement';
}
/**
* Probe one LNURL mint.
*
* The withdraw side first, because it is the only endpoint carrying everything the site
* renders. The payRequest is fetched too, but for different reasons in the two cases:
* alongside a good advertisement it adds the fee, the pay-side limits and a
* human-written description; when the advertisement failed it is the fallback that can
* still prove the host is a live LNURL mint whose withdraw alias is simply configured
* under a username this probe cannot guess.
*
* The two opportunistic reads — the one-pager for a Tor address, `/openapi.json` for a
* version — never affect the outcome. A mint is not less online because its operator
* turned off the docs endpoint.
*/
export async function probeLnurl(
baseUrl: string,
get: TextFetcher = fetchBounded,
): Promise<LnurlProbeResult> {
const started = Date.now();
const [withdrawProbe, payProbe] = await Promise.all([
fetchJson(`${baseUrl}${WITHDRAW_INFO_PATH}`, get),
fetchJson(`${baseUrl}${PAY_INFO_PATH}`, get),
]);
const advertisement = parseAdvertisement(jsonValue(withdrawProbe));
const pay = parsePayInfo(jsonValue(payProbe));
const latencyMs = Date.now() - started;
const base = {
latencyMs,
advertisement,
pay,
lightningAddress: addressFromPayLink(advertisement?.payLink) ?? null,
};
if (!advertisement && !pay) {
/*
* Nothing parsed on either side. Distinguish "the host said something" from "the
* host said nothing at all": a timeout is an ordinary failed probe and feeds the
* consecutive-fails machinery as a plain offline, while a reply that is not a mint
* advertisement is the invalid state and gets to say why.
*/
const answered =
withdrawProbe.state !== 'unreachable' || payProbe.state !== 'unreachable';
if (!answered) throw new Error('no response from either LNURL endpoint');
/*
* Report on whichever endpoint actually said something, preferring the withdraw
* side. A host whose withdraw alias times out while its payRequest returns an HTML
* error page should say what the payRequest did, not "no response".
*/
const reported = withdrawProbe.state === 'unreachable' ? payProbe : withdrawProbe;
return {
...base,
outcome: 'invalid',
endpoint: withdrawProbe.state === 'unreachable' ? PAY_INFO_PATH : WITHDRAW_INFO_PATH,
onionUrl: null,
software: null,
invalidReason: invalidReasonFor(reported),
};
}
// Only worth two extra requests once the host has proved it is a mint.
const [html, openapi] = await Promise.all([
get(`${baseUrl}/`, 'text/html', MAX_HTML_BYTES),
fetchJson(`${baseUrl}/openapi.json`, get),
]);
const extras = {
onionUrl: html ? onionFromHtml(html.body) : null,
software: parseSoftware(jsonValue(openapi)),
invalidReason: null,
};
if (!advertisement) {
/*
* The payRequest answered and the withdraw alias did not.
*
* Still online — the host is demonstrably a live LNURL mint — but the site has no
* withdraw limits, no mint pubkey and no node identity for it, and `funding_available`
* stays unknown rather than being guessed at from the pay side. `/.well-known/lnurlp/_`
* is recorded as the endpoint so the page says what was actually checked.
*/
return { ...base, ...extras, outcome: 'online', endpoint: PAY_INFO_PATH };
}
return {
...base,
...extras,
outcome: advertisement.fundingAvailable ? 'online' : 'degraded-funding',
endpoint: WITHDRAW_INFO_PATH,
};
}
+292 -7
View File
@@ -1,6 +1,6 @@
import {
fedimintKey, fedimintSlug, normalizeMintUrl,
type FedimintAnnouncement, type FedimintFields,
LNURL_SLUG_PREFIX, fedimintKey, fedimintSlug, lnurlIdentifier, lnurlKey, normalizeMintUrl,
type FedimintAnnouncement, type FedimintFields, type LnurlAnnouncement, type LnurlFields,
} from '@cashumints/shared';
import { getDb } from './db.ts';
import { log } from './log.ts';
@@ -18,7 +18,7 @@ const reportedSkips = new Set<string>();
export interface MintRow {
url: string;
host: string;
/** 'cashu' or 'fedimint'. Rows written before the column existed default to 'cashu'. */
/** 'cashu', 'fedimint' or 'lnurl'. Rows written before the column existed default to 'cashu'. */
type: string;
name: string | null;
description: string | null;
@@ -26,7 +26,10 @@ export interface MintRow {
icon_file: string | null;
pubkey: string | null;
info_json: string | null;
/** Type-specific data. `FedimintFields` for a federation, null for a Cashu mint. */
/**
* Type-specific data: `FedimintFields` for a federation, `LnurlFields` for an LNURL
* mint, null for a Cashu one. Read it back through `parseEcosystem`.
*/
ecosystem_json: string | null;
nuts_json: string | null;
version: string | null;
@@ -106,11 +109,23 @@ export async function insertMintIfNew(
/* ---------- fedimint ---------- */
/** Read a federation row's type-specific columns back out. */
export function parseEcosystem(row: Pick<MintRow, 'ecosystem_json'>): FedimintFields | null {
/**
* Read a row's type-specific column back out.
*
* Generic, defaulting to `FedimintFields` so every existing call site reads exactly as
* it did. The caller already knows the row's `type` — it is what decided to call this
* at all — so the type argument is a statement of what was stored, not a guess.
*
* A column that will not parse degrades to null rather than throwing. Such a row still
* has a page and still resolves by its key; it simply has no ecosystem-specific fields
* on it, which is honest and is better than a probe cycle dying on one bad row.
*/
export function parseEcosystem<T = FedimintFields>(
row: Pick<MintRow, 'ecosystem_json'>,
): T | null {
if (!row.ecosystem_json) return null;
try {
return JSON.parse(row.ecosystem_json) as FedimintFields;
return JSON.parse(row.ecosystem_json) as T;
} catch {
return null;
}
@@ -196,12 +211,282 @@ export async function upsertFedimint(
return null;
}
/**
* Insert a federation from an invite code alone, with no announcement behind it.
*
* For `POST /api/index`: a reader pastes the one thing they have, and there is nothing
* else to go on. The id comes out of the code itself (`federationIdFromInviteCode`), so
* the row is keyed exactly as an announced one would be and the two can never become
* two rows for one federation.
*
* `announced` rather than `unknown`, and the distinction is the same one the status
* carries everywhere else: `unknown` means nothing has checked yet, `announced` means
* there is nothing this site *can* check. A federation has no public endpoint, so an
* invite code is exactly as much as anyone will ever be able to confirm from here until
* the observer lookup covers it.
*
* Returns the row key when a row was created, null when one already existed.
*/
export async function insertFedimintFromInvite(
code: string,
federationId: string,
now = Math.floor(Date.now() / 1000),
): Promise<string | null> {
const db = await getDb();
const url = fedimintKey(federationId);
const fields: FedimintFields = {
federation_id: federationId,
invite_codes: [code.trim().toLowerCase()],
// Everything an announcement would have carried. Discovery fills these in when one
// turns up, and until then the page says only what the code itself proves.
modules: [],
network: null,
announced_at: null,
announcer_pubkey: null,
status_source: null,
};
const result = await db.run(
`INSERT INTO mints (url, host, type, ecosystem_json, status, first_seen, updated_at)
VALUES (?, ?, 'fedimint', ?, 'announced', ?, ?)
ON CONFLICT DO NOTHING`,
url,
fedimintSlug(federationId),
JSON.stringify(fields),
now,
now,
);
return result.changes > 0 ? url : null;
}
/** Every federation row, for the probe cycle and for review resolution. */
export async function fedimintRows(): Promise<MintRow[]> {
const db = await getDb();
return db.all<MintRow>(`SELECT * FROM mints WHERE type = 'fedimint'`);
}
/* ---------- lnurl ---------- */
/**
* The routing slug an LNURL mint gets, given what the table already holds.
*
* `mints.host` carries a UNIQUE index across every ecosystem, so a Cashu mint and an
* LNURL mint on one hostname would compete for a single slug and — with the existing
* insert path — the loser would silently not be tracked at all. That is the one outcome
* worth writing code to avoid: a mint that exists, is reviewable, and has no page.
*
* So the clean slug is used whenever it is free, which is very nearly always, and a
* colliding row takes `lnurl-` in front instead. Decided once at insert and then stored,
* never recomputed: a mint that took the prefixed slug keeps it even if the row it
* collided with later disappears, because its URL is already indexed and linked and a
* silently moving page is worse than a slightly long one.
*/
async function lnurlSlug(preferred: string, url: string): Promise<string | null> {
const db = await getDb();
const takenBy = async (slug: string): Promise<string | null> => {
const row = await db.get<{ url: string }>('SELECT url FROM mints WHERE host = ?', slug);
return row && row.url !== url ? row.url : null;
};
const clash = await takenBy(preferred);
if (!clash) return preferred;
const prefixed = LNURL_SLUG_PREFIX + preferred;
const secondClash = await takenBy(prefixed);
if (!secondClash) {
log.info('lnurl slug taken, using prefixed form', { url, slug: prefixed, existing: clash });
return prefixed;
}
log.warn('lnurl slug collision, mint not tracked', { url, slug: preferred, existing: clash });
return null;
}
/** The `ecosystem_json` an LNURL row starts life with, before anything has probed it. */
function blankLnurlFields(baseUrl: string, identifier: string): LnurlFields {
return {
lnurl_id: identifier,
base_url: baseUrl,
features: [],
network: null,
announced_at: null,
announcer_pubkey: null,
mint_pubkey: null,
funding_available: null,
probe_endpoint: null,
invalid_reason: null,
min_withdrawable_msat: null,
max_withdrawable_msat: null,
min_sendable_msat: null,
max_sendable_msat: null,
fee_base_msat: null,
fee_ppm: null,
lightning_address: null,
onion_url: null,
node_alias: null,
node_uri: null,
node_capacity_msat: null,
node_channels: null,
node_peers: null,
observed_features: [],
};
}
/**
* Insert an LNURL mint by URL alone, with no announcement behind it.
*
* For the seed list and for operators added by hand. `status` starts `unknown` and the
* very next probe cycle decides it, exactly as a seeded Cashu mint works — nothing here
* claims the mint is up, and `insertMintIfNew`'s rule that an existing row is never
* touched applies just as strictly.
*
* Returns the row key when a row was created, null otherwise.
*/
export async function insertLnurlIfNew(
rawUrl: string,
now = Math.floor(Date.now() / 1000),
): Promise<string | null> {
const normalized = normalizeMintUrl(rawUrl);
if (!normalized) {
if (!reportedSkips.has(rawUrl)) {
reportedSkips.add(rawUrl);
log.warn('skipped lnurl url', { url: rawUrl.slice(0, 80) });
}
return null;
}
const db = await getDb();
const url = lnurlKey(normalized.url);
const existing = await db.get<{ url: string }>('SELECT url FROM mints WHERE url = ?', url);
if (existing) return null;
const host = await lnurlSlug(normalized.host, url);
if (host === null) return null;
const result = await db.run(
`INSERT INTO mints (url, host, type, ecosystem_json, status, first_seen, updated_at)
VALUES (?, ?, 'lnurl', ?, 'unknown', ?, ?)
ON CONFLICT DO NOTHING`,
url,
host,
JSON.stringify(blankLnurlFields(normalized.url, lnurlIdentifier(normalized.url, null))),
now,
now,
);
return result.changes > 0 ? url : null;
}
/**
* Insert or refresh an LNURL mint from a kind 38174 announcement.
*
* **Deduped on the normalized base URL, not on the `d` tag**, and that is the whole
* reason this function is not a copy of `upsertFedimint`. An LNURL mint's identifier is
* its funding node's pubkey when it has one and its host otherwise, so the same mint
* legitimately appears under two different `d` values across its life — announced by
* host before a funding source was configured, by pubkey afterwards. Keying rows on `d`
* would split one mint into two pages with half its reviews on each. The `u` tag is the
* one value present in every form of the event, so it is the key.
*
* Unlike `insertMintIfNew` this does update an existing row, because an announcement is
* the only source of `features`, the network and the operator's own name for the mint.
* Older announcements are ignored (`announced_at` goes forwards only) so a replayed
* event from last year cannot overwrite this week's capability list.
*
* Two things are deliberately never written here:
*
* - **The status columns.** Whether a mint is up is a probe's answer. An announcement
* is not evidence of anything being up.
* - **Anything a probe learned.** `mint_pubkey`, the limits, the address, the node
* fields and `funding_available` are all carried over from the existing row
* untouched. In particular a `mint_pubkey` already observed is *never* cleared by an
* announcement that lacks one: identity is sticky, per the kind document.
*
* Returns the row's key when a row was created, null when one was merely updated.
*/
export async function upsertLnurl(
announcement: LnurlAnnouncement,
now = Math.floor(Date.now() / 1000),
): Promise<string | null> {
const db = await getDb();
const url = lnurlKey(announcement.baseUrl);
const existing = await db.get<MintRow>('SELECT * FROM mints WHERE url = ?', url);
const previous = existing ? parseEcosystem<LnurlFields>(existing) : null;
if (previous && (previous.announced_at ?? 0) > announcement.announcedAt) return null;
/*
* The stored pubkey wins over the announcement's.
*
* A probe reads `mintPubkey` off the mint itself; an announcement is a stranger's
* claim about it. Where both exist the observed one is the better evidence, and where
* only the announcement has one it is still worth keeping, so this prefers the probe
* and falls back rather than overwriting in either direction.
*/
const mintPubkey = previous?.mint_pubkey ?? announcement.mintPubkey;
const fields: LnurlFields = {
...(previous ?? blankLnurlFields(announcement.baseUrl, announcement.identifier)),
lnurl_id: lnurlIdentifier(announcement.baseUrl, mintPubkey),
base_url: announcement.baseUrl,
features: announcement.features,
network: announcement.network,
announced_at: announcement.announcedAt,
announcer_pubkey: announcement.announcerPubkey,
mint_pubkey: mintPubkey,
};
if (!existing) {
const host = await lnurlSlug(announcement.slug, url);
if (host === null) return null;
await db.run(
`INSERT INTO mints (url, host, type, name, description, icon_url, ecosystem_json,
status, first_seen, updated_at)
VALUES (?, ?, 'lnurl', ?, ?, ?, ?, 'unknown', ?, ?)
ON CONFLICT DO NOTHING`,
url,
host,
announcement.name,
announcement.about,
announcement.picture,
JSON.stringify(fields),
now,
now,
);
return url;
}
await db.run(
`UPDATE mints SET
name = COALESCE(?, name),
description = COALESCE(?, description),
icon_url = COALESCE(?, icon_url),
ecosystem_json = ?,
updated_at = ?
WHERE url = ?`,
announcement.name,
announcement.about,
announcement.picture,
JSON.stringify(fields),
now,
url,
);
return null;
}
/** Every LNURL row, for the probe cycle and for review resolution. */
export async function lnurlRows(): Promise<MintRow[]> {
const db = await getDb();
return db.all<MintRow>(`SELECT * FROM mints WHERE type = 'lnurl'`);
}
/** Canonical mint URL for a normalized slug, or null if no such mint is tracked. */
export async function mintUrlByHost(host: string): Promise<string | null> {
const db = await getDb();
+367 -42
View File
@@ -1,10 +1,14 @@
import { parseNuts, type FedimintFields, type MintInfo } from '@cashumints/shared';
import {
lnurlIdentifier, observedFeatures, parseNuts,
type FedimintFields, type LnurlFields, type MintInfo,
} from '@cashumints/shared';
import { config } from './config.ts';
import { getDb, setState } from './db.ts';
import { fetchObserverIndex, OBSERVER_SOURCE, type ObserverIndex } from './fedimint-observer.ts';
import { readBodyBounded } from './http.ts';
import { cacheIcon } from './icons.ts';
import { log } from './log.ts';
import { probeLnurl } from './lnurl-probe.ts';
import { allMintRows, mintByUrl, parseEcosystem, type MintRow } from './mints.ts';
export interface ProbeResult {
@@ -28,45 +32,24 @@ export function statusForFails(fails: number): 'online' | 'degraded' | 'offline'
*/
const MAX_INFO_BYTES = 256 * 1024;
async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: number }> {
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
try {
const res = await fetch(`${url}/v1/info`, {
signal: controller.signal,
headers: { Accept: 'application/json', 'User-Agent': config.userAgent },
redirect: 'follow',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await readBodyBounded(res, MAX_INFO_BYTES);
if (!body) throw new Error(`info larger than ${MAX_INFO_BYTES} bytes or unreadable`);
const info = JSON.parse(body.toString('utf8')) as MintInfo;
if (!info || typeof info !== 'object') throw new Error('not a JSON object');
return { info, latencyMs: Date.now() - started };
} finally {
clearTimeout(timer);
}
}
/**
* Probe one mint and write the result.
* Write everything a successful Cashu probe learned, and record the probe.
*
* On failure the cached metadata columns are deliberately left untouched. That cache is
* what lets an offline mint still render its full page, which is acceptance criterion #1.
* Split out of `probeMint` because the on-demand indexer has already made this exact
* request: a reader pastes a mint URL, `POST /api/index` fetches `/v1/info` once
* through the guarded fetcher to decide whether the address is a mint at all, and then
* needs the row to end up in precisely the state a probe cycle would have left it in.
* Re-probing to achieve that would hit a stranger's mint twice for one submission and
* would leave two ways for "what a good probe writes" to drift apart.
*/
export async function probeMint(row: MintRow): Promise<ProbeResult> {
export async function recordCashuOnline(
row: MintRow,
info: MintInfo,
latencyMs: number,
now = Math.floor(Date.now() / 1000),
): Promise<void> {
const db = await getDb();
const now = Math.floor(Date.now() / 1000);
try {
const { info, latencyMs } = await fetchInfo(row.url);
const nuts = parseNuts(info.nuts);
const iconFile = await cacheIcon(row, info.icon_url ?? null);
await db.run(
@@ -105,6 +88,46 @@ export async function probeMint(row: MintRow): Promise<ProbeResult> {
now,
latencyMs,
);
}
async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: number }> {
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
try {
const res = await fetch(`${url}/v1/info`, {
signal: controller.signal,
headers: { Accept: 'application/json', 'User-Agent': config.userAgent },
redirect: 'follow',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await readBodyBounded(res, MAX_INFO_BYTES);
if (!body) throw new Error(`info larger than ${MAX_INFO_BYTES} bytes or unreadable`);
const info = JSON.parse(body.toString('utf8')) as MintInfo;
if (!info || typeof info !== 'object') throw new Error('not a JSON object');
return { info, latencyMs: Date.now() - started };
} finally {
clearTimeout(timer);
}
}
/**
* Probe one mint and write the result.
*
* On failure the cached metadata columns are deliberately left untouched. That cache is
* what lets an offline mint still render its full page, which is acceptance criterion #1.
*/
export async function probeMint(row: MintRow): Promise<ProbeResult> {
const db = await getDb();
const now = Math.floor(Date.now() / 1000);
try {
const { info, latencyMs } = await fetchInfo(row.url);
await recordCashuOnline(row, info, latencyMs, now);
const changed = row.status !== 'online';
if (changed) log.info('mint state change', { url: row.url, from: row.status, to: 'online' });
@@ -251,6 +274,297 @@ async function checkFederations(rows: MintRow[], now: number): Promise<ProbeResu
return results;
}
/* ---------- lnurl ---------- */
/**
* Probe one LNURL mint and write the result.
*
* Same shape as `probeMint` and the same guarantee: on failure the cached metadata
* columns are left untouched, which is what lets an offline LNURL mint still render its
* full page with a banner over it and a working review form. The rug-review guarantee
* covers all three ecosystems, and this is the half of it that lives in the indexer.
*
* Three outcomes rather than two, and the middle one is the point:
*
* - **online** — an advertisement parsed and the mint's Lightning node answered.
* - **degraded-funding** — an advertisement parsed and the node did not. The mint is
* up: it is serving, its limits are real, and rotate/split/merge still work. It is
* recorded as a *successful* probe with `funding_available: false`, which the
* warnings turn into "minting and melting unavailable". Storing it as `degraded`
* status instead would be wrong twice over — that value already means "one or two
* checks failed" here, and none did — and this is exactly the shape the Cashu side
* already uses for a mint that answers perfectly while refusing to move sats.
* - **invalid** — the host answered with something that is not a mint advertisement.
* Counted as a failed probe, because nothing about the mint was confirmed, but with
* `invalid_reason` recorded so the page can say "responding but invalid" instead of
* the flatly wrong "offline".
*/
async function probeLnurlMint(row: MintRow): Promise<ProbeResult> {
const now = Math.floor(Date.now() / 1000);
const previous = parseEcosystem<LnurlFields>(row);
const baseUrl = previous?.base_url ?? row.url.replace(/^lnurl:/, '');
try {
return await applyLnurlResult(row, await probeLnurl(baseUrl), now);
} catch (err) {
return await recordLnurlFailure(row, previous, err, now);
}
}
/**
* Write what one LNURL probe concluded, whichever of the three things it concluded.
*
* Split out of `probeLnurlMint` for the same reason `recordCashuOnline` was: the
* on-demand indexer runs `probeLnurl` itself, through the guarded fetcher, and the row
* it creates has to land in exactly the state a probe cycle would have left it in. One
* writer, two callers.
*/
export async function applyLnurlResult(
row: MintRow,
result: Awaited<ReturnType<typeof probeLnurl>>,
now = Math.floor(Date.now() / 1000),
): Promise<ProbeResult> {
const db = await getDb();
const previous = parseEcosystem<LnurlFields>(row);
const baseUrl = previous?.base_url ?? row.url.replace(/^lnurl:/, '');
if (result.outcome === 'invalid') {
const fails = row.consecutive_fails + 1;
const status = statusForFails(fails);
const fields: LnurlFields = {
...(previous ?? emptyLnurlFields(baseUrl)),
invalid_reason: result.invalidReason,
probe_endpoint: result.endpoint,
};
await db.run(
`UPDATE mints SET consecutive_fails = ?, status = ?, ecosystem_json = ?,
last_probe = ?, updated_at = ?
WHERE url = ?`,
fails,
status,
JSON.stringify(fields),
now,
now,
row.url,
);
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)',
row.url,
now,
);
const changed = row.status !== status;
if (changed) {
log.info('mint state change', {
url: row.url, from: row.status, to: status, fails, reason: result.invalidReason,
});
}
return { url: row.url, ok: false, latencyMs: null, status, changed };
}
const ad = result.advertisement;
/*
* `mint_pubkey` is sticky: `?? previous?.mint_pubkey` and never the other way round.
* A node that was unreachable for this one request drops the field from the
* response, and letting that clear a pubkey the site has already seen would change
* the mint's Nostr identity — its `d` tag — every time its node hiccuped. See
* "When a mint gains a pubkey after being announced by host" in the kind document.
*/
const mintPubkey = ad?.mintPubkey ?? previous?.mint_pubkey ?? null;
const fields: LnurlFields = {
...(previous ?? emptyLnurlFields(baseUrl)),
lnurl_id: lnurlIdentifier(baseUrl, mintPubkey),
base_url: baseUrl,
mint_pubkey: mintPubkey,
/*
* Left as whatever it was when the payRequest fallback answered and the withdraw
* side did not: that endpoint carries no node section at all, so its silence is
* not evidence either way. Only a parsed advertisement gets to set this.
*/
funding_available: ad ? ad.fundingAvailable : previous?.funding_available ?? null,
probe_endpoint: result.endpoint,
invalid_reason: null,
min_withdrawable_msat: ad?.minWithdrawableMsat ?? previous?.min_withdrawable_msat ?? null,
max_withdrawable_msat: ad?.maxWithdrawableMsat ?? previous?.max_withdrawable_msat ?? null,
min_sendable_msat: result.pay?.minSendableMsat ?? previous?.min_sendable_msat ?? null,
max_sendable_msat: result.pay?.maxSendableMsat ?? previous?.max_sendable_msat ?? null,
fee_base_msat: result.pay?.feeBaseMsat ?? null,
fee_ppm: result.pay?.feePpm ?? null,
lightning_address: result.lightningAddress ?? previous?.lightning_address ?? null,
onion_url: result.onionUrl ?? null,
node_alias: ad?.nodeAlias ?? null,
node_uri: ad?.nodeUri ?? null,
node_capacity_msat: ad?.nodeCapacityMsat ?? null,
node_channels: ad?.nodeChannels ?? null,
node_peers: ad?.nodePeers ?? null,
/*
* What this probe actually saw, kept beside — never merged into — whatever the
* operator announced. It is the only capability list a mint nobody has announced
* has, which today is every LNURL mint on the network, and it is what the page
* renders when `features` is empty. `displayFeatures` does the joining.
*/
observed_features: observedFeatures({
fundingAvailable: ad ? ad.fundingAvailable : null,
maxWithdrawableMsat: ad?.maxWithdrawableMsat ?? null,
maxSendableMsat: result.pay?.maxSendableMsat ?? null,
lightningAddress: result.lightningAddress,
mintPubkey,
onionUrl: result.onionUrl,
}),
};
const iconFile = await cacheIcon(row, row.icon_url);
/*
* `COALESCE(description, ?)`, the opposite way round from the Cashu probe.
*
* There, `/v1/info` is the mint's own word about itself and rightly overwrites a
* cached value. Here the candidate is a string the mint *software* generates —
* "Mint an lnurlcash bearer note on {host}" is a template, not a sentence anyone
* wrote — so it fills a gap and never displaces an operator's own announcement
* metadata.
*
* `name` is deliberately not written at all. The only candidate the endpoints offer
* is `nodeAlias`, and that is the *Lightning node's* name, not the mint's: the
* software's own one-pager prints it under a "Node" heading, separate from the
* mint's title. Calling a mint after its node would be a small invention, and the
* existing fallback — the hostname — is both true and what a reader typed to get
* here. The alias is kept in `ecosystem_json` and shown in the sidebar, where it is
* labelled as what it is.
*/
await db.run(
`UPDATE mints SET
description = COALESCE(description, ?),
icon_file = ?,
ecosystem_json = ?,
version = COALESCE(?, version),
status = 'online',
consecutive_fails = 0,
last_online = ?,
last_probe = ?,
updated_at = ?
WHERE url = ?`,
result.pay?.description ?? ad?.defaultDescription ?? null,
iconFile,
JSON.stringify(fields),
result.software,
now,
now,
now,
row.url,
);
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 1, ?)',
row.url,
now,
result.latencyMs,
);
const changed = row.status !== 'online' || previous?.funding_available !== fields.funding_available;
if (changed) {
log.info('mint state change', {
url: row.url,
from: row.status,
to: 'online',
funding: fields.funding_available === false ? 'unavailable' : 'ok',
});
}
return { url: row.url, ok: true, latencyMs: result.latencyMs, status: 'online', changed };
}
/**
* Write a failed LNURL probe: the host said nothing at all.
*
* Its own function only because two callers reach it — the probe cycle and the
* on-demand indexer, which treats a host that never answered as a candidate for the
* Nostr lookup rather than as a failure to report immediately.
*/
async function recordLnurlFailure(
row: MintRow,
previous: LnurlFields | null,
err: unknown,
now: number,
): Promise<ProbeResult> {
const db = await getDb();
const fails = row.consecutive_fails + 1;
const status = statusForFails(fails);
/*
* A host that said nothing at all is offline, not invalid. Clearing `invalid_reason`
* here matters: a mint that spent a day serving junk and then went dark should stop
* showing "responding but invalid" the moment it stops responding.
*/
if (previous?.invalid_reason) {
await db.run(
'UPDATE mints SET ecosystem_json = ? WHERE url = ?',
JSON.stringify({ ...previous, invalid_reason: null } satisfies LnurlFields),
row.url,
);
}
await db.run(
`UPDATE mints SET consecutive_fails = ?, status = ?, last_probe = ?, updated_at = ?
WHERE url = ?`,
fails,
status,
now,
now,
row.url,
);
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)',
row.url,
now,
);
const changed = row.status !== status;
if (changed) {
log.info('mint state change', {
url: row.url,
from: row.status,
to: status,
fails,
reason: err instanceof Error ? err.message : String(err),
});
}
return { url: row.url, ok: false, latencyMs: null, status, changed };
}
/** The `ecosystem_json` shape a row falls back to when its own will not parse. */
function emptyLnurlFields(baseUrl: string): LnurlFields {
return {
lnurl_id: lnurlIdentifier(baseUrl, null),
base_url: baseUrl,
features: [],
network: null,
announced_at: null,
announcer_pubkey: null,
mint_pubkey: null,
funding_available: null,
probe_endpoint: null,
invalid_reason: null,
min_withdrawable_msat: null,
max_withdrawable_msat: null,
min_sendable_msat: null,
max_sendable_msat: null,
fee_base_msat: null,
fee_ppm: null,
lightning_address: null,
onion_url: null,
node_alias: null,
node_uri: null,
node_capacity_msat: null,
node_channels: null,
node_peers: null,
observed_features: [],
};
}
/** Run `tasks` with at most `limit` in flight. */
async function pooled<T>(items: T[], limit: number, fn: (item: T) => Promise<unknown>): Promise<void> {
let cursor = 0;
@@ -270,16 +584,26 @@ export async function probeAll(rows?: MintRow[]): Promise<ProbeResult[]> {
const started = Date.now();
const now = Math.floor(Date.now() / 1000);
// Two ecosystems, two entirely different checks: an HTTP fetch per mint, and one
// lookup covering every federation. Split here rather than inside the worker so the
// federations are not each waiting behind a slot in the mint pool.
const mints = targets.filter((row) => row.type !== 'fedimint');
/*
* Three ecosystems, three checks.
*
* Two of them are an HTTP fetch per row and one is a single lookup covering every
* federation, so the federations are split out rather than each waiting behind a slot
* in the fetch pool. Cashu and LNURL mints share that pool — they are the same kind of
* work, a handful of small HTTPS requests each — and are told apart inside the worker
* by `type` rather than by two pools that would each idle waiting for the other.
*
* The default arm is Cashu, not LNURL: a row of some type this build has never heard
* of is far more likely to be an older ecosystem than a newer one, and `/v1/info` is
* what every row written before the column existed means.
*/
const federations = targets.filter((row) => row.type === 'fedimint');
const fetched = targets.filter((row) => row.type !== 'fedimint');
const results: ProbeResult[] = [];
const [, federationResults] = await Promise.all([
pooled(mints, config.probeConcurrency, async (row) => {
results.push(await probeMint(row));
pooled(fetched, config.probeConcurrency, async (row) => {
results.push(row.type === 'lnurl' ? await probeLnurlMint(row) : await probeMint(row));
}),
checkFederations(federations, now),
]);
@@ -288,7 +612,8 @@ export async function probeAll(rows?: MintRow[]): Promise<ProbeResult[]> {
const online = results.filter((r) => r.ok).length;
const changed = results.filter((r) => r.changed).length;
log.info('probe cycle', {
mints: mints.length,
mints: fetched.filter((row) => row.type !== 'lnurl').length,
lnurl: fetched.filter((row) => row.type === 'lnurl').length,
federations: federations.length,
online,
down: results.length - online,
+37 -1
View File
@@ -11,6 +11,7 @@ import {
type MintType,
type ProbeSample,
type RatingDistribution,
type LnurlFields,
type Stats,
} from '@cashumints/shared';
import { config, startedAt } from './config.ts';
@@ -289,7 +290,8 @@ export async function getStats(): Promise<Stats> {
* COUNT(*) FILTER, not SUM(status = 'online'): Postgres has no implicit cast from
* boolean to integer, so the SQLite spelling is a type error there.
*/
const [counts, federations, reviews, fedimintReviews] = await Promise.all([
const [counts, federations, lnurl, lnurlFunding, reviews, fedimintReviews, lnurlReviews] =
await Promise.all([
db.get<{ total: number; online: number; offline: number; degraded: number }>(
`SELECT
COUNT(*) AS total,
@@ -306,6 +308,24 @@ export async function getStats(): Promise<Stats> {
COUNT(*) FILTER (WHERE status = 'announced') AS announced
FROM mints WHERE type = 'fedimint'`,
),
db.get<{ total: number; online: number; offline: number }>(
`SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'online') AS online,
COUNT(*) FILTER (WHERE status = 'offline') AS offline
FROM mints WHERE type = 'lnurl'`,
),
/*
* The degraded-funding count is read in JavaScript rather than in SQL, because the
* flag lives inside `ecosystem_json` and neither a `LIKE '%"funding_available":false%'`
* (which depends on how the two drivers happen to serialise) nor a JSON operator
* (which SQLite and Postgres spell differently) is portable. There are a handful of
* these rows, the whole result is memoised for a minute, and a correct answer on
* both backends is worth one small scan.
*/
db.all<{ ecosystem_json: string | null }>(
`SELECT ecosystem_json FROM mints WHERE type = 'lnurl' AND status = 'online'`,
),
db.get<{ n: number; last: number | null }>(
`WITH latest AS (${LATEST_REVIEWS}) SELECT COUNT(*) AS n, MAX(created_at) AS last FROM latest`,
),
@@ -314,8 +334,19 @@ export async function getStats(): Promise<Stats> {
SELECT COUNT(*) AS n FROM latest
WHERE mint_url IN (SELECT url FROM mints WHERE type = 'fedimint')`,
),
db.get<{ n: number }>(
`WITH latest AS (${LATEST_REVIEWS})
SELECT COUNT(*) AS n FROM latest
WHERE mint_url IN (SELECT url FROM mints WHERE type = 'lnurl')`,
),
]);
let lnurlDegradedFunding = 0;
for (const row of lnurlFunding) {
const fields = parseEcosystem<LnurlFields>(row);
if (fields?.funding_available === false) lnurlDegradedFunding++;
}
const cashuTotal = counts?.total ?? 0;
const value: Stats = {
@@ -332,6 +363,11 @@ export async function getStats(): Promise<Stats> {
fedimint_offline: federations?.offline ?? 0,
fedimint_announced: federations?.announced ?? 0,
fedimint_reviews: fedimintReviews?.n ?? 0,
lnurl_total: lnurl?.total ?? 0,
lnurl_online: lnurl?.online ?? 0,
lnurl_offline: lnurl?.offline ?? 0,
lnurl_degraded_funding: lnurlDegradedFunding,
lnurl_reviews: lnurlReviews?.n ?? 0,
};
statsCache = { at: now, value };
+92
View File
@@ -0,0 +1,92 @@
/**
* A per-address budget for the one endpoint that does work on a stranger's behalf.
*
* Every other route reads rows this process already has. `POST /api/index` resolves
* DNS, opens a socket to an address somebody chose, and may query five relays, so it is
* the one place where a loop in a browser tab costs this server real outbound work —
* and costs whoever is at the other end an unexpected visitor.
*
* In-process and in-memory, deliberately. The alternatives were considered and both are
* worse here: a table would put a write on the path of every submission for a counter
* that may be forgotten at any time, and doing it in nginx would put the limit in a
* file the application cannot see, cannot test, and does not ship — this repository's
* README documents an nginx block that a deployment is free to edit, so a limit that
* lives only there is a limit that silently varies per deployment. The cost of keeping
* it here is that a restart forgives everyone and a second process would double the
* allowance; at ten an hour, neither matters. BACKEND.md records the choice.
*/
/**
* Submissions one address may make per window.
*
* Ten by default, which is BACKEND.md's number and is several times what any honest use
* of the dialog needs. `INDEX_RATE_LIMIT` raises or lowers it, which exists for two
* real cases rather than as reflexive configurability: exercising the whole flow
* end to end trips a limit of ten in about a minute, and a deployment behind a proxy
* that does not forward the client address has every visitor sharing one bucket until
* it does. Both are better served by a number than by a code change.
*/
export const RATE_LIMIT = (() => {
const raw = Number.parseInt(process.env['INDEX_RATE_LIMIT'] ?? '', 10);
return Number.isFinite(raw) && raw > 0 ? raw : 10;
})();
/** The window those submissions are counted over. */
export const RATE_WINDOW_MS = 60 * 60 * 1000;
/** Addresses tracked at once. Past this the oldest bucket is dropped, not the newest. */
const MAX_TRACKED = 5000;
const hits = new Map<string, number[]>();
export interface RateVerdict {
ok: boolean;
/** Submissions left in this window after this one. */
remaining: number;
/** Seconds until the oldest counted submission falls out of the window. */
retryAfter: number;
}
/**
* Count one submission from `key`, and say whether it is allowed.
*
* A sliding window over timestamps rather than a fixed bucket, so ten submissions at
* 10:59 do not give a fresh ten at 11:00. A refused submission is *not* counted: being
* over the limit should not extend the wait every time the reader presses the button
* again, which is what turns a rate limit into a lockout.
*/
export function takeToken(key: string, now = Date.now()): RateVerdict {
const cutoff = now - RATE_WINDOW_MS;
const recent = (hits.get(key) ?? []).filter((at) => at > cutoff);
if (recent.length >= RATE_LIMIT) {
hits.set(key, recent);
const oldest = recent[0] ?? now;
return {
ok: false,
remaining: 0,
retryAfter: Math.max(1, Math.ceil((oldest + RATE_WINDOW_MS - now) / 1000)),
};
}
recent.push(now);
hits.set(key, recent);
if (hits.size > MAX_TRACKED) sweep(cutoff);
return { ok: true, remaining: RATE_LIMIT - recent.length, retryAfter: 0 };
}
/** Drop every bucket with nothing left in the window. Called only when the map grows. */
function sweep(cutoff: number): void {
for (const [key, times] of hits) {
const live = times.filter((at) => at > cutoff);
if (live.length === 0) hits.delete(key);
else hits.set(key, live);
}
}
/** Forget every counter. For tests, which must not inherit each other's budgets. */
export function resetRateLimits(): void {
hits.clear();
}
+173
View File
@@ -0,0 +1,173 @@
/**
* "Has anyone ever heard of this mint?"
*
* The last question `POST /api/index` asks before giving up, and the one that makes the
* rugged-mint case work. A reader pastes an address; nothing answers at it. That is two
* different situations wearing the same silence:
*
* - a typo, a dead domain, an address nobody has ever used — nothing to index; or
* - a mint that ran for two years, took people's money, and went dark last week.
*
* The second is exactly the mint somebody most wants to write a review of, and it is
* the one this site exists to keep a page for. HTTP cannot tell them apart, but Nostr
* can: a mint that was ever real has an announcement, or reviews, or both, and a typo
* has neither. So before answering "we cannot verify that", the endpoint spends one
* bounded query asking the relay pool.
*
* Bounded is the operative word. This runs inside a request a person is waiting on, so
* it gets one round of filters and `RELAY_LOOKUP_MS` to answer them; whatever has
* arrived by then is the answer. The discovery loop is where exhaustive paging lives —
* it runs on a timer, with nobody watching — and the row this creates is an ordinary
* row that the next discovery cycle will fill in properly.
*/
import type { Event as NostrEvent, Filter } from 'nostr-tools';
import {
KIND_LNURL_ANNOUNCEMENT,
KIND_MINT_ANNOUNCEMENT,
KIND_REVIEW,
hostIdentifier,
mintUrlSpellings,
parseAnnouncementMetadata,
parseLnurlAnnouncement,
reviewEcosystem,
type LnurlAnnouncement,
} from '@cashumints/shared';
import { config } from './config.ts';
import { getPool } from './discovery.ts';
import { log } from './log.ts';
/**
* How long the relays get, in total, for the whole lookup.
*
* Three seconds, which is the number BACKEND.md now records for this endpoint. It is
* chosen against the two facts that matter: the pool's own `querySync` resolves as soon
* as every relay has sent EOSE, which on this pool is usually well under a second, and
* the request this sits inside has already spent up to five seconds failing to reach
* the mint. Eight seconds of a reader watching a spinner is the outer bound of what
* this feature may cost, and three is what buys nearly all of the recall — the events
* being asked for are single, indexed, tag-filtered lookups, not a sweep.
*/
export const RELAY_LOOKUP_MS = 3000;
/** Bounded so a hostile or confused relay cannot answer with a hundred thousand rows. */
const LOOKUP_LIMIT = 60;
/** What the relays remember about an address that does not answer over HTTPS. */
export interface RelayTrace {
/** True when anything at all was found: an announcement, or a review, or both. */
found: boolean;
/** How many kind 38000 events name this address. Nothing is stored from them here. */
reviews: number;
/** Metadata from the newest announcement, when there was one. */
name: string | null;
picture: string | null;
about: string | null;
announcerPubkey: string | null;
announcedAt: number | null;
/** The parsed 38174, for an LNURL mint: the only source of its features and network. */
lnurl: LnurlAnnouncement | null;
}
const EMPTY: RelayTrace = {
found: false,
reviews: 0,
name: null,
picture: null,
about: null,
announcerPubkey: null,
announcedAt: null,
lnurl: null,
};
/**
* Run several filters against the pool at once, with one deadline over all of them.
*
* `querySync`'s own `maxWait` bounds each call, but a relay that accepts a connection
* and then never sends EOSE can outlive it; the race is what guarantees the caller gets
* an answer inside the budget whatever the sockets do.
*/
async function query(filters: Filter[]): Promise<NostrEvent[]> {
const started = Date.now();
const events = new Map<string, NostrEvent>();
const work = Promise.all(
filters.map((filter) =>
getPool()
.querySync(config.relays, filter, { maxWait: RELAY_LOOKUP_MS })
.then((batch) => {
for (const event of batch) events.set(event.id, event);
})
.catch(() => undefined),
),
);
await Promise.race([work, new Promise((resolve) => setTimeout(resolve, RELAY_LOOKUP_MS))]);
log.info('relay trace', { filters: filters.length, events: events.size, ms: Date.now() - started });
return [...events.values()];
}
/**
* Everything the relays know about one unreachable address.
*
* The filters are the same ones the discovery loop uses to resolve a review, asked in
* the other direction: there, "which mint is this review about?"; here, "are there any
* reviews, and any announcement, for this address?". Reusing `mintUrlSpellings` is what
* makes the two agree — a mint announced with a trailing slash is found by an address
* typed without one.
*/
export async function traceOnRelays(type: string, url: string): Promise<RelayTrace> {
const spellings = mintUrlSpellings(url);
const announcementKind = type === 'lnurl' ? KIND_LNURL_ANNOUNCEMENT : KIND_MINT_ANNOUNCEMENT;
const filters: Filter[] = [
{ kinds: [announcementKind], '#u': spellings, limit: LOOKUP_LIMIT },
{ kinds: [KIND_REVIEW], '#u': spellings, limit: LOOKUP_LIMIT },
];
/*
* An LNURL mint with no funding source is announced — and reviewed — under its bare
* host in `d` rather than under a pubkey, and a client that only writes `d` leaves no
* `u` to match on. One extra filter covers those; a Cashu mint's `d` is a pubkey
* nobody can derive from a URL, so it has no equivalent.
*/
if (type === 'lnurl') {
const identifier = hostIdentifier(url);
filters.push({ kinds: [announcementKind, KIND_REVIEW], '#d': [identifier], limit: LOOKUP_LIMIT });
}
const events = await query(filters);
if (events.length === 0) return EMPTY;
let reviews = 0;
let newest: NostrEvent | null = null;
for (const event of events) {
if (event.kind === KIND_REVIEW) {
// A review found by `#u` could be about any ecosystem; only count the ones whose
// `k` agrees, so a federation review carrying a stray URL is not evidence here.
if (reviewEcosystem(event) === type) reviews++;
continue;
}
if (event.kind !== announcementKind) continue;
if (!newest || event.created_at > newest.created_at) newest = event;
}
if (!newest) {
return reviews > 0 ? { ...EMPTY, found: true, reviews } : EMPTY;
}
const lnurl = type === 'lnurl' ? parseLnurlAnnouncement(newest) : null;
const meta = parseAnnouncementMetadata(newest.content);
return {
found: true,
reviews,
name: lnurl?.name ?? meta.name,
picture: lnurl?.picture ?? meta.picture,
about: lnurl?.about ?? meta.about,
announcerPubkey: newest.pubkey,
announcedAt: newest.created_at,
lnurl,
};
}
+288
View File
@@ -0,0 +1,288 @@
/**
* Fetching a URL a stranger typed.
*
* Every other fetch in this codebase goes to an address that reached the database
* through discovery or the seed list. `POST /api/index` is the first one that does not:
* a reader pastes something, and this server opens a socket to it. That inverts who the
* untrusted party is — it is no longer just the *response* that cannot be trusted, it
* is the *destination* — so this module exists to answer one question before any packet
* leaves: is that address somewhere this server has any business connecting to?
*
* Four rules, and each is here because leaving it out is a known exploit:
*
* 1. **https only.** Not http-upgraded-to-https, not any other scheme. `file:` reads
* the disk, `gopher:` used to be a way to make a server speak arbitrary protocols,
* and plaintext http to a stranger's address is a downgrade nobody asked for.
* 2. **Resolve first, judge the addresses, then connect.** A hostname check alone
* stops `http://127.0.0.1` and nothing else: `internal.example.com` is a perfectly
* ordinary public name that resolves to `10.0.0.5`, and only DNS can say so. Every
* address the name resolves to is checked, not the first: a name with one public
* and one private A record must be refused, not raced.
* 3. **Every redirect hop is a new destination.** A public URL that 302s to
* `http://169.254.169.254/latest/meta-data/` is the cloud-metadata attack in its
* classic form. Redirects are followed manually, capped at two, and each hop goes
* through the same check as the first.
* 4. **Bounded in bytes, in time, and in content type.** A stranger's server can be
* slow forever and large forever; `readBodyBounded` and an abort timer cap both,
* and a response that is not the media type asked for is not read at all.
*
* What this cannot close is the DNS rebinding window: the name is resolved here, and
* the socket resolves it again a moment later, and a hostile resolver can answer
* differently the second time. Closing it means dialing the vetted IP with the hostname
* pinned for TLS, and Node exposes no supported way to do that through `fetch` (undici's
* dispatchers are not a public API here). The exposure is a single GET whose body is
* parsed as JSON and discarded unless it is a valid mint advertisement, so the practical
* gain from a rebind is one unauthenticated GET — noted here rather than left implicit.
*/
import { lookup } from 'node:dns/promises';
import { isBlockedHostname, isPrivateIpAddress } from '@cashumints/shared';
import { config } from './config.ts';
import { readBodyBounded } from './http.ts';
/** BACKEND.md's cap for this endpoint: no probe response may exceed it. */
export const MAX_PROBE_BYTES = 256 * 1024;
/** How many redirects a probe follows before giving up. */
export const MAX_REDIRECTS = 2;
/**
* What one guarded fetch did.
*
* `blocked` and `unreachable` are kept apart because the endpoint answers them
* differently: a blocked address is a refusal this site made and can explain, while an
* unreachable one is a mint that may still be real and falls through to the Nostr
* lookup. Collapsing them would file "you may not point us at 10.0.0.1" under "we
* checked Nostr and found nothing", which is not what happened.
*/
export type FetchOutcome =
| { state: 'ok'; status: number; body: string; contentType: string | null; url: string }
| { state: 'blocked'; reason: string }
| { state: 'unreachable'; reason: string };
/**
* The two pieces of the outside world this module touches.
*
* Injectable so the SSRF rules can be tested without a network: `check-index.ts` hands
* in a resolver that answers `10.0.0.1` for an ordinary-looking name, and a fetch that
* returns a redirect to a private address, and asserts that neither is ever connected
* to. Those are the two attacks this file exists to stop, and a rule that is only
* exercised against the real internet is a rule that is not exercised.
*/
export interface FetchDeps {
resolve?: (hostname: string) => Promise<string[] | null>;
fetchImpl?: typeof fetch;
}
/** Every address a hostname resolves to, or null when it does not resolve at all. */
async function resolveAll(hostname: string): Promise<string[] | null> {
try {
const records = await lookup(hostname, { all: true, verbatim: true });
const addresses = records.map((record) => record.address).filter(Boolean);
return addresses.length > 0 ? addresses : null;
} catch {
return null;
}
}
/**
* Why an address was not connected to.
*
* Two kinds, and keeping them apart is load-bearing rather than tidy. `blocked` is a
* refusal this site made — the address is one it will not fetch, whoever asked — and
* the endpoint reports it as such. `unresolved` is the network saying nothing, and a
* name that no longer resolves is the *normal* state of a mint whose operator walked
* away: that submission has to fall through to the Nostr lookup, not be rejected as if
* the reader had typed something inadmissible.
*/
export interface DestinationVerdict {
kind: 'blocked' | 'unresolved';
reason: string;
}
/**
* May this server connect to this URL?
*
* Returns null when it may. Exported because the on-demand indexer runs it once up
* front — before it decides whether to probe at all — and because the redirect loop
* below runs it again on every hop.
*/
export async function checkDestination(
target: URL,
deps: FetchDeps = {},
): Promise<DestinationVerdict | null> {
const blocked = (reason: string): DestinationVerdict => ({ kind: 'blocked', reason });
if (target.protocol !== 'https:') return blocked('only https addresses are checked');
const hostname = target.hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (!hostname) return blocked('no hostname');
// localhost, .onion, .local, a bare label, an IP literal in a private range: all
// refusable without asking a resolver anything.
if (isBlockedHostname(target.hostname)) return blocked('not a public address');
// An IP literal has already been judged by the line above; a name has to be resolved.
if (/^[\d.]+$/.test(hostname) || hostname.includes(':')) return null;
const addresses = await (deps.resolve ?? resolveAll)(hostname);
if (addresses === null) return { kind: 'unresolved', reason: 'the address does not resolve' };
// Every answer, not the first: a name with one public and one private record must be
// refused outright rather than depending on which one the socket happens to pick.
const private_ = addresses.find((address) => isPrivateIpAddress(address));
return private_ === undefined ? null : blocked(`resolves to a private address (${private_})`);
}
/**
* A response's media type, without its parameters. `application/json; charset=utf-8`
* and `application/json` are the same answer.
*/
function mediaType(res: Response): string | null {
const header = res.headers.get('content-type');
return header ? (header.split(';')[0] ?? '').trim().toLowerCase() : null;
}
/**
* Is this media type plausible for what was asked for?
*
* Deliberately lenient in one direction only. A mint serving JSON as `text/plain` is a
* misconfiguration this site should still read — several real ones do — so anything
* text-shaped or unlabelled passes. What it refuses is a response that is *positively*
* something else: an image, a video, an archive, an executable. Those are never a mint
* advertisement, and reading 256KB of one to find out costs bandwidth for nothing.
*/
function plausibleType(type: string | null, accept: string): boolean {
if (type === null) return true; // Unlabelled. The parser is the real check.
if (type.startsWith('text/') || type.includes('json') || type.includes('xml')) return true;
if (accept.includes('html') && type.includes('html')) return true;
return false;
}
export interface SafeFetchOptions {
accept: string;
maxBytes?: number;
timeoutMs?: number;
}
/**
* GET a stranger's URL, following at most two redirects and checking every hop.
*
* `redirect: 'manual'` rather than `follow`, which is the crux: with `follow`, undici
* resolves and connects to the redirect target itself and this code never sees the
* address. Doing the hops by hand is what makes rule 3 above enforceable at all.
*/
export async function safeFetchText(
rawUrl: string,
options: SafeFetchOptions,
deps: FetchDeps = {},
): Promise<FetchOutcome> {
const maxBytes = options.maxBytes ?? MAX_PROBE_BYTES;
const timeoutMs = options.timeoutMs ?? config.probeTimeoutMs;
let target: URL;
try {
target = new URL(rawUrl);
} catch {
return { state: 'blocked', reason: 'not a URL' };
}
/*
* One timer for the whole chain, not one per hop.
*
* Otherwise two redirects turn the endpoint's "standard 5s timeout" into fifteen
* seconds of a reader watching a spinner, and a hostile server can extend that for
* as long as the redirect cap allows.
*/
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const verdict = await checkDestination(target, deps);
if (verdict) {
// A name that does not resolve is not a refusal, it is a mint that is not there,
// and the caller has a whole extra step for that case.
if (verdict.kind === 'unresolved') {
return { state: 'unreachable', reason: verdict.reason };
}
return {
state: 'blocked',
reason:
hop === 0
? verdict.reason
: `redirected to an address we will not fetch: ${verdict.reason}`,
};
}
let res: Response;
try {
res = await (deps.fetchImpl ?? fetch)(target, {
signal: controller.signal,
redirect: 'manual',
headers: { Accept: options.accept, 'User-Agent': config.userAgent },
});
} catch (err) {
return {
state: 'unreachable',
reason: err instanceof Error ? err.message : 'connection failed',
};
}
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get('location');
// Drain rather than leak the socket: a redirect body is never read.
await res.body?.cancel().catch(() => undefined);
if (!location) return { state: 'unreachable', reason: `HTTP ${res.status} with no target` };
if (hop === MAX_REDIRECTS) return { state: 'unreachable', reason: 'too many redirects' };
try {
target = new URL(location, target);
} catch {
return { state: 'unreachable', reason: 'redirect target is not a URL' };
}
continue;
}
const type = mediaType(res);
if (!plausibleType(type, options.accept)) {
await res.body?.cancel().catch(() => undefined);
return { state: 'unreachable', reason: `unexpected content type ${type ?? 'none'}` };
}
const body = await readBodyBounded(res, maxBytes);
if (!body) {
return { state: 'unreachable', reason: `body larger than ${maxBytes} bytes or unreadable` };
}
return {
state: 'ok',
status: res.status,
body: body.toString('utf8'),
contentType: type,
url: target.href,
};
}
// Unreachable in practice: the loop returns or continues, and the last iteration
// refuses to continue. Kept so the function has one type on every path.
return { state: 'unreachable', reason: 'too many redirects' };
} finally {
clearTimeout(timer);
}
}
/**
* The `TextFetcher` shape `probeLnurl` takes, backed by the guarded fetch above.
*
* The probe loop keeps its own plain fetcher: those rows are addresses this site chose
* to track and have already been through `normalizeMintUrl`. This one is for the path
* where the address arrived seconds ago from a stranger.
*/
export function guardedTextFetcher(deps: FetchDeps = {}) {
return async (
url: string,
accept: string,
maxBytes: number,
): Promise<{ body: string; status: number } | null> => {
const outcome = await safeFetchText(url, { accept, maxBytes }, deps);
return outcome.state === 'ok' ? { body: outcome.body, status: outcome.status } : null;
};
}
+32 -2
View File
@@ -10,7 +10,7 @@
import { closeDb, getDb } from './db.ts';
import { closePool, runDiscovery } from './discovery.ts';
import { log } from './log.ts';
import { insertMintIfNew } from './mints.ts';
import { insertLnurlIfNew, insertMintIfNew } from './mints.ts';
import { probeAll } from './probe.ts';
import { listMints } from './queries.ts';
@@ -54,6 +54,23 @@ const SEED_MINTS = [
'https://kashu.me',
];
/**
* LNURL mints, seeded by URL alone.
*
* Short for the obvious reason: `kind:38174` is a proposed kind (see
* `docs/KIND-LNURL-MINT.md`) and there are no announcements on the network yet, so
* unlike Cashu — where this list was read off real 38172 events — there is nothing to
* read it off. This is the reference instance the ecosystem was built against, and
* discovery takes over the moment operators start announcing.
*
* Seeded with no status: `insertLnurlIfNew` writes `unknown` and the first probe cycle
* decides, exactly as it does for a Cashu mint. Being on this list is not a claim that
* a mint is up, and is not an endorsement of it.
*/
const SEED_LNURL = [
'https://lnurl.21mint.me',
];
function pad(s: string, width: number): string {
return s.length > width ? `${s.slice(0, width - 1)}…` : s.padEnd(width);
}
@@ -64,7 +81,15 @@ async function main(): Promise<void> {
let added = 0;
for (const url of SEED_MINTS) if (await insertMintIfNew(url)) added++;
log.info('seed list ingested', { listed: SEED_MINTS.length, added });
let addedLnurl = 0;
for (const url of SEED_LNURL) if (await insertLnurlIfNew(url)) addedLnurl++;
log.info('seed list ingested', {
listed: SEED_MINTS.length + SEED_LNURL.length,
added: added + addedLnurl,
lnurl: addedLnurl,
});
await probeAll();
const discovery = await runDiscovery(true);
@@ -109,6 +134,7 @@ async function main(): Promise<void> {
const mints = listed.filter((m) => m.type === 'cashu');
const federations = listed.filter((m) => m.type === 'fedimint');
const lnurlMints = listed.filter((m) => m.type === 'lnurl');
const count = (rows: typeof listed, status: string) =>
rows.filter((m) => m.status === status).length;
const reviews = listed.reduce((sum, m) => sum + m.review_count, 0);
@@ -128,6 +154,10 @@ async function main(): Promise<void> {
`${count(federations, 'offline')} reported down, ` +
`${count(federations, 'announced')} announced only)`,
);
console.log(
`${lnurlMints.length} LNURL mints (${count(lnurlMints, 'online')} online, ` +
`${count(lnurlMints, 'offline')} offline)`,
);
console.log(`${reviews} reviews indexed`);
closePool();
+75
View File
@@ -1,9 +1,41 @@
import { Hono } from 'hono';
import type { Context } from 'hono';
import { cors } from 'hono/cors';
import { serveStatic } from '@hono/node-server/serve-static';
import path from 'node:path';
import { config } from './config.ts';
import { indexSubmission } from './index-mint.ts';
import { getHealth, getMintDetail, getStats, listMints } from './queries.ts';
import { RATE_LIMIT, takeToken } from './rate-limit.ts';
/** The largest `POST /api/index` body read. A JSON object with two short strings. */
const MAX_BODY_BYTES = 8 * 1024;
/**
* Who is submitting, for the rate limiter.
*
* The socket's peer address, unless that peer is the loopback interface — in which case
* this process is behind the reverse proxy the README documents, every request has the
* same peer, and `X-Forwarded-For` is the only thing that tells two visitors apart.
*
* The *last* entry of that header, not the first. nginx's `$proxy_add_x_forwarded_for`
* appends the peer it actually saw to whatever the client sent, so the first entry is a
* value a client can write for itself — a free way around the limit — and the last is
* the one the proxy vouched for. When the peer is not loopback the header is ignored
* entirely, because then there is no proxy to have vouched for anything.
*/
function clientKey(c: Context): string {
const socket = (c.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined)
?.incoming?.socket;
const peer = socket?.remoteAddress ?? '';
const loopback = peer === '' || peer === '::1' || peer === '127.0.0.1' || peer.startsWith('::ffff:127.');
if (!loopback) return peer;
const forwarded = c.req.header('x-forwarded-for') ?? '';
const hops = forwarded.split(',').map((hop) => hop.trim()).filter(Boolean);
return hops[hops.length - 1] ?? peer ?? 'unknown';
}
export function createApp(): Hono {
const app = new Hono();
@@ -41,6 +73,49 @@ export function createApp(): Hono {
return c.json(detail);
});
/*
* The one endpoint that writes: index a mint nobody has announced yet.
*
* It is a POST because it creates a row, and it is rate limited because it is the
* only route that makes this server fetch an address somebody else chose. Everything
* it actually does lives in `index-mint.ts`; what is here is the shape of the request
* and the two things that can only be decided at the edge — who is asking, and how
* much body to read from them.
*/
app.post('/api/index', async (c) => {
const verdict = takeToken(clientKey(c));
if (!verdict.ok) {
c.header('Retry-After', String(verdict.retryAfter));
return c.json(
{
error: 'rate_limited',
message: `At most ${RATE_LIMIT} submissions an hour from one address`,
retry_after: verdict.retryAfter,
},
429,
);
}
// An invite code runs to a few hundred characters; nothing legitimate is near this.
const declared = Number(c.req.header('content-length') ?? '0');
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
return c.json({ error: 'bad_input', message: 'Request body too large' }, 422);
}
let body: { type?: unknown; input?: unknown };
try {
body = (await c.req.json()) as { type?: unknown; input?: unknown };
} catch {
return c.json({ error: 'bad_input', message: 'Body must be JSON' }, 422);
}
const outcome = await indexSubmission(String(body?.type ?? ''), body?.input);
if (outcome.status === 429 && 'retry_after' in outcome.body) {
c.header('Retry-After', String(outcome.body.retry_after ?? 30));
}
return c.json(outcome.body, outcome.status);
});
// Cached mint icons, so an offline mint keeps its icon.
app.use(
'/icons/*',
+311
View File
@@ -0,0 +1,311 @@
/**
* A Nostr relay, in one file, for tests.
*
* Enough of NIP-01 to accept an EVENT, answer a REQ and close a subscription, plus the
* addressable-replacement rule, which is not optional here: the whole point of the
* round-trip test is a `kind:38174`, and an addressable kind that a relay stores twice
* would let a broken publisher pass.
*
* Written rather than depended on, deliberately. The alternatives were a real relay
* binary (which CI would have to install and keep running) or a `ws` dependency added to
* the lockfile for test-only code. Node 22 ships a WebSocket *client* — which is what
* nostr-tools uses — but no server, so the handshake and framing below are the actual
* cost of not adding either, and RFC 6455 is small when the only frames that matter are
* short unfragmented text ones.
*
* **Not for production.** No authentication, no persistence, no NIP-42, no rate limits,
* no fragmentation support beyond a single continuation, and everything lives in a Map
* until the process exits. `api/src/check-lnurl.ts` is the only caller.
*/
import { createHash } from 'node:crypto';
import { createServer, type IncomingMessage, type Server } from 'node:http';
import type { Duplex } from 'node:stream';
/** RFC 6455's fixed handshake GUID. */
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
interface StoredEvent {
id: string;
pubkey: string;
kind: number;
created_at: number;
content: string;
tags: string[][];
sig?: string;
}
type Filter = Record<string, unknown>;
/* ---------- framing ---------- */
/** Encode one unfragmented text frame, server to client, never masked. */
function encodeText(text: string): Buffer {
const payload = Buffer.from(text, 'utf8');
const length = payload.length;
let header: Buffer;
if (length < 126) {
header = Buffer.from([0x81, length]);
} else if (length < 65536) {
header = Buffer.alloc(4);
header[0] = 0x81;
header[1] = 126;
header.writeUInt16BE(length, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x81;
header[1] = 127;
header.writeBigUInt64BE(BigInt(length), 2);
}
return Buffer.concat([header, payload]);
}
interface DecodedFrame {
opcode: number;
payload: Buffer;
/** Total bytes consumed, so the caller can advance its buffer. */
size: number;
}
/**
* Decode one frame from the front of `buffer`, or null when it is not all there yet.
*
* Client frames are always masked, per the spec, so the mask is applied unconditionally
* when the bit is set and ignored when it is not — a browser or Node client never omits
* it, and a test relay has no reason to reject one that did.
*/
function decodeFrame(buffer: Buffer): DecodedFrame | null {
if (buffer.length < 2) return null;
const first = buffer[0]!;
const second = buffer[1]!;
const opcode = first & 0x0f;
const masked = (second & 0x80) !== 0;
let length = second & 0x7f;
let offset = 2;
if (length === 126) {
if (buffer.length < offset + 2) return null;
length = buffer.readUInt16BE(offset);
offset += 2;
} else if (length === 127) {
if (buffer.length < offset + 8) return null;
const big = buffer.readBigUInt64BE(offset);
// A test relay has no business buffering a 4GB frame.
if (big > 8n * 1024n * 1024n) throw new Error('frame too large');
length = Number(big);
offset += 8;
}
let mask: Buffer | null = null;
if (masked) {
if (buffer.length < offset + 4) return null;
mask = buffer.subarray(offset, offset + 4);
offset += 4;
}
if (buffer.length < offset + length) return null;
const payload = Buffer.from(buffer.subarray(offset, offset + length));
if (mask) {
for (let i = 0; i < payload.length; i++) payload[i] = payload[i]! ^ mask[i % 4]!;
}
return { opcode, payload, size: offset + length };
}
/* ---------- filters ---------- */
function matchesFilter(event: StoredEvent, filter: Filter): boolean {
const ids = filter['ids'] as string[] | undefined;
if (ids && !ids.includes(event.id)) return false;
const authors = filter['authors'] as string[] | undefined;
if (authors && !authors.includes(event.pubkey)) return false;
const kinds = filter['kinds'] as number[] | undefined;
if (kinds && !kinds.includes(event.kind)) return false;
const since = filter['since'] as number | undefined;
if (typeof since === 'number' && event.created_at < since) return false;
const until = filter['until'] as number | undefined;
if (typeof until === 'number' && event.created_at > until) return false;
// `#e`, `#p`, `#d`, `#k`, `#u`: match any value of that single-letter tag.
for (const [key, wanted] of Object.entries(filter)) {
if (!key.startsWith('#') || key.length !== 2) continue;
const name = key.slice(1);
const values = wanted as string[];
const present = event.tags.filter((t) => t[0] === name).map((t) => t[1]);
if (!present.some((value) => value !== undefined && values.includes(value))) return false;
}
return true;
}
/**
* The storage key for an event.
*
* Addressable kinds (30000–39999) are keyed by `kind:pubkey:d`, so a second announcement
* from the same publisher for the same mint replaces the first instead of accumulating —
* which is exactly the behaviour `kind:38174` depends on and therefore exactly what a
* test of it must reproduce. Everything else is keyed by its own id.
*/
function storageKey(event: StoredEvent): string {
if (event.kind >= 30000 && event.kind < 40000) {
const d = event.tags.find((t) => t[0] === 'd')?.[1] ?? '';
return `${event.kind}:${event.pubkey}:${d}`;
}
if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) {
return `${event.kind}:${event.pubkey}`;
}
return event.id;
}
/* ---------- the relay ---------- */
export interface TestRelay {
/** `ws://127.0.0.1:<port>`, ready to hand to a pool. */
url: string;
/** Every event currently stored, newest first. */
events(): StoredEvent[];
/** Events of one kind, newest first. */
byKind(kind: number): StoredEvent[];
close(): Promise<void>;
}
/**
* Start a relay on an ephemeral port.
*
* Port 0 rather than a fixed one so two tests, or two CI jobs on one machine, cannot
* collide — the caller reads the real port back off `url`.
*/
export async function startTestRelay(): Promise<TestRelay> {
const stored = new Map<string, StoredEvent>();
const sockets = new Set<Duplex>();
const server: Server = createServer((_req, res) => {
// Not a websocket upgrade. NIP-11 would go here on a real relay.
res.writeHead(426, { 'Content-Type': 'text/plain' });
res.end('websocket only');
});
server.on('upgrade', (req: IncomingMessage, socket: Duplex) => {
const key = req.headers['sec-websocket-key'];
if (typeof key !== 'string') {
socket.destroy();
return;
}
const accept = createHash('sha1').update(key + WS_GUID).digest('base64');
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
);
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
socket.on('error', () => sockets.delete(socket));
const send = (message: unknown): void => {
if (!socket.destroyed) socket.write(encodeText(JSON.stringify(message)));
};
let buffer = Buffer.alloc(0);
socket.on('data', (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
for (;;) {
let frame: DecodedFrame | null;
try {
frame = decodeFrame(buffer);
} catch {
socket.destroy();
return;
}
if (!frame) break;
buffer = buffer.subarray(frame.size);
if (frame.opcode === 0x8) {
socket.end();
return;
}
// Ping: answer with a pong carrying the same payload, per the spec.
if (frame.opcode === 0x9) {
const pong = encodeText('');
pong[0] = 0x8a;
socket.write(pong);
continue;
}
if (frame.opcode !== 0x1) continue;
let message: unknown;
try {
message = JSON.parse(frame.payload.toString('utf8'));
} catch {
continue;
}
if (!Array.isArray(message)) continue;
const [verb, ...rest] = message as [string, ...unknown[]];
if (verb === 'EVENT') {
const event = rest[0] as StoredEvent | undefined;
if (!event?.id) continue;
const existing = stored.get(storageKey(event));
// Older replacement for an addressable kind: keep what is there, and still
// answer OK, which is what a real relay does.
if (!existing || existing.created_at <= event.created_at) {
stored.set(storageKey(event), event);
}
send(['OK', event.id, true, '']);
continue;
}
if (verb === 'REQ') {
const subId = rest[0] as string;
const filters = rest.slice(1) as Filter[];
const all = [...stored.values()].sort((a, b) => b.created_at - a.created_at);
for (const filter of filters) {
const limit = typeof filter['limit'] === 'number' ? (filter['limit'] as number) : Infinity;
let sent = 0;
for (const event of all) {
if (sent >= limit) break;
if (!matchesFilter(event, filter)) continue;
send(['EVENT', subId, event]);
sent++;
}
}
send(['EOSE', subId]);
continue;
}
if (verb === 'CLOSE') {
send(['CLOSED', rest[0] as string, '']);
}
}
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('relay did not bind a port');
return {
url: `ws://127.0.0.1:${address.port}`,
events: () => [...stored.values()].sort((a, b) => b.created_at - a.created_at),
byKind: (kind) =>
[...stored.values()].filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at),
close: async () => {
for (const socket of sockets) socket.destroy();
sockets.clear();
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}