/** * 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 { 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(`SELECT * FROM mints WHERE type = 'lnurl'`); const pool = new SimplePool(); const result: AnnounceResult = { ...empty }; try { for (const row of rows) { const fields = parseEcosystem(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; }