From 5b23ea04d09bdbf019157cd49520720507a0c49d Mon Sep 17 00:00:00 2001 From: woikos Date: Thu, 15 Jan 2026 10:41:00 +0100 Subject: [PATCH] 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 --- package-lock.json | 11 +- package.json | 1 + src/components/AccountManager/BunkerLogin.tsx | 106 ++- src/components/NRCSettings/index.tsx | 698 +++++++++++--- src/constants.ts | 1 + src/providers/NRCProvider.tsx | 669 +++++++++++-- src/providers/NostrProvider/bunker.signer.ts | 38 +- src/providers/SettingsSyncProvider.tsx | 16 +- src/services/cashu-token.service.ts | 3 +- src/services/client.service.ts | 13 + src/services/local-storage.service.ts | 13 + src/services/nrc/index.ts | 2 + src/services/nrc/nrc-client.service.ts | 877 ++++++++++++++++++ src/services/nrc/nrc-listener.service.ts | 280 +++++- src/services/nrc/nrc-session.ts | 2 +- src/services/nrc/nrc-types.ts | 35 +- src/services/nrc/nrc-uri.ts | 70 +- src/types/index.d.ts | 1 + 18 files changed, 2525 insertions(+), 311 deletions(-) create mode 100644 src/services/nrc/nrc-client.service.ts diff --git a/package-lock.json b/package-lock.json index 1bdcc1dd..febf76d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "smesh", - "version": "0.3.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "smesh", - "version": "0.3.0", + "version": "0.4.1", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -56,6 +56,7 @@ "emoji-picker-react": "^4.12.2", "flexsearch": "^0.7.43", "franc-min": "^6.2.0", + "html5-qrcode": "^2.3.8", "i18next": "^24.2.0", "i18next-browser-languagedetector": "^8.0.4", "jotai": "^2.15.0", @@ -8932,6 +8933,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", + "license": "Apache-2.0" + }, "node_modules/i18next": { "version": "24.2.0", "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.0.tgz", diff --git a/package.json b/package.json index 67395362..b0e34828 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "emoji-picker-react": "^4.12.2", "flexsearch": "^0.7.43", "franc-min": "^6.2.0", + "html5-qrcode": "^2.3.8", "i18next": "^24.2.0", "i18next-browser-languagedetector": "^8.0.4", "jotai": "^2.15.0", diff --git a/src/components/AccountManager/BunkerLogin.tsx b/src/components/AccountManager/BunkerLogin.tsx index cb365bd3..eab01171 100644 --- a/src/components/AccountManager/BunkerLogin.tsx +++ b/src/components/AccountManager/BunkerLogin.tsx @@ -1,9 +1,10 @@ +import QrScannerModal from '@/components/QrScannerModal' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useNostr } from '@/providers/NostrProvider' import { BunkerSigner } from '@/providers/NostrProvider/bunker.signer' -import { ArrowLeft, Loader2, QrCode, Server, Copy, Check } from 'lucide-react' +import { ArrowLeft, Loader2, QrCode, Server, Copy, Check, ScanLine } from 'lucide-react' import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import QRCode from 'qrcode' @@ -28,6 +29,7 @@ export default function BunkerLogin({ const [connectUrl, setConnectUrl] = useState(null) const [qrDataUrl, setQrDataUrl] = useState(null) const [copied, setCopied] = useState(false) + const [showScanner, setShowScanner] = useState(false) // Generate QR code when in scan mode useEffect(() => { @@ -88,6 +90,11 @@ export default function BunkerLogin({ } }, [mode, relayUrl, bunkerLoginWithSigner, onLoginSuccess]) + const handleScan = (result: string) => { + setBunkerUrl(result) + setError(null) + } + const handlePasteSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!bunkerUrl.trim()) { @@ -263,49 +270,66 @@ export default function BunkerLogin({ // Paste mode return ( -
e.stopPropagation()}> -
- + <> + {showScanner && ( + setShowScanner(false)} /> + )} +
e.stopPropagation()}>
- - {t('Paste Bunker URL')} + +
+ + {t('Paste Bunker URL')} +
-
-
-
- - setBunkerUrl(e.target.value)} - disabled={loading} - className="font-mono text-sm" - /> -

