Files
CashuMints.space/api/src/announce.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

254 lines
9.8 KiB
TypeScript

/**
* Publishing `kind:38174` announcements for LNURL mints this indexer has confirmed.
*
* **This writes to public relays.** Nostr has no delete that anyone is obliged to
* honour, so an event published here is on the network permanently, signed by this
* site's key and attributed to it. That is the whole reason for the gating below, and
* the reason it is two switches rather than one.
*
* Why publish at all: 38174 is a proposed kind (see `docs/KIND-LNURL-MINT.md`) and a
* proposed kind with no events is a document rather than a protocol. An indexer that
* has already probed a mint and confirmed what it serves is the one party in a position
* to put honest announcements on the network before any operator has heard of the kind.
*
* ## What is announced, and what deliberately is not
*
* Only what the probe actually **observed**. The vocabulary has eleven values; this
* publishes at most seven of them, and the four it never publishes are the point:
*
* - `rotate`, `split`, `merge` — these are only provable by calling `/w/cb`, which
* mutates or destroys a note. Not probeable, not published, even though the one
* implementation in existence supports all three unconditionally. Inferring them
* from a version string would be publishing a guess under this site's signature.
* - `lud21` — genuinely undetectable: verify-disabled and unknown-payment-hash return
* byte-identical responses. See NOTES-LNURL.md §5.
*
* An operator's own announcement can and should claim more; theirs is a statement about
* what they built, this is a statement about what was seen. When both exist the newer
* `created_at` wins, which is normal addressable-event behaviour and means an operator
* takes over their own listing simply by publishing one.
*/
import { finalizeEvent, getPublicKey, type EventTemplate } from 'nostr-tools/pure';
import { SimplePool } from 'nostr-tools/pool';
import * as nip19 from 'nostr-tools/nip19';
import {
KIND_LNURL_ANNOUNCEMENT, lnurlIdentifier, observedFeatures, type LnurlFields,
} from '@cashumints/shared';
import { announceConfig } from './config.ts';
import { getDb, getState, setState } from './db.ts';
import { log } from './log.ts';
import { parseEcosystem, type MintRow } from './mints.ts';
/**
* Republish interval.
*
* An addressable event is replaced, not duplicated, so republishing is cheap — but it
* is still traffic to somebody else's relay and a new `created_at` on every cycle would
* make this site's announcement permanently outrank an operator's own. Content changes
* are published immediately; an unchanged announcement is refreshed once a week, which
* keeps it from ageing out of relays that prune.
*/
const REPUBLISH_AFTER_S = 7 * 24 * 60 * 60;
/** State-table key holding what was last published for one mint, and when. */
const stateKey = (url: string): string => `announce:${url}`;
/**
* The secret key to sign with, as 32 bytes.
*
* Accepts an `nsec1…` or bare hex. Returns null — with a loud log line rather than a
* throw — for anything else: a misconfigured key must stop announcements and must not
* stop the indexer, which has a directory to serve either way.
*/
export function parseAnnounceKey(raw: string): Uint8Array | null {
const value = raw.trim();
if (!value) return null;
if (value.startsWith('nsec1')) {
try {
const decoded = nip19.decode(value);
if (decoded.type === 'nsec') return decoded.data;
} catch {
return null;
}
return null;
}
if (!/^[0-9a-f]{64}$/i.test(value)) return null;
return Uint8Array.from(Buffer.from(value, 'hex'));
}
/**
* The `features` this site is willing to sign for, from one mint's stored probe.
*
* Thin, and deliberately: the rule about what a probe may claim lives in
* `observedFeatures` in shared/, next to the vocabulary it draws from, so the list the
* page renders and the list this signs cannot drift apart. See that function for why
* `rotate`, `split`, `merge` and `lud21` are never among them.
*/
export function announcedFeatures(fields: LnurlFields): string[] {
return observedFeatures({
fundingAvailable: fields.funding_available,
maxWithdrawableMsat: fields.max_withdrawable_msat,
maxSendableMsat: fields.max_sendable_msat,
lightningAddress: fields.lightning_address,
mintPubkey: fields.mint_pubkey,
onionUrl: fields.onion_url,
});
}
/** The event this site would publish for one confirmed LNURL mint. */
export function announcementTemplate(
row: MintRow,
fields: LnurlFields,
createdAt: number,
): EventTemplate {
const tags: string[][] = [
['d', lnurlIdentifier(fields.base_url, fields.mint_pubkey)],
['u', fields.base_url],
];
const features = announcedFeatures(fields);
if (features.length > 0) tags.push(['features', features.join(',')]);
// Only when the mint said so. Absent reads as mainnet per the kind document, and
// asserting mainnet on a mint that never claimed a network would be this site
// inventing the one fact that decides whether the money is real.
if (fields.network) tags.push(['n', fields.network]);
/*
* `content` is left empty unless the mint published a name of its own.
*
* Empty content means "use the publisher's kind 0", per NIP-87 and the kind document
* — and the publisher here is this site, whose kind 0 is this site. That is the
* honest default for an announcement this site wrote: it is not the mint speaking.
* A name the mint itself served (its node alias) is worth passing on, and nothing else
* from the row is, because everything else came from a previous announcement.
*/
const content = row.name ? JSON.stringify({ name: row.name }) : '';
return { kind: KIND_LNURL_ANNOUNCEMENT, created_at: createdAt, tags, content };
}
/** Stable fingerprint of an announcement's meaning, for the change detector. */
function fingerprint(template: EventTemplate): string {
return JSON.stringify([template.tags, template.content]);
}
/**
* Which rows are eligible.
*
* Confirmed by probing, and only that: `status = 'online'` with a base URL and a
* withdraw ceiling means an advertisement genuinely parsed at some point. A mint that
* has never answered, is offline, or is only "responding but invalid" is never
* announced — this site does not put its signature on the existence of something it has
* not seen.
*
* A `degraded-funding` mint **is** announced: it is up and serving, and the reduced
* `features` list already tells the whole story.
*/
function eligible(row: MintRow, fields: LnurlFields | null): fields is LnurlFields {
if (row.type !== 'lnurl' || row.status !== 'online') return false;
if (!fields?.base_url) return false;
if (fields.invalid_reason) return false;
return fields.max_withdrawable_msat !== null;
}
export interface AnnounceResult {
eligible: number;
published: number;
skipped: number;
failed: number;
}
/**
* Publish an announcement for every confirmed LNURL mint that needs one.
*
* Returns counts rather than throwing: a relay refusing an event is not a reason for a
* probe cycle to fail. Does nothing at all, and says why once, when announcing is off —
* which is the default and is how every deployment that has not opted in behaves.
*/
export async function announceLnurlMints(
now = Math.floor(Date.now() / 1000),
): Promise<AnnounceResult> {
const empty: AnnounceResult = { eligible: 0, published: 0, skipped: 0, failed: 0 };
const settings = announceConfig();
if (!settings) return empty;
const secret = parseAnnounceKey(settings.secretKey);
if (!secret) {
log.error('announce disabled: ANNOUNCE_KEY is not an nsec or 64 hex characters');
return empty;
}
const db = await getDb();
const rows = await db.all<MintRow>(`SELECT * FROM mints WHERE type = 'lnurl'`);
const pool = new SimplePool();
const result: AnnounceResult = { ...empty };
try {
for (const row of rows) {
const fields = parseEcosystem<LnurlFields>(row);
if (!eligible(row, fields)) continue;
result.eligible++;
const template = announcementTemplate(row, fields, now);
const mark = fingerprint(template);
const previous = await getState(stateKey(row.url));
if (previous) {
try {
const { mark: lastMark, at } = JSON.parse(previous) as { mark: string; at: number };
if (lastMark === mark && now - at < REPUBLISH_AFTER_S) {
result.skipped++;
continue;
}
} catch {
// Unreadable marker: republish, which is the safe direction.
}
}
const event = finalizeEvent(template, secret);
/*
* `Promise.allSettled`, not `Promise.all`: one relay refusing the event (rate
* limits, a paid-relay policy, a write it does not accept) must not stop the
* others from taking it. One acceptance is a successful publish.
*/
const outcomes = await Promise.allSettled(pool.publish(settings.relays, event));
const accepted = outcomes.filter((o) => o.status === 'fulfilled').length;
if (accepted === 0) {
result.failed++;
log.warn('announcement rejected by every relay', {
url: row.url,
d: template.tags[0]?.[1],
relays: settings.relays.length,
});
continue;
}
await setState(stateKey(row.url), JSON.stringify({ mark, at: now }));
result.published++;
log.info('announced lnurl mint', {
url: row.url,
d: template.tags[0]?.[1],
features: template.tags.find((t) => t[0] === 'features')?.[1] ?? '',
relays: `${accepted}/${settings.relays.length}`,
event: event.id,
});
}
} finally {
try {
pool.close(settings.relays);
} catch {
// A relay that is already gone throws on close. Nothing to do about it.
}
}
if (result.eligible > 0) {
log.info('announce cycle', { ...result, pubkey: getPublicKey(secret) });
}
return result;
}