/** 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, Partial { 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: ,` 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; } 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; }