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
+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;