Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
+165
-27
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user