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

312 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A Nostr relay, in one file, for tests.
*
* Enough of NIP-01 to accept an EVENT, answer a REQ and close a subscription, plus the
* addressable-replacement rule, which is not optional here: the whole point of the
* round-trip test is a `kind:38174`, and an addressable kind that a relay stores twice
* would let a broken publisher pass.
*
* Written rather than depended on, deliberately. The alternatives were a real relay
* binary (which CI would have to install and keep running) or a `ws` dependency added to
* the lockfile for test-only code. Node 22 ships a WebSocket *client* — which is what
* nostr-tools uses — but no server, so the handshake and framing below are the actual
* cost of not adding either, and RFC 6455 is small when the only frames that matter are
* short unfragmented text ones.
*
* **Not for production.** No authentication, no persistence, no NIP-42, no rate limits,
* no fragmentation support beyond a single continuation, and everything lives in a Map
* until the process exits. `api/src/check-lnurl.ts` is the only caller.
*/
import { createHash } from 'node:crypto';
import { createServer, type IncomingMessage, type Server } from 'node:http';
import type { Duplex } from 'node:stream';
/** RFC 6455's fixed handshake GUID. */
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
interface StoredEvent {
id: string;
pubkey: string;
kind: number;
created_at: number;
content: string;
tags: string[][];
sig?: string;
}
type Filter = Record<string, unknown>;
/* ---------- framing ---------- */
/** Encode one unfragmented text frame, server to client, never masked. */
function encodeText(text: string): Buffer {
const payload = Buffer.from(text, 'utf8');
const length = payload.length;
let header: Buffer;
if (length < 126) {
header = Buffer.from([0x81, length]);
} else if (length < 65536) {
header = Buffer.alloc(4);
header[0] = 0x81;
header[1] = 126;
header.writeUInt16BE(length, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x81;
header[1] = 127;
header.writeBigUInt64BE(BigInt(length), 2);
}
return Buffer.concat([header, payload]);
}
interface DecodedFrame {
opcode: number;
payload: Buffer;
/** Total bytes consumed, so the caller can advance its buffer. */
size: number;
}
/**
* Decode one frame from the front of `buffer`, or null when it is not all there yet.
*
* Client frames are always masked, per the spec, so the mask is applied unconditionally
* when the bit is set and ignored when it is not — a browser or Node client never omits
* it, and a test relay has no reason to reject one that did.
*/
function decodeFrame(buffer: Buffer): DecodedFrame | null {
if (buffer.length < 2) return null;
const first = buffer[0]!;
const second = buffer[1]!;
const opcode = first & 0x0f;
const masked = (second & 0x80) !== 0;
let length = second & 0x7f;
let offset = 2;
if (length === 126) {
if (buffer.length < offset + 2) return null;
length = buffer.readUInt16BE(offset);
offset += 2;
} else if (length === 127) {
if (buffer.length < offset + 8) return null;
const big = buffer.readBigUInt64BE(offset);
// A test relay has no business buffering a 4GB frame.
if (big > 8n * 1024n * 1024n) throw new Error('frame too large');
length = Number(big);
offset += 8;
}
let mask: Buffer | null = null;
if (masked) {
if (buffer.length < offset + 4) return null;
mask = buffer.subarray(offset, offset + 4);
offset += 4;
}
if (buffer.length < offset + length) return null;
const payload = Buffer.from(buffer.subarray(offset, offset + length));
if (mask) {
for (let i = 0; i < payload.length; i++) payload[i] = payload[i]! ^ mask[i % 4]!;
}
return { opcode, payload, size: offset + length };
}
/* ---------- filters ---------- */
function matchesFilter(event: StoredEvent, filter: Filter): boolean {
const ids = filter['ids'] as string[] | undefined;
if (ids && !ids.includes(event.id)) return false;
const authors = filter['authors'] as string[] | undefined;
if (authors && !authors.includes(event.pubkey)) return false;
const kinds = filter['kinds'] as number[] | undefined;
if (kinds && !kinds.includes(event.kind)) return false;
const since = filter['since'] as number | undefined;
if (typeof since === 'number' && event.created_at < since) return false;
const until = filter['until'] as number | undefined;
if (typeof until === 'number' && event.created_at > until) return false;
// `#e`, `#p`, `#d`, `#k`, `#u`: match any value of that single-letter tag.
for (const [key, wanted] of Object.entries(filter)) {
if (!key.startsWith('#') || key.length !== 2) continue;
const name = key.slice(1);
const values = wanted as string[];
const present = event.tags.filter((t) => t[0] === name).map((t) => t[1]);
if (!present.some((value) => value !== undefined && values.includes(value))) return false;
}
return true;
}
/**
* The storage key for an event.
*
* Addressable kinds (30000–39999) are keyed by `kind:pubkey:d`, so a second announcement
* from the same publisher for the same mint replaces the first instead of accumulating —
* which is exactly the behaviour `kind:38174` depends on and therefore exactly what a
* test of it must reproduce. Everything else is keyed by its own id.
*/
function storageKey(event: StoredEvent): string {
if (event.kind >= 30000 && event.kind < 40000) {
const d = event.tags.find((t) => t[0] === 'd')?.[1] ?? '';
return `${event.kind}:${event.pubkey}:${d}`;
}
if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) {
return `${event.kind}:${event.pubkey}`;
}
return event.id;
}
/* ---------- the relay ---------- */
export interface TestRelay {
/** `ws://127.0.0.1:<port>`, ready to hand to a pool. */
url: string;
/** Every event currently stored, newest first. */
events(): StoredEvent[];
/** Events of one kind, newest first. */
byKind(kind: number): StoredEvent[];
close(): Promise<void>;
}
/**
* Start a relay on an ephemeral port.
*
* Port 0 rather than a fixed one so two tests, or two CI jobs on one machine, cannot
* collide — the caller reads the real port back off `url`.
*/
export async function startTestRelay(): Promise<TestRelay> {
const stored = new Map<string, StoredEvent>();
const sockets = new Set<Duplex>();
const server: Server = createServer((_req, res) => {
// Not a websocket upgrade. NIP-11 would go here on a real relay.
res.writeHead(426, { 'Content-Type': 'text/plain' });
res.end('websocket only');
});
server.on('upgrade', (req: IncomingMessage, socket: Duplex) => {
const key = req.headers['sec-websocket-key'];
if (typeof key !== 'string') {
socket.destroy();
return;
}
const accept = createHash('sha1').update(key + WS_GUID).digest('base64');
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
);
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
socket.on('error', () => sockets.delete(socket));
const send = (message: unknown): void => {
if (!socket.destroyed) socket.write(encodeText(JSON.stringify(message)));
};
let buffer = Buffer.alloc(0);
socket.on('data', (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
for (;;) {
let frame: DecodedFrame | null;
try {
frame = decodeFrame(buffer);
} catch {
socket.destroy();
return;
}
if (!frame) break;
buffer = buffer.subarray(frame.size);
if (frame.opcode === 0x8) {
socket.end();
return;
}
// Ping: answer with a pong carrying the same payload, per the spec.
if (frame.opcode === 0x9) {
const pong = encodeText('');
pong[0] = 0x8a;
socket.write(pong);
continue;
}
if (frame.opcode !== 0x1) continue;
let message: unknown;
try {
message = JSON.parse(frame.payload.toString('utf8'));
} catch {
continue;
}
if (!Array.isArray(message)) continue;
const [verb, ...rest] = message as [string, ...unknown[]];
if (verb === 'EVENT') {
const event = rest[0] as StoredEvent | undefined;
if (!event?.id) continue;
const existing = stored.get(storageKey(event));
// Older replacement for an addressable kind: keep what is there, and still
// answer OK, which is what a real relay does.
if (!existing || existing.created_at <= event.created_at) {
stored.set(storageKey(event), event);
}
send(['OK', event.id, true, '']);
continue;
}
if (verb === 'REQ') {
const subId = rest[0] as string;
const filters = rest.slice(1) as Filter[];
const all = [...stored.values()].sort((a, b) => b.created_at - a.created_at);
for (const filter of filters) {
const limit = typeof filter['limit'] === 'number' ? (filter['limit'] as number) : Infinity;
let sent = 0;
for (const event of all) {
if (sent >= limit) break;
if (!matchesFilter(event, filter)) continue;
send(['EVENT', subId, event]);
sent++;
}
}
send(['EOSE', subId]);
continue;
}
if (verb === 'CLOSE') {
send(['CLOSED', rest[0] as string, '']);
}
}
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('relay did not bind a port');
return {
url: `ws://127.0.0.1:${address.port}`,
events: () => [...stored.values()].sort((a, b) => b.created_at - a.created_at),
byKind: (kind) =>
[...stored.values()].filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at),
close: async () => {
for (const socket of sockets) socket.destroy();
sockets.clear();
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}