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