import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:nostr_tools/nostr_tools.dart'; import 'package:http/http.dart' as http; import 'models/nostr_keypair.dart'; import 'models/nostr_event.dart'; import 'models/nostr_relay.dart'; import 'models/nostr_profile.dart'; /// Exception thrown when Nostr operations fail. class NostrException implements Exception { /// Error message. final String message; /// Creates a [NostrException] with the provided message. NostrException(this.message); @override String toString() => 'NostrException: $message'; } /// 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. List getRelays() { 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) // Wrap in try-catch to ensure it's caught WebSocketChannel channel; try { channel = WebSocketChannel.connect(Uri.parse(relayUrl)); } catch (e) { throw NostrException('Failed to connect to relay: $e'); } _connections[relayUrl] = channel; final controller = StreamController>.broadcast(); _messageControllers[relayUrl] = controller; // Update relay status (relay already found above) relay.isConnected = true; // Listen for messages channel.stream.listen( (message) { 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) { relay.isConnected = false; controller.addError(NostrException('Relay error: $error')); }, onDone: () { relay.isConnected = false; 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 connected 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) { if (relay.isConnected) { try { await publishEvent(event, relay.url); results[relay.url] = true; } catch (e) { results[relay.url] = false; } } else { 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 debugPrint('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; } } /// 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 debugPrint('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'); } } /// Closes all connections and cleans up resources. void dispose() { for (final relayUrl in _connections.keys.toList()) { disconnectRelay(relayUrl); } _relays.clear(); } }