/** * 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`: 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 { 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; // 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 { 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, }; }