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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mobile-app/lib/providers/remote_config_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class RemoteConfigNotifier extends StateNotifier<RemoteConfigModel> {
bool _isEnablingRemoteNotifications = false;

RemoteConfigNotifier(this._service) : super(_service.readLocalConfig()) {
NetworkEndpointsService().apply(state.endpoints);
syncConfig();
}

Expand All @@ -38,6 +39,7 @@ class RemoteConfigNotifier extends StateNotifier<RemoteConfigModel> {

if (remote != state) {
_service.cacheConfig(remote.toCacheJson());
NetworkEndpointsService().apply(remote.endpoints);
state = remote;
}
} catch (e) {
Expand Down
37 changes: 23 additions & 14 deletions mobile-app/lib/services/remote_config_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,46 @@ 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<RemoteConfigModel?> 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;
}
}

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<void> cacheConfig(Object json) async {
Expand Down
2 changes: 1 addition & 1 deletion mobile-app/lib/shared/utils/url_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> launchXPost(String xUrl) async {
final match = RegExp(r'/status/(\d+)').firstMatch(xUrl);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
7 changes: 6 additions & 1 deletion mobile-app/test/fakes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,13 @@ class FakeSettingsService extends Fake implements SettingsService {
@override
String? getWalletName(int walletIndex) => null;

final Map<String, String> strings = {};

@override
String? getString(String key) => strings[key];

@override
String? getString(String key) => null;
Future<void> setString(String key, String value) async => strings[key] = value;
}

/// Drives [LocalAuthState] directly so tests can lock/unlock without the
Expand Down
62 changes: 62 additions & 0 deletions mobile-app/test/unit/remote_config_service_test.dart
Original file line number Diff line number Diff line change
@@ -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<RemoteConfigModel> Function() answer;
FakeQuersiService(this.answer);

@override
Future<RemoteConfigModel> 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<RemoteConfigModel> 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 = <Object>[
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');
});
}
1 change: 1 addition & 0 deletions quantus_sdk/lib/quantus_sdk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
63 changes: 63 additions & 0 deletions quantus_sdk/lib/src/models/network_endpoints.dart
Original file line number Diff line number Diff line change
@@ -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<String> rpc;
final List<String> 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<String, dynamic> 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<String, dynamic> toJson() => {'rpc': rpc, 'graphQl': graphQl, 'explorer': explorer, 'senoti': senoti};

static String _url(Map<String, dynamic> json, String key, String fallback, Set<String> schemes) {
final value = json[key];
return value == null ? fallback : _validUrl(key, value, schemes);
}

static List<String> _urls(Map<String, dynamic> json, String key, List<String> fallback, Set<String> 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<String> 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());
}
114 changes: 32 additions & 82 deletions quantus_sdk/lib/src/models/remote_config_model.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -6,6 +9,7 @@ class RemoteConfigModel {
final bool enableSwap;
final bool enableEncryptedAccount;
final bool enableMultisig;
final NetworkEndpoints endpoints;

const RemoteConfigModel({
required this.enableTestButtons,
Expand All @@ -15,31 +19,9 @@ class RemoteConfigModel {
required this.enableSwap,
required this.enableEncryptedAccount,
required this.enableMultisig,
this.endpoints = NetworkEndpoints.defaults,
});

R match<R>({
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,
Expand All @@ -50,65 +32,33 @@ class RemoteConfigModel {
enableMultisig: true,
);

Map<String, dynamic> 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<String, dynamic> toCacheJson() => {
'enableTestButtons': enableTestButtons,
'enableKeystoneHardwareWallet': enableKeystoneHardwareWallet,
'enableHighSecurity': enableHighSecurity,
'enableRemoteNotifications': enableRemoteNotifications,
'enableSwap': enableSwap,
'enableEncryptedAccount': enableEncryptedAccount,
'enableMultisig': enableMultisig,
'endpoints': endpoints.toJson(),
};

factory RemoteConfigModel.fromJson(Map<String, dynamic> 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<String, dynamic> 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());
}
Loading
Loading