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
+165 -27
View File
@@ -1,23 +1,33 @@
import { normalizeMintUrl } from '@cashumints/shared';
import {
fedimintKey, fedimintSlug, normalizeMintUrl,
type FedimintAnnouncement, type FedimintFields,
} from '@cashumints/shared';
import { getDb } from './db.ts';
import { log } from './log.ts';
/**
* URLs already reported as unusable. A rejected value (a Fedimint `fed11…` invite, an
* onion address) recurs in dozens of events per cycle, and logging each occurrence
* buries the lines that matter.
* URLs already reported as unusable. A rejected value (an onion address, a bare label)
* recurs in dozens of events per cycle, and logging each occurrence buries the lines
* that matter.
*
* Fedimint invite codes used to be the loudest entry in here, arriving as `u` tags this
* function could make no sense of. They have their own path now and never reach it.
*/
const reportedSkips = new Set<string>();
export interface MintRow {
url: string;
host: string;
/** 'cashu' or 'fedimint'. Rows written before the column existed default to 'cashu'. */
type: string;
name: string | null;
description: string | null;
icon_url: string | null;
icon_file: string | null;
pubkey: string | null;
info_json: string | null;
/** Type-specific data. `FedimintFields` for a federation, null for a Cashu mint. */
ecosystem_json: string | null;
nuts_json: string | null;
version: string | null;
status: string;
@@ -44,7 +54,10 @@ export interface MintRow {
* If that ever stops holding, the upgrade is to probe both casings once and keep the
* one that answers.
*/
export function insertMintIfNew(rawUrl: string, now = Math.floor(Date.now() / 1000)): string | null {
export async function insertMintIfNew(
rawUrl: string,
now = Math.floor(Date.now() / 1000),
): Promise<string | null> {
const normalized = normalizeMintUrl(rawUrl);
if (!normalized) {
if (!reportedSkips.has(rawUrl)) {
@@ -54,41 +67,166 @@ export function insertMintIfNew(rawUrl: string, now = Math.floor(Date.now() / 10
return null;
}
const result = getDb()
.prepare(
// OR IGNORE covers both unique keys, url and host, in one clause.
`INSERT OR IGNORE INTO mints (url, host, status, first_seen, updated_at)
VALUES (?, ?, 'unknown', ?, ?)`,
)
.run(normalized.url, normalized.host, now, now);
const db = await getDb();
// No conflict target, so this covers both unique keys — url and host — in one clause.
// (`INSERT OR IGNORE` would too, but only SQLite knows that spelling.)
const result = await db.run(
`INSERT INTO mints (url, host, type, status, first_seen, updated_at)
VALUES (?, ?, 'cashu', 'unknown', ?, ?)
ON CONFLICT DO NOTHING`,
normalized.url,
normalized.host,
now,
now,
);
return result.changes > 0 ? normalized.url : null;
if (result.changes > 0) return normalized.url;
/*
* The no-op insert is almost always this exact URL already being tracked. The other
* possibility is a *different* URL whose slug collides (`host/a-b` vs `host/a/b`
* both slug to `host-a-b`): that mint is silently not tracked and any review of it
* files under the other one, which is worth a log line the first time it happens.
*/
const holder = await db.get<{ url: string }>(
'SELECT url FROM mints WHERE host = ?',
normalized.host,
);
if (holder && holder.url !== normalized.url && !reportedSkips.has(normalized.url)) {
reportedSkips.add(normalized.url);
log.warn('slug collision, mint not tracked', {
url: normalized.url,
host: normalized.host,
existing: holder.url,
});
}
return null;
}
/* ---------- fedimint ---------- */
/** Read a federation row's type-specific columns back out. */
export function parseEcosystem(row: Pick<MintRow, 'ecosystem_json'>): FedimintFields | null {
if (!row.ecosystem_json) return null;
try {
return JSON.parse(row.ecosystem_json) as FedimintFields;
} catch {
return null;
}
}
/**
* Insert or refresh a federation from a kind 38173 announcement.
*
* Deduped on the federation id, which is the only identity a federation has: the same
* federation is announced by several people (two different npubs currently announce
* "Bitcoin Principles" with the same `d`), and every one of those is the same thing to
* join. One row, keyed on the id, whoever published it.
*
* Unlike `insertMintIfNew` this does update an existing row, and it has to: an
* announcement is the *only* source of a federation's invite codes, modules and name,
* where a Cashu mint's row is refreshed by probing the mint itself. Older announcements
* are ignored (`announced_at` goes forwards only) so a replayed event from last year
* cannot overwrite this week's invite code.
*
* The status columns are never touched here. Whether a federation is up is a probe's
* answer, and an announcement is not evidence of anything being up.
*
* Returns the row's key when a row was created, null when one was merely updated.
*/
export async function upsertFedimint(
announcement: FedimintAnnouncement,
now = Math.floor(Date.now() / 1000),
): Promise<string | null> {
const db = await getDb();
const url = fedimintKey(announcement.federationId);
const host = fedimintSlug(announcement.federationId);
const fields: FedimintFields = {
federation_id: announcement.federationId,
invite_codes: announcement.inviteCodes,
modules: announcement.modules,
network: announcement.network,
announced_at: announcement.announcedAt,
announcer_pubkey: announcement.announcerPubkey,
// Set by the probe, not by an announcement. Carried over below when a row exists.
status_source: null,
};
const existing = await db.get<MintRow>('SELECT * FROM mints WHERE url = ?', url);
if (!existing) {
await db.run(
`INSERT INTO mints (url, host, type, name, description, icon_url, ecosystem_json,
status, first_seen, updated_at)
VALUES (?, ?, 'fedimint', ?, ?, ?, ?, 'announced', ?, ?)
ON CONFLICT DO NOTHING`,
url,
host,
announcement.name,
announcement.about,
announcement.picture,
JSON.stringify(fields),
now,
now,
);
return url;
}
const previous = parseEcosystem(existing);
if (previous && (previous.announced_at ?? 0) > announcement.announcedAt) return null;
await db.run(
`UPDATE mints SET
name = COALESCE(?, name),
description = COALESCE(?, description),
icon_url = COALESCE(?, icon_url),
ecosystem_json = ?,
updated_at = ?
WHERE url = ?`,
announcement.name,
announcement.about,
announcement.picture,
JSON.stringify({ ...fields, status_source: previous?.status_source ?? null }),
now,
url,
);
return null;
}
/** Every federation row, for the probe cycle and for review resolution. */
export async function fedimintRows(): Promise<MintRow[]> {
const db = await getDb();
return db.all<MintRow>(`SELECT * FROM mints WHERE type = 'fedimint'`);
}
/** Canonical mint URL for a normalized slug, or null if no such mint is tracked. */
export function mintUrlByHost(host: string): string | null {
const row = getDb().prepare('SELECT url FROM mints WHERE host = ?').get(host) as
| { url: string }
| undefined;
export async function mintUrlByHost(host: string): Promise<string | null> {
const db = await getDb();
const row = await db.get<{ url: string }>('SELECT url FROM mints WHERE host = ?', host);
return row?.url ?? null;
}
export function allMintRows(): MintRow[] {
return getDb().prepare('SELECT * FROM mints').all() as MintRow[];
export async function allMintRows(): Promise<MintRow[]> {
const db = await getDb();
return db.all<MintRow>('SELECT * FROM mints');
}
export function mintByHost(host: string): MintRow | undefined {
return getDb().prepare('SELECT * FROM mints WHERE host = ?').get(host) as MintRow | undefined;
export async function mintByHost(host: string): Promise<MintRow | undefined> {
const db = await getDb();
return db.get<MintRow>('SELECT * FROM mints WHERE host = ?', host);
}
export function mintByUrl(url: string): MintRow | undefined {
return getDb().prepare('SELECT * FROM mints WHERE url = ?').get(url) as MintRow | undefined;
export async function mintByUrl(url: string): Promise<MintRow | undefined> {
const db = await getDb();
return db.get<MintRow>('SELECT * FROM mints WHERE url = ?', url);
}
/** Resolve a mint by the pubkey it publishes in /v1/info, for reviews with only a `d` tag. */
export function mintUrlByPubkey(pubkey: string): string | null {
const row = getDb().prepare('SELECT url FROM mints WHERE pubkey = ? LIMIT 1').get(pubkey) as
| { url: string }
| undefined;
export async function mintUrlByPubkey(pubkey: string): Promise<string | null> {
const db = await getDb();
const row = await db.get<{ url: string }>('SELECT url FROM mints WHERE pubkey = ? LIMIT 1', pubkey);
return row?.url ?? null;
}