import { LNURL_SLUG_PREFIX, fedimintKey, fedimintSlug, lnurlIdentifier, lnurlKey, normalizeMintUrl, type FedimintAnnouncement, type FedimintFields, type LnurlAnnouncement, type LnurlFields, } from '@cashumints/shared'; import { getDb } from './db.ts'; import { log } from './log.ts'; /** * 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(); export interface MintRow { url: string; host: string; /** 'cashu', 'fedimint' or 'lnurl'. 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, `LnurlFields` for an LNURL * mint, null for a Cashu one. Read it back through `parseEcosystem`. */ ecosystem_json: string | null; nuts_json: string | null; version: string | null; status: string; consecutive_fails: number; last_online: number | null; last_probe: number | null; first_seen: number; updated_at: number; } /** * Insert a mint if it is new. Never touches an existing row: cached metadata is the * whole point of the table, and discovery must not overwrite what a probe learned. * Returns the normalized URL when a row was created, null otherwise. * * Deduped on the slug, not the URL. Real data has `https://mint.minibits.cash/Bitcoin` * and `https://mint.minibits.cash/bitcoin` announced as separate mints; they are one * mint written two ways, and keying on the URL would split its reviews across two pages. * The URL keeps its original path case, because a mint's path is case sensitive over * HTTP and the lowercase spelling 404s when probed. * * ponytail: whichever casing is seen first wins and is the one probed. The seed list * pins the working spelling, so in practice the correct one is always inserted first. * If that ever stops holding, the upgrade is to probe both casings once and keep the * one that answers. */ export async function insertMintIfNew( rawUrl: string, now = Math.floor(Date.now() / 1000), ): Promise { const normalized = normalizeMintUrl(rawUrl); if (!normalized) { if (!reportedSkips.has(rawUrl)) { reportedSkips.add(rawUrl); log.warn('skipped mint url', { url: rawUrl.slice(0, 80) }); } return null; } 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, ); 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 row's type-specific column back out. * * Generic, defaulting to `FedimintFields` so every existing call site reads exactly as * it did. The caller already knows the row's `type` — it is what decided to call this * at all — so the type argument is a statement of what was stored, not a guess. * * A column that will not parse degrades to null rather than throwing. Such a row still * has a page and still resolves by its key; it simply has no ecosystem-specific fields * on it, which is honest and is better than a probe cycle dying on one bad row. */ export function parseEcosystem( row: Pick, ): T | null { if (!row.ecosystem_json) return null; try { return JSON.parse(row.ecosystem_json) as T; } 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 { 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('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; } /** * Insert a federation from an invite code alone, with no announcement behind it. * * For `POST /api/index`: a reader pastes the one thing they have, and there is nothing * else to go on. The id comes out of the code itself (`federationIdFromInviteCode`), so * the row is keyed exactly as an announced one would be and the two can never become * two rows for one federation. * * `announced` rather than `unknown`, and the distinction is the same one the status * carries everywhere else: `unknown` means nothing has checked yet, `announced` means * there is nothing this site *can* check. A federation has no public endpoint, so an * invite code is exactly as much as anyone will ever be able to confirm from here until * the observer lookup covers it. * * Returns the row key when a row was created, null when one already existed. */ export async function insertFedimintFromInvite( code: string, federationId: string, now = Math.floor(Date.now() / 1000), ): Promise { const db = await getDb(); const url = fedimintKey(federationId); const fields: FedimintFields = { federation_id: federationId, invite_codes: [code.trim().toLowerCase()], // Everything an announcement would have carried. Discovery fills these in when one // turns up, and until then the page says only what the code itself proves. modules: [], network: null, announced_at: null, announcer_pubkey: null, status_source: null, }; const result = await db.run( `INSERT INTO mints (url, host, type, ecosystem_json, status, first_seen, updated_at) VALUES (?, ?, 'fedimint', ?, 'announced', ?, ?) ON CONFLICT DO NOTHING`, url, fedimintSlug(federationId), JSON.stringify(fields), now, now, ); return result.changes > 0 ? url : null; } /** Every federation row, for the probe cycle and for review resolution. */ export async function fedimintRows(): Promise { const db = await getDb(); return db.all(`SELECT * FROM mints WHERE type = 'fedimint'`); } /* ---------- lnurl ---------- */ /** * The routing slug an LNURL mint gets, given what the table already holds. * * `mints.host` carries a UNIQUE index across every ecosystem, so a Cashu mint and an * LNURL mint on one hostname would compete for a single slug and — with the existing * insert path — the loser would silently not be tracked at all. That is the one outcome * worth writing code to avoid: a mint that exists, is reviewable, and has no page. * * So the clean slug is used whenever it is free, which is very nearly always, and a * colliding row takes `lnurl-` in front instead. Decided once at insert and then stored, * never recomputed: a mint that took the prefixed slug keeps it even if the row it * collided with later disappears, because its URL is already indexed and linked and a * silently moving page is worse than a slightly long one. */ async function lnurlSlug(preferred: string, url: string): Promise { const db = await getDb(); const takenBy = async (slug: string): Promise => { const row = await db.get<{ url: string }>('SELECT url FROM mints WHERE host = ?', slug); return row && row.url !== url ? row.url : null; }; const clash = await takenBy(preferred); if (!clash) return preferred; const prefixed = LNURL_SLUG_PREFIX + preferred; const secondClash = await takenBy(prefixed); if (!secondClash) { log.info('lnurl slug taken, using prefixed form', { url, slug: prefixed, existing: clash }); return prefixed; } log.warn('lnurl slug collision, mint not tracked', { url, slug: preferred, existing: clash }); return null; } /** The `ecosystem_json` an LNURL row starts life with, before anything has probed it. */ function blankLnurlFields(baseUrl: string, identifier: string): LnurlFields { return { lnurl_id: identifier, base_url: baseUrl, features: [], network: null, announced_at: null, announcer_pubkey: null, mint_pubkey: null, funding_available: null, probe_endpoint: null, invalid_reason: null, min_withdrawable_msat: null, max_withdrawable_msat: null, min_sendable_msat: null, max_sendable_msat: null, fee_base_msat: null, fee_ppm: null, lightning_address: null, onion_url: null, node_alias: null, node_uri: null, node_capacity_msat: null, node_channels: null, node_peers: null, observed_features: [], }; } /** * Insert an LNURL mint by URL alone, with no announcement behind it. * * For the seed list and for operators added by hand. `status` starts `unknown` and the * very next probe cycle decides it, exactly as a seeded Cashu mint works — nothing here * claims the mint is up, and `insertMintIfNew`'s rule that an existing row is never * touched applies just as strictly. * * Returns the row key when a row was created, null otherwise. */ export async function insertLnurlIfNew( rawUrl: string, now = Math.floor(Date.now() / 1000), ): Promise { const normalized = normalizeMintUrl(rawUrl); if (!normalized) { if (!reportedSkips.has(rawUrl)) { reportedSkips.add(rawUrl); log.warn('skipped lnurl url', { url: rawUrl.slice(0, 80) }); } return null; } const db = await getDb(); const url = lnurlKey(normalized.url); const existing = await db.get<{ url: string }>('SELECT url FROM mints WHERE url = ?', url); if (existing) return null; const host = await lnurlSlug(normalized.host, url); if (host === null) return null; const result = await db.run( `INSERT INTO mints (url, host, type, ecosystem_json, status, first_seen, updated_at) VALUES (?, ?, 'lnurl', ?, 'unknown', ?, ?) ON CONFLICT DO NOTHING`, url, host, JSON.stringify(blankLnurlFields(normalized.url, lnurlIdentifier(normalized.url, null))), now, now, ); return result.changes > 0 ? url : null; } /** * Insert or refresh an LNURL mint from a kind 38174 announcement. * * **Deduped on the normalized base URL, not on the `d` tag**, and that is the whole * reason this function is not a copy of `upsertFedimint`. An LNURL mint's identifier is * its funding node's pubkey when it has one and its host otherwise, so the same mint * legitimately appears under two different `d` values across its life — announced by * host before a funding source was configured, by pubkey afterwards. Keying rows on `d` * would split one mint into two pages with half its reviews on each. The `u` tag is the * one value present in every form of the event, so it is the key. * * Unlike `insertMintIfNew` this does update an existing row, because an announcement is * the only source of `features`, the network and the operator's own name for the mint. * Older announcements are ignored (`announced_at` goes forwards only) so a replayed * event from last year cannot overwrite this week's capability list. * * Two things are deliberately never written here: * * - **The status columns.** Whether a mint is up is a probe's answer. An announcement * is not evidence of anything being up. * - **Anything a probe learned.** `mint_pubkey`, the limits, the address, the node * fields and `funding_available` are all carried over from the existing row * untouched. In particular a `mint_pubkey` already observed is *never* cleared by an * announcement that lacks one: identity is sticky, per the kind document. * * Returns the row's key when a row was created, null when one was merely updated. */ export async function upsertLnurl( announcement: LnurlAnnouncement, now = Math.floor(Date.now() / 1000), ): Promise { const db = await getDb(); const url = lnurlKey(announcement.baseUrl); const existing = await db.get('SELECT * FROM mints WHERE url = ?', url); const previous = existing ? parseEcosystem(existing) : null; if (previous && (previous.announced_at ?? 0) > announcement.announcedAt) return null; /* * The stored pubkey wins over the announcement's. * * A probe reads `mintPubkey` off the mint itself; an announcement is a stranger's * claim about it. Where both exist the observed one is the better evidence, and where * only the announcement has one it is still worth keeping, so this prefers the probe * and falls back rather than overwriting in either direction. */ const mintPubkey = previous?.mint_pubkey ?? announcement.mintPubkey; const fields: LnurlFields = { ...(previous ?? blankLnurlFields(announcement.baseUrl, announcement.identifier)), lnurl_id: lnurlIdentifier(announcement.baseUrl, mintPubkey), base_url: announcement.baseUrl, features: announcement.features, network: announcement.network, announced_at: announcement.announcedAt, announcer_pubkey: announcement.announcerPubkey, mint_pubkey: mintPubkey, }; if (!existing) { const host = await lnurlSlug(announcement.slug, url); if (host === null) return null; await db.run( `INSERT INTO mints (url, host, type, name, description, icon_url, ecosystem_json, status, first_seen, updated_at) VALUES (?, ?, 'lnurl', ?, ?, ?, ?, 'unknown', ?, ?) ON CONFLICT DO NOTHING`, url, host, announcement.name, announcement.about, announcement.picture, JSON.stringify(fields), now, now, ); return url; } 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), now, url, ); return null; } /** Every LNURL row, for the probe cycle and for review resolution. */ export async function lnurlRows(): Promise { const db = await getDb(); return db.all(`SELECT * FROM mints WHERE type = 'lnurl'`); } /** Canonical mint URL for a normalized slug, or null if no such mint is tracked. */ export async function mintUrlByHost(host: string): Promise { const db = await getDb(); const row = await db.get<{ url: string }>('SELECT url FROM mints WHERE host = ?', host); return row?.url ?? null; } export async function allMintRows(): Promise { const db = await getDb(); return db.all('SELECT * FROM mints'); } export async function mintByHost(host: string): Promise { const db = await getDb(); return db.get('SELECT * FROM mints WHERE host = ?', host); } export async function mintByUrl(url: string): Promise { const db = await getDb(); return db.get('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 async function mintUrlByPubkey(pubkey: string): Promise { const db = await getDb(); const row = await db.get<{ url: string }>('SELECT url FROM mints WHERE pubkey = ? LIMIT 1', pubkey); return row?.url ?? null; }