Files
CashuMints.space/api/src/mints.ts
T
michilisandCursor 2a9444942b Index, probe, and announce LNURL mints in the API.
Wire discovery and probing for LNURL mints, add rate-limited POST /api/index
for user submissions, and optionally announce confirmed state to relays.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:35 +02:00

518 lines
18 KiB
TypeScript

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<string>();
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<string | null> {
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<T = FedimintFields>(
row: Pick<MintRow, 'ecosystem_json'>,
): 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<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;
}
/**
* 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<string | null> {
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<MintRow[]> {
const db = await getDb();
return db.all<MintRow>(`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<string | null> {
const db = await getDb();
const takenBy = async (slug: string): Promise<string | null> => {
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<string | null> {
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<string | null> {
const db = await getDb();
const url = lnurlKey(announcement.baseUrl);
const existing = await db.get<MintRow>('SELECT * FROM mints WHERE url = ?', url);
const previous = existing ? parseEcosystem<LnurlFields>(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<MintRow[]> {
const db = await getDb();
return db.all<MintRow>(`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<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 async function allMintRows(): Promise<MintRow[]> {
const db = await getDb();
return db.all<MintRow>('SELECT * FROM mints');
}
export async function mintByHost(host: string): Promise<MintRow | undefined> {
const db = await getDb();
return db.get<MintRow>('SELECT * FROM mints WHERE host = ?', host);
}
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 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;
}