import 'dart:async'; import 'dart:convert'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:nostr_tools/nostr_tools.dart'; import 'package:http/http.dart' as http; import '../../core/logger.dart'; import '../../core/exceptions/nostr_exception.dart'; import 'models/nostr_keypair.dart'; import 'models/nostr_event.dart'; import 'models/nostr_relay.dart'; import 'models/nostr_profile.dart'; /// Service for interacting with Nostr protocol. /// /// This service provides: /// - Keypair generation /// - Event publishing to relays /// - Metadata synchronization with multiple relays /// /// The service is modular and UI-independent, designed for testing without real relays. class NostrService { /// List of configured relays. final List _relays = []; /// Active WebSocket connections to relays. final Map _connections = {}; /// Stream controllers for relay messages. final Map>> _messageControllers = {}; /// Creates a [NostrService] instance. NostrService(); /// Generates a new Nostr keypair. /// /// Returns a [NostrKeyPair] with random private and public keys. NostrKeyPair generateKeyPair() { return NostrKeyPair.generate(); } /// Adds a relay to the service. /// /// [relayUrl] - The WebSocket URL of the relay (e.g., 'wss://relay.example.com'). void addRelay(String relayUrl) { final relay = NostrRelay.fromUrl(relayUrl); if (!_relays.contains(relay)) { _relays.add(relay); } } /// Removes a relay from the service. /// /// [relayUrl] - The URL of the relay to remove. void removeRelay(String relayUrl) { _relays.removeWhere((r) => r.url == relayUrl); disconnectRelay(relayUrl); } /// Enables or disables a relay. /// /// [relayUrl] - The URL of the relay to enable/disable. /// [enabled] - Whether the relay should be enabled. void setRelayEnabled(String relayUrl, bool enabled) { final relay = _relays.firstWhere( (r) => r.url == relayUrl, orElse: () => throw NostrException('Relay not found: $relayUrl'), ); relay.isEnabled = enabled; // If disabling, also disconnect if (!enabled && relay.isConnected) { disconnectRelay(relayUrl); } } /// Toggles all relays enabled/disabled. /// /// [enabled] - Whether all relays should be enabled. void setAllRelaysEnabled(bool enabled) { for (final relay in _relays) { relay.isEnabled = enabled; if (!enabled && relay.isConnected) { disconnectRelay(relay.url); } } } /// Gets the list of configured relays. /// /// Automatically disables any relays that are enabled but not connected, /// since enabled should always mean connected. List getRelays() { // Ensure enabled relays are actually connected // If a relay is enabled but not connected, disable it for (final relay in _relays) { if (relay.isEnabled && !relay.isConnected) { relay.isEnabled = false; } } return List.unmodifiable(_relays); } /// Connects to a relay. /// /// [relayUrl] - The URL of the relay to connect to. /// /// Returns a [Stream] of messages from the relay. /// /// Throws [NostrException] if connection fails. Future>> connectRelay(String relayUrl) async { try { // Check if relay is enabled final relay = _relays.firstWhere( (r) => r.url == relayUrl, orElse: () => NostrRelay.fromUrl(relayUrl), ); if (!relay.isEnabled) { throw NostrException('Relay is disabled: $relayUrl'); } if (_connections.containsKey(relayUrl) && _connections[relayUrl] != null) { // Already connected return _messageControllers[relayUrl]!.stream; } // WebSocketChannel.connect can throw synchronously (e.g., host lookup failure) // But errors can also occur asynchronously in the stream // Wrap in try-catch to ensure synchronous errors are caught WebSocketChannel channel; try { Logger.info('Creating WebSocket connection to: $relayUrl'); channel = WebSocketChannel.connect(Uri.parse(relayUrl)); Logger.debug('WebSocketChannel created for: $relayUrl'); } catch (e) { Logger.error('Failed to create WebSocketChannel for: $relayUrl', e); throw NostrException('Failed to connect to relay: $e'); } _connections[relayUrl] = channel; final controller = StreamController>.broadcast(); _messageControllers[relayUrl] = controller; // Mark connection as established after a short delay if no errors occur // WebSocket connection is established when channel is created successfully // We'll wait a bit to catch any immediate connection errors bool connectionConfirmed = false; bool hasError = false; // Set up error tracking before listening Timer? connectionTimer; connectionTimer = Timer(const Duration(milliseconds: 500), () { if (!hasError && !connectionConfirmed) { // No errors occurred, connection is established connectionConfirmed = true; relay.isConnected = true; Logger.info('Connection confirmed for relay: $relayUrl (no errors after 500ms)'); } }); // Listen for messages channel.stream.listen( (message) { // First message received - connection is confirmed if (!connectionConfirmed) { connectionConfirmed = true; relay.isConnected = true; Logger.info('Connection confirmed for relay: $relayUrl (first message received)'); connectionTimer?.cancel(); } try { final data = jsonDecode(message as String); if (data is List && data.isNotEmpty) { final messageType = data[0] as String; if (messageType == 'EVENT' && data.length >= 3) { // EVENT format: ["EVENT", , ] // event_json can be either a JSON object or array format final eventData = data[2]; controller.add({ 'type': 'EVENT', 'subscription_id': data[1], 'data': eventData, }); } else if (messageType == 'EOSE' && data.length >= 2) { // EOSE format: ["EOSE", ] controller.add({ 'type': 'EOSE', 'subscription_id': data[1], 'data': null, }); } else { // Other message types controller.add({ 'type': messageType, 'data': data.length > 1 ? data[1] : null, }); } } } catch (e) { // Ignore invalid messages } }, onError: (error) { hasError = true; connectionTimer?.cancel(); Logger.error('WebSocket error for relay: $relayUrl', error); relay.isConnected = false; // Automatically disable relay when connection error occurs relay.isEnabled = false; Logger.warning('Relay $relayUrl disabled due to connection error'); controller.addError(NostrException('Relay error: $error')); }, onDone: () { hasError = true; connectionTimer?.cancel(); Logger.warning('WebSocket stream closed for relay: $relayUrl'); relay.isConnected = false; // Automatically disable relay when connection closes relay.isEnabled = false; Logger.warning('Relay $relayUrl disabled due to stream closure'); controller.close(); }, ); return controller.stream; } catch (e) { throw NostrException('Failed to connect to relay: $e'); } } /// Disconnects from a relay. /// /// [relayUrl] - The URL of the relay to disconnect from. void disconnectRelay(String relayUrl) { final channel = _connections[relayUrl]; if (channel != null) { channel.sink.close(); _connections.remove(relayUrl); } final controller = _messageControllers[relayUrl]; if (controller != null) { controller.close(); _messageControllers.remove(relayUrl); } final relay = _relays.firstWhere((r) => r.url == relayUrl, orElse: () => NostrRelay.fromUrl(relayUrl)); relay.isConnected = false; } /// Publishes an event to a relay. /// /// [event] - The Nostr event to publish. /// [relayUrl] - The URL of the relay to publish to. /// /// Returns a [Future] that completes when the event is published. /// /// Throws [NostrException] if publishing fails. Future publishEvent(NostrEvent event, String relayUrl) async { try { final channel = _connections[relayUrl]; if (channel == null) { throw NostrException('Not connected to relay: $relayUrl'); } // Convert to nostr_tools Event and then to JSON final nostrToolsEvent = event.toNostrToolsEvent(); final eventJson = nostrToolsEvent.toJson(); // Send event in Nostr format: ["EVENT", ] final message = jsonEncode(['EVENT', eventJson]); channel.sink.add(message); } catch (e) { throw NostrException('Failed to publish event: $e'); } } /// Publishes an event to all enabled relays. /// /// [event] - The Nostr event to publish. /// /// Returns a map of relay URLs to success/failure status. Future> publishEventToAllRelays(NostrEvent event) async { final results = {}; for (final relay in _relays) { // Only publish to enabled relays if (!relay.isEnabled) { results[relay.url] = false; continue; } // Try to connect if not already connected if (!relay.isConnected) { try { final stream = await connectRelay(relay.url).timeout( const Duration(seconds: 3), onTimeout: () { throw Exception('Connection timeout'); }, ); // Wait for connection to be established or fail // Listen to the stream to catch connection errors final completer = Completer(); late StreamSubscription subscription; bool gotFirstMessage = false; subscription = stream.listen( (data) { // Connection successful - we received data gotFirstMessage = true; if (!completer.isCompleted) { completer.complete(true); } }, onError: (error) { // Connection failed if (!completer.isCompleted) { completer.complete(false); } }, onDone: () { // Stream closed before connection established if (!completer.isCompleted) { completer.complete(false); } }, ); // Wait for connection to establish (first message) or fail // Give it a short timeout to see if connection succeeds final connected = await completer.future.timeout( const Duration(seconds: 2), onTimeout: () { subscription.cancel(); // If we got a first message, connection was established return gotFirstMessage; }, ); subscription.cancel(); // Check if relay is actually connected if (!connected || !relay.isConnected) { results[relay.url] = false; continue; } } catch (e) { results[relay.url] = false; continue; } } // Publish to the relay try { await publishEvent(event, relay.url); results[relay.url] = true; } catch (e) { results[relay.url] = false; } } return results; } /// Syncs metadata by publishing an event with metadata content. /// /// [metadata] - The metadata to sync (as a Map). /// [privateKey] - Private key for signing the event. /// [kind] - Event kind (default: 0 for metadata). /// /// Returns the created and published event. /// /// Throws [NostrException] if sync fails. Future syncMetadata({ required Map metadata, required String privateKey, int kind = 0, }) async { try { // Create event with metadata as content final content = jsonEncode(metadata); final event = NostrEvent.create( content: content, kind: kind, privateKey: privateKey, ); // Publish to all connected relays await publishEventToAllRelays(event); return event; } catch (e) { throw NostrException('Failed to sync metadata: $e'); } } /// Fetches user profile (kind 0 metadata event) from relays. /// /// [publicKey] - The public key (hex format) of the user. /// [timeout] - Timeout for the request (default: 10 seconds). /// /// Returns [NostrProfile] if found, null otherwise. /// /// Throws [NostrException] if fetch fails. Future fetchProfile(String publicKey, {Duration timeout = const Duration(seconds: 10)}) async { if (_relays.isEmpty) { throw NostrException('No relays configured'); } // Try to fetch from connected relays first for (final relay in _relays) { if (relay.isConnected) { try { final profile = await _fetchProfileFromRelay(publicKey, relay.url, timeout); if (profile != null) { return profile; } } catch (e) { // Continue to next relay continue; } } } // If no connected relays or all failed, try connecting to first relay if (_relays.isNotEmpty) { try { final firstRelay = _relays.first; if (!firstRelay.isConnected) { await connectRelay(firstRelay.url).timeout(timeout); } return await _fetchProfileFromRelay(publicKey, firstRelay.url, timeout); } catch (e) { throw NostrException('Failed to fetch profile: $e'); } } return null; } /// Fetches profile from a specific relay. Future _fetchProfileFromRelay( String publicKey, String relayUrl, Duration timeout) async { final channel = _connections[relayUrl]; final messageController = _messageControllers[relayUrl]; if (channel == null || messageController == null) { return null; } // Send REQ message to request kind 0 events for this public key // Nostr REQ format: ["REQ", , ] final reqId = 'profile_${DateTime.now().millisecondsSinceEpoch}'; final completer = Completer(); final subscription = messageController.stream.listen( (message) { // Message format from connectRelay: // {'type': 'EVENT', 'subscription_id': , 'data': } // or {'type': 'EOSE', 'subscription_id': , 'data': null} if (message['type'] == 'EVENT' && message['subscription_id'] == reqId && message['data'] != null) { try { final eventData = message['data']; Event nostrToolsEvent; // Handle both JSON object and array formats if (eventData is Map) { // JSON object format nostrToolsEvent = Event( id: eventData['id'] as String? ?? '', pubkey: eventData['pubkey'] as String? ?? '', created_at: eventData['created_at'] as int? ?? 0, kind: eventData['kind'] as int? ?? 0, tags: (eventData['tags'] as List?) ?.map((tag) => (tag as List) .map((e) => e.toString()) .toList()) .toList() ?? [], content: eventData['content'] as String? ?? '', sig: eventData['sig'] as String? ?? '', verify: false, // Skip verification for profile fetching ); } else if (eventData is List && eventData.length >= 7) { // Array format: [id, pubkey, created_at, kind, tags, content, sig] nostrToolsEvent = Event( id: eventData[0] as String? ?? '', pubkey: eventData[1] as String? ?? '', created_at: eventData[2] as int? ?? 0, kind: eventData[3] as int? ?? 0, tags: (eventData[4] as List?) ?.map((tag) => (tag as List) .map((e) => e.toString()) .toList()) .toList() ?? [], content: eventData[5] as String? ?? '', sig: eventData[6] as String? ?? '', verify: false, // Skip verification for profile fetching ); } else { return; // Invalid format } // Convert to our NostrEvent model final event = NostrEvent.fromNostrToolsEvent(nostrToolsEvent); // Check if it's a kind 0 (metadata) event for this public key if (event.kind == 0 && event.pubkey.toLowerCase() == publicKey.toLowerCase()) { final profile = NostrProfile.fromEventContent( publicKey: publicKey, content: event.content, updatedAt: DateTime.fromMillisecondsSinceEpoch(event.createdAt * 1000), ); if (!completer.isCompleted) { completer.complete(profile); } } } catch (e) { // Ignore parsing errors Logger.warning('Error parsing profile event: $e'); } } else if (message['type'] == 'EOSE' && message['subscription_id'] == reqId) { // End of stored events - no profile found if (!completer.isCompleted) { completer.complete(null); } } }, onError: (error) { if (!completer.isCompleted) { completer.completeError(error); } }, ); // Send REQ message to request kind 0 events for this public key final reqMessage = jsonEncode([ 'REQ', reqId, { 'authors': [publicKey], 'kinds': [0], 'limit': 1, } ]); channel.sink.add(reqMessage); try { final profile = await completer.future.timeout(timeout); subscription.cancel(); return profile; } catch (e) { subscription.cancel(); return null; } } /// Queries events from a specific relay. /// /// [publicKey] - The public key (hex format) to query events for. /// [relayUrl] - The relay URL to query from. /// [kinds] - List of event kinds to query (e.g., [30000] for recipes). /// [timeout] - Timeout for the request (default: 30 seconds). /// /// Returns a list of [NostrEvent] matching the query. /// /// Throws [NostrException] if query fails. Future> queryEvents( String publicKey, String relayUrl, List kinds, { Duration timeout = const Duration(seconds: 30), }) async { final channel = _connections[relayUrl]; final messageController = _messageControllers[relayUrl]; if (channel == null || messageController == null) { throw NostrException('Not connected to relay: $relayUrl'); } final reqId = 'query_${DateTime.now().millisecondsSinceEpoch}'; final completer = Completer>(); final events = []; final subscription = messageController.stream.listen( (message) { if (message['type'] == 'EVENT' && message['subscription_id'] == reqId && message['data'] != null) { try { final eventData = message['data']; NostrEvent event; // Parse event (handle both JSON object and array formats) if (eventData is Map) { event = NostrEvent.fromJson([ eventData['id'] ?? '', eventData['pubkey'] ?? '', eventData['created_at'] ?? 0, eventData['kind'] ?? 0, eventData['tags'] ?? [], eventData['content'] ?? '', eventData['sig'] ?? '', ]); } else if (eventData is List && eventData.length >= 7) { event = NostrEvent.fromJson(eventData); } else { return; // Skip invalid format } // Only process events matching the query if (kinds.contains(event.kind) && event.pubkey.toLowerCase() == publicKey.toLowerCase()) { events.add(event); } } catch (e) { Logger.warning('Error parsing event: $e'); } } else if (message['type'] == 'EOSE' && message['subscription_id'] == reqId) { // End of stored events if (!completer.isCompleted) { completer.complete(events); } } }, onError: (error) { if (!completer.isCompleted) { completer.completeError(error); } }, ); // Send REQ message final reqMessage = jsonEncode([ 'REQ', reqId, { 'authors': [publicKey], 'kinds': kinds, 'limit': 100, } ]); channel.sink.add(reqMessage); try { final result = await completer.future.timeout(timeout); subscription.cancel(); return result; } catch (e) { subscription.cancel(); if (events.isNotEmpty) { // Return what we got before timeout return events; } throw NostrException('Failed to query events: $e'); } } /// Fetches preferred relays from a NIP-05 identifier. /// /// NIP-05 verification endpoint format: https:///.well-known/nostr.json?name= /// The response can include relay hints in the format: /// { /// "names": { "": "" }, /// "relays": { "": ["wss://relay1.com", "wss://relay2.com"] } /// } /// /// [nip05] - The NIP-05 identifier (e.g., 'user@domain.com'). /// [publicKey] - The public key (hex format) to match against relay hints. /// /// Returns a list of preferred relay URLs, or empty list if none found. /// /// Throws [NostrException] if fetch fails. Future> fetchPreferredRelaysFromNip05( String nip05, String publicKey, ) async { try { // Parse NIP-05 identifier (format: local-part@domain) final parts = nip05.split('@'); if (parts.length != 2) { throw NostrException('Invalid NIP-05 format: $nip05'); } final localPart = parts[0]; final domain = parts[1]; // Construct the verification URL final url = Uri.https(domain, '/.well-known/nostr.json', {'name': localPart}); // Fetch the NIP-05 verification data final response = await http.get(url).timeout( const Duration(seconds: 10), onTimeout: () { throw NostrException('Timeout fetching NIP-05 data'); }, ); if (response.statusCode != 200) { throw NostrException( 'Failed to fetch NIP-05 data: ${response.statusCode}'); } // Parse the JSON response final data = jsonDecode(response.body) as Map; // Extract relay hints for the public key final relays = data['relays'] as Map?; if (relays == null) { return []; } // Find relays for the matching public key (case-insensitive) final publicKeyLower = publicKey.toLowerCase(); for (final entry in relays.entries) { final keyLower = entry.key.toLowerCase(); if (keyLower == publicKeyLower) { final relayList = entry.value; if (relayList is List) { return relayList .map((r) => r.toString()) .where((r) => r.isNotEmpty) .toList(); } } } return []; } catch (e) { if (e is NostrException) { rethrow; } throw NostrException('Failed to fetch preferred relays from NIP-05: $e'); } } /// Loads preferred relays from NIP-05 if available and adds them to the relay list. /// /// [nip05] - The NIP-05 identifier (e.g., 'user@domain.com'). /// [publicKey] - The public key (hex format) to match against relay hints. /// /// Returns the number of relays added. /// /// Throws [NostrException] if fetch fails. Future loadPreferredRelaysFromNip05( String nip05, String publicKey, ) async { try { final preferredRelays = await fetchPreferredRelaysFromNip05(nip05, publicKey); int addedCount = 0; for (final relayUrl in preferredRelays) { try { addRelay(relayUrl); addedCount++; } catch (e) { // Skip invalid relay URLs Logger.warning('Invalid relay URL from NIP-05: $relayUrl'); } } return addedCount; } catch (e) { if (e is NostrException) { rethrow; } throw NostrException('Failed to load preferred relays from NIP-05: $e'); } } /// Replaces all existing relays with preferred relays from NIP-05. /// /// This will: /// 1. Disconnect and remove all current relays /// 2. Fetch preferred relays from NIP-05 /// 3. Add the preferred relays /// 4. Automatically enable and connect to the relays /// /// [nip05] - The NIP-05 identifier (e.g., 'user@domain.com'). /// [publicKey] - The public key (hex format) to match against relay hints. /// /// Returns the number of preferred relays added. /// /// Throws [NostrException] if fetch fails. Future replaceRelaysWithPreferredFromNip05( String nip05, String publicKey, ) async { try { // Disconnect and remove all existing relays final existingRelays = List.from(_relays.map((r) => r.url)); for (final relayUrl in existingRelays) { try { disconnectRelay(relayUrl); removeRelay(relayUrl); } catch (e) { // Continue even if disconnect fails Logger.warning('Failed to disconnect relay $relayUrl: $e'); } } // Fetch preferred relays final preferredRelays = await fetchPreferredRelaysFromNip05(nip05, publicKey); // Add preferred relays and enable them int addedCount = 0; final addedRelayUrls = []; for (final relayUrl in preferredRelays) { try { addRelay(relayUrl); setRelayEnabled(relayUrl, true); addedRelayUrls.add(relayUrl); addedCount++; } catch (e) { // Skip invalid relay URLs Logger.warning('Invalid relay URL from NIP-05: $relayUrl'); } } // Attempt to connect to all added relays in parallel // Connections happen in the background - if they fail, relays will be auto-disabled for (final relayUrl in addedRelayUrls) { try { // Connect in background - don't wait for it connectRelay(relayUrl).then((stream) { // Connection successful - stream will be handled by existing connection logic Logger.info('Successfully connected to NIP-05 relay: $relayUrl'); }).catchError((error) { // Connection failed - relay will be auto-disabled by getRelays() logic Logger.warning('Failed to connect to NIP-05 relay: $relayUrl - $error'); try { setRelayEnabled(relayUrl, false); } catch (_) { // Ignore errors } }); } catch (e) { // Connection attempt failed - disable the relay Logger.warning('Failed to connect to NIP-05 relay: $relayUrl - $e'); try { setRelayEnabled(relayUrl, false); } catch (_) { // Ignore errors } } } Logger.info('Replaced ${existingRelays.length} relay(s) with $addedCount preferred relay(s) from NIP-05: $nip05 (all enabled and connecting)'); return addedCount; } catch (e) { if (e is NostrException) { rethrow; } throw NostrException('Failed to replace relays with preferred from NIP-05: $e'); } } /// Closes all connections and cleans up resources. void dispose() { for (final relayUrl in _connections.keys.toList()) { disconnectRelay(relayUrl); } _relays.clear(); } }