Expand ecash explorer capabilities

Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
michilis
2026-08-21 02:10:48 +02:00
parent aa1771ea20
commit 6f17b572b1
80 changed files with 7580 additions and 704 deletions
+302
View File
@@ -0,0 +1,302 @@
/**
* Fedimint federations: what a NIP-87 kind 38173 announcement actually contains, and
* the handful of values derived from it that the API, the pages and the islands all
* have to agree on.
*
* Everything here was written against real events pulled off the relay pool this site
* already reads, not against a reading of the spec, for the same reason NOTES.md gives
* for the Cashu side: what matters is what publishers actually put on relays. Three
* places where the two differ, all resolved in favour of the wire:
*
* - the `n` tag is `bitcoin`, not `mainnet`. `normalizeNetwork` maps it.
* - `modules` are protocol short names (`ln`, `mint`, `wallet`, `lnv2`, `meta`,
* `stability_pool`), not the prose words. `MODULE_ALIASES` maps them.
* - `content` carries `{"federation_name": "..."}` rather than a kind-0 `name`.
* `parseFedimintAnnouncement` accepts either.
*
* A federation has no URL and no HTTP info endpoint, so its identity is the federation
* id from the `d` tag and nothing else. `fedimintKey` turns that into the synthetic
* primary key its row uses, and `fedimintSlug` into the routing slug.
*/
import {
sanitizeDisplayText, sanitizePictureUrl, tagValue, tagValues, type NostrEventLike,
} from './nostr.js';
/** Routing slugs are prefixed so a federation can never collide with a mint host. */
export const FEDIMINT_SLUG_PREFIX = 'fed-';
/** How much of the federation id the slug carries. The full id is in the payload. */
export const FEDIMINT_SLUG_CHARS = 16;
/** The scheme on the synthetic `mints.url` a federation row is keyed by. */
export const FEDIMINT_KEY_SCHEME = 'fedimint:';
/** A federation id is a 32 byte hash, written as 64 hex characters. */
export function isFederationId(value: unknown): value is string {
return typeof value === 'string' && /^[0-9a-f]{64}$/i.test(value);
}
/** `fed-` plus the first 16 characters of the federation id. */
export function fedimintSlug(federationId: string): string {
return FEDIMINT_SLUG_PREFIX + federationId.toLowerCase().slice(0, FEDIMINT_SLUG_CHARS);
}
/**
* The primary key a federation row uses in place of a mint URL.
*
* `mints.url` is the table's primary key and a federation has no URL to put there. A
* `fedimint:` scheme keeps the column non-null and unique, is obviously not something
* to fetch, and is what a review row points at.
*/
export function fedimintKey(federationId: string): string {
return FEDIMINT_KEY_SCHEME + federationId.toLowerCase();
}
/** The federation id inside a `fedimint:` key, or null if that is not what this is. */
export function federationIdFromKey(key: string): string | null {
if (!key.startsWith(FEDIMINT_KEY_SCHEME)) return null;
const id = key.slice(FEDIMINT_KEY_SCHEME.length);
return isFederationId(id) ? id.toLowerCase() : null;
}
/* ---------- invite codes ---------- */
/**
* A fedimint invite code: bech32m holding the federation id and the guardian addresses
* a wallet needs to join. Long, opaque, and the only thing a reader actually copies off
* a federation page.
*
* Every real code starts `fed11`, which is two things and not a typo: `fed1` is the
* human-readable part, and the `1` after it is bech32's separator. A first attempt at
* this anchored on `fed1` followed by a bech32 data character and rejected every code
* on the network, because bech32's alphabet deliberately excludes `1`.
*
* Past the prefix it is loose on purpose. The code is handed to a wallet verbatim and
* nothing in this codebase decodes it, so the check only has to keep junk out of a copy
* button — and being stricter than the wallets that consume it would mean dropping a
* federation from the site over a character this code has no opinion about.
*/
export function isInviteCode(value: unknown): value is string {
return typeof value === 'string' && /^fed1[a-z0-9]{20,}$/i.test(value);
}
/** Every usable invite code in a list, lowercased, in order, without repeats. */
export function cleanInviteCodes(values: readonly string[]): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const raw of values) {
const code = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
if (!isInviteCode(code) || seen.has(code)) continue;
seen.add(code);
out.push(code);
}
return out;
}
/* ---------- modules ---------- */
/**
* The three modules a federation page gives a named row of its own, and every short
* name that satisfies each.
*
* Versioned modules are aliases rather than separate rows: a federation running `lnv2`
* and no `ln` can still do Lightning, and a row reading "Lightning — not supported"
* beside a `lnv2` chip would be false. The version still shows, as its own chip in the
* list underneath.
*/
export const MODULE_ALIASES: Record<string, readonly string[]> = {
lightning: ['ln', 'lnv2', 'lightning'],
mint: ['mint', 'mintv2'],
wallet: ['wallet', 'walletv2'],
};
/** Order of the named rows, highest interest first. Mirrors HIGHLIGHT_NUTS. */
export const HIGHLIGHT_MODULES = ['lightning', 'mint', 'wallet'] as const;
export type HighlightModule = (typeof HIGHLIGHT_MODULES)[number];
/**
* Plain-language names for the module short names seen in the wild, and the English
* source of truth for them. The catalogs carry a translation per key under
* `fedimint.module.`; a module neither knows renders as its own short name, which is
* still a true label.
*/
export const MODULE_NAMES_EN: Record<string, string> = {
lightning: 'Lightning',
mint: 'Ecash mint',
wallet: 'On-chain wallet',
meta: 'Metadata',
stability_pool: 'Stability pool',
multi_sig_stability_pool: 'Stability pool (multisig)',
'fedi-social': 'Social recovery',
unknown: 'Unknown module',
};
/** Which highlight row a module short name belongs to, or null for the chip list. */
export function highlightModuleFor(module: string): HighlightModule | null {
const name = module.toLowerCase();
for (const key of HIGHLIGHT_MODULES) {
if (MODULE_ALIASES[key]?.includes(name)) return key;
}
return null;
}
/** True when this federation runs anything satisfying one of the named rows. */
export function hasModule(modules: readonly string[], key: HighlightModule): boolean {
const aliases = MODULE_ALIASES[key] ?? [];
return modules.some((module) => aliases.includes(module.toLowerCase()));
}
/**
* Split a `modules` tag: `"ln,mint,wallet,lnv2,meta"`.
*
* Comma separated in every event seen, but whitespace is tolerated because a publisher
* writing `"ln, mint"` meant the same thing.
*/
export function parseModules(value: string | null | undefined): string[] {
if (!value) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const part of value.split(/[,\s]+/)) {
const module = part.trim().toLowerCase();
if (!module || module.length > 40 || seen.has(module)) continue;
if (!/^[a-z0-9_-]+$/.test(module)) continue;
seen.add(module);
out.push(module);
}
return out;
}
/* ---------- network ---------- */
/** The networks this site has a name for. Anything else renders as published. */
export const KNOWN_NETWORKS = ['mainnet', 'testnet', 'testnet4', 'signet', 'regtest'] as const;
/**
* Normalize an `n` tag.
*
* NIP-87 describes the value as mainnet/testnet/signet/regtest; every real announcement
* on the network writes `bitcoin` for mainnet, which is what fedimint's own config
* calls it. Both are accepted and both come out as `mainnet`, so one federation cannot
* appear on two networks depending on which word its operator used.
*/
export function normalizeNetwork(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;
}
/** True for anything that is not real bitcoin. Worth a badge of its own on a page. */
export function isTestNetwork(network: string | null): boolean {
return network !== null && network !== 'mainnet';
}
/* ---------- the announcement ---------- */
export interface FedimintAnnouncement {
/** The `d` tag: the federation id, lowercase hex. */
federationId: string;
/** Every `u` tag that is a usable invite code. At least one, or this is not valid. */
inviteCodes: string[];
/** The `modules` tag, split. */
modules: string[];
/** The `n` tag, normalized. */
network: string | null;
/** From `content`, which is kind-0-shaped metadata or fedimint's own. */
name: string | null;
picture: string | null;
about: string | null;
/** Who published it. Needed for the `a` tag a review points back with. */
announcerPubkey: string;
announcedAt: number;
}
/**
* Read a kind 38173 event, or return null if it is not one this site can use.
*
* An announcement with no valid `d` and no invite code is not something a reader can
* act on: there is nothing to join and nothing to key the row by. Everything else is
* optional and simply renders as absent.
*/
export function parseFedimintAnnouncement(event: NostrEventLike): FedimintAnnouncement | null {
const d = tagValue(event.tags, 'd');
if (!isFederationId(d)) return null;
const inviteCodes = cleanInviteCodes(tagValues(event.tags, 'u'));
if (inviteCodes.length === 0) return null;
const meta = parseAnnouncementMetadata(event.content);
return {
federationId: d.toLowerCase(),
inviteCodes,
modules: parseModules(tagValue(event.tags, 'modules')),
network: normalizeNetwork(tagValue(event.tags, 'n')),
name: meta.name,
picture: meta.picture,
about: meta.about,
announcerPubkey: event.pubkey,
announcedAt: event.created_at,
};
}
export interface AnnouncementMetadata {
name: string | null;
picture: string | null;
about: string | null;
}
/**
* The `content` of an announcement, which NIP-87 describes as kind-0-style metadata.
*
* Every fedimint announcement seen writes `{"federation_name": "..."}` instead, so both
* spellings are read. Unparseable content degrades to no metadata rather than throwing:
* this is arbitrary text written by anyone with a relay connection.
*/
export function parseAnnouncementMetadata(content: string): AnnouncementMetadata {
const empty: AnnouncementMetadata = { 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', 'federation_name', 'display_name'), 64) ?? null,
picture: sanitizePictureUrl(first('picture', 'federation_icon_url', 'icon_url', 'image')) ?? null,
about: sanitizeDisplayText(first('about', 'description', 'federation_description'), 400) ?? null,
};
}
/**
* The federation id a slug carries, which is the first 16 characters of it.
*
* The list payload has no `federation_id` — that lives on the detail — so a card built
* from the list reads its identifier back out of the routing slug. Shortened is all a
* card has room for anyway, and the full id is on the page it links to.
*/
export function federationIdFromSlug(slug: string): string {
return slug.startsWith(FEDIMINT_SLUG_PREFIX) ? slug.slice(FEDIMINT_SLUG_PREFIX.length) : slug;
}
/**
* The short form of an invite code, for a row that has to fit one.
*
* More head than tail: the prefix is what tells a reader it is an invite code at all,
* and the tail is what tells two of the same federation's codes apart.
*/
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)}`;
}
+1
View File
@@ -4,3 +4,4 @@ export * from './normalize.js';
export * from './score.js';
export * from './nuts.js';
export * from './warnings.js';
export * from './fedimint.js';
+38 -1
View File
@@ -26,13 +26,50 @@ function isDisallowedHost(hostname: string): boolean {
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
if (hostname.endsWith('.onion')) return true;
if (hostname.endsWith('.local')) return true;
if (hostname === '::1' || hostname === '[::1]') return true;
if (PRIVATE_IPV4.test(hostname)) return true;
if (isPrivateIpv6(hostname)) return true;
// A bare label with no dot cannot be a public host.
if (!hostname.includes('.') && !hostname.includes(':')) return true;
return false;
}
/**
* IPv6 forms that are loopback, link-local (fe80::/10), unique-local (fc00::/7) or an
* IPv4-mapped address whose IPv4 part is private. The URL parser brackets an IPv6
* hostname, so both spellings are accepted.
*/
function isPrivateIpv6(hostname: string): boolean {
const bare =
hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
if (!bare.includes(':')) return false;
if (bare === '::' || bare === '::1') return true;
if (/^f[cd]/i.test(bare)) return true;
if (/^fe[89ab]/i.test(bare)) return true;
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(bare);
if (mapped?.[1]) return PRIVATE_IPV4.test(mapped[1]) || mapped[1].startsWith('127.');
return false;
}
/**
* May the indexer fetch this URL? http(s) only, and never a private or local host.
*
* For URLs a mint *publishes* rather than the URL it lives at — its `icon_url` above
* all. Those never pass through `normalizeMintUrl`, so without this check a mint's
* /v1/info could point the indexer at a cloud metadata endpoint or anything else on
* the API host's own network. Callers that follow redirects must re-check every hop.
*/
export function isFetchableUrl(value: URL | string): boolean {
let u: URL;
try {
u = typeof value === 'string' ? new URL(value) : value;
} catch {
return false;
}
if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
const hostname = u.hostname.toLowerCase();
return hostname !== '' && !isDisallowedHost(hostname);
}
/**
* Normalize a mint URL as found in a Nostr `u` tag or typed by a user.
* Returns null when the input is not a usable public mint URL.
+89 -7
View File
@@ -11,9 +11,48 @@ import type { Profile } from './types.js';
export const KIND_REVIEW = 38000;
/** NIP-87 Cashu mint announcement. */
export const KIND_MINT_ANNOUNCEMENT = 38172;
/** NIP-87 Fedimint federation announcement. */
export const KIND_FEDIMINT_ANNOUNCEMENT = 38173;
/** Profile metadata. */
export const KIND_PROFILE = 0;
/**
* The one place an ecosystem is tied to its announcement kind.
*
* Everything downstream reads this rather than testing for 38172 or 38173 itself: the
* indexer's subscription list, the review resolver, the `k` tag a published review
* carries, and the filter on /reviews. Adding a third ecosystem is an entry here plus
* a probe strategy and its pages — see the "Adding an ecosystem" section of README.
*
* `mints.type` holds these keys verbatim, so they are a stored value: never rename one
* without a data migration.
*/
export const ANNOUNCEMENT_KINDS = {
cashu: KIND_MINT_ANNOUNCEMENT,
fedimint: KIND_FEDIMINT_ANNOUNCEMENT,
} as const;
/**
* Ecosystems in display order. Cashu first: it is what this site was, and what most of
* its content still is.
*/
export const ECOSYSTEMS = Object.keys(ANNOUNCEMENT_KINDS) as Array<keyof typeof ANNOUNCEMENT_KINDS>;
/** The announcement kind for an ecosystem, or null for one this build does not know. */
export function announcementKind(type: string): number | null {
return (ANNOUNCEMENT_KINDS as Record<string, number>)[type] ?? null;
}
/** The ecosystem an announcement kind belongs to, or null. */
export function ecosystemForKind(kind: number | string | null): string | null {
const n = typeof kind === 'string' ? Number.parseInt(kind, 10) : kind;
if (n === null || !Number.isFinite(n)) return null;
for (const [type, value] of Object.entries(ANNOUNCEMENT_KINDS)) {
if (value === n) return type;
}
return null;
}
/**
* Union of the old site's read pool (utils/ndk.ts CASHU_RELAY_POOL) and its write pool
* (services/reviewPublisher.ts RELAY_URLS). The two lists differed, so reviews the old
@@ -180,6 +219,45 @@ export function isCashuMintReview(event: NostrEventLike): boolean {
return tagValue(event.tags, 'k') === String(KIND_MINT_ANNOUNCEMENT);
}
/** True when the event's `k` tag marks it as being about a Fedimint federation. */
export function isFedimintReview(event: NostrEventLike): boolean {
return tagValue(event.tags, 'k') === String(KIND_FEDIMINT_ANNOUNCEMENT);
}
/**
* The kind a review says it is about, from its `k` tag, as a number.
*
* null when the tag is missing or unparseable, which is the shape most reviews already
* on the network have: the old publisher wrote `k` but plenty of other clients do not.
* A review with no `k` is treated as Cashu by every resolver here, because that is the
* only ecosystem that existed when those events were written.
*/
export function reviewTargetKind(event: NostrEventLike): number | null {
const raw = tagValue(event.tags, 'k');
if (!raw) return null;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
}
/**
* Which ecosystem a review is about: its `k` tag, falling back to Cashu.
*
* The fallback is not a guess about the future, it is a fact about the past. Every
* kind 38000 written before Fedimint support existed is about a Cashu mint, and a great
* many of them carry no `k` at all (see NOTES.md). A review whose `k` names a kind this
* build does not know returns null and is skipped rather than filed under Cashu.
*/
export function reviewEcosystem(event: NostrEventLike): string | null {
const kind = reviewTargetKind(event);
if (kind === null) return 'cashu';
return ecosystemForKind(kind);
}
/** The `d` identifier a review points at, verbatim. Resolution is the caller's job. */
export function reviewTargetId(event: NostrEventLike): string | null {
return tagValue(event.tags, 'd');
}
/**
* Every spelling of a mint URL that may appear in a `u` tag.
*
@@ -210,8 +288,12 @@ export const MAX_PROFILE_NAME = 24;
* Written as a code point scan rather than a regex because the set is exactly the
* invisible ones: C0 and C1 controls, zero width joiners and the bidi overrides that
* let a name reorder the text around it.
*
* Exported because a Fedimint announcement's `content` carries a federation name
* written by the same kind of stranger, and `fedimint.ts` has no business owning a
* second copy of these rules.
*/
function sanitizeText(value: unknown, max: number): string | undefined {
export function sanitizeDisplayText(value: unknown, max: number): string | undefined {
if (typeof value !== 'string') return undefined;
let out = '';
@@ -238,7 +320,7 @@ function sanitizeText(value: unknown, max: number): string | undefined {
* been verified against a domain. Anything not shaped like an address is dropped.
*/
function sanitizeNip05(value: unknown, max: number): string | undefined {
const text = sanitizeText(value, max);
const text = sanitizeDisplayText(value, max);
if (!text || text.length > max) return undefined;
if (!/^[a-z0-9._+-]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(text)) return undefined;
// "_@domain" is NIP-05's root form and reads better as the bare domain.
@@ -249,7 +331,7 @@ function sanitizeNip05(value: unknown, max: number): string | undefined {
* Only http(s) picture URLs are accepted. A `data:` or `javascript:` picture from a
* relay has no business being written into an `img src`.
*/
function sanitizePicture(value: unknown): string | undefined {
export function sanitizePictureUrl(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const url = value.trim();
if (!/^https?:\/\/[^\s"'<>]+$/i.test(url) || url.length > 400) return undefined;
@@ -278,11 +360,11 @@ export function parseProfileContent(pubkey: string, content: string): Profile {
const profile: Profile = {
pubkey,
name: sanitizeText(meta['name'], MAX_PROFILE_NAME),
name: sanitizeDisplayText(meta['name'], MAX_PROFILE_NAME),
display_name:
sanitizeText(meta['display_name'], MAX_PROFILE_NAME)
?? sanitizeText(meta['displayName'], MAX_PROFILE_NAME),
picture: sanitizePicture(meta['picture']),
sanitizeDisplayText(meta['display_name'], MAX_PROFILE_NAME)
?? sanitizeDisplayText(meta['displayName'], MAX_PROFILE_NAME),
picture: sanitizePictureUrl(meta['picture']),
nip05: sanitizeNip05(meta['nip05'], 64),
found: true,
};
+38 -8
View File
@@ -55,18 +55,48 @@ export function bayesianScore(m: ScoreInput, priorMean: number, now: number): nu
return Math.round(score * 1000) / 1000;
}
/**
* Three tiers, and the middle one exists for federations.
*
* `announced` is a Fedimint status: nothing has ever confirmed the thing is running, so
* it cannot sit with the confirmed-online rows — but it is not evidence of being down
* either, so it must not sink to the bottom with the rows a check actually failed on.
* Not knowing belongs between knowing and knowing otherwise.
*
* A Cashu mint is never `announced`, so this is exactly the two-tier order it always
* had for them.
*/
function healthRank(status: string): number {
if (status === 'offline') return 2;
if (status === 'announced') return 1;
return 0;
}
/**
* Default sort: every online mint before every offline one, then by score descending.
* Offline mints must stay findable (people need to reach them to review them), they
* just never appear above a live mint.
*/
export function compareMints<T extends { status: string; score: number; review_count: number }>(
a: T,
b: T,
): number {
const aOff = a.status === 'offline' ? 1 : 0;
const bOff = b.status === 'offline' ? 1 : 0;
if (aOff !== bOff) return aOff - bOff;
export function compareMints<
T extends { status: string; score: number; review_count: number; host?: string },
>(a: T, b: T): number {
const aRank = healthRank(a.status);
const bRank = healthRank(b.status);
if (aRank !== bRank) return aRank - bRank;
if (b.score !== a.score) return b.score - a.score;
return b.review_count - a.review_count;
if (b.review_count !== a.review_count) return b.review_count - a.review_count;
/*
* A final tiebreak on the slug, so two indistinguishable rows still have an order.
*
* Without it the winner is whatever the database happened to return first, which is a
* different answer on SQLite and on Postgres — the same data served in two orders, and
* a nightly rebuild that reshuffles rows for no reason. It went unnoticed while ties
* were rare (two unreviewed mints); federations made it the common case, since every
* one that nobody has reviewed scores exactly the prior.
*
* Optional in the type because the ranking checks compare bare score objects that have
* no slug, and there is nothing to tiebreak in a two-element fixture.
*/
return (a.host ?? '').localeCompare(b.host ?? '');
}
+81 -4
View File
@@ -1,6 +1,24 @@
/** Shapes returned by the API. The web app builds against these. */
export type MintStatus = 'online' | 'degraded' | 'offline' | 'unknown';
/**
* Statuses a listed thing can be in.
*
* `announced` is the Fedimint addition, and it is deliberately not a synonym for
* `unknown`. `unknown` means "we have not checked yet"; `announced` means "there is no
* check we can run" — the federation exists on Nostr, nothing has confirmed it since,
* and nothing here will claim otherwise. A Cashu mint never carries it.
*/
export type MintStatus = 'online' | 'degraded' | 'offline' | 'unknown' | 'announced';
/**
* Which ecosystem a listing belongs to.
*
* Deliberately open rather than a closed union: `mints.type` is a stored TEXT column
* with no CHECK constraint, so an older build reading a database written by a newer one
* 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 & {});
/** One item of `GET /api/mints`. */
export interface MintListItem {
@@ -8,6 +26,8 @@ export interface MintListItem {
host: string;
name: string | null;
icon: string | null;
/** 'cashu' or 'fedimint'. Rows written before the column existed read as 'cashu'. */
type: MintType;
status: MintStatus;
last_online: number | null;
review_count: number;
@@ -25,8 +45,13 @@ export interface ProbeSample {
export type RatingDistribution = Record<'1' | '2' | '3' | '4' | '5', number>;
/** `GET /api/mints/:host`. */
export interface MintDetail extends MintListItem {
/**
* `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`.
*/
export interface MintDetail extends MintListItem, Partial<FedimintFields> {
description: string | null;
pubkey: string | null;
info: MintInfo | null;
@@ -41,7 +66,49 @@ export interface MintDetail extends MintListItem {
probes_recent: ProbeSample[];
}
/** `GET /api/stats`. */
/**
* What `ecosystem_json` holds for a Fedimint row, and what `GET /api/mints/:host`
* spreads across the detail payload for one.
*
* Flattened rather than nested so a Cashu payload is byte for byte what it always was:
* these keys are simply absent on one. A third ecosystem adds its own interface here
* and its own optional keys below; nothing existing has to move.
*/
export interface FedimintFields {
/** The `d` tag of the announcement: 64 hex characters. `host` is derived from it. */
federation_id: string;
/** Every `u` tag that was a usable invite code, in announcement order. */
invite_codes: string[];
/** The `modules` tag, split: `["ln", "mint", "wallet", "lnv2", "meta"]`. */
modules: string[];
/** The `n` tag, normalized — `bitcoin` and `mainnet` both arrive as `mainnet`. */
network: string | null;
/** `created_at` of the newest announcement seen for this federation. */
announced_at: number | null;
/** Who published that announcement. The `a` tag of a review points back at them. */
announcer_pubkey: string | null;
/**
* Who confirmed the status, when anything did: `"fedimint.observer"` today.
*
* null means nothing has, and then `status` is `announced` and never `online`. The
* page prints this next to the status, because "someone else says it is up" is a
* different claim from "we checked".
*/
status_source: string | null;
}
/** `GET /api/mints/:host` for a Fedimint federation: the detail plus its own fields. */
export type FedimintDetail = MintDetail & FedimintFields;
/**
* `GET /api/stats`.
*
* The four `mints_*` fields count Cashu mints and only Cashu mints, exactly as they did
* before federations were indexed: they are read by the pulse ticker, the /mints
* description and the home page, and a number that silently changed meaning would be
* worse than a new field. `cashu_total` is the same number under a name that says so,
* and every ecosystem added later gets its own `*_total` beside `fedimint_total`.
*/
export interface Stats {
mints_total: number;
mints_online: number;
@@ -50,6 +117,16 @@ export interface Stats {
reviews_total: number;
last_review_at: number | null;
updated_at: number;
/** Same value as `mints_total`, named for the ecosystem it counts. */
cashu_total: number;
fedimint_total: number;
/** Federations a real check confirmed were up. Never inferred from an announcement. */
fedimint_online: number;
fedimint_offline: number;
/** Federations announced on Nostr that no check has ever confirmed. */
fedimint_announced: number;
/** Reviews of federations (`k` = 38173), included in `reviews_total`. */
fedimint_reviews: number;
}
/** `GET /api/health`. */
+133 -1
View File
@@ -8,6 +8,12 @@
* `/v1/info` (NUT-04 and NUT-05), plus `status` and `last_online`. The cache is the
* point: an offline mint still has its last known configuration, and the copy says
* "had" rather than "has" so nobody reads a stale flag as a live one.
*
* Two branches, chosen by `type` and never mixed. Cashu gets the six states below,
* every one of them read out of a `/v1/info` this site fetched itself. Fedimint gets
* two, because a federation publishes no capability list to read: a real check said the
* guardians are down, or nothing has ever confirmed the federation at all. There is no
* Fedimint equivalent of "melt only" and this file does not invent one.
*/
import type { MintInfo, MintStatus } from './types.js';
import { nutEntry } from './nuts.js';
@@ -73,12 +79,16 @@ function isLightningMethod(method: unknown): boolean {
export type MintWarningSeverity = 'critical' | 'warning';
export type MintWarningKind =
// Cashu
| 'gone' // offline 30 days or more, or never reached at all
| 'melt-disabled' // sats can get in but not out
| 'frozen' // neither in nor out
| 'offline-long' // offline 7 to 30 days
| 'melt-only' // minting off, melting still works
| 'offline'; // offline under 7 days
| '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
export interface MintWarning {
kind: MintWarningKind;
@@ -108,8 +118,18 @@ export interface MintWarning {
export type WarningStrings = (key: string, vars?: Record<string, string | number>) => string;
export interface MintWarningInput {
/**
* Which ecosystem this is. Absent reads as `cashu`, so every existing caller keeps
* exactly the behaviour it had; a `fedimint` row takes the separate branch below,
* which shares the severity machinery and none of the NUT reasoning.
*/
type?: string;
status: MintStatus | string;
last_online: number | null;
/** Fedimint: `created_at` of the newest announcement, for "announced {date}". */
announced_at?: number | null;
/** Fedimint: used only to decide whether reviews are the page's one sign of life. */
last_review_at?: number | null;
/** Cached `/v1/info`, as `GET /api/mints/:host` returns it. */
info?: MintInfo | null;
/**
@@ -183,6 +203,21 @@ export const WARNING_COPY_EN: Record<string, string> = {
'offline.body': 'Details were last confirmed then. You can still write a review.',
'offline.meta': 'Offline since {date}',
'fedimintOffline.lead': 'Reported offline.',
'fedimintOffline.body.since':
'The last check that reached this federation was on {date} ({days} ago). Its guardians have not answered since. Treat funds held there as at risk, and do not join it with new funds.',
'fedimintOffline.body.never':
'No check of this federation has ever reached it, and the most recent one failed. Treat funds held there as at risk, and do not join it with new funds.',
'fedimintOffline.meta.since': 'Reported offline since {date}',
'fedimintOffline.meta.never': 'Reported offline, never once reached',
'neverConfirmed.lead': 'Announced, never confirmed.',
'neverConfirmed.body.reviewed':
'This federation was announced on Nostr on {date}, {days} ago, and nothing has confirmed since then that it is running. The reviews below are the only sign of life on this page; everything else came from the announcement.',
'neverConfirmed.body.quiet':
'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',
'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}).',
@@ -213,8 +248,23 @@ 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',
// 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',
];
/**
* How stale an unconfirmed announcement has to be before it is worth saying so.
*
* A federation announced last week that nothing has checked is not news: nothing has
* had the chance. A month of silence is.
*/
export const NEVER_CONFIRMED_AFTER_S = 30 * 24 * 60 * 60;
/** Reviews inside this window count as the page having a pulse. Matches `reviews_90d`. */
const RECENT_REVIEW_S = 90 * 24 * 60 * 60;
const defaultDate = (unix: number): string =>
new Date(unix * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
@@ -232,6 +282,88 @@ const defaultMonth = (unix: number): string =>
export function getMintWarnings(
mint: MintWarningInput,
options: MintWarningOptions = {},
): MintWarning[] {
if (mint.type === 'fedimint') return fedimintWarnings(mint, options);
return cashuWarnings(mint, options);
}
/**
* What can be said about a federation, which is much less than about a mint.
*
* A federation publishes no capability list, so there is no NUT-04/NUT-05 equivalent to
* read and nothing here invents one: the melt-only, withdrawals-disabled and frozen
* banners have no Fedimint counterpart and never will until federations publish
* something a check can actually read. That leaves two honest things to say.
*
* The first is that a check reported the guardians down — a real result, from
* `status_source`, never inferred from the absence of an announcement. The second is
* the soft one: this federation has been sitting on Nostr for a month or more and
* nothing has ever confirmed it is running, so everything on its page came from a
* stranger's announcement rather than from the federation itself.
*
* Note what is deliberately missing: a federation that no check reached is `announced`,
* not `offline`, and gets the soft banner rather than the "likely gone" one. Not
* knowing is not the same as knowing it is dead.
*/
function fedimintWarnings(
mint: MintWarningInput,
options: MintWarningOptions = {},
): MintWarning[] {
const now = options.now ?? Math.floor(Date.now() / 1000);
const date = options.formatDate ?? defaultDate;
const s = options.strings ?? englishStrings;
const days = (since: number): string =>
s('dayCount', { n: Math.max(0, Math.floor((now - since) / 86400)) });
if (mint.status === 'offline') {
const since = mint.last_online;
return [
{
kind: 'fedimint-offline',
severity: 'critical',
lead: s('fedimintOffline.lead'),
body:
since !== null
? s('fedimintOffline.body.since', { date: date(since), days: days(since) })
: s('fedimintOffline.body.never'),
meta:
since !== null
? s('fedimintOffline.meta.since', { date: date(since) })
: s('fedimintOffline.meta.never'),
},
];
}
// Anything a check has confirmed up needs no banner, and a fresh announcement no
// check has got to yet has not earned one either.
if (mint.status !== 'announced') return [];
const announced = mint.announced_at ?? mint.first_seen ?? null;
if (announced === null || now - announced < NEVER_CONFIRMED_AFTER_S) return [];
const reviewed =
mint.last_review_at !== null &&
mint.last_review_at !== undefined &&
now - mint.last_review_at <= RECENT_REVIEW_S;
return [
{
kind: 'never-confirmed',
severity: 'warning',
lead: s('neverConfirmed.lead'),
body: s(`neverConfirmed.body.${reviewed ? 'reviewed' : 'quiet'}`, {
date: date(announced),
days: days(announced),
}),
meta: s('neverConfirmed.meta', { date: date(announced) }),
},
];
}
function cashuWarnings(
mint: MintWarningInput,
options: MintWarningOptions = {},
): MintWarning[] {
const now = options.now ?? Math.floor(Date.now() / 1000);
const date = options.formatDate ?? defaultDate;