import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure"; declare global { interface Window { nostr?: { getPublicKey(): Promise; signEvent(event: any): Promise; getRelays?(): Promise>; }; } } export type BunkerSignerInterface = { getPublicKey(): Promise; signEvent(event: any): Promise; close(): Promise; }; export function hasNostrExtension(): boolean { return typeof window !== "undefined" && !!window.nostr; } export async function getPublicKey(): Promise { if (!window.nostr) throw new Error("No Nostr extension found"); return window.nostr.getPublicKey(); } export async function signEvent(event: any): Promise { if (!window.nostr) throw new Error("No Nostr extension found"); return window.nostr.signEvent(event); } export function createAuthEvent(pubkey: string, challenge: string) { return { kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [ ["relay", ""], ["challenge", challenge], ], content: "", pubkey, }; } export function shortenPubkey(pubkey: string): string { if (!pubkey) return ""; return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`; } export interface NostrProfile { name?: string; picture?: string; about?: string; nip05?: string; displayName?: string; } const FALLBACK_RELAYS = [ "wss://relay.damus.io", "wss://nos.lol", "wss://relay.nostr.band", ]; let _siteRelaysCache: { relays: string[]; fetchedAt: number } | null = null; const SITE_RELAY_TTL = 5 * 60 * 1000; // 5 minutes export async function getSiteRelays(): Promise { if (_siteRelaysCache && Date.now() - _siteRelaysCache.fetchedAt < SITE_RELAY_TTL) { return _siteRelaysCache.relays; } try { const res = await fetch("/api/relays/public"); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); if (Array.isArray(data.relays) && data.relays.length > 0) { _siteRelaysCache = { relays: data.relays, fetchedAt: Date.now() }; return data.relays; } } catch {} return FALLBACK_RELAYS; } export interface Nip65RelayList { write: string[]; read: string[]; all: string[]; } const _nip65Cache = new Map(); const NIP65_TTL = 10 * 60 * 1000; // 10 minutes export async function fetchNip65RelayList(pubkey: string): Promise { const cached = _nip65Cache.get(pubkey); if (cached && Date.now() - cached.fetchedAt < NIP65_TTL) return cached.data; const { SimplePool } = await import("nostr-tools/pool"); const siteRelays = await getSiteRelays(); const pool = new SimplePool(); try { const event = await pool.get(siteRelays, { kinds: [10002], authors: [pubkey], }); const result: Nip65RelayList = { write: [], read: [], all: [] }; if (!event) { _nip65Cache.set(pubkey, { data: result, fetchedAt: Date.now() }); return result; } for (const tag of event.tags) { if (tag[0] !== "r" || !tag[1]) continue; const url = tag[1]; const marker = tag[2]; result.all.push(url); if (marker === "write") { result.write.push(url); } else if (marker === "read") { result.read.push(url); } else { result.write.push(url); result.read.push(url); } } _nip65Cache.set(pubkey, { data: result, fetchedAt: Date.now() }); return result; } catch { return { write: [], read: [], all: [] }; } finally { pool.close(siteRelays); } } let _userRelaysCache: { relays: { url: string; read: boolean; write: boolean }[]; fetchedAt: number } | null = null; const USER_RELAY_TTL = 2 * 60 * 1000; // 2 minutes export async function getUserStoredRelays(): Promise<{ url: string; read: boolean; write: boolean }[]> { if (_userRelaysCache && Date.now() - _userRelaysCache.fetchedAt < USER_RELAY_TTL) { return _userRelaysCache.relays; } try { const token = typeof window !== "undefined" ? localStorage.getItem("bbe_token") : null; if (!token) return []; const res = await fetch("/api/user-relays", { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) return []; const data = await res.json(); if (Array.isArray(data)) { _userRelaysCache = { relays: data, fetchedAt: Date.now() }; return data; } } catch {} return []; } export function clearUserRelaysCache(): void { _userRelaysCache = null; } export async function publishEvent(signedEvent: any): Promise { const { SimplePool } = await import("nostr-tools/pool"); const siteRelays = await getSiteRelays(); const allRelays = new Set(siteRelays); // Merge user's stored write relays try { const stored = await getUserStoredRelays(); stored .filter((r) => r.write) .forEach((r) => allRelays.add(r.url)); } catch {} // Merge NIP-65 write relays for the event author if (signedEvent.pubkey) { try { const nip65 = await fetchNip65RelayList(signedEvent.pubkey); nip65.write.forEach((url) => allRelays.add(url)); } catch {} } // Merge extension relays try { if (window.nostr?.getRelays) { const ext = await window.nostr.getRelays(); Object.entries(ext) .filter(([, p]) => (p as any).write) .forEach(([url]) => allRelays.add(url)); } } catch {} const relayUrls = [...allRelays]; const pool = new SimplePool(); try { await Promise.allSettled(pool.publish(relayUrls, signedEvent)); } finally { pool.close(relayUrls); } } export async function fetchNostrProfile( pubkey: string, relayUrls?: string[] ): Promise { const { SimplePool } = await import("nostr-tools/pool"); const allRelays = new Set(relayUrls || await getSiteRelays()); try { const nip65 = await fetchNip65RelayList(pubkey); nip65.write.forEach((url) => allRelays.add(url)); } catch {} const urls = [...allRelays]; const pool = new SimplePool(); try { const event = await pool.get(urls, { kinds: [0], authors: [pubkey], }); if (!event?.content) return {}; const meta = JSON.parse(event.content); return { name: meta.name || meta.display_name, displayName: meta.display_name, picture: meta.picture, about: meta.about, nip05: meta.nip05, }; } catch { return {}; } finally { pool.close(urls); } } export async function fetchEventFromRelays(eventId: string): Promise { const { SimplePool } = await import("nostr-tools/pool"); const siteRelays = await getSiteRelays(); const pool = new SimplePool(); try { const event = await pool.get(siteRelays, { ids: [eventId] }); return event || null; } catch { return null; } finally { pool.close(siteRelays); } } export async function fetchLongformFromRelays(naddrStr: string): Promise { const { SimplePool } = await import("nostr-tools/pool"); const { nip19 } = await import("nostr-tools"); let decoded: { kind: number; pubkey: string; identifier: string; relays?: string[] }; try { const result = nip19.decode(naddrStr); if (result.type !== "naddr") return null; decoded = result.data; } catch { return null; } const siteRelays = await getSiteRelays(); const naddrRelays = decoded.relays || []; const allRelays = new Set([...naddrRelays, ...siteRelays]); try { const nip65 = await fetchNip65RelayList(decoded.pubkey); nip65.write.forEach((url) => allRelays.add(url)); } catch {} const relayUrls = [...allRelays]; const filter = { kinds: [decoded.kind], authors: [decoded.pubkey], "#d": [decoded.identifier], }; const pool = new SimplePool(); try { const event = await pool.get(relayUrls, filter); return event || null; } catch { return null; } finally { pool.close(relayUrls); } } // NIP-46: External signer (bunker:// URI) export async function createBunkerSigner( input: string ): Promise<{ signer: BunkerSignerInterface; pubkey: string }> { const { BunkerSigner, parseBunkerInput } = await import("nostr-tools/nip46"); const bp = await parseBunkerInput(input); if (!bp) throw new Error("Invalid bunker URI or NIP-05 identifier"); const clientSecretKey = generateSecretKey(); const signer = BunkerSigner.fromBunker(clientSecretKey, bp); await signer.connect(); const pubkey = await signer.getPublicKey(); return { signer, pubkey }; } // NIP-46: Generate a nostrconnect:// URI for QR display export async function generateNostrConnectSetup( relayUrls?: string[] ): Promise<{ uri: string; clientSecretKey: Uint8Array }> { if (!relayUrls) relayUrls = (await getSiteRelays()).slice(0, 2); const { createNostrConnectURI } = await import("nostr-tools/nip46"); const clientSecretKey = generateSecretKey(); const clientPubkey = getPubKeyFromSecret(clientSecretKey); const secretBytes = crypto.getRandomValues(new Uint8Array(8)); const secret = Array.from(secretBytes) .map((b) => b.toString(16).padStart(2, "0")) .join(""); const uri = createNostrConnectURI({ clientPubkey, relays: relayUrls, secret, name: "Belgian Bitcoin Embassy", }); return { uri, clientSecretKey }; } // NIP-46: Wait for a remote signer to connect via nostrconnect:// URI export async function waitForNostrConnectSigner( clientSecretKey: Uint8Array, uri: string, signal?: AbortSignal ): Promise<{ signer: BunkerSignerInterface; pubkey: string }> { const { BunkerSigner } = await import("nostr-tools/nip46"); const signer = await BunkerSigner.fromURI(clientSecretKey, uri, {}, signal); const pubkey = await signer.getPublicKey(); return { signer, pubkey }; }