import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure"; import { nip19 } from "nostr-tools"; // Relays return events keyed by hex pubkeys and only accept hex in `authors` // filters. Pubkeys may be stored/passed as npub (or nprofile), so normalize. export function toHexPubkey(pubkey: string | null | undefined): string | null { if (!pubkey) return null; const trimmed = pubkey.trim(); if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase(); try { const decoded = nip19.decode(trimmed); if (decoded.type === "npub") return decoded.data as string; if (decoded.type === "nprofile") return (decoded.data as { pubkey: string }).pubkey; } catch { // fall through } return null; } // Relays that specialize in (or broadly aggregate) kind:0 profile metadata. // Included alongside the site relays so profiles are found even when a user's // metadata was never published to the site's configured relay set. const PROFILE_METADATA_RELAYS = [ "wss://purplepag.es", "wss://relay.nostr.band", ]; 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 hex = toHexPubkey(pubkey); if (!hex) return {}; const { SimplePool } = await import("nostr-tools/pool"); const allRelays = new Set(relayUrls || await getSiteRelays()); PROFILE_METADATA_RELAYS.forEach((url) => allRelays.add(url)); try { const nip65 = await fetchNip65RelayList(hex); 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: [hex], }); if (!event?.content) return {}; return parseProfileContent(event.content); } catch { return {}; } finally { pool.close(urls); } } function parseProfileContent(content: string): NostrProfile { const meta = JSON.parse(content); return { name: meta.name || meta.display_name, displayName: meta.display_name, picture: meta.picture, about: meta.about, nip05: meta.nip05, }; } const _profileCache = new Map(); const PROFILE_TTL = 5 * 60 * 1000; // 5 minutes for resolved profiles const PROFILE_EMPTY_TTL = 30 * 1000; // retry misses sooner function isProfileEmpty(p: NostrProfile): boolean { return !p.name && !p.displayName && !p.picture && !p.about && !p.nip05; } function readProfileCache(pubkey: string, now: number): NostrProfile | null { const cached = _profileCache.get(pubkey); if (!cached) return null; const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL; return now - cached.fetchedAt < ttl ? cached.profile : null; } function writeProfileCache(pubkey: string, profile: NostrProfile, now: number): void { _profileCache.set(pubkey, { profile, fetchedAt: now, empty: isProfileEmpty(profile) }); } // Batched profile fetch. Resolves kind:0 metadata for many pubkeys using a // single SimplePool and one query, instead of opening a pool (plus a NIP-65 // lookup pool) per pubkey. Querying many profiles individually opens dozens of // simultaneous websocket connections to the same relays, which get // throttled/dropped and return empty results. Pubkeys are normalized to hex // (relays reject npub/nprofile in `authors`). export async function fetchNostrProfiles( pubkeys: string[], relayUrls?: string[] ): Promise> { const result: Record = {}; const now = Date.now(); // Map each requested key to its hex form. Skip cached and un-decodable keys. const hexByKey = new Map(); for (const pk of pubkeys) { const cached = readProfileCache(pk, now); if (cached) { result[pk] = cached; continue; } const hex = toHexPubkey(pk); if (!hex) { result[pk] = {}; continue; } hexByKey.set(pk, hex); } if (hexByKey.size === 0) return result; const { SimplePool } = await import("nostr-tools/pool"); const base = relayUrls && relayUrls.length > 0 ? relayUrls : await getSiteRelays(); const urls = [...new Set([...base, ...PROFILE_METADATA_RELAYS])]; const pool = new SimplePool(); try { const authors = [...new Set(hexByKey.values())]; const events = await pool.querySync( urls, { kinds: [0], authors }, { maxWait: 6000 } ); // Keep only the most recent kind:0 event per hex author. const latest = new Map(); for (const event of events) { const prev = latest.get(event.pubkey); if (!prev || event.created_at > prev.created_at) { latest.set(event.pubkey, { created_at: event.created_at, content: event.content }); } } for (const [key, hex] of hexByKey) { const ev = latest.get(hex); let profile: NostrProfile = {}; if (ev?.content) { try { profile = parseProfileContent(ev.content); } catch { profile = {}; } } result[key] = profile; writeProfileCache(key, profile, now); } return result; } catch { for (const key of hexByKey.keys()) { if (!(key in result)) result[key] = {}; } return result; } finally { pool.close(urls); } } // DataLoader-style batching for single-pubkey requests. Component instances // (e.g. ) each ask for one pubkey; this coalesces all requests // made within a short window into a single batched relay query. let _batchQueue = new Set(); let _batchResolvers = new Map void>>(); let _batchTimer: ReturnType | null = null; const BATCH_WINDOW_MS = 60; async function flushProfileBatch(): Promise { const pubkeys = [..._batchQueue]; const resolvers = _batchResolvers; _batchQueue = new Set(); _batchResolvers = new Map(); _batchTimer = null; let profiles: Record = {}; try { profiles = await fetchNostrProfiles(pubkeys); } catch { profiles = {}; } for (const pk of pubkeys) { const profile = profiles[pk] ?? {}; resolvers.get(pk)?.forEach((resolve) => resolve(profile)); } } // Resolve a single pubkey's profile, batching concurrent calls and reusing the // shared profile cache. export function loadNostrProfile(pubkey: string): Promise { const cached = readProfileCache(pubkey, Date.now()); if (cached) { return Promise.resolve(cached); } return new Promise((resolve) => { const existing = _batchResolvers.get(pubkey); if (existing) { existing.push(resolve); } else { _batchResolvers.set(pubkey, [resolve]); } _batchQueue.add(pubkey); if (_batchTimer === null) { _batchTimer = setTimeout(() => void flushProfileBatch(), BATCH_WINDOW_MS); } }); } 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 }; }