Dev #4

Merged
Michilis merged 4 commits from dev into main 2026-08-22 04:00:47 +00:00
8 changed files with 1553 additions and 14 deletions
Showing only changes of commit c97b44018d - Show all commits
+198
View File
@@ -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;
}
+2
View File
@@ -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';
+208
View File
@@ -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]}` : ''}`;
}
+714
View File
@@ -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;
}
+64
View File
@@ -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.
*
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}