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:
+178
-12
@@ -3,25 +3,28 @@ import type { Event as NostrEvent, Filter } from 'nostr-tools';
|
||||
import {
|
||||
ANNOUNCEMENT_KINDS,
|
||||
KIND_FEDIMINT_ANNOUNCEMENT,
|
||||
KIND_LNURL_ANNOUNCEMENT,
|
||||
KIND_MINT_ANNOUNCEMENT,
|
||||
KIND_REVIEW,
|
||||
isCashuMintReview,
|
||||
mintPubkeyRef,
|
||||
mintUrlSpellings,
|
||||
mintUrlsFromEvent,
|
||||
normalizeMintUrl,
|
||||
parseFedimintAnnouncement,
|
||||
parseLnurlAnnouncement,
|
||||
parseRating,
|
||||
reviewEcosystem,
|
||||
reviewTargetId,
|
||||
reviewTargetKind,
|
||||
type FedimintAnnouncement,
|
||||
type LnurlAnnouncement,
|
||||
type LnurlFields,
|
||||
} from '@cashumints/shared';
|
||||
import { config } from './config.ts';
|
||||
import { getDb, setState, getStateNumber } from './db.ts';
|
||||
import type { Sql } from './db-driver.ts';
|
||||
import { log } from './log.ts';
|
||||
import { insertMintIfNew, upsertFedimint } from './mints.ts';
|
||||
import { insertMintIfNew, upsertFedimint, upsertLnurl } from './mints.ts';
|
||||
|
||||
const QUERY_LIMIT = 500;
|
||||
const MAX_WAIT_MS = 12_000;
|
||||
@@ -40,7 +43,16 @@ export interface DiscoveryResult {
|
||||
|
||||
let pool: SimplePool | null = null;
|
||||
|
||||
function getPool(): SimplePool {
|
||||
/**
|
||||
* The process's one relay pool.
|
||||
*
|
||||
* Exported because `relay-lookup.ts` asks the same relays a different question — "has
|
||||
* anyone ever mentioned this address?", for a mint a reader just submitted that does
|
||||
* not answer — and opening a second pool for it would mean a second set of sockets to
|
||||
* the same five relays, with its own reconnect behaviour and its own lifetime to get
|
||||
* wrong at shutdown.
|
||||
*/
|
||||
export function getPool(): SimplePool {
|
||||
pool ??= new SimplePool();
|
||||
return pool;
|
||||
}
|
||||
@@ -137,6 +149,27 @@ async function fetchReviewsForMint(
|
||||
'#d': [target.federationId],
|
||||
'#k': [String(KIND_FEDIMINT_ANNOUNCEMENT)],
|
||||
});
|
||||
} else if (target.type === 'lnurl') {
|
||||
/*
|
||||
* Asked for by every identifier it has ever had, not just its current one.
|
||||
*
|
||||
* An LNURL mint's `d` is its funding node's pubkey when it has one and its host
|
||||
* otherwise, so a mint that gained a funding source has reviews filed under both
|
||||
* spellings — the older ones under the host, the newer under the pubkey. One `#d`
|
||||
* filter carrying both is one round trip and finds all of them; asking for only the
|
||||
* current identifier would silently strand the earlier half. See the kind document.
|
||||
*
|
||||
* `#u` is asked as well, and unlike a federation's invite codes an LNURL mint's URLs
|
||||
* are short enough that no relay rejects the filter.
|
||||
*/
|
||||
if (target.identifiers.length > 0) {
|
||||
filters.push({
|
||||
...base,
|
||||
'#d': target.identifiers,
|
||||
'#k': [String(KIND_LNURL_ANNOUNCEMENT)],
|
||||
});
|
||||
}
|
||||
filters.push({ ...base, '#u': mintUrlSpellings(target.baseUrl ?? target.url) });
|
||||
} else {
|
||||
if (target.pubkey) {
|
||||
filters.push({ ...base, '#d': [target.pubkey], '#k': [String(KIND_MINT_ANNOUNCEMENT)] });
|
||||
@@ -164,10 +197,14 @@ async function fetchReviewsForMint(
|
||||
*/
|
||||
interface ReviewTarget {
|
||||
type: string;
|
||||
/** Row key: the mint URL, or `fedimint:<id>`. */
|
||||
/** Row key: the mint URL, `fedimint:<id>`, or `lnurl:<base url>`. */
|
||||
url: string;
|
||||
pubkey: string | null;
|
||||
federationId: string | null;
|
||||
/** LNURL: every `d` this mint could be reviewed under. Empty for the others. */
|
||||
identifiers: string[];
|
||||
/** LNURL: the fetchable https URL inside the row key. */
|
||||
baseUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,6 +238,41 @@ async function ingestFedimints(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every LNURL mint the batch announces, inserted or refreshed.
|
||||
*
|
||||
* Deduped **by normalized base URL**, not by the `d` tag, which is the one place this
|
||||
* genuinely differs from `ingestFedimints`. A federation has exactly one identity for
|
||||
* life; an LNURL mint's identifier is its funding node's pubkey when it has one and its
|
||||
* host otherwise, so the same mint legitimately announces under two different `d` values
|
||||
* across its life. Keying on `d` would split it into two pages with half its reviews on
|
||||
* each. The `u` tag is present in every form of the event, so it is the key.
|
||||
*
|
||||
* Newest announcement per URL wins inside the batch, for the same reason the Fedimint
|
||||
* side does it: the same mint is announced by more than one npub and more than once, and
|
||||
* writing each of those would be one UPDATE per event to reach the same final state.
|
||||
*/
|
||||
async function ingestLnurl(
|
||||
events: Iterable<NostrEvent>,
|
||||
now: number,
|
||||
newRows: Set<string>,
|
||||
): Promise<void> {
|
||||
const newest = new Map<string, LnurlAnnouncement>();
|
||||
|
||||
for (const event of events) {
|
||||
const announcement = parseLnurlAnnouncement(event);
|
||||
if (!announcement) continue;
|
||||
const existing = newest.get(announcement.baseUrl);
|
||||
if (existing && existing.announcedAt >= announcement.announcedAt) continue;
|
||||
newest.set(announcement.baseUrl, announcement);
|
||||
}
|
||||
|
||||
for (const announcement of newest.values()) {
|
||||
const created = await upsertLnurl(announcement, now);
|
||||
if (created) newRows.add(created);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every mint URL the batch points at, normalized and inserted if new.
|
||||
*
|
||||
@@ -247,6 +319,18 @@ interface MintIndex {
|
||||
byFederation: Map<string, string>;
|
||||
/** Invite code to row key, for a review that carries `u` but no usable `d`. */
|
||||
byInvite: Map<string, string>;
|
||||
/**
|
||||
* Every LNURL identifier to its row key: the mint pubkey *and* the normalized host,
|
||||
* both pointing at the same row.
|
||||
*
|
||||
* Kept apart from `byPubkey`, which is the Cashu index over `mints.pubkey`. An LNURL
|
||||
* mint's key is its funding node's identity, not a Cashu mint pubkey, and mixing the
|
||||
* two namespaces would let a review of one resolve to the other. Nothing writes
|
||||
* `mints.pubkey` for an LNURL row, for exactly that reason.
|
||||
*/
|
||||
byLnurlId: Map<string, string>;
|
||||
/** Normalized base URL to row key, for a review that carries `u` but no usable `d`. */
|
||||
byLnurlUrl: Map<string, string>;
|
||||
}
|
||||
|
||||
async function loadMintIndex(): Promise<MintIndex> {
|
||||
@@ -263,12 +347,30 @@ async function loadMintIndex(): Promise<MintIndex> {
|
||||
const byPubkey = new Map<string, string>();
|
||||
const byFederation = new Map<string, string>();
|
||||
const byInvite = new Map<string, string>();
|
||||
const byLnurlId = new Map<string, string>();
|
||||
const byLnurlUrl = new Map<string, string>();
|
||||
|
||||
for (const row of rows) {
|
||||
byHost.set(row.host, row.url);
|
||||
// First writer wins, matching the LIMIT 1 the per-event query used.
|
||||
if (row.pubkey && !byPubkey.has(row.pubkey)) byPubkey.set(row.pubkey, row.url);
|
||||
|
||||
if (row.type === 'lnurl' && row.ecosystem_json) {
|
||||
try {
|
||||
const fields = JSON.parse(row.ecosystem_json) as Partial<LnurlFields>;
|
||||
// Both identifiers, always: a mint announced by host and later by pubkey has
|
||||
// reviews under each, and both belong to this one row.
|
||||
for (const id of [fields.lnurl_id, fields.mint_pubkey]) {
|
||||
if (id) byLnurlId.set(id.toLowerCase(), row.url);
|
||||
}
|
||||
if (fields.base_url) byLnurlUrl.set(fields.base_url, row.url);
|
||||
} catch {
|
||||
// Unparseable column: this row simply cannot be matched by identifier. It still
|
||||
// has a page and still resolves by its key, which is honest.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.type !== 'fedimint' || !row.ecosystem_json) continue;
|
||||
try {
|
||||
const fields = JSON.parse(row.ecosystem_json) as {
|
||||
@@ -283,7 +385,7 @@ async function loadMintIndex(): Promise<MintIndex> {
|
||||
}
|
||||
}
|
||||
|
||||
return { byHost, byPubkey, byFederation, byInvite };
|
||||
return { byHost, byPubkey, byFederation, byInvite, byLnurlId, byLnurlUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,6 +405,7 @@ function resolveReviewTarget(event: NostrEvent, index: MintIndex): string | null
|
||||
*/
|
||||
const ecosystem = reviewEcosystem(event);
|
||||
if (ecosystem === 'fedimint') return resolveFedimintReview(event, index);
|
||||
if (ecosystem === 'lnurl') return resolveLnurlReview(event, index);
|
||||
// A `k` naming a kind this build has no ecosystem for: not ours to file.
|
||||
if (ecosystem === null) return null;
|
||||
|
||||
@@ -344,6 +447,39 @@ function resolveFedimintReview(event: NostrEvent, index: MintIndex): string | nu
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which LNURL mint a review is about.
|
||||
*
|
||||
* `d` first, matched against both identifiers a mint can have — its funding node's
|
||||
* pubkey and its normalized host — because a mint that gained a funding source has
|
||||
* reviews written under each and both name the same page.
|
||||
*
|
||||
* The `u` fallback is not the afterthought it is on the Fedimint side. An LNURL mint's
|
||||
* `u` tag is a real, normalizable https URL, so a review that carries only an address
|
||||
* resolves exactly as a Cashu review does, through the same normalizer. That matters
|
||||
* for reviews written by clients that do not know this kind: `u` is the tag they are
|
||||
* most likely to get right.
|
||||
*
|
||||
* A review of a mint this site has never seen resolves to nothing and is dropped, as it
|
||||
* is for the other two ecosystems. There is no page to put it on.
|
||||
*/
|
||||
function resolveLnurlReview(event: NostrEvent, index: MintIndex): string | null {
|
||||
const d = reviewTargetId(event);
|
||||
if (d) {
|
||||
const found = index.byLnurlId.get(d.trim().toLowerCase());
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
for (const raw of mintUrlsFromEvent(event)) {
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
const found = index.byLnurlUrl.get(normalized.url);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Postgres caps a statement at 65535 bounds parameters; this keeps every batch clear of it. */
|
||||
const INSERT_BATCH = 500;
|
||||
|
||||
@@ -402,8 +538,19 @@ async function ingestReviews(events: Iterable<NostrEvent>, index: MintIndex): Pr
|
||||
*/
|
||||
const target = resolveReviewTarget(e, index);
|
||||
if (!target) continue;
|
||||
const fedimint = reviewTargetKind(e) === KIND_FEDIMINT_ANNOUNCEMENT;
|
||||
if (!fedimint && !isCashuMintReview(e) && mintUrlsFromEvent(e).length === 0) continue;
|
||||
/*
|
||||
* A review whose `k` names an ecosystem this build knows has already proved itself
|
||||
* by resolving: `resolveReviewTarget` runs one resolver per ecosystem and returns a
|
||||
* row only for an event whose `k` matches that resolver, so nothing here has to
|
||||
* re-test it. Written against `ANNOUNCEMENT_KINDS` rather than against a list of
|
||||
* kind numbers, so a fourth ecosystem needs no edit here either.
|
||||
*
|
||||
* The `u` clause is what keeps the legacy events: a great many kind 38000 on the
|
||||
* network carry no `k` at all, every one of them is about a Cashu mint, and they are
|
||||
* accepted on having a resolvable mint URL exactly as they always were.
|
||||
*/
|
||||
const kindKnown = reviewTargetKind(e) !== null && reviewEcosystem(e) !== null;
|
||||
if (!kindKnown && mintUrlsFromEvent(e).length === 0) continue;
|
||||
|
||||
const k = reviewTargetKind(e);
|
||||
|
||||
@@ -469,10 +616,12 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
||||
|
||||
const announcements = announcementsByType.get('cashu') ?? [];
|
||||
const federations = announcementsByType.get('fedimint') ?? [];
|
||||
events += announcements.length + federations.length;
|
||||
const lnurlMints = announcementsByType.get('lnurl') ?? [];
|
||||
events += announcements.length + federations.length + lnurlMints.length;
|
||||
|
||||
await ingestMintUrls(announcements, now, newMints);
|
||||
await ingestFedimints(federations, now, newMints);
|
||||
await ingestLnurl(lnurlMints, now, newMints);
|
||||
|
||||
// Announcements alone miss mints that only ever appear in a review's `u` tag,
|
||||
// so reviews feed discovery too.
|
||||
@@ -505,15 +654,32 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
||||
|
||||
const targets: ReviewTarget[] = rows.map((row) => {
|
||||
let federationId: string | null = null;
|
||||
if (row.type === 'fedimint' && row.ecosystem_json) {
|
||||
let identifiers: string[] = [];
|
||||
let baseUrl: string | null = null;
|
||||
|
||||
if (row.ecosystem_json) {
|
||||
try {
|
||||
federationId =
|
||||
(JSON.parse(row.ecosystem_json) as { federation_id?: string }).federation_id ?? null;
|
||||
if (row.type === 'fedimint') {
|
||||
federationId =
|
||||
(JSON.parse(row.ecosystem_json) as { federation_id?: string }).federation_id ?? null;
|
||||
} else if (row.type === 'lnurl') {
|
||||
const fields = JSON.parse(row.ecosystem_json) as Partial<LnurlFields>;
|
||||
baseUrl = fields.base_url ?? null;
|
||||
// Deduped, because a mint with no funding source has `lnurl_id` and
|
||||
// `mint_pubkey` describing the same thing and a relay filter listing one
|
||||
// value twice is one wasted slot.
|
||||
identifiers = [...new Set(
|
||||
[fields.lnurl_id, fields.mint_pubkey]
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.map((id) => id.toLowerCase()),
|
||||
)];
|
||||
}
|
||||
} catch {
|
||||
// Nothing to ask by. The global sweep above already had its chance.
|
||||
}
|
||||
}
|
||||
return { type: row.type, url: row.url, pubkey: row.pubkey, federationId };
|
||||
|
||||
return { type: row.type, url: row.url, pubkey: row.pubkey, federationId, identifiers, baseUrl };
|
||||
});
|
||||
|
||||
const CONCURRENCY = 6;
|
||||
|
||||
Reference in New Issue
Block a user