Add shared LNURL types, indexing helpers, and warnings.
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>
This commit is contained in:
@@ -300,3 +300,201 @@ 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user