Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/app/features/core/providers/env_provider.r.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
};
}
}
Original file line number Diff line number Diff line change
@@ -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<IonConnectRelay> 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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -70,20 +78,42 @@ 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;
}
}

class RelayAuthService {
RelayAuthService({
required this.relay,
required this.addDislikedConnectUrl,
required this.createAuthEvent,
required this.onError,
this.completer,
});

final IonConnectRelay relay;

final void Function(String connectUrl) addDislikedConnectUrl;

final Future<EventMessage> Function({required String challenge, required String relayUrl})
createAuthEvent;

Expand Down Expand Up @@ -130,7 +160,7 @@ class RelayAuthService {
}
}

Future<void> authenticateRelay({bool isRetry = false}) async {
Future<void> authenticateRelay() async {
if (challenge == null || challenge!.isEmpty) throw AuthChallengeIsEmptyException();

// Cases when we need to re-authenticate the relay:
Expand All @@ -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<OkMessage>()
.firstWhere((message) => signedAuthEvent.id == message.eventId);
relay.sendMessage(authMessage);

final okMessage = await relay.messages
.where((message) => message is OkMessage)
.cast<OkMessage>()
.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);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> build(String logicalRelayUrl) => <String>{};

void reset() {
state = <String>{};
}

/// 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;
}
}
Loading