You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

223 lines
8.4 KiB

/**
* NIP-66 Relay Discovery and Liveness Monitoring (consumer side).
*
* Parses kind 30166 relay discovery events and exposes relay metadata (supported NIPs,
* requirements, RTT, etc.) to supplement NIP-11 and static relay lists. Clients MUST NOT
* require this data to function; use as a hint only.
*/
import { normalizeUrl } from '@/lib/url'
import indexDb from '@/services/indexed-db.service'
import { TNip66RelayDiscovery } from '@/types'
import { Event as NEvent } from 'nostr-tools'
const RELAY_DISCOVERY_KIND = 30166
function parseRequirement(value: string): { key: string; required: boolean } {
const negated = value.startsWith('!')
return { key: negated ? value.slice(1) : value, required: !negated }
}
function parseEvent(ev: NEvent): TNip66RelayDiscovery | null {
if (ev.kind !== RELAY_DISCOVERY_KIND) return null
const d = ev.tags.find((t) => t[0] === 'd')?.[1]
if (!d) return null
const url = d.startsWith('wss://') || d.startsWith('ws://') ? d : `wss://${d}`
const nips = ev.tags.filter((t) => t[0] === 'N').map((t) => parseInt(t[1], 10)).filter((n) => !Number.isNaN(n))
const requirements: TNip66RelayDiscovery['requirements'] = {}
for (const t of ev.tags.filter((t) => t[0] === 'R')) {
const { key, required } = parseRequirement(t[1] ?? '')
if (key === 'auth') requirements.auth = required
else if (key === 'payment') requirements.payment = required
else if (key === 'writes') requirements.writes = required
else if (key === 'pow') requirements.pow = required
}
const rttOpen = ev.tags.find((t) => t[0] === 'rtt-open')?.[1]
const rttRead = ev.tags.find((t) => t[0] === 'rtt-read')?.[1]
const rttWrite = ev.tags.find((t) => t[0] === 'rtt-write')?.[1]
const networkType = ev.tags.find((t) => t[0] === 'n')?.[1]
const relayType = ev.tags.find((t) => t[0] === 'T')?.[1]
const topics = ev.tags.filter((t) => t[0] === 't').map((t) => t[1]).filter(Boolean) as string[]
return {
url,
supportedNips: [...new Set(nips)],
requirements,
rttOpenMs: rttOpen != null ? parseInt(rttOpen, 10) : undefined,
rttReadMs: rttRead != null ? parseInt(rttRead, 10) : undefined,
rttWriteMs: rttWrite != null ? parseInt(rttWrite, 10) : undefined,
networkType,
relayType,
topics: topics.length ? topics : undefined,
created_at: ev.created_at,
monitorPubkey: ev.pubkey
}
}
/** TTL for the IndexedDB cache of public lively relay list (7 days). */
const PUBLIC_LIVELY_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
/** TTL for per-relay NIP-66 discovery cache (24h). After this, we refetch from network. */
const DISCOVERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000
class Nip66Service {
private static instance: Nip66Service
/** Normalized relay URL -> latest discovery (we keep the most recent 30166 per relay). */
private discoveryByUrl = new Map<string, TNip66RelayDiscovery>()
static getInstance(): Nip66Service {
if (!Nip66Service.instance) {
Nip66Service.instance = new Nip66Service()
}
return Nip66Service.instance
}
private isDiscoveryStale(cachedAt: number): boolean {
return Date.now() - cachedAt > DISCOVERY_CACHE_TTL_MS
}
/**
* Ingest kind 30166 events (e.g. from a query). Merges supported NIPs from multiple
* events for the same relay; keeps the most recent event's metadata, union of NIPs.
* Updates the IndexedDB cache of public lively relays and per-relay discovery cache.
*/
loadFromEvents(events: NEvent[]): void {
const updatedKeys = new Set<string>()
for (const ev of events) {
const discovery = parseEvent(ev)
if (!discovery) continue
const key = normalizeUrl(discovery.url) || discovery.url
const existing = this.discoveryByUrl.get(key)
if (!existing) {
this.discoveryByUrl.set(key, discovery)
updatedKeys.add(key)
continue
}
const mergedNips = [...new Set([...existing.supportedNips, ...discovery.supportedNips])]
if (discovery.created_at >= existing.created_at) {
this.discoveryByUrl.set(key, { ...discovery, supportedNips: mergedNips })
} else {
this.discoveryByUrl.set(key, { ...existing, supportedNips: mergedNips })
}
updatedKeys.add(key)
}
const publicLively = this.buildPublicLivelyFromDiscovery()
if (publicLively.length > 0 && typeof window !== 'undefined') {
indexDb.setPublicLivelyRelayUrlsCache(publicLively).catch(() => {})
}
if (typeof window !== 'undefined') {
for (const key of updatedKeys) {
const d = this.discoveryByUrl.get(key)
if (d) indexDb.setNip66Discovery(key, d).catch(() => {})
}
}
}
/**
* Get discovery for a relay from memory or IndexedDB cache (if not stale).
* Use this to show UI immediately; then refetch if stale to update cache and GUI.
*/
async getDiscoveryCached(relayUrl: string): Promise<TNip66RelayDiscovery | undefined> {
const key = normalizeUrl(relayUrl) || relayUrl
const fromMemory = this.discoveryByUrl.get(key)
if (fromMemory) return fromMemory
if (typeof window === 'undefined') return undefined
try {
const cached = await indexDb.getNip66Discovery(key)
if (!cached?.discovery || this.isDiscoveryStale(cached.cachedAt)) return undefined
this.discoveryByUrl.set(key, cached.discovery)
return cached.discovery
} catch {
return undefined
}
}
/**
* True if we should refetch discovery (no cache or IDB cache is stale).
* Uses IDB only (not memory), so we refetch when cached data is past TTL.
*/
async isDiscoveryStaleForRelay(relayUrl: string): Promise<boolean> {
const key = normalizeUrl(relayUrl) || relayUrl
try {
const cached = await indexDb.getNip66Discovery(key)
return !cached || this.isDiscoveryStale(cached.cachedAt)
} catch {
return true
}
}
/**
* Build list of relay URLs that are public (no auth, no payment) and have been
* reported by NIP-66 monitors (lively). Used for random publish relays (censorship resilience).
* Relays with a sane monitor `rtt-write` measurement are shuffled first — more likely to accept EVENT.
*/
private buildPublicLivelyFromDiscovery(): string[] {
const eligible: TNip66RelayDiscovery[] = []
for (const d of this.discoveryByUrl.values()) {
const authRequired = d.requirements.auth === true
const paymentRequired = d.requirements.payment === true
if (!authRequired && !paymentRequired) eligible.push(d)
}
const shuffleInPlace = <T>(arr: T[]) => {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j]!, arr[i]!]
}
return arr
}
/** Monitor recorded write RTT — indicates write path was exercised recently */
const writeProven = eligible.filter(
(d) => d.rttWriteMs != null && d.rttWriteMs > 0 && d.rttWriteMs < 120_000
)
const rest = eligible.filter(
(d) => !(d.rttWriteMs != null && d.rttWriteMs > 0 && d.rttWriteMs < 120_000)
)
shuffleInPlace(writeProven)
shuffleInPlace(rest)
return [...writeProven, ...rest].map((d) => d.url)
}
/**
* Returns relay URLs from NIP-66 discovery (in-memory then IndexedDB cache).
* Returns empty array when no monitoring list is available (caller may fallback to other relay lists).
*/
async getPublicLivelyRelayUrls(): Promise<string[]> {
const fromMemory = this.buildPublicLivelyFromDiscovery()
if (fromMemory.length > 0) return fromMemory
if (typeof window === 'undefined') return []
try {
const cached = await indexDb.getPublicLivelyRelayUrlsCache()
if (cached?.urls?.length && (Date.now() - cached.cachedAt) < PUBLIC_LIVELY_CACHE_TTL_MS) {
return cached.urls
}
} catch {
// ignore
}
return []
}
getDiscovery(url: string): TNip66RelayDiscovery | undefined {
const key = normalizeUrl(url) || url
return this.discoveryByUrl.get(key)
}
/** Relay URLs that NIP-66 reports as supporting NIP-50 (search). Do not rely solely on this. */
getSearchableRelayUrls(): string[] {
const out: string[] = []
for (const d of this.discoveryByUrl.values()) {
if (d.supportedNips.includes(50)) out.push(d.url)
}
return out
}
/** True if we have a 30166 for this relay that lists NIP 50. Fall back to static list / NIP-11 when false. */
isRelaySearchable(url: string): boolean {
const d = this.getDiscovery(url)
return d?.supportedNips.includes(50) ?? false
}
}
export const nip66Service = Nip66Service.getInstance()
export default nip66Service