From fc0c988056125fc29104ba85ca5743caf06b7a1c Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 4 Sep 2026 12:31:25 +0800 Subject: [PATCH 1/2] feat: network endpoints overridable from remote config Remote config can now move the mobile wallet to another network. The wallet payload's optional `endpoints` block (rpc, graphQl, explorer, senoti) replaces the hard-coded AppConstants URLs at runtime; absent keys keep their defaults and a malformed block rejects the payload so the last good config stays in effect. NetworkEndpointsService holds the endpoints in effect and pushes them into the RPC and GraphQL endpoint services (keeping measured latency for URLs that stay) and clears the substrate chain caches, since genesis hash and runtime version belong to one chain. Senoti and the explorer links read from it instead of AppConstants. RemoteConfigModel drops the match/compare boilerplate for value equality, so the notifier only re-caches when the config changed. --- .../lib/providers/remote_config_provider.dart | 2 + mobile-app/lib/shared/utils/url_utils.dart | 2 +- .../activity/transaction_detail_sheet.dart | 2 +- .../multisig_proposal_detail_sheet.dart | 3 +- quantus_sdk/lib/quantus_sdk.dart | 1 + .../lib/src/models/network_endpoints.dart | 63 ++++++++++ .../lib/src/models/remote_config_model.dart | 114 +++++------------- .../services/network/redundant_endpoint.dart | 29 +++++ .../lib/src/services/senoti_service.dart | 2 +- .../lib/src/services/substrate_service.dart | 6 +- .../test/models/network_endpoints_test.dart | 66 ++++++++++ .../test/models/remote_config_model_test.dart | 46 +++++++ .../network_endpoints_service_test.dart | 38 ++++++ 13 files changed, 286 insertions(+), 88 deletions(-) create mode 100644 quantus_sdk/lib/src/models/network_endpoints.dart create mode 100644 quantus_sdk/test/models/network_endpoints_test.dart create mode 100644 quantus_sdk/test/models/remote_config_model_test.dart create mode 100644 quantus_sdk/test/services/network_endpoints_service_test.dart diff --git a/mobile-app/lib/providers/remote_config_provider.dart b/mobile-app/lib/providers/remote_config_provider.dart index d3046efe5..b5540759d 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/shared/utils/url_utils.dart b/mobile-app/lib/shared/utils/url_utils.dart index d74d3c9da..1de5603e8 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 dd413dd6f..662741782 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 eede15c2c..91a7e2831 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/quantus_sdk/lib/quantus_sdk.dart b/quantus_sdk/lib/quantus_sdk.dart index 6167f4f53..0181ccbaf 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 000000000..49de3b361 --- /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 9ca094207..832785a5f 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 15afdb76c..3092f62f1 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/senoti_service.dart b/quantus_sdk/lib/src/services/senoti_service.dart index 2d3ef78d0..c4e5753af 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 63287a371..da2a604c6 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 000000000..c1ea10412 --- /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 000000000..f4d59e860 --- /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 000000000..2c75d30e4 --- /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); + }); +} From b9f19f5ec46093d805372e9e9b4534a40f557e23 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 4 Sep 2026 13:15:45 +0800 Subject: [PATCH 2/2] fix: remote config fails silently when quersi is unreachable Wallets must never depend on quersi being up. The quersi client now bounds every request to 10s so a black-holed host cannot stall the background sync, and both quersi calls share one data reader. RemoteConfigService keeps its quiet failure path (unreachable server or bad payload -> null, the current config stays in effect) and now also survives an unreadable cache by resetting to the in-code defaults instead of throwing out of the provider. Its collaborators are injectable so the path is covered by tests: an unreachable server leaves the defaults and the built-in endpoints in place; a reachable one applies its flags and endpoints. --- .../lib/services/remote_config_service.dart | 37 ++++++----- mobile-app/test/fakes.dart | 7 ++- .../test/unit/remote_config_service_test.dart | 62 +++++++++++++++++++ .../lib/src/services/quersi_service.dart | 45 +++++--------- 4 files changed, 107 insertions(+), 44 deletions(-) create mode 100644 mobile-app/test/unit/remote_config_service_test.dart diff --git a/mobile-app/lib/services/remote_config_service.dart b/mobile-app/lib/services/remote_config_service.dart index 1b2ff8c02..7ef0d26c8 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/test/fakes.dart b/mobile-app/test/fakes.dart index 976975f6b..1c692fd67 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 000000000..2a1b5e065 --- /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/src/services/quersi_service.dart b/quantus_sdk/lib/src/services/quersi_service.dart index facec5473..642ad0e58 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'''