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:
+292
-7
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user