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>
518 lines
18 KiB
TypeScript
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;
|
|
}
|