import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure"; import { nip19 } from "nostr-tools"; import { readCache, writeCache } from "./browserCache"; // Persistent (localStorage) cache lifetimes. Longer than the in-memory TTLs: // these survive reloads, where the whole point is to avoid re-querying relays. // Profiles and immutable events change rarely; addressable (replaceable) // long-form events use a shorter window so edits are picked up reasonably soon. const PROFILE_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours const EVENT_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours (events by id are immutable) const LONGFORM_PERSIST_TTL = 30 * 60 * 1000; // 30 minutes (replaceable) // 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)}`; } // Shortened npub for display when no profile is available. Accepts a hex pubkey // or an existing npub/nprofile and always renders bech32 (never raw hex). export function shortenNpub(pubkeyOrBech32: string): string { if (!pubkeyOrBech32) return ""; try { const npub = pubkeyOrBech32.startsWith("npub") ? pubkeyOrBech32 : nip19.npubEncode(toHexPubkey(pubkeyOrBech32) || pubkeyOrBech32); return shortenPubkey(npub); } catch { return shortenPubkey(pubkeyOrBech32); } } 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", ]; // Only secure WebSocket (wss://) relays may be dialed: the site is served over // HTTPS, so ws:// endpoints (e.g. ws://umbrel.local) are blocked as mixed // content and must never be connected to. Relay hints reach us from untrusted // sources (naddr hints, NIP-65 lists, user/extension relay configs), so filter // at every connection point. export function isSecureRelay(url: unknown): url is string { return typeof url === "string" && /^wss:\/\//i.test(url.trim()); } export function filterSecureRelays(urls: Iterable): string[] { const out: string[] = []; for (const url of urls) { if (isSecureRelay(url)) out.push(url.trim()); } return [...new Set(out)]; } // How long a single relay query may run before we give up on it. Relays go // dead or stall mid-subscription without ever sending EOSE, which is what makes // blog loads occasionally hang; bounding every query keeps the page responsive. const RELAY_MAX_WAIT = 5000; // ms, passed to nostr-tools as `maxWait` const RELAY_HARD_TIMEOUT = 8000; // ms, absolute ceiling incl. connection setup // A single shared read pool for the whole app. Reusing one pool keeps relay // websockets warm and—crucially—avoids opening several simultaneous // connections to the same relays (author profile + article body + NIP-65 all at // once on a blog open), which relays throttle/drop and which caused the // intermittent "couldn't fetch" failure on first load. Never closed: the pool // manages and reuses its own connections for the SPA's lifetime. let _readPool: any = null; async function getReadPool(): Promise { if (!_readPool) { const { SimplePool } = await import("nostr-tools/pool"); _readPool = new SimplePool(); } return _readPool; } // Resolves to `fallback` if `promise` hasn't settled within `ms`. Used as a // hard ceiling around relay queries so a connection that never opens (or never // closes) can't block rendering. function withTimeout(promise: Promise, ms: number, fallback: T): Promise { return new Promise((resolve) => { const timer = setTimeout(() => resolve(fallback), ms); promise.then( (v) => { clearTimeout(timer); resolve(v); }, () => { clearTimeout(timer); resolve(fallback); }, ); }); } // A single bounded query across `relays` (which are dialed in parallel by the // pool). Returns null on timeout, empty relay set, or error. async function getEventBounded( pool: { get: (relays: string[], filter: any, params?: { maxWait?: number }) => Promise }, relays: string[], filter: any, ): Promise { if (relays.length === 0) return null; return withTimeout( pool.get(relays, filter, { maxWait: RELAY_MAX_WAIT }), RELAY_HARD_TIMEOUT, null, ); } 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(); const secure = Array.isArray(data.relays) ? filterSecureRelays(data.relays) : []; if (secure.length > 0) { _siteRelaysCache = { relays: secure, fetchedAt: Date.now() }; return secure; } } 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 siteRelays = await getSiteRelays(); const pool = await getReadPool(); try { const event = await getEventBounded(pool, 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]; if (!isSecureRelay(url)) continue; 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: [] }; } } 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)) { const secure = data.filter((r) => isSecureRelay(r?.url)); _userRelaysCache = { relays: secure, fetchedAt: Date.now() }; return secure; } } 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 = filterSecureRelays(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 cached = readProfileCache(hex, Date.now()); if (cached) return cached; const filter = { kinds: [0], authors: [hex] }; // Overlap the NIP-65 lookup with the first query rather than waiting on it. const nip65Promise = fetchNip65RelayList(hex).catch( () => ({ write: [], read: [], all: [] }) as Nip65RelayList, ); const pool = await getReadPool(); const tried = new Set(); try { // Phase 1: provided relays (or site relays) + profile aggregators. const phase1 = filterSecureRelays([ ...(relayUrls || await getSiteRelays()), ...PROFILE_METADATA_RELAYS, ]); phase1.forEach((u) => tried.add(u)); let event = await getEventBounded(pool, phase1, filter); // Phase 2: author's NIP-65 write relays if not found yet. if (!event?.content) { const nip65 = await nip65Promise; const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u)); if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter); } if (!event?.content) return {}; const profile = parseProfileContent(event.content); writeProfileCache(hex, profile, Date.now()); return profile; } catch { return {}; } } 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) { const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL; if (now - cached.fetchedAt < ttl) return cached.profile; } // Fall back to the persistent browser cache (survives reloads/navigation). // Only resolved profiles are persisted, so a hit here is always non-empty. const persisted = readCache(`profile:${pubkey}`, PROFILE_PERSIST_TTL); if (persisted) { _profileCache.set(pubkey, { profile: persisted, fetchedAt: now, empty: false }); return persisted; } return null; } function writeProfileCache(pubkey: string, profile: NostrProfile, now: number): void { const empty = isProfileEmpty(profile); _profileCache.set(pubkey, { profile, fetchedAt: now, empty }); // Persist resolved profiles only; skip empties so a reload can retry the miss. if (!empty) writeCache(`profile:${pubkey}`, 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 base = relayUrls && relayUrls.length > 0 ? relayUrls : await getSiteRelays(); const urls = filterSecureRelays([...base, ...PROFILE_METADATA_RELAYS]); const pool = await getReadPool(); 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; } } // 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 cacheKey = `event:${eventId}`; const cached = readCache(cacheKey, EVENT_PERSIST_TTL); if (cached) return cached; const siteRelays = await getSiteRelays(); const pool = await getReadPool(); try { const event = await getEventBounded(pool, siteRelays, { ids: [eventId] }); if (event) writeCache(cacheKey, event); return event || null; } catch { return null; } } export async function fetchLongformFromRelays(naddrStr: string): Promise { const cacheKey = `longform:${naddrStr}`; const cached = readCache(cacheKey, LONGFORM_PERSIST_TTL); if (cached) return cached; 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 = filterSecureRelays(decoded.relays || []); const filter = { kinds: [decoded.kind], authors: [decoded.pubkey], "#d": [decoded.identifier], }; // Kick off the author's NIP-65 lookup immediately so it overlaps the first // query instead of adding a serial round-trip before it. const nip65Promise = fetchNip65RelayList(decoded.pubkey).catch( () => ({ write: [], read: [], all: [] }) as Nip65RelayList, ); const pool = await getReadPool(); const tried = new Set(); // Phase 1: naddr relay hints + site relays, all dialed in parallel. The // hints usually point at the author's own relay, so this is the fast path. const phase1 = filterSecureRelays([...naddrRelays, ...siteRelays]); phase1.forEach((u) => tried.add(u)); let event = await getEventBounded(pool, phase1, filter); // Phase 2: fall back to the author's NIP-65 write relays only if needed. if (!event) { const nip65 = await nip65Promise; const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u)); if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter); } if (event) writeCache(cacheKey, event); return event || null; } // Resolves any NIP-19 reference (naddr / nevent / note) or a raw 64-char hex // event id to its underlying Nostr event by querying relays. Lets the blog page // render a long-form note straight from a shared link even when it was never // indexed by the backend. Returns null if it can't be decoded or found. export async function resolveEventFromRelays(identifier: string): Promise { const trimmed = identifier.trim(); if (/^[0-9a-f]{64}$/i.test(trimmed)) { return fetchEventFromRelays(trimmed.toLowerCase()); } let decoded; try { decoded = nip19.decode(trimmed); } catch { return null; } switch (decoded.type) { case "naddr": return fetchLongformFromRelays(trimmed); case "nevent": return fetchEventFromRelays((decoded.data as { id: string }).id); case "note": return fetchEventFromRelays(decoded.data as string); default: return null; } } // 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 }; }