Add shared LNURL types, indexing helpers, and warnings.
Introduce lnurl as a first-class mint type with probe/announcement fields and shared helpers the API and web can both rely on. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<InviteCodePart>`: 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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<IndexType, string> = {
|
||||
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]}` : ''}`;
|
||||
}
|
||||
@@ -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<string, readonly string[]> = {
|
||||
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<string, string> = {
|
||||
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<string>();
|
||||
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<HighlightFeature, FeatureState> {
|
||||
const out = {} as Record<HighlightFeature, FeatureState>;
|
||||
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<string, unknown>;
|
||||
|
||||
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: <base>,<ppm>`, 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<string, unknown>;
|
||||
|
||||
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: <base_msat>,<ppm>` 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<string, unknown>)['info'];
|
||||
if (!info || typeof info !== 'object') return null;
|
||||
|
||||
const record = info as Record<string, unknown>;
|
||||
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<typeof normalizeMintUrl> = 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<string, unknown>;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return empty;
|
||||
meta = parsed as Record<string, unknown>;
|
||||
} 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;
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
+112
-5
@@ -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<FedimintFields> {
|
||||
export interface MintDetail
|
||||
extends MintListItem,
|
||||
Partial<FedimintFields>,
|
||||
Partial<LnurlFields> {
|
||||
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: <base>,<ppm>` 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`. */
|
||||
|
||||
+240
-9
@@ -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<MintCapabilities, 'mintDisabled' | 'meltDisabled'> | 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<string, string> = {
|
||||
'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<MintWarningInput, 'status' | 'last_online'>,
|
||||
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<string, string> = {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user