diff --git a/shared/src/fedimint.ts b/shared/src/fedimint.ts index 2d7a8c1..061d016 100644 --- a/shared/src/fedimint.ts +++ b/shared/src/fedimint.ts @@ -300,3 +300,201 @@ export function shortInviteCode(code: string, head = 14, tail = 8): string { if (code.length <= head + tail + 1) return code; return `${code.slice(0, head)}…${code.slice(-tail)}`; } + +/* ---------- decoding an invite code ---------- */ + +/** + * Decoding one, which until now nothing in this codebase did. + * + * `isInviteCode` above is a shape check, and a shape check is all the *display* side + * has ever needed: a code arrives inside an announcement that already carries the + * federation id in its `d` tag, and the code itself is handed to a wallet verbatim. + * + * On-demand indexing changes that. A reader pasting an invite code into the review + * dialog hands over the only thing they have, and there is no announcement beside it + * to read an id off — so the id has to come out of the code, or the federation cannot + * be keyed, deduped against what is already indexed, or given a page. + * + * Two layers, both small and both self-contained (a bech32 dependency for one function + * would be the tail wagging the dog): + * + * 1. **bech32m**, per BIP-350: the same alphabet and checksum as bech32 with a + * different constant. Fedimint uses bech32m, so a code that verifies under the + * *bech32* constant is rejected rather than accepted — it would mean the code was + * produced by something else. + * 2. **fedimint's consensus encoding** of `Vec`: a BigSize count, then + * per part a BigSize tag, a BigSize length and that many bytes. Tag 1 is the + * federation id, 32 bytes. Everything else — guardian API URLs (tag 0), an API + * secret (tag 2), anything a later fedimint adds — is skipped by its length + * without being understood, which is what the tag/length framing is for. + * + * Written against real codes off the relay pool, not against a reading of the Rust, and + * `check-index.ts` asserts it still decodes them. + */ + +/** bech32's alphabet, and its two checksum constants. `1` is deliberately not in it. */ +const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; +const BECH32M_CONST = 0x2bc830a3; +const GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + +function bech32Polymod(values: readonly number[]): number { + let chk = 1; + for (const value of values) { + const top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ value; + for (let i = 0; i < 5; i++) if ((top >> i) & 1) chk ^= GENERATOR[i]!; + } + return chk >>> 0; +} + +function hrpExpand(hrp: string): number[] { + const out: number[] = []; + for (const char of hrp) out.push(char.charCodeAt(0) >> 5); + out.push(0); + for (const char of hrp) out.push(char.charCodeAt(0) & 31); + return out; +} + +/** + * The payload bytes of a bech32m string, or null if it is not a valid one. + * + * Length capped well above any real invite code: the checksum is only meaningful over + * a string somebody could plausibly have produced, and an unbounded input here would + * be an unbounded loop below. + */ +function decodeBech32m(input: string): { hrp: string; bytes: Uint8Array } | null { + if (input.length < 8 || input.length > 4000) return null; + // Mixed case is invalid in bech32; one case throughout is not. + if (input !== input.toLowerCase() && input !== input.toUpperCase()) return null; + + const value = input.toLowerCase(); + const split = value.lastIndexOf('1'); + if (split < 1 || split + 7 > value.length) return null; + + const hrp = value.slice(0, split); + for (const char of hrp) { + const code = char.charCodeAt(0); + if (code < 33 || code > 126) return null; + } + + const data: number[] = []; + for (const char of value.slice(split + 1)) { + const index = BECH32_CHARSET.indexOf(char); + if (index === -1) return null; + data.push(index); + } + + if (bech32Polymod([...hrpExpand(hrp), ...data]) !== BECH32M_CONST) return null; + + // Five-bit groups to eight, dropping the checksum and the final partial group. + const payload = data.slice(0, -6); + const bytes: number[] = []; + let acc = 0; + let bits = 0; + for (const group of payload) { + acc = (acc << 5) | group; + bits += 5; + while (bits >= 8) { + bits -= 8; + bytes.push((acc >> bits) & 0xff); + } + } + // Leftover bits must be zero padding, and there must be fewer than five of them. + if (bits >= 5 || ((acc << (8 - bits)) & 0xff) !== 0) return null; + + return { hrp, bytes: Uint8Array.from(bytes) }; +} + +/** A cursor over the decoded bytes, reading fedimint's BigSize integers and blobs. */ +class ByteReader { + private at = 0; + constructor(private readonly bytes: Uint8Array) {} + + get done(): boolean { + return this.at >= this.bytes.length; + } + + /** + * Lightning's BigSize, which is what fedimint encodes a `u64` as: one byte under + * 0xfd, otherwise a marker and 2, 4 or 8 big-endian bytes. + * + * Returns null rather than throwing when the buffer runs out, so a truncated code is + * a rejected code and not an exception a caller has to catch. + */ + bigSize(): number | null { + const first = this.byte(); + if (first === null) return null; + if (first < 0xfd) return first; + + const width = first === 0xfd ? 2 : first === 0xfe ? 4 : 8; + let value = 0; + for (let i = 0; i < width; i++) { + const next = this.byte(); + if (next === null) return null; + // Above 2^53 nothing here is a real length or tag anyway, and the arithmetic + // stops being exact, so an absurd value is refused rather than rounded. + value = value * 256 + next; + if (value > Number.MAX_SAFE_INTEGER) return null; + } + return value; + } + + bytesOf(length: number): Uint8Array | null { + if (length < 0 || this.at + length > this.bytes.length) return null; + const slice = this.bytes.subarray(this.at, this.at + length); + this.at += length; + return slice; + } + + private byte(): number | null { + return this.at < this.bytes.length ? this.bytes[this.at++]! : null; + } +} + +/** The tag fedimint gives the federation id inside an invite code. */ +const INVITE_PART_FEDERATION_ID = 1; +/** A federation id is a 32 byte hash. A part of any other length is not one. */ +const FEDERATION_ID_BYTES = 32; +/** Real codes carry two or three parts. This only has to stop a hostile count. */ +const MAX_INVITE_PARTS = 64; + +function toHex(bytes: Uint8Array): string { + let out = ''; + for (const byte of bytes) out += byte.toString(16).padStart(2, '0'); + return out; +} + +/** + * The federation id inside an invite code, or null if the code is not one. + * + * Null covers every way a pasted string can fail — wrong prefix, a typo the checksum + * catches, valid bech32m that is not an invite code, an invite code with no federation + * id part — because a caller has exactly one thing to say about all of them ("that is + * not an invite code") and telling them apart would be telling a stranger which of + * their guesses was closest. + */ +export function federationIdFromInviteCode(code: string): string | null { + const raw = code?.trim(); + if (!raw || !/^fed1[a-z0-9]+$/i.test(raw)) return null; + + const decoded = decodeBech32m(raw); + // `fed1` is the human-readable part; the `1` after it is bech32's separator. + if (!decoded || decoded.hrp !== 'fed1') return null; + + const reader = new ByteReader(decoded.bytes); + const parts = reader.bigSize(); + if (parts === null || parts === 0 || parts > MAX_INVITE_PARTS) return null; + + for (let i = 0; i < parts; i++) { + const tag = reader.bigSize(); + const length = tag === null ? null : reader.bigSize(); + const value = length === null ? null : reader.bytesOf(length); + if (value === null) return null; + + if (tag === INVITE_PART_FEDERATION_ID && value.length === FEDERATION_ID_BYTES) { + return toHex(value); + } + } + + return null; +} diff --git a/shared/src/index.ts b/shared/src/index.ts index a08e006..bbf19cd 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -5,3 +5,5 @@ export * from './score.js'; export * from './nuts.js'; export * from './warnings.js'; export * from './fedimint.js'; +export * from './lnurl.js'; +export * from './indexing.js'; diff --git a/shared/src/indexing.ts b/shared/src/indexing.ts new file mode 100644 index 0000000..a8de5ce --- /dev/null +++ b/shared/src/indexing.ts @@ -0,0 +1,208 @@ +/** + * On-demand indexing: the contract `POST /api/index` speaks. + * + * Both ends of that request are in this repository — the API answers it, the 404 + * resolver and the review-by-URL dialog send it — so the shapes live here rather than + * being written out twice and drifting. The reason codes in particular are the whole + * point of this file: the API decides *what happened*, the browser decides *what to + * say about it*, and a typo in a string literal must not be the thing that silently + * turns a precise sentence into a generic one. + * + * The endpoint itself is documented in BACKEND.md. What is here is only what both + * sides need to agree on: which types are indexable, what a valid submission looks + * like before any network call, and what comes back. + */ +import { federationIdFromInviteCode, isInviteCode } from './fedimint.js'; +import { normalizeMintUrl } from './normalize.js'; +import type { MintDetail, MintInfo } from './types.js'; + +/** The three ecosystems a reader can hand this site an identifier for. */ +export const INDEXABLE_TYPES = ['cashu', 'fedimint', 'lnurl'] as const; +export type IndexType = (typeof INDEXABLE_TYPES)[number]; + +export function isIndexType(value: unknown): value is IndexType { + return typeof value === 'string' && (INDEXABLE_TYPES as readonly string[]).includes(value); +} + +/** + * Why a submission did not become a row. + * + * Every one of these is a different sentence to a reader, which is why they are not + * collapsed into a generic failure: + * + * `bad_type` the `type` field was not one of the three + * `bad_input` the input is not a URL (or an invite code) at all + * `blocked_host` it resolves somewhere this server will not fetch from + * `wrong_type` the host answered, as a *different* ecosystem's mint + * `invalid_response` the host answered, as nothing this site recognises + * `invalid_invite` the invite code does not decode + * `unverifiable` nothing answered, and Nostr has never heard of it either + * `rate_limited` too many submissions from one address this hour + */ +export type IndexFailureReason = + | 'bad_type' + | 'bad_input' + | 'blocked_host' + | 'wrong_type' + | 'invalid_response' + | 'invalid_invite' + | 'unverifiable' + | 'rate_limited'; + +/** How a row that did not already exist came to be written. */ +export type IndexSource = 'probe' | 'announcement' | 'invite'; + +export interface IndexFailure { + error: IndexFailureReason; + /** English, for a log or a `curl`. The browser renders its own translated copy. */ + message: string; + /** + * The ecosystem this address *does* look like, when the answer said so. + * + * Only ever set beside `wrong_type`, and it is what lets the dialog offer "this looks + * like a Cashu mint, review it there instead" with a button rather than making the + * reader work out which page they wanted. + */ + detected_type?: IndexType; + /** Seconds until the next submission is accepted. Only beside `rate_limited`. */ + retry_after?: number; +} + +/** 200 or 201: the same payload `GET /api/mints/:host` returns, plus how it got there. */ +export type IndexSuccess = MintDetail & { + /** True when the identifier was already indexed and nothing was probed. */ + existing: boolean; + /** Absent on an `existing` hit: nothing was written, so nothing wrote it. */ + indexed_from?: IndexSource; +}; + +export type IndexResponse = IndexSuccess | IndexFailure; + +export function isIndexFailure(body: IndexResponse): body is IndexFailure { + return typeof (body as IndexFailure).error === 'string'; +} + +/** + * The client-side pre-check, run before any network call. + * + * Deliberately shallow: it answers "could this possibly be an address of this kind?" + * and nothing more. Whether the mint exists, answers, or is what it claims is the + * server's question, and asking the browser to guess would only produce a second + * opinion to disagree with. What it does catch is the common typo — an empty box, a + * sentence, a `fed1…` pasted into the Cashu field — before a request goes out. + * + * Returns the value to submit, which is the input with the scheme the normalizer would + * add, so the dialog can show the reader what it is about to check. + */ +export function checkIndexInput( + type: IndexType, + input: string, +): { ok: true; value: string } | { ok: false; reason: 'empty' | 'bad_url' | 'bad_invite' } { + const raw = input.trim(); + if (!raw) return { ok: false, reason: 'empty' }; + + if (type === 'fedimint') { + return isInviteCode(raw) && federationIdFromInviteCode(raw) !== null + ? { ok: true, value: raw.toLowerCase() } + : { ok: false, reason: 'bad_invite' }; + } + + // A pasted invite code in a URL field is a wrong-field mistake, not a malformed URL, + // and saying "that is an invite code" is more use than "that is not a URL". + if (isInviteCode(raw)) return { ok: false, reason: 'bad_invite' }; + + const normalized = normalizeMintUrl(raw); + return normalized ? { ok: true, value: normalized.url } : { ok: false, reason: 'bad_url' }; +} + +/** + * Is this body a NUT-06 mint info document? + * + * The probe needs a test that a Cashu mint passes and an arbitrary JSON endpoint fails, + * because `/v1/info` on a host that is not a mint is very often a 200 with *something* + * on it — an API index, a health check, a framework's error object. NUT-06 makes every + * field optional, so the test is "does it carry any of the things only a mint has": + * a `nuts` object, or a mint pubkey, or the name/version pair a mint's info always has. + * + * Kept here rather than in the prober because the wrong-type detection on the LNURL + * path runs the same test against the same document, and two spellings of "is this a + * Cashu mint" is exactly how a submission ends up filed under both ecosystems. + */ +export function isNut06Info(body: unknown): body is MintInfo { + if (!body || typeof body !== 'object' || Array.isArray(body)) return false; + const info = body as Record; + + const nuts = info['nuts']; + if (nuts && typeof nuts === 'object' && !Array.isArray(nuts) && Object.keys(nuts).length > 0) { + return true; + } + // A mint pubkey is 33 compressed bytes, exactly as an LNURL mint's is. + if (typeof info['pubkey'] === 'string' && /^0[23][0-9a-f]{64}$/i.test(info['pubkey'])) { + return true; + } + return typeof info['name'] === 'string' && typeof info['version'] === 'string'; +} + +/** + * The route a page for this listing lives at, given its type and routing slug. + * + * One table, because three pages, the 404 resolver, the review dialog and the sitemap + * all have to agree on it, and the failure mode of disagreeing is a link to a page that + * does not exist. Locale prefixing is the caller's job (`localePath`). + */ +export const MINT_ROUTES: Record = { + cashu: '/mint', + fedimint: '/fedimint', + lnurl: '/lnurl-mint', +}; + +/** `/mint/mint.example.com`, unprefixed. */ +export function mintPath(type: string, host: string): string { + const base = MINT_ROUTES[type as IndexType] ?? MINT_ROUTES.cashu; + return `${base}/${host}`; +} + +/** + * Which ecosystem a page path belongs to, or null when it is not a listing page. + * + * The 404 resolver's first question: the reader asked for *something*, and whether + * this build has any business indexing it on their behalf is decided entirely by the + * shape of the path they used. + */ +export function typeForPath(path: string): { type: IndexType; host: string } | null { + const match = /^\/(mint|fedimint|lnurl-mint)\/([^/]+)\/?$/.exec(path); + if (!match?.[1] || !match[2]) return null; + + const type: IndexType = + match[1] === 'fedimint' ? 'fedimint' : match[1] === 'lnurl-mint' ? 'lnurl' : 'cashu'; + return { type, host: decodeURIComponent(match[2]) }; +} + +/** + * The address a `/mint/{slug}` or `/lnurl-mint/{slug}` deep link implies, or null when + * the slug cannot be turned back into one. + * + * Routing slugs are deterministic but not reversible: `mint.example.com/Bitcoin` becomes + * `mint.example.com-bitcoin`, and so would a mint at `mint.example.com-bitcoin` if one + * existed. Looking a row up by slug is unaffected — that is what the column is for — but + * *indexing* from a slug means fetching an address, and guessing which of two readings a + * hyphen had would mean probing the wrong host and possibly indexing it. + * + * So only the unambiguous shape is derived: a plain hostname, optionally with the port + * suffix the slug spells `-3338`. Everything else returns null, and the caller says it + * cannot look this one up from the link alone and offers the dialog, where the reader + * can paste the address including its path. + * + * The `lnurl-` collision prefix is deliberately *not* stripped. A slug only takes that + * prefix at insert time, so a link carrying one describes a row that exists and never + * reaches this function; a slug that merely starts with those characters is far more + * likely to be a mint whose hostname begins `lnurl-`, and turning it into a different + * host would mean probing — and possibly indexing — somebody else's server. + */ +export function addressFromSlug(slug: string): string | null { + const value = slug.trim().toLowerCase(); + const match = /^([a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,})(?:-(\d{2,5}))?$/.exec(value); + if (!match?.[1]) return null; + + return `https://${match[1]}${match[2] ? `:${match[2]}` : ''}`; +} diff --git a/shared/src/lnurl.ts b/shared/src/lnurl.ts new file mode 100644 index 0000000..951cde9 --- /dev/null +++ b/shared/src/lnurl.ts @@ -0,0 +1,714 @@ +/** + * LNURL mints: what a `kind:38174` announcement contains, what the mint's own endpoints + * return, and the handful of derived values the API, the pages and the islands all have + * to agree on. + * + * The kind is this site's own proposed NIP-87 extension, specified in + * `docs/KIND-LNURL-MINT.md`. That document and this file are meant to be read together: + * every rule below is stated there normatively, and `api/src/check-lnurl.ts` asserts the + * two have not drifted apart. + * + * The endpoint parsing was written against the live reference instance and against a + * locally run build of the mint software, not against a reading of the README — the same + * rule NOTES.md sets for the Cashu side. `NOTES-LNURL.md` records what was actually on + * the wire. Four places where the wire and the prose disagreed, all resolved for the wire: + * + * - There is no bare `/p`. The payRequest lives only at `/.well-known/lnurlp/{username}`, + * and `/p` is a hard 404. The mint advertisement — limits, description, pubkey, node + * identity — is on the *withdraw* side, `/.well-known/lnurlw/{username}`. + * - Every registered route answers its errors with **HTTP 200** and an LNURL + * `{"status":"ERROR"}` body. A status code proves nothing; only the parsed body does. + * - `None` fields are dropped from responses entirely, so "no funding source" shows up + * as `mintPubkey` and the node fields being *absent*, not null. + * - LUD-21 verify cannot be distinguished from an unknown payment hash. It is + * announcement-only; nothing here infers it. + * + * Every amount on the wire is millisatoshi. Nothing in this file rounds; `msatToSat` + * is where that decision is made once. + */ +import { + sanitizeDisplayText, sanitizePictureUrl, tagValue, tagValues, type NostrEventLike, +} from './nostr.js'; +import { displayDomain, normalizeMintUrl } from './normalize.js'; + +/** The scheme on the synthetic `mints.url` an LNURL row is keyed by. */ +export const LNURL_KEY_SCHEME = 'lnurl:'; + +/** + * Routing-slug prefix, used **only** on collision. + * + * `mints.host` is globally unique across every ecosystem, so an LNURL mint and a Cashu + * mint on the same hostname would fight over one slug and the loser would silently not + * be tracked. Almost always they do not collide, and the LNURL mint keeps the clean + * `lnurl.21mint.me` slug; when one does, the row takes `lnurl-` in front rather than + * vanishing. See `insertLnurlMint` for why that is decided at insert time and then never + * revisited. + */ +export const LNURL_SLUG_PREFIX = 'lnurl-'; + +/** + * The primary key an LNURL row uses. + * + * Namespaced rather than storing the bare URL, for the same reason `fedimint:` is: the + * column is the table's primary key across all three ecosystems, and one host serving + * both a Cashu mint and an LNURL mint must produce two rows, not a collision. The base + * URL is recoverable in full, so nothing is lost by the prefix. + */ +export function lnurlKey(baseUrl: string): string { + return LNURL_KEY_SCHEME + baseUrl; +} + +/** The base URL inside an `lnurl:` key, or null if that is not what this is. */ +export function baseUrlFromKey(key: string): string | null { + if (!key.startsWith(LNURL_KEY_SCHEME)) return null; + const url = key.slice(LNURL_KEY_SCHEME.length); + return url.startsWith('https://') ? url : null; +} + +/* ---------- identity ---------- */ + +/** + * A mint pubkey: the funding node's identity key, 33 bytes compressed, 66 hex characters + * beginning `02` or `03`. + * + * Strict about the length and the prefix on purpose. This value becomes the `d` tag, and + * the whole reason the two `d` forms are unambiguous is that one of them is exactly this + * shape and the other never is. + */ +export function isMintPubkey(value: unknown): value is string { + return typeof value === 'string' && /^0[23][0-9a-f]{64}$/i.test(value); +} + +/** + * The `d` fallback: a mint's normalized host, with no scheme and no trailing slash. + * + * `https://mint.example.com/lnurl/` becomes `mint.example.com/lnurl`. Always contains a + * dot and never matches `isMintPubkey`, which is what keeps the two forms apart. + */ +export function hostIdentifier(baseUrl: string): string { + return displayDomain(baseUrl).toLowerCase(); +} + +/** + * The canonical `d` for a mint, given whatever is known about it. + * + * Pubkey when there is one, host otherwise — and the pubkey is *sticky*: a caller + * passing a previously known pubkey keeps it even when the current probe found none, + * because a node being unreachable for one request is not a change of identity. See + * "When a mint gains a pubkey after being announced by host" in the kind document. + */ +export function lnurlIdentifier(baseUrl: string, mintPubkey: string | null | undefined): string { + return isMintPubkey(mintPubkey) ? mintPubkey.toLowerCase() : hostIdentifier(baseUrl); +} + +/** + * Both identifiers a review of this mint could carry, for a `#d` relay filter and for + * resolution. + * + * A mint announced by host before it had a funding source, and by pubkey afterwards, has + * reviews pointing at both. Asking for only the current one strands the older half. + */ +export function lnurlIdentifiers( + baseUrl: string, + mintPubkey: string | null | undefined, +): string[] { + const host = hostIdentifier(baseUrl); + return isMintPubkey(mintPubkey) ? [mintPubkey.toLowerCase(), host] : [host]; +} + +/* ---------- features ---------- */ + +/** + * Note operations: the four branches of LUD-25's `/w/cb`, plus minting. + * + * Split into two groups because that is where a missing funding source cuts. `mint` and + * `melt` both need the node — one issues an invoice, the other pays one — while + * `rotate`, `split` and `merge` only rewrite this mint's own book and keep working with + * no node at all. That distinction is the whole reason the degraded state is worth + * rendering rather than collapsing into "offline". + */ +export const FUNDED_FEATURES = ['mint', 'melt'] as const; +export const NOTE_FEATURES = ['rotate', 'split', 'merge'] as const; + +/** The LNURL sub-specifications a mint can speak. */ +export const SPEC_FEATURES = ['lud06', 'lud03', 'lud16', 'lud21'] as const; + +/** Optional extras, both of which are genuinely visible from outside. */ +export const EXTRA_FEATURES = ['signed-notes', 'onion'] as const; + +/** + * The whole vocabulary, and the only values this build has an opinion about. + * + * Anything else in a `features` tag is kept and shown as its own chip rather than + * dropped: the kind document requires consumers to ignore tokens they do not recognise, + * and rendering an unknown capability under its published name says exactly as much as + * it should. + */ +export const FEATURE_VOCABULARY: readonly string[] = [ + ...FUNDED_FEATURES, ...NOTE_FEATURES, ...SPEC_FEATURES, ...EXTRA_FEATURES, +]; + +/** + * The named rows on the mint page's Features panel, in display order. + * + * `notes` is a row and not a feature: the three note-rewriting operations are one thing + * to a reader ("can I reshape what I hold?") and always ship together, so one row + * satisfied by any of them beats three rows that are always identical. Every other row + * is one vocabulary value. + */ +export const HIGHLIGHT_FEATURES = [ + 'mint', 'melt', 'notes', 'lud16', 'lud21', 'signed-notes', 'onion', +] as const; +export type HighlightFeature = (typeof HIGHLIGHT_FEATURES)[number]; + +/** Which vocabulary values satisfy each named row. */ +export const FEATURE_ALIASES: Record = { + mint: ['mint'], + melt: ['melt'], + notes: ['rotate', 'split', 'merge'], + lud16: ['lud16'], + lud21: ['lud21'], + 'signed-notes': ['signed-notes'], + onion: ['onion'], +}; + +/** + * Rows whose row is only as good as the funding source behind it. + * + * A mint that implements minting, melting and note signing still cannot do any of them + * while its node is unreachable, and the panel says so rather than showing a tick that + * is not true today. `rotate`/`split`/`merge` are deliberately absent from this set: + * they are exactly what still works. + */ +export const FUNDING_DEPENDENT: readonly HighlightFeature[] = ['mint', 'melt', 'signed-notes']; + +/** + * Plain-language names, and the English source of truth for them. + * + * The catalogs carry a translation per key under `lnurl.feature.`; anything neither + * knows renders as its own published token, which is still a true label. + */ +export const FEATURE_NAMES_EN: Record = { + mint: 'Mint via Lightning (LUD-06)', + melt: 'Melt to Lightning', + notes: 'Rotate / split / merge notes', + lud16: 'Lightning address', + lud21: 'Payment verification (LUD-21)', + 'signed-notes': 'Signed notes (offline verification)', + onion: 'Tor address', + rotate: 'Rotate a note', + split: 'Split a note', + merge: 'Merge notes', + lud06: 'LUD-06 payRequest', + lud03: 'LUD-03 withdrawRequest', +}; + +/** + * Split a `features` tag: `"mint,melt,rotate,lud06"`. + * + * Comma separated, but whitespace is tolerated because a publisher writing + * `"mint, melt"` meant the same thing — the same latitude `parseModules` gives a + * `modules` tag. Lowercased, deduped, order preserved, and bounded so a hostile tag + * cannot become a thousand chips on a page. + */ +export function parseFeatures(value: string | null | undefined): string[] { + if (!value) return []; + const out: string[] = []; + const seen = new Set(); + for (const part of value.split(/[,\s]+/)) { + const feature = part.trim().toLowerCase(); + if (!feature || feature.length > 32 || seen.has(feature)) continue; + if (!/^[a-z0-9_-]+$/.test(feature)) continue; + seen.add(feature); + out.push(feature); + if (out.length >= 40) break; + } + return out; +} + +/** Which named row a vocabulary value belongs to, or null for the chip list. */ +export function highlightFeatureFor(feature: string): HighlightFeature | null { + const name = feature.toLowerCase(); + for (const key of HIGHLIGHT_FEATURES) { + if (FEATURE_ALIASES[key]?.includes(name)) return key; + } + return null; +} + +/** True when this mint claims anything satisfying one of the named rows. */ +export function hasFeature(features: readonly string[], key: HighlightFeature): boolean { + const aliases = FEATURE_ALIASES[key] ?? []; + return features.some((feature) => aliases.includes(feature.toLowerCase())); +} + +/** Features with no named row of their own, for the chip list under the seven. */ +export function otherFeatures(features: readonly string[]): string[] { + return features.filter((feature) => highlightFeatureFor(feature) === null); +} + +/** + * What one row of the Features panel says. + * + * Three states rather than two, which is the one place this panel is richer than the + * Fedimint Modules panel it is modelled on: a capability can be published, and still be + * unavailable this minute because the node behind it is unreachable. Collapsing that + * into "supported" would show a tick beside something that would fail if tried. + */ +export type FeatureState = 'ok' | 'unavailable' | 'none'; + +/** + * The state of every named row, given what was announced and what the probe saw. + * + * The announcement decides whether a capability exists at all; the probe can only take + * one away, and only the three that depend on a funding source. That asymmetry is + * normative in the kind document: a prober never rewrites an operator's `features`. + * + * `fundingAvailable` is null when nothing has probed yet, which reads as "no reason to + * doubt it" rather than as a failure. + */ +export function featureStates( + features: readonly string[], + fundingAvailable: boolean | null, +): Record { + const out = {} as Record; + for (const key of HIGHLIGHT_FEATURES) { + if (!hasFeature(features, key)) { + out[key] = 'none'; + continue; + } + out[key] = + fundingAvailable === false && FUNDING_DEPENDENT.includes(key) ? 'unavailable' : 'ok'; + } + return out; +} + +/** + * The features a probe can honestly claim, from what it observed. + * + * Every entry has an observation behind it, and the four that are missing are the + * point. `rotate`, `split` and `merge` are only provable by calling `/w/cb`, which + * mutates or destroys a stranger's note; `lud21` is genuinely undetectable, because a + * disabled verify endpoint and an unknown payment hash return byte-identical responses + * (NOTES-LNURL.md §5). None of the four is inferred from a version string. + * + * Two callers, one definition, deliberately: the page renders this beside an operator's + * announced list, and the publisher signs it into a `kind:38174`. Those must not be + * able to disagree about what this site claims to have seen. + * + * The order is the vocabulary's own, so two runs over one mint produce identical + * output and the publisher's change detector does not fire on a reordering. + */ +export function observedFeatures(probe: { + fundingAvailable: boolean | null; + maxWithdrawableMsat: number | null; + maxSendableMsat: number | null; + lightningAddress: string | null; + mintPubkey: string | null; + onionUrl: string | null; +}): string[] { + const features: string[] = []; + + /* + * `mint` and `melt` each need two things: the mint advertising the relevant side, + * and a funding source that can actually perform it. A mint whose node is unreachable + * implements both and can do neither, and this site only ever saw it in the state + * where it could not — so it does not say otherwise. + */ + const funded = probe.fundingAvailable === true; + if (funded && probe.maxSendableMsat !== null) features.push('mint'); + if (funded && (probe.maxWithdrawableMsat ?? 0) > 0) features.push('melt'); + + if (probe.maxSendableMsat !== null) features.push('lud06'); + // The advertisement's `callback` is `/w`, the LUD-03 withdrawRequest, and parsing the + // advertisement at all is what proves it was served. + if (probe.maxWithdrawableMsat !== null) features.push('lud03'); + if (probe.lightningAddress) features.push('lud16'); + if (probe.mintPubkey && funded) features.push('signed-notes'); + if (probe.onionUrl) features.push('onion'); + + return features; +} + +/** + * What the Features panel renders: what the operator announced, plus what was observed. + * + * A union, and it has to be one. The announcement is the operator's claim about what + * they built and is the richer list — only they can tell you that notes rotate. The + * probe is this site's own observation and is the *only* list for a mint nobody has + * announced yet, which today is every LNURL mint on the network. + * + * Announced values come first so an operator's own ordering survives. Note that this is + * a display concern and nothing else: `LnurlFields.features` still holds the + * announcement verbatim, and the publisher still signs only `observedFeatures`. The + * kind document's rule is that a prober never rewrites an operator's list, and nothing + * here does — it renders two lists side by side. + */ +export function displayFeatures( + announced: readonly string[] | null | undefined, + observed: readonly string[] | null | undefined, +): string[] { + return [...new Set([...(announced ?? []), ...(observed ?? [])])]; +} + +/* ---------- amounts ---------- */ + +/** + * Millisatoshi to satoshi, floored. + * + * Floored rather than rounded because both of the numbers this converts are *bounds*: a + * `maxWithdrawable` rounded up advertises a note larger than the mint will ever issue, + * and a `minWithdrawable` rounded down advertises one it will refuse. Flooring keeps the + * displayed range inside the real one at both ends, which is the safe direction to be + * wrong in. Sub-sat amounts floor to 0, which is true and is what the "withdrawals + * disabled" warning keys on. + */ +export function msatToSat(msat: number | null | undefined): number | null { + if (typeof msat !== 'number' || !Number.isFinite(msat) || msat < 0) return null; + return Math.floor(msat / 1000); +} + +/* ---------- the mint's own endpoints ---------- */ + +/** The path the mint advertisement lives on. `_` is LUD-16's reserved bare-domain name. */ +export const WITHDRAW_INFO_PATH = '/.well-known/lnurlw/_'; +/** The LUD-06 payRequest, used as a fallback when the withdraw side does not answer. */ +export const PAY_INFO_PATH = '/.well-known/lnurlp/_'; + +/** + * What a mint advertisement yields once parsed. + * + * Everything is optional except the two limits and the tag, because that is genuinely + * what varies: a mint with no funding source omits its whole node section, and the + * response is still valid and still worth rendering. + */ +export interface LnurlAdvertisement { + minWithdrawableMsat: number; + maxWithdrawableMsat: number; + defaultDescription: string | null; + mintPubkey: string | null; + payLink: string | null; + nodeAlias: string | null; + nodeUri: string | null; + nodeCapacityMsat: number | null; + nodeChannels: number | null; + nodePeers: number | null; + /** + * Whether the mint's funding source answered. + * + * Derived, not read: `mintPubkey` is populated only when a funding source is both + * configured and reachable, and is dropped from the response otherwise. One bit, and + * it is the only HTTP-visible signal there is — see NOTES-LNURL.md for why it is not + * possible to separate "never configured" from "unreachable right now", and why the + * site does not try. + */ + fundingAvailable: boolean; +} + +function num(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function str(value: unknown, max = 400): string | null { + if (typeof value !== 'string') return null; + const text = value.trim(); + if (!text || text.length > max) return null; + return text; +} + +/** + * Parse `/.well-known/lnurlw/_`. + * + * Returns null for anything that is not a withdrawRequest carrying both limits — which + * includes the mint's own `{"status":"ERROR"}` bodies, since those arrive with HTTP 200 + * and would otherwise read as a successful probe. The caller turns null into the + * "responding but invalid" state, which is neither online nor offline. + */ +export function parseAdvertisement(body: unknown): LnurlAdvertisement | null { + if (!body || typeof body !== 'object' || Array.isArray(body)) return null; + const raw = body as Record; + + if (raw['tag'] !== 'withdrawRequest') return null; + + const min = num(raw['minWithdrawable']); + const max = num(raw['maxWithdrawable']); + // Both bounds are required, and inverted bounds are not a mint advertisement. + if (min === null || max === null || min < 0 || max < 0 || min > max) return null; + + const mintPubkey = isMintPubkey(raw['mintPubkey']) ? String(raw['mintPubkey']).toLowerCase() : null; + + return { + minWithdrawableMsat: min, + maxWithdrawableMsat: max, + defaultDescription: str(raw['defaultDescription']), + mintPubkey, + payLink: str(raw['payLink']), + nodeAlias: sanitizeDisplayText(raw['nodeAlias'], 64) ?? null, + nodeUri: str(raw['nodeUri'], 200), + nodeCapacityMsat: num(raw['nodeCapacity']), + nodeChannels: num(raw['nodeNumChannels']), + nodePeers: num(raw['nodeNumPeers']), + fundingAvailable: mintPubkey !== null, + }; +} + +/** What the LUD-06 payRequest yields. Fetched for the fee, the address and the limits. */ +export interface LnurlPayInfo { + minSendableMsat: number; + maxSendableMsat: number; + /** The `text/plain` entry: the closest thing to an operator-written description. */ + description: string | null; + /** The `text/identifier` entry, a LUD-16 lightning address. */ + identifier: string | null; + /** `Mint fees: ,`, absent when the mint is fee-free. */ + feeBaseMsat: number | null; + feePpm: number | null; + /** The `withdrawLink` extension: lnurlcash's pointer back to the withdraw side. */ + withdrawLink: string | null; +} + +/** + * Parse `/.well-known/lnurlp/_`. + * + * `metadata` is a JSON *string* holding a JSON array of `[mime, value]` pairs, so it is + * parsed twice. A metadata blob that will not parse costs the description and the + * address and nothing else: the limits above it are still good. + */ +export function parsePayInfo(body: unknown): LnurlPayInfo | null { + if (!body || typeof body !== 'object' || Array.isArray(body)) return null; + const raw = body as Record; + + if (raw['tag'] !== 'payRequest') return null; + + const min = num(raw['minSendable']); + const max = num(raw['maxSendable']); + if (min === null || max === null || min < 0 || max < 0 || min > max) return null; + + let description: string | null = null; + let identifier: string | null = null; + let feeBaseMsat: number | null = null; + let feePpm: number | null = null; + + try { + const entries: unknown = JSON.parse(typeof raw['metadata'] === 'string' ? raw['metadata'] : '[]'); + if (Array.isArray(entries)) { + for (const entry of entries) { + if (!Array.isArray(entry) || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') { + continue; + } + const [mime, value] = entry as [string, string]; + // `Mint fees: ,` shares the `text/plain` mime with the + // description, so it is recognised by its prefix and taken out of the running + // for one — otherwise a fee-charging mint's description would be its fee line. + const fees = /^Mint fees:\s*(\d+)\s*,\s*(\d+)\s*$/i.exec(value); + if (mime === 'text/plain' && fees) { + feeBaseMsat = Number.parseInt(fees[1]!, 10); + feePpm = Number.parseInt(fees[2]!, 10); + continue; + } + if (mime === 'text/plain' && description === null) { + description = sanitizeDisplayText(value, 400) ?? null; + } + if (mime === 'text/identifier' && identifier === null) { + identifier = isLightningAddress(value) ? value.trim().toLowerCase() : null; + } + } + } + } catch { + // Unparseable metadata. The limits are still real, so this is not a failed probe. + } + + return { + minSendableMsat: min, + maxSendableMsat: max, + description, + identifier, + feeBaseMsat, + feePpm, + withdrawLink: str(raw['withdrawLink']), + }; +} + +/** `name@domain`, and only that. Same shape rule the review cards apply to a NIP-05. */ +export function isLightningAddress(value: unknown): value is string { + if (typeof value !== 'string') return false; + const text = value.trim(); + return text.length <= 128 && /^[a-z0-9._+-]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(text); +} + +/** + * The mint's lightning address, derived from `payLink`. + * + * Not read off the payRequest's own `text/identifier`, which echoes back whatever + * username was queried — probing `_` gets `_@host`, which is LUD-16's bare-domain form + * and not something to render at a reader. `payLink` is built from the operator's + * configured username unconditionally, so it is the one place the real name appears. + */ +export function addressFromPayLink(payLink: string | null | undefined): string | null { + if (!payLink) return null; + let url: URL; + try { + url = new URL(payLink); + } catch { + return null; + } + const username = /\/\.well-known\/lnurlp\/([^/?#]+)$/.exec(url.pathname)?.[1]; + if (!username || username === '_') return null; + const address = `${decodeURIComponent(username)}@${url.hostname.toLowerCase()}`; + return isLightningAddress(address) ? address : null; +} + +/** + * A `*.onion` host in the one-pager, or null. + * + * Deliberately looser than base32's alphabet. A v3 address is 56 characters of `a-z2-7`, + * but the mint software's own test fixture is not valid base32, and a real address that + * does not fit the expected shape is still the operator's address. Nothing is ever + * fetched over Tor by this site, so the cost of a wrong match is one displayed string. + */ +export function onionFromHtml(html: string): string | null { + const match = /\b([a-z0-9]{16,60}\.onion)\b/i.exec(html); + return match?.[1]?.toLowerCase() ?? null; +} + +/** + * The software version, from `GET /openapi.json`. + * + * `0.0.0+unknown` is the package's own "I could not find my metadata" sentinel, not a + * release, so it is treated as no version at all rather than printed at a reader. The + * title is checked too: an arbitrary FastAPI app on the same host would otherwise + * contribute its version to a mint's page. + */ +export function parseSoftware(body: unknown): string | null { + if (!body || typeof body !== 'object') return null; + const info = (body as Record)['info']; + if (!info || typeof info !== 'object') return null; + + const record = info as Record; + const title = str(record['title'], 64); + const version = str(record['version'], 64); + if (!title || !version) return null; + if (!/^[\w.+-]+$/.test(version) || version.startsWith('0.0.0+unknown')) return null; + if (!/^[\w.@/ -]+$/.test(title)) return null; + + return `${title}/${version}`; +} + +/* ---------- the announcement ---------- */ + +export interface LnurlAnnouncement { + /** The `d` tag: a mint pubkey, or a normalized host. */ + identifier: string; + /** The `d` tag when it was a pubkey, else null. Feeds the sticky identity rule. */ + mintPubkey: string | null; + /** The canonical `u` tag, normalized. This is what rows are deduped by. */ + baseUrl: string; + /** The routing slug the normalizer derived from that URL. */ + slug: string; + /** The `features` tag, split. */ + features: string[]; + /** The `n` tag, normalized. */ + network: string | null; + /** From `content`, which is kind-0-shaped metadata. */ + name: string | null; + picture: string | null; + about: string | null; + announcerPubkey: string; + announcedAt: number; +} + +/** + * Read a kind 38174 event, or return null if it is not one this site can use. + * + * `u` is required here where NIP-87 makes it a SHOULD for 38172, and the kind document + * says why: an LNURL mint's `d` may be a bare host with no scheme, which is not + * something to fetch, so an announcement with no usable `u` carries no address at all + * and there is nothing to probe, key a row by, or link to. + * + * The `d` tag is *not* required to match the `u` tag's host. A mint that has a pubkey + * announces under it, and checking the two against each other would reject exactly the + * events the identity rule exists to allow. + */ +export function parseLnurlAnnouncement(event: NostrEventLike): LnurlAnnouncement | null { + const d = tagValue(event.tags, 'd')?.trim().toLowerCase(); + if (!d || d.length > 200) return null; + + // A `d` that is neither a pubkey nor host-shaped is not an identifier this build can + // resolve a review against, so the announcement is not usable even if `u` is fine. + if (!isMintPubkey(d) && !/^[a-z0-9.-]+\.[a-z]{2,}(:\d+)?(\/[\w./~-]*)?$/.test(d)) return null; + + let normalized: ReturnType = null; + for (const raw of tagValues(event.tags, 'u')) { + normalized = normalizeMintUrl(raw); + if (normalized) break; + } + if (!normalized) return null; + + const meta = parseLnurlMetadata(event.content); + + return { + identifier: d, + mintPubkey: isMintPubkey(d) ? d : null, + baseUrl: normalized.url, + slug: normalized.host, + features: parseFeatures(tagValue(event.tags, 'features')), + network: normalizeLnurlNetwork(tagValue(event.tags, 'n')), + name: meta.name, + picture: meta.picture, + about: meta.about, + announcerPubkey: event.pubkey, + announcedAt: event.created_at, + }; +} + +/** + * The `content` of a 38174, which the kind document defines as kind-0-style metadata. + * + * Same defensive posture as the Fedimint parser: this is arbitrary text written by + * anyone with a relay connection, so unparseable JSON, wrong types and junk fields all + * degrade to "no metadata" rather than throwing. + */ +export function parseLnurlMetadata(content: string): { + name: string | null; + picture: string | null; + about: string | null; +} { + const empty = { name: null, picture: null, about: null }; + if (!content.trim()) return empty; + + let meta: Record; + try { + const parsed: unknown = JSON.parse(content); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return empty; + meta = parsed as Record; + } catch { + return empty; + } + + const first = (...keys: string[]): unknown => { + for (const key of keys) if (meta[key] !== undefined && meta[key] !== null) return meta[key]; + return undefined; + }; + + return { + name: sanitizeDisplayText(first('name', 'display_name', 'mint_name'), 64) ?? null, + picture: sanitizePictureUrl(first('picture', 'icon_url', 'image')) ?? null, + about: sanitizeDisplayText(first('about', 'description'), 400) ?? null, + }; +} + +/** + * Normalize an `n` tag. + * + * The same mapping the Fedimint side applies, and for the same reason: NIP-87 names the + * value `mainnet`, publishers copy each other, and `bitcoin` is what they write. Kept + * separate from `normalizeNetwork` only so the two ecosystems' rules can diverge later + * without one quietly changing the other; today they agree, and `check-lnurl.ts` asserts + * that they still do. + */ +export function normalizeLnurlNetwork(value: string | null | undefined): string | null { + const raw = value?.trim().toLowerCase(); + if (!raw) return null; + if (raw === 'bitcoin' || raw === 'main' || raw === 'mainnet') return 'mainnet'; + if (!/^[a-z0-9]{1,20}$/.test(raw)) return null; + return raw; +} diff --git a/shared/src/normalize.ts b/shared/src/normalize.ts index 3069c70..280becb 100644 --- a/shared/src/normalize.ts +++ b/shared/src/normalize.ts @@ -28,6 +28,9 @@ function isDisallowedHost(hostname: string): boolean { if (hostname.endsWith('.local')) return true; if (PRIVATE_IPV4.test(hostname)) return true; if (isPrivateIpv6(hostname)) return true; + // Anything that parses as an IP literal is judged by the resolved-address rule too, + // so the two checks cannot disagree about, say, 100.64.0.1 or 224.0.0.1. + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) && isPrivateIpAddress(hostname)) return true; // A bare label with no dot cannot be a public host. if (!hostname.includes('.') && !hostname.includes(':')) return true; return false; @@ -50,6 +53,67 @@ function isPrivateIpv6(hostname: string): boolean { return false; } +/** + * Is this literal IP address one the indexer must never connect to? + * + * Written against a *resolved* address rather than a hostname, which is the difference + * between this and `isDisallowedHost` above: `mint.example.com` looks like an ordinary + * public name and can resolve to `127.0.0.1`, and only the answer DNS gave can tell you + * so. `POST /api/index` fetches URLs a stranger typed, so it resolves first and checks + * every address here before a socket is opened. + * + * Broader than the hostname rule on purpose. Beyond loopback, link-local and the three + * RFC-1918 ranges it also refuses carrier-grade NAT (100.64/10), `0.0.0.0/8`, the + * benchmarking and documentation ranges, multicast and the broadcast address: none of + * them is a public mint, and each of them is somewhere on a network this server can see + * and a stranger should not be able to point it at. + */ +export function isPrivateIpAddress(value: string): boolean { + const ip = value.trim().toLowerCase().replace(/^\[|\]$/g, ''); + if (!ip) return true; + + const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip); + if (v4) { + const [a, b] = [Number(v4[1]), Number(v4[2])]; + if (a === undefined || b === undefined || a > 255 || b > 255) return true; + if (a === 0 || a === 10 || a === 127) return true; // this-network, RFC1918, loopback + if (a === 169 && b === 254) return true; // link-local + if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918 + if (a === 192 && b === 168) return true; // RFC1918 + if (a === 192 && b === 0) return true; // IETF protocol assignments / 192.0.2.0 docs + if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking + if (a === 198 && b === 51) return true; // documentation + if (a === 203 && b === 0) return true; // documentation + if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT + if (a >= 224) return true; // multicast, reserved, broadcast + return false; + } + + if (!ip.includes(':')) return true; // Not an address this function understands. + + // An IPv4-mapped or IPv4-compatible address is judged on its IPv4 half. + const mapped = /:((?:\d{1,3}\.){3}\d{1,3})$/.exec(ip); + if (mapped?.[1]) return isPrivateIpAddress(mapped[1]); + + if (ip === '::' || ip === '::1') return true; + if (/^f[cd]/.test(ip)) return true; // unique local, fc00::/7 + if (/^fe[89ab]/.test(ip)) return true; // link-local, fe80::/10 + if (/^ff/.test(ip)) return true; // multicast + return false; +} + +/** + * Is this hostname one the indexer must never fetch, before DNS is consulted at all? + * + * The cheap half of the check: `localhost`, `.onion`, `.local`, a bare label with no + * dot, and an IP literal that is already disqualified by `isPrivateIpAddress`. Exported + * so the on-demand indexer can refuse the obvious cases without paying for a lookup, + * and so a redirect hop can be judged by the same rule its origin was. + */ +export function isBlockedHostname(hostname: string): boolean { + return isDisallowedHost(hostname.toLowerCase()); +} + /** * May the indexer fetch this URL? http(s) only, and never a private or local host. * diff --git a/shared/src/nostr.ts b/shared/src/nostr.ts index 613a403..c49402d 100644 --- a/shared/src/nostr.ts +++ b/shared/src/nostr.ts @@ -13,6 +13,15 @@ export const KIND_REVIEW = 38000; export const KIND_MINT_ANNOUNCEMENT = 38172; /** NIP-87 Fedimint federation announcement. */ export const KIND_FEDIMINT_ANNOUNCEMENT = 38173; +/** + * LNURL mint announcement. + * + * Not in NIP-87: this site's own proposed extension to it, specified in + * `docs/KIND-LNURL-MINT.md` and intended for a PR to nostr-protocol/nips. 38174 is the + * next free slot in the family — checked against the kind index in the nips README and + * against every issue and PR in that repository before it was claimed. + */ +export const KIND_LNURL_ANNOUNCEMENT = 38174; /** Profile metadata. */ export const KIND_PROFILE = 0; @@ -30,6 +39,7 @@ export const KIND_PROFILE = 0; export const ANNOUNCEMENT_KINDS = { cashu: KIND_MINT_ANNOUNCEMENT, fedimint: KIND_FEDIMINT_ANNOUNCEMENT, + lnurl: KIND_LNURL_ANNOUNCEMENT, } as const; /** @@ -224,6 +234,11 @@ export function isFedimintReview(event: NostrEventLike): boolean { return tagValue(event.tags, 'k') === String(KIND_FEDIMINT_ANNOUNCEMENT); } +/** True when the event's `k` tag marks it as being about an LNURL mint. */ +export function isLnurlReview(event: NostrEventLike): boolean { + return tagValue(event.tags, 'k') === String(KIND_LNURL_ANNOUNCEMENT); +} + /** * The kind a review says it is about, from its `k` tag, as a number. * diff --git a/shared/src/types.ts b/shared/src/types.ts index 63fd260..4177c24 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -18,7 +18,7 @@ export type MintStatus = 'online' | 'degraded' | 'offline' | 'unknown' | 'announ * has to be able to hold a value it does not have a page for. Compare against * `ANNOUNCEMENT_KINDS` rather than switching exhaustively on this. */ -export type MintType = 'cashu' | 'fedimint' | (string & {}); +export type MintType = 'cashu' | 'fedimint' | 'lnurl' | (string & {}); /** One item of `GET /api/mints`. */ export interface MintListItem { @@ -26,7 +26,7 @@ export interface MintListItem { host: string; name: string | null; icon: string | null; - /** 'cashu' or 'fedimint'. Rows written before the column existed read as 'cashu'. */ + /** 'cashu', 'fedimint' or 'lnurl'. Rows written before the column existed read as 'cashu'. */ type: MintType; status: MintStatus; last_online: number | null; @@ -48,10 +48,14 @@ export type RatingDistribution = Record<'1' | '2' | '3' | '4' | '5', number>; /** * `GET /api/mints/:host`. * - * The Fedimint keys are optional and absent on a Cashu mint, which is what keeps the - * Cashu payload unchanged. Read them through `FedimintDetail` after checking `type`. + * The Fedimint and LNURL keys are optional and absent on a Cashu mint, which is what + * keeps the Cashu payload unchanged. Read them through `FedimintDetail` or + * `LnurlDetail` after checking `type`. */ -export interface MintDetail extends MintListItem, Partial { +export interface MintDetail + extends MintListItem, + Partial, + Partial { description: string | null; pubkey: string | null; info: MintInfo | null; @@ -100,6 +104,97 @@ export interface FedimintFields { /** `GET /api/mints/:host` for a Fedimint federation: the detail plus its own fields. */ export type FedimintDetail = MintDetail & FedimintFields; +/** + * What `ecosystem_json` holds for an LNURL row. + * + * Two sources, never mixed: `features` is what the operator *announced* on Nostr, and + * everything under "probed" is what the mint's own endpoints said when they were last + * reached. The kind document makes that separation normative — a prober never rewrites + * an operator's capability list — and keeping the two in different fields is what makes + * it impossible to do by accident. + * + * Every millisatoshi field is stored exactly as the wire gave it. Conversion to sats + * happens once, at render, through `msatToSat`. + */ +export interface LnurlFields { + /** The `d` tag: the mint pubkey when it has one, else the normalized host. */ + lnurl_id: string; + /** The https base URL. `host` is the routing slug derived from it. */ + base_url: string; + /** The `features` tag, split. The operator's claim, never edited by a probe. */ + features: string[]; + /** + * What the last probe actually observed this mint serving. + * + * Kept apart from `features` above rather than merged into it, because the two are + * different kinds of statement — a claim and an observation — and the kind document + * makes it normative that a prober never rewrites the first. The page renders their + * union through `displayFeatures`; the publisher signs only this one. + */ + observed_features: string[]; + /** The `n` tag, normalized — `bitcoin` and `mainnet` both arrive as `mainnet`. */ + network: string | null; + /** `created_at` of the newest announcement seen. null for a seeded row. */ + announced_at: number | null; + /** Who published that announcement. The `a` tag of a review points back at them. */ + announcer_pubkey: string | null; + + /* ---- probed: from the mint's own endpoints ---- */ + + /** + * The funding node's identity key, from the mint advertisement. + * + * Sticky once learned: a probe that finds none does not clear it, because a node + * being unreachable for one request is not a change of identity. Its *absence from + * the latest probe* is recorded separately, in `funding_available`. + */ + mint_pubkey: string | null; + /** + * Whether the last probe found a reachable funding source. + * + * null before anything has probed. false is the degraded-but-online state: the mint + * answers, its limits are real, and `rotate`/`split`/`merge` still work, but nothing + * moves in or out over Lightning. See NOTES-LNURL.md for why this one bit cannot + * distinguish "never configured" from "unreachable right now", and why that is fine. + */ + funding_available: boolean | null; + /** Which endpoint answered: the withdraw advertisement, or the payRequest fallback. */ + probe_endpoint: string | null; + /** + * Set when the host answered but with something that is not a mint advertisement. + * + * A distinct outcome from both online and offline, and it has to be: these endpoints + * return HTTP 200 for their errors, so "responding" and "working" are different + * questions. Carries the short reason, for the banner. + */ + invalid_reason: string | null; + + /** Withdraw bounds, millisatoshi: what a note's value can actually be. */ + min_withdrawable_msat: number | null; + max_withdrawable_msat: number | null; + /** Pay bounds, millisatoshi: what a minter can actually send. Not the same numbers. */ + min_sendable_msat: number | null; + max_sendable_msat: number | null; + /** `Mint fees: ,` from the payRequest metadata. Absent means fee-free. */ + fee_base_msat: number | null; + fee_ppm: number | null; + + /** The LUD-16 address, derived from `payLink` rather than the echoed identifier. */ + lightning_address: string | null; + /** The Tor address from the one-pager, when one is advertised. */ + onion_url: string | null; + + /** The funding node, as the mint chooses to describe it. All optional, all msat. */ + node_alias: string | null; + node_uri: string | null; + node_capacity_msat: number | null; + node_channels: number | null; + node_peers: number | null; +} + +/** `GET /api/mints/:host` for an LNURL mint: the detail plus its own fields. */ +export type LnurlDetail = MintDetail & LnurlFields; + /** * `GET /api/stats`. * @@ -127,6 +222,18 @@ export interface Stats { fedimint_announced: number; /** Reviews of federations (`k` = 38173), included in `reviews_total`. */ fedimint_reviews: number; + lnurl_total: number; + /** LNURL mints a probe reached. Online includes the degraded-funding ones. */ + lnurl_online: number; + lnurl_offline: number; + /** + * Online mints whose funding source was unreachable at the last probe: up and + * serving, but nothing moves in or out over Lightning. A subset of `lnurl_online`, + * never added to it. + */ + lnurl_degraded_funding: number; + /** Reviews of LNURL mints (`k` = 38174), included in `reviews_total`. */ + lnurl_reviews: number; } /** `GET /api/health`. */ diff --git a/shared/src/warnings.ts b/shared/src/warnings.ts index b259f77..1b13846 100644 --- a/shared/src/warnings.ts +++ b/shared/src/warnings.ts @@ -88,7 +88,14 @@ export type MintWarningKind = | 'offline' // offline under 7 days // Fedimint | 'fedimint-offline' // a real check reported the guardians down - | 'never-confirmed'; // announced on Nostr, and nothing has ever confirmed it + | 'never-confirmed' // announced on Nostr, and nothing has ever confirmed it + // LNURL. The offline tiers above are shared verbatim rather than twinned: `gone`, + // `offline-long` and `offline` are about reachability, which means exactly the same + // thing for an LNURL mint as for a Cashu one, and their copy already reads correctly + // for both. Only the states with no Cashu equivalent are new. + | 'lnurl-withdrawals-disabled' // maxWithdrawable is zero: nothing can be redeemed + | 'lnurl-invalid' // the host answers, but not with a mint advertisement + | 'lnurl-no-funding'; // up and serving, but its Lightning node is unreachable export interface MintWarning { kind: MintWarningKind; @@ -139,6 +146,19 @@ export interface MintWarningInput { capabilities?: Pick | null; /** Only used to date "never answered a single check". */ first_seen?: number; + + /* ---- LNURL ---- */ + + /** + * The advertised withdraw ceiling, millisatoshi. Zero is the disabling value, and it + * is checked as `=== 0` rather than as falsy: `null` means nothing has probed, which + * is not the same claim at all. + */ + max_withdrawable_msat?: number | null; + /** false when the last probe found the mint's Lightning node unreachable. */ + funding_available?: boolean | null; + /** Set when the host answered with something that is not a mint advertisement. */ + invalid_reason?: string | null; } export interface MintWarningOptions { @@ -218,9 +238,28 @@ export const WARNING_COPY_EN: Record = { 'This federation was announced on Nostr on {date}, {days} ago, nothing has confirmed since then that it is running, and nobody has reviewed it recently. Everything below came from the announcement.', 'neverConfirmed.meta': 'Announced {date}, never confirmed', + 'lnurlWithdrawalsDisabled.lead': 'Withdrawals disabled.', + 'lnurlWithdrawalsDisabled.body.online': + 'This mint advertises a maximum withdrawal of zero, so no note it issues can be redeemed for anything. Do not mint here until that changes.', + 'lnurlWithdrawalsDisabled.body.offline': + 'This mint advertised a maximum withdrawal of zero when it was last reached, so no note it issued could be redeemed for anything. Do not mint here until that changes.', + 'lnurlWithdrawalsDisabled.meta.online': 'Withdrawals currently disabled', + 'lnurlWithdrawalsDisabled.meta.offline': 'Withdrawals were disabled when last seen', + + 'lnurlInvalid.lead': 'Endpoint responding but invalid.', + 'lnurlInvalid.body': + 'The host answers, but not with a mint advertisement this site can read, so withdrawals may not work. Anything below is the last state that did parse, and reviews still work.', + 'lnurlInvalid.meta': 'Responding, but not with a valid mint advertisement', + + 'lnurlNoFunding.lead': 'Minting and melting unavailable.', + 'lnurlNoFunding.body': + 'Existing notes can still be rotated, split, or merged, but nothing moves in or out via Lightning right now. The mint itself is up and answering; the Lightning node behind it is not reachable from it.', + 'lnurlNoFunding.meta': 'Minting and melting unavailable', + 'also.meltDisabled': 'Before going offline it had also disabled withdrawals.', 'also.mintDisabled': 'Before going offline it had also disabled new minting.', 'also.offline': 'It has also been offline since {date} ({days}).', + 'also.noFunding': 'Its Lightning node was also unreachable, so nothing could be minted or melted.', }; /** @@ -247,12 +286,24 @@ const englishStrings: WarningStrings = (key, vars) => { /** Severity order, highest first. Index 0 of the returned list is the one to show. */ const ORDER: MintWarningKind[] = [ - 'gone', 'melt-disabled', 'frozen', 'offline-long', 'melt-only', 'offline', + 'gone', + 'melt-disabled', 'frozen', 'lnurl-withdrawals-disabled', + 'offline-long', + 'melt-only', 'lnurl-invalid', 'lnurl-no-funding', + 'offline', // Appended rather than interleaved. The Fedimint kinds never share a list with the // Cashu ones (the two branches are exclusive), so their position relative to those // is arbitrary — and appending leaves every existing rank exactly where it was. 'fedimint-offline', 'never-confirmed', ]; +/* + * The LNURL kinds *are* interleaved, unlike the Fedimint ones, and they have to be: + * that branch reuses `gone`, `offline-long` and `offline`, so its warnings genuinely + * share a list with those ranks and appending would put a critical + * "withdrawals disabled" below a mild "offline since yesterday". The relative order of + * everything that existed before is unchanged, which is what `check-warnings.ts` + * asserts. + */ /** * How stale an unconfirmed announcement has to be before it is worth saying so. @@ -284,6 +335,7 @@ export function getMintWarnings( options: MintWarningOptions = {}, ): MintWarning[] { if (mint.type === 'fedimint') return fedimintWarnings(mint, options); + if (mint.type === 'lnurl') return lnurlWarnings(mint, options); return cashuWarnings(mint, options); } @@ -373,13 +425,7 @@ function cashuWarnings( const caps = mint.capabilities ?? readCapabilities(mint.info?.nuts); const { mintDisabled, meltDisabled } = caps; - const offline = mint.status === 'offline'; - const since = mint.last_online; - const days = offline && since !== null ? Math.max(0, Math.floor((now - since) / 86400)) : null; - - // Offline with no last_online means it has never once answered, which is at least - // as bad as a month of silence, so it lands in the top tier rather than the bottom. - const tier = !offline ? 0 : days === null ? 3 : days >= 30 ? 3 : days >= 7 ? 2 : 1; + const { offline, since, days, tier } = offlineTier(mint, now); /* * Cached flags describe the last configuration seen, not the current one, and the @@ -468,6 +514,183 @@ function cashuWarnings( return out; } +/** + * How long a listing has been unreachable, in the four bands the banners key on. + * + * Shared by the Cashu and LNURL branches, which apply exactly the same thresholds — + * being unreachable means the same thing whether the endpoint that stopped answering + * was `/v1/info` or a mint advertisement, and two copies of "is 7 days long?" is two + * places for it to become 8 in one of them. + * + * Tier 3 covers both a month of silence and never having answered at all: offline with + * no `last_online` means it has never once answered, which is at least as bad as a + * month of it, so it lands in the top tier rather than the bottom. + */ +function offlineTier( + mint: Pick, + now: number, +): { offline: boolean; since: number | null; days: number | null; tier: 0 | 1 | 2 | 3 } { + const offline = mint.status === 'offline'; + const since = mint.last_online; + const days = offline && since !== null ? Math.max(0, Math.floor((now - since) / 86400)) : null; + const tier = !offline ? 0 : days === null ? 3 : days >= 30 ? 3 : days >= 7 ? 2 : 1; + return { offline, since, days, tier }; +} + +/** + * What can be said about an LNURL mint. + * + * Between the two extremes of the other ecosystems. A federation publishes no switches + * at all, so its page can only talk about reachability; a Cashu mint publishes NUT-04 + * and NUT-05 flags, so its page can be specific about which direction is broken. An + * LNURL mint sits in between: two things about it are genuinely checkable over HTTP, + * and both get a banner. + * + * - `maxWithdrawable` of zero. A mint advertising that no note can be redeemed for + * anything is the closest analogue this ecosystem has to "withdrawals disabled", + * and it is read from the advertisement rather than inferred, so it ranks with the + * Cashu capability banners. + * - A funding source the mint cannot reach. Distinctive to lnurlcash and worth its + * own sentence, because it is *partial*: the mint is up, its notes still rotate, + * split and merge, and only the two operations that need a Lightning node are + * unavailable. Calling that "offline" would be wrong in both directions. + * + * The third, `lnurl-invalid`, is about this site's own reading rather than the mint's + * configuration: these endpoints answer their errors with HTTP 200, so a host that + * responds with something unparseable is a state that has to be named rather than + * silently counted as either up or down. + * + * Note what is deliberately missing: nothing here reads `features`. That tag is the + * operator's claim about what they built, and a banner derived from a claim rather than + * from an observation would be the same invention the Fedimint branch refuses to make. + */ +function lnurlWarnings( + mint: MintWarningInput, + options: MintWarningOptions = {}, +): MintWarning[] { + const now = options.now ?? Math.floor(Date.now() / 1000); + const date = options.formatDate ?? defaultDate; + const month = options.formatMonth ?? defaultMonth; + const s = options.strings ?? englishStrings; + + const { offline, since, days, tier } = offlineTier(mint, now); + const tense = offline ? 'offline' : 'online'; + const dayCount = s('dayCount', { n: days ?? 0 }); + + // `=== 0`, never falsy: null means nothing has probed this mint yet, and "we have not + // looked" must not render as "withdrawals are disabled". + const withdrawalsDisabled = mint.max_withdrawable_msat === 0; + const invalid = Boolean(mint.invalid_reason); + const noFunding = mint.funding_available === false; + + const out: MintWarning[] = []; + + // Pushed in ORDER, so the first one added is the one that wins. + if (tier === 3) { + out.push({ + kind: 'gone', + severity: 'critical', + lead: s('gone.lead'), + body: + since !== null + ? s('gone.body.since', { date: date(since), days: dayCount }) + : mint.first_seen + ? s('gone.body.neverDated', { month: month(mint.first_seen) }) + : s('gone.body.never'), + meta: since !== null ? s('gone.meta.since', { date: date(since) }) : s('gone.meta.never'), + }); + } + + if (withdrawalsDisabled) { + out.push({ + kind: 'lnurl-withdrawals-disabled', + severity: 'critical', + lead: s('lnurlWithdrawalsDisabled.lead'), + body: s(`lnurlWithdrawalsDisabled.body.${tense}`), + meta: s(`lnurlWithdrawalsDisabled.meta.${tense}`), + }); + } + + if (tier === 2 && since !== null) { + out.push({ + kind: 'offline-long', + severity: 'critical', + lead: s('offlineLong.lead', { days: dayCount, date: date(since) }), + body: s('offlineLong.body'), + meta: s('offlineLong.meta', { date: date(since) }), + }); + } + + if (invalid) { + out.push({ + kind: 'lnurl-invalid', + severity: 'warning', + lead: s('lnurlInvalid.lead'), + body: s('lnurlInvalid.body'), + meta: s('lnurlInvalid.meta'), + }); + } + + if (noFunding) { + out.push({ + kind: 'lnurl-no-funding', + severity: 'warning', + lead: s('lnurlNoFunding.lead'), + body: s('lnurlNoFunding.body'), + meta: s('lnurlNoFunding.meta'), + }); + } + + if (tier === 1 && since !== null) { + out.push({ + kind: 'offline', + severity: 'warning', + lead: s('offline.lead', { date: date(since) }), + body: s('offline.body'), + meta: s('offline.meta', { date: date(since) }), + }); + } + + const primary = out[0]; + if (primary) { + const extra = lnurlCollapsed(primary.kind, { noFunding, offline, since, dayCount, date, s }); + if (extra) primary.body += ` ${extra}`; + } + + return out; +} + +/** + * The one sentence a losing LNURL condition earns inside the winner's text. + * + * Same contract as `collapsed`, and separate from it because the conditions being + * folded in are different ones: there is no NUT-04 or NUT-05 here, and the fact worth + * rescuing from an offline banner is that the mint's Lightning node was down too. + */ +function lnurlCollapsed( + kind: MintWarningKind, + ctx: { + noFunding: boolean; + offline: boolean; + since: number | null; + dayCount: string; + date: (unix: number) => string; + s: WarningStrings; + }, +): string | null { + if (kind === 'gone' || kind === 'offline-long' || kind === 'offline') { + return ctx.noFunding ? ctx.s('also.noFunding') : null; + } + + // A capability banner outranked an offline one: say the mint is also unreachable, + // otherwise the page reads as if it were up and merely misconfigured. + if (kind === 'lnurl-withdrawals-disabled' && ctx.offline && ctx.since !== null) { + return ctx.s('also.offline', { date: ctx.date(ctx.since), days: ctx.dayCount }); + } + + return null; +} + /** * The one sentence a losing condition earns inside the winner's text. Banners never * stack, but a mint that is both gone and had stopped paying out is a worse story @@ -533,6 +756,8 @@ export const CHIP_COPY_EN: Record = { frozen: 'Frozen', noWithdrawals: 'No withdrawals', meltOnly: 'Melt only', + /** LNURL: up and serving, but nothing moves in or out over Lightning. */ + noFunding: 'No mint / melt', }; export function mintChip(warnings: MintWarning[], strings?: WarningStrings): MintChip | null { @@ -541,7 +766,13 @@ export function mintChip(warnings: MintWarning[], strings?: WarningStrings): Min if (kinds.has('frozen')) return { label: s('frozen'), severity: 'critical' }; // "Melt only" is the other direction, so it cannot double as the label here. if (kinds.has('melt-disabled')) return { label: s('noWithdrawals'), severity: 'critical' }; + // An LNURL mint advertising a zero withdraw ceiling is the same statement to a + // reader as a Cashu mint with melting off, so it earns the same two words. + if (kinds.has('lnurl-withdrawals-disabled')) { + return { label: s('noWithdrawals'), severity: 'critical' }; + } if (kinds.has('melt-only')) return { label: s('meltOnly'), severity: 'warning' }; + if (kinds.has('lnurl-no-funding')) return { label: s('noFunding'), severity: 'warning' }; return null; }