diff --git a/mobile-app/lib/providers/remote_config_provider.dart b/mobile-app/lib/providers/remote_config_provider.dart index d3046efe..b5540759 100644 --- a/mobile-app/lib/providers/remote_config_provider.dart +++ b/mobile-app/lib/providers/remote_config_provider.dart @@ -23,6 +23,7 @@ class RemoteConfigNotifier extends StateNotifier { bool _isEnablingRemoteNotifications = false; RemoteConfigNotifier(this._service) : super(_service.readLocalConfig()) { + NetworkEndpointsService().apply(state.endpoints); syncConfig(); } @@ -38,6 +39,7 @@ class RemoteConfigNotifier extends StateNotifier { if (remote != state) { _service.cacheConfig(remote.toCacheJson()); + NetworkEndpointsService().apply(remote.endpoints); state = remote; } } catch (e) { diff --git a/mobile-app/lib/services/remote_config_service.dart b/mobile-app/lib/services/remote_config_service.dart index 1b2ff8c0..7ef0d26c 100644 --- a/mobile-app/lib/services/remote_config_service.dart +++ b/mobile-app/lib/services/remote_config_service.dart @@ -6,16 +6,23 @@ import 'package:resonance_network_wallet/shared/utils/print.dart'; const String remoteConfigCacheKey = 'remote_config_cache_v1'; +/// Remote config never blocks or breaks the wallet: an unreachable quersi +/// server or a bad payload leaves the current config (last fetched, else the +/// in-code defaults) in effect. class RemoteConfigService { - final QuersiService _quersiService = QuersiService(); - final SettingsService _settingsService = SettingsService(); + final QuersiService _quersiService; + final SettingsService _settingsService; + RemoteConfigService({QuersiService? quersiService, SettingsService? settingsService}) + : _quersiService = quersiService ?? QuersiService(), + _settingsService = settingsService ?? SettingsService(); + + /// Null when quersi cannot be reached or answers with a bad payload. Future readRemoteConfig() async { try { - final remoteData = await _quersiService.getRemoteConfig(); - return remoteData; + return await _quersiService.getRemoteConfig(); } catch (error) { - quantusPrint('Remote config remote read failed: $error'); + quantusPrint('Remote config remote read failed, keeping current config: $error'); return null; } } @@ -23,20 +30,22 @@ class RemoteConfigService { RemoteConfigModel readLocalConfig() { // In debug builds never trust the persisted cache: stale flags from an // earlier run can poison local state. Always reset to in-code defaults. - if (kDebugMode) { - cacheConfig(RemoteConfigModel.defaults.toCacheJson()); - return RemoteConfigModel.defaults; - } + if (kDebugMode) return _resetToDefaults(); final jsonString = _settingsService.getString(remoteConfigCacheKey); + if (jsonString == null || jsonString.isEmpty) return _resetToDefaults(); - if (jsonString == null || jsonString.isEmpty) { - cacheConfig(RemoteConfigModel.defaults.toCacheJson()); - return RemoteConfigModel.defaults; + try { + return RemoteConfigModel.fromJson(jsonDecode(jsonString)); + } catch (error) { + quantusPrint('Remote config cache unreadable, resetting to defaults: $error'); + return _resetToDefaults(); } + } - final decoded = jsonDecode(jsonString); - return RemoteConfigModel.fromJson(decoded); + RemoteConfigModel _resetToDefaults() { + cacheConfig(RemoteConfigModel.defaults.toCacheJson()); + return RemoteConfigModel.defaults; } Future cacheConfig(Object json) async { diff --git a/mobile-app/lib/shared/utils/url_utils.dart b/mobile-app/lib/shared/utils/url_utils.dart index d74d3c9d..1de5603e 100644 --- a/mobile-app/lib/shared/utils/url_utils.dart +++ b/mobile-app/lib/shared/utils/url_utils.dart @@ -3,7 +3,7 @@ import 'package:url_launcher/url_launcher.dart'; /// Block-explorer URL for an immediate (single-signer) transfer extrinsic. String explorerImmediateTransactionUrl(String extrinsicHash) => - '${AppConstants.explorerEndpoint}/immediate-transactions/$extrinsicHash'; + '${NetworkEndpointsService().current.explorer}/immediate-transactions/$extrinsicHash'; Future launchXPost(String xUrl) async { final match = RegExp(r'/status/(\d+)').firstMatch(xUrl); diff --git a/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart b/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart index dd413dd6..66274178 100644 --- a/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart +++ b/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart @@ -667,6 +667,6 @@ class _ExplorerLink extends StatelessWidget { path = '$transactionType/${tx.blockHash}'; } - return path == null ? null : '${AppConstants.explorerEndpoint}/$path'; + return path == null ? null : '${NetworkEndpointsService().current.explorer}/$path'; } } diff --git a/mobile-app/lib/v2/screens/multisig/multisig_proposal_detail_sheet.dart b/mobile-app/lib/v2/screens/multisig/multisig_proposal_detail_sheet.dart index eede15c2..91a7e283 100644 --- a/mobile-app/lib/v2/screens/multisig/multisig_proposal_detail_sheet.dart +++ b/mobile-app/lib/v2/screens/multisig/multisig_proposal_detail_sheet.dart @@ -240,7 +240,8 @@ class _MultisigProposalDetailSheet extends ConsumerWidget { ), Center( child: ExplorerLink( - url: '${AppConstants.explorerEndpoint}/multisig-proposals/${liveProposal.explorerProposalId}', + url: + '${NetworkEndpointsService().current.explorer}/multisig-proposals/${liveProposal.explorerProposalId}', ), ), const SizedBox(height: 8), diff --git a/mobile-app/test/fakes.dart b/mobile-app/test/fakes.dart index 976975f6..1c692fd6 100644 --- a/mobile-app/test/fakes.dart +++ b/mobile-app/test/fakes.dart @@ -35,8 +35,13 @@ class FakeSettingsService extends Fake implements SettingsService { @override String? getWalletName(int walletIndex) => null; + final Map strings = {}; + + @override + String? getString(String key) => strings[key]; + @override - String? getString(String key) => null; + Future setString(String key, String value) async => strings[key] = value; } /// Drives [LocalAuthState] directly so tests can lock/unlock without the diff --git a/mobile-app/test/unit/remote_config_service_test.dart b/mobile-app/test/unit/remote_config_service_test.dart new file mode 100644 index 00000000..2a1b5e06 --- /dev/null +++ b/mobile-app/test/unit/remote_config_service_test.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/remote_config_provider.dart'; +import 'package:resonance_network_wallet/services/remote_config_service.dart'; + +import '../fakes.dart'; + +class FakeQuersiService extends Fake implements QuersiService { + final Future Function() answer; + FakeQuersiService(this.answer); + + @override + Future getRemoteConfig() => answer(); +} + +void main() { + final overrides = RemoteConfigModel.fromJson(const { + 'enableSwap': false, + 'endpoints': { + 'rpc': ['https://rpc.example.net'], + 'explorer': 'https://explorer.example.net', + }, + }); + + RemoteConfigService service(Future Function() answer) => + RemoteConfigService(quersiService: FakeQuersiService(answer), settingsService: FakeSettingsService()); + + tearDown(() => NetworkEndpointsService().apply(NetworkEndpoints.defaults)); + + test('an unreachable quersi server yields no remote config instead of an error', () async { + final failures = [ + const SocketException('Failed host lookup: qrc-1.quantus.com'), + TimeoutException('quersi', const Duration(seconds: 10)), + Exception('Configs request failed with status: 503'), + const FormatException('Remote config endpoints.rpc must be a non-empty list of URLs: []'), + ]; + for (final failure in failures) { + expect(await service(() => Future.error(failure)).readRemoteConfig(), isNull, reason: '$failure'); + } + }); + + test('the wallet keeps the in-code defaults when quersi is unreachable', () async { + final notifier = RemoteConfigNotifier(service(() => Future.error(const SocketException('unreachable')))); + await pumpEventQueue(); + + expect(notifier.state, RemoteConfigModel.defaults); + expect(NetworkEndpointsService().current, NetworkEndpoints.defaults); + expect(AppConstants.rpcEndpoints, contains(RpcEndpointService().bestEndpointUrl)); + }); + + test('a reachable quersi server applies its flags and endpoints', () async { + final notifier = RemoteConfigNotifier(service(() async => overrides)); + await pumpEventQueue(); + + expect(notifier.state, overrides); + expect(NetworkEndpointsService().current, overrides.endpoints); + expect(RpcEndpointService().bestEndpointUrl, 'https://rpc.example.net'); + }); +} diff --git a/quantus_sdk/lib/quantus_sdk.dart b/quantus_sdk/lib/quantus_sdk.dart index 6167f4f5..0181ccba 100644 --- a/quantus_sdk/lib/quantus_sdk.dart +++ b/quantus_sdk/lib/quantus_sdk.dart @@ -33,6 +33,7 @@ export 'src/models/extrinsic_data.dart'; export 'src/models/extrinsic_fee_data.dart'; export 'src/models/signing_request.dart'; export 'src/models/unsigned_transaction_data.dart'; +export 'src/models/network_endpoints.dart'; export 'src/models/remote_config_model.dart'; export 'src/models/miner_reward_event.dart'; export 'src/models/multisig_creation_event.dart'; diff --git a/quantus_sdk/lib/src/models/network_endpoints.dart b/quantus_sdk/lib/src/models/network_endpoints.dart new file mode 100644 index 00000000..49de3b36 --- /dev/null +++ b/quantus_sdk/lib/src/models/network_endpoints.dart @@ -0,0 +1,63 @@ +import 'package:collection/collection.dart'; +import 'package:quantus_sdk/src/constants/app_constants.dart'; + +/// URLs that bind the wallet to one network. Defaults come from +/// [AppConstants]; remote config overrides them to move wallets elsewhere. +class NetworkEndpoints { + final List rpc; + final List graphQl; + final String explorer; + final String senoti; + + const NetworkEndpoints({required this.rpc, required this.graphQl, required this.explorer, required this.senoti}); + + static const NetworkEndpoints defaults = NetworkEndpoints( + rpc: AppConstants.rpcEndpoints, + graphQl: AppConstants.graphQlEndpoints, + explorer: AppConstants.explorerEndpoint, + senoti: AppConstants.senotiEndpoint, + ); + + static const _rpcSchemes = {'http', 'https', 'ws', 'wss'}; + static const _httpSchemes = {'http', 'https'}; + static const _equality = DeepCollectionEquality(); + + /// Absent keys keep their defaults. A present key must hold a well-formed + /// URL (or a non-empty list of them); anything else rejects the whole config. + factory NetworkEndpoints.fromJson(Map json) => NetworkEndpoints( + rpc: _urls(json, 'rpc', defaults.rpc, _rpcSchemes), + graphQl: _urls(json, 'graphQl', defaults.graphQl, _httpSchemes), + explorer: _url(json, 'explorer', defaults.explorer, _httpSchemes), + senoti: _url(json, 'senoti', defaults.senoti, _httpSchemes), + ); + + Map toJson() => {'rpc': rpc, 'graphQl': graphQl, 'explorer': explorer, 'senoti': senoti}; + + static String _url(Map json, String key, String fallback, Set schemes) { + final value = json[key]; + return value == null ? fallback : _validUrl(key, value, schemes); + } + + static List _urls(Map json, String key, List fallback, Set schemes) { + final value = json[key]; + if (value == null) return fallback; + if (value is! List || value.isEmpty) { + throw FormatException('Remote config endpoints.$key must be a non-empty list of URLs: $value'); + } + return List.unmodifiable(value.map((v) => _validUrl(key, v, schemes))); + } + + static String _validUrl(String key, Object? value, Set schemes) { + final uri = value is String ? Uri.tryParse(value) : null; + if (uri == null || !schemes.contains(uri.scheme) || uri.host.isEmpty) { + throw FormatException('Remote config endpoints.$key is not a ${schemes.join('/')} URL: $value'); + } + return value.toString().replaceAll(RegExp(r'/+$'), ''); + } + + @override + bool operator ==(Object other) => other is NetworkEndpoints && _equality.equals(toJson(), other.toJson()); + + @override + int get hashCode => _equality.hash(toJson()); +} diff --git a/quantus_sdk/lib/src/models/remote_config_model.dart b/quantus_sdk/lib/src/models/remote_config_model.dart index 9ca09420..832785a5 100644 --- a/quantus_sdk/lib/src/models/remote_config_model.dart +++ b/quantus_sdk/lib/src/models/remote_config_model.dart @@ -1,3 +1,6 @@ +import 'package:collection/collection.dart'; +import 'package:quantus_sdk/src/models/network_endpoints.dart'; + class RemoteConfigModel { final bool enableTestButtons; final bool enableKeystoneHardwareWallet; @@ -6,6 +9,7 @@ class RemoteConfigModel { final bool enableSwap; final bool enableEncryptedAccount; final bool enableMultisig; + final NetworkEndpoints endpoints; const RemoteConfigModel({ required this.enableTestButtons, @@ -15,31 +19,9 @@ class RemoteConfigModel { required this.enableSwap, required this.enableEncryptedAccount, required this.enableMultisig, + this.endpoints = NetworkEndpoints.defaults, }); - R match({ - required R Function( - bool enableTestButtons, - bool enableKeystoneHardwareWallet, - bool enableHighSecurity, - bool enableRemoteNotifications, - bool enableSwap, - bool enableEncryptedAccount, - bool enableMultisig, - ) - fn, - }) { - return fn( - enableTestButtons, - enableKeystoneHardwareWallet, - enableHighSecurity, - enableRemoteNotifications, - enableSwap, - enableEncryptedAccount, - enableMultisig, - ); - } - static const RemoteConfigModel defaults = RemoteConfigModel( enableTestButtons: false, enableKeystoneHardwareWallet: true, @@ -50,65 +32,33 @@ class RemoteConfigModel { enableMultisig: true, ); - Map toCacheJson() { - return match( - fn: (test, keystone, security, notifications, swap, encrypted, multisig) => { - 'enableTestButtons': test, - 'enableKeystoneHardwareWallet': keystone, - 'enableHighSecurity': security, - 'enableRemoteNotifications': notifications, - 'enableSwap': swap, - 'enableEncryptedAccount': encrypted, - 'enableMultisig': multisig, - }, - ); - } + static const _equality = DeepCollectionEquality(); + + Map toCacheJson() => { + 'enableTestButtons': enableTestButtons, + 'enableKeystoneHardwareWallet': enableKeystoneHardwareWallet, + 'enableHighSecurity': enableHighSecurity, + 'enableRemoteNotifications': enableRemoteNotifications, + 'enableSwap': enableSwap, + 'enableEncryptedAccount': enableEncryptedAccount, + 'enableMultisig': enableMultisig, + 'endpoints': endpoints.toJson(), + }; + + factory RemoteConfigModel.fromJson(Map json) => RemoteConfigModel( + enableTestButtons: json['enableTestButtons'] ?? defaults.enableTestButtons, + enableKeystoneHardwareWallet: json['enableKeystoneHardwareWallet'] ?? defaults.enableKeystoneHardwareWallet, + enableHighSecurity: json['enableHighSecurity'] ?? defaults.enableHighSecurity, + enableRemoteNotifications: json['enableRemoteNotifications'] ?? defaults.enableRemoteNotifications, + enableSwap: json['enableSwap'] ?? defaults.enableSwap, + enableEncryptedAccount: json['enableEncryptedAccount'] ?? defaults.enableEncryptedAccount, + enableMultisig: json['enableMultisig'] ?? defaults.enableMultisig, + endpoints: NetworkEndpoints.fromJson(json['endpoints'] ?? const {}), + ); - factory RemoteConfigModel.fromJson(Map json) { - return RemoteConfigModel( - enableTestButtons: json['enableTestButtons'] ?? defaults.enableTestButtons, - enableKeystoneHardwareWallet: json['enableKeystoneHardwareWallet'] ?? defaults.enableKeystoneHardwareWallet, - enableHighSecurity: json['enableHighSecurity'] ?? defaults.enableHighSecurity, - enableRemoteNotifications: json['enableRemoteNotifications'] ?? defaults.enableRemoteNotifications, - enableSwap: json['enableSwap'] ?? defaults.enableSwap, - enableEncryptedAccount: json['enableEncryptedAccount'] ?? defaults.enableEncryptedAccount, - enableMultisig: json['enableMultisig'] ?? defaults.enableMultisig, - ); - } + @override + bool operator ==(Object other) => other is RemoteConfigModel && _equality.equals(toCacheJson(), other.toCacheJson()); - bool compare(RemoteConfigModel other) { - return match( - fn: - ( - enableTestButtons, - enableKeystoneHardwareWallet, - enableHighSecurity, - enableRemoteNotifications, - enableSwap, - enableEncryptedAccount, - enableMultisig, - ) { - return other.match( - fn: - ( - otherEnableTestButtons, - otherEnableKeystoneHardwareWallet, - otherEnableHighSecurity, - otherEnableRemoteNotifications, - otherEnableSwap, - otherEnableEncryptedAccount, - otherEnableMultisig, - ) { - return enableTestButtons == otherEnableTestButtons && - enableKeystoneHardwareWallet == otherEnableKeystoneHardwareWallet && - enableHighSecurity == otherEnableHighSecurity && - enableRemoteNotifications == otherEnableRemoteNotifications && - enableSwap == otherEnableSwap && - enableEncryptedAccount == otherEnableEncryptedAccount && - enableMultisig == otherEnableMultisig; - }, - ); - }, - ); - } + @override + int get hashCode => _equality.hash(toCacheJson()); } diff --git a/quantus_sdk/lib/src/services/network/redundant_endpoint.dart b/quantus_sdk/lib/src/services/network/redundant_endpoint.dart index 15afdb76..3092f62f 100644 --- a/quantus_sdk/lib/src/services/network/redundant_endpoint.dart +++ b/quantus_sdk/lib/src/services/network/redundant_endpoint.dart @@ -109,6 +109,26 @@ class RpcEndpointService extends RedundantEndpointService { } } +/// Endpoints the app is talking to: [NetworkEndpoints.defaults] until remote +/// config supplies overrides. +class NetworkEndpointsService { + static final NetworkEndpointsService _instance = NetworkEndpointsService._internal(); + factory NetworkEndpointsService() => _instance; + NetworkEndpointsService._internal(); + + NetworkEndpoints _current = NetworkEndpoints.defaults; + NetworkEndpoints get current => _current; + + void apply(NetworkEndpoints endpoints) { + if (endpoints == _current) return; + quantusPrint('Switching network endpoints to ${jsonEncode(endpoints.toJson())}'); + RpcEndpointService().setEndpoints(endpoints.rpc); + GraphQlEndpointService().setEndpoints(endpoints.graphQl); + SubstrateService().clearChainCaches(); + _current = endpoints; + } +} + class RedundantEndpointService { final List endpoints; @@ -117,6 +137,15 @@ class RedundantEndpointService { RedundantEndpointService({required this.endpoints}); + /// Replaces the endpoint set, keeping measured latency for URLs that stay. + void setEndpoints(List urls) { + final known = {for (final e in endpoints) e.url: e}; + endpoints + ..clear() + ..addAll(urls.map((url) => known[url] ?? Endpoint(url: url))); + _sortServers(); + } + Map _mergedHeaders(Map? headers) { return {'Content-Type': 'application/json', ...?headers}; } diff --git a/quantus_sdk/lib/src/services/quersi_service.dart b/quantus_sdk/lib/src/services/quersi_service.dart index facec547..642ad0e5 100644 --- a/quantus_sdk/lib/src/services/quersi_service.dart +++ b/quantus_sdk/lib/src/services/quersi_service.dart @@ -36,43 +36,30 @@ class QuersiService { return rawKeyPair.ss58Address; } - Future getRemoteConfig() async { - final http.Response response = await http.get( - _remoteConfigsEndpoint, - headers: {'Content-Type': 'application/json'}, - ); + static const _requestTimeout = Duration(seconds: 10); + + /// Reads `data` from a quersi endpoint. Bounded so an unreachable server + /// fails within [_requestTimeout] instead of stalling callers. + Future> _getData(Uri endpoint, String what) async { + final http.Response response = await http + .get(endpoint, headers: {'Content-Type': 'application/json'}) + .timeout(_requestTimeout); if (response.statusCode != 200) { - throw Exception('Configs request failed with status: ${response.statusCode}. Body: ${response.body}'); + throw Exception('$what request failed with status: ${response.statusCode}. Body: ${response.body}'); } - final Map? responseBody = jsonDecode(response.body); - final Map? data = responseBody?['data']; - + final Map? data = (jsonDecode(response.body) as Map?)?['data']; if (data == null) { - throw Exception('Configs request failed with status: ${response.statusCode}. Body: ${response.body}'); + throw Exception('$what response has no data. Body: ${response.body}'); } - - return RemoteConfigModel.fromJson(data); + return data; } - Future getExchangeRates() async { - final http.Response response = await http.get( - _exchangeRatesEndpoint, - headers: {'Content-Type': 'application/json'}, - ); - if (response.statusCode != 200) { - throw Exception('Exchange rates request failed with status: ${response.statusCode}. Body: ${response.body}'); - } - - final Map? responseBody = jsonDecode(response.body); - final Map? data = responseBody?['data']; + Future getRemoteConfig() async => + RemoteConfigModel.fromJson(await _getData(_remoteConfigsEndpoint, 'Configs')); - if (data == null) { - throw Exception('Exchange rates not found!'); - } - - return ExchangeRatesResult.fromJson(data); - } + Future getExchangeRates() async => + ExchangeRatesResult.fromJson(await _getData(_exchangeRatesEndpoint, 'Exchange rates')); Future getMinerStats() async { final String minerStatsQuery = r''' diff --git a/quantus_sdk/lib/src/services/senoti_service.dart b/quantus_sdk/lib/src/services/senoti_service.dart index 2d3ef78d..c4e5753a 100644 --- a/quantus_sdk/lib/src/services/senoti_service.dart +++ b/quantus_sdk/lib/src/services/senoti_service.dart @@ -57,7 +57,7 @@ class SenotiService { SenotiService._internal(); final SettingsService _settingsService = SettingsService(); - SenotiAuthClient get _client => SenotiAuthClient(AppConstants.senotiEndpoint); + SenotiAuthClient get _client => SenotiAuthClient(NetworkEndpointsService().current.senoti); /// Wormhole addresses are meant to be unlinkable to the user's identity, so /// registering them with the notification service would deanonymize them. diff --git a/quantus_sdk/lib/src/services/substrate_service.dart b/quantus_sdk/lib/src/services/substrate_service.dart index 63287a37..da2a604c 100644 --- a/quantus_sdk/lib/src/services/substrate_service.dart +++ b/quantus_sdk/lib/src/services/substrate_service.dart @@ -34,7 +34,9 @@ class SubstrateService { DateTime? _runtimeVersionFetchedAt; static const _runtimeVersionMaxAge = Duration(minutes: 5); - void _clearChainCaches() { + /// Genesis hash and runtime version belong to one chain; drop them whenever + /// the RPC endpoints move. + void clearChainCaches() { _cachedGenesisHash = null; _cachedRuntimeVersion = null; _runtimeVersionFetchedAt = null; @@ -155,7 +157,7 @@ class SubstrateService { // A rejected extrinsic can mean a runtime upgrade landed while the cached // spec/genesis was still considered fresh — drop the caches so the next // payload is built against re-fetched chain state. - _clearChainCaches(); + clearChainCaches(); throw Exception(response.error.toString()); } diff --git a/quantus_sdk/test/models/network_endpoints_test.dart b/quantus_sdk/test/models/network_endpoints_test.dart new file mode 100644 index 00000000..c1ea1041 --- /dev/null +++ b/quantus_sdk/test/models/network_endpoints_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +void main() { + const overrides = { + 'rpc': ['wss://rpc-1.example.net', 'https://rpc-2.example.net/'], + 'graphQl': ['https://indexer.example.net/v1/graphql'], + 'explorer': 'https://explorer.example.net/', + 'senoti': 'https://snt.example.net/api', + }; + + test('absent keys keep the built-in defaults', () { + expect(NetworkEndpoints.fromJson(const {}), NetworkEndpoints.defaults); + expect(NetworkEndpoints.defaults.rpc, AppConstants.rpcEndpoints); + expect(NetworkEndpoints.defaults.graphQl, AppConstants.graphQlEndpoints); + expect(NetworkEndpoints.defaults.explorer, AppConstants.explorerEndpoint); + expect(NetworkEndpoints.defaults.senoti, AppConstants.senotiEndpoint); + }); + + test('present keys override and lose trailing slashes', () { + final endpoints = NetworkEndpoints.fromJson(overrides); + expect(endpoints.rpc, ['wss://rpc-1.example.net', 'https://rpc-2.example.net']); + expect(endpoints.graphQl, ['https://indexer.example.net/v1/graphql']); + expect(endpoints.explorer, 'https://explorer.example.net'); + expect(endpoints.senoti, 'https://snt.example.net/api'); + expect(endpoints, isNot(NetworkEndpoints.defaults)); + }); + + test('partial override keeps defaults for the rest', () { + final endpoints = NetworkEndpoints.fromJson(const { + 'rpc': ['https://rpc.example.net'], + }); + expect(endpoints.rpc, ['https://rpc.example.net']); + expect(endpoints.graphQl, NetworkEndpoints.defaults.graphQl); + expect(endpoints.explorer, NetworkEndpoints.defaults.explorer); + }); + + test('toJson round-trips with value equality', () { + final endpoints = NetworkEndpoints.fromJson(overrides); + final again = NetworkEndpoints.fromJson(endpoints.toJson()); + expect(again, endpoints); + expect(again.hashCode, endpoints.hashCode); + }); + + test('malformed values reject the whole block', () { + final bad = >[ + {'rpc': []}, + {'rpc': 'https://rpc.example.net'}, + { + 'rpc': ['ftp://rpc.example.net'], + }, + { + 'rpc': ['not a url'], + }, + { + 'graphQl': ['wss://indexer.example.net'], + }, + {'explorer': 'explorer.example.net'}, + {'senoti': 42}, + {'senoti': 'https://'}, + ]; + for (final json in bad) { + expect(() => NetworkEndpoints.fromJson(json), throwsFormatException, reason: '$json'); + } + }); +} diff --git a/quantus_sdk/test/models/remote_config_model_test.dart b/quantus_sdk/test/models/remote_config_model_test.dart new file mode 100644 index 00000000..f4d59e86 --- /dev/null +++ b/quantus_sdk/test/models/remote_config_model_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +void main() { + test('a payload without endpoints keeps the default network', () { + final config = RemoteConfigModel.fromJson(const {'enableSwap': false}); + expect(config.enableSwap, isFalse); + expect(config.endpoints, NetworkEndpoints.defaults); + }); + + test('cache json round-trips including endpoints', () { + final config = RemoteConfigModel.fromJson(const { + 'enableTestButtons': true, + 'endpoints': { + 'rpc': ['https://rpc.example.net'], + 'explorer': 'https://explorer.example.net', + }, + }); + final again = RemoteConfigModel.fromJson(config.toCacheJson()); + expect(again, config); + expect(again.endpoints.rpc, ['https://rpc.example.net']); + expect(again.endpoints.explorer, 'https://explorer.example.net'); + expect(again.endpoints.graphQl, NetworkEndpoints.defaults.graphQl); + }); + + test('equality tracks both flags and endpoints', () { + expect(RemoteConfigModel.fromJson(const {}), RemoteConfigModel.defaults); + expect(RemoteConfigModel.fromJson(const {'enableSwap': false}), isNot(RemoteConfigModel.defaults)); + expect( + RemoteConfigModel.fromJson(const { + 'endpoints': {'explorer': 'https://explorer.example.net'}, + }), + isNot(RemoteConfigModel.defaults), + ); + }); + + test('a bad endpoints block rejects the whole payload', () { + expect( + () => RemoteConfigModel.fromJson(const { + 'enableSwap': false, + 'endpoints': {'rpc': []}, + }), + throwsFormatException, + ); + }); +} diff --git a/quantus_sdk/test/services/network_endpoints_service_test.dart b/quantus_sdk/test/services/network_endpoints_service_test.dart new file mode 100644 index 00000000..2c75d30e --- /dev/null +++ b/quantus_sdk/test/services/network_endpoints_service_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +void main() { + tearDown(() => NetworkEndpointsService().apply(NetworkEndpoints.defaults)); + + test('setEndpoints replaces the set but keeps latency of endpoints that stay', () { + final rpc = RpcEndpointService(); + final kept = rpc.endpoints.first; + kept.latency = const Duration(milliseconds: 5); + + rpc.setEndpoints(['https://rpc.example.net', kept.url]); + + expect(rpc.endpoints.map((e) => e.url), [kept.url, 'https://rpc.example.net']); + expect(rpc.endpoints.first, same(kept)); + expect(rpc.endpoints.last.latency, isNull); + }); + + test('apply pushes endpoints into the rpc and graphql services', () { + final endpoints = NetworkEndpoints.fromJson(const { + 'rpc': ['https://rpc.example.net'], + 'graphQl': ['https://indexer.example.net/v1/graphql'], + 'explorer': 'https://explorer.example.net', + 'senoti': 'https://snt.example.net/api', + }); + + NetworkEndpointsService().apply(endpoints); + + expect(NetworkEndpointsService().current, endpoints); + expect(RpcEndpointService().bestEndpointUrl, 'https://rpc.example.net'); + expect(GraphQlEndpointService().endpoints.map((e) => e.url), ['https://indexer.example.net/v1/graphql']); + + NetworkEndpointsService().apply(NetworkEndpoints.defaults); + + expect(RpcEndpointService().endpoints.map((e) => e.url), unorderedEquals(AppConstants.rpcEndpoints)); + expect(GraphQlEndpointService().endpoints.map((e) => e.url), AppConstants.graphQlEndpoints); + }); +}