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)}`;
}