Introduce lnurl as a first-class mint type with probe/announcement fields and shared helpers the API and web can both rely on. Co-authored-by: Cursor <cursoragent@cursor.com>
501 lines
19 KiB
TypeScript
501 lines
19 KiB
TypeScript
/**
|
|
* Fedimint federations: what a NIP-87 kind 38173 announcement actually contains, and
|
|
* the handful of values derived from it that the API, the pages and the islands all
|
|
* have to agree on.
|
|
*
|
|
* Everything here was written against real events pulled off the relay pool this site
|
|
* already reads, not against a reading of the spec, for the same reason NOTES.md gives
|
|
* for the Cashu side: what matters is what publishers actually put on relays. Three
|
|
* places where the two differ, all resolved in favour of the wire:
|
|
*
|
|
* - the `n` tag is `bitcoin`, not `mainnet`. `normalizeNetwork` maps it.
|
|
* - `modules` are protocol short names (`ln`, `mint`, `wallet`, `lnv2`, `meta`,
|
|
* `stability_pool`), not the prose words. `MODULE_ALIASES` maps them.
|
|
* - `content` carries `{"federation_name": "..."}` rather than a kind-0 `name`.
|
|
* `parseFedimintAnnouncement` accepts either.
|
|
*
|
|
* A federation has no URL and no HTTP info endpoint, so its identity is the federation
|
|
* id from the `d` tag and nothing else. `fedimintKey` turns that into the synthetic
|
|
* primary key its row uses, and `fedimintSlug` into the routing slug.
|
|
*/
|
|
import {
|
|
sanitizeDisplayText, sanitizePictureUrl, tagValue, tagValues, type NostrEventLike,
|
|
} from './nostr.js';
|
|
|
|
/** Routing slugs are prefixed so a federation can never collide with a mint host. */
|
|
export const FEDIMINT_SLUG_PREFIX = 'fed-';
|
|
|
|
/** How much of the federation id the slug carries. The full id is in the payload. */
|
|
export const FEDIMINT_SLUG_CHARS = 16;
|
|
|
|
/** The scheme on the synthetic `mints.url` a federation row is keyed by. */
|
|
export const FEDIMINT_KEY_SCHEME = 'fedimint:';
|
|
|
|
/** A federation id is a 32 byte hash, written as 64 hex characters. */
|
|
export function isFederationId(value: unknown): value is string {
|
|
return typeof value === 'string' && /^[0-9a-f]{64}$/i.test(value);
|
|
}
|
|
|
|
/** `fed-` plus the first 16 characters of the federation id. */
|
|
export function fedimintSlug(federationId: string): string {
|
|
return FEDIMINT_SLUG_PREFIX + federationId.toLowerCase().slice(0, FEDIMINT_SLUG_CHARS);
|
|
}
|
|
|
|
/**
|
|
* The primary key a federation row uses in place of a mint URL.
|
|
*
|
|
* `mints.url` is the table's primary key and a federation has no URL to put there. A
|
|
* `fedimint:` scheme keeps the column non-null and unique, is obviously not something
|
|
* to fetch, and is what a review row points at.
|
|
*/
|
|
export function fedimintKey(federationId: string): string {
|
|
return FEDIMINT_KEY_SCHEME + federationId.toLowerCase();
|
|
}
|
|
|
|
/** The federation id inside a `fedimint:` key, or null if that is not what this is. */
|
|
export function federationIdFromKey(key: string): string | null {
|
|
if (!key.startsWith(FEDIMINT_KEY_SCHEME)) return null;
|
|
const id = key.slice(FEDIMINT_KEY_SCHEME.length);
|
|
return isFederationId(id) ? id.toLowerCase() : null;
|
|
}
|
|
|
|
/* ---------- invite codes ---------- */
|
|
|
|
/**
|
|
* A fedimint invite code: bech32m holding the federation id and the guardian addresses
|
|
* a wallet needs to join. Long, opaque, and the only thing a reader actually copies off
|
|
* a federation page.
|
|
*
|
|
* Every real code starts `fed11`, which is two things and not a typo: `fed1` is the
|
|
* human-readable part, and the `1` after it is bech32's separator. A first attempt at
|
|
* this anchored on `fed1` followed by a bech32 data character and rejected every code
|
|
* on the network, because bech32's alphabet deliberately excludes `1`.
|
|
*
|
|
* Past the prefix it is loose on purpose. The code is handed to a wallet verbatim and
|
|
* nothing in this codebase decodes it, so the check only has to keep junk out of a copy
|
|
* button — and being stricter than the wallets that consume it would mean dropping a
|
|
* federation from the site over a character this code has no opinion about.
|
|
*/
|
|
export function isInviteCode(value: unknown): value is string {
|
|
return typeof value === 'string' && /^fed1[a-z0-9]{20,}$/i.test(value);
|
|
}
|
|
|
|
/** Every usable invite code in a list, lowercased, in order, without repeats. */
|
|
export function cleanInviteCodes(values: readonly string[]): string[] {
|
|
const out: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const raw of values) {
|
|
const code = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
|
if (!isInviteCode(code) || seen.has(code)) continue;
|
|
seen.add(code);
|
|
out.push(code);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/* ---------- modules ---------- */
|
|
|
|
/**
|
|
* The three modules a federation page gives a named row of its own, and every short
|
|
* name that satisfies each.
|
|
*
|
|
* Versioned modules are aliases rather than separate rows: a federation running `lnv2`
|
|
* and no `ln` can still do Lightning, and a row reading "Lightning — not supported"
|
|
* beside a `lnv2` chip would be false. The version still shows, as its own chip in the
|
|
* list underneath.
|
|
*/
|
|
export const MODULE_ALIASES: Record<string, readonly string[]> = {
|
|
lightning: ['ln', 'lnv2', 'lightning'],
|
|
mint: ['mint', 'mintv2'],
|
|
wallet: ['wallet', 'walletv2'],
|
|
};
|
|
|
|
/** Order of the named rows, highest interest first. Mirrors HIGHLIGHT_NUTS. */
|
|
export const HIGHLIGHT_MODULES = ['lightning', 'mint', 'wallet'] as const;
|
|
export type HighlightModule = (typeof HIGHLIGHT_MODULES)[number];
|
|
|
|
/**
|
|
* Plain-language names for the module short names seen in the wild, and the English
|
|
* source of truth for them. The catalogs carry a translation per key under
|
|
* `fedimint.module.`; a module neither knows renders as its own short name, which is
|
|
* still a true label.
|
|
*/
|
|
export const MODULE_NAMES_EN: Record<string, string> = {
|
|
lightning: 'Lightning',
|
|
mint: 'Ecash mint',
|
|
wallet: 'On-chain wallet',
|
|
meta: 'Metadata',
|
|
stability_pool: 'Stability pool',
|
|
multi_sig_stability_pool: 'Stability pool (multisig)',
|
|
'fedi-social': 'Social recovery',
|
|
unknown: 'Unknown module',
|
|
};
|
|
|
|
/** Which highlight row a module short name belongs to, or null for the chip list. */
|
|
export function highlightModuleFor(module: string): HighlightModule | null {
|
|
const name = module.toLowerCase();
|
|
for (const key of HIGHLIGHT_MODULES) {
|
|
if (MODULE_ALIASES[key]?.includes(name)) return key;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** True when this federation runs anything satisfying one of the named rows. */
|
|
export function hasModule(modules: readonly string[], key: HighlightModule): boolean {
|
|
const aliases = MODULE_ALIASES[key] ?? [];
|
|
return modules.some((module) => aliases.includes(module.toLowerCase()));
|
|
}
|
|
|
|
/**
|
|
* Split a `modules` tag: `"ln,mint,wallet,lnv2,meta"`.
|
|
*
|
|
* Comma separated in every event seen, but whitespace is tolerated because a publisher
|
|
* writing `"ln, mint"` meant the same thing.
|
|
*/
|
|
export function parseModules(value: string | null | undefined): string[] {
|
|
if (!value) return [];
|
|
const out: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const part of value.split(/[,\s]+/)) {
|
|
const module = part.trim().toLowerCase();
|
|
if (!module || module.length > 40 || seen.has(module)) continue;
|
|
if (!/^[a-z0-9_-]+$/.test(module)) continue;
|
|
seen.add(module);
|
|
out.push(module);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/* ---------- network ---------- */
|
|
|
|
/** The networks this site has a name for. Anything else renders as published. */
|
|
export const KNOWN_NETWORKS = ['mainnet', 'testnet', 'testnet4', 'signet', 'regtest'] as const;
|
|
|
|
/**
|
|
* Normalize an `n` tag.
|
|
*
|
|
* NIP-87 describes the value as mainnet/testnet/signet/regtest; every real announcement
|
|
* on the network writes `bitcoin` for mainnet, which is what fedimint's own config
|
|
* calls it. Both are accepted and both come out as `mainnet`, so one federation cannot
|
|
* appear on two networks depending on which word its operator used.
|
|
*/
|
|
export function normalizeNetwork(value: string | null | undefined): string | null {
|
|
const raw = value?.trim().toLowerCase();
|
|
if (!raw) return null;
|
|
if (raw === 'bitcoin' || raw === 'main' || raw === 'mainnet') return 'mainnet';
|
|
if (!/^[a-z0-9]{1,20}$/.test(raw)) return null;
|
|
return raw;
|
|
}
|
|
|
|
/** True for anything that is not real bitcoin. Worth a badge of its own on a page. */
|
|
export function isTestNetwork(network: string | null): boolean {
|
|
return network !== null && network !== 'mainnet';
|
|
}
|
|
|
|
/* ---------- the announcement ---------- */
|
|
|
|
export interface FedimintAnnouncement {
|
|
/** The `d` tag: the federation id, lowercase hex. */
|
|
federationId: string;
|
|
/** Every `u` tag that is a usable invite code. At least one, or this is not valid. */
|
|
inviteCodes: string[];
|
|
/** The `modules` tag, split. */
|
|
modules: string[];
|
|
/** The `n` tag, normalized. */
|
|
network: string | null;
|
|
/** From `content`, which is kind-0-shaped metadata or fedimint's own. */
|
|
name: string | null;
|
|
picture: string | null;
|
|
about: string | null;
|
|
/** Who published it. Needed for the `a` tag a review points back with. */
|
|
announcerPubkey: string;
|
|
announcedAt: number;
|
|
}
|
|
|
|
/**
|
|
* Read a kind 38173 event, or return null if it is not one this site can use.
|
|
*
|
|
* An announcement with no valid `d` and no invite code is not something a reader can
|
|
* act on: there is nothing to join and nothing to key the row by. Everything else is
|
|
* optional and simply renders as absent.
|
|
*/
|
|
export function parseFedimintAnnouncement(event: NostrEventLike): FedimintAnnouncement | null {
|
|
const d = tagValue(event.tags, 'd');
|
|
if (!isFederationId(d)) return null;
|
|
|
|
const inviteCodes = cleanInviteCodes(tagValues(event.tags, 'u'));
|
|
if (inviteCodes.length === 0) return null;
|
|
|
|
const meta = parseAnnouncementMetadata(event.content);
|
|
|
|
return {
|
|
federationId: d.toLowerCase(),
|
|
inviteCodes,
|
|
modules: parseModules(tagValue(event.tags, 'modules')),
|
|
network: normalizeNetwork(tagValue(event.tags, 'n')),
|
|
name: meta.name,
|
|
picture: meta.picture,
|
|
about: meta.about,
|
|
announcerPubkey: event.pubkey,
|
|
announcedAt: event.created_at,
|
|
};
|
|
}
|
|
|
|
export interface AnnouncementMetadata {
|
|
name: string | null;
|
|
picture: string | null;
|
|
about: string | null;
|
|
}
|
|
|
|
/**
|
|
* The `content` of an announcement, which NIP-87 describes as kind-0-style metadata.
|
|
*
|
|
* Every fedimint announcement seen writes `{"federation_name": "..."}` instead, so both
|
|
* spellings are read. Unparseable content degrades to no metadata rather than throwing:
|
|
* this is arbitrary text written by anyone with a relay connection.
|
|
*/
|
|
export function parseAnnouncementMetadata(content: string): AnnouncementMetadata {
|
|
const empty: AnnouncementMetadata = { name: null, picture: null, about: null };
|
|
if (!content.trim()) return empty;
|
|
|
|
let meta: Record<string, unknown>;
|
|
try {
|
|
const parsed: unknown = JSON.parse(content);
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return empty;
|
|
meta = parsed as Record<string, unknown>;
|
|
} catch {
|
|
return empty;
|
|
}
|
|
|
|
const first = (...keys: string[]): unknown => {
|
|
for (const key of keys) if (meta[key] !== undefined && meta[key] !== null) return meta[key];
|
|
return undefined;
|
|
};
|
|
|
|
return {
|
|
name: sanitizeDisplayText(first('name', 'federation_name', 'display_name'), 64) ?? null,
|
|
picture: sanitizePictureUrl(first('picture', 'federation_icon_url', 'icon_url', 'image')) ?? null,
|
|
about: sanitizeDisplayText(first('about', 'description', 'federation_description'), 400) ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The federation id a slug carries, which is the first 16 characters of it.
|
|
*
|
|
* The list payload has no `federation_id` — that lives on the detail — so a card built
|
|
* from the list reads its identifier back out of the routing slug. Shortened is all a
|
|
* card has room for anyway, and the full id is on the page it links to.
|
|
*/
|
|
export function federationIdFromSlug(slug: string): string {
|
|
return slug.startsWith(FEDIMINT_SLUG_PREFIX) ? slug.slice(FEDIMINT_SLUG_PREFIX.length) : slug;
|
|
}
|
|
|
|
/**
|
|
* The short form of an invite code, for a row that has to fit one.
|
|
*
|
|
* More head than tail: the prefix is what tells a reader it is an invite code at all,
|
|
* and the tail is what tells two of the same federation's codes apart.
|
|
*/
|
|
export function shortInviteCode(code: string, head = 14, tail = 8): string {
|
|
if (code.length <= head + tail + 1) return code;
|
|
return `${code.slice(0, head)}…${code.slice(-tail)}`;
|
|
}
|
|
|
|
/* ---------- decoding an invite code ---------- */
|
|
|
|
/**
|
|
* Decoding one, which until now nothing in this codebase did.
|
|
*
|
|
* `isInviteCode` above is a shape check, and a shape check is all the *display* side
|
|
* has ever needed: a code arrives inside an announcement that already carries the
|
|
* federation id in its `d` tag, and the code itself is handed to a wallet verbatim.
|
|
*
|
|
* On-demand indexing changes that. A reader pasting an invite code into the review
|
|
* dialog hands over the only thing they have, and there is no announcement beside it
|
|
* to read an id off — so the id has to come out of the code, or the federation cannot
|
|
* be keyed, deduped against what is already indexed, or given a page.
|
|
*
|
|
* Two layers, both small and both self-contained (a bech32 dependency for one function
|
|
* would be the tail wagging the dog):
|
|
*
|
|
* 1. **bech32m**, per BIP-350: the same alphabet and checksum as bech32 with a
|
|
* different constant. Fedimint uses bech32m, so a code that verifies under the
|
|
* *bech32* constant is rejected rather than accepted — it would mean the code was
|
|
* produced by something else.
|
|
* 2. **fedimint's consensus encoding** of `Vec<InviteCodePart>`: a BigSize count, then
|
|
* per part a BigSize tag, a BigSize length and that many bytes. Tag 1 is the
|
|
* federation id, 32 bytes. Everything else — guardian API URLs (tag 0), an API
|
|
* secret (tag 2), anything a later fedimint adds — is skipped by its length
|
|
* without being understood, which is what the tag/length framing is for.
|
|
*
|
|
* Written against real codes off the relay pool, not against a reading of the Rust, and
|
|
* `check-index.ts` asserts it still decodes them.
|
|
*/
|
|
|
|
/** bech32's alphabet, and its two checksum constants. `1` is deliberately not in it. */
|
|
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
|
const BECH32M_CONST = 0x2bc830a3;
|
|
const GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
|
|
|
function bech32Polymod(values: readonly number[]): number {
|
|
let chk = 1;
|
|
for (const value of values) {
|
|
const top = chk >> 25;
|
|
chk = ((chk & 0x1ffffff) << 5) ^ value;
|
|
for (let i = 0; i < 5; i++) if ((top >> i) & 1) chk ^= GENERATOR[i]!;
|
|
}
|
|
return chk >>> 0;
|
|
}
|
|
|
|
function hrpExpand(hrp: string): number[] {
|
|
const out: number[] = [];
|
|
for (const char of hrp) out.push(char.charCodeAt(0) >> 5);
|
|
out.push(0);
|
|
for (const char of hrp) out.push(char.charCodeAt(0) & 31);
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The payload bytes of a bech32m string, or null if it is not a valid one.
|
|
*
|
|
* Length capped well above any real invite code: the checksum is only meaningful over
|
|
* a string somebody could plausibly have produced, and an unbounded input here would
|
|
* be an unbounded loop below.
|
|
*/
|
|
function decodeBech32m(input: string): { hrp: string; bytes: Uint8Array } | null {
|
|
if (input.length < 8 || input.length > 4000) return null;
|
|
// Mixed case is invalid in bech32; one case throughout is not.
|
|
if (input !== input.toLowerCase() && input !== input.toUpperCase()) return null;
|
|
|
|
const value = input.toLowerCase();
|
|
const split = value.lastIndexOf('1');
|
|
if (split < 1 || split + 7 > value.length) return null;
|
|
|
|
const hrp = value.slice(0, split);
|
|
for (const char of hrp) {
|
|
const code = char.charCodeAt(0);
|
|
if (code < 33 || code > 126) return null;
|
|
}
|
|
|
|
const data: number[] = [];
|
|
for (const char of value.slice(split + 1)) {
|
|
const index = BECH32_CHARSET.indexOf(char);
|
|
if (index === -1) return null;
|
|
data.push(index);
|
|
}
|
|
|
|
if (bech32Polymod([...hrpExpand(hrp), ...data]) !== BECH32M_CONST) return null;
|
|
|
|
// Five-bit groups to eight, dropping the checksum and the final partial group.
|
|
const payload = data.slice(0, -6);
|
|
const bytes: number[] = [];
|
|
let acc = 0;
|
|
let bits = 0;
|
|
for (const group of payload) {
|
|
acc = (acc << 5) | group;
|
|
bits += 5;
|
|
while (bits >= 8) {
|
|
bits -= 8;
|
|
bytes.push((acc >> bits) & 0xff);
|
|
}
|
|
}
|
|
// Leftover bits must be zero padding, and there must be fewer than five of them.
|
|
if (bits >= 5 || ((acc << (8 - bits)) & 0xff) !== 0) return null;
|
|
|
|
return { hrp, bytes: Uint8Array.from(bytes) };
|
|
}
|
|
|
|
/** A cursor over the decoded bytes, reading fedimint's BigSize integers and blobs. */
|
|
class ByteReader {
|
|
private at = 0;
|
|
constructor(private readonly bytes: Uint8Array) {}
|
|
|
|
get done(): boolean {
|
|
return this.at >= this.bytes.length;
|
|
}
|
|
|
|
/**
|
|
* Lightning's BigSize, which is what fedimint encodes a `u64` as: one byte under
|
|
* 0xfd, otherwise a marker and 2, 4 or 8 big-endian bytes.
|
|
*
|
|
* Returns null rather than throwing when the buffer runs out, so a truncated code is
|
|
* a rejected code and not an exception a caller has to catch.
|
|
*/
|
|
bigSize(): number | null {
|
|
const first = this.byte();
|
|
if (first === null) return null;
|
|
if (first < 0xfd) return first;
|
|
|
|
const width = first === 0xfd ? 2 : first === 0xfe ? 4 : 8;
|
|
let value = 0;
|
|
for (let i = 0; i < width; i++) {
|
|
const next = this.byte();
|
|
if (next === null) return null;
|
|
// Above 2^53 nothing here is a real length or tag anyway, and the arithmetic
|
|
// stops being exact, so an absurd value is refused rather than rounded.
|
|
value = value * 256 + next;
|
|
if (value > Number.MAX_SAFE_INTEGER) return null;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
bytesOf(length: number): Uint8Array | null {
|
|
if (length < 0 || this.at + length > this.bytes.length) return null;
|
|
const slice = this.bytes.subarray(this.at, this.at + length);
|
|
this.at += length;
|
|
return slice;
|
|
}
|
|
|
|
private byte(): number | null {
|
|
return this.at < this.bytes.length ? this.bytes[this.at++]! : null;
|
|
}
|
|
}
|
|
|
|
/** The tag fedimint gives the federation id inside an invite code. */
|
|
const INVITE_PART_FEDERATION_ID = 1;
|
|
/** A federation id is a 32 byte hash. A part of any other length is not one. */
|
|
const FEDERATION_ID_BYTES = 32;
|
|
/** Real codes carry two or three parts. This only has to stop a hostile count. */
|
|
const MAX_INVITE_PARTS = 64;
|
|
|
|
function toHex(bytes: Uint8Array): string {
|
|
let out = '';
|
|
for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The federation id inside an invite code, or null if the code is not one.
|
|
*
|
|
* Null covers every way a pasted string can fail — wrong prefix, a typo the checksum
|
|
* catches, valid bech32m that is not an invite code, an invite code with no federation
|
|
* id part — because a caller has exactly one thing to say about all of them ("that is
|
|
* not an invite code") and telling them apart would be telling a stranger which of
|
|
* their guesses was closest.
|
|
*/
|
|
export function federationIdFromInviteCode(code: string): string | null {
|
|
const raw = code?.trim();
|
|
if (!raw || !/^fed1[a-z0-9]+$/i.test(raw)) return null;
|
|
|
|
const decoded = decodeBech32m(raw);
|
|
// `fed1` is the human-readable part; the `1` after it is bech32's separator.
|
|
if (!decoded || decoded.hrp !== 'fed1') return null;
|
|
|
|
const reader = new ByteReader(decoded.bytes);
|
|
const parts = reader.bigSize();
|
|
if (parts === null || parts === 0 || parts > MAX_INVITE_PARTS) return null;
|
|
|
|
for (let i = 0; i < parts; i++) {
|
|
const tag = reader.bigSize();
|
|
const length = tag === null ? null : reader.bigSize();
|
|
const value = length === null ? null : reader.bytesOf(length);
|
|
if (value === null) return null;
|
|
|
|
if (tag === INVITE_PART_FEDERATION_ID && value.length === FEDERATION_ID_BYTES) {
|
|
return toHex(value);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|