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:
+3
-1
@@ -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:*",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -0,0 +1,935 @@
|
||||
/**
|
||||
* pnpm --filter ./api test:lnurl
|
||||
*
|
||||
* The LNURL ecosystem, checked against the two things it has to agree with: the
|
||||
* responses recorded in `NOTES-LNURL.md`, and the rules written down in
|
||||
* `docs/KIND-LNURL-MINT.md`. Those two documents are the specification; this file is
|
||||
* what stops the code and the documents drifting apart in silence.
|
||||
*
|
||||
* Four groups:
|
||||
*
|
||||
* 1. **Parsers**, against verbatim captures of the live reference instance and of a
|
||||
* locally run mint with no funding source. Not hand-written approximations — the
|
||||
* bytes that were actually on the wire.
|
||||
* 2. **The `d` rules**, including the sticky-identity case that the kind document
|
||||
* spends a section on and that a naive implementation gets wrong.
|
||||
* 3. **The features vocabulary**, including the two negative cases that matter:
|
||||
* nothing invents `lud21`, and nothing invents `rotate`/`split`/`merge`.
|
||||
* 4. **A full round trip** — announcement and review published to a real relay by this
|
||||
* site's own publisher, read back by the indexer's own parsers, resolved to a row.
|
||||
* The relay is `test-relay.ts`, in-process and on an ephemeral port, so this runs
|
||||
* in CI with nothing installed and never touches a public relay.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import * as nip19 from 'nostr-tools/nip19';
|
||||
import {
|
||||
ANNOUNCEMENT_KINDS,
|
||||
FEATURE_VOCABULARY,
|
||||
KIND_LNURL_ANNOUNCEMENT,
|
||||
KIND_REVIEW,
|
||||
addressFromPayLink,
|
||||
baseUrlFromKey,
|
||||
ecosystemForKind,
|
||||
featureStates,
|
||||
getMintWarnings,
|
||||
hasFeature,
|
||||
hostIdentifier,
|
||||
isMintPubkey,
|
||||
lnurlIdentifier,
|
||||
lnurlIdentifiers,
|
||||
lnurlKey,
|
||||
mintChip,
|
||||
msatToSat,
|
||||
normalizeLnurlNetwork,
|
||||
normalizeNetwork,
|
||||
onionFromHtml,
|
||||
otherFeatures,
|
||||
parseAdvertisement,
|
||||
parseFeatures,
|
||||
parseLnurlAnnouncement,
|
||||
parsePayInfo,
|
||||
parseRating,
|
||||
parseSoftware,
|
||||
reviewEcosystem,
|
||||
type LnurlFields,
|
||||
type NostrEventLike,
|
||||
} from '@cashumints/shared';
|
||||
import { announcedFeatures, announcementTemplate, parseAnnounceKey } from './announce.ts';
|
||||
import { announceConfig } from './config.ts';
|
||||
import type { MintRow } from './mints.ts';
|
||||
import { startTestRelay } from './test-relay.ts';
|
||||
|
||||
let checks = 0;
|
||||
function check(name: string, fn: () => void): void {
|
||||
try {
|
||||
fn();
|
||||
checks++;
|
||||
} catch (err) {
|
||||
console.error(`FAIL: ${name}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAsync(name: string, fn: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
checks++;
|
||||
} catch (err) {
|
||||
console.error(`FAIL: ${name}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Captures. Verbatim, from NOTES-LNURL.md.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** `GET https://lnurl.21mint.me/.well-known/lnurlw/_`, 2026-08-21. */
|
||||
const LIVE_WITHDRAW = {
|
||||
tag: 'withdrawRequest',
|
||||
callback: 'https://lnurl.21mint.me/w',
|
||||
minWithdrawable: 5000,
|
||||
maxWithdrawable: 999899000,
|
||||
defaultDescription: 'lnurlcash bearer note on lnurl.21mint.me',
|
||||
mintPubkey: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555',
|
||||
payLink: 'https://lnurl.21mint.me/.well-known/lnurlp/mint',
|
||||
nodeAlias: 'Azzamo',
|
||||
nodeUri: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555@145.239.92.138:9736',
|
||||
nodeColor: '#68f442',
|
||||
nodeCapacity: 30027500000,
|
||||
nodeNumChannels: 8,
|
||||
nodeNumPeers: 18,
|
||||
};
|
||||
|
||||
/** `GET https://lnurl.21mint.me/.well-known/lnurlp/_`, same run. */
|
||||
const LIVE_PAY = {
|
||||
tag: 'payRequest',
|
||||
callback: 'https://lnurl.21mint.me/p/cb',
|
||||
minSendable: 6000,
|
||||
maxSendable: 1000000000,
|
||||
metadata:
|
||||
'[["text/plain", "Mint an lnurlcash bearer note on lnurl.21mint.me"], ' +
|
||||
'["text/identifier", "_@lnurl.21mint.me"], ["text/plain", "Mint fees: 1000,100"]]',
|
||||
withdrawLink: 'https://lnurl.21mint.me/w',
|
||||
};
|
||||
|
||||
/**
|
||||
* The same endpoint on a locally run mint with no `FUNDINGSOURCE_*` configured at all.
|
||||
*
|
||||
* Every node field is *absent* rather than null, because the software runs with
|
||||
* `response_model_exclude_none`. This object is the degraded state, and it is the reason
|
||||
* the probe can report one.
|
||||
*/
|
||||
const NO_FUNDING_WITHDRAW = {
|
||||
tag: 'withdrawRequest',
|
||||
callback: 'https://lnurl.test/w',
|
||||
minWithdrawable: 10000,
|
||||
maxWithdrawable: 999999000,
|
||||
defaultDescription: 'lnurlcash bearer note on lnurl.test',
|
||||
payLink: 'https://lnurl.test/.well-known/lnurlp/mint',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 1. Parsers
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
check('the live mint advertisement parses into every field the site renders', () => {
|
||||
const ad = parseAdvertisement(LIVE_WITHDRAW);
|
||||
assert.ok(ad, 'the live advertisement must parse');
|
||||
assert.equal(ad.minWithdrawableMsat, 5000);
|
||||
assert.equal(ad.maxWithdrawableMsat, 999899000);
|
||||
assert.equal(ad.defaultDescription, 'lnurlcash bearer note on lnurl.21mint.me');
|
||||
assert.equal(ad.mintPubkey, '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555');
|
||||
assert.equal(ad.nodeAlias, 'Azzamo');
|
||||
assert.equal(ad.nodeCapacityMsat, 30027500000);
|
||||
assert.equal(ad.nodeChannels, 8);
|
||||
assert.equal(ad.nodePeers, 18);
|
||||
assert.equal(ad.fundingAvailable, true);
|
||||
});
|
||||
|
||||
check('millisatoshi limits become the sat figures the verdict strip shows', () => {
|
||||
assert.equal(msatToSat(5000), 5);
|
||||
assert.equal(msatToSat(999899000), 999899);
|
||||
// Floored, not rounded: both numbers are bounds, and rounding a ceiling up would
|
||||
// advertise a note larger than the mint will ever issue.
|
||||
assert.equal(msatToSat(1999), 1);
|
||||
assert.equal(msatToSat(999), 0);
|
||||
assert.equal(msatToSat(null), null);
|
||||
assert.equal(msatToSat(-1), null);
|
||||
});
|
||||
|
||||
check('a missing mintPubkey is the degraded state, not a parse failure', () => {
|
||||
const ad = parseAdvertisement(NO_FUNDING_WITHDRAW);
|
||||
assert.ok(ad, 'a mint with no funding source still serves a valid advertisement');
|
||||
assert.equal(ad.fundingAvailable, false);
|
||||
assert.equal(ad.mintPubkey, null);
|
||||
assert.equal(ad.nodeAlias, null);
|
||||
assert.equal(ad.nodeUri, null);
|
||||
// The limits are real and must still render. This is the whole point of the state.
|
||||
assert.equal(msatToSat(ad.minWithdrawableMsat), 10);
|
||||
assert.equal(msatToSat(ad.maxWithdrawableMsat), 999999);
|
||||
});
|
||||
|
||||
check('an LNURL error body is not a mint advertisement, despite arriving as HTTP 200', () => {
|
||||
// These endpoints answer their errors with 200. A probe keying on the status code
|
||||
// would call every one of these "online".
|
||||
assert.equal(parseAdvertisement({ status: 'ERROR', reason: 'Unknown user.' }), null);
|
||||
assert.equal(parseAdvertisement({ status: 'ERROR', reason: 'Unknown note.' }), null);
|
||||
assert.equal(parseAdvertisement({ detail: 'Not Found' }), null);
|
||||
assert.equal(parseAdvertisement(LIVE_PAY), null, 'a payRequest is not a withdrawRequest');
|
||||
assert.equal(parseAdvertisement(null), null);
|
||||
assert.equal(parseAdvertisement('withdrawRequest'), null);
|
||||
assert.equal(parseAdvertisement([]), null);
|
||||
});
|
||||
|
||||
check('an advertisement with inverted or missing bounds is rejected', () => {
|
||||
assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, minWithdrawable: 900, maxWithdrawable: 100 }), null);
|
||||
assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, maxWithdrawable: undefined }), null);
|
||||
assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, maxWithdrawable: 'lots' }), null);
|
||||
});
|
||||
|
||||
check('a zero withdraw ceiling parses, because the warning needs to see it', () => {
|
||||
// Rejecting this would turn "withdrawals disabled" into "offline", which is a
|
||||
// different and much less useful thing to tell a reader.
|
||||
const ad = parseAdvertisement({ ...LIVE_WITHDRAW, minWithdrawable: 0, maxWithdrawable: 0 });
|
||||
assert.ok(ad);
|
||||
assert.equal(ad.maxWithdrawableMsat, 0);
|
||||
});
|
||||
|
||||
check('the payRequest metadata is parsed twice and yields fee, description and address', () => {
|
||||
const pay = parsePayInfo(LIVE_PAY);
|
||||
assert.ok(pay);
|
||||
assert.equal(pay.minSendableMsat, 6000);
|
||||
assert.equal(pay.maxSendableMsat, 1000000000);
|
||||
assert.equal(pay.description, 'Mint an lnurlcash bearer note on lnurl.21mint.me');
|
||||
assert.equal(pay.feeBaseMsat, 1000);
|
||||
assert.equal(pay.feePpm, 100);
|
||||
assert.equal(pay.withdrawLink, 'https://lnurl.21mint.me/w');
|
||||
});
|
||||
|
||||
check('the "Mint fees:" entry never becomes the description', () => {
|
||||
// It shares `text/plain` with the description, so a naive first-match reader shows
|
||||
// "Mint fees: 1000,100" as what the mint is.
|
||||
const pay = parsePayInfo({
|
||||
...LIVE_PAY,
|
||||
metadata: '[["text/plain", "Mint fees: 2000,50"], ["text/plain", "A real description"]]',
|
||||
});
|
||||
assert.equal(pay?.description, 'A real description');
|
||||
assert.equal(pay?.feeBaseMsat, 2000);
|
||||
assert.equal(pay?.feePpm, 50);
|
||||
});
|
||||
|
||||
check('a fee-free mint reports no fee rather than zero', () => {
|
||||
const pay = parsePayInfo({ ...LIVE_PAY, metadata: '[["text/plain", "Just a mint"]]' });
|
||||
assert.equal(pay?.feeBaseMsat, null);
|
||||
assert.equal(pay?.feePpm, null);
|
||||
});
|
||||
|
||||
check('unparseable metadata costs the description, not the whole response', () => {
|
||||
const pay = parsePayInfo({ ...LIVE_PAY, metadata: 'not json at all' });
|
||||
assert.ok(pay, 'the limits above the metadata are still good');
|
||||
assert.equal(pay.minSendableMsat, 6000);
|
||||
assert.equal(pay.description, null);
|
||||
});
|
||||
|
||||
check('the lightning address comes from payLink, never from the echoed identifier', () => {
|
||||
// `text/identifier` echoes whichever username was queried, so probing `_` gets back
|
||||
// `_@host` — LUD-16's bare-domain form, and not a name to show a reader.
|
||||
assert.equal(parsePayInfo(LIVE_PAY)?.identifier, '_@lnurl.21mint.me');
|
||||
assert.equal(addressFromPayLink(LIVE_WITHDRAW.payLink), 'mint@lnurl.21mint.me');
|
||||
|
||||
assert.equal(addressFromPayLink('https://x.example/.well-known/lnurlp/_'), null);
|
||||
assert.equal(addressFromPayLink('https://x.example/somewhere/else'), null);
|
||||
assert.equal(addressFromPayLink(null), null);
|
||||
assert.equal(addressFromPayLink('not a url'), null);
|
||||
});
|
||||
|
||||
check('an onion address is found in the one-pager and nowhere else', () => {
|
||||
const html =
|
||||
'<h2>Also via Tor</h2><button class="copy" ' +
|
||||
'data-copy="mint@abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion" ' +
|
||||
'title="Copy lightning address">⚡</button>';
|
||||
assert.equal(
|
||||
onionFromHtml(html),
|
||||
'abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion',
|
||||
);
|
||||
assert.equal(onionFromHtml('<h1>a mint with no tor section</h1>'), null);
|
||||
});
|
||||
|
||||
check('the version comes from openapi, and the unknown sentinel is not a version', () => {
|
||||
assert.equal(
|
||||
parseSoftware({ info: { title: 'lnurl-mint', version: '0.1.0' } }),
|
||||
'lnurl-mint/0.1.0',
|
||||
);
|
||||
// What a source checkout with no installed package metadata reports. It is the
|
||||
// library saying "I do not know", not a release, and must not reach a reader.
|
||||
assert.equal(parseSoftware({ info: { title: 'lnurl-mint', version: '0.0.0+unknown' } }), null);
|
||||
assert.equal(parseSoftware({ info: { version: '1.2.3' } }), null);
|
||||
assert.equal(parseSoftware({}), null);
|
||||
assert.equal(parseSoftware(null), null);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 2. Identity: the `d` rules
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
check('a mint pubkey is 66 hex characters beginning 02 or 03', () => {
|
||||
assert.equal(isMintPubkey(LIVE_WITHDRAW.mintPubkey), true);
|
||||
assert.equal(isMintPubkey('03' + 'a'.repeat(64)), true);
|
||||
// A Cashu mint pubkey or a federation id is 64 characters, and must never be
|
||||
// mistaken for one of these.
|
||||
assert.equal(isMintPubkey('a'.repeat(64)), false);
|
||||
assert.equal(isMintPubkey('04' + 'a'.repeat(64)), false);
|
||||
assert.equal(isMintPubkey('021ab8'), false);
|
||||
assert.equal(isMintPubkey(null), false);
|
||||
});
|
||||
|
||||
check('the two `d` forms can never be confused for one another', () => {
|
||||
const host = hostIdentifier('https://lnurl.21mint.me');
|
||||
assert.equal(host, 'lnurl.21mint.me');
|
||||
assert.equal(isMintPubkey(host), false, 'a host is never pubkey-shaped');
|
||||
assert.ok(host.includes('.'), 'a host always contains a dot; a pubkey never does');
|
||||
});
|
||||
|
||||
check('the `d` fallback drops the scheme and the trailing slash but keeps the path', () => {
|
||||
assert.equal(hostIdentifier('https://mint.example.com/lnurl/'), 'mint.example.com/lnurl');
|
||||
assert.equal(hostIdentifier('https://Mint.Example.com'), 'mint.example.com');
|
||||
});
|
||||
|
||||
check('the identifier prefers the pubkey and falls back to the host', () => {
|
||||
assert.equal(
|
||||
lnurlIdentifier('https://lnurl.21mint.me', LIVE_WITHDRAW.mintPubkey),
|
||||
LIVE_WITHDRAW.mintPubkey,
|
||||
);
|
||||
assert.equal(lnurlIdentifier('https://lnurl.21mint.me', null), 'lnurl.21mint.me');
|
||||
// Junk in the pubkey position falls back rather than being published as a `d`.
|
||||
assert.equal(lnurlIdentifier('https://lnurl.21mint.me', 'nonsense'), 'lnurl.21mint.me');
|
||||
});
|
||||
|
||||
check('a mint that gained a pubkey is still reviewable under its old host identifier', () => {
|
||||
// The case the kind document spends a section on. Reviews written before the mint had
|
||||
// a funding source carry the host `d`; asking only for the current identifier would
|
||||
// silently strand every one of them.
|
||||
const both = lnurlIdentifiers('https://lnurl.21mint.me', LIVE_WITHDRAW.mintPubkey);
|
||||
assert.deepEqual(both, [LIVE_WITHDRAW.mintPubkey, 'lnurl.21mint.me']);
|
||||
|
||||
const hostOnly = lnurlIdentifiers('https://lnurl.21mint.me', null);
|
||||
assert.deepEqual(hostOnly, ['lnurl.21mint.me']);
|
||||
});
|
||||
|
||||
check('the row key round-trips the base URL', () => {
|
||||
const key = lnurlKey('https://lnurl.21mint.me');
|
||||
assert.equal(key, 'lnurl:https://lnurl.21mint.me');
|
||||
assert.equal(baseUrlFromKey(key), 'https://lnurl.21mint.me');
|
||||
// Not an LNURL key, and must not be mistaken for one.
|
||||
assert.equal(baseUrlFromKey('https://mint.example.com'), null);
|
||||
assert.equal(baseUrlFromKey('fedimint:' + 'a'.repeat(64)), null);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 3. The features vocabulary
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
check('the features tag splits like a modules tag, and tolerates what publishers write', () => {
|
||||
assert.deepEqual(parseFeatures('mint,melt,rotate'), ['mint', 'melt', 'rotate']);
|
||||
assert.deepEqual(parseFeatures('mint, melt, rotate'), ['mint', 'melt', 'rotate']);
|
||||
assert.deepEqual(parseFeatures('MINT,Melt'), ['mint', 'melt']);
|
||||
assert.deepEqual(parseFeatures('mint,mint,melt'), ['mint', 'melt']);
|
||||
assert.deepEqual(parseFeatures(''), []);
|
||||
assert.deepEqual(parseFeatures(null), []);
|
||||
// Junk tokens are dropped, not carried into a chip.
|
||||
assert.deepEqual(parseFeatures('mint,<script>,melt'), ['mint', 'melt']);
|
||||
});
|
||||
|
||||
check('an unrecognised feature is kept and shown, never dropped', () => {
|
||||
// The kind document requires consumers to ignore tokens they do not know rather than
|
||||
// reject the event, which is what lets the vocabulary grow without a new kind.
|
||||
const features = parseFeatures('mint,melt,quantum-notes');
|
||||
assert.deepEqual(features, ['mint', 'melt', 'quantum-notes']);
|
||||
assert.deepEqual(otherFeatures(features), ['quantum-notes']);
|
||||
});
|
||||
|
||||
check('the vocabulary is exactly what the kind document lists', () => {
|
||||
assert.deepEqual([...FEATURE_VOCABULARY], [
|
||||
'mint', 'melt', 'rotate', 'split', 'merge',
|
||||
'lud06', 'lud03', 'lud16', 'lud21',
|
||||
'signed-notes', 'onion',
|
||||
]);
|
||||
});
|
||||
|
||||
check('the notes row is satisfied by any one of rotate, split or merge', () => {
|
||||
assert.equal(hasFeature(['rotate'], 'notes'), true);
|
||||
assert.equal(hasFeature(['split'], 'notes'), true);
|
||||
assert.equal(hasFeature(['merge'], 'notes'), true);
|
||||
assert.equal(hasFeature(['mint', 'melt'], 'notes'), false);
|
||||
});
|
||||
|
||||
check('a funding outage marks mint, melt and signed-notes unavailable, and nothing else', () => {
|
||||
const features = ['mint', 'melt', 'rotate', 'split', 'merge', 'lud16', 'lud21', 'signed-notes'];
|
||||
|
||||
const down = featureStates(features, false);
|
||||
assert.equal(down.mint, 'unavailable');
|
||||
assert.equal(down.melt, 'unavailable');
|
||||
assert.equal(down['signed-notes'], 'unavailable');
|
||||
// Exactly what still works with no Lightning node, and the reason this state is
|
||||
// rendered rather than collapsed into "offline".
|
||||
assert.equal(down.notes, 'ok');
|
||||
assert.equal(down.lud16, 'ok');
|
||||
assert.equal(down.lud21, 'ok');
|
||||
assert.equal(down.onion, 'none');
|
||||
|
||||
const up = featureStates(features, true);
|
||||
assert.equal(up.mint, 'ok');
|
||||
assert.equal(up['signed-notes'], 'ok');
|
||||
|
||||
// Nothing probed yet is not a reason to doubt an operator's claim.
|
||||
const unknown = featureStates(features, null);
|
||||
assert.equal(unknown.mint, 'ok');
|
||||
});
|
||||
|
||||
check('a feature that was never claimed stays absent even when funding is down', () => {
|
||||
const states = featureStates(['rotate'], false);
|
||||
assert.equal(states.mint, 'none', 'absent, not "unavailable" — it was never claimed');
|
||||
assert.equal(states.notes, 'ok');
|
||||
});
|
||||
|
||||
check('the network tag agrees with the Fedimint side, bitcoin included', () => {
|
||||
for (const value of ['bitcoin', 'mainnet', 'MAIN', 'signet', 'regtest', 'testnet4', '', null]) {
|
||||
assert.equal(
|
||||
normalizeLnurlNetwork(value),
|
||||
normalizeNetwork(value),
|
||||
`the two normalizers disagree about ${JSON.stringify(value)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 4. The announcement
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const ANNOUNCER = 'f'.repeat(64);
|
||||
|
||||
function announcement(tags: string[][], content = ''): NostrEventLike {
|
||||
return {
|
||||
id: 'e'.repeat(64),
|
||||
pubkey: ANNOUNCER,
|
||||
kind: KIND_LNURL_ANNOUNCEMENT,
|
||||
created_at: 1_780_000_000,
|
||||
content,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
check('kind 38174 is wired into the one table every ecosystem is read from', () => {
|
||||
assert.equal(ANNOUNCEMENT_KINDS.lnurl, 38174);
|
||||
assert.equal(ecosystemForKind(38174), 'lnurl');
|
||||
assert.equal(ecosystemForKind('38174'), 'lnurl');
|
||||
// The neighbours are untouched.
|
||||
assert.equal(ecosystemForKind(38172), 'cashu');
|
||||
assert.equal(ecosystemForKind(38173), 'fedimint');
|
||||
});
|
||||
|
||||
check('a full announcement parses into every field a row needs', () => {
|
||||
const parsed = parseLnurlAnnouncement(
|
||||
announcement(
|
||||
[
|
||||
['d', LIVE_WITHDRAW.mintPubkey],
|
||||
['u', 'https://lnurl.21mint.me'],
|
||||
['features', 'mint,melt,rotate,split,merge,lud06,lud03,lud16,lud21,signed-notes'],
|
||||
['n', 'mainnet'],
|
||||
],
|
||||
JSON.stringify({ name: '21 Mint', about: 'Bearer notes.' }),
|
||||
),
|
||||
);
|
||||
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.identifier, LIVE_WITHDRAW.mintPubkey);
|
||||
assert.equal(parsed.mintPubkey, LIVE_WITHDRAW.mintPubkey);
|
||||
assert.equal(parsed.baseUrl, 'https://lnurl.21mint.me');
|
||||
assert.equal(parsed.slug, 'lnurl.21mint.me');
|
||||
assert.equal(parsed.network, 'mainnet');
|
||||
assert.equal(parsed.name, '21 Mint');
|
||||
assert.equal(parsed.about, 'Bearer notes.');
|
||||
assert.equal(parsed.announcerPubkey, ANNOUNCER);
|
||||
assert.equal(parsed.features.length, 10);
|
||||
});
|
||||
|
||||
check('an announcement by host parses, and carries no pubkey', () => {
|
||||
const parsed = parseLnurlAnnouncement(
|
||||
announcement([['d', 'lnurl.example.com'], ['u', 'https://lnurl.example.com']]),
|
||||
);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.identifier, 'lnurl.example.com');
|
||||
assert.equal(parsed.mintPubkey, null, 'a host `d` is not a pubkey and must not read as one');
|
||||
});
|
||||
|
||||
check('an announcement with no usable `u` is rejected', () => {
|
||||
// `u` is REQUIRED for this kind, unlike 38172: a host-form `d` has no scheme and is
|
||||
// not fetchable, so without `u` there is no address at all.
|
||||
assert.equal(parseLnurlAnnouncement(announcement([['d', 'lnurl.example.com']])), null);
|
||||
assert.equal(
|
||||
parseLnurlAnnouncement(announcement([['d', 'lnurl.example.com'], ['u', 'not a url']])),
|
||||
null,
|
||||
);
|
||||
// An onion cannot be the canonical `u`; the normalizer refuses it for every ecosystem.
|
||||
assert.equal(
|
||||
parseLnurlAnnouncement(
|
||||
announcement([['d', 'lnurl.example.com'], ['u', 'http://abcdefghijklmnop.onion']]),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
check('an announcement with an unusable `d` is rejected', () => {
|
||||
assert.equal(parseLnurlAnnouncement(announcement([['u', 'https://lnurl.example.com']])), null);
|
||||
assert.equal(
|
||||
parseLnurlAnnouncement(
|
||||
announcement([['d', 'not an identifier'], ['u', 'https://lnurl.example.com']]),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
check('a `d` naming one mint and a `u` naming another is accepted, deliberately', () => {
|
||||
// Not a validation failure: a mint's `d` is its funding node's pubkey, which has no
|
||||
// relationship to its hostname at all. Cross-checking them would reject exactly the
|
||||
// events the identity rule exists to allow.
|
||||
const parsed = parseLnurlAnnouncement(
|
||||
announcement([['d', LIVE_WITHDRAW.mintPubkey], ['u', 'https://somewhere.else.example']]),
|
||||
);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.baseUrl, 'https://somewhere.else.example');
|
||||
});
|
||||
|
||||
check('hostile announcement content degrades to no metadata', () => {
|
||||
for (const content of ['not json', '[]', 'null', '{"name": 12345}', '""']) {
|
||||
const parsed = parseLnurlAnnouncement(
|
||||
announcement([['d', 'lnurl.example.com'], ['u', 'https://lnurl.example.com']], content),
|
||||
);
|
||||
assert.ok(parsed, `content ${content} must not reject the announcement`);
|
||||
assert.equal(parsed.name, null);
|
||||
}
|
||||
|
||||
// A `javascript:` picture never reaches an img src.
|
||||
const parsed = parseLnurlAnnouncement(
|
||||
announcement(
|
||||
[['d', 'lnurl.example.com'], ['u', 'https://lnurl.example.com']],
|
||||
JSON.stringify({ picture: 'javascript:alert(1)' }),
|
||||
),
|
||||
);
|
||||
assert.equal(parsed?.picture, null);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 5. Reviews
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function review(tags: string[][], content = '[5/5] Good mint.'): NostrEventLike {
|
||||
return {
|
||||
id: 'd'.repeat(64),
|
||||
pubkey: 'c'.repeat(64),
|
||||
kind: KIND_REVIEW,
|
||||
created_at: 1_780_000_100,
|
||||
content,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
check('a k=38174 review is filed under lnurl and nothing else', () => {
|
||||
assert.equal(reviewEcosystem(review([['k', '38174']])), 'lnurl');
|
||||
assert.equal(reviewEcosystem(review([['k', '38172']])), 'cashu');
|
||||
assert.equal(reviewEcosystem(review([['k', '38173']])), 'fedimint');
|
||||
// A review with no `k` is Cashu, because every one of those predates everything else.
|
||||
assert.equal(reviewEcosystem(review([])), 'cashu');
|
||||
// A kind this build has no ecosystem for belongs to nobody.
|
||||
assert.equal(reviewEcosystem(review([['k', '39999']])), null);
|
||||
});
|
||||
|
||||
check('the rating convention is identical across all three ecosystems', () => {
|
||||
// A reader comparing a Cashu mint to an LNURL one must be comparing the same scale,
|
||||
// so this uses the site's existing parser with no LNURL-specific path at all.
|
||||
assert.equal(parseRating(review([['k', '38174']], '[4/5] Fast melts')), 4);
|
||||
assert.equal(parseRating(review([['k', '38174'], ['rating', '2']])), 2);
|
||||
assert.equal(parseRating(review([['k', '38174'], ['rating', '0.8']])), 4);
|
||||
assert.equal(parseRating(review([['k', '38174']], 'no rating here')), null);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 6. Warnings
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const onlineMint = {
|
||||
type: 'lnurl',
|
||||
status: 'online' as const,
|
||||
last_online: 1_786_999_910,
|
||||
first_seen: 1_769_720_000,
|
||||
};
|
||||
const NOW = 1_787_000_000;
|
||||
|
||||
check('a healthy LNURL mint gets no banner', () => {
|
||||
const warnings = getMintWarnings(
|
||||
{ ...onlineMint, max_withdrawable_msat: 999899000, funding_available: true },
|
||||
{ now: NOW },
|
||||
);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
check('a zero withdraw ceiling is critical and says withdrawals are disabled', () => {
|
||||
const warnings = getMintWarnings(
|
||||
{ ...onlineMint, max_withdrawable_msat: 0, funding_available: true },
|
||||
{ now: NOW },
|
||||
);
|
||||
assert.equal(warnings[0]?.kind, 'lnurl-withdrawals-disabled');
|
||||
assert.equal(warnings[0]?.severity, 'critical');
|
||||
assert.match(warnings[0]!.lead, /Withdrawals disabled/);
|
||||
assert.equal(mintChip(warnings)?.label, 'No withdrawals');
|
||||
});
|
||||
|
||||
check('never having probed is not "withdrawals disabled"', () => {
|
||||
// `=== 0`, not falsy. null means nothing has looked yet.
|
||||
const warnings = getMintWarnings(
|
||||
{ ...onlineMint, max_withdrawable_msat: null, funding_available: null },
|
||||
{ now: NOW },
|
||||
);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
check('an unreachable funding source is a warning that says what still works', () => {
|
||||
const warnings = getMintWarnings(
|
||||
{ ...onlineMint, max_withdrawable_msat: 999899000, funding_available: false },
|
||||
{ now: NOW },
|
||||
);
|
||||
assert.equal(warnings[0]?.kind, 'lnurl-no-funding');
|
||||
assert.equal(warnings[0]?.severity, 'warning');
|
||||
assert.match(warnings[0]!.body, /rotated, split, or merged/);
|
||||
assert.match(warnings[0]!.body, /nothing moves in or out/);
|
||||
assert.equal(mintChip(warnings)?.label, 'No mint / melt');
|
||||
});
|
||||
|
||||
check('a host responding with junk says so, rather than saying offline', () => {
|
||||
const warnings = getMintWarnings(
|
||||
{
|
||||
...onlineMint,
|
||||
status: 'degraded',
|
||||
max_withdrawable_msat: 999899000,
|
||||
invalid_reason: 'mint replied: Unknown user.',
|
||||
},
|
||||
{ now: NOW },
|
||||
);
|
||||
assert.equal(warnings[0]?.kind, 'lnurl-invalid');
|
||||
assert.match(warnings[0]!.lead, /Endpoint responding but invalid/);
|
||||
assert.match(warnings[0]!.body, /withdrawals may not work/);
|
||||
});
|
||||
|
||||
check('the offline tiers are exactly the Cashu ones', () => {
|
||||
const day = 86400;
|
||||
const tiers: Array<[number, string]> = [
|
||||
[2 * day, 'offline'],
|
||||
[10 * day, 'offline-long'],
|
||||
[40 * day, 'gone'],
|
||||
];
|
||||
|
||||
for (const [age, kind] of tiers) {
|
||||
const warnings = getMintWarnings(
|
||||
{ type: 'lnurl', status: 'offline', last_online: NOW - age, first_seen: NOW - 200 * day },
|
||||
{ now: NOW },
|
||||
);
|
||||
assert.equal(warnings[0]?.kind, kind, `${age / day} days offline should be ${kind}`);
|
||||
|
||||
// The same input with `type: cashu` reaches the same tier, which is what makes
|
||||
// "identical to Cashu" a checked claim rather than a comment.
|
||||
const cashu = getMintWarnings(
|
||||
{ type: 'cashu', status: 'offline', last_online: NOW - age, first_seen: NOW - 200 * day },
|
||||
{ now: NOW },
|
||||
);
|
||||
assert.equal(cashu[0]?.kind, kind);
|
||||
assert.equal(cashu[0]?.lead, warnings[0]?.lead, 'and says the same words');
|
||||
}
|
||||
});
|
||||
|
||||
check('a mint that never answered once lands in the top tier', () => {
|
||||
const warnings = getMintWarnings(
|
||||
{ type: 'lnurl', status: 'offline', last_online: null, first_seen: NOW - 40 * 86400 },
|
||||
{ now: NOW },
|
||||
);
|
||||
assert.equal(warnings[0]?.kind, 'gone');
|
||||
});
|
||||
|
||||
check('withdrawals disabled outranks an offline banner, and still says it is offline', () => {
|
||||
const warnings = getMintWarnings(
|
||||
{
|
||||
type: 'lnurl',
|
||||
status: 'offline',
|
||||
last_online: NOW - 3 * 86400,
|
||||
first_seen: NOW - 200 * 86400,
|
||||
max_withdrawable_msat: 0,
|
||||
},
|
||||
{ now: NOW },
|
||||
);
|
||||
assert.equal(warnings[0]?.kind, 'lnurl-withdrawals-disabled');
|
||||
assert.match(warnings[0]!.body, /also been offline/);
|
||||
});
|
||||
|
||||
check('an offline banner rescues the funding fact into its last sentence', () => {
|
||||
const warnings = getMintWarnings(
|
||||
{
|
||||
type: 'lnurl',
|
||||
status: 'offline',
|
||||
last_online: NOW - 40 * 86400,
|
||||
first_seen: NOW - 200 * 86400,
|
||||
funding_available: false,
|
||||
},
|
||||
{ now: NOW },
|
||||
);
|
||||
assert.equal(warnings[0]?.kind, 'gone');
|
||||
assert.match(warnings[0]!.body, /Lightning node was also unreachable/);
|
||||
});
|
||||
|
||||
check('no LNURL warning is ever derived from the features tag', () => {
|
||||
// `features` is the operator's claim about what they built. A banner derived from a
|
||||
// claim rather than an observation is the invention the Fedimint branch refuses to
|
||||
// make, and this branch refuses it too.
|
||||
const claimed = getMintWarnings(
|
||||
{ ...onlineMint, features: [], max_withdrawable_msat: 5000, funding_available: true } as never,
|
||||
{ now: NOW },
|
||||
);
|
||||
assert.deepEqual(claimed, []);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 7. The publisher
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const probedRow: MintRow = {
|
||||
url: 'lnurl:https://lnurl.21mint.me',
|
||||
host: 'lnurl.21mint.me',
|
||||
type: 'lnurl',
|
||||
name: '21 Mint',
|
||||
description: null,
|
||||
icon_url: null,
|
||||
icon_file: null,
|
||||
pubkey: null,
|
||||
info_json: null,
|
||||
ecosystem_json: null,
|
||||
nuts_json: null,
|
||||
version: 'lnurl-mint/0.1.0',
|
||||
status: 'online',
|
||||
consecutive_fails: 0,
|
||||
last_online: 1_786_999_910,
|
||||
last_probe: 1_786_999_910,
|
||||
first_seen: 1_769_720_000,
|
||||
updated_at: 1_786_999_910,
|
||||
};
|
||||
|
||||
const probedFields: LnurlFields = {
|
||||
lnurl_id: LIVE_WITHDRAW.mintPubkey,
|
||||
base_url: 'https://lnurl.21mint.me',
|
||||
features: [],
|
||||
network: 'mainnet',
|
||||
announced_at: null,
|
||||
announcer_pubkey: null,
|
||||
mint_pubkey: LIVE_WITHDRAW.mintPubkey,
|
||||
funding_available: true,
|
||||
probe_endpoint: '/.well-known/lnurlw/_',
|
||||
invalid_reason: null,
|
||||
min_withdrawable_msat: 5000,
|
||||
max_withdrawable_msat: 999899000,
|
||||
min_sendable_msat: 6000,
|
||||
max_sendable_msat: 1000000000,
|
||||
fee_base_msat: 1000,
|
||||
fee_ppm: 100,
|
||||
lightning_address: 'mint@lnurl.21mint.me',
|
||||
onion_url: null,
|
||||
node_alias: 'Azzamo',
|
||||
node_uri: LIVE_WITHDRAW.nodeUri,
|
||||
node_capacity_msat: 30027500000,
|
||||
node_channels: 8,
|
||||
node_peers: 18,
|
||||
observed_features: ['mint', 'melt', 'lud06', 'lud03', 'lud16', 'signed-notes'],
|
||||
};
|
||||
|
||||
check('announcing is off unless all three switches are set', () => {
|
||||
assert.equal(announceConfig({}), null);
|
||||
assert.equal(announceConfig({ ANNOUNCE_LNURL: 'true' }), null, 'no key, no publish');
|
||||
assert.equal(
|
||||
announceConfig({ ANNOUNCE_LNURL: 'true', ANNOUNCE_KEY: 'nsec1x' }),
|
||||
null,
|
||||
'no relays, no publish — there is deliberately no default',
|
||||
);
|
||||
assert.equal(
|
||||
announceConfig({ ANNOUNCE_LNURL: 'false', ANNOUNCE_KEY: 'k', ANNOUNCE_RELAYS: 'wss://r' }),
|
||||
null,
|
||||
);
|
||||
|
||||
const on = announceConfig({
|
||||
ANNOUNCE_LNURL: 'true',
|
||||
ANNOUNCE_KEY: 'nsec1x',
|
||||
ANNOUNCE_RELAYS: 'wss://one, wss://two, http://not-a-relay',
|
||||
});
|
||||
assert.deepEqual(on, { secretKey: 'nsec1x', relays: ['wss://one', 'wss://two'] });
|
||||
});
|
||||
|
||||
check('the signing key accepts an nsec or hex, and refuses anything else', () => {
|
||||
const secret = generateSecretKey();
|
||||
const hex = Buffer.from(secret).toString('hex');
|
||||
assert.deepEqual(parseAnnounceKey(hex), secret);
|
||||
assert.deepEqual(parseAnnounceKey(nip19.nsecEncode(secret)), secret);
|
||||
assert.equal(parseAnnounceKey('npub1abc'), null);
|
||||
assert.equal(parseAnnounceKey('nsec1notvalid'), null);
|
||||
assert.equal(parseAnnounceKey('deadbeef'), null);
|
||||
assert.equal(parseAnnounceKey(''), null);
|
||||
});
|
||||
|
||||
check('this site announces only what it observed', () => {
|
||||
const features = announcedFeatures(probedFields);
|
||||
assert.deepEqual(features, ['mint', 'melt', 'lud06', 'lud03', 'lud16', 'signed-notes']);
|
||||
|
||||
// The four it must never publish, and the reasons, from the kind document:
|
||||
assert.ok(!features.includes('lud21'), 'verify is undetectable over HTTP');
|
||||
for (const operation of ['rotate', 'split', 'merge']) {
|
||||
assert.ok(
|
||||
!features.includes(operation),
|
||||
`${operation} is only provable by calling /w/cb, which mutates a stranger's note`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
check('a mint with no reachable node is announced without the funded capabilities', () => {
|
||||
const features = announcedFeatures({ ...probedFields, funding_available: false });
|
||||
assert.deepEqual(features, ['lud06', 'lud03', 'lud16']);
|
||||
assert.ok(!features.includes('signed-notes'), 'note signing needs the same node');
|
||||
});
|
||||
|
||||
check('a zero withdraw ceiling is never announced as melt', () => {
|
||||
const features = announcedFeatures({ ...probedFields, max_withdrawable_msat: 0 });
|
||||
assert.ok(!features.includes('melt'));
|
||||
assert.ok(features.includes('mint'), 'the pay side is unaffected');
|
||||
});
|
||||
|
||||
check('the announcement template is shaped exactly as the kind document says', () => {
|
||||
const template = announcementTemplate(probedRow, probedFields, 1_787_000_000);
|
||||
assert.equal(template.kind, 38174);
|
||||
assert.deepEqual(template.tags[0], ['d', LIVE_WITHDRAW.mintPubkey]);
|
||||
assert.deepEqual(template.tags[1], ['u', 'https://lnurl.21mint.me']);
|
||||
assert.deepEqual(template.tags[2], ['features', 'mint,melt,lud06,lud03,lud16,signed-notes']);
|
||||
assert.deepEqual(template.tags[3], ['n', 'mainnet']);
|
||||
assert.equal(template.content, JSON.stringify({ name: '21 Mint' }));
|
||||
});
|
||||
|
||||
check('a mint that never named a network is not assigned one', () => {
|
||||
// Absent reads as mainnet, so asserting it would be this site inventing the one fact
|
||||
// that decides whether the money is real.
|
||||
const template = announcementTemplate(probedRow, { ...probedFields, network: null }, 1);
|
||||
assert.equal(template.tags.some((t) => t[0] === 'n'), false);
|
||||
});
|
||||
|
||||
check('an announcement this site wrote defers to its own kind 0 when there is no name', () => {
|
||||
const template = announcementTemplate({ ...probedRow, name: null }, probedFields, 1);
|
||||
assert.equal(template.content, '');
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 8. The round trip, against a real relay
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
await checkAsync('a 38174 announcement and a k=38174 review round-trip a relay', async () => {
|
||||
const relay = await startTestRelay();
|
||||
const pool = new SimplePool();
|
||||
|
||||
try {
|
||||
const siteKey = generateSecretKey();
|
||||
const sitePubkey = getPublicKey(siteKey);
|
||||
const reviewerKey = generateSecretKey();
|
||||
|
||||
/* --- publish the announcement, using the publisher's own template builder --- */
|
||||
const created = 1_787_000_000;
|
||||
const announcementEvent = finalizeEvent(
|
||||
announcementTemplate(probedRow, probedFields, created),
|
||||
siteKey,
|
||||
);
|
||||
await Promise.allSettled(pool.publish([relay.url], announcementEvent));
|
||||
|
||||
/* --- publish a review of it, shaped as the kind document specifies --- */
|
||||
const reviewEvent = finalizeEvent(
|
||||
{
|
||||
kind: KIND_REVIEW,
|
||||
created_at: created + 60,
|
||||
content: '[5/5] Rotations are instant and melts have never failed me.',
|
||||
tags: [
|
||||
['k', String(KIND_LNURL_ANNOUNCEMENT)],
|
||||
['u', probedFields.base_url, 'lnurl'],
|
||||
['d', probedFields.lnurl_id],
|
||||
['a', `${KIND_LNURL_ANNOUNCEMENT}:${sitePubkey}:${probedFields.lnurl_id}`, relay.url],
|
||||
],
|
||||
},
|
||||
reviewerKey,
|
||||
);
|
||||
await Promise.allSettled(pool.publish([relay.url], reviewEvent));
|
||||
|
||||
/* --- read both back the way the indexer does --- */
|
||||
const announcements = await pool.querySync(
|
||||
[relay.url],
|
||||
{ kinds: [KIND_LNURL_ANNOUNCEMENT] },
|
||||
{ maxWait: 4000 },
|
||||
);
|
||||
assert.equal(announcements.length, 1, 'the relay should hold exactly one announcement');
|
||||
|
||||
const parsed = parseLnurlAnnouncement(announcements[0]!);
|
||||
assert.ok(parsed, 'the indexer must be able to parse what the site published');
|
||||
assert.equal(parsed.identifier, LIVE_WITHDRAW.mintPubkey);
|
||||
assert.equal(parsed.baseUrl, 'https://lnurl.21mint.me');
|
||||
assert.equal(parsed.mintPubkey, LIVE_WITHDRAW.mintPubkey);
|
||||
assert.equal(parsed.network, 'mainnet');
|
||||
assert.equal(parsed.announcerPubkey, sitePubkey);
|
||||
assert.deepEqual(parsed.features, ['mint', 'melt', 'lud06', 'lud03', 'lud16', 'signed-notes']);
|
||||
assert.equal(parsed.name, '21 Mint');
|
||||
|
||||
/* --- the review, asked for exactly as the targeted pass asks --- */
|
||||
const reviews = await pool.querySync(
|
||||
[relay.url],
|
||||
{
|
||||
kinds: [KIND_REVIEW],
|
||||
'#k': [String(KIND_LNURL_ANNOUNCEMENT)],
|
||||
'#d': lnurlIdentifiers(parsed.baseUrl, parsed.mintPubkey),
|
||||
},
|
||||
{ maxWait: 4000 },
|
||||
);
|
||||
assert.equal(reviews.length, 1, 'the #d/#k filter the indexer uses must find it');
|
||||
assert.equal(reviewEcosystem(reviews[0]!), 'lnurl');
|
||||
assert.equal(parseRating(reviews[0]!), 5);
|
||||
|
||||
/* --- and by `u`, which is how a client that does not know this kind writes one --- */
|
||||
const byUrl = await pool.querySync(
|
||||
[relay.url],
|
||||
{ kinds: [KIND_REVIEW], '#u': [probedFields.base_url] },
|
||||
{ maxWait: 4000 },
|
||||
);
|
||||
assert.equal(byUrl.length, 1, 'the #u filter must find it too');
|
||||
|
||||
/* --- replacement: a second announcement for the same `d` replaces the first --- */
|
||||
const updated = finalizeEvent(
|
||||
announcementTemplate(
|
||||
probedRow,
|
||||
{ ...probedFields, funding_available: false },
|
||||
created + 3600,
|
||||
),
|
||||
siteKey,
|
||||
);
|
||||
await Promise.allSettled(pool.publish([relay.url], updated));
|
||||
|
||||
const after = relay.byKind(KIND_LNURL_ANNOUNCEMENT);
|
||||
assert.equal(after.length, 1, '38174 is addressable: the relay holds one, not two');
|
||||
assert.equal(
|
||||
after[0]?.tags.find((t) => t[0] === 'features')?.[1],
|
||||
'lud06,lud03,lud16',
|
||||
'and it is the newer one, with the funded capabilities dropped',
|
||||
);
|
||||
} finally {
|
||||
pool.close([relay.url]);
|
||||
await relay.close();
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`ok, ${checks} LNURL checks passed`);
|
||||
@@ -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);
|
||||
|
||||
+178
-12
@@ -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 {
|
||||
federationId =
|
||||
(JSON.parse(row.ecosystem_json) as { federation_id?: string }).federation_id ?? null;
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
+373
-48
@@ -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,6 +32,64 @@ export function statusForFails(fails: number): 'online' | 'degraded' | 'offline'
|
||||
*/
|
||||
const MAX_INFO_BYTES = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Write everything a successful Cashu probe learned, and record the probe.
|
||||
*
|
||||
* 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 recordCashuOnline(
|
||||
row: MintRow,
|
||||
info: MintInfo,
|
||||
latencyMs: number,
|
||||
now = Math.floor(Date.now() / 1000),
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
const nuts = parseNuts(info.nuts);
|
||||
const iconFile = await cacheIcon(row, info.icon_url ?? null);
|
||||
|
||||
await db.run(
|
||||
`UPDATE mints SET
|
||||
name = COALESCE(?, name),
|
||||
description = COALESCE(?, description),
|
||||
icon_url = ?,
|
||||
icon_file = ?,
|
||||
pubkey = COALESCE(?, pubkey),
|
||||
info_json = ?,
|
||||
nuts_json = ?,
|
||||
version = COALESCE(?, version),
|
||||
status = 'online',
|
||||
consecutive_fails = 0,
|
||||
last_online = ?,
|
||||
last_probe = ?,
|
||||
updated_at = ?
|
||||
WHERE url = ?`,
|
||||
info.name ?? null,
|
||||
info.description ?? null,
|
||||
info.icon_url ?? null,
|
||||
iconFile,
|
||||
info.pubkey ?? null,
|
||||
JSON.stringify(info),
|
||||
JSON.stringify(nuts),
|
||||
info.version ?? null,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
row.url,
|
||||
);
|
||||
|
||||
await db.run(
|
||||
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 1, ?)',
|
||||
row.url,
|
||||
now,
|
||||
latencyMs,
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: number }> {
|
||||
const started = Date.now();
|
||||
const controller = new AbortController();
|
||||
@@ -65,46 +127,7 @@ export async function probeMint(row: MintRow): Promise<ProbeResult> {
|
||||
|
||||
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(
|
||||
`UPDATE mints SET
|
||||
name = COALESCE(?, name),
|
||||
description = COALESCE(?, description),
|
||||
icon_url = ?,
|
||||
icon_file = ?,
|
||||
pubkey = COALESCE(?, pubkey),
|
||||
info_json = ?,
|
||||
nuts_json = ?,
|
||||
version = COALESCE(?, version),
|
||||
status = 'online',
|
||||
consecutive_fails = 0,
|
||||
last_online = ?,
|
||||
last_probe = ?,
|
||||
updated_at = ?
|
||||
WHERE url = ?`,
|
||||
info.name ?? null,
|
||||
info.description ?? null,
|
||||
info.icon_url ?? null,
|
||||
iconFile,
|
||||
info.pubkey ?? null,
|
||||
JSON.stringify(info),
|
||||
JSON.stringify(nuts),
|
||||
info.version ?? null,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
row.url,
|
||||
);
|
||||
|
||||
await db.run(
|
||||
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 1, ?)',
|
||||
row.url,
|
||||
now,
|
||||
latencyMs,
|
||||
);
|
||||
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,
|
||||
|
||||
+38
-2
@@ -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,7 +334,18 @@ 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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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/*',
|
||||
|
||||
@@ -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()));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user