For about a year the production RELAYS list did not include the relay carrying
the kind 38000/38172 archive. Every backfill read about thirty events, wrote them
faithfully, reported ok=true, and the nightly build republished an index of eight
mints. Nothing measured the difference between "the cycle completed" and "the
cycle read anything", so nothing went red.
Three signals now do:
- Per-relay attribution. queryRelays() replaces pool.querySync(), which merges
every relay into one deduplicated array and throws away who sent what. It
keeps one subscription per relay over the pool's existing sockets and shares
a single alreadyHaveEvent across them, so an event five relays carry is still
verified once; receivedEvent fires before that check, which is what makes the
per-relay count mean "what this relay contributed". The deadline moved out of
each Subscription's own EOSE timer so `eose` means a frame arrived rather than
something timed out.
- A WARN naming any relay that will not connect, on every cycle, and any relay
that connected and sent nothing, on backfills only. An incremental cycle is
supposed to come back empty.
- BACKFILL_MIN_EVENTS, default 200. Under it, ERROR discovery starvation
suspected and a flag health reports as discovery_starved, forcing 503. Sticky
across incremental cycles so an hourly cycle finding four events cannot clear
what a backfill diagnosed; stored in the database so a restart cannot either.
A fresh database is starved until its first backfill lands. That is intended: it
holds the build's health gate rather than publishing a site made from nothing.
Verified against the live relay set — 1528 events, five relays connected, EOSE on
all five, health 200 — and against an unreachable list, which produces the two
WARN lines, the ERROR, and 503.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
337 lines
13 KiB
TypeScript
337 lines
13 KiB
TypeScript
/** Shapes returned by the API. The web app builds against these. */
|
|
|
|
/**
|
|
* 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' | 'lnurl' | (string & {});
|
|
|
|
/** One item of `GET /api/mints`. */
|
|
export interface MintListItem {
|
|
url: string;
|
|
host: string;
|
|
name: string | null;
|
|
icon: string | null;
|
|
/** 'cashu', 'fedimint' or 'lnurl'. Rows written before the column existed read as 'cashu'. */
|
|
type: MintType;
|
|
status: MintStatus;
|
|
last_online: number | null;
|
|
review_count: number;
|
|
rating_avg: number | null;
|
|
score: number;
|
|
last_review_at: number | null;
|
|
version: string | null;
|
|
}
|
|
|
|
export interface ProbeSample {
|
|
ts: number;
|
|
ok: 0 | 1;
|
|
latency_ms: number | null;
|
|
}
|
|
|
|
export type RatingDistribution = Record<'1' | '2' | '3' | '4' | '5', number>;
|
|
|
|
/**
|
|
* `GET /api/mints/:host`.
|
|
*
|
|
* 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>,
|
|
Partial<LnurlFields> {
|
|
description: string | null;
|
|
pubkey: string | null;
|
|
info: MintInfo | null;
|
|
nuts: string[];
|
|
first_seen: number;
|
|
last_probe: number | null;
|
|
updated_at: number;
|
|
rating_distribution: RatingDistribution;
|
|
/** Reviews in the last 90 days, for the verdict strip's activity cell. */
|
|
reviews_90d: number;
|
|
uptime_30d: number | null;
|
|
probes_recent: ProbeSample[];
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/**
|
|
* 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`.
|
|
*
|
|
* 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;
|
|
mints_offline: number;
|
|
mints_degraded: number;
|
|
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;
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* How one configured relay answered during the last discovery cycle.
|
|
*
|
|
* The three facts are deliberately separate, because the failure this exists to catch
|
|
* had all three looking different from each other: the relays in `RELAYS` connected
|
|
* fine, reached EOSE fine, and simply did not carry the archive, so `events` was the
|
|
* only field that would have said anything. A relay that is down and a relay that is
|
|
* up and empty are different problems with different fixes.
|
|
*/
|
|
export interface RelayHealth {
|
|
url: string;
|
|
/** A socket was opened to it. false means the address is wrong or the relay is down. */
|
|
connected: boolean;
|
|
/**
|
|
* Events it sent, counted before cross-relay deduplication — so this is what *this*
|
|
* relay contributed, not what was new because of it. Zero on a connected relay is
|
|
* the interesting number.
|
|
*/
|
|
events: number;
|
|
/** Every query it was asked ended in a real EOSE rather than in a timeout. */
|
|
eose: boolean;
|
|
}
|
|
|
|
/** `GET /api/health`. */
|
|
export interface Health {
|
|
status: 'ok' | 'degraded';
|
|
uptime_s: number;
|
|
last_probe_at: number | null;
|
|
last_discovery_at: number | null;
|
|
mints_tracked: number;
|
|
updated_at: number;
|
|
/**
|
|
* Per-relay outcome of the last discovery cycle. Empty until one has run — including
|
|
* on a fresh database, which is why a brand new deployment reports degraded until its
|
|
* first backfill finishes.
|
|
*/
|
|
discovery_relays: RelayHealth[];
|
|
/** Unique events the last discovery cycle received. null before the first one. */
|
|
last_discovery_events: number | null;
|
|
/** Which kind of cycle those numbers describe. */
|
|
last_discovery_mode: 'backfill' | 'incremental' | null;
|
|
/**
|
|
* The last backfill came back under `backfill_min_events`, or none has run yet.
|
|
*
|
|
* This is the flag that would have caught a year of ~31-event backfills against a
|
|
* relay list missing the archive. It forces `status` to degraded, and /api/health to
|
|
* 503, which is what the build gate and the site's own health checks read.
|
|
*/
|
|
discovery_starved: boolean;
|
|
/** `BACKFILL_MIN_EVENTS`, echoed so a reader of this payload can see the threshold. */
|
|
backfill_min_events: number;
|
|
}
|
|
|
|
/**
|
|
* A mint's `/v1/info` response (NUT-06). Every field is optional: mints in the wild
|
|
* omit most of it, and the site must render whatever is there.
|
|
*/
|
|
export interface MintInfo {
|
|
name?: string;
|
|
pubkey?: string;
|
|
version?: string;
|
|
description?: string;
|
|
description_long?: string;
|
|
contact?: MintContact[];
|
|
motd?: string;
|
|
icon_url?: string;
|
|
urls?: string[];
|
|
time?: number;
|
|
tos_url?: string;
|
|
nuts?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface MintContact {
|
|
method?: string;
|
|
info?: string;
|
|
}
|
|
|
|
/** A review as rendered in the browser, parsed from a Nostr event. */
|
|
export interface Review {
|
|
id: string;
|
|
pubkey: string;
|
|
created_at: number;
|
|
mint_url: string;
|
|
/** null when the event carries no parseable rating. Never defaulted to 5. */
|
|
rating: number | null;
|
|
content: string;
|
|
}
|
|
|
|
/** Resolved kind-0 metadata for a reviewer. */
|
|
export interface Profile {
|
|
pubkey: string;
|
|
name?: string;
|
|
display_name?: string;
|
|
picture?: string;
|
|
nip05?: string;
|
|
/** false when the kind-0 lookup returned nothing, used for the provenance note. */
|
|
found: boolean;
|
|
}
|