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>
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|