diff --git a/lib/bloc/app_bloc_root.dart b/lib/bloc/app_bloc_root.dart index 488079ef87..409bc8619e 100644 --- a/lib/bloc/app_bloc_root.dart +++ b/lib/bloc/app_bloc_root.dart @@ -284,7 +284,8 @@ class AppBlocRoot extends StatelessWidget { ), ), BlocProvider( - create: (_) => SystemHealthBloc(SystemClockRepository(), mm2Api), + create: (_) => SystemHealthBloc(SystemClockRepository(), mm2Api) + ..add(SystemHealthPeriodicCheckStarted()), ), BlocProvider( create: (context) => TrezorInitBloc( @@ -300,7 +301,8 @@ class AppBlocRoot extends StatelessWidget { ), ), BlocProvider( - create: (context) => FaucetBloc(kdfSdk: context.read()), + create: (context) => + FaucetBloc(kdfSdk: context.read()), ) ], child: _MyAppView(), diff --git a/lib/bloc/system_health/providers/binance_time_provider.dart b/lib/bloc/system_health/providers/binance_time_provider.dart new file mode 100644 index 0000000000..1f82d8c947 --- /dev/null +++ b/lib/bloc/system_health/providers/binance_time_provider.dart @@ -0,0 +1,101 @@ +import 'dart:async' show TimeoutException; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:web_dex/bloc/system_health/providers/time_provider.dart'; + +/// A time provider that fetches time from the Binance API +class BinanceTimeProvider extends TimeProvider { + BinanceTimeProvider({ + this.url = 'https://api.binance.com/api/v3/time', + http.Client? httpClient, + this.timeout = const Duration(seconds: 2), + this.maxRetries = 3, + Logger? logger, + }) : _httpClient = httpClient ?? http.Client(), + _logger = logger ?? Logger('BinanceTimeProvider'); + + /// The URL of the Binance time API + final String url; + + /// Timeout for HTTP requests + final Duration timeout; + + /// Maximum retries + final int maxRetries; + + /// Logger instance + final Logger _logger; + + /// HTTP client for making requests + final http.Client _httpClient; + + @override + String get name => 'Binance'; + + @override + Future getCurrentUtcTime() async { + int retries = 0; + + while (retries < maxRetries) { + try { + final serverTime = await _fetchServerTime(); + _logger.fine('Successfully retrieved time from Binance API'); + return serverTime; + } on SocketException catch (e, s) { + _logger.warning('Socket error with Binance API', e, s); + } on TimeoutException catch (e, s) { + _logger.warning('Timeout with Binance API', e, s); + } on FormatException catch (e, s) { + _logger.severe('Failed to parse response from Binance API', e, s); + } on Exception catch (e, s) { + _logger.severe('Error fetching time from Binance API', e, s); + } + retries++; + + // Calculate exponential backoff: 100ms, 200ms, 400ms, 800ms... + if (retries < maxRetries) { + final delayDuration = Duration(milliseconds: 100 * (1 << retries)); + await Future.delayed(delayDuration); + } + } + + _logger.severe( + 'Failed to get time from Binance API after $maxRetries retries', + ); + throw TimeoutException( + 'Failed to get time from Binance API after $maxRetries retries', + ); + } + + /// Fetches server time from the Binance API + Future _fetchServerTime() async { + final response = await _httpClient.get(Uri.parse(url)).timeout(timeout); + + if (response.statusCode != 200) { + _logger.warning('HTTP error from $url: ${response.statusCode}'); + throw HttpException( + 'HTTP error from $url: ${response.statusCode}', + uri: Uri.parse(url), + ); + } + + final jsonData = jsonDecode(response.body) as Map; + final serverTime = jsonData['serverTime'] as int?; + + if (serverTime == null) { + throw const FormatException( + 'No serverTime field in Binance API response', + ); + } + + return DateTime.fromMillisecondsSinceEpoch(serverTime, isUtc: true); + } + + @override + void dispose() { + _httpClient.close(); + } +} diff --git a/lib/bloc/system_health/providers/http_head_time_provider.dart b/lib/bloc/system_health/providers/http_head_time_provider.dart new file mode 100644 index 0000000000..58e7e8b27e --- /dev/null +++ b/lib/bloc/system_health/providers/http_head_time_provider.dart @@ -0,0 +1,107 @@ +import 'dart:async' show TimeoutException; +import 'dart:io'; +import 'dart:math' show Random; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:web_dex/bloc/system_health/providers/time_provider.dart'; + +/// A time provider that fetches time from server 'Date' headers via HEAD requests +class HttpHeadTimeProvider extends TimeProvider { + HttpHeadTimeProvider({ + this.servers = const [ + 'https://alibaba.com/', + 'https://google.com/', + 'https://cloudflare.com/', + 'https://microsoft.com/', + 'https://github.com/', + ], + http.Client? httpClient, + this.timeout = const Duration(seconds: 2), + this.maxRetries = 3, + Logger? logger, + }) : _httpClient = httpClient ?? http.Client(), + _logger = logger ?? Logger('HttpHeadTimeProvider'); + + /// The name of the provider (for logging and identification) + final Logger _logger; + + /// List of servers to query via HEAD requests + final List servers; + + /// Timeout for HTTP requests + final Duration timeout; + + /// Maximum retries per server + final int maxRetries; + + final http.Client _httpClient; + + @override + String get name => 'HttpHead'; + + @override + Future getCurrentUtcTime() async { + // Randomize the order of servers to avoid overloading any single server + // and to provide a more even distribution of requests. + // This also avoid a single server being a single point of failure. + final shuffledServers = List.from(servers)..shuffle(Random()); + _logger.fine('Randomized server order for time retrieval'); + + for (final serverUrl in shuffledServers) { + int retries = 0; + + while (retries < maxRetries) { + try { + final serverTime = await _fetchServerTime(serverUrl); + _logger.fine('Successfully retrieved time from $serverUrl'); + return serverTime; + } on SocketException catch (e, s) { + _logger.warning('Socket error with $serverUrl', e, s); + } on TimeoutException catch (e, s) { + _logger.warning('Timeout with $serverUrl', e, s); + } on HttpException catch (e, s) { + _logger.warning('HTTP error with $serverUrl', e, s); + } on FormatException catch (e, s) { + _logger.warning('Date header parse error with $serverUrl', e, s); + } + retries++; + } + } + + _logger + .severe('Failed to get time from any server after $maxRetries retries'); + throw TimeoutException( + 'Failed to get time from any server after $maxRetries retries', + ); + } + + /// Fetches server time from the 'date' header of an HTTP HEAD response + Future _fetchServerTime(String url) async { + final response = await _httpClient.head(Uri.parse(url)).timeout(timeout); + + // Treat any successful or redirect status as acceptable. + if (response.statusCode < 200 || response.statusCode >= 400) { + _logger.warning('HTTP error from $url: ${response.statusCode}'); + throw HttpException( + 'HTTP error from $url: ${response.statusCode}', + uri: Uri.parse(url), + ); + } + + final dateHeader = response.headers['date']; + if (dateHeader == null) { + _logger.warning('No Date header in response from $url'); + throw FormatException('No Date header in response from $url'); + } + + final parsed = HttpDate.parse(dateHeader); + return parsed.toUtc(); + } + + /// Disposes the HTTP client when done + @override + void dispose() { + _httpClient.close(); + } +} diff --git a/lib/bloc/system_health/providers/http_time_provider.dart b/lib/bloc/system_health/providers/http_time_provider.dart new file mode 100644 index 0000000000..5aa1b5f9c8 --- /dev/null +++ b/lib/bloc/system_health/providers/http_time_provider.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:web_dex/bloc/system_health/providers/time_provider.dart'; + +/// A time provider that fetches time from an HTTP API +class HttpTimeProvider extends TimeProvider { + HttpTimeProvider({ + required this.url, + required this.timeFieldPath, + required this.timeFormat, + required String providerName, + http.Client? httpClient, + Duration? apiTimeout, + Logger? logger, + }) : _httpClient = httpClient ?? http.Client(), + _apiTimeout = apiTimeout ?? const Duration(seconds: 2), + name = providerName, + _logger = logger ?? Logger(providerName); + + /// The URL of the time API + final String url; + + /// The field path in the JSON response that contains the time. + /// + /// Separate nested fields with dots (e.g., "time.current") + final String timeFieldPath; + + /// The format of the time string in the response + final TimeFormat timeFormat; + + /// The name of the provider (for logging and identification) + @override + final String name; + + final Logger _logger; + + final http.Client _httpClient; + final Duration _apiTimeout; + + @override + Future getCurrentUtcTime() async { + final response = await _httpClient.get(Uri.parse(url)).timeout(_apiTimeout); + + if (response.statusCode != 200) { + _logger.warning('API request failed with status ${response.statusCode}'); + throw HttpException( + 'API request failed with status ${response.statusCode}', + uri: Uri.parse(url), + ); + } + + final dynamic decoded = json.decode(response.body); + if (decoded is! Map) { + _logger.warning( + 'Expected top-level JSON object, got ${decoded.runtimeType}', + ); + throw const FormatException('Invalid JSON structure – object expected'); + } + final Map jsonResponse = decoded; + final parsedTime = await _parseTimeFromJson(jsonResponse); + + return parsedTime; + } + + Future _parseTimeFromJson(Map jsonResponse) async { + final fieldParts = timeFieldPath.split('.'); + dynamic value = jsonResponse; + + for (final part in fieldParts) { + if (value is! Map) { + _logger.warning('JSON path error: expected Map at $part'); + throw FormatException('JSON path error: expected Map at $part'); + } + value = value[part]; + if (value == null) { + _logger.warning('JSON path error: null value at $part'); + throw FormatException('JSON path error: null value at $part'); + } + } + + final timeStr = value.toString(); + if (timeStr.isEmpty) { + _logger.warning('Empty time string'); + throw const FormatException('Empty time string'); + } + + return _parseDateTime(timeStr); + } + + DateTime _parseDateTime(String timeStr) { + switch (timeFormat) { + case TimeFormat.iso8601: + return DateTime.parse(timeStr).toUtc(); + case TimeFormat.custom: + throw const FormatException('Custom time format not supported'); + } + } + + @override + void dispose() { + _httpClient.close(); + } +} + +/// Enum representing the format of time returned by the API +enum TimeFormat { + /// ISO8601 format (e.g. "2023-05-07T12:34:56Z") + iso8601, + + /// Custom format that may require special parsing + custom +} diff --git a/lib/bloc/system_health/providers/ntp_time_provider.dart b/lib/bloc/system_health/providers/ntp_time_provider.dart new file mode 100644 index 0000000000..27ab08d886 --- /dev/null +++ b/lib/bloc/system_health/providers/ntp_time_provider.dart @@ -0,0 +1,77 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:ntp/ntp.dart'; +import 'package:web_dex/bloc/system_health/providers/time_provider.dart'; + +/// A time provider that fetches accurate time from NTP servers +class NtpTimeProvider extends TimeProvider { + NtpTimeProvider({ + this.ntpServers = const [ + 'pool.ntp.org', + 'time.google.com', + 'time.cloudflare.com', + 'time.apple.com', + ], + this.lookupTimeout = const Duration(seconds: 1), + this.maxRetries = 3, + Logger? logger, + }) : _logger = logger ?? Logger('NtpTimeProvider'); + + /// The name of the provider (for logging and identification) + final Logger _logger; + + /// List of NTP servers to query + final List ntpServers; + + /// Timeout for NTP lookup + final Duration lookupTimeout; + + /// Maximum number of retries per server + final int maxRetries; + + @override + String get name => 'NTP'; + + @override + Future getCurrentUtcTime() async { + for (final server in ntpServers) { + DateTime? time; + int retries = 0; + + while (time == null && retries < maxRetries) { + try { + final localNow = DateTime.now(); + final int offset = await NTP.getNtpOffset( + localTime: localNow, + lookUpAddress: server, + timeout: lookupTimeout, + ); + + time = localNow.add(Duration(milliseconds: offset)); + final utcTime = time.toUtc(); + + _logger.fine('Successfully retrieved time from $server'); + return utcTime; + } on SocketException catch (e) { + _logger.warning('Socket error with $server: ${e.message}'); + retries++; + } on TimeoutException catch (e) { + _logger.warning('Timeout with $server: ${e.message}'); + retries++; + } on Exception catch (e) { + _logger.severe('Error with $server: $e'); + retries++; + } + } + } + + _logger.severe( + 'Failed to get time from any NTP server after $maxRetries retries', + ); + throw TimeoutException( + 'Failed to get time from any NTP server after $maxRetries retries', + ); + } +} diff --git a/lib/bloc/system_health/providers/time_provider.dart b/lib/bloc/system_health/providers/time_provider.dart new file mode 100644 index 0000000000..25b100a22c --- /dev/null +++ b/lib/bloc/system_health/providers/time_provider.dart @@ -0,0 +1,13 @@ +/// Base interface for all time providers +abstract class TimeProvider { + /// Returns the current UTC time from an external source + Future getCurrentUtcTime(); + + /// Returns a descriptive name for the provider + String get name; + + /// Dispose of any resources used by the provider + void dispose() { + // Default implementation does nothing + } +} diff --git a/lib/bloc/system_health/providers/time_provider_registry.dart b/lib/bloc/system_health/providers/time_provider_registry.dart new file mode 100644 index 0000000000..8dbd4484b7 --- /dev/null +++ b/lib/bloc/system_health/providers/time_provider_registry.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart'; +import 'package:web_dex/bloc/system_health/providers/binance_time_provider.dart'; +import 'package:web_dex/bloc/system_health/providers/http_head_time_provider.dart'; +import 'package:web_dex/bloc/system_health/providers/http_time_provider.dart'; +import 'package:web_dex/bloc/system_health/providers/ntp_time_provider.dart'; +import 'package:web_dex/bloc/system_health/providers/time_provider.dart'; + +/// Registry of all available time providers +class TimeProviderRegistry { + TimeProviderRegistry({ + List? providers, + Duration? apiTimeout, + }) : _apiTimeout = apiTimeout ?? const Duration(seconds: 2) { + _providers = providers ?? _createDefaultProviders(); + } + + final Duration _apiTimeout; + late final List _providers; + + /// Returns all registered time providers + List get providers => _providers; + + /// Creates the default time providers + List _createDefaultProviders() { + return [ + // NTP is not supported on web with the existing flutter packages, + // so we use HTTP time providers instead via REST APIs that correctly + // configure the CORS headers to allow all origins + if (!kIsWeb && !kIsWasm) NtpTimeProvider(), + + // CORS errors on web block head requests to external servers, so HTTP + // header time providers are not available. We use REST APIs instead. + if (!kIsWeb && !kIsWasm) HttpHeadTimeProvider(timeout: _apiTimeout), + + // Web fallback to NTP and HTTP head providers before trying the REST APIs + BinanceTimeProvider(timeout: _apiTimeout), + + // REST APIs that return the current UTC time + // NOTE: these are prone to change, outages, and rate limits. + HttpTimeProvider( + url: 'https://timeapi.io/api/time/current/zone?timeZone=UTC', + timeFieldPath: 'currentDateTime', + timeFormat: TimeFormat.iso8601, + providerName: 'TimeAPI', + apiTimeout: _apiTimeout, + ), + HttpTimeProvider( + url: 'https://worldtimeapi.org/api/timezone/Etc/UTC', + timeFieldPath: 'utc_datetime', + timeFormat: TimeFormat.iso8601, + providerName: 'WorldTimeAPI', + apiTimeout: _apiTimeout, + ), + ]; + } + + /// Disposes all providers that need cleanup + /// Necessary for providers that manage resources like sockets or streams + void dispose() { + for (final provider in _providers) { + provider.dispose(); + } + } +} diff --git a/lib/bloc/system_health/system_clock_repository.dart b/lib/bloc/system_health/system_clock_repository.dart index 21cc0c6954..425b7b0065 100644 --- a/lib/bloc/system_health/system_clock_repository.dart +++ b/lib/bloc/system_health/system_clock_repository.dart @@ -1,117 +1,70 @@ -// lib/repositories/system_clock_repository.dart -import 'dart:convert'; -import 'dart:io'; - -import 'package:http/http.dart' as http; -import 'package:web_dex/shared/utils/utils.dart'; +import 'package:logging/logging.dart'; +import 'package:web_dex/bloc/system_health/providers/time_provider_registry.dart'; class SystemClockRepository { SystemClockRepository({ - http.Client? httpClient, + TimeProviderRegistry? providerRegistry, Duration? maxAllowedDifference, Duration? apiTimeout, - }) : _httpClient = httpClient ?? http.Client(), - _maxAllowedDifference = + Logger? logger, + }) : _maxAllowedDifference = maxAllowedDifference ?? const Duration(seconds: 60), - _apiTimeout = apiTimeout ?? const Duration(seconds: 2); - - static const _utcWorldTimeApis = [ - 'https://worldtimeapi.org/api/timezone/UTC', - 'https://timeapi.io/api/time/current/zone?timeZone=UTC', - 'http://worldclockapi.com/api/json/utc/now', - ]; + _providerRegistry = providerRegistry ?? + TimeProviderRegistry( + apiTimeout: apiTimeout, + ), + _logger = logger ?? Logger('SystemClockRepository'); final Duration _maxAllowedDifference; - final Duration _apiTimeout; - final http.Client _httpClient; - - /// Queries the available 3rd party APIs to validate the system clock validity - /// returning true if the system clock is within allowed difference of the API - /// time, false otherwise. Uses the first successful response - Future isSystemClockValid({ - List timeApiUrls = _utcWorldTimeApis, - }) async { + final TimeProviderRegistry _providerRegistry; + final Logger _logger; + + /// Queries the available time providers to validate the system clock validity + /// returning true if the system clock is within allowed difference of the + /// first provider that responds, false otherwise. Returns true in case of + /// errors to avoid blocking app usage. + Future isSystemClockValid() async { try { - final futures = timeApiUrls.map((url) => _httpGet(url)); - - final responses = await Future.wait( - futures, - eagerError: false, - ); - - for (final response in responses) { - if (response.statusCode != 200) { - continue; + final providers = _providerRegistry.providers; + bool receivedValidResponse = false; + + for (final provider in providers) { + try { + final apiTime = await provider.getCurrentUtcTime(); + receivedValidResponse = true; + + final localTime = DateTime.timestamp(); + final Duration difference = apiTime.difference(localTime).abs(); + + final isValid = difference < _maxAllowedDifference; + if (isValid) { + _logger.info('System clock validated by ${provider.name} provider'); + } else { + _logger.warning( + 'System clock differs by ${difference.inSeconds}s from ' + '${provider.name} provider'); + } + + return isValid; + } on Exception catch (e, s) { + _logger.severe('Provider ${provider.name} failed', e, s); } + } - final jsonResponse = json.decode(response.body) as Map; - final DateTime apiTime = _parseUtcDateTimeString(jsonResponse); - final localTime = DateTime.timestamp(); - final Duration difference = apiTime.difference(localTime).abs(); - - return difference < _maxAllowedDifference; + if (!receivedValidResponse) { + _logger.warning('All time providers failed to provide a time'); } - // Log error if no successful responses - log('All time API requests failed').ignore(); + // Default to allowing usage when no provider responded + return true; + } on Exception catch (e, s) { + _logger.shout('Failed to validate system clock', e, s); + // Don't block usage of dex if the time provider fetch fails return true; - } catch (e) { - log('Failed to validate system clock: $e').ignore(); - return true; // Don't block usage - } - } - - Future _httpGet(String url) async { - try { - return await _httpClient.get(Uri.parse(url)).timeout(_apiTimeout); - } catch (e) { - return http.Response('Error: $e', HttpStatus.internalServerError); - } - } - - DateTime _parseUtcDateTimeString(Map jsonResponse) { - dynamic apiTimeStr = jsonResponse['datetime'] ?? // worldtimeapi.org - jsonResponse['dateTime'] ?? // worldclockapi.com - jsonResponse['currentDateTime']; // timeapi.io - - if (apiTimeStr == null) { - throw Exception('API response does not contain datetime field'); - } - - if (apiTimeStr is! String || apiTimeStr.isEmpty) { - throw const FormatException('API datetime field is not a string'); - } - - // Convert +00:00 format to Z format if needed - if (apiTimeStr.endsWith('+00:00')) { - apiTimeStr = apiTimeStr.replaceAll('+00:00', 'Z'); - } else if (!apiTimeStr.endsWith('Z')) { - apiTimeStr += 'Z'; // Add UTC timezone indicator if missing - } - - final apiTime = DateTime.parse(apiTimeStr); - if (!apiTime.isUtc) { - throw const FormatException('API time is not in UTC'); } - return apiTime; - } - - /// Checks if there are enough active seeders to indicate valid system clock - Future hasActiveSeeders() async { - // TODO: Implement seeder check logic onur suggested - few seeders - // implies that the user's clock is invalid and being rejected by seeders - throw UnimplementedError('Not implemented yet'); - } - - /// Combines multiple clock validation methods - Future isClockValidWithAllChecks() async { - final apiCheck = await isSystemClockValid(); - final seederCheck = await hasActiveSeeders(); - - return apiCheck && seederCheck; } void dispose() { - _httpClient.close(); + _providerRegistry.dispose(); } } diff --git a/lib/bloc/system_health/system_health_bloc.dart b/lib/bloc/system_health/system_health_bloc.dart index 7635960f57..367f5aead5 100644 --- a/lib/bloc/system_health/system_health_bloc.dart +++ b/lib/bloc/system_health/system_health_bloc.dart @@ -1,46 +1,81 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:web_dex/bloc/system_health/system_clock_repository.dart'; -import 'package:web_dex/bloc/system_health/system_health_event.dart'; -import 'package:web_dex/bloc/system_health/system_health_state.dart'; import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; import 'package:web_dex/mm2/mm2_api/rpc/directly_connected_peers/get_directly_connected_peers.dart'; +part 'system_health_event.dart'; +part 'system_health_state.dart'; + class SystemHealthBloc extends Bloc { - SystemHealthBloc(this._systemClockRepository, this._api) - : super(SystemHealthInitial()) { - on(_onCheckSystemClock); - _startPeriodicCheck(); + SystemHealthBloc( + this._systemClockRepository, + this._api, { + Duration checkInterval = const Duration(seconds: 60), + }) : _checkInterval = checkInterval, + super(SystemHealthInitial()) { + on( + _onSystemHealthCheckRequested, + transformer: restartable(), + ); + on(_onSystemHealthPeriodicCheckStarted); + on( + _onSystemHealthPeriodicCheckCancelled, + ); + add(SystemHealthPeriodicCheckStarted()); } Timer? _timer; + final Duration _checkInterval; final SystemClockRepository _systemClockRepository; final Mm2Api _api; - void _startPeriodicCheck() { - _timer = Timer.periodic(const Duration(seconds: 60), (timer) { - add(CheckSystemClock()); + void _cancelPeriodicCheck() { + _timer?.cancel(); + _timer = null; + } + + Future _onSystemHealthPeriodicCheckStarted( + SystemHealthPeriodicCheckStarted event, + Emitter emit, + ) async { + _cancelPeriodicCheck(); + + add(SystemHealthCheckRequested()); + _timer = Timer.periodic(_checkInterval, (timer) { + add(SystemHealthCheckRequested()); }); } - Future _onCheckSystemClock( - CheckSystemClock event, + Future _onSystemHealthPeriodicCheckCancelled( + SystemHealthPeriodicCheckCancelled event, + Emitter emit, + ) async { + _cancelPeriodicCheck(); + } + + Future _onSystemHealthCheckRequested( + SystemHealthCheckRequested event, Emitter emit, ) async { emit(SystemHealthLoadInProgress()); try { final bool systemClockValid = await _systemClockRepository.isSystemClockValid(); - final bool connectedPeersHealthy = await _arePeersConnected(); - final bool isSystemHealthy = systemClockValid || connectedPeersHealthy; - emit(SystemHealthLoadSuccess(isSystemHealthy)); - } catch (_) { + emit(SystemHealthLoadSuccess(systemClockValid)); + } on Exception catch (_) { emit(SystemHealthLoadFailure()); } } + // TODO: add an additional state or banner if there are no peers connected + // the current system health check indicates an out-of-sync clock message + // to the user, which might not be the reason for too few peers + // final bool connectedPeersHealthy = await _arePeersConnected(); + // ignore: unused_element Future _arePeersConnected() async { try { final directlyConnectedPeers = @@ -56,7 +91,7 @@ class SystemHealthBloc extends Bloc { @override Future close() { - _timer?.cancel(); + _cancelPeriodicCheck(); return super.close(); } } diff --git a/lib/bloc/system_health/system_health_event.dart b/lib/bloc/system_health/system_health_event.dart index 3caba2ca8e..f5813a11f1 100644 --- a/lib/bloc/system_health/system_health_event.dart +++ b/lib/bloc/system_health/system_health_event.dart @@ -1,3 +1,12 @@ +part of 'system_health_bloc.dart'; + abstract class SystemHealthEvent {} -class CheckSystemClock extends SystemHealthEvent {} +/// Event to request a system health check (past tense, as per conventions) +class SystemHealthCheckRequested extends SystemHealthEvent {} + +/// Event to start the periodic check timer +class SystemHealthPeriodicCheckStarted extends SystemHealthEvent {} + +/// Event to cancel the periodic check timer +class SystemHealthPeriodicCheckCancelled extends SystemHealthEvent {} diff --git a/lib/bloc/system_health/system_health_state.dart b/lib/bloc/system_health/system_health_state.dart index 60857c9b6d..3af24e42e7 100644 --- a/lib/bloc/system_health/system_health_state.dart +++ b/lib/bloc/system_health/system_health_state.dart @@ -1,3 +1,5 @@ +part of 'system_health_bloc.dart'; + abstract class SystemHealthState {} class SystemHealthInitial extends SystemHealthState {} @@ -5,9 +7,9 @@ class SystemHealthInitial extends SystemHealthState {} class SystemHealthLoadInProgress extends SystemHealthState {} class SystemHealthLoadSuccess extends SystemHealthState { - final bool isValid; - SystemHealthLoadSuccess(this.isValid); + + final bool isValid; } class SystemHealthLoadFailure extends SystemHealthState {} diff --git a/lib/main.dart b/lib/main.dart index 3a04775f16..179a5358a0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:feedback/feedback.dart'; -import 'package:flutter/foundation.dart' show kIsWeb, kIsWasm; +import 'package:flutter/foundation.dart' show kIsWasm, kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; @@ -12,7 +12,6 @@ import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; -import 'package:web_dex/services/feedback/custom_feedback_form.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/app_config/package_information.dart'; import 'package:web_dex/bloc/app_bloc_observer.dart'; @@ -30,6 +29,7 @@ import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; import 'package:web_dex/mm2/mm2_api/mm2_api_trezor.dart'; import 'package:web_dex/model/stored_settings.dart'; import 'package:web_dex/performance_analytics/performance_analytics.dart'; +import 'package:web_dex/services/feedback/custom_feedback_form.dart'; import 'package:web_dex/services/logger/get_logger.dart'; import 'package:web_dex/services/storage/get_storage.dart'; import 'package:web_dex/shared/constants.dart'; @@ -55,8 +55,14 @@ Future main() async { catchUnhandledExceptions(details.exception, details.stack); }; + // Foundational dependencies / setup - everything else builds on these 3. + // The current focus is migrating mm2Api to the new sdk, so that the sdk + // is the only/primary API/repository for KDF final KomodoDefiSdk komodoDefiSdk = await mm2.initialize(); + final mm2Api = Mm2Api(mm2: mm2, sdk: komodoDefiSdk); + await AppBootstrapper.instance.ensureInitialized(komodoDefiSdk, mm2Api); + // Strange inter-dependencies here that should ideally not be the case. final trezorRepo = TrezorRepo( api: Mm2ApiTrezor(mm2.call), kdfSdk: komodoDefiSdk, @@ -67,16 +73,12 @@ Future main() async { mm2: mm2, trezorBloc: trezor, ); - final mm2Api = Mm2Api(mm2: mm2, sdk: komodoDefiSdk); final walletsRepository = WalletsRepository( komodoDefiSdk, mm2Api, getStorage(), ); - await AppBootstrapper.instance.ensureInitialized(komodoDefiSdk); - await initializeLogger(mm2Api); - runApp( EasyLocalization( supportedLocales: localeList, diff --git a/lib/services/initializer/app_bootstrapper.dart b/lib/services/initializer/app_bootstrapper.dart index e1fe0f40c0..f2649aa4e7 100644 --- a/lib/services/initializer/app_bootstrapper.dart +++ b/lib/services/initializer/app_bootstrapper.dart @@ -9,13 +9,14 @@ final class AppBootstrapper { bool _isInitialized = false; - Future ensureInitialized(KomodoDefiSdk kdfSdk) async { + Future ensureInitialized(KomodoDefiSdk kdfSdk, Mm2Api mm2Api) async { if (_isInitialized) return; GetIt.I.registerSingleton(kdfSdk); final timer = Stopwatch()..start(); await logger.init(); + await initializeLogger(mm2Api); log('AppBootstrapper: Log initialized in ${timer.elapsedMilliseconds}ms'); timer.reset(); diff --git a/lib/shared/ui/clock_warning_banner.dart b/lib/shared/ui/clock_warning_banner.dart index dac7b0058e..d98c1932e7 100644 --- a/lib/shared/ui/clock_warning_banner.dart +++ b/lib/shared/ui/clock_warning_banner.dart @@ -1,10 +1,9 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; -import 'package:web_dex/bloc/system_health/system_health_state.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:easy_localization/easy_localization.dart'; class ClockWarningBanner extends StatelessWidget { const ClockWarningBanner({Key? key}) : super(key: key); diff --git a/lib/views/bridge/bridge_exchange_form.dart b/lib/views/bridge/bridge_exchange_form.dart index 71fbbf51c0..6bea57f2e6 100644 --- a/lib/views/bridge/bridge_exchange_form.dart +++ b/lib/views/bridge/bridge_exchange_form.dart @@ -9,7 +9,6 @@ import 'package:web_dex/bloc/bridge_form/bridge_bloc.dart'; import 'package:web_dex/bloc/bridge_form/bridge_event.dart'; import 'package:web_dex/bloc/bridge_form/bridge_state.dart'; import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; -import 'package:web_dex/bloc/system_health/system_health_state.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/shared/widgets/connect_wallet/connect_wallet_wrapper.dart'; import 'package:web_dex/views/bridge/bridge_group.dart'; diff --git a/lib/views/dex/simple/form/maker/maker_form_trade_button.dart b/lib/views/dex/simple/form/maker/maker_form_trade_button.dart index 2f728c1a4f..d0dbe8f6f8 100644 --- a/lib/views/dex/simple/form/maker/maker_form_trade_button.dart +++ b/lib/views/dex/simple/form/maker/maker_form_trade_button.dart @@ -5,7 +5,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; -import 'package:web_dex/bloc/system_health/system_health_state.dart'; import 'package:web_dex/blocs/maker_form_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; diff --git a/lib/views/dex/simple/form/taker/taker_form_content.dart b/lib/views/dex/simple/form/taker/taker_form_content.dart index 42fd7f6dbb..f66068b443 100644 --- a/lib/views/dex/simple/form/taker/taker_form_content.dart +++ b/lib/views/dex/simple/form/taker/taker_form_content.dart @@ -6,7 +6,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; -import 'package:web_dex/bloc/system_health/system_health_state.dart'; import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; import 'package:web_dex/bloc/taker_form/taker_event.dart'; import 'package:web_dex/bloc/taker_form/taker_state.dart'; diff --git a/lib/views/market_maker_bot/add_market_maker_bot_trade_button.dart b/lib/views/market_maker_bot/add_market_maker_bot_trade_button.dart index 56a1fd1767..0c81ff781a 100644 --- a/lib/views/market_maker_bot/add_market_maker_bot_trade_button.dart +++ b/lib/views/market_maker_bot/add_market_maker_bot_trade_button.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; -import 'package:web_dex/bloc/system_health/system_health_state.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; class AddMarketMakerBotTradeButton extends StatelessWidget { diff --git a/pubspec.lock b/pubspec.lock index 6918b79260..e48fd1ae13 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -890,6 +890,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + ntp: + dependency: "direct main" + description: + name: ntp + sha256: "198db73e5059b334b50dbe8c626011c26576778ee9fc53f4c55c1d89d08ed2d2" + url: "https://pub.dev" + source: hosted + version: "2.0.0" package_config: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2099b77e30..3bce361b41 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -161,6 +161,7 @@ dependencies: path: packages/komodo_ui ref: dev feedback: ^3.1.0 + ntp: ^2.0.0 dev_dependencies: integration_test: # SDK diff --git a/test_units/main.dart b/test_units/main.dart index e5008f3126..55955f9458 100644 --- a/test_units/main.dart +++ b/test_units/main.dart @@ -24,6 +24,11 @@ import 'tests/helpers/update_sell_amount_test.dart'; import 'tests/password/validate_password_test.dart'; import 'tests/password/validate_rpc_password_test.dart'; import 'tests/sorting/sorting_test.dart'; +import 'tests/system_health/http_head_time_provider_test.dart'; +import 'tests/system_health/http_time_provider_test.dart'; +import 'tests/system_health/ntp_time_provider_test.dart'; +import 'tests/system_health/system_clock_repository_test.dart'; +import 'tests/system_health/time_provider_registry_test.dart'; import 'tests/utils/convert_double_to_string_test.dart'; import 'tests/utils/convert_fract_rat_test.dart'; import 'tests/utils/double_to_string_test.dart'; @@ -86,4 +91,12 @@ void main() { testProfitLossRepository(); testGenerateDemoData(); }); + + group('SystemHealth: ', () { + testHttpHeadTimeProvider(); + testSystemClockRepository(); + testHttpTimeProvider(); + testNtpTimeProvider(); + testTimeProviderRegistry(); + }); } diff --git a/test_units/tests/system_health/http_head_time_provider_test.dart b/test_units/tests/system_health/http_head_time_provider_test.dart new file mode 100644 index 0000000000..bef4db0658 --- /dev/null +++ b/test_units/tests/system_health/http_head_time_provider_test.dart @@ -0,0 +1,113 @@ +import 'dart:async' show TimeoutException; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; +import 'package:web_dex/bloc/system_health/providers/http_head_time_provider.dart'; + +void testHttpHeadTimeProvider() { + group('HttpHeadTimeProvider', () { + late HttpHeadTimeProvider provider; + late MockClient mockClient; + + setUp(() { + mockClient = MockClient(); + provider = HttpHeadTimeProvider( + httpClient: mockClient, + timeout: const Duration(seconds: 1), + ); + }); + + tearDown(() { + provider.dispose(); + }); + + test('returns DateTime when header is valid', () async { + // RFC 1123 date format used in HTTP headers + const dateHeader = 'Wed, 07 May 2025 12:34:56 GMT'; + final expectedDateTime = HttpDate.parse(dateHeader); + + mockClient.mockResponse = http.Response( + '', + 200, + headers: {'date': dateHeader}, + ); + + final result = await provider.getCurrentUtcTime(); + + expect(result, isNotNull); + expect(result, equals(expectedDateTime)); + }); + + // Timeout expected because other servers should be tried rather than + // exiting + test('throws TimeoutException when date header is missing', () async { + mockClient.mockResponse = http.Response('', 200, headers: {}); + + expect( + () => provider.getCurrentUtcTime(), + throwsA(isA()), + ); + }); + + test('throws TimeoutException when response status is not 200', () async { + mockClient.mockResponse = http.Response('', 404); + + expect( + () => provider.getCurrentUtcTime(), + throwsA(isA()), + ); + }); + + test('throws TimeoutException when all servers fail', () async { + mockClient.mockResponse = http.Response('', 500); + + expect( + () => provider.getCurrentUtcTime(), + throwsA(isA()), + ); + }); + + test('throws TimeoutException on timeout', () async { + mockClient.shouldThrowTimeout = true; + + expect( + () => provider.getCurrentUtcTime(), + throwsA(isA()), + ); + }); + + // HttpDate.parse is used, which throws HttpException + test('throws TimeoutException with invalid date', () async { + mockClient.mockResponse = http.Response( + '', + 200, + headers: {'date': 'invalid-date-format'}, + ); + + expect( + () => provider.getCurrentUtcTime(), + throwsA(isA()), + ); + }); + }); +} + +/// Simple mock HTTP client for testing +class MockClient extends http.BaseClient { + http.Response mockResponse = http.Response('', 200); + bool shouldThrowTimeout = false; + + @override + Future send(http.BaseRequest request) async { + if (shouldThrowTimeout) { + throw TimeoutException('Timeout'); + } + + return http.StreamedResponse( + Stream.value(mockResponse.bodyBytes), + mockResponse.statusCode, + headers: mockResponse.headers, + ); + } +} diff --git a/test_units/tests/system_health/http_time_provider_test.dart b/test_units/tests/system_health/http_time_provider_test.dart new file mode 100644 index 0000000000..c873c17a55 --- /dev/null +++ b/test_units/tests/system_health/http_time_provider_test.dart @@ -0,0 +1,85 @@ +import 'dart:async'; +import 'dart:convert' show jsonEncode; +import 'dart:io' show HttpException; + +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; +import 'package:web_dex/bloc/system_health/providers/http_time_provider.dart'; + +void testHttpTimeProvider() { + group('HttpTimeProvider', () { + late HttpTimeProvider provider; + late MockClient mockClient; + + setUp(() { + mockClient = MockClient(); + provider = HttpTimeProvider( + url: 'http://example.com', + timeFieldPath: 'currentDateTime', + timeFormat: TimeFormat.iso8601, + providerName: 'TestProvider', + httpClient: mockClient, + ); + }); + + tearDown(() { + provider.dispose(); + }); + + test('returns DateTime when response is valid', () async { + final now = DateTime.utc(2025, 5, 7, 12, 34, 56); + mockClient.mockResponse = http.Response( + jsonEncode({'currentDateTime': now.toIso8601String()}), 200); + final result = await provider.getCurrentUtcTime(); + expect(result, equals(now)); + }); + + test('throws HttpException on non-200 response', () async { + mockClient.mockResponse = http.Response('error', 500); + expect( + () => provider.getCurrentUtcTime(), + throwsA(isA()), + ); + }); + + test('throws FormatException if field missing', () async { + mockClient.mockResponse = + http.Response(jsonEncode({'other': 'value'}), 200); + expect( + () => provider.getCurrentUtcTime(), + throwsA(isA()), + ); + }); + + test('throws FormatException on invalid date format', () async { + mockClient.mockResponse = + http.Response(jsonEncode({'currentDateTime': 'not-a-date'}), 200); + expect( + () => provider.getCurrentUtcTime(), + throwsA(isA()), + ); + }); + + test('throws HttpException on exception', () async { + mockClient.shouldThrow = true; + expect( + () => provider.getCurrentUtcTime(), + throwsA(isA()), + ); + }); + }); +} + +class MockClient extends http.BaseClient { + http.Response mockResponse = http.Response('', 200); + bool shouldThrow = false; + @override + Future send(http.BaseRequest request) async { + if (shouldThrow) throw const HttpException('error'); + return http.StreamedResponse( + Stream.value(mockResponse.bodyBytes), + mockResponse.statusCode, + headers: mockResponse.headers, + ); + } +} diff --git a/test_units/tests/system_health/ntp_time_provider_test.dart b/test_units/tests/system_health/ntp_time_provider_test.dart new file mode 100644 index 0000000000..f303a7ac33 --- /dev/null +++ b/test_units/tests/system_health/ntp_time_provider_test.dart @@ -0,0 +1,22 @@ +import 'dart:async'; +import 'package:test/test.dart'; +import 'package:web_dex/bloc/system_health/providers/ntp_time_provider.dart'; + +void testNtpTimeProvider() { + group('NtpTimeProvider', () { + test('throws TimeoutException if all servers fail', () async { + final provider = NtpTimeProvider( + ntpServers: ['bad.ntp.server'], + lookupTimeout: const Duration(milliseconds: 10), + maxRetries: 1, + ); + // This will likely fail due to bad server + expect( + () => provider.getCurrentUtcTime(), + throwsA(isA()), + ); + }); + // Note: A true success test would require a real NTP server and is best as + // an integration test. + }); +} diff --git a/test_units/tests/system_health/system_clock_repository_test.dart b/test_units/tests/system_health/system_clock_repository_test.dart new file mode 100644 index 0000000000..aa4647c28f --- /dev/null +++ b/test_units/tests/system_health/system_clock_repository_test.dart @@ -0,0 +1,129 @@ +import 'package:test/test.dart'; +import 'package:web_dex/bloc/system_health/providers/time_provider.dart'; +import 'package:web_dex/bloc/system_health/providers/time_provider_registry.dart'; +import 'package:web_dex/bloc/system_health/system_clock_repository.dart'; + +void testSystemClockRepository() { + group('SystemClockRepository', () { + late SystemClockRepository repository; + late MockTimeProviderRegistry mockRegistry; + + setUp(() { + mockRegistry = MockTimeProviderRegistry(); + repository = SystemClockRepository( + providerRegistry: mockRegistry, + maxAllowedDifference: const Duration(seconds: 30), + ); + }); + + tearDown(() { + repository.dispose(); + }); + + test('returns true when first provider returns valid time', () async { + mockRegistry.mockProviders = [ + MockTimeProvider( + name: 'ValidProvider', + returnTime: DateTime.now(), + ), + ]; + + final result = await repository.isSystemClockValid(); + + expect(result, isTrue); + }); + + test('returns false when first provider time differs too much', () async { + // This time is significantly different from local time + mockRegistry.mockProviders = [ + MockTimeProvider( + name: 'InvalidTimeProvider', + returnTime: DateTime.utc(2030), + ), + ]; + + final result = await repository.isSystemClockValid(); + + expect(result, isFalse); + }); + + test('returns true when no providers respond', () async { + mockRegistry.mockProviders = [ + MockTimeProvider(name: 'FailingProvider', returnTime: DateTime.now()), + ]; + + final result = await repository.isSystemClockValid(); + + expect(result, isTrue); + }); + + test('returns true when provider throws exception', () async { + mockRegistry.mockProviders = [ + MockTimeProvider( + name: 'ExceptionProvider', + shouldThrow: true, + returnTime: DateTime.now(), + ), + ]; + + final result = await repository.isSystemClockValid(); + + expect(result, isTrue); + }); + + test('uses provider order from registry', () async { + final validProvider = MockTimeProvider( + name: 'ValidProvider', + returnTime: DateTime.timestamp().toUtc(), + ); + final failingProvider = + MockTimeProvider(name: 'FailingProvider', returnTime: DateTime.now()); + + mockRegistry.mockProviders = [validProvider, failingProvider]; + await repository.isSystemClockValid(); + + expect(validProvider.callCount, 1); + expect(failingProvider.callCount, 0); + }); + }); +} + +class MockTimeProviderRegistry implements TimeProviderRegistry { + List mockProviders = []; + + @override + List get providers => mockProviders; + + @override + void dispose() {} + + @override + dynamic noSuchMethod(Invocation invocation) { + return null; + } +} + +class MockTimeProvider extends TimeProvider { + MockTimeProvider({ + required String name, + required this.returnTime, + this.shouldThrow = false, + }) : _name = name; + + final String _name; + final DateTime returnTime; + final bool shouldThrow; + int callCount = 0; + + @override + String get name => _name; + + @override + Future getCurrentUtcTime() async { + callCount++; + if (shouldThrow) { + throw Exception('Test exception'); + } + return returnTime; + } +} diff --git a/test_units/tests/system_health/time_provider_registry_test.dart b/test_units/tests/system_health/time_provider_registry_test.dart new file mode 100644 index 0000000000..63250bc1f8 --- /dev/null +++ b/test_units/tests/system_health/time_provider_registry_test.dart @@ -0,0 +1,41 @@ +import 'package:test/test.dart'; +import 'package:web_dex/bloc/system_health/providers/time_provider.dart'; +import 'package:web_dex/bloc/system_health/providers/time_provider_registry.dart'; + +void testTimeProviderRegistry() { + group('TimeProviderRegistry', () { + test('returns default providers', () { + final registry = TimeProviderRegistry(); + expect(registry.providers, isNotEmpty); + expect(registry.providers.first.name, isNotEmpty); + }); + + test('accepts custom providers', () { + final custom = _MockTimeProvider(); + final registry = TimeProviderRegistry(providers: [custom]); + expect(registry.providers.length, 1); + expect(registry.providers.first, custom); + }); + + test('dispose calls dispose on all providers', () { + final disposed = []; + final p1 = _MockTimeProvider(onDispose: () => disposed.add(true)); + final p2 = _MockTimeProvider(onDispose: () => disposed.add(true)); + TimeProviderRegistry(providers: [p1, p2]).dispose(); + expect(disposed.length, 2); + }); + }); +} + +class _MockTimeProvider extends TimeProvider { + _MockTimeProvider({this.onDispose}); + final void Function()? onDispose; + @override + String get name => 'mock'; + @override + Future getCurrentUtcTime() async => DateTime.now(); + @override + void dispose() { + onDispose?.call(); + } +}