1// Shared client for the black-box conformance suite. It speaks plain2// NIP-01 over a websocket to whatever relay RELAY_URL names.3import WebSocket from "ws";4import { afterEach } from "vitest";5import { finalizeEvent, generateSecretKey, getPublicKey, type Event as NostrEvent, type EventTemplate } from "nostr-tools/pure";6import { sha256 } from "@noble/hashes/sha2.js";7import { Negentropy, hexToBytes, type SyncItem } from "./negentropy.ts";89export const RELAY_URL = process.env.RELAY_URL ?? "ws://127.0.0.1:7447";10export const HTTP_URL = RELAY_URL.replace(/^ws/, "http");11export const now = () => Math.floor(Date.now() / 1000);12export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));1314export type Key = Uint8Array;15export const newKey = (): Key => generateSecretKey();16// fixedKey gives multi-process conformance runs stable actors without ever17// reading a signing key from the environment.18export const fixedKey = (seed: string): Key => sha256(new TextEncoder().encode(`tinyrelay conformance actor:${seed}`));19export const pub = (sk: Key) => getPublicKey(sk);2021export function newEvent(sk: Key, kind: number, content: string, tags: string[][] = [], created_at = now()): NostrEvent {22 return finalizeEvent({ kind, content, tags, created_at }, sk);23}2425export function difficulty(id: string): number {26 let bits = 0;27 for (const ch of id) {28 const n = parseInt(ch, 16);29 if (n === 0) {30 bits += 4;31 continue;32 }33 bits += Math.clz32(n) - 28;34 break;35 }36 return bits;37}3839export function mine(sk: Key, t: EventTemplate, bits: number): NostrEvent {40 for (let n = 0; ; n++) {41 const e = finalizeEvent({ ...t, tags: [["nonce", String(n), String(bits)]] }, sk);42 if (difficulty(e.id) >= bits) return e;43 }44}4546export class Client {47 private ws!: WebSocket;48 private queue: any[][] = [];49 private waiters: ((m: any[]) => void)[] = [];50 challenge = "";51 closed = false;5253 static async connect(url = RELAY_URL): Promise<Client> {54 const c = new Client();55 c.ws = new WebSocket(url);56 // Listen before open: the relay's AUTH challenge can arrive in the same tick.57 c.ws.on("message", (data) => {58 const msg = JSON.parse(data.toString());59 const w = c.waiters.shift();60 if (w) w(msg);61 else c.queue.push(msg);62 });63 c.ws.on("close", () => {64 c.closed = true;65 });66 await new Promise<void>((resolve, reject) => {67 c.ws.once("open", () => resolve());68 c.ws.once("error", reject);69 });70 // NIP-42: every connection opens with a challenge.71 const first = await c.recv();72 if (first[0] !== "AUTH") throw new Error(`expected AUTH challenge, got ${JSON.stringify(first)}`);73 c.challenge = first[1];74 return c;75 }7677 send(...msg: any[]) {78 this.ws.send(JSON.stringify(msg));79 }8081 recv(timeoutMs = 15_000): Promise<any[]> {82 const m = this.queue.shift();83 if (m) return Promise.resolve(m);84 return new Promise((resolve, reject) => {85 const waiter = (msg: any[]) => {86 clearTimeout(t);87 resolve(msg);88 };89 const t = setTimeout(() => {90 this.waiters = this.waiters.filter((w) => w !== waiter);91 reject(new Error("timed out waiting for a message"));92 }, timeoutMs);93 this.waiters.push(waiter);94 });95 }9697 async expect(type: string): Promise<any[]> {98 const m = await this.recv();99 if (m[0] !== type) throw new Error(`expected ${type}, got ${JSON.stringify(m)}`);100 return m;101 }102103 async expectOK(id: string, ok: boolean): Promise<string> {104 const m = await this.expect("OK");105 if (m[1] !== id || m[2] !== ok) throw new Error(`OK mismatch: want (${id},${ok}) got ${JSON.stringify(m)}`);106 return m[3];107 }108109 async expectClosed(prefix: string): Promise<void> {110 const m = await this.expect("CLOSED");111 if (!String(m[2]).startsWith(prefix)) throw new Error(`expected CLOSED ${prefix}..., got ${JSON.stringify(m)}`);112 }113114 // drain reads until EOSE, returning the events seen and the EOSE hints.115 async drain(): Promise<{ events: NostrEvent[]; hints: string[] }> {116 const events: NostrEvent[] = [];117 for (;;) {118 const m = await this.recv();119 if (m[0] === "EVENT") events.push(m[2]);120 else if (m[0] === "EOSE") return { events, hints: Array.isArray(m[2]) ? m[2] : [] };121 else throw new Error(`unexpected ${JSON.stringify(m)}`);122 }123 }124125 async req(filter: Record<string, unknown>, id = "q" + Math.random().toString(36).slice(2, 8)) {126 this.send("REQ", id, filter);127 const r = await this.drain();128 this.send("CLOSE", id);129 return r;130 }131132 async publish(e: NostrEvent): Promise<string> {133 this.send("EVENT", e);134 return this.expectOK(e.id, true);135 }136137 async expectNothing(ms = 400) {138 try {139 const m = await this.recv(ms);140 throw new Error(`expected silence, got ${JSON.stringify(m)}`);141 } catch (err) {142 if (!(err instanceof Error) || !err.message.includes("timed out")) throw err;143 }144 }145146 async auth(sk: Key, relay = RELAY_URL) {147 const e = newEvent(sk, 22242, "", [["relay", relay], ["challenge", this.challenge]]);148 this.send("AUTH", e);149 await this.expectOK(e.id, true);150 }151152 // sync runs a full NIP-77 exchange for the filter and returns have/need.153 async sync(items: SyncItem[], filter: Record<string, unknown>, id = "s1"): Promise<{ have: string[]; need: string[] }> {154 const neg = new Negentropy(items, sha256);155 let msg: Uint8Array | null = neg.initiate();156 this.send("NEG-OPEN", id, filter, hex(msg));157 const have: string[] = [];158 const need: string[] = [];159 for (;;) {160 const m = await this.recv();161 if (m[0] === "NEG-ERR") throw new Error(`NEG-ERR ${m[2]}`);162 if (m[0] !== "NEG-MSG") throw new Error(`unexpected ${JSON.stringify(m)}`);163 const r = neg.reconcile(hexToBytes(m[2]));164 have.push(...r.have);165 need.push(...r.need);166 if (!r.reply) {167 this.send("NEG-CLOSE", id);168 return { have: have.sort(), need: need.sort() };169 }170 this.send("NEG-MSG", id, hex(r.reply));171 }172 }173174 close() {175 this.ws.close();176 }177}178179export function hex(b: Uint8Array): string {180 let s = "";181 for (const x of b) s += x.toString(16).padStart(2, "0");182 return s;183}184185export function item(e: NostrEvent): SyncItem {186 return { timestamp: e.created_at, id: hexToBytes(e.id) };187}188189export function rand(): string {190 return Math.random().toString(36).slice(2, 10);191}192193// randHex returns n random lowercase hex characters.194export function randHex(n = 64): string {195 let s = "";196 while (s.length < n) s += Math.floor(Math.random() * 16).toString(16);197 return s;198}199200// sockets returns a connect that keeps its clients and closes them after201// each test.202export function sockets() {203 const open: Client[] = [];204 afterEach(() => open.splice(0).forEach((c) => c.close()));205 return async () => {206 const c = await Client.connect();207 open.push(c);208 return c;209 };210}