Files
smesh/src/providers/NRCProvider.tsx
woikos 5b23ea04d0 Add QR scanner to bunker login and enhance NRC functionality
- Add QR scanner button to bunker URL paste input for easier mobile login
- Enhance NRC (Nostr Relay Connect) with improved connection handling
- Update NRC settings UI with better status display
- Improve bunker signer reliability

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:41:00 +01:00

860 lines
26 KiB
TypeScript

/**
* NRC (Nostr Relay Connect) Provider
*
* Manages NRC state for both:
* - Listener mode: Accept connections from other devices
* - Client mode: Connect to and sync from other devices
*/
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
import { Filter, Event } from 'nostr-tools'
import * as utils from '@noble/curves/abstract/utils'
import { useNostr } from './NostrProvider'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import cashuTokenService, { TokenScope } from '@/services/cashu-token.service'
import {
NRCConnection,
CATConfig,
NRCListenerConfig,
generateConnectionURI,
generateCATConnectionURI,
getNRCListenerService,
syncFromRemote,
testConnection,
parseConnectionURI,
relaySupportsCat,
deriveMintUrlFromRelay,
requestRemoteIDs,
sendEventsToRemote,
EventManifestEntry
} from '@/services/nrc'
import type { SyncProgress, RemoteConnection } from '@/services/nrc'
// Kinds to sync bidirectionally
const SYNC_KINDS = [0, 3, 10000, 10001, 10002, 10003, 10012, 30002]
// Storage keys
const STORAGE_KEY_ENABLED = 'nrc:enabled'
const STORAGE_KEY_CONNECTIONS = 'nrc:connections'
const STORAGE_KEY_REMOTE_CONNECTIONS = 'nrc:remoteConnections'
const STORAGE_KEY_RENDEZVOUS_URL = 'nrc:rendezvousUrl'
// Default rendezvous relay
const DEFAULT_RENDEZVOUS_URL = 'wss://relay.damus.io'
interface NRCContextType {
// Listener State (this device accepts connections)
isEnabled: boolean
isListening: boolean
isConnected: boolean
connections: NRCConnection[] // Devices authorized to connect to us
activeSessions: number
relaySupportsCat: boolean // Auto-detected CAT support
rendezvousUrl: string
// Client State (this device connects to others)
remoteConnections: RemoteConnection[] // Devices we connect to
isSyncing: boolean
syncProgress: SyncProgress | null
// Listener Actions
enable: () => Promise<void>
disable: () => void
addConnection: (label: string, useCat?: boolean) => Promise<{ uri: string; connection: NRCConnection }>
removeConnection: (id: string) => Promise<void>
getConnectionURI: (connection: NRCConnection) => string
setRendezvousUrl: (url: string) => void
// Client Actions
addRemoteConnection: (uri: string, label: string) => Promise<RemoteConnection>
removeRemoteConnection: (id: string) => Promise<void>
testRemoteConnection: (id: string) => Promise<boolean>
syncFromDevice: (id: string, filters?: Filter[]) => Promise<Event[]>
syncAllRemotes: (filters?: Filter[]) => Promise<Event[]>
}
const NRCContext = createContext<NRCContextType | undefined>(undefined)
export const useNRC = () => {
const context = useContext(NRCContext)
if (!context) {
throw new Error('useNRC must be used within an NRCProvider')
}
return context
}
interface NRCProviderProps {
children: ReactNode
}
export function NRCProvider({ children }: NRCProviderProps) {
const { pubkey } = useNostr()
// ===== Listener State =====
const [isEnabled, setIsEnabled] = useState<boolean>(() => {
const stored = localStorage.getItem(STORAGE_KEY_ENABLED)
return stored === 'true'
})
const [connections, setConnections] = useState<NRCConnection[]>(() => {
const stored = localStorage.getItem(STORAGE_KEY_CONNECTIONS)
if (stored) {
try {
return JSON.parse(stored)
} catch {
return []
}
}
return []
})
const [rendezvousUrl, setRendezvousUrlState] = useState<string>(() => {
return localStorage.getItem(STORAGE_KEY_RENDEZVOUS_URL) || DEFAULT_RENDEZVOUS_URL
})
// Auto-detected CAT support for the rendezvous relay
const [relaySupportsCatState, setRelaySupportsCatState] = useState(false)
const [isListening, setIsListening] = useState(false)
const [isConnected, setIsConnected] = useState(false)
const [activeSessions, setActiveSessions] = useState(0)
// ===== Client State =====
const [remoteConnections, setRemoteConnections] = useState<RemoteConnection[]>(() => {
const stored = localStorage.getItem(STORAGE_KEY_REMOTE_CONNECTIONS)
if (stored) {
try {
return JSON.parse(stored)
} catch {
return []
}
}
return []
})
const [isSyncing, setIsSyncing] = useState(false)
const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null)
const listenerService = getNRCListenerService()
// ===== Persist State =====
useEffect(() => {
localStorage.setItem(STORAGE_KEY_ENABLED, String(isEnabled))
}, [isEnabled])
useEffect(() => {
localStorage.setItem(STORAGE_KEY_CONNECTIONS, JSON.stringify(connections))
}, [connections])
useEffect(() => {
localStorage.setItem(STORAGE_KEY_REMOTE_CONNECTIONS, JSON.stringify(remoteConnections))
}, [remoteConnections])
useEffect(() => {
localStorage.setItem(STORAGE_KEY_RENDEZVOUS_URL, rendezvousUrl)
}, [rendezvousUrl])
// Auto-detect CAT support when rendezvous URL changes
useEffect(() => {
let cancelled = false
const checkCatSupport = async () => {
try {
const supported = await relaySupportsCat(rendezvousUrl)
if (!cancelled) {
setRelaySupportsCatState(supported)
console.log(`[NRC] Relay ${rendezvousUrl} CAT support:`, supported)
}
} catch (err) {
if (!cancelled) {
setRelaySupportsCatState(false)
console.log(`[NRC] Failed to check CAT support for ${rendezvousUrl}:`, err)
}
}
}
checkCatSupport()
return () => {
cancelled = true
}
}, [rendezvousUrl])
// ===== Listener Logic =====
const buildAuthorizedSecrets = useCallback((): Map<string, string> => {
const map = new Map<string, string>()
for (const conn of connections) {
if (conn.secret && conn.clientPubkey) {
map.set(conn.clientPubkey, conn.label)
}
}
return map
}, [connections])
useEffect(() => {
if (!isEnabled || !client.signer || !pubkey) {
if (listenerService.isRunning()) {
listenerService.stop()
setIsListening(false)
setIsConnected(false)
setActiveSessions(0)
}
return
}
// Stop existing listener before starting with new config
if (listenerService.isRunning()) {
listenerService.stop()
}
let statusInterval: ReturnType<typeof setInterval> | null = null
const startListener = async () => {
try {
// Build CAT config if relay supports it
const catConfig: CATConfig | undefined = relaySupportsCatState
? { mintUrl: deriveMintUrlFromRelay(rendezvousUrl), scope: 'nrc' }
: undefined
const config: NRCListenerConfig = {
rendezvousUrl,
signer: client.signer!,
authorizedSecrets: buildAuthorizedSecrets(),
catConfig
}
console.log('[NRC] Starting listener with', config.authorizedSecrets.size, 'authorized clients')
listenerService.setOnSessionChange((count) => {
setActiveSessions(count)
})
await listenerService.start(config)
setIsListening(true)
setIsConnected(listenerService.isConnected())
statusInterval = setInterval(() => {
setIsConnected(listenerService.isConnected())
setActiveSessions(listenerService.getActiveSessionCount())
}, 5000)
} catch (error) {
console.error('[NRC] Failed to start listener:', error)
setIsListening(false)
setIsConnected(false)
}
}
startListener()
return () => {
if (statusInterval) {
clearInterval(statusInterval)
}
listenerService.stop()
setIsListening(false)
setIsConnected(false)
setActiveSessions(0)
}
}, [isEnabled, pubkey, rendezvousUrl, buildAuthorizedSecrets, relaySupportsCatState])
useEffect(() => {
if (!isEnabled || !client.signer || !pubkey) return
}, [connections, isEnabled, pubkey])
// ===== Auto-sync remote connections (bidirectional) =====
// Sync interval: 15 minutes
const AUTO_SYNC_INTERVAL = 15 * 60 * 1000
// Minimum time between syncs for the same connection: 5 minutes
const MIN_SYNC_INTERVAL = 5 * 60 * 1000
/**
* Get a CAT token for authentication
*/
const getCATToken = async (mintUrl: string, userPubkey: string): Promise<string | undefined> => {
if (!client.signer) return undefined
try {
cashuTokenService.setMint(mintUrl)
await cashuTokenService.fetchMintInfo()
const userPubkeyBytes = utils.hexToBytes(userPubkey)
const token = await cashuTokenService.requestToken(
TokenScope.NRC,
userPubkeyBytes,
async (url: string, method: string) => {
const authEvent = await client.signer!.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', url],
['method', method]
],
content: ''
})
return `Nostr ${btoa(JSON.stringify(authEvent))}`
}
)
return cashuTokenService.encodeToken(token)
} catch (err) {
console.error('[NRC] Failed to get CAT token:', err)
return undefined
}
}
/**
* Get local events for sync kinds and build manifest
*/
const getLocalEventsAndManifest = async (): Promise<{
events: Event[]
manifest: EventManifestEntry[]
}> => {
const events = await indexedDb.queryEventsForNRC([{ kinds: SYNC_KINDS, limit: 1000 }])
const manifest: EventManifestEntry[] = events.map((e) => ({
kind: e.kind,
id: e.id,
created_at: e.created_at,
d: e.tags.find((t) => t[0] === 'd')?.[1]
}))
return { events, manifest }
}
/**
* Diff manifests to find what each side needs
* For replaceable events: compare by (kind, pubkey, d) and use newer created_at
*/
const diffManifests = (
local: EventManifestEntry[],
remote: EventManifestEntry[],
localEvents: Event[]
): { toSend: Event[]; toFetch: string[] } => {
// Build maps keyed by (kind, d) for replaceable events
const localMap = new Map<string, EventManifestEntry>()
const localEventsMap = new Map<string, Event>()
for (let i = 0; i < local.length; i++) {
const entry = local[i]
const key = `${entry.kind}:${entry.d || ''}`
const existing = localMap.get(key)
// Keep the newer one
if (!existing || entry.created_at > existing.created_at) {
localMap.set(key, entry)
localEventsMap.set(entry.id, localEvents[i])
}
}
const remoteMap = new Map<string, EventManifestEntry>()
for (const entry of remote) {
const key = `${entry.kind}:${entry.d || ''}`
const existing = remoteMap.get(key)
if (!existing || entry.created_at > existing.created_at) {
remoteMap.set(key, entry)
}
}
const toSend: Event[] = []
const toFetch: string[] = []
// Find events we have that are newer than remote's (or remote doesn't have)
for (const [key, localEntry] of localMap) {
const remoteEntry = remoteMap.get(key)
if (!remoteEntry || localEntry.created_at > remoteEntry.created_at) {
const event = localEventsMap.get(localEntry.id)
if (event) {
toSend.push(event)
}
}
}
// Find events remote has that are newer than ours (or we don't have)
for (const [key, remoteEntry] of remoteMap) {
const localEntry = localMap.get(key)
if (!localEntry || remoteEntry.created_at > localEntry.created_at) {
toFetch.push(remoteEntry.id)
}
}
return { toSend, toFetch }
}
useEffect(() => {
// Only auto-sync if we have remote connections and a signer
if (remoteConnections.length === 0 || !client.signer || !pubkey) {
return
}
// Don't auto-sync if already syncing
if (isSyncing) {
return
}
const bidirectionalSync = async () => {
const now = Date.now()
// Find connections that need syncing
const needsSync = remoteConnections.filter(
(c) => !c.lastSync || (now - c.lastSync) > MIN_SYNC_INTERVAL
)
if (needsSync.length === 0) {
return
}
console.log(`[NRC] Bidirectional sync: ${needsSync.length} connection(s) need syncing`)
for (const remote of needsSync) {
if (isSyncing) break
try {
console.log(`[NRC] Bidirectional sync with ${remote.label}...`)
setIsSyncing(true)
setSyncProgress({ phase: 'connecting', eventsReceived: 0 })
// Get CAT token if needed
let catToken: string | undefined
if (remote.authMode === 'cat' && remote.mintUrl) {
catToken = await getCATToken(remote.mintUrl, pubkey)
if (!catToken) {
console.error(`[NRC] Failed to get CAT token for ${remote.label}, skipping`)
continue
}
}
const signer = remote.authMode === 'cat' ? client.signer : undefined
// Step 1: Get remote's event IDs
setSyncProgress({ phase: 'requesting', eventsReceived: 0, message: 'Getting remote event list...' })
const remoteManifest = await requestRemoteIDs(
remote.uri,
[{ kinds: SYNC_KINDS, limit: 1000 }],
undefined,
signer,
catToken
)
console.log(`[NRC] Remote has ${remoteManifest.length} events`)
// Step 2: Get our local events and manifest
const { events: localEvents, manifest: localManifest } = await getLocalEventsAndManifest()
console.log(`[NRC] Local has ${localManifest.length} events`)
// Step 3: Diff to find what each side needs
const { toSend, toFetch } = diffManifests(localManifest, remoteManifest, localEvents)
console.log(`[NRC] Diff: sending ${toSend.length}, fetching ${toFetch.length}`)
let eventsSent = 0
let eventsReceived = 0
// Step 4: Send events remote needs (need new CAT token for new connection)
if (toSend.length > 0) {
setSyncProgress({ phase: 'sending', eventsReceived: 0, eventsSent: 0, message: `Sending ${toSend.length} events...` })
// Get fresh CAT token for sending
let sendCatToken = catToken
if (remote.authMode === 'cat' && remote.mintUrl) {
sendCatToken = await getCATToken(remote.mintUrl, pubkey)
}
eventsSent = await sendEventsToRemote(
remote.uri,
toSend,
(progress) => setSyncProgress({ ...progress, message: `Sending events... (${progress.eventsSent || 0}/${toSend.length})` }),
signer,
sendCatToken
)
console.log(`[NRC] Sent ${eventsSent} events to ${remote.label}`)
}
// Step 5: Fetch events we need using regular filter queries
if (toFetch.length > 0) {
setSyncProgress({ phase: 'receiving', eventsReceived: 0, eventsSent, message: `Fetching ${toFetch.length} events...` })
// Get fresh CAT token for fetching
let fetchCatToken = catToken
if (remote.authMode === 'cat' && remote.mintUrl) {
fetchCatToken = await getCATToken(remote.mintUrl, pubkey)
}
// Fetch by ID in batches (relay may limit number of IDs per filter)
const BATCH_SIZE = 50
const fetchedEvents: Event[] = []
for (let i = 0; i < toFetch.length; i += BATCH_SIZE) {
const batch = toFetch.slice(i, i + BATCH_SIZE)
const events = await syncFromRemote(
remote.uri,
[{ ids: batch }],
(progress) => setSyncProgress({
...progress,
eventsSent,
message: `Fetching events... (${fetchedEvents.length + progress.eventsReceived}/${toFetch.length})`
}),
signer,
fetchCatToken
)
fetchedEvents.push(...events)
// Get new CAT token for next batch if needed
if (remote.authMode === 'cat' && remote.mintUrl && i + BATCH_SIZE < toFetch.length) {
fetchCatToken = await getCATToken(remote.mintUrl, pubkey)
}
}
// Store fetched events
for (const event of fetchedEvents) {
try {
await indexedDb.putReplaceableEvent(event)
} catch {
// Ignore storage errors
}
}
eventsReceived = fetchedEvents.length
console.log(`[NRC] Received ${eventsReceived} events from ${remote.label}`)
}
// Update last sync time
setRemoteConnections((prev) =>
prev.map((c) =>
c.id === remote.id
? { ...c, lastSync: Date.now(), eventCount: eventsReceived }
: c
)
)
console.log(`[NRC] Bidirectional sync complete with ${remote.label}: sent ${eventsSent}, received ${eventsReceived}`)
} catch (err) {
console.error(`[NRC] Bidirectional sync failed for ${remote.label}:`, err)
} finally {
setIsSyncing(false)
setSyncProgress(null)
}
}
}
// Run initial sync after a short delay
const initialTimer = setTimeout(bidirectionalSync, 3000)
// Set up periodic sync
const intervalTimer = setInterval(bidirectionalSync, AUTO_SYNC_INTERVAL)
return () => {
clearTimeout(initialTimer)
clearInterval(intervalTimer)
}
}, [remoteConnections.length, pubkey, isSyncing])
// ===== Listener Actions =====
const enable = useCallback(async () => {
if (!client.signer) {
throw new Error('Signer required to enable NRC')
}
setIsEnabled(true)
}, [])
const disable = useCallback(() => {
setIsEnabled(false)
listenerService.stop()
setIsListening(false)
setIsConnected(false)
setActiveSessions(0)
}, [])
const addConnection = useCallback(
async (label: string, useCat = false): Promise<{ uri: string; connection: NRCConnection }> => {
if (!pubkey) {
throw new Error('Not logged in')
}
const id = crypto.randomUUID()
const createdAt = Date.now()
let connection: NRCConnection
let uri: string
// Use CAT if requested AND relay supports it, otherwise fall back to secret-based
if (useCat && relaySupportsCatState) {
uri = generateCATConnectionURI(pubkey, rendezvousUrl)
connection = {
id,
label,
useCat: true,
createdAt
}
} else {
const result = generateConnectionURI(pubkey, rendezvousUrl, undefined, label)
uri = result.uri
connection = {
id,
label,
secret: result.secret,
clientPubkey: result.clientPubkey,
useCat: false,
createdAt
}
}
setConnections((prev) => [...prev, connection])
return { uri, connection }
},
[pubkey, rendezvousUrl, relaySupportsCatState]
)
const removeConnection = useCallback(async (id: string) => {
setConnections((prev) => prev.filter((c) => c.id !== id))
}, [])
const getConnectionURI = useCallback(
(connection: NRCConnection): string => {
if (!pubkey) {
throw new Error('Not logged in')
}
if (connection.useCat && relaySupportsCatState) {
return generateCATConnectionURI(pubkey, rendezvousUrl)
}
if (connection.secret) {
const result = generateConnectionURI(
pubkey,
rendezvousUrl,
connection.secret,
connection.label
)
return result.uri
}
throw new Error('Connection has no secret and relay does not support CAT')
},
[pubkey, rendezvousUrl, relaySupportsCatState]
)
const setRendezvousUrl = useCallback((url: string) => {
setRendezvousUrlState(url)
}, [])
// ===== Client Actions =====
const addRemoteConnection = useCallback(
async (uri: string, label: string): Promise<RemoteConnection> => {
// Validate and parse the URI
const parsed = parseConnectionURI(uri)
const remoteConnection: RemoteConnection = {
id: crypto.randomUUID(),
uri,
label,
relayPubkey: parsed.relayPubkey,
rendezvousUrl: parsed.rendezvousUrl,
authMode: parsed.authMode,
mintUrl: parsed.mintUrl
}
setRemoteConnections((prev) => [...prev, remoteConnection])
return remoteConnection
},
[]
)
const removeRemoteConnection = useCallback(async (id: string) => {
setRemoteConnections((prev) => prev.filter((c) => c.id !== id))
}, [])
const syncFromDevice = useCallback(
async (id: string, filters?: Filter[]): Promise<Event[]> => {
const remote = remoteConnections.find((c) => c.id === id)
if (!remote) {
throw new Error('Remote connection not found')
}
setIsSyncing(true)
setSyncProgress({ phase: 'connecting', eventsReceived: 0 })
try {
// Default filters: sync everything
const syncFilters = filters || [
{ kinds: [0, 3, 10000, 10001, 10002, 10003, 10012, 30002], limit: 1000 }
]
let catToken: string | undefined
// For CAT mode, obtain a token from the mint
if (remote.authMode === 'cat' && remote.mintUrl && client.signer && pubkey) {
console.log('[NRC] CAT mode: obtaining token from mint', remote.mintUrl)
try {
cashuTokenService.setMint(remote.mintUrl)
await cashuTokenService.fetchMintInfo()
const userPubkeyBytes = utils.hexToBytes(pubkey)
const token = await cashuTokenService.requestToken(
TokenScope.NRC,
userPubkeyBytes,
async (url: string, method: string) => {
// NIP-98 HTTP auth signing
const authEvent = await client.signer!.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', url],
['method', method]
],
content: ''
})
return `Nostr ${btoa(JSON.stringify(authEvent))}`
}
)
catToken = cashuTokenService.encodeToken(token)
console.log('[NRC] CAT token obtained successfully')
} catch (err) {
console.error('[NRC] Failed to obtain CAT token:', err)
throw new Error(`Failed to obtain CAT token: ${err instanceof Error ? err.message : 'Unknown error'}`)
}
}
const events = await syncFromRemote(
remote.uri,
syncFilters,
(progress) => setSyncProgress(progress),
remote.authMode === 'cat' ? client.signer : undefined,
catToken
)
// Store synced events in IndexedDB
for (const event of events) {
try {
await indexedDb.putReplaceableEvent(event)
} catch (err) {
console.warn('[NRC] Failed to store event:', err)
}
}
// Update last sync time
setRemoteConnections((prev) =>
prev.map((c) =>
c.id === id ? { ...c, lastSync: Date.now(), eventCount: events.length } : c
)
)
return events
} finally {
setIsSyncing(false)
setSyncProgress(null)
}
},
[remoteConnections, pubkey]
)
const syncAllRemotes = useCallback(
async (filters?: Filter[]): Promise<Event[]> => {
const allEvents: Event[] = []
for (const remote of remoteConnections) {
try {
const events = await syncFromDevice(remote.id, filters)
allEvents.push(...events)
} catch (error) {
console.error(`[NRC] Failed to sync from ${remote.label}:`, error)
}
}
return allEvents
},
[remoteConnections, syncFromDevice]
)
const testRemoteConnection = useCallback(
async (id: string): Promise<boolean> => {
const remote = remoteConnections.find((c) => c.id === id)
if (!remote) {
throw new Error('Remote connection not found')
}
setIsSyncing(true)
setSyncProgress({ phase: 'connecting', eventsReceived: 0, message: 'Testing connection...' })
try {
let catToken: string | undefined
// For CAT mode, obtain a token from the mint
if (remote.authMode === 'cat' && remote.mintUrl && client.signer && pubkey) {
console.log('[NRC] CAT mode: obtaining token for test from mint', remote.mintUrl)
try {
cashuTokenService.setMint(remote.mintUrl)
await cashuTokenService.fetchMintInfo()
const userPubkeyBytes = utils.hexToBytes(pubkey)
const token = await cashuTokenService.requestToken(
TokenScope.NRC,
userPubkeyBytes,
async (url: string, method: string) => {
const authEvent = await client.signer!.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', url],
['method', method]
],
content: ''
})
return `Nostr ${btoa(JSON.stringify(authEvent))}`
}
)
catToken = cashuTokenService.encodeToken(token)
} catch (err) {
console.error('[NRC] Failed to obtain CAT token for test:', err)
throw new Error(`Failed to obtain CAT token: ${err instanceof Error ? err.message : 'Unknown error'}`)
}
}
const result = await testConnection(
remote.uri,
(progress) => setSyncProgress(progress),
remote.authMode === 'cat' ? client.signer : undefined,
catToken
)
// Update connection to mark it as tested
setRemoteConnections((prev) =>
prev.map((c) =>
c.id === id ? { ...c, lastSync: Date.now(), eventCount: 0 } : c
)
)
return result
} finally {
setIsSyncing(false)
setSyncProgress(null)
}
},
[remoteConnections, pubkey]
)
const value: NRCContextType = {
// Listener
isEnabled,
isListening,
isConnected,
connections,
activeSessions,
relaySupportsCat: relaySupportsCatState,
rendezvousUrl,
enable,
disable,
addConnection,
removeConnection,
getConnectionURI,
setRendezvousUrl,
// Client
remoteConnections,
isSyncing,
syncProgress,
addRemoteConnection,
removeRemoteConnection,
testRemoteConnection,
syncFromDevice,
syncAllRemotes
}
return <NRCContext.Provider value={value}>{children}</NRCContext.Provider>
}