Files
CashuMints.space/api/src/lnurl-probe.ts
T
michilisandCursor 2a9444942b 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>
2026-08-22 03:44:35 +02:00

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,
};
}