// Nostr channel: queries relays for kind-30078 events authored by the // requested npub, then runs each event's content through parseAndVerifyFor. // Fetcher is abstracted so tests use canned events. import { type Identity, NostrSecret, nostrPubkeyHex } from "@kez/core"; import { sha256 } from "@noble/hashes/sha2"; import { bytesToHex } from "@noble/hashes/utils"; import { ChannelError, type Channel, type ChannelHit, parseAndVerifyFor } from "./index.js"; export const KEZ_NOSTR_KIND = 30078; const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net"]; const FETCH_TIMEOUT_MS = 8_000; export interface NostrFilter { authors: string[]; // lowercase hex pubkeys kinds: number[]; limit?: number; } export interface NostrEvent { id: string; pubkey: string; created_at: number; kind: number; tags: string[][]; content: string; sig: string; } export interface NostrFetcher { fetchEvents(filter: NostrFilter): Promise; } export class RelayPoolFetcher implements NostrFetcher { constructor(private relays: string[] = DEFAULT_RELAYS) {} async fetchEvents(filter: NostrFilter): Promise { let lastError: Error | undefined; const events: NostrEvent[] = []; for (const relay of this.relays) { try { events.push(...(await queryRelay(relay, filter))); } catch (e) { lastError = e as Error; } if (events.length > 0) break; } if (events.length === 0 && lastError) { throw ChannelError.unreachable(lastError.message, lastError); } return events; } } async function queryRelay(url: string, filter: NostrFilter): Promise { // Node 22+ ships a global WebSocket; we use it directly. // eslint-disable-next-line no-undef const ws = new WebSocket(url); const subId = "kez-1"; const events: NostrEvent[] = []; await new Promise((resolve, reject) => { const timer = setTimeout(() => { try { ws.close(); } catch { /* noop */ } reject(new Error(`relay ${url} timed out after ${FETCH_TIMEOUT_MS}ms`)); }, FETCH_TIMEOUT_MS); ws.addEventListener("open", () => { ws.send(buildReqMessage(subId, filter)); }); ws.addEventListener("message", (ev: MessageEvent) => { const parsed = parseRelayMessage(typeof ev.data === "string" ? ev.data : String(ev.data)); if (parsed.kind === "event") events.push(parsed.event); else if (parsed.kind === "eose") { clearTimeout(timer); try { ws.send(JSON.stringify(["CLOSE", subId])); } catch { /* noop */ } try { ws.close(); } catch { /* noop */ } resolve(); } }); ws.addEventListener("error", () => { clearTimeout(timer); reject(new Error(`relay ${url} websocket error`)); }); ws.addEventListener("close", () => { clearTimeout(timer); resolve(); }); }); return events; } export class NostrChannel implements Channel { readonly system = "nostr"; private readonly fetcher: NostrFetcher; constructor(fetcher: NostrFetcher = new RelayPoolFetcher()) { this.fetcher = fetcher; } async fetchAndVerify(identity: Identity): Promise { const pubkeyHex = nostrPubkeyHex(identity); const filter: NostrFilter = { authors: [pubkeyHex], kinds: [KEZ_NOSTR_KIND], limit: 20, }; let events: NostrEvent[]; try { events = await this.fetcher.fetchEvents(filter); } catch (e) { if (e instanceof ChannelError) throw e; throw ChannelError.unreachable((e as Error).message, e); } let lastError: ChannelError | undefined; for (const ev of events) { if (!eventMatchesAuthor(ev, pubkeyHex)) continue; try { return parseAndVerifyFor(ev.content, identity); } catch (e) { lastError = e instanceof ChannelError ? e : ChannelError.invalid((e as Error).message, e); } } throw lastError ?? ChannelError.notFound(identity); } } /** * Build and sign a NIP-01 event. Event id = sha256 of the canonical array * [0, pubkey, created_at, kind, tags, content]; signature = Schnorr over * that id. Matches Rust's `build_signed_event` byte-for-byte. */ export function buildSignedEvent( signer: NostrSecret, createdAt: number, kind: number, tags: string[][], content: string, ): NostrEvent { const pubkey = signer.pubkeyHex(); const canonical = JSON.stringify([0, pubkey, createdAt, kind, tags, content]); const digest = sha256(new TextEncoder().encode(canonical)); const id = bytesToHex(digest); const sig = bytesToHex(signer.signDigest(digest)); return { id, pubkey, created_at: createdAt, kind, tags, content, sig }; } /** * Publish a single event to one relay. Waits up to 5s for `["OK", id, true]`; * silently accepts timeouts (many relays accept without replying). */ export async function publishEventToRelay( relayUrl: string, event: NostrEvent, ): Promise { // eslint-disable-next-line no-undef const ws = new WebSocket(relayUrl); const deadline = 5_000; await new Promise((resolve, reject) => { const timer = setTimeout(() => { try { ws.close(); } catch { /* noop */ } // Timeout = treat as accepted; client can re-fetch to confirm. resolve(); }, deadline); ws.addEventListener("open", () => { try { ws.send(JSON.stringify(["EVENT", event])); } catch (e) { clearTimeout(timer); reject(ChannelError.unreachable(`send EVENT ${relayUrl}: ${(e as Error).message}`)); } }); ws.addEventListener("message", (ev: MessageEvent) => { const text = typeof ev.data === "string" ? ev.data : String(ev.data); let arr: unknown; try { arr = JSON.parse(text); } catch { return; } if (!Array.isArray(arr)) return; if (arr[0] === "OK") { clearTimeout(timer); try { ws.close(); } catch { /* noop */ } if (arr[2] === false) { const reason = typeof arr[3] === "string" ? arr[3] : ""; reject( ChannelError.other(`relay ${relayUrl} rejected event: ${reason}`), ); } else { resolve(); } } // NOTICE messages are informational; keep waiting. }); ws.addEventListener("error", () => { clearTimeout(timer); reject(ChannelError.unreachable(`relay ${relayUrl} websocket error`)); }); }); } export function buildReqMessage(subId: string, filter: NostrFilter): string { const spec: Record = { authors: filter.authors, kinds: filter.kinds, }; if (filter.limit !== undefined) spec.limit = filter.limit; return JSON.stringify(["REQ", subId, spec]); } export function eventMatchesAuthor(event: NostrEvent, expectedHex: string): boolean { return event.pubkey.toLowerCase() === expectedHex.toLowerCase(); } export type RelayMessage = | { kind: "event"; event: NostrEvent } | { kind: "eose" } | { kind: "other" }; export function parseRelayMessage(text: string): RelayMessage { try { const arr = JSON.parse(text); if (!Array.isArray(arr)) return { kind: "other" }; if (arr[0] === "EVENT" && typeof arr[2] === "object" && arr[2] !== null) { const ev = arr[2] as NostrEvent; if ( typeof ev.id === "string" && typeof ev.pubkey === "string" && typeof ev.kind === "number" && typeof ev.content === "string" && typeof ev.sig === "string" ) { return { kind: "event", event: ev }; } } if (arr[0] === "EOSE") return { kind: "eose" }; return { kind: "other" }; } catch { return { kind: "other" }; } }