diff --git a/lib/app/features/core/providers/env_provider.r.dart b/lib/app/features/core/providers/env_provider.r.dart index b209b500a9..c02dc02a7a 100644 --- a/lib/app/features/core/providers/env_provider.r.dart +++ b/lib/app/features/core/providers/env_provider.r.dart @@ -61,6 +61,7 @@ enum EnvVariable { CRYPTOCURRENCIES_ION_BRIDGE_CONTRACT_ADDRESS, CRYPTOCURRENCIES_ION_TRADE_URL, CRYPTOCURRENCIES_ION_JRPC_URL, + RELAY_PROXY_DOMAINS, } @Riverpod(keepAlive: true) @@ -186,6 +187,7 @@ class Env extends _$Env { const String.fromEnvironment('CRYPTOCURRENCIES_ION_TRADE_URL') as T, EnvVariable.CRYPTOCURRENCIES_ION_JRPC_URL => const String.fromEnvironment('CRYPTOCURRENCIES_ION_JRPC_URL') as T, + EnvVariable.RELAY_PROXY_DOMAINS => const String.fromEnvironment('RELAY_PROXY_DOMAINS') as T, }; } } diff --git a/lib/app/features/ion_connect/providers/mixins/relay_create_mixin.dart b/lib/app/features/ion_connect/providers/mixins/relay_create_mixin.dart index cc7a2a4fe0..da3bdbff1a 100644 --- a/lib/app/features/ion_connect/providers/mixins/relay_create_mixin.dart +++ b/lib/app/features/ion_connect/providers/mixins/relay_create_mixin.dart @@ -1,41 +1,107 @@ // SPDX-License-Identifier: ice License 1.0 import 'dart:async'; -import 'dart:io'; +import 'dart:io' hide WebSocket; +import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:ion/app/exceptions/exceptions.dart'; import 'package:ion/app/features/core/providers/internet_connection_checker_provider.r.dart'; import 'package:ion/app/features/core/providers/internet_status_stream_provider.r.dart'; import 'package:ion/app/features/ion_connect/ion_connect.dart'; +import 'package:ion/app/features/ion_connect/providers/relays/relay_disliked_connect_urls_provider.r.dart'; +import 'package:ion/app/features/ion_connect/providers/relays/relay_proxy_domain_preference_provider.r.dart'; +import 'package:ion/app/features/ion_connect/providers/relays/relay_proxy_domains_provider.r.dart'; import 'package:ion/app/features/user/providers/relays/relays_reachability_provider.r.dart'; import 'package:ion/app/services/logger/logger.dart'; +import 'package:ion/app/utils/logging.dart'; mixin RelayCreateMixin { Future createRelay(Ref ref, String url) async { - final socket = WebSocket(Uri.parse(url)); + final dislikedConnectUrls = ref.read(relayDislikedConnectUrlsProvider(url)); + final candidates = ref.read(relayConnectUrisProvider(url)); + final proxyDomains = ref.read(relaysProxyDomainsProvider); + final savedPreferredDomain = ref.read(relayProxyDomainPreferenceProvider(url)); - final relay = IonConnectRelay(url: url, socket: socket); - final connectionState = await socket.connection.firstWhere( - (state) => state is Connected || state is Reconnected || state is Disconnected, - ); + ConnectionState? lastConnectionState; + var triggeredConnectivityCheck = false; + + for (final connectUri in candidates) { + final connectUrl = connectUri.toString(); + if (dislikedConnectUrls.contains(connectUrl)) { + continue; + } + + final socket = WebSocket(connectUri); + final relay = IonConnectRelay( + url: url, + connectUrl: connectUrl, + socket: socket, + ); + + final connectionState = await socket.connection.firstWhere( + (state) => state is Connected || state is Reconnected || state is Disconnected, + ); + + lastConnectionState = connectionState; + + final usedDomain = proxyDomains.firstWhereOrNull( + (d) => connectUri.host.endsWith(d), + ); + + final hasInternetConnection = ref.read(hasInternetConnectionProvider); + final isUnreachable = _isRelayUnreachable( + connectionState: connectionState, + hasInternetConnection: hasInternetConnection, + ); + + if (!isUnreachable) { + // Persist which proxy domain worked for this logical relay. + // If we connected directly (no proxy domain), clear any previously saved preference. + ref + .read(relayProxyDomainPreferenceProvider(url).notifier) + .persistPreferredProxyDomain(usedDomain); + + await ref.read(relayReachabilityProvider.notifier).clear(url); + return relay; + } + + // Report failover (sampled) with what failed. + final err = (connectionState is Disconnected) ? connectionState.error : null; + reportFailover( + Exception( + '[RELAY] Relay connection failover for logical URL: $url and connect URL $connectUrl with reason${err != null ? ' (${err.runtimeType}: $err)' : ''}', + ), + StackTrace.current, + tag: 'relay_failover_connect', + ); + + // If the currently saved preferred proxy domain was attempted and failed, clear it + // so subsequent relay creations don't keep trying a known-bad preference first. + if (usedDomain == savedPreferredDomain) { + ref + .read(relayProxyDomainPreferenceProvider(url).notifier) + .persistPreferredProxyDomain(null); + } - final hasInternetConnection = ref.read(hasInternetConnectionProvider); - if (_isRelayUnreachable( - connectionState: connectionState, - hasInternetConnection: hasInternetConnection, - )) { // Trigger an immediate connectivity check on network-like WebSocket errors // to update the global status promptly. - unawaited(ref.read(internetConnectionCheckerProvider).checkNow()); + if (!triggeredConnectivityCheck) { + triggeredConnectivityCheck = true; + unawaited(ref.read(internetConnectionCheckerProvider).checkNow()); + } + socket.close(); + } + + // All candidates were unreachable. + if (lastConnectionState != null) { await _updateRelayReachabilityInfo(ref, url); throw RelayUnreachableException(url); } - await ref.read(relayReachabilityProvider.notifier).clear(url); - - return relay; + // Should not happen because candidates always include at least the logical URL. + throw RelayUnreachableException(url); } bool _isRelayUnreachable({ diff --git a/lib/app/features/ion_connect/providers/relays/relay_auth_provider.r.dart b/lib/app/features/ion_connect/providers/relays/relay_auth_provider.r.dart index 7dcca3ba0f..494945fc83 100644 --- a/lib/app/features/ion_connect/providers/relays/relay_auth_provider.r.dart +++ b/lib/app/features/ion_connect/providers/relays/relay_auth_provider.r.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:ion/app/exceptions/exceptions.dart'; import 'package:ion/app/extensions/extensions.dart'; @@ -11,19 +12,26 @@ import 'package:ion/app/features/ion_connect/ion_connect.dart' hide requestEvent import 'package:ion/app/features/ion_connect/model/action_source.f.dart'; import 'package:ion/app/features/ion_connect/model/auth_event.f.dart'; import 'package:ion/app/features/ion_connect/providers/ion_connect_notifier.r.dart'; +import 'package:ion/app/features/ion_connect/providers/relays/relay_disliked_connect_urls_provider.r.dart'; import 'package:ion/app/features/ion_connect/providers/relays/relays_replica_delay_provider.m.dart'; import 'package:ion/app/features/user/providers/relays/user_relays_manager.r.dart'; import 'package:ion/app/features/user/providers/user_delegation_provider.r.dart'; +import 'package:ion/app/utils/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'relay_auth_provider.r.g.dart'; +const _defaultTimeout = Duration(seconds: 30); + @riverpod class RelayAuth extends _$RelayAuth { @override RelayAuthService build(IonConnectRelay relay) { final service = RelayAuthService( relay: relay, + addDislikedConnectUrl: (connectUrl) { + ref.read(relayDislikedConnectUrlsProvider(relay.url).notifier).add(connectUrl); + }, createAuthEvent: ({ required String challenge, required String relayUrl, @@ -70,6 +78,25 @@ class RelayAuth extends _$RelayAuth { }); ref.onDispose(authMessageSubscription.cancel); + final connectionSubscription = relay.socket.connection.listen((state) { + // When the underlying websocket reconnects, any in-flight AUTH attempt is no longer valid. + // If we keep the old completer, authenticateRelay() will "short circuit" and we will never + // send a new AUTH for the new connection. + if (state is Disconnected || + state is Disconnecting || + state is Reconnecting || + state is Reconnected) { + final c = service.completer; + if (c != null && !c.isCompleted) { + c.completeError( + const SocketException('Relay connection changed during authentication'), + ); + } + service.completer = null; + } + }); + ref.onDispose(connectionSubscription.cancel); + return service; } } @@ -77,6 +104,7 @@ class RelayAuth extends _$RelayAuth { class RelayAuthService { RelayAuthService({ required this.relay, + required this.addDislikedConnectUrl, required this.createAuthEvent, required this.onError, this.completer, @@ -84,6 +112,8 @@ class RelayAuthService { final IonConnectRelay relay; + final void Function(String connectUrl) addDislikedConnectUrl; + final Future Function({required String challenge, required String relayUrl}) createAuthEvent; @@ -130,7 +160,7 @@ class RelayAuthService { } } - Future authenticateRelay({bool isRetry = false}) async { + Future authenticateRelay() async { if (challenge == null || challenge!.isEmpty) throw AuthChallengeIsEmptyException(); // Cases when we need to re-authenticate the relay: @@ -141,34 +171,61 @@ class RelayAuthService { } else { return completer!.future; } - try { - final signedAuthEvent = await createAuthEvent( - challenge: challenge!, - relayUrl: Uri.parse(relay.url).toString(), - ); - final authMessage = AuthMessage( - challenge: jsonEncode(signedAuthEvent.toJson().last), - ); + var didRetry = false; + while (true) { + try { + final signedAuthEvent = await createAuthEvent( + challenge: challenge!, + relayUrl: Uri.parse(relay.url).toString(), + ); - relay.sendMessage(authMessage); + final authMessage = AuthMessage( + challenge: jsonEncode(signedAuthEvent.toJson().last), + ); - final okMessages = await relay.messages - .where((message) => message is OkMessage) - .cast() - .firstWhere((message) => signedAuthEvent.id == message.eventId); + relay.sendMessage(authMessage); + + final okMessage = await relay.messages + .where((message) => message is OkMessage) + .cast() + .firstWhere((message) => signedAuthEvent.id == message.eventId) + .timeout( + _defaultTimeout, + onTimeout: () { + addDislikedConnectUrl(relay.connectUrl); + relay.close(); + reportFailover( + Exception( + '[RELAY] Relay connection failover for logical URL: ${relay.url} and connect URL ${relay.connectUrl} with reason: AUTH OK timeout}', + ), + StackTrace.current, + tag: 'relay_failover_auth_timeout', + ); + throw SendEventException( + 'auth-required: AUTH OK timeout after ${_defaultTimeout.inSeconds}s', + ); + }, + ); - if (!okMessages.accepted) { - throw SendEventException(okMessages.message); - } + if (!okMessage.accepted) { + throw SendEventException(okMessage.message); + } - completer?.complete(); - } catch (error) { - final shouldRetry = await onError(error); - if (shouldRetry && !isRetry) { - return authenticateRelay(isRetry: true); + completer?.complete(); + return; + } catch (error, st) { + final shouldRetry = await onError(error); + if (shouldRetry && !didRetry) { + didRetry = true; + continue; + } + + // Make sure waiters get the failure and propagate it to the current caller. + completer?.completeError(error, st); + + Error.throwWithStackTrace(error, st); } - completer?.completeError(error); } } diff --git a/lib/app/features/ion_connect/providers/relays/relay_disliked_connect_urls_provider.r.dart b/lib/app/features/ion_connect/providers/relays/relay_disliked_connect_urls_provider.r.dart new file mode 100644 index 0000000000..fb736cff92 --- /dev/null +++ b/lib/app/features/ion_connect/providers/relays/relay_disliked_connect_urls_provider.r.dart @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'relay_disliked_connect_urls_provider.r.g.dart'; + +/// Holds a per-logical-relay set of connect URLs that should be avoided for the +/// current relay build / failover attempt. +/// +/// This is intentionally separated from the Relay provider so other layers +/// (e.g. auth handling) can mark a connect URL as bad and force failover. +@Riverpod(keepAlive: true) +class RelayDislikedConnectUrls extends _$RelayDislikedConnectUrls { + @override + Set build(String logicalRelayUrl) => {}; + + void reset() { + state = {}; + } + + /// Adds [connectUrl] to the disliked set. + /// + /// Returns `true` if the url was newly added. + bool add(String connectUrl) { + if (state.contains(connectUrl)) return false; + state = {...state, connectUrl}; + return true; + } +} diff --git a/lib/app/features/ion_connect/providers/relays/relay_provider.r.dart b/lib/app/features/ion_connect/providers/relays/relay_provider.r.dart index 63950f0b6e..9413692d8d 100644 --- a/lib/app/features/ion_connect/providers/relays/relay_provider.r.dart +++ b/lib/app/features/ion_connect/providers/relays/relay_provider.r.dart @@ -8,7 +8,10 @@ import 'package:ion/app/features/ion_connect/providers/mixins/relay_auth_mixin.d import 'package:ion/app/features/ion_connect/providers/mixins/relay_closed_mixin.dart'; import 'package:ion/app/features/ion_connect/providers/mixins/relay_create_mixin.dart'; import 'package:ion/app/features/ion_connect/providers/mixins/relay_timer_mixin.dart'; +import 'package:ion/app/features/ion_connect/providers/relays/relay_auth_provider.r.dart'; +import 'package:ion/app/features/ion_connect/providers/relays/relay_disliked_connect_urls_provider.r.dart'; import 'package:ion/app/services/logger/logger.dart'; +import 'package:ion/app/utils/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'relay_provider.r.g.dart'; @@ -20,20 +23,54 @@ class Relay extends _$Relay with RelayTimerMixin, RelayCreateMixin, RelayAuthMixin, RelayClosedMixin, RelayActiveMixin { @override Future build(String url, {bool anonymous = false}) async { + final dislikedConnectUrlsNotifier = ref.read(relayDislikedConnectUrlsProvider(url).notifier); + try { - final relay = await createRelay(ref, url); + while (true) { + final relay = await createRelay(ref, url); + try { + // Treat auth init as part of relay creation. + // If auth fails with an auth-required loop, we failover by retrying with the next connect URL. + if (!anonymous) { + await initializeAuth(relay, ref); + } - trackRelayAsActive(relay, ref); - initializeRelayTimer(relay, ref); - initializeRelayClosedListener(relay, ref); + // Only after relay is usable, start the rest of the lifecycle listeners. + trackRelayAsActive(relay, ref); + initializeRelayTimer(relay, ref); + initializeRelayClosedListener(relay, ref); - if (!anonymous) { - await initializeAuth(relay, ref); - } + ref.onDispose(relay.close); + return relay; + } catch (e, st) { + // If we are stuck in an auth-required loop, consider this connect URL unusable and retry. + if (!anonymous && RelayAuthService.isRelayAuthError(e)) { + final connectUrl = relay.connectUrl; + final added = dislikedConnectUrlsNotifier.add(connectUrl); + + // Close the bad relay before retrying. + relay.close(); - ref.onDispose(relay.close); + // Safety: if we can't make progress (same URL again), stop retrying. + if (!added) { + rethrow; + } - return relay; + reportFailover( + Exception( + '[RELAY] Relay auth failover for logical URL: $url and connect URL: $connectUrl; reason: $e', + ), + st, + tag: 'relay_failover_auth', + ); + continue; + } + + // Non-auth errors bubble up. + relay.close(); + rethrow; + } + } } catch (e) { Logger.warning( '[RELAY] Failed to create relay for URL: $url, error: $e', diff --git a/lib/app/features/ion_connect/providers/relays/relay_proxy_domain_preference_provider.r.dart b/lib/app/features/ion_connect/providers/relays/relay_proxy_domain_preference_provider.r.dart new file mode 100644 index 0000000000..5b15ab9d12 --- /dev/null +++ b/lib/app/features/ion_connect/providers/relays/relay_proxy_domain_preference_provider.r.dart @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'dart:async'; + +import 'package:ion/app/features/ion_connect/providers/relays/relay_proxy_domains_provider.r.dart'; +import 'package:ion/app/services/storage/user_preferences_service.r.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'relay_proxy_domain_preference_provider.r.g.dart'; + +/// Stores the currently working relay proxy *domain* for a given logical relay URL. +/// +/// Keyed by the logical relay URL (e.g. `wss://192.168.1.1:4443`) but persists only +/// the chosen proxy *domain* (e.g. `relays1.ion-connect.identity.io`), so we can +/// rebuild connect candidates as: +/// `wss://.:` +/// +/// If the saved domain is no longer present in [relaysProxyDomainsProvider], it is cleared. +@riverpod +class RelayProxyDomainPreference extends _$RelayProxyDomainPreference { + static const _prefKeyPrefix = 'relay_proxy_domain'; + + @override + String? build(String logicalRelayUrl) { + final prefs = ref.watch(currentUserPreferencesServiceProvider); + if (prefs == null) return null; + + final allowedDomains = ref.watch(relaysProxyDomainsProvider); + + final saved = prefs.getValue(_prefKeyFor(logicalRelayUrl)); + if (saved == null || saved.trim().isEmpty) return null; + + final domain = saved.trim(); + + if (!allowedDomains.contains(domain)) { + // List changed or value is invalid -> clear. + unawaited(prefs.remove(_prefKeyFor(logicalRelayUrl))); + return null; + } + + return domain; + } + + /// Persists the preferred proxy *domain* for this logical relay URL. + /// + /// If [domain] is `null`, the stored value is cleared. + FutureOr persistPreferredProxyDomain(String? domain) async { + final prefs = ref.read(currentUserPreferencesServiceProvider); + if (prefs == null) return; + + final key = _prefKeyFor(logicalRelayUrl); + + final normalized = domain?.trim(); + final allowedDomains = ref.read(relaysProxyDomainsProvider); + // Don't persist unknown domains; clear instead. + if (normalized == null || !allowedDomains.contains(normalized)) { + await prefs.remove(key); + state = null; + return; + } + + await prefs.setValue(key, normalized); + state = normalized; + } + + String _prefKeyFor(String logicalRelayUrl) { + return '$_prefKeyPrefix:$logicalRelayUrl'; + } +} diff --git a/lib/app/features/ion_connect/providers/relays/relay_proxy_domains_provider.r.dart b/lib/app/features/ion_connect/providers/relays/relay_proxy_domains_provider.r.dart new file mode 100644 index 0000000000..e438004986 --- /dev/null +++ b/lib/app/features/ion_connect/providers/relays/relay_proxy_domains_provider.r.dart @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'dart:convert'; + +import 'package:convert/convert.dart' as convert; +import 'package:cryptography/dart.dart'; +import 'package:ion/app/features/core/providers/env_provider.r.dart'; +import 'package:ion/app/features/ion_connect/providers/relays/relay_proxy_domain_preference_provider.r.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'relay_proxy_domains_provider.r.g.dart'; + +/// Proxy domains used to reach Nostr relays when direct IP connectivity is +/// unavailable or unreliable. +@riverpod +List relaysProxyDomains(Ref ref) { + final env = ref.watch(envProvider.notifier); + final domainsRaw = env.get(EnvVariable.RELAY_PROXY_DOMAINS); + return domainsRaw.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty).toList(); +} + +/// Returns a list of candidate relay connection URIs for a logical relay URL. +/// +/// The logical relay URL is expected to be an IP-based websocket URL like: +/// `wss://192.168.1.1:4443`. +/// +/// Candidates are returned in the following order: +/// 1) Preferred proxy URI (if saved for this logical relay) +/// 2) The original logical relay URI (direct IP) +/// 3) Remaining proxy URIs built as `wss://.domain:443` +@riverpod +List relayConnectUris(Ref ref, String logicalRelayUrl) { + final logicalUri = Uri.parse(logicalRelayUrl); + final normalizedLogicalUri = + (logicalUri.hasPort && logicalUri.port == 4443) ? logicalUri.replace(port: 443) : logicalUri; + final ip = logicalUri.host; + + // If we can't extract an IP/host, fall back to the original URI only. + if (ip.isEmpty) { + return [normalizedLogicalUri]; + } + + final domains = ref.read(relaysProxyDomainsProvider); + final preferredDomain = ref.read(relayProxyDomainPreferenceProvider(logicalRelayUrl)); + + final hash = const DartSha256().hashSync(utf8.encode(ip)); + final hashHex = convert.hex.encode(hash.bytes); + final normalizedIp = hashHex.substring(0, 16); + + Uri proxyUriForDomain(String domain) => Uri( + scheme: normalizedLogicalUri.scheme.isNotEmpty ? normalizedLogicalUri.scheme : 'wss', + host: '$normalizedIp.$domain', + port: normalizedLogicalUri.hasPort ? normalizedLogicalUri.port : null, + ); + + final candidates = []; + + final preferred = preferredDomain?.trim(); + if (preferred != null && preferred.isNotEmpty) { + candidates.add(proxyUriForDomain(preferred)); + } + + // Then try direct IP. + candidates.add(normalizedLogicalUri); + + // Then try the rest of proxy domains. + for (final domain in domains) { + if (preferred != null && preferred == domain) continue; + candidates.add(proxyUriForDomain(domain)); + } + + return candidates; +} diff --git a/lib/app/features/tokenized_communities/services/bsc_rpc_failover_http_client.dart b/lib/app/features/tokenized_communities/services/bsc_rpc_failover_http_client.dart index a67e1a36e1..cd2313b617 100644 --- a/lib/app/features/tokenized_communities/services/bsc_rpc_failover_http_client.dart +++ b/lib/app/features/tokenized_communities/services/bsc_rpc_failover_http_client.dart @@ -4,7 +4,7 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:http/http.dart' as http; -import 'package:ion/app/services/sentry/sentry_service.dart'; +import 'package:ion/app/utils/logging.dart'; import 'package:web3dart/json_rpc.dart'; /// Persists the currently working RPC URL. @@ -52,25 +52,6 @@ class BscRpcFailoverHttpClient extends http.BaseClient { // Serializes preference updates to avoid races with concurrent requests. Future _serial = Future.value(); - void _reportFailover(Object error, StackTrace stackTrace, {required String tag}) { - // Sample to avoid flooding Sentry: ~1% of events. - if (DateTime.now().microsecondsSinceEpoch % 100 != 0) return; - - // Networking must not depend on Sentry availability. - unawaited(() async { - try { - final exception = error is Exception ? error : Exception(error.toString()); - await SentryService.logException( - exception, - stackTrace: stackTrace, - tag: tag, - ); - } catch (_) { - // Ignore logging failures. - } - }()); - } - @override void close() { _inner.close(); @@ -169,7 +150,7 @@ class BscRpcFailoverHttpClient extends http.BaseClient { final response = await _inner.send(req); if (_shouldFailoverStatus(response.statusCode)) { - _reportFailover( + reportFailover( Exception( 'BSC RPC failover: HTTP ${response.statusCode} from $endpoint', ), @@ -199,7 +180,7 @@ class BscRpcFailoverHttpClient extends http.BaseClient { lastError = e; lastStackTrace = st; - _reportFailover( + reportFailover( Exception('BSC RPC failover: transport error from $endpoint: ${e.runtimeType}: $e'), st, tag: 'bsc_rpc_failover_transport_error', diff --git a/lib/app/features/user/providers/relays/user_relays_manager.r.dart b/lib/app/features/user/providers/relays/user_relays_manager.r.dart index 31b82dff4a..6b2204ae04 100644 --- a/lib/app/features/user/providers/relays/user_relays_manager.r.dart +++ b/lib/app/features/user/providers/relays/user_relays_manager.r.dart @@ -238,13 +238,35 @@ class UserRelaysManager extends _$UserRelaysManager { } } +String normalizeRelayUrl(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return trimmed; + + try { + final uri = Uri.parse(trimmed); + + // Migration: old relays used :4443, new standard is :443. + if (uri.hasPort && uri.port == 4443) { + return uri.replace(port: 443).toString(); + } + + return uri.toString(); + } catch (_) { + // If parsing fails, keep the original string. + return trimmed; + } +} + @riverpod Future currentUserRelays(Ref ref) async { final identityConnectRelays = await ref.watch(currentUserIdentityConnectRelaysProvider.future); if (identityConnectRelays == null) { return null; } - final updatedUserRelays = UserRelaysData(list: identityConnectRelays); + + final updatedUserRelays = UserRelaysData( + list: identityConnectRelays.map((r) => r.copyWith(url: normalizeRelayUrl(r.url))).toList(), + ); final userRelaysEvent = await ref.read(ionConnectNotifierProvider.notifier).sign(updatedUserRelays); @@ -254,7 +276,7 @@ Future currentUserRelays(Ref ref) async { extension IonConnectRelayInfoToUserRelay on IonConnectRelayInfo { UserRelay toUserRelay() { return UserRelay( - url: url, + url: normalizeRelayUrl(url), write: type == null || type == IonConnectRelayType.write, read: type == null || type == IonConnectRelayType.read, ); diff --git a/lib/app/utils/logging.dart b/lib/app/utils/logging.dart new file mode 100644 index 0000000000..3a5abdc0c1 --- /dev/null +++ b/lib/app/utils/logging.dart @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'dart:async'; + +import 'package:ion/app/services/sentry/sentry_service.dart'; + +void reportFailover(Object error, StackTrace stackTrace, {required String tag}) { + // Sample to avoid flooding Sentry: ~1% of events. + if (DateTime.now().microsecondsSinceEpoch % 100 != 0) return; + + // Networking must not depend on Sentry availability. + unawaited(() async { + try { + final exception = error is Exception ? error : Exception(error.toString()); + await SentryService.logException( + exception, + stackTrace: stackTrace, + tag: tag, + ); + } catch (_) { + // Ignore logging failures. + } + }()); +} diff --git a/packages/ion_connect_cache/pubspec.yaml b/packages/ion_connect_cache/pubspec.yaml index ac7beae809..81068cc926 100644 --- a/packages/ion_connect_cache/pubspec.yaml +++ b/packages/ion_connect_cache/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: nostr_dart: git: url: https://github.com/ice-blockchain/nostr-dart - ref: 0.0.41 + ref: 0.0.43 dev_dependencies: build_runner: ^2.4.15 diff --git a/packages/ion_identity_client/lib/src/users/available_ion_connect_relays/data_sources/available_ion_connect_relays_data_source.dart b/packages/ion_identity_client/lib/src/users/available_ion_connect_relays/data_sources/available_ion_connect_relays_data_source.dart index 7469cfd28a..f007a932f5 100644 --- a/packages/ion_identity_client/lib/src/users/available_ion_connect_relays/data_sources/available_ion_connect_relays_data_source.dart +++ b/packages/ion_identity_client/lib/src/users/available_ion_connect_relays/data_sources/available_ion_connect_relays_data_source.dart @@ -7,6 +7,18 @@ import 'package:ion_identity_client/src/core/storage/token_storage.dart'; import 'package:ion_identity_client/src/core/types/request_headers.dart'; import 'package:ion_identity_client/src/users/available_ion_connect_relays/models/available_ion_connect_relays_response.f.dart'; +String normalizeRelayUrlNo443(String raw) { + final uri = Uri.tryParse(raw.trim()); + if (uri == null) return raw.trim(); + + // Only strip explicit :443 for secure websockets. + if (uri.scheme == 'wss' && uri.hasPort && uri.port == 443) { + return uri.replace(port: 0).toString(); // port:0 removes the explicit port + } + + return uri.toString(); +} + class AvailableIONConnectRelaysDataSource { AvailableIONConnectRelaysDataSource( this._networkClient, @@ -33,7 +45,7 @@ class AvailableIONConnectRelaysDataSource { headers: RequestHeaders.getTokenHeader( token: token.token, ), - queryParams: {'ion-connect-relay': relayUrl}, + queryParams: {'ion-connect-relay': normalizeRelayUrlNo443(relayUrl)}, decoder: (result, _) => parseJsonObject(result, fromJson: AvailableIONConnectRelaysResponse.fromJson), ); diff --git a/pubspec.lock b/pubspec.lock index f975322655..c7b7dffee6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1980,11 +1980,11 @@ packages: dependency: "direct main" description: path: "." - ref: "0.0.41" - resolved-ref: a9d9ed127a65e087dcd35b6c7729bd3019bcd11b + ref: "0.0.43" + resolved-ref: "334645a49b4dabcfb0012aba9cef7da5eebdc128" url: "https://github.com/ice-blockchain/nostr-dart" source: git - version: "0.0.41" + version: "0.0.42" octo_image: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ec302fccb3..e489d2b7df 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -147,7 +147,7 @@ dependencies: nostr_dart: git: url: https://github.com/ice-blockchain/nostr-dart - ref: 0.0.41 + ref: 0.0.43 ogp_data_extract: ^0.1.4 open_filex: ^4.4.0 package_info_plus: ^8.3.0