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>
This commit is contained in:
michilis
2026-08-22 03:44:35 +02:00
co-authored by Cursor
parent c97b44018d
commit 2a9444942b
19 changed files with 4383 additions and 72 deletions
+292 -7
View File
@@ -1,6 +1,6 @@
import {
fedimintKey, fedimintSlug, normalizeMintUrl,
type FedimintAnnouncement, type FedimintFields,
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';
@@ -18,7 +18,7 @@ const reportedSkips = new Set<string>();
export interface MintRow {
url: string;
host: string;
/** 'cashu' or 'fedimint'. Rows written before the column existed default to 'cashu'. */
/** 'cashu', 'fedimint' or 'lnurl'. Rows written before the column existed default to 'cashu'. */
type: string;
name: string | null;
description: string | null;
@@ -26,7 +26,10 @@ export interface MintRow {
icon_file: string | null;
pubkey: string | null;
info_json: string | null;
/** Type-specific data. `FedimintFields` for a federation, null for a Cashu mint. */
/**
* 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;
@@ -106,11 +109,23 @@ export async function insertMintIfNew(
/* ---------- fedimint ---------- */
/** Read a federation row's type-specific columns back out. */
export function parseEcosystem(row: Pick<MintRow, 'ecosystem_json'>): FedimintFields | null {
/**
* 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 FedimintFields;
return JSON.parse(row.ecosystem_json) as T;
} catch {
return null;
}
@@ -196,12 +211,282 @@ export async function upsertFedimint(
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();