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:
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user