Files
CashuMints.space/shared/src/warnings.ts
T
michilis 6f17b572b1 Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
2026-08-21 02:10:48 +02:00

552 lines
22 KiB
TypeScript

/**
* The states where a mint cannot do its basic job, derived in one place.
*
* A mint page, a mint card and the live-status island must never disagree about
* whether a mint can take deposits or pay them out, so all three call this.
*
* Everything here is derived from what the API already returns: the cached
* `/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';
/** Methods that move sats over Lightning. Anything else cannot stand in for them. */
const LIGHTNING_METHODS = new Set(['bolt11', 'bolt12']);
export interface MintCapabilities {
/** NUT-04 off: no new ecash can be minted, so deposits are impossible. */
mintDisabled: boolean;
/** NUT-05 off: ecash cannot be melted, so withdrawals are impossible. */
meltDisabled: boolean;
/** false when the mint never published the entry at all, which is not the same as off. */
mintPublished: boolean;
meltPublished: boolean;
}
/**
* Read the NUT-04 / NUT-05 switches out of a mint's `nuts` object.
*
* Both cdk-mintd and Nutshell publish `{ "4": { methods: [...], disabled: bool } }`,
* and every mint indexed so far uses that exact shape with plain numeric keys. Two
* things still vary:
*
* - the flag can be absent, which means enabled (Nutshell omits it on some builds);
* - the methods list can be empty while `disabled` is true (LekMint ships both).
*
* An entry that lists methods but none over Lightning is treated as off, because the
* question this answers is "can sats move in and out over Lightning", and an
* onchain-only or venmo-only method list answers no.
*
* A missing entry is NOT reported as disabled. Not publishing a NUT is a different
* fact from switching it off, and warning about the first would cry wolf.
*/
export function readCapabilities(nuts: unknown): MintCapabilities {
const mint = nutEntry(nuts, 4);
const melt = nutEntry(nuts, 5);
return {
mintDisabled: isDisabled(mint),
meltDisabled: isDisabled(melt),
mintPublished: mint !== null,
meltPublished: melt !== null,
};
}
function isDisabled(entry: Record<string, unknown> | null): boolean {
if (!entry) return false;
if (entry['disabled'] === true) return true;
const methods = entry['methods'];
if (Array.isArray(methods)) return !methods.some(isLightningMethod);
return false;
}
function isLightningMethod(method: unknown): boolean {
if (!method || typeof method !== 'object') return false;
const name = (method as Record<string, unknown>)['method'];
return typeof name === 'string' && LIGHTNING_METHODS.has(name.toLowerCase());
}
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
// 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;
severity: MintWarningSeverity;
/** The one short bold lead phrase, punctuation included. */
lead: string;
/** Plain sentences after the lead. Already formatted, never contains markup. */
body: string;
/** Short tail for the page's meta description, so search results carry the state. */
meta: string;
}
/**
* Where the words come from.
*
* The decisions in this file (which state a mint is in, which banner outranks which,
* what gets folded into the winner's last sentence) are language-free, and they have to
* stay that way: three locales agreeing about the facts and disagreeing about the
* severity order would be worse than not translating at all. So the logic stays here
* and only the sentences are looked up, by key, through this.
*
* Keys are the catalog's own, minus the `mint.warnings.` prefix: `gone.lead`,
* `meltOnly.body.online`, `dayCount` with an `n`. Left unset, `WARNING_COPY_EN` answers
* and the output is exactly what it has always been, which is what the API's
* fixture tests assert against.
*/
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;
/**
* Precomputed switches, for a caller that already read them (a card built from the
* list payload, which carries no info). Wins over `info` when both are given.
*/
capabilities?: Pick<MintCapabilities, 'mintDisabled' | 'meltDisabled'> | null;
/** Only used to date "never answered a single check". */
first_seen?: number;
}
export interface MintWarningOptions {
/** Unix seconds. Injected so the day counts are testable. */
now?: number;
formatDate?: (unix: number) => string;
formatMonth?: (unix: number) => string;
/** The locale's sentences. Defaults to English, see `WarningStrings`. */
strings?: WarningStrings;
}
/**
* The English warning copy, and the only place it is written down.
*
* `web/src/i18n/en.json` carries the same keys under `mint.warnings.`, and
* `web/scripts/check-i18n.mjs` diffs the two on every build: this file and that
* catalog disagreeing about what "withdrawals disabled" says is exactly the kind of
* drift nobody notices until a reader is looking at two different warnings on two
* pages about the same mint.
*/
export const WARNING_COPY_EN: Record<string, string> = {
'dayCount.one': '{n} day',
'dayCount.other': '{n} days',
'gone.lead': 'Likely gone.',
'gone.body.since':
'This mint has been unreachable since {date} ({days}). Treat funds held there as at risk, and do not send new funds. Details below show its last known state.',
'gone.body.neverDated':
'This mint has not answered a single check since we started watching it in {month}. Treat funds held there as at risk, and do not send new funds. Anything below came from the Nostr announcement, not from the mint.',
'gone.body.never':
'This mint has not answered a single check since we started watching it. Treat funds held there as at risk, and do not send new funds. Anything below came from the Nostr announcement, not from the mint.',
'gone.meta.since': 'Offline since {date}',
'gone.meta.never': 'Never reachable since it was discovered',
'meltDisabled.lead': 'Withdrawals disabled.',
'meltDisabled.body.online':
'This mint has Lightning melting (NUT-05) turned off: you can put sats in, but you cannot withdraw them via Lightning. Do not deposit until melting is back.',
'meltDisabled.body.offline':
'This mint had Lightning melting (NUT-05) turned off: you can put sats in, but you cannot withdraw them via Lightning. Do not deposit until melting is back.',
'meltDisabled.meta.online': 'Withdrawals currently disabled',
'meltDisabled.meta.offline': 'Withdrawals were disabled when last seen',
'frozen.lead': 'Mint frozen.',
'frozen.body.online': 'Both minting and melting are disabled. No sats can move in or out via Lightning.',
'frozen.body.offline': 'Both minting and melting were disabled. No sats can move in or out via Lightning.',
'frozen.meta.online': 'Currently frozen, nothing moves in or out',
'frozen.meta.offline': 'Frozen when last seen',
'offlineLong.lead': 'Offline for {days}, since {date}.',
'offlineLong.body': 'The mint may be gone. Everything below is its last known state, and reviews still work.',
'offlineLong.meta': 'Offline since {date}',
'meltOnly.lead': 'Melt only.',
'meltOnly.body.online':
'New minting (NUT-04) is disabled: you cannot deposit, but existing ecash can still be withdrawn. This often means a mint is winding down, so if you hold its ecash, consider melting out.',
'meltOnly.body.offline':
'New minting (NUT-04) was disabled: you cannot deposit, but existing ecash can still be withdrawn. This often means a mint is winding down, so if you hold its ecash, consider melting out.',
'meltOnly.meta.online': 'Currently melt only',
'meltOnly.meta.offline': 'Melt only when last seen',
'offline.lead': 'Offline since {date}.',
'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}).',
};
/**
* The default resolver: English, with the one plural this file needs.
*
* English, Spanish and Dutch all split on `n === 1`, so the rule here is that split and
* nothing more. A caller in a language that needs more categories passes its own
* `strings`, which is backed by `Intl.PluralRules` and gets it right for every language
* at once. Nothing in this file has to know that.
*/
const englishStrings: WarningStrings = (key, vars) => {
const n = typeof vars?.['n'] === 'number' ? (vars['n'] as number) : undefined;
const resolved =
n !== undefined
? (WARNING_COPY_EN[`${key}.${n === 1 ? 'one' : 'other'}`] ?? WARNING_COPY_EN[key])
: WARNING_COPY_EN[key];
if (resolved === undefined) return key;
if (!vars) return resolved;
return resolved.replace(/\{(\w+)\}/g, (whole, name: string) => {
const value = vars[name];
return value === undefined ? whole : String(value);
});
};
/** 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' });
const defaultMonth = (unix: number): string =>
new Date(unix * 1000).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' });
/**
* Every warning that applies, most severe first.
*
* Pages show only `[0]`: a stack of banners is a wall nobody reads, and the secondary
* conditions are folded into the winner's last sentence instead. The rest of the list
* is still returned because a card needs to know a mint is frozen even when its
* offline banner outranks that fact.
*/
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;
const month = options.formatMonth ?? defaultMonth;
const s = options.strings ?? englishStrings;
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;
/*
* Cached flags describe the last configuration seen, not the current one, and the
* copy has to say so. English did that by swapping one verb ("has" for "had"); most
* languages cannot, so the tense is a whole sentence and each state carries a
* `.online` and an `.offline` variant. The key is picked here, the grammar lives in
* the catalogs where a translator can see all of it at once.
*/
const tense = offline ? 'offline' : 'online';
const dayCount = days === null ? s('dayCount', { n: 0 }) : s('dayCount', { n: days });
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 (meltDisabled && !mintDisabled) {
out.push({
kind: 'melt-disabled',
severity: 'critical',
lead: s('meltDisabled.lead'),
body: s(`meltDisabled.body.${tense}`),
meta: s(`meltDisabled.meta.${tense}`),
});
}
if (meltDisabled && mintDisabled) {
out.push({
kind: 'frozen',
severity: 'critical',
lead: s('frozen.lead'),
body: s(`frozen.body.${tense}`),
meta: s(`frozen.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 (mintDisabled && !meltDisabled) {
out.push({
kind: 'melt-only',
severity: 'warning',
lead: s('meltOnly.lead'),
body: s(`meltOnly.body.${tense}`),
meta: s(`meltOnly.meta.${tense}`),
});
}
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 = collapsed(primary.kind, { mintDisabled, meltDisabled, offline, since, dayCount, date, s });
if (extra) primary.body += ` ${extra}`;
}
return out;
}
/**
* 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
* than either fact alone, and the page has to tell it.
*/
const CAPABILITY_KINDS = new Set<MintWarningKind>(['melt-disabled', 'frozen', 'melt-only']);
function collapsed(
kind: MintWarningKind,
ctx: {
mintDisabled: boolean;
meltDisabled: boolean;
offline: boolean;
since: number | null;
dayCount: string;
date: (unix: number) => string;
s: WarningStrings;
},
): string | null {
if (kind === 'gone' || kind === 'offline-long') {
if (ctx.meltDisabled) return ctx.s('also.meltDisabled');
if (ctx.mintDisabled) return ctx.s('also.mintDisabled');
return 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. Only the
// capability banners need this; an offline banner has already said it.
if (CAPABILITY_KINDS.has(kind) && ctx.offline && ctx.since !== null) {
return ctx.s('also.offline', { date: ctx.date(ctx.since), days: ctx.dayCount });
}
return null;
}
/** The winner, or null for a healthy mint. */
export function primaryWarning(
mint: MintWarningInput,
options: MintWarningOptions = {},
): MintWarning | null {
return getMintWarnings(mint, options)[0] ?? null;
}
/**
* The chip a mint card carries next to its status.
*
* Cards say what is broken in two words and stop there; the banner on the mint page
* does the explaining. Offline already has its own card styling, so only the
* capability states produce a chip.
*/
export interface MintChip {
label: string;
severity: MintWarningSeverity;
}
/**
* The chip's three labels, in English. Keyed to match `mint.chip.*` in the catalogs.
*
* Separate from `WARNING_COPY_EN` because these are not warning sentences: they are two
* words on a card, and a translator sizing them has a different job.
*/
export const CHIP_COPY_EN: Record<string, string> = {
frozen: 'Frozen',
noWithdrawals: 'No withdrawals',
meltOnly: 'Melt only',
};
export function mintChip(warnings: MintWarning[], strings?: WarningStrings): MintChip | null {
const s = strings ?? ((key: string) => CHIP_COPY_EN[key] ?? key);
const kinds = new Set(warnings.map((w) => w.kind));
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' };
if (kinds.has('melt-only')) return { label: s('meltOnly'), severity: 'warning' };
return null;
}
/** Sort key, exported so a caller can assert the order rather than assume it. */
export function warningRank(kind: MintWarningKind): number {
return ORDER.indexOf(kind);
}