- {t( - 'Enter the bunker connection URL. This is typically provided by your signing device or service.' + +

+ +
+ setBunkerUrl(e.target.value)} + disabled={loading} + className="font-mono text-sm" + /> + +
+

+ {t( + 'Enter the bunker connection URL. This is typically provided by your signing device or service.' + )} +

+
+ + {error &&
{error}
} + +
- - {error &&
{error}
} - - -
-
+ + +
+ ) } diff --git a/src/components/NRCSettings/index.tsx b/src/components/NRCSettings/index.tsx index b76c9062..e3692911 100644 --- a/src/components/NRCSettings/index.tsx +++ b/src/components/NRCSettings/index.tsx @@ -2,16 +2,21 @@ * NRC Settings Component * * UI for managing Nostr Relay Connect (NRC) connections and listener settings. + * Includes both: + * - Listener mode: Allow other devices to connect to this one + * - Client mode: Connect to and sync from other devices */ -import { useState, useCallback } from 'react' +import { useState, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useNRC } from '@/providers/NRCProvider' import { useNostr } from '@/providers/NostrProvider' +import storage, { dispatchSettingsChanged } from '@/services/local-storage.service' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Dialog, DialogContent, @@ -41,28 +46,47 @@ import { Wifi, WifiOff, Users, - Server + Server, + RefreshCw, + Smartphone, + Download, + Camera, + Zap, + Coins } from 'lucide-react' -import { NRCConnection } from '@/services/nrc' +import { NRCConnection, RemoteConnection } from '@/services/nrc' import QRCode from 'qrcode' +import { Html5Qrcode } from 'html5-qrcode' export default function NRCSettings() { const { t } = useTranslation() const { pubkey } = useNostr() const { + // Listener state isEnabled, isConnected, connections, activeSessions, rendezvousUrl, + relaySupportsCat, enable, disable, addConnection, removeConnection, getConnectionURI, - setRendezvousUrl + setRendezvousUrl, + // Client state + remoteConnections, + isSyncing, + syncProgress, + addRemoteConnection, + removeRemoteConnection, + testRemoteConnection, + syncFromDevice, + syncAllRemotes } = useNRC() + // Listener state const [newConnectionLabel, setNewConnectionLabel] = useState('') const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [isQRDialogOpen, setIsQRDialogOpen] = useState(false) @@ -72,6 +96,24 @@ export default function NRCSettings() { const [copiedUri, setCopiedUri] = useState(false) const [isLoading, setIsLoading] = useState(false) + // Client state + const [connectionUri, setConnectionUri] = useState('') + const [newRemoteLabel, setNewRemoteLabel] = useState('') + const [isConnectDialogOpen, setIsConnectDialogOpen] = useState(false) + const [isScannerOpen, setIsScannerOpen] = useState(false) + const [scannerError, setScannerError] = useState('') + const scannerRef = useRef(null) + const scannerContainerRef = useRef(null) + + // Private config sync setting + const [nrcOnlyConfigSync, setNrcOnlyConfigSync] = useState(storage.getNrcOnlyConfigSync()) + + const handleToggleNrcOnlyConfig = useCallback((checked: boolean) => { + storage.setNrcOnlyConfigSync(checked) + setNrcOnlyConfigSync(checked) + dispatchSettingsChanged() + }, []) + // Generate QR code when URI changes const generateQRCode = useCallback(async (uri: string) => { try { @@ -158,6 +200,118 @@ export default function NRCSettings() { [removeConnection] ) + // ===== Client Handlers ===== + const handleAddRemoteConnection = useCallback(async () => { + if (!connectionUri.trim() || !newRemoteLabel.trim()) return + + setIsLoading(true) + try { + await addRemoteConnection(connectionUri.trim(), newRemoteLabel.trim()) + setIsConnectDialogOpen(false) + setConnectionUri('') + setNewRemoteLabel('') + } catch (error) { + console.error('Failed to add remote connection:', error) + } finally { + setIsLoading(false) + } + }, [connectionUri, newRemoteLabel, addRemoteConnection]) + + const handleRemoveRemoteConnection = useCallback( + async (id: string) => { + try { + await removeRemoteConnection(id) + } catch (error) { + console.error('Failed to remove remote connection:', error) + } + }, + [removeRemoteConnection] + ) + + const handleSyncDevice = useCallback( + async (id: string) => { + try { + await syncFromDevice(id) + } catch (error) { + console.error('Failed to sync from device:', error) + } + }, + [syncFromDevice] + ) + + const handleTestConnection = useCallback( + async (id: string) => { + try { + await testRemoteConnection(id) + } catch (error) { + console.error('Failed to test connection:', error) + } + }, + [testRemoteConnection] + ) + + const handleSyncAll = useCallback(async () => { + try { + await syncAllRemotes() + } catch (error) { + console.error('Failed to sync all remotes:', error) + } + }, [syncAllRemotes]) + + const startScanner = useCallback(async () => { + if (!scannerContainerRef.current) return + + setScannerError('') + try { + const scanner = new Html5Qrcode('qr-scanner-container') + scannerRef.current = scanner + + await scanner.start( + { facingMode: 'environment' }, + { + fps: 10, + qrbox: { width: 250, height: 250 } + }, + (decodedText) => { + // Found a QR code + if (decodedText.startsWith('nostr+relayconnect://')) { + setConnectionUri(decodedText) + stopScanner() + setIsScannerOpen(false) + setIsConnectDialogOpen(true) + } + }, + () => { + // Ignore errors while scanning + } + ) + } catch (error) { + console.error('Failed to start scanner:', error) + setScannerError(error instanceof Error ? error.message : 'Failed to start camera') + } + }, []) + + const stopScanner = useCallback(() => { + if (scannerRef.current) { + scannerRef.current.stop().catch(() => { + // Ignore errors when stopping + }) + scannerRef.current = null + } + }, []) + + const handleOpenScanner = useCallback(() => { + setIsScannerOpen(true) + // Start scanner after dialog renders + setTimeout(startScanner, 100) + }, [startScanner]) + + const handleCloseScanner = useCallback(() => { + stopScanner() + setIsScannerOpen(false) + setScannerError('') + }, [stopScanner]) + if (!pubkey) { return (
@@ -168,156 +322,364 @@ export default function NRCSettings() { return (
- {/* Enable/Disable Toggle */} -
+ {/* Private Configuration Sync Toggle */} +
-
- {/* Status Indicator */} - {isEnabled && ( -
-
- {isConnected ? ( - - ) : ( - - )} - - {isConnected ? t('Connected') : t('Connecting...')} - + + + + + {t('Share')} + + + + {t('Connect')} + + + + {/* ===== LISTENER TAB ===== */} + + {/* Enable/Disable Toggle */} +
+
+ +

+ {t('Allow other devices to sync with this client')} +

+
+
- {activeSessions > 0 && ( -
- - - {activeSessions} {t('active session(s)')} - + + {/* Status Indicator */} + {isEnabled && ( +
+
+ {isConnected ? ( + + ) : ( + + )} + + {isConnected ? t('Connected') : t('Connecting...')} + +
+ {activeSessions > 0 && ( +
+ + + {activeSessions} {t('active session(s)')} + +
+ )}
)} -
- )} - {/* Rendezvous Relay */} -
- - setRendezvousUrl(e.target.value)} - placeholder="wss://relay.example.com" - disabled={isEnabled} - /> - {isEnabled && ( -

- {t('Disable NRC to change the relay')} -

- )} -
- - {/* Connections List */} -
-
- - -
- - {connections.length === 0 ? ( -
- {t('No devices connected yet')} -
- ) : ( + {/* Rendezvous Relay */}
- {connections.map((connection) => ( -
+ + {t('Rendezvous Relay')} + + setRendezvousUrl(e.target.value)} + placeholder="wss://relay.example.com" + disabled={isEnabled} + /> + {isEnabled && ( +

+ {t('Disable NRC to change the relay')} +

+ )} +
+ + {/* CAT (Cashu Access Token) Status */} +
+
+ + {t('CAT Authentication')} +
+ {relaySupportsCat ? ( + + {t('Available')} + + ) : ( + + {t('Not Available')} + + )} +
+ + {/* Connections List */} +
+
+ + +
+ + {connections.length === 0 ? ( +
+ {t('No devices connected yet')} +
+ ) : ( +
+ {connections.map((connection) => ( +
- - - - +
+
{connection.label}
+
+ {new Date(connection.createdAt).toLocaleDateString()} + {connection.useCat && ( + + CAT + + )} +
+
+
- - - - {t('Remove Device?')} - - {t('This will revoke access for "{{label}}". The device will no longer be able to sync.', { - label: connection.label - })} - - - - {t('Cancel')} - handleRemoveConnection(connection.id)} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {t('Remove')} - - - - -
+ + + + + + + {t('Remove Device?')} + + {t('This will revoke access for "{{label}}". The device will no longer be able to sync.', { + label: connection.label + })} + + + + {t('Cancel')} + handleRemoveConnection(connection.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t('Remove')} + + + + +
+
+ ))}
- ))} + )}
- )} -
+
- {/* Add Connection Dialog */} + {/* ===== CLIENT TAB ===== */} + + {/* Sync Progress */} + {isSyncing && syncProgress && ( +
+
+ + + {syncProgress.phase === 'connecting' && t('Connecting...')} + {syncProgress.phase === 'requesting' && t('Requesting events...')} + {syncProgress.phase === 'receiving' && t('Receiving events...')} + {syncProgress.phase === 'complete' && t('Sync complete')} + {syncProgress.phase === 'error' && t('Error')} + +
+ {syncProgress.eventsReceived > 0 && ( +
+ {t('{{count}} events received', { count: syncProgress.eventsReceived })} +
+ )} + {syncProgress.message && syncProgress.phase === 'error' && ( +
{syncProgress.message}
+ )} +
+ )} + + {/* Connect to Device */} +
+
+ +
+ + +
+
+ + {remoteConnections.length === 0 ? ( +
+ {t('No remote devices configured')} +
+ ) : ( +
+ {/* Sync All Button */} + {remoteConnections.length > 1 && ( + + )} + + {remoteConnections.map((remote: RemoteConnection) => ( +
+
+
{remote.label}
+
+ {remote.lastSync ? ( + <> + {t('Last sync')}: {new Date(remote.lastSync).toLocaleString()} + {remote.eventCount !== undefined && ( + ({remote.eventCount} {t('events')}) + )} + + ) : ( + t('Never synced') + )} +
+
+
+ {/* Show Test button if never synced, Sync button otherwise */} + {!remote.lastSync ? ( + + ) : null} + + + + + + + + {t('Remove Remote Device?')} + + {t('This will remove "{{label}}" from your remote devices list.', { + label: remote.label + })} + + + + {t('Cancel')} + handleRemoveRemoteConnection(remote.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t('Remove')} + + + + +
+
+ ))} +
+ )} +
+
+
+ + {/* ===== DIALOGS ===== */} + + {/* Add Connection Dialog (Listener) */} @@ -404,6 +766,82 @@ export default function NRCSettings() { + + {/* Connect to Remote Dialog (Client) */} + + + + {t('Connect to Device')} + + {t('Enter a connection URI from another device to sync with it')} + + +
+
+ + setConnectionUri(e.target.value)} + placeholder="nostr+relayconnect://..." + className="font-mono text-xs" + /> +
+
+ + setNewRemoteLabel(e.target.value)} + placeholder={t('e.g., Desktop, Main Phone')} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleAddRemoteConnection() + } + }} + /> +
+
+ + + + +
+
+ + {/* QR Scanner Dialog */} + + + + {t('Scan QR Code')} + + {t('Point your camera at a connection QR code')} + + +
+
+ {scannerError && ( +
{scannerError}
+ )} +
+ + + + +
) } diff --git a/src/constants.ts b/src/constants.ts index 98831733..9c5b6a57 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -50,6 +50,7 @@ export const StorageKey = { GRAPH_QUERIES_ENABLED: 'graphQueriesEnabled', SOCIAL_GRAPH_PROXIMITY: 'socialGraphProximity', SOCIAL_GRAPH_INCLUDE_MODE: 'socialGraphIncludeMode', + NRC_ONLY_CONFIG_SYNC: 'nrcOnlyConfigSync', DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated diff --git a/src/providers/NRCProvider.tsx b/src/providers/NRCProvider.tsx index d46786fe..a4a32401 100644 --- a/src/providers/NRCProvider.tsx +++ b/src/providers/NRCProvider.tsx @@ -1,48 +1,77 @@ /** * NRC (Nostr Relay Connect) Provider * - * Manages NRC listener state and connections for cross-device sync. + * 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 + 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' -const STORAGE_KEY_CAT_CONFIG = 'nrc:catConfig' // Default rendezvous relay const DEFAULT_RENDEZVOUS_URL = 'wss://relay.damus.io' interface NRCContextType { - // State + // Listener State (this device accepts connections) isEnabled: boolean isListening: boolean isConnected: boolean - connections: NRCConnection[] + connections: NRCConnection[] // Devices authorized to connect to us activeSessions: number - catConfig: CATConfig | null + relaySupportsCat: boolean // Auto-detected CAT support rendezvousUrl: string - // Actions + // Client State (this device connects to others) + remoteConnections: RemoteConnection[] // Devices we connect to + isSyncing: boolean + syncProgress: SyncProgress | null + + // Listener Actions enable: () => Promise disable: () => void addConnection: (label: string, useCat?: boolean) => Promise<{ uri: string; connection: NRCConnection }> removeConnection: (id: string) => Promise getConnectionURI: (connection: NRCConnection) => string setRendezvousUrl: (url: string) => void - setCATConfig: (config: CATConfig | null) => void + + // Client Actions + addRemoteConnection: (uri: string, label: string) => Promise + removeRemoteConnection: (id: string) => Promise + testRemoteConnection: (id: string) => Promise + syncFromDevice: (id: string, filters?: Filter[]) => Promise + syncAllRemotes: (filters?: Filter[]) => Promise } const NRCContext = createContext(undefined) @@ -62,7 +91,7 @@ interface NRCProviderProps { export function NRCProvider({ children }: NRCProviderProps) { const { pubkey } = useNostr() - // Load initial state from storage + // ===== Listener State ===== const [isEnabled, setIsEnabled] = useState(() => { const stored = localStorage.getItem(STORAGE_KEY_ENABLED) return stored === 'true' @@ -84,25 +113,32 @@ export function NRCProvider({ children }: NRCProviderProps) { return localStorage.getItem(STORAGE_KEY_RENDEZVOUS_URL) || DEFAULT_RENDEZVOUS_URL }) - const [catConfig, setCATConfigState] = useState(() => { - const stored = localStorage.getItem(STORAGE_KEY_CAT_CONFIG) - if (stored) { - try { - return JSON.parse(stored) - } catch { - return null - } - } - return null - }) + // 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(() => { + 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(null) + const listenerService = getNRCListenerService() - // Persist state to storage + // ===== Persist State ===== useEffect(() => { localStorage.setItem(STORAGE_KEY_ENABLED, String(isEnabled)) }, [isEnabled]) @@ -111,19 +147,41 @@ export function NRCProvider({ children }: NRCProviderProps) { 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(() => { - if (catConfig) { - localStorage.setItem(STORAGE_KEY_CAT_CONFIG, JSON.stringify(catConfig)) - } else { - localStorage.removeItem(STORAGE_KEY_CAT_CONFIG) - } - }, [catConfig]) + let cancelled = false - // Build authorized secrets map from connections + 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 => { const map = new Map() for (const conn of connections) { @@ -134,7 +192,6 @@ export function NRCProvider({ children }: NRCProviderProps) { return map }, [connections]) - // Start/stop listener based on enabled state useEffect(() => { if (!isEnabled || !client.signer || !pubkey) { if (listenerService.isRunning()) { @@ -146,15 +203,29 @@ export function NRCProvider({ children }: NRCProviderProps) { return } + // Stop existing listener before starting with new config + if (listenerService.isRunning()) { + listenerService.stop() + } + + let statusInterval: ReturnType | 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: catConfig || undefined + catConfig } + console.log('[NRC] Starting listener with', config.authorizedSecrets.size, 'authorized clients') + listenerService.setOnSessionChange((count) => { setActiveSessions(count) }) @@ -163,15 +234,10 @@ export function NRCProvider({ children }: NRCProviderProps) { setIsListening(true) setIsConnected(listenerService.isConnected()) - // Poll connection status - const statusInterval = setInterval(() => { + statusInterval = setInterval(() => { setIsConnected(listenerService.isConnected()) setActiveSessions(listenerService.getActiveSessionCount()) }, 5000) - - return () => { - clearInterval(statusInterval) - } } catch (error) { console.error('[NRC] Failed to start listener:', error) setIsListening(false) @@ -179,21 +245,305 @@ export function NRCProvider({ children }: NRCProviderProps) { } } - const cleanup = startListener() - return () => { - cleanup?.then((fn) => fn?.()) - } - }, [isEnabled, pubkey, rendezvousUrl, buildAuthorizedSecrets, catConfig]) + startListener() + + return () => { + if (statusInterval) { + clearInterval(statusInterval) + } + listenerService.stop() + setIsListening(false) + setIsConnected(false) + setActiveSessions(0) + } + }, [isEnabled, pubkey, rendezvousUrl, buildAuthorizedSecrets, relaySupportsCatState]) - // Restart listener when connections change (to update authorized secrets) useEffect(() => { if (!isEnabled || !client.signer || !pubkey) return - - // Update authorized secrets without full restart - // Note: The current implementation requires a full restart - // A future optimization could update secrets dynamically }, [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 => { + 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() + const localEventsMap = new Map() + + 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() + 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') @@ -221,9 +571,9 @@ export function NRCProvider({ children }: NRCProviderProps) { let connection: NRCConnection let uri: string - if (useCat && catConfig) { - // CAT-based connection - uri = generateCATConnectionURI(pubkey, rendezvousUrl, catConfig.mintUrl) + // Use CAT if requested AND relay supports it, otherwise fall back to secret-based + if (useCat && relaySupportsCatState) { + uri = generateCATConnectionURI(pubkey, rendezvousUrl) connection = { id, label, @@ -231,7 +581,6 @@ export function NRCProvider({ children }: NRCProviderProps) { createdAt } } else { - // Secret-based connection const result = generateConnectionURI(pubkey, rendezvousUrl, undefined, label) uri = result.uri connection = { @@ -248,7 +597,7 @@ export function NRCProvider({ children }: NRCProviderProps) { return { uri, connection } }, - [pubkey, rendezvousUrl, catConfig] + [pubkey, rendezvousUrl, relaySupportsCatState] ) const removeConnection = useCallback(async (id: string) => { @@ -261,8 +610,8 @@ export function NRCProvider({ children }: NRCProviderProps) { throw new Error('Not logged in') } - if (connection.useCat && catConfig) { - return generateCATConnectionURI(pubkey, rendezvousUrl, catConfig.mintUrl) + if (connection.useCat && relaySupportsCatState) { + return generateCATConnectionURI(pubkey, rendezvousUrl) } if (connection.secret) { @@ -275,27 +624,219 @@ export function NRCProvider({ children }: NRCProviderProps) { return result.uri } - throw new Error('Connection has no secret or CAT config') + throw new Error('Connection has no secret and relay does not support CAT') }, - [pubkey, rendezvousUrl, catConfig] + [pubkey, rendezvousUrl, relaySupportsCatState] ) const setRendezvousUrl = useCallback((url: string) => { setRendezvousUrlState(url) - // Listener will restart automatically via effect }, []) - const setCATConfig = useCallback((config: CATConfig | null) => { - setCATConfigState(config) + // ===== Client Actions ===== + const addRemoteConnection = useCallback( + async (uri: string, label: string): Promise => { + // 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 => { + 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 => { + 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 => { + 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, - catConfig, + relaySupportsCat: relaySupportsCatState, rendezvousUrl, enable, disable, @@ -303,7 +844,15 @@ export function NRCProvider({ children }: NRCProviderProps) { removeConnection, getConnectionURI, setRendezvousUrl, - setCATConfig + // Client + remoteConnections, + isSyncing, + syncProgress, + addRemoteConnection, + removeRemoteConnection, + testRemoteConnection, + syncFromDevice, + syncAllRemotes } return {children} diff --git a/src/providers/NostrProvider/bunker.signer.ts b/src/providers/NostrProvider/bunker.signer.ts index a8bd7406..ca5f173e 100644 --- a/src/providers/NostrProvider/bunker.signer.ts +++ b/src/providers/NostrProvider/bunker.signer.ts @@ -13,6 +13,7 @@ import { ISigner, TDraftEvent } from '@/types' import cashuTokenService, { TCashuToken, TokenScope } from '@/services/cashu-token.service' +import { relaySupportsCat, deriveMintUrlFromRelay } from '@/services/nrc' import * as utils from '@noble/curves/abstract/utils' import { secp256k1 } from '@noble/curves/secp256k1' import { Event, VerifiedEvent, getPublicKey as nGetPublicKey, nip04, finalizeEvent } from 'nostr-tools' @@ -397,35 +398,28 @@ export class BunkerSigner implements ISigner { } /** - * Check if relay requires Cashu token and acquire one if needed. + * Check if relay supports Cashu tokens and acquire one if so. + * Falls back gracefully to regular connection if CAT is not supported. */ private async acquireTokenIfNeeded(relayUrl: string): Promise { - // Convert to HTTP URL for mint endpoints - let mintUrl = relayUrl - if (relayUrl.startsWith('ws://')) { - mintUrl = 'http://' + relayUrl.slice(5) - } else if (relayUrl.startsWith('wss://')) { - mintUrl = 'https://' + relayUrl.slice(6) - } else if (!relayUrl.startsWith('http://') && !relayUrl.startsWith('https://')) { - mintUrl = 'https://' + relayUrl - } - mintUrl = mintUrl.replace(/\/$/, '') - try { - // Check if relay has Cashu mint endpoints - const infoResponse = await fetch(`${mintUrl}/cashu/info`) - if (!infoResponse.ok) { - console.log(`Relay ${relayUrl} does not support Cashu tokens`) + // First check if relay supports CAT using the NRC helper + const supportsCat = await relaySupportsCat(relayUrl) + if (!supportsCat) { + console.log(`[Bunker] Relay ${relayUrl} does not support CAT, using regular connection`) return } - await infoResponse.json() // Validate JSON response - console.log(`Relay ${relayUrl} requires Cashu token, acquiring...`) + console.log(`[Bunker] Relay ${relayUrl} supports CAT, acquiring token...`) - // Configure the mint + // Derive mint URL from relay URL + const mintUrl = deriveMintUrlFromRelay(relayUrl) this.mintUrl = mintUrl cashuTokenService.setMint(mintUrl) + // Fetch mint info to initialize the service + await cashuTokenService.fetchMintInfo() + // Create NIP-98 auth signer using our local ephemeral key const signHttpAuth = async (url: string, method: string): Promise => { const authEvent: TDraftEvent = { @@ -457,10 +451,10 @@ export class BunkerSigner implements ISigner { this.token = token cashuTokenService.storeTokens(this.bunkerPubkey, token) - console.log(`Acquired Cashu token for ${relayUrl}, expires: ${new Date(token.expiry * 1000).toISOString()}`) + console.log(`[Bunker] Acquired CAT token for ${relayUrl}, expires: ${new Date(token.expiry * 1000).toISOString()}`) } catch (err) { - // Relay doesn't support Cashu or request failed - continue without token - console.warn(`Could not acquire Cashu token for ${relayUrl}:`, err) + // Token acquisition failed - continue without token + console.log(`[Bunker] CAT token acquisition failed for ${relayUrl}, using regular connection:`, err instanceof Error ? err.message : err) } } diff --git a/src/providers/SettingsSyncProvider.tsx b/src/providers/SettingsSyncProvider.tsx index acd4a11a..d2f9d03a 100644 --- a/src/providers/SettingsSyncProvider.tsx +++ b/src/providers/SettingsSyncProvider.tsx @@ -45,7 +45,8 @@ function getCurrentSettings(): TSyncSettings { filterOutOnionRelays: storage.getFilterOutOnionRelays(), quickReaction: storage.getQuickReaction(), quickReactionEmoji: storage.getQuickReactionEmoji(), - noteListMode: storage.getNoteListMode() + noteListMode: storage.getNoteListMode(), + nrcOnlyConfigSync: storage.getNrcOnlyConfigSync() } } @@ -113,6 +114,9 @@ function applySettings(settings: TSyncSettings) { if (settings.noteListMode !== undefined) { storage.setNoteListMode(settings.noteListMode) } + if (settings.nrcOnlyConfigSync !== undefined) { + storage.setNrcOnlyConfigSync(settings.nrcOnlyConfigSync) + } } export function SettingsSyncProvider({ children }: { children: React.ReactNode }) { @@ -155,6 +159,9 @@ export function SettingsSyncProvider({ children }: { children: React.ReactNode } const syncSettings = useCallback(async () => { if (!pubkey || !account) return + // Skip relay-based settings sync if NRC-only config sync is enabled + if (storage.getNrcOnlyConfigSync()) return + const currentSettings = getCurrentSettings() const settingsJson = JSON.stringify(currentSettings) @@ -192,6 +199,13 @@ export function SettingsSyncProvider({ children }: { children: React.ReactNode } return } + // Skip relay-based settings sync if NRC-only config sync is enabled + // (settings will sync via NRC instead) + if (storage.getNrcOnlyConfigSync()) { + lastSyncedSettingsRef.current = JSON.stringify(getCurrentSettings()) + return + } + const loadRemoteSettings = async () => { setIsLoading(true) try { diff --git a/src/services/cashu-token.service.ts b/src/services/cashu-token.service.ts index ab5c6dcc..b530b8c0 100644 --- a/src/services/cashu-token.service.ts +++ b/src/services/cashu-token.service.ts @@ -22,7 +22,8 @@ export const TokenScope = { RELAY: 'relay', NIP46: 'nip46', BLOSSOM: 'blossom', - API: 'api' + API: 'api', + NRC: 'nrc' } as const export type TTokenScope = (typeof TokenScope)[keyof typeof TokenScope] diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 2cf6d55f..ba2be6f7 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -95,6 +95,19 @@ class ClientService extends EventTarget { } } + // NRC-only config sync: don't publish config events to relays, only sync via NRC + const CONFIG_KINDS = [ + kinds.Contacts, // 3 + kinds.Mutelist, // 10000 + kinds.RelayList, // 10002 + 30002, // Relay sets + ExtendedKind.FAVORITE_RELAYS, // 10012 + 30078 // Application data (settings sync) + ] + if (storage.getNrcOnlyConfigSync() && CONFIG_KINDS.includes(event.kind)) { + return [] // No relays - NRC will sync this event to paired devices + } + const relaySet = new Set() if (specifiedRelayUrls?.length) { specifiedRelayUrls.forEach((url) => relaySet.add(url)) diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index aa9069f2..0aa8310f 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -63,6 +63,7 @@ class LocalStorageService { private graphQueriesEnabled: boolean = true private socialGraphProximity: number | null = null private socialGraphIncludeMode: boolean = true // true = include only, false = exclude + private nrcOnlyConfigSync: boolean = false constructor() { if (!LocalStorageService.instance) { @@ -264,6 +265,9 @@ class LocalStorageService { this.socialGraphIncludeMode = window.localStorage.getItem(StorageKey.SOCIAL_GRAPH_INCLUDE_MODE) !== 'false' + this.nrcOnlyConfigSync = + window.localStorage.getItem(StorageKey.NRC_ONLY_CONFIG_SYNC) === 'true' + // Clean up deprecated data window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS) window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) @@ -686,6 +690,15 @@ class LocalStorageService { this.socialGraphIncludeMode = include window.localStorage.setItem(StorageKey.SOCIAL_GRAPH_INCLUDE_MODE, include.toString()) } + + getNrcOnlyConfigSync() { + return this.nrcOnlyConfigSync + } + + setNrcOnlyConfigSync(nrcOnly: boolean) { + this.nrcOnlyConfigSync = nrcOnly + window.localStorage.setItem(StorageKey.NRC_ONLY_CONFIG_SYNC, nrcOnly.toString()) + } } const instance = new LocalStorageService() diff --git a/src/services/nrc/index.ts b/src/services/nrc/index.ts index ba406633..565e3b1b 100644 --- a/src/services/nrc/index.ts +++ b/src/services/nrc/index.ts @@ -2,3 +2,5 @@ export * from './nrc-types' export * from './nrc-uri' export * from './nrc-session' export { NRCListenerService, getNRCListenerService, default as nrcListenerService } from './nrc-listener.service' +export { NRCClient, syncFromRemote, testConnection, requestRemoteIDs, sendEventsToRemote } from './nrc-client.service' +export type { SyncProgress, RemoteConnection } from './nrc-client.service' diff --git a/src/services/nrc/nrc-client.service.ts b/src/services/nrc/nrc-client.service.ts new file mode 100644 index 00000000..2512e9a2 --- /dev/null +++ b/src/services/nrc/nrc-client.service.ts @@ -0,0 +1,877 @@ +/** + * NRC (Nostr Relay Connect) Client Service + * + * Connects to a remote NRC listener and syncs events. + * Uses the nostr+relayconnect:// URI scheme to establish encrypted + * communication through a rendezvous relay. + */ + +import { Event, Filter } from 'nostr-tools' +import * as nip44 from 'nostr-tools/nip44' +import * as utils from '@noble/curves/abstract/utils' +import { finalizeEvent } from 'nostr-tools' +import { ISigner } from '@/types' +import { + KIND_NRC_REQUEST, + KIND_NRC_RESPONSE, + RequestMessage, + ResponseMessage, + ParsedConnectionURI, + EventManifestEntry +} from './nrc-types' +import { parseConnectionURI, deriveConversationKey } from './nrc-uri' + +/** + * Generate a random subscription ID + */ +function generateSubId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(8)) + return utils.bytesToHex(bytes) +} + +/** + * Generate a random session ID + */ +function generateSessionId(): string { + return crypto.randomUUID() +} + +/** + * Sync progress callback + */ +export interface SyncProgress { + phase: 'connecting' | 'requesting' | 'receiving' | 'sending' | 'complete' | 'error' + eventsReceived: number + eventsSent?: number + message?: string +} + +/** + * Remote connection state + */ +export interface RemoteConnection { + id: string + uri: string + label: string + relayPubkey: string + rendezvousUrl: string + authMode: 'secret' | 'cat' + mintUrl?: string // For CAT mode + lastSync?: number + eventCount?: number +} + +// Chunk buffer for reassembling large messages +interface ChunkBuffer { + chunks: Map + total: number + receivedAt: number +} + +// Default sync timeout: 60 seconds +const DEFAULT_SYNC_TIMEOUT = 60000 + +/** + * NRC Client for connecting to remote devices + */ +export class NRCClient { + private uri: ParsedConnectionURI + private ws: WebSocket | null = null + private sessionId: string + private connected = false + private subId: string | null = null + private pendingEvents: Event[] = [] + private onProgress?: (progress: SyncProgress) => void + private resolveSync?: (events: Event[]) => void + private rejectSync?: (error: Error) => void + private chunkBuffers: Map = new Map() + private syncTimeout: ReturnType | null = null + private lastActivityTime: number = 0 + // CAT mode fields + private signer?: ISigner + private catToken?: string + private clientPubkey?: string + + constructor(connectionUri: string, signer?: ISigner, catToken?: string) { + this.uri = parseConnectionURI(connectionUri) + this.sessionId = generateSessionId() + this.signer = signer + this.catToken = catToken + } + + /** + * Get the relay pubkey this client connects to + */ + getRelayPubkey(): string { + return this.uri.relayPubkey + } + + /** + * Get the rendezvous URL + */ + getRendezvousUrl(): string { + return this.uri.rendezvousUrl + } + + /** + * Connect to the rendezvous relay and sync events + */ + async sync( + filters: Filter[], + onProgress?: (progress: SyncProgress) => void, + timeout: number = DEFAULT_SYNC_TIMEOUT + ): Promise { + this.onProgress = onProgress + this.pendingEvents = [] + this.chunkBuffers.clear() + this.lastActivityTime = Date.now() + + // For CAT mode, get our pubkey from the signer + if (this.uri.authMode === 'cat' && this.signer) { + this.clientPubkey = await this.signer.getPublicKey() + console.log(`[NRC Client] CAT mode, our pubkey: ${this.clientPubkey?.slice(0, 8)}...`) + } + + return new Promise((resolve, reject) => { + this.resolveSync = resolve + this.rejectSync = reject + + // Set up sync timeout + this.syncTimeout = setTimeout(() => { + const timeSinceActivity = Date.now() - this.lastActivityTime + if (timeSinceActivity > 30000) { + // No activity for 30s, likely stalled + console.error('[NRC Client] Sync timeout - no activity for 30s') + this.disconnect() + reject(new Error('Sync timeout - connection stalled')) + } else { + // Still receiving data, extend timeout + console.log('[NRC Client] Sync still active, extending timeout') + this.syncTimeout = setTimeout(() => { + this.disconnect() + reject(new Error('Sync timeout')) + }, timeout) + } + }, timeout) + + this.connect() + .then(() => { + this.sendREQ(filters) + }) + .catch((err) => { + this.clearSyncTimeout() + reject(err) + }) + }) + } + + // State for IDS request + private idsMode = false + private resolveIDs?: (manifest: EventManifestEntry[]) => void + private rejectIDs?: (error: Error) => void + + // State for sending events + private sendingEvents = false + private eventsSentCount = 0 + private eventsToSend: Event[] = [] + private resolveSend?: (count: number) => void + + /** + * Request event IDs from remote (for diffing) + */ + async requestIDs( + filters: Filter[], + onProgress?: (progress: SyncProgress) => void, + timeout: number = DEFAULT_SYNC_TIMEOUT + ): Promise { + this.onProgress = onProgress + this.chunkBuffers.clear() + this.lastActivityTime = Date.now() + this.idsMode = true + + // For CAT mode, get our pubkey from the signer + if (this.uri.authMode === 'cat' && this.signer) { + this.clientPubkey = await this.signer.getPublicKey() + } + + return new Promise((resolve, reject) => { + this.resolveIDs = resolve + this.rejectIDs = reject + + this.syncTimeout = setTimeout(() => { + this.disconnect() + reject(new Error('IDS request timeout')) + }, timeout) + + this.connect() + .then(() => { + this.sendIDSRequest(filters) + }) + .catch((err) => { + this.clearSyncTimeout() + reject(err) + }) + }) + } + + /** + * Send IDS request + */ + private sendIDSRequest(filters: Filter[]): void { + if (!this.ws || !this.connected) { + this.rejectIDs?.(new Error('Not connected')) + return + } + + this.onProgress?.({ + phase: 'requesting', + eventsReceived: 0, + message: 'Requesting event IDs...' + }) + + this.subId = generateSubId() + + const request: RequestMessage = { + type: 'IDS', + payload: ['IDS', this.subId, ...filters] + } + + this.sendEncryptedRequest(request).catch((err) => { + console.error('[NRC Client] Failed to send IDS:', err) + this.rejectIDs?.(err) + }) + } + + /** + * Send events to remote device + */ + async sendEvents( + events: Event[], + onProgress?: (progress: SyncProgress) => void, + timeout: number = DEFAULT_SYNC_TIMEOUT + ): Promise { + if (events.length === 0) return 0 + + this.onProgress = onProgress + this.chunkBuffers.clear() + this.lastActivityTime = Date.now() + this.sendingEvents = true + this.eventsSentCount = 0 + this.eventsToSend = [...events] + + // For CAT mode, get our pubkey from the signer + if (this.uri.authMode === 'cat' && this.signer) { + this.clientPubkey = await this.signer.getPublicKey() + } + + return new Promise((resolve, reject) => { + this.resolveSend = resolve + + this.syncTimeout = setTimeout(() => { + this.disconnect() + reject(new Error('Send events timeout')) + }, timeout) + + this.connect() + .then(() => { + this.sendNextEvent() + }) + .catch((err) => { + this.clearSyncTimeout() + reject(err) + }) + }) + } + + /** + * Send the next event in the queue + */ + private sendNextEvent(): void { + if (this.eventsToSend.length === 0) { + // All done + this.clearSyncTimeout() + this.onProgress?.({ + phase: 'complete', + eventsReceived: 0, + eventsSent: this.eventsSentCount, + message: `Sent ${this.eventsSentCount} events` + }) + this.resolveSend?.(this.eventsSentCount) + this.disconnect() + return + } + + const event = this.eventsToSend.shift()! + this.onProgress?.({ + phase: 'sending', + eventsReceived: 0, + eventsSent: this.eventsSentCount, + message: `Sending event ${this.eventsSentCount + 1}...` + }) + + const request: RequestMessage = { + type: 'EVENT', + payload: ['EVENT', event] + } + + this.sendEncryptedRequest(request).catch((err) => { + console.error('[NRC Client] Failed to send EVENT:', err) + // Continue with next event even if this one failed + this.sendNextEvent() + }) + } + + /** + * Clear the sync timeout + */ + private clearSyncTimeout(): void { + if (this.syncTimeout) { + clearTimeout(this.syncTimeout) + this.syncTimeout = null + } + } + + /** + * Update last activity time (called when receiving data) + */ + private updateActivity(): void { + this.lastActivityTime = Date.now() + } + + /** + * Connect to the rendezvous relay + */ + private async connect(): Promise { + if (this.connected) return + + this.onProgress?.({ + phase: 'connecting', + eventsReceived: 0, + message: 'Connecting to rendezvous relay...' + }) + + const relayUrl = this.uri.rendezvousUrl + + return new Promise((resolve, reject) => { + // Normalize WebSocket URL + let wsUrl = relayUrl + if (relayUrl.startsWith('http://')) { + wsUrl = 'ws://' + relayUrl.slice(7) + } else if (relayUrl.startsWith('https://')) { + wsUrl = 'wss://' + relayUrl.slice(8) + } else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) { + wsUrl = 'wss://' + relayUrl + } + + console.log(`[NRC Client] Connecting to: ${wsUrl}`) + + const ws = new WebSocket(wsUrl) + + const timeout = setTimeout(() => { + ws.close() + reject(new Error('Connection timeout')) + }, 10000) + + ws.onopen = () => { + clearTimeout(timeout) + this.ws = ws + this.connected = true + + // Subscribe to responses for our client pubkey + const responseSubId = generateSubId() + // Use CAT-mode pubkey if available, otherwise use secret-derived pubkey + const clientPubkey = this.clientPubkey || this.uri.clientPubkey + + if (!clientPubkey) { + reject(new Error('Client pubkey not available')) + return + } + + ws.send( + JSON.stringify([ + 'REQ', + responseSubId, + { + kinds: [KIND_NRC_RESPONSE], + '#p': [clientPubkey], + since: Math.floor(Date.now() / 1000) - 60 + } + ]) + ) + + console.log(`[NRC Client] Connected, subscribed for responses to ${clientPubkey.slice(0, 8)}...`) + resolve() + } + + ws.onerror = (error) => { + clearTimeout(timeout) + console.error('[NRC Client] WebSocket error:', error) + reject(new Error('WebSocket error')) + } + + ws.onclose = () => { + this.connected = false + this.ws = null + console.log('[NRC Client] WebSocket closed') + } + + ws.onmessage = (event) => { + this.handleMessage(event.data) + } + }) + } + + /** + * Send a REQ message to the remote listener + */ + private sendREQ(filters: Filter[]): void { + if (!this.ws || !this.connected) { + this.rejectSync?.(new Error('Not connected')) + return + } + + console.log(`[NRC Client] Sending REQ to listener pubkey: ${this.uri.relayPubkey?.slice(0, 8)}...`) + console.log(`[NRC Client] Our client pubkey: ${this.uri.clientPubkey?.slice(0, 8)}...`) + console.log(`[NRC Client] Filters:`, JSON.stringify(filters)) + + this.onProgress?.({ + phase: 'requesting', + eventsReceived: 0, + message: 'Requesting events...' + }) + + this.subId = generateSubId() + + const request: RequestMessage = { + type: 'REQ', + payload: ['REQ', this.subId, ...filters] + } + + this.sendEncryptedRequest(request).catch((err) => { + console.error('[NRC Client] Failed to send request:', err) + this.rejectSync?.(err) + }) + } + + /** + * Send an encrypted request to the remote listener + */ + private async sendEncryptedRequest(request: RequestMessage): Promise { + if (!this.ws) { + throw new Error('Not connected') + } + + const plaintext = JSON.stringify(request) + let encrypted: string + let signedEvent: Event + + if (this.uri.authMode === 'cat' && this.signer && this.clientPubkey) { + // CAT mode: use signer for encryption and signing + if (!this.signer.nip44Encrypt) { + throw new Error('Signer does not support NIP-44 encryption') + } + + encrypted = await this.signer.nip44Encrypt(this.uri.relayPubkey, plaintext) + + // Build the request event with CAT token + const tags: string[][] = [ + ['p', this.uri.relayPubkey], + ['encryption', 'nip44_v2'], + ['session', this.sessionId] + ] + + // Add CAT token if available + if (this.catToken) { + tags.push(['cashu', this.catToken]) + } + + const unsignedEvent = { + kind: KIND_NRC_REQUEST, + content: encrypted, + tags, + created_at: Math.floor(Date.now() / 1000) + } + + signedEvent = await this.signer.signEvent(unsignedEvent) + console.log(`[NRC Client] CAT mode: Sent encrypted REQ with CAT token`) + } else { + // Secret mode: use derived keys + if (!this.uri.clientPrivkey || !this.uri.clientPubkey) { + throw new Error('Missing keys for secret mode') + } + + // Derive conversation key + const conversationKey = deriveConversationKey( + this.uri.clientPrivkey, + this.uri.relayPubkey + ) + + encrypted = nip44.v2.encrypt(plaintext, conversationKey) + + // Build the request event + const unsignedEvent = { + kind: KIND_NRC_REQUEST, + content: encrypted, + tags: [ + ['p', this.uri.relayPubkey], + ['encryption', 'nip44_v2'], + ['session', this.sessionId] + ], + created_at: Math.floor(Date.now() / 1000), + pubkey: this.uri.clientPubkey + } + + signedEvent = finalizeEvent(unsignedEvent, this.uri.clientPrivkey) + } + + // Send to rendezvous relay + this.ws.send(JSON.stringify(['EVENT', signedEvent])) + console.log(`[NRC Client] Sent encrypted REQ, event id: ${signedEvent.id?.slice(0, 8)}..., p-tag: ${this.uri.relayPubkey?.slice(0, 8)}...`) + } + + /** + * Handle incoming WebSocket messages + */ + private handleMessage(data: string): void { + try { + const msg = JSON.parse(data) + if (!Array.isArray(msg)) return + + const [type, ...rest] = msg + + if (type === 'EVENT') { + const [subId, event] = rest as [string, Event] + console.log(`[NRC Client] Received EVENT on sub ${subId}, kind ${event.kind}, from ${event.pubkey?.slice(0, 8)}...`) + + if (event.kind === KIND_NRC_RESPONSE) { + // Check p-tag to see who it's addressed to + const pTag = event.tags.find(t => t[0] === 'p')?.[1] + console.log(`[NRC Client] Response p-tag: ${pTag?.slice(0, 8)}..., our pubkey: ${this.uri.clientPubkey?.slice(0, 8)}...`) + this.handleResponse(event) + } else { + console.log(`[NRC Client] Ignoring event kind ${event.kind}`) + } + } else if (type === 'EOSE') { + console.log('[NRC Client] Received EOSE from relay subscription') + } else if (type === 'OK') { + console.log('[NRC Client] Event published:', rest) + } else if (type === 'NOTICE') { + console.log('[NRC Client] Relay notice:', rest[0]) + } + } catch (err) { + console.error('[NRC Client] Failed to parse message:', err) + } + } + + /** + * Handle a response event from the remote listener + */ + private handleResponse(event: Event): void { + console.log(`[NRC Client] Attempting to decrypt response from ${event.pubkey?.slice(0, 8)}...`) + + this.decryptAndProcessResponse(event).catch((err) => { + console.error('[NRC Client] Failed to handle response:', err) + }) + } + + /** + * Decrypt and process a response event + */ + private async decryptAndProcessResponse(event: Event): Promise { + let plaintext: string + + if (this.uri.authMode === 'cat' && this.signer) { + // CAT mode: use signer for decryption + if (!this.signer.nip44Decrypt) { + throw new Error('Signer does not support NIP-44 decryption') + } + plaintext = await this.signer.nip44Decrypt(event.pubkey, event.content) + } else { + // Secret mode: use derived key + if (!this.uri.clientPrivkey) { + throw new Error('Missing private key for decryption') + } + + const conversationKey = deriveConversationKey( + this.uri.clientPrivkey, + this.uri.relayPubkey + ) + plaintext = nip44.v2.decrypt(event.content, conversationKey) + } + + const response: ResponseMessage = JSON.parse(plaintext) + console.log(`[NRC Client] Received response: ${response.type}`) + + // Handle chunked messages + if (response.type === 'CHUNK') { + this.handleChunk(response) + return + } + + this.processResponse(response) + } + + /** + * Handle a chunk message and reassemble when complete + */ + private handleChunk(response: ResponseMessage): void { + const chunk = response.payload[0] as { + type: 'CHUNK' + messageId: string + index: number + total: number + data: string + } + + if (!chunk || chunk.type !== 'CHUNK') { + console.error('[NRC Client] Invalid chunk message') + return + } + + const { messageId, index, total, data } = chunk + + // Get or create buffer for this message + let buffer = this.chunkBuffers.get(messageId) + if (!buffer) { + buffer = { + chunks: new Map(), + total, + receivedAt: Date.now() + } + this.chunkBuffers.set(messageId, buffer) + } + + // Store the chunk + buffer.chunks.set(index, data) + this.updateActivity() + console.log(`[NRC Client] Received chunk ${index + 1}/${total} for message ${messageId.slice(0, 8)}`) + + // Check if we have all chunks + if (buffer.chunks.size === buffer.total) { + // Reassemble the message + const parts: string[] = [] + for (let i = 0; i < buffer.total; i++) { + const part = buffer.chunks.get(i) + if (!part) { + console.error(`[NRC Client] Missing chunk ${i} for message ${messageId}`) + this.chunkBuffers.delete(messageId) + return + } + parts.push(part) + } + + // Decode from base64 + const encoded = parts.join('') + try { + const plaintext = decodeURIComponent(escape(atob(encoded))) + const reassembled: ResponseMessage = JSON.parse(plaintext) + console.log(`[NRC Client] Reassembled chunked message: ${reassembled.type}`) + this.processResponse(reassembled) + } catch (err) { + console.error('[NRC Client] Failed to reassemble chunked message:', err) + } + + // Clean up buffer + this.chunkBuffers.delete(messageId) + } + + // Clean up old buffers (older than 60 seconds) + const now = Date.now() + for (const [id, buf] of this.chunkBuffers) { + if (now - buf.receivedAt > 60000) { + console.warn(`[NRC Client] Discarding stale chunk buffer: ${id}`) + this.chunkBuffers.delete(id) + } + } + } + + /** + * Process a complete response message + */ + private processResponse(response: ResponseMessage): void { + this.updateActivity() + + switch (response.type) { + case 'EVENT': { + // Extract the event from payload: ["EVENT", subId, eventObject] + const [, , syncedEvent] = response.payload as [string, string, Event] + if (syncedEvent) { + this.pendingEvents.push(syncedEvent) + this.onProgress?.({ + phase: 'receiving', + eventsReceived: this.pendingEvents.length, + message: `Received ${this.pendingEvents.length} events...` + }) + } + break + } + case 'EOSE': { + console.log(`[NRC Client] EOSE received, got ${this.pendingEvents.length} events`) + this.complete() + break + } + case 'NOTICE': { + const [, message] = response.payload as [string, string] + console.log(`[NRC Client] Notice: ${message}`) + this.onProgress?.({ + phase: 'error', + eventsReceived: this.pendingEvents.length, + message: message + }) + break + } + case 'OK': { + // Response to EVENT publish + if (this.sendingEvents) { + const [, eventId, success, message] = response.payload as [string, string, boolean, string] + if (success) { + this.eventsSentCount++ + console.log(`[NRC Client] Event ${eventId?.slice(0, 8)} stored successfully`) + } else { + console.warn(`[NRC Client] Event ${eventId?.slice(0, 8)} failed: ${message}`) + } + // Send next event + this.sendNextEvent() + } + break + } + case 'IDS': { + // Response to IDS request - contains event manifest + if (this.idsMode) { + const [, , manifest] = response.payload as [string, string, EventManifestEntry[]] + console.log(`[NRC Client] Received IDS response with ${manifest?.length || 0} entries`) + this.clearSyncTimeout() + this.resolveIDs?.(manifest || []) + this.disconnect() + } + break + } + default: + console.log(`[NRC Client] Unknown response type: ${response.type}`) + } + } + + /** + * Complete the sync operation + */ + private complete(): void { + this.clearSyncTimeout() + + this.onProgress?.({ + phase: 'complete', + eventsReceived: this.pendingEvents.length, + message: `Synced ${this.pendingEvents.length} events` + }) + + this.resolveSync?.(this.pendingEvents) + this.disconnect() + } + + /** + * Disconnect from the rendezvous relay + */ + disconnect(): void { + this.clearSyncTimeout() + + if (this.ws) { + this.ws.close() + this.ws = null + } + this.connected = false + } +} + +/** + * Sync events from a remote device + * + * @param connectionUri - The nostr+relayconnect:// URI + * @param filters - Nostr filters for events to sync + * @param onProgress - Optional progress callback + * @param signer - Optional signer for CAT mode + * @param catToken - Optional CAT token for CAT mode + * @returns Array of synced events + */ +export async function syncFromRemote( + connectionUri: string, + filters: Filter[], + onProgress?: (progress: SyncProgress) => void, + signer?: ISigner, + catToken?: string +): Promise { + const client = new NRCClient(connectionUri, signer, catToken) + return client.sync(filters, onProgress) +} + +/** + * Test connection to a remote device + * Performs a minimal sync (kind 0 with limit 1) to verify the connection works + * + * @param connectionUri - The nostr+relayconnect:// URI + * @param onProgress - Optional progress callback + * @param signer - Optional signer for CAT mode + * @param catToken - Optional CAT token for CAT mode + * @returns true if connection successful + */ +export async function testConnection( + connectionUri: string, + onProgress?: (progress: SyncProgress) => void, + signer?: ISigner, + catToken?: string +): Promise { + const client = new NRCClient(connectionUri, signer, catToken) + try { + // Request just one profile event to test the full round-trip + const events = await client.sync( + [{ kinds: [0], limit: 1 }], + onProgress, + 15000 // 15 second timeout for test + ) + console.log(`[NRC] Test connection successful, received ${events.length} events`) + return true + } catch (err) { + console.error('[NRC] Test connection failed:', err) + throw err + } +} + +/** + * Request event IDs from a remote device (for diffing) + * + * @param connectionUri - The nostr+relayconnect:// URI + * @param filters - Filters to match events + * @param onProgress - Optional progress callback + * @param signer - Optional signer for CAT mode + * @param catToken - Optional CAT token for CAT mode + * @returns Array of event manifest entries (id, kind, created_at, d) + */ +export async function requestRemoteIDs( + connectionUri: string, + filters: Filter[], + onProgress?: (progress: SyncProgress) => void, + signer?: ISigner, + catToken?: string +): Promise { + const client = new NRCClient(connectionUri, signer, catToken) + return client.requestIDs(filters, onProgress) +} + +/** + * Send events to a remote device + * + * @param connectionUri - The nostr+relayconnect:// URI + * @param events - Events to send + * @param onProgress - Optional progress callback + * @param signer - Optional signer for CAT mode + * @param catToken - Optional CAT token for CAT mode + * @returns Number of events successfully stored + */ +export async function sendEventsToRemote( + connectionUri: string, + events: Event[], + onProgress?: (progress: SyncProgress) => void, + signer?: ISigner, + catToken?: string +): Promise { + const client = new NRCClient(connectionUri, signer, catToken) + return client.sendEvents(events, onProgress) +} diff --git a/src/services/nrc/nrc-listener.service.ts b/src/services/nrc/nrc-listener.service.ts index 18900de3..906bf300 100644 --- a/src/services/nrc/nrc-listener.service.ts +++ b/src/services/nrc/nrc-listener.service.ts @@ -12,9 +12,9 @@ */ import { Event, Filter } from 'nostr-tools' -import * as nip44 from 'nostr-tools/nip44' import * as utils from '@noble/curves/abstract/utils' import indexedDb from '@/services/indexed-db.service' +import cashuTokenService, { decodeToken, TCashuToken } from '@/services/cashu-token.service' import { KIND_NRC_REQUEST, KIND_NRC_RESPONSE, @@ -23,10 +23,10 @@ import { ResponseMessage, AuthResult, NRCSession, - isDeviceSpecificEvent + isDeviceSpecificEvent, + EventManifestEntry } from './nrc-types' import { NRCSessionManager } from './nrc-session' -import { deriveConversationKey } from './nrc-uri' /** * Generate a random subscription ID @@ -189,7 +189,7 @@ export class NRCListenerService { ]) ) - console.log(`[NRC] Connected and subscribed with subId: ${this.subId}`) + console.log(`[NRC] Connected and subscribed with subId: ${this.subId}, listening for pubkey: ${this.listenerPubkey}`) resolve() } @@ -252,6 +252,7 @@ export class NRCListenerService { if (type === 'EVENT') { const [, event] = rest as [string, Event] if (event.kind === KIND_NRC_REQUEST) { + console.log('[NRC] Received NRC request from pubkey:', event.pubkey) this.handleRequest(event).catch((err) => { console.error('[NRC] Error handling request:', err) }) @@ -289,7 +290,7 @@ export class NRCListenerService { // Get or create session const session = this.sessions.getOrCreateSession( event.pubkey, - authResult.conversationKey, + undefined, // We use signer's nip44 methods instead of conversationKey authResult.mode, authResult.deviceName ) @@ -297,9 +298,10 @@ export class NRCListenerService { // Notify session change this.onSessionChange?.(this.sessions.getActiveSessionCount()) - // Decrypt the content - const plaintext = nip44.v2.decrypt(event.content, authResult.conversationKey) + // Decrypt the content using signer + const plaintext = await this.decrypt(event.pubkey, event.content) const request: RequestMessage = JSON.parse(plaintext) + console.log('[NRC] Received request:', request.type) // Handle the request based on type switch (request.type) { @@ -310,8 +312,11 @@ export class NRCListenerService { await this.handleCLOSE(session, request.payload) break case 'EVENT': - // Read-only mode - reject EVENT requests - await this.sendError(event, session, 'This NRC endpoint is read-only') + await this.handleEVENT(event, session, request.payload) + break + case 'IDS': + // Return just event IDs matching filters (for diffing) + await this.handleIDS(event, session, request.payload) break case 'COUNT': // Not implemented @@ -341,53 +346,125 @@ export class NRCListenerService { // Check for CAT token in cashu tag const cashuTag = event.tags.find((t) => t[0] === 'cashu') - if (cashuTag && this.config.catConfig) { - // TODO: Implement CAT token verification - // For now, we'll use the client's Nostr pubkey for ECDH - const ourPrivkey = await this.getPrivateKey() - const conversationKey = deriveConversationKey(ourPrivkey, event.pubkey) - return { - mode: 'cat', - conversationKey, - deviceName: 'cat-client' + if (cashuTag && cashuTag[1] && this.config.catConfig) { + const catResult = await this.verifyCATToken(cashuTag[1], event.pubkey) + if (catResult) { + return catResult } + // CAT verification failed, fall through to check other auth methods + console.log('[NRC] CAT verification failed, checking other auth methods') } // Secret-based auth: check if pubkey is authorized const deviceName = this.config.authorizedSecrets.get(event.pubkey) if (!deviceName) { + console.log('[NRC] Unauthorized pubkey:', event.pubkey) + console.log('[NRC] Authorized pubkeys:', Array.from(this.config.authorizedSecrets.keys())) + console.log('[NRC] Authorized pubkeys (full):', JSON.stringify(Array.from(this.config.authorizedSecrets.entries()))) throw new Error('Unauthorized: unknown client pubkey') } - // Derive conversation key via ECDH - const ourPrivkey = await this.getPrivateKey() - const conversationKey = deriveConversationKey(ourPrivkey, event.pubkey) - return { mode: 'secret', - conversationKey, deviceName } } /** - * Get our private key from the signer - * Note: This requires the signer to expose the private key, which not all signers do + * Verify a CAT (Cashu Access Token) for NRC authentication */ - private async getPrivateKey(): Promise { + private async verifyCATToken(encodedToken: string, clientPubkey: string): Promise { + if (!this.config?.catConfig) { + return null + } + + try { + // Decode the token + const token: TCashuToken = decodeToken(encodedToken) + console.log('[NRC] Verifying CAT token, scope:', token.scope, 'expiry:', new Date(token.expiry * 1000)) + + // Check expiry + const now = Math.floor(Date.now() / 1000) + if (token.expiry < now) { + console.log('[NRC] CAT token expired') + return null + } + + // Check scope - must be 'nrc' or 'relay' for NRC auth + if (token.scope !== 'nrc' && token.scope !== 'relay') { + console.log('[NRC] CAT token has wrong scope:', token.scope) + return null + } + + // Verify the token pubkey matches the event pubkey + const tokenPubkeyHex = utils.bytesToHex(token.pubkey) + if (tokenPubkeyHex !== clientPubkey) { + console.log('[NRC] CAT token pubkey mismatch:', tokenPubkeyHex, '!=', clientPubkey) + return null + } + + // Initialize the cashu service with the mint URL if not already done + cashuTokenService.setMint(this.config.catConfig.mintUrl) + + // Verify token signature with mint + // Note: This requires the mint info to be fetched first + try { + await cashuTokenService.fetchMintInfo() + } catch (err) { + console.warn('[NRC] Could not fetch mint info for CAT verification:', err) + // Continue anyway - we've done basic validation + } + + if (!cashuTokenService.verifyToken(token)) { + console.log('[NRC] CAT token signature verification failed') + return null + } + + console.log('[NRC] CAT token verified successfully') + return { + mode: 'cat', + deviceName: `cat:${clientPubkey.slice(0, 8)}` + } + } catch (err) { + console.error('[NRC] CAT token verification error:', err) + return null + } + } + + /** + * Decrypt content using the signer's NIP-44 implementation + */ + private async decrypt(clientPubkey: string, ciphertext: string): Promise { if (!this.config) { throw new Error('Listener not configured') } - // Try to get private key from signer if it's an NsecSigner - const signer = this.config.signer as { privkey?: Uint8Array } - if (signer.privkey) { - return signer.privkey + if (!this.config.signer.nip44Decrypt) { + throw new Error('Signer does not support NIP-44 decryption') } - throw new Error('Signer does not expose private key - NRC requires direct key access') + return this.config.signer.nip44Decrypt(clientPubkey, ciphertext) } + /** + * Encrypt content using the signer's NIP-44 implementation + */ + private async encrypt(clientPubkey: string, plaintext: string): Promise { + if (!this.config) { + throw new Error('Listener not configured') + } + + if (!this.config.signer.nip44Encrypt) { + throw new Error('Signer does not support NIP-44 encryption') + } + + return this.config.signer.nip44Encrypt(clientPubkey, plaintext) + } + + // Max chunk size (accounting for encryption overhead and event wrapper) + // NIP-44 adds ~100 bytes overhead, plus base64 encoding increases size by ~33% + private static readonly MAX_CHUNK_SIZE = 40000 // ~40KB chunks to stay safely under 65KB limit + /** * Handle REQ message - query local storage and respond */ @@ -413,6 +490,7 @@ export class NRCListenerService { // Query local events matching the filters const events = await this.queryLocalEvents(filterObjs) + console.log(`[NRC] Found ${events.length} events matching filters`) // Send each matching event for (const evt of events) { @@ -420,8 +498,13 @@ export class NRCListenerService { type: 'EVENT', payload: ['EVENT', subId, evt] } - await this.sendResponse(reqEvent, session, response) - this.sessions.incrementEventCount(session.id, subId) + + try { + await this.sendResponseChunked(reqEvent, session, response) + this.sessions.incrementEventCount(session.id, subId) + } catch (err) { + console.error(`[NRC] Failed to send event ${evt.id?.slice(0, 8)}:`, err) + } } // Send EOSE @@ -431,6 +514,7 @@ export class NRCListenerService { } await this.sendResponse(reqEvent, session, eoseResponse) this.sessions.markEOSE(session.id, subId) + console.log(`[NRC] Sent EOSE for subscription ${subId}`) } /** @@ -444,6 +528,81 @@ export class NRCListenerService { } } + /** + * Handle EVENT message - store an event from the remote device + */ + private async handleEVENT( + reqEvent: Event, + session: NRCSession, + payload: unknown[] + ): Promise { + // Parse EVENT: ["EVENT", eventObject] + const [, eventToStore] = payload as [string, Event] + + if (!eventToStore || !eventToStore.id || !eventToStore.sig) { + await this.sendError(reqEvent, session, 'Invalid EVENT: missing event data') + return + } + + try { + // Store the event in IndexedDB + await indexedDb.putReplaceableEvent(eventToStore) + console.log(`[NRC] Stored event ${eventToStore.id.slice(0, 8)} kind ${eventToStore.kind} from ${session.deviceName}`) + + // Send OK response + const response: ResponseMessage = { + type: 'OK', + payload: ['OK', eventToStore.id, true, ''] + } + await this.sendResponse(reqEvent, session, response) + } catch (err) { + console.error('[NRC] Failed to store event:', err) + const response: ResponseMessage = { + type: 'OK', + payload: ['OK', eventToStore.id, false, `Failed to store: ${err instanceof Error ? err.message : 'Unknown error'}`] + } + await this.sendResponse(reqEvent, session, response) + } + } + + /** + * Handle IDS message - return event IDs matching filters (for diffing) + * Similar to REQ but returns only IDs, not full events + */ + private async handleIDS( + reqEvent: Event, + session: NRCSession, + payload: unknown[] + ): Promise { + // Parse IDS: ["IDS", subId, filter1, filter2, ...] + if (payload.length < 2) { + await this.sendError(reqEvent, session, 'Invalid IDS: missing subscription ID or filters') + return + } + + const [, subId, ...filterObjs] = payload as [string, string, ...Filter[]] + + // Query local events matching the filters + const events = await this.queryLocalEvents(filterObjs) + console.log(`[NRC] Found ${events.length} events for IDS request`) + + // Build manifest of event IDs with metadata for diffing + const manifest: EventManifestEntry[] = events.map((evt) => ({ + kind: evt.kind, + id: evt.id, + created_at: evt.created_at, + d: evt.tags.find((t) => t[0] === 'd')?.[1] + })) + + // Send IDS response with the manifest + const response: ResponseMessage = { + type: 'IDS', + payload: ['IDS', subId, manifest] + } + await this.sendResponseChunked(reqEvent, session, response) + console.log(`[NRC] Sent IDS response with ${manifest.length} entries`) + } + /** * Query local IndexedDB for events matching filters */ @@ -467,9 +626,9 @@ export class NRCListenerService { throw new Error('Not connected') } - // Encrypt the response + // Encrypt the response using signer const plaintext = JSON.stringify(response) - const encrypted = nip44.v2.encrypt(plaintext, session.conversationKey) + const encrypted = await this.encrypt(session.clientPubkey, plaintext) // Build the response event const unsignedEvent = { @@ -491,6 +650,50 @@ export class NRCListenerService { this.ws.send(JSON.stringify(['EVENT', signedEvent])) } + /** + * Send a response, chunking if necessary for large payloads + */ + private async sendResponseChunked( + reqEvent: Event, + session: NRCSession, + response: ResponseMessage + ): Promise { + const plaintext = JSON.stringify(response) + + // If small enough, send directly + if (plaintext.length <= NRCListenerService.MAX_CHUNK_SIZE) { + await this.sendResponse(reqEvent, session, response) + return + } + + // Need to chunk - convert to base64 for safe transmission + const encoded = btoa(unescape(encodeURIComponent(plaintext))) + const chunks: string[] = [] + + // Split into chunks + for (let i = 0; i < encoded.length; i += NRCListenerService.MAX_CHUNK_SIZE) { + chunks.push(encoded.slice(i, i + NRCListenerService.MAX_CHUNK_SIZE)) + } + + const messageId = crypto.randomUUID() + console.log(`[NRC] Chunking large message (${plaintext.length} bytes) into ${chunks.length} chunks`) + + // Send each chunk + for (let i = 0; i < chunks.length; i++) { + const chunkResponse: ResponseMessage = { + type: 'CHUNK', + payload: [{ + type: 'CHUNK', + messageId, + index: i, + total: chunks.length, + data: chunks[i] + }] + } + await this.sendResponse(reqEvent, session, chunkResponse) + } + } + /** * Send an error response */ @@ -507,7 +710,7 @@ export class NRCListenerService { } /** - * Send error response with best-effort ECDH + * Send error response with best-effort encryption */ private async sendErrorBestEffort(reqEvent: Event, message: string): Promise { if (!this.ws || !this.config || !this.listenerPubkey) { @@ -515,16 +718,13 @@ export class NRCListenerService { } try { - const ourPrivkey = await this.getPrivateKey() - const conversationKey = deriveConversationKey(ourPrivkey, reqEvent.pubkey) - const response: ResponseMessage = { type: 'NOTICE', payload: ['NOTICE', message] } const plaintext = JSON.stringify(response) - const encrypted = nip44.v2.encrypt(plaintext, conversationKey) + const encrypted = await this.encrypt(reqEvent.pubkey, plaintext) const unsignedEvent = { kind: KIND_NRC_RESPONSE, diff --git a/src/services/nrc/nrc-session.ts b/src/services/nrc/nrc-session.ts index ca52fa0d..3a5c9fe6 100644 --- a/src/services/nrc/nrc-session.ts +++ b/src/services/nrc/nrc-session.ts @@ -59,7 +59,7 @@ export class NRCSessionManager { */ getOrCreateSession( clientPubkey: string, - conversationKey: Uint8Array, + conversationKey: Uint8Array | undefined, authMode: AuthMode, deviceName?: string ): NRCSession { diff --git a/src/services/nrc/nrc-types.ts b/src/services/nrc/nrc-types.ts index 195dc4ba..baef63f3 100644 --- a/src/services/nrc/nrc-types.ts +++ b/src/services/nrc/nrc-types.ts @@ -12,7 +12,7 @@ export type AuthMode = 'secret' | 'cat' export interface NRCSession { id: string clientPubkey: string - conversationKey: Uint8Array + conversationKey?: Uint8Array // Optional - only set when using direct key access deviceName?: string authMode: AuthMode createdAt: number @@ -30,15 +30,42 @@ export interface NRCSubscription { // Message types (encrypted content) export interface RequestMessage { - type: 'REQ' | 'CLOSE' | 'EVENT' | 'COUNT' + type: 'REQ' | 'CLOSE' | 'EVENT' | 'COUNT' | 'IDS' payload: unknown[] } export interface ResponseMessage { - type: 'EVENT' | 'EOSE' | 'OK' | 'NOTICE' | 'CLOSED' | 'COUNT' + type: 'EVENT' | 'EOSE' | 'OK' | 'NOTICE' | 'CLOSED' | 'COUNT' | 'CHUNK' | 'IDS' payload: unknown[] } +// ===== Sync Types ===== + +/** + * Event manifest entry - describes an event we have + * Used by IDS request/response for diffing + */ +export interface EventManifestEntry { + kind: number + id: string + created_at: number + d?: string // For parameterized replaceable events (kinds 30000-39999) +} + +// Chunked message for large payloads +export interface ChunkMessage { + type: 'CHUNK' + messageId: string // Unique ID for this chunked message + index: number // 0-based chunk index + total: number // Total number of chunks + data: string // Base64 encoded chunk data +} + +// Helper to check if a message is a chunk +export function isChunkMessage(msg: ResponseMessage): msg is ResponseMessage & { payload: [ChunkMessage] } { + return msg.type === 'CHUNK' +} + // Connection management export interface NRCConnection { id: string @@ -69,7 +96,7 @@ export interface NRCListenerConfig { // Authorization result export interface AuthResult { mode: AuthMode - conversationKey: Uint8Array + conversationKey?: Uint8Array // Optional - only set when using direct key access deviceName: string } diff --git a/src/services/nrc/nrc-uri.ts b/src/services/nrc/nrc-uri.ts index 593dbde0..23188b2c 100644 --- a/src/services/nrc/nrc-uri.ts +++ b/src/services/nrc/nrc-uri.ts @@ -3,6 +3,31 @@ import { getPublicKey } from 'nostr-tools' import * as nip44 from 'nostr-tools/nip44' import { ParsedConnectionURI, AuthMode } from './nrc-types' +/** + * Derive the Cashu mint URL from a relay URL. + * The mint is always at {relay-root}/cashu + * + * @param relayUrl - WebSocket relay URL (ws:// or wss://) + * @returns HTTP(S) mint URL + */ +export function deriveMintUrlFromRelay(relayUrl: string): string { + let mintUrl = relayUrl + + // Convert WebSocket URL to HTTP URL + if (relayUrl.startsWith('ws://')) { + mintUrl = 'http://' + relayUrl.slice(5) + } else if (relayUrl.startsWith('wss://')) { + mintUrl = 'https://' + relayUrl.slice(6) + } else if (!relayUrl.startsWith('http://') && !relayUrl.startsWith('https://')) { + mintUrl = 'https://' + relayUrl + } + + // Remove trailing slash and append /cashu + mintUrl = mintUrl.replace(/\/$/, '') + + return mintUrl +} + /** * Generate a random 32-byte secret as hex string */ @@ -69,20 +94,21 @@ export function generateConnectionURI( /** * Generate a CAT-based NRC connection URI * + * The mint URL is derived automatically from the rendezvous relay URL + * (mint is always at {relay-root}/cashu) + * * @param relayPubkey - The public key of the listening client/relay * @param rendezvousUrl - The URL of the rendezvous relay - * @param mintUrl - The URL of the Cashu mint for token verification * @returns The connection URI */ export function generateCATConnectionURI( relayPubkey: string, - rendezvousUrl: string, - mintUrl: string + rendezvousUrl: string ): string { const params = new URLSearchParams() params.set('relay', rendezvousUrl) params.set('auth', 'cat') - params.set('mint', mintUrl) + // Note: mint URL is derived from relay URL, not stored in URI return `nostr+relayconnect://${relayPubkey}?${params.toString()}` } @@ -135,11 +161,9 @@ export function parseConnectionURI(uri: string): ParsedConnectionURI { const deviceName = url.searchParams.get('name') || undefined if (authMode === 'cat') { - // CAT-based auth - const mintUrl = url.searchParams.get('mint') - if (!mintUrl) { - throw new Error('CAT auth requires mint parameter') - } + // CAT-based auth - mint URL is derived from relay URL + // (mint is always at {relay-root}/cashu) + const mintUrl = deriveMintUrlFromRelay(rendezvousUrl) return { relayPubkey, @@ -187,3 +211,31 @@ export function isValidConnectionURI(uri: string): boolean { return false } } + +/** + * Check if a relay supports CAT (Cashu Access Tokens) + * by probing the /cashu/info endpoint + * + * @param relayUrl - WebSocket relay URL + * @returns true if the relay has a Cashu mint + */ +export async function relaySupportsCat(relayUrl: string): Promise { + const mintUrl = deriveMintUrlFromRelay(relayUrl) + + try { + const response = await fetch(`${mintUrl}/cashu/info`, { + method: 'GET', + headers: { Accept: 'application/json' } + }) + + if (!response.ok) { + return false + } + + // Check if response is valid mint info + const info = await response.json() + return info && typeof info === 'object' && 'name' in info + } catch { + return false + } +} diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 7e1484a1..1bfc8801 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -236,6 +236,7 @@ export type TSyncSettings = { quickReactionEmoji?: string | TEmoji noteListMode?: TNoteListMode preferNip44?: boolean + nrcOnlyConfigSync?: boolean } // DM types