From 76464e1f5dbb29d163143f3ed4767141e973b0c1 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 10:10:40 -0600 Subject: [PATCH 01/16] Add Electrum TLS certificate approvals with adapter 5 timeouts Allow users to review and approve untrusted Electrum certificates per endpoint. Persist approvals, propagate separate primary/failover certificates, and retire connections when approvals change. Guard stale tests and node-form replacement. Integrate electrum_adapter 5.0.0 with bounded setup, subscription lifecycle handling, setup failover, and caller-specific request deadlines. History and broadcast requests default to five minutes; Firo set requests use two minutes. Broadcasts retain existing send/retry behavior with a longer per-attempt deadline. Validation: 246 tests across 36 suites passed. Analysis of 45 changed Dart files found no errors; existing warnings/lint findings remain. All changed Dart files pass formatting checks. Production dependency pins resolved without overrides. --- lib/electrumx_rpc/client_manager.dart | 166 +++-- .../electrum_request_timeouts.dart | 26 + lib/electrumx_rpc/electrumx_client.dart | 429 ++++++----- lib/models/node_form_data.dart | 81 ++ lib/models/node_model.dart | 40 + lib/models/type_adaptors/node_model.g.dart | 9 +- .../add_edit_node_view.dart | 433 ++++++----- .../manage_nodes_views/node_details_view.dart | 260 ++++--- lib/services/node_service.dart | 278 +++++-- lib/services/notifications_service.dart | 29 +- .../electrum_connection_check.dart | 154 +++- lib/utilities/node_change_notifier.dart | 36 + .../test_monero_node_connection.dart | 44 +- lib/utilities/test_node_connection.dart | 42 +- lib/utilities/tls_certificate.dart | 42 ++ .../wallet/impl/bitcoin_frost_wallet.dart | 2 + .../electrumx_interface.dart | 2 + lib/widgets/node_card.dart | 117 +-- lib/widgets/node_options_sheet.dart | 133 ++-- lib/widgets/tls_certificate_dialog.dart | 40 + pubspec.lock | 10 +- .../templates/pubspec.template.yaml | 2 +- test/client_manager_lifecycle_test.dart | 301 ++++++++ test/electrumx_deadline_test.dart | 208 ++++++ test/electrumx_duplicate_endpoint_test.dart | 121 +++ test/electrumx_failover_test.dart | 492 +++++++++++++ test/electrumx_late_approval_test.dart | 114 +++ test/electrumx_request_policy_test.dart | 574 +++++++++++++++ test/electrumx_setup_failover_test.dart | 300 ++++++++ test/electrumx_test.dart | 2 +- test/electrumx_tls_test.dart | 216 ++++++ test/fixtures/tls/expired.key.pem | 28 + test/fixtures/tls/expired.pem | 18 + test/fixtures/tls/node_a.key.pem | 28 + test/fixtures/tls/node_b.key.pem | 28 + test/models/node_model_backup_test.dart | 41 ++ test/models/node_tls_trust_test.dart | 198 +++++ .../node_certificate_writes_test.dart | 293 ++++++++ ...ification_certificate_revocation_test.dart | 156 ++++ .../electrum_connection_check_test.dart | 96 +++ test/utilities/electrum_tls_test.dart | 232 ++++++ test/utilities/mock_electrum_server.dart | 2 + test/utilities/monero_socks_write_test.dart | 116 +++ .../test_electrum_node_connection_test.dart | 205 ++++++ test/utilities/tls_test_server.dart | 324 ++++++++ test/widget_tests/node_card_test.dart | 25 +- test/widget_tests/node_card_test.mocks.dart | 213 +++--- .../widget_tests/node_options_sheet_test.dart | 22 +- .../node_options_sheet_test.mocks.dart | 696 ++++++++---------- test/widget_tests/node_tls_trust_test.dart | 472 ++++++++++++ .../tls_certificate_dialog_test.dart | 132 ++++ 51 files changed, 6635 insertions(+), 1393 deletions(-) create mode 100644 lib/electrumx_rpc/electrum_request_timeouts.dart create mode 100644 lib/models/node_form_data.dart create mode 100644 lib/utilities/node_change_notifier.dart create mode 100644 lib/utilities/tls_certificate.dart create mode 100644 lib/widgets/tls_certificate_dialog.dart create mode 100644 test/client_manager_lifecycle_test.dart create mode 100644 test/electrumx_deadline_test.dart create mode 100644 test/electrumx_duplicate_endpoint_test.dart create mode 100644 test/electrumx_failover_test.dart create mode 100644 test/electrumx_late_approval_test.dart create mode 100644 test/electrumx_request_policy_test.dart create mode 100644 test/electrumx_setup_failover_test.dart create mode 100644 test/electrumx_tls_test.dart create mode 100644 test/fixtures/tls/expired.key.pem create mode 100644 test/fixtures/tls/expired.pem create mode 100644 test/fixtures/tls/node_a.key.pem create mode 100644 test/fixtures/tls/node_b.key.pem create mode 100644 test/models/node_tls_trust_test.dart create mode 100644 test/services/node_certificate_writes_test.dart create mode 100644 test/services/notification_certificate_revocation_test.dart create mode 100644 test/utilities/electrum_connection_check_test.dart create mode 100644 test/utilities/electrum_tls_test.dart create mode 100644 test/utilities/monero_socks_write_test.dart create mode 100644 test/utilities/test_electrum_node_connection_test.dart create mode 100644 test/utilities/tls_test_server.dart create mode 100644 test/widget_tests/node_tls_trust_test.dart create mode 100644 test/widget_tests/tls_certificate_dialog_test.dart diff --git a/lib/electrumx_rpc/client_manager.dart b/lib/electrumx_rpc/client_manager.dart index 8cd9d53241..2c68d3abf2 100644 --- a/lib/electrumx_rpc/client_manager.dart +++ b/lib/electrumx_rpc/client_manager.dart @@ -7,12 +7,19 @@ import '../utilities/prefs.dart'; import '../utilities/tor_plain_net_option_enum.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; +typedef _DetachedClient = ({ + ElectrumClient? client, + TorPlainNetworkOption? network, + StreamSubscription? subscription, +}); + class ClientManager { ClientManager._(); static final ClientManager sharedInstance = ClientManager._(); final Map _map = {}; final Map _mapNet = {}; + final Map _certificates = {}; final Map _heights = {}; final Map> _subscriptions = {}; final Map> _heightCompleters = {}; @@ -42,82 +49,138 @@ class ClientManager { ElectrumClient client, { required CryptoCurrency cryptoCurrency, required TorPlainNetworkOption netType, + String? trustedCertificatePem, }) async { final key = _keyHelper(cryptoCurrency); - if (_map[key] != null) { - if (_mapNet[key] == netType) { - throw Exception( - "ElectrumX Client for $key and $netType already exists.", - ); - } - - await remove(cryptoCurrency: cryptoCurrency); - - _map[key] = client; - _mapNet[key] = netType; - } else { - _map[key] = client; - _mapNet[key] = netType; - } - - _heightCompleters[key] = Completer(); + final previous = _detachClient( + key, + error: StateError('Electrum client for $key was replaced'), + ); + _map[key] = client; + _mapNet[key] = netType; + _heights.remove(key); + _certificates[key] = trustedCertificatePem; + final firstHeight = _heightCompleters[key] = Completer(); + // A subscription can fail before a wallet starts waiting for its height. + // Observe the error now without changing what later consumers receive. + firstHeight.future.ignore(); _subscriptions[key] = client.subscribeHeaders().listen( (event) { + if (!identical(_map[key], client)) return; _heights[key] = event.height; - - if (!_heightCompleters[key]!.isCompleted) { - _heightCompleters[key]!.complete(event.height); + if (!firstHeight.isCompleted) { + firstHeight.complete(event.height); } }, - onError: (Object err, StackTrace s) => Logging.instance.e( - "ClientManager listen", - error: err, - stackTrace: s, + onError: (Object err, StackTrace s) { + Logging.instance.e("ClientManager listen", error: err, stackTrace: s); + _retireFailedClient(key, client, err, s); + }, + onDone: () => _retireFailedClient( + key, + client, + StateError('Electrum header subscription for $key ended'), ), ); + await _closeDetached(previous); + } + + void _retireFailedClient( + String key, + ElectrumClient client, + Object error, [ + StackTrace? stackTrace, + ]) { + final detached = _detachClient( + key, + expectedClient: client, + error: error, + stackTrace: stackTrace, + ); + // A rejected header subscription can leave its RPC connection open. Retire + // it as well, so the next wallet request can establish a healthy client. + unawaited( + _closeDetached(detached).catchError((Object err, StackTrace stack) { + Logging.instance.e( + 'ClientManager cleanup', + error: err, + stackTrace: stack, + ); + }), + ); + } + + _DetachedClient _detachClient( + String key, { + ElectrumClient? expectedClient, + required Object error, + StackTrace? stackTrace, + }) { + if (expectedClient != null && !identical(_map[key], expectedClient)) { + return (client: null, network: null, subscription: null); + } + final detached = ( + client: _map.remove(key), + network: _mapNet.remove(key), + subscription: _subscriptions.remove(key), + ); + _heights.remove(key); + _certificates.remove(key); + final firstHeight = _heightCompleters.remove(key); + if (firstHeight != null && !firstHeight.isCompleted) { + firstHeight.completeError(error, stackTrace); + } + return detached; + } + + Future _closeDetached(_DetachedClient detached) async { + try { + await detached.subscription?.cancel(); + } finally { + await detached.client?.close(); + } } Future getChainHeightFor(CryptoCurrency cryptoCurrency) async { final key = _keyHelper(cryptoCurrency); if (_map[key] == null) { - throw Exception( - "No managed ElectrumClient for $key found.", - ); + throw Exception("No managed ElectrumClient for $key found."); } if (_heightCompleters[key] == null) { - throw Exception( - "No managed _heightCompleters for $key found.", - ); + throw Exception("No managed _heightCompleters for $key found."); } if (Prefs.instance.useTor) { if (_mapNet[key]! == TorPlainNetworkOption.clear) { - throw Exception( - "Non-TOR only client for $key found.", - ); + throw Exception("Non-TOR only client for $key found."); } } else { if (_mapNet[key]! == TorPlainNetworkOption.tor) { - throw Exception( - "TOR only client for $key found.", - ); + throw Exception("TOR only client for $key found."); } } return _heights[key] ?? await _heightCompleters[key]!.future; } + /// The certificate the managed connection for [cryptoCurrency] was opened + /// with, or null when it relies on the platform's trusted roots. + String? trustedCertificateFor(CryptoCurrency cryptoCurrency) => + _certificates[_keyHelper(cryptoCurrency)]; + Future<(ElectrumClient?, TorPlainNetworkOption?)> remove({ required CryptoCurrency cryptoCurrency, + ElectrumClient? expectedClient, }) async { final key = _keyHelper(cryptoCurrency); - await _subscriptions[key]?.cancel(); - _subscriptions.remove(key); - _heights.remove(key); - _heightCompleters.remove(key); - - return (_map.remove(key), _mapNet.remove(key)); + final detached = _detachClient( + key, + expectedClient: expectedClient, + error: StateError('Electrum client for $key was removed'), + ); + await detached.subscription?.cancel(); + return (detached.client, detached.network); } Future closeAll() async { @@ -126,17 +189,12 @@ class ClientManager { } Future _kill() async { - for (final sub in _subscriptions.values) { - await sub.cancel(); - } - for (final client in _map.values) { - await client.close(); - } - - _heightCompleters.clear(); - _heights.clear(); - _subscriptions.clear(); - _mapNet.clear(); - _map.clear(); + // Detach synchronously before awaiting cancellation. Old callbacks must + // never remove a successor that is added while cleanup is in progress. + final detached = [ + for (final key in _map.keys.toList()) + _detachClient(key, error: StateError('Electrum clients were closed')), + ]; + await Future.wait(detached.map(_closeDetached)); } } diff --git a/lib/electrumx_rpc/electrum_request_timeouts.dart b/lib/electrumx_rpc/electrum_request_timeouts.dart new file mode 100644 index 0000000000..ad28da75b4 --- /dev/null +++ b/lib/electrumx_rpc/electrum_request_timeouts.dart @@ -0,0 +1,26 @@ +/// Response limits shared by individual and batched Electrum requests. +class ElectrumRequestTimeouts { + const ElectrumRequestTimeouts({ + this.standard = const Duration(seconds: 60), + this.history = const Duration(minutes: 5), + this.firoSet = const Duration(minutes: 2), + this.broadcast = const Duration(minutes: 5), + }); + + final Duration standard; + final Duration history; + final Duration firoSet; + final Duration broadcast; + + Duration forMethod(String method) => switch (method) { + 'blockchain.scripthash.get_history' => history, + 'blockchain.transaction.broadcast' => broadcast, + 'lelantus.getanonymityset' || + 'lelantus.getusedcoinserials' || + 'spark.getusedcoinstags' || + 'spark.getusedcoinstagstxhashes' || + 'spark.getsparkanonymityset' || + 'spark.getsparkanonymitysetsector' => firoSet, + _ => standard, + }; +} diff --git a/lib/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart index 89bac9a46f..0d54db7f5e 100644 --- a/lib/electrumx_rpc/electrumx_client.dart +++ b/lib/electrumx_rpc/electrumx_client.dart @@ -18,7 +18,6 @@ import 'package:electrum_adapter/electrum_adapter.dart'; import 'package:electrum_adapter/methods/specific/firo.dart'; import 'package:event_bus/event_bus.dart'; import 'package:mutex/mutex.dart'; -import 'package:stream_channel/stream_channel.dart'; import '../app_config.dart'; import '../exceptions/electrumx/no_such_transaction.dart'; @@ -31,10 +30,12 @@ import '../utilities/amount/amount.dart'; import '../utilities/extensions/impl/string.dart'; import '../utilities/logger.dart'; import '../utilities/prefs.dart'; +import '../utilities/tls_certificate.dart'; import '../utilities/tor_plain_net_option_enum.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; import '../wallets/crypto_currency/interfaces/electrumx_currency_interface.dart'; import 'client_manager.dart'; +import 'electrum_request_timeouts.dart'; class WifiOnlyException implements Exception {} @@ -51,6 +52,7 @@ class ElectrumXNode { required this.useSSL, required this.torEnabled, required this.clearnetEnabled, + this.trustedCertificatePem, }); final String address; final int port; @@ -59,6 +61,7 @@ class ElectrumXNode { final bool useSSL; final bool torEnabled; final bool clearnetEnabled; + final String? trustedCertificatePem; factory ElectrumXNode.from(ElectrumXNode node) { return ElectrumXNode( @@ -69,6 +72,7 @@ class ElectrumXNode { useSSL: node.useSSL, torEnabled: node.torEnabled, clearnetEnabled: node.clearnetEnabled, + trustedCertificatePem: node.trustedCertificatePem, ); } @@ -79,6 +83,8 @@ class ElectrumXNode { } class ElectrumXClient { + final ElectrumRequestTimeouts requestTimeouts; + final CryptoCurrency cryptoCurrency; final TorPlainNetworkOption netType; @@ -90,12 +96,19 @@ class ElectrumXClient { bool get useSSL => _useSSL; late bool _useSSL; + final String? trustedCertificatePem; - // StreamChannel? get electrumAdapterChannel => _electrumAdapterChannel; - StreamChannel? _electrumAdapterChannel; + bool _closed = false; + int _connectionGeneration = 0; + ElectrumClient? _adapter; + ElectrumClient? _pendingClient; - ElectrumClient? getElectrumAdapter() => ClientManager.sharedInstance - .getClient(cryptoCurrency: cryptoCurrency, netType: netType); + ElectrumClient? getElectrumAdapter() => _closed + ? null + : ClientManager.sharedInstance.getClient( + cryptoCurrency: cryptoCurrency, + netType: netType, + ); late Prefs _prefs; late TorService _torService; @@ -105,12 +118,6 @@ class ElectrumXClient { final Duration connectionTimeoutForSpecialCaseJsonRPCClients; - // add finalizer to cancel stream subscription when all references to an - // instance of ElectrumX becomes inaccessible - static final Finalizer _finalizer = Finalizer((p0) { - p0._torPreferenceListener?.cancel(); - p0._torStatusListener?.cancel(); - }); StreamSubscription? _torPreferenceListener; StreamSubscription? _torStatusListener; @@ -132,7 +139,19 @@ class ElectrumXClient { ), TorService? torService, EventBus? globalEventBusForTesting, + this.trustedCertificatePem, + this.requestTimeouts = const ElectrumRequestTimeouts(), }) { + for (final limit in [ + requestTimeouts.standard, + requestTimeouts.history, + requestTimeouts.firoSet, + requestTimeouts.broadcast, + ]) { + if (limit <= Duration.zero) { + throw ArgumentError.value(limit, 'requestTimeouts', 'Must be positive'); + } + } _prefs = prefs; _torService = torService ?? TorService.sharedInstance; _host = host; @@ -146,9 +165,14 @@ class ElectrumXClient { _torStatusListener = bus.on().listen(( event, ) async { + if (_closed) return; switch (event.newStatus) { case TorConnectionStatus.connecting: await _torConnectingLock.acquire(); + if (_closed) { + _torConnectingLock.release(); + return; + } _requireMutex = true; break; @@ -166,21 +190,7 @@ class ElectrumXClient { _torPreferenceListener = bus.on().listen(( event, ) async { - // not sure if we need to do anything specific here - // switch (event.status) { - // case TorStatus.enabled: - // case TorStatus.disabled: - // } - - // setting to null should force the creation of a new json rpc client - // on the next request sent through this electrumx instance - _electrumAdapterChannel = null; - await (await ClientManager.sharedInstance.remove( - cryptoCurrency: cryptoCurrency, - )).$1?.close(); - - // Also close any chain height services that are currently open. - // await ChainHeightServiceManager.dispose(); + if (!_closed) await _resetAdapter(); }); } @@ -191,11 +201,14 @@ class ElectrumXClient { required CryptoCurrency cryptoCurrency, TorService? torService, EventBus? globalEventBusForTesting, + ElectrumRequestTimeouts requestTimeouts = const ElectrumRequestTimeouts(), }) { return ElectrumXClient( host: node.address, port: node.port, useSSL: node.useSSL, + trustedCertificatePem: node.trustedCertificatePem, + requestTimeouts: requestTimeouts, prefs: prefs, torService: torService, failovers: failovers, @@ -218,120 +231,180 @@ class ElectrumXClient { } Future closeAdapter() async { - await getElectrumAdapter()?.close(); + _closed = true; + _requireMutex = false; + if (_torConnectingLock.isLocked) _torConnectingLock.release(); + await _torPreferenceListener?.cancel(); + await _torStatusListener?.cancel(); + await _resetAdapter(); } - Future checkElectrumAdapter() async { - await _adapterMutex.protect(() async { - ({InternetAddress host, int port})? proxyInfo; - - if (AppConfig.hasFeature(AppFeature.tor)) { - // If we're supposed to use Tor... - if (_prefs.useTor) { - // But Tor isn't running... - if (_torService.status != TorConnectionStatus.connected) { - // And the killswitch isn't set... - if (!_prefs.torKillSwitch) { - // Then we'll just proceed and connect to ElectrumX through - // clearnet at the bottom of this function. - Logging.instance.w( - "Tor preference set but Tor is not enabled, killswitch not set," - " connecting to Electrum adapter through clearnet", - ); - } else { - // ... But if the killswitch is set, then we throw an exception. - throw Exception( - "Tor preference and killswitch set but Tor is not enabled, " - "not connecting to Electrum adapter", - ); - // TODO [prio=low]: Try to start Tor. - } - } else { - // Get the proxy info from the TorService. - proxyInfo = _torService.getProxyInfo(); - } - - if (netType == TorPlainNetworkOption.clear) { - _electrumAdapterChannel = null; - await ClientManager.sharedInstance.remove( - cryptoCurrency: cryptoCurrency, - ); - } - } else { - if (netType == TorPlainNetworkOption.tor) { - _electrumAdapterChannel = null; - await ClientManager.sharedInstance.remove( - cryptoCurrency: cryptoCurrency, - ); - } - } - } - // If the current ElectrumAdapterClient is closed, create a new one. - if (getElectrumAdapter() != null && getElectrumAdapter()!.peer.isClosed) { - _electrumAdapterChannel = null; - await ClientManager.sharedInstance.remove( - cryptoCurrency: cryptoCurrency, - ); - } + Future _release(ElectrumClient client) async { + final removed = ClientManager.sharedInstance.remove( + cryptoCurrency: cryptoCurrency, + expectedClient: client, + ); + try { + await client.close(); + } finally { + await removed; + } + } - final String useHost; - final int usePort; - final bool useUseSSL; + Future _resetAdapter() async { + _connectionGeneration++; + final pending = _pendingClient; + final current = _adapter; + _pendingClient = _adapter = null; + if (pending != null) await _release(pending); + if (current != null && !identical(current, pending)) { + await _release(current); + } + } - if (currentFailoverIndex == -1) { - useHost = host; - usePort = port; - useUseSSL = useSSL; - } else { - _electrumAdapterChannel = null; - await ClientManager.sharedInstance.remove( - cryptoCurrency: cryptoCurrency, - ); - useHost = _failovers[currentFailoverIndex].address; - usePort = _failovers[currentFailoverIndex].port; - useUseSSL = _failovers[currentFailoverIndex].useSSL; + Future checkElectrumAdapter() => _adapterMutex.protect(() async { + final generation = _connectionGeneration; + void ensureCurrent() { + if (_closed || generation != _connectionGeneration) { + throw StateError('Electrum client changed'); } + } - _electrumAdapterChannel ??= await electrum_adapter.connect( - useHost, - port: usePort, - connectionTimeout: connectionTimeoutForSpecialCaseJsonRPCClients, - aliveTimerDuration: connectionTimeoutForSpecialCaseJsonRPCClients, - acceptUnverified: false, - useSSL: useUseSSL, - proxyInfo: proxyInfo, - ); + ensureCurrent(); + ({InternetAddress host, int port})? proxy; + if (AppConfig.hasFeature(AppFeature.tor) && _prefs.useTor) { + if (_torService.status == TorConnectionStatus.connected) { + proxy = _torService.getProxyInfo(); + } else if (_prefs.torKillSwitch) { + throw StateError('Tor is not connected'); + } + } + bool matches( + ElectrumClient client, + String h, + int p, + bool tls, + String? pin, + ) => + client.host == h && + client.port == p && + client.useSSL == tls && + ClientManager.sharedInstance.trustedCertificateFor(cryptoCurrency) == + (tls ? pin : null); + // Setup fails before the caller RPC is sent. Try each remaining configured + // node once, including for callers that use the adapter directly. + for (var index = currentFailoverIndex; index < _failovers.length; index++) { + ensureCurrent(); + currentFailoverIndex = index; + final failover = index == -1 ? null : _failovers[index]; + final useHost = failover?.address ?? host; + final usePort = failover?.port ?? port; + final ssl = failover?.useSSL ?? useSSL; + final pem = ssl + ? (failover == null + ? trustedCertificatePem + : failover.trustedCertificatePem) + : null; + bool shareable(ElectrumClient client) => + !client.peer.isClosed && + client.proxyInfo?.host.address == proxy?.host.address && + client.proxyInfo?.port == proxy?.port && + (failover != null + ? matches(client, useHost, usePort, ssl, pem) + : matches(client, host, port, useSSL, trustedCertificatePem) || + _failovers.any( + (n) => matches( + client, + n.address, + n.port, + n.useSSL, + n.trustedCertificatePem, + ), + )); + final existing = getElectrumAdapter(); + if (existing != null && shareable(existing)) { + _adapter = existing; + return; + } + if (existing != null) { + await _release(existing); + ensureCurrent(); + } - if (getElectrumAdapter() == null) { - final ElectrumClient newClient; - if (cryptoCurrency is Firo) { - newClient = FiroElectrumClient( - _electrumAdapterChannel!, - useHost, - usePort, - useUseSSL, - proxyInfo, - ); + // Invalid saved approvals are a local configuration error. They must not + // be mistaken for a failed network attempt or bypassed by failover. + final certificateCheck = trustedCertificateCheck(pem); + ElectrumClient? candidate; + bool negotiated = false; + bool kept = false; + try { + final channel = await electrum_adapter.connect( + useHost, + port: usePort, + useSSL: ssl, + proxyInfo: proxy, + connectionTimeout: connectionTimeoutForSpecialCaseJsonRPCClients, + connectionDeadline: connectionTimeoutForSpecialCaseJsonRPCClients, + acceptUnverified: false, + onBadCertificate: certificateCheck, + ); + candidate = cryptoCurrency is Firo + ? FiroElectrumClient( + channel, + useHost, + usePort, + ssl, + proxy, + requestTimeout: requestTimeouts.standard, + ) + : ElectrumClient( + channel, + useHost, + usePort, + ssl, + proxy, + requestTimeout: requestTimeouts.standard, + ); + _pendingClient = candidate; + ensureCurrent(); + await candidate.requestWithTimeout( + 'server.version', + timeout: connectionTimeoutForSpecialCaseJsonRPCClients, + ); + ensureCurrent(); + negotiated = true; + final other = getElectrumAdapter(); + if (other != null && shareable(other)) { + _adapter = other; } else { - newClient = ElectrumClient( - _electrumAdapterChannel!, - useHost, - usePort, - useUseSSL, - proxyInfo, + _adapter = candidate; + await ClientManager.sharedInstance.addClient( + candidate, + cryptoCurrency: cryptoCurrency, + netType: netType, + trustedCertificatePem: pem, ); + ensureCurrent(); + kept = true; } - - await newClient.request('server.version'); - - await ClientManager.sharedInstance.addClient( - newClient, - cryptoCurrency: cryptoCurrency, - netType: netType, - ); + } catch (error) { + // Closing the owner or changing its network settings cancels the whole + // operation. Publication failures and programming errors are not node + // faults; StateError can instead mean the negotiating peer has closed. + ensureCurrent(); + if (negotiated || (error is Error && error is! StateError)) rethrow; + if (index == _failovers.length - 1) { + currentFailoverIndex = -1; + rethrow; + } + } finally { + if (identical(_pendingClient, candidate)) _pendingClient = null; + if (!kept && candidate != null) await _release(candidate); } - }); - } + ensureCurrent(); + if (negotiated) return; + } + }); /// Send raw rpc command Future request({ @@ -339,7 +412,7 @@ class ElectrumXClient { List args = const [], String? requestID, int retries = 2, - Duration requestTimeout = const Duration(seconds: 60), + Duration? requestTimeout, }) async { if (!(await _allow())) { throw WifiOnlyException(); @@ -354,7 +427,11 @@ class ElectrumXClient { } try { - final response = await getElectrumAdapter()!.request(command, args); + final response = await _adapter!.requestWithTimeout( + command, + parameters: args, + timeout: requestTimeout ?? requestTimeouts.forMethod(command), + ); if (response is Map && response.keys.contains("error") && @@ -431,9 +508,10 @@ class ElectrumXClient { Future> batchRequest({ required String command, required List args, - Duration requestTimeout = const Duration(seconds: 60), + Duration? requestTimeout, int retries = 2, }) async { + if (args.isEmpty) return []; if (!(await _allow())) { throw WifiOnlyException(); } @@ -448,9 +526,15 @@ class ElectrumXClient { try { final futures = >[]; - getElectrumAdapter()!.peer.withBatch(() { + _adapter!.peer.withBatch(() { for (final arg in args) { - futures.add(getElectrumAdapter()!.request(command, arg)); + futures.add( + _adapter!.requestWithTimeout( + command, + parameters: arg, + timeout: requestTimeout ?? requestTimeouts.forMethod(command), + ), + ); } }); final response = await Future.wait(futures); @@ -614,16 +698,12 @@ class ElectrumXClient { required String rawTx, String? requestID, }) async { - try { - final response = await request( - requestID: requestID, - command: 'blockchain.transaction.broadcast', - args: [rawTx], - ); - return response as String; - } catch (e) { - rethrow; - } + final response = await request( + requestID: requestID, + command: 'blockchain.transaction.broadcast', + args: [rawTx], + ); + return response as String; } /// Return the confirmed and unconfirmed balances for the scripthash of a given scripthash @@ -677,7 +757,6 @@ class ElectrumXClient { final response = await request( requestID: requestID, command: 'blockchain.scripthash.get_history', - requestTimeout: const Duration(minutes: 5), args: [scripthash], ); result = response; @@ -829,9 +908,10 @@ class ElectrumXClient { }) async { Logging.instance.d("attempting to fetch blockchain.transaction.get..."); await checkElectrumAdapter(); - final dynamic response = await getElectrumAdapter()!.request( + final dynamic response = await _adapter!.requestWithTimeout( 'blockchain.transaction.get', - [txHash, verbose], + parameters: [txHash, verbose], + timeout: requestTimeouts.forMethod('blockchain.transaction.get'), ); Logging.instance.d("Fetching blockchain.transaction.get finished"); @@ -885,9 +965,12 @@ class ElectrumXClient { }) async { Logging.instance.d("attempting to fetch lelantus.getanonymityset..."); await checkElectrumAdapter(); - final Map response = - await (getElectrumAdapter() as FiroElectrumClient) - .getLelantusAnonymitySet(groupId: groupId, blockHash: blockhash); + final Map response = await (_adapter as FiroElectrumClient) + .getLelantusAnonymitySet( + groupId: groupId, + blockHash: blockhash, + timeout: requestTimeouts.firoSet, + ); Logging.instance.d("Fetching lelantus.getanonymityset finished"); return response; } @@ -902,8 +985,11 @@ class ElectrumXClient { }) async { Logging.instance.d("attempting to fetch lelantus.getmintmetadata..."); await checkElectrumAdapter(); - final dynamic response = await (getElectrumAdapter() as FiroElectrumClient) - .getLelantusMintData(mints: mints); + final dynamic response = await _adapter!.requestWithTimeout( + 'lelantus.getmintmetadata', + parameters: [mints], + timeout: requestTimeouts.forMethod('lelantus.getmintmetadata'), + ); Logging.instance.d("Fetching lelantus.getmintmetadata finished"); return response; } @@ -921,9 +1007,11 @@ class ElectrumXClient { dynamic response; while (retryCount > 0 && response is! List) { - response = await (getElectrumAdapter() as FiroElectrumClient) - .getLelantusUsedCoinSerials(startNumber: startNumber); - // TODO add 2 minute timeout. + response = await (_adapter as FiroElectrumClient) + .getLelantusUsedCoinSerials( + startNumber: startNumber, + timeout: requestTimeouts.firoSet, + ); Logging.instance.d("Fetching lelantus.getusedcoinserials finished"); retryCount--; @@ -938,8 +1026,10 @@ class ElectrumXClient { Future getLelantusLatestCoinId({String? requestID}) async { Logging.instance.d("attempting to fetch lelantus.getlatestcoinid..."); await checkElectrumAdapter(); - final int response = await (getElectrumAdapter() as FiroElectrumClient) - .getLatestCoinId(); + final int response = await _adapter!.requestWithTimeout( + 'lelantus.getlatestcoinid', + timeout: requestTimeouts.forMethod('lelantus.getlatestcoinid'), + ) as int; Logging.instance.d("Fetching lelantus.getlatestcoinid finished"); return response; } @@ -968,11 +1058,11 @@ class ElectrumXClient { final start = DateTime.now(); await checkElectrumAdapter(); final Map response = - await (getElectrumAdapter() as FiroElectrumClient) - .getSparkAnonymitySet( - coinGroupId: coinGroupId, - startBlockHash: startBlockHash, - ); + await (_adapter as FiroElectrumClient).getSparkAnonymitySet( + coinGroupId: coinGroupId, + startBlockHash: startBlockHash, + timeout: requestTimeouts.firoSet, + ); Logging.instance.d( "Finished ElectrumXClient.getSparkAnonymitySet(coinGroupId" "=$coinGroupId, startBlockHash=$startBlockHash). " @@ -996,7 +1086,7 @@ class ElectrumXClient { // final start = DateTime.now(); // await _checkElectrumAdapter(); // final Map response = - // await (getElectrumAdapter() as FiroElectrumClient) + // await (_adapter as FiroElectrumClient) // .getUsedCoinsTags(startNumber: startNumber); // // TODO: Add 2 minute timeout. // // Why 2 minutes? @@ -1043,7 +1133,7 @@ class ElectrumXClient { // ); // await checkElectrumAdapter(); // final List response = - // await (getElectrumAdapter() as FiroElectrumClient) + // await (_adapter as FiroElectrumClient) // .getSparkMintMetaData(sparkCoinHashes: sparkCoinHashes); // Logging.instance.log( // "Fetching spark.getsparkmintmetadata finished", @@ -1063,8 +1153,10 @@ class ElectrumXClient { try { Logging.instance.d("attempting to fetch spark.getsparklatestcoinid..."); await checkElectrumAdapter(); - final int response = await (getElectrumAdapter() as FiroElectrumClient) - .getSparkLatestCoinId(); + final int response = await _adapter!.requestWithTimeout( + 'spark.getsparklatestcoinid', + timeout: requestTimeouts.forMethod('spark.getsparklatestcoinid'), + ) as int; Logging.instance.d("Fetching spark.getsparklatestcoinid finished"); return response; } catch (e, s) { @@ -1364,7 +1456,10 @@ class ElectrumXClient { /// } Future> getFeeRate({String? requestID}) async { await checkElectrumAdapter(); - return await getElectrumAdapter()!.getFeeRate(); + return await _adapter!.requestWithTimeout( + 'blockchain.getfeerate', + timeout: requestTimeouts.forMethod('blockchain.getfeerate'), + ) as Map; } /// Return the estimated transaction fee per kilobyte for a transaction to be diff --git a/lib/models/node_form_data.dart b/lib/models/node_form_data.dart new file mode 100644 index 0000000000..eff051c23d --- /dev/null +++ b/lib/models/node_form_data.dart @@ -0,0 +1,81 @@ +import '../utilities/tor_plain_net_option_enum.dart'; +import 'node_model.dart'; + +class NodeFormData { + NodeFormData(); + + // The certificate is assigned last so it is bound to the node's endpoint. + factory NodeFormData.fromNode(NodeModel node) => NodeFormData() + ..name = node.name + ..host = node.host + ..port = node.port + ..useSSL = node.useSSL + ..login = node.loginName + ..trusted = node.trusted + ..isFailover = node.isFailover + ..netOption = TorPlainNetworkOption.fromNodeData( + node.torEnabled, + node.clearnetEnabled, + ) + ..forceNoTor = node.forceNoTor + ..isPrimary = node.isPrimary + ..apiSecret = node.nodeApiSecret + ..trustedCertificatePem = node.trustedCertificatePem + ..source = node; + + String? name, host, login, password, apiSecret; + int? port; + bool? useSSL, isFailover, trusted, forceNoTor, isPrimary; + TorPlainNetworkOption? netOption; + NodeModel? source; + Future? passwordLoad; + int certificateRevision = 0; + + // A certificate approval belongs to the endpoint it was reviewed for. It + // stays with that endpoint: editing the host and restoring it keeps the + // approval, while any other host, port or SSL setting reads as unapproved. + String? _approvedPem; + String? _approvedHost; + int? _approvedPort; + bool? _approvedUseSSL; + bool _forgotten = false; + + bool get certificateForgotten => + _forgotten && + host == _approvedHost && + port == _approvedPort && + useSSL == _approvedUseSSL; + + void forgetCertificate() { + trustedCertificatePem = null; + _forgotten = true; + } + + String? get trustedCertificatePem => + _approvedPem != null && + host == _approvedHost && + port == _approvedPort && + useSSL == _approvedUseSSL + ? _approvedPem + : null; + + set trustedCertificatePem(String? value) { + certificateRevision++; + _forgotten = false; + _approvedPem = value; + _approvedHost = host; + _approvedPort = port; + _approvedUseSSL = useSSL; + } + + /// Whether the form currently names [node]'s endpoint, the identity a + /// trusted certificate is bound to. + bool matchesEndpointOf(NodeModel node) => + host == node.host && port == node.port && useSSL == node.useSSL; + + @override + String toString() { + return "{ name: $name, host: $host, port: $port, useSSL: $useSSL, " + "trusted: $trusted, netOption: $netOption, isPrimary: $isPrimary }"; + } +} diff --git a/lib/models/node_model.dart b/lib/models/node_model.dart index 85c02c48a3..796bcd9b86 100644 --- a/lib/models/node_model.dart +++ b/lib/models/node_model.dart @@ -12,6 +12,7 @@ import 'package:hive_ce/hive.dart'; import '../utilities/default_nodes.dart'; import '../utilities/flutter_secure_storage_interface.dart'; +import '../utilities/tls_certificate.dart'; part 'type_adaptors/node_model.g.dart'; @@ -49,6 +50,8 @@ class NodeModel { final bool isPrimary; // @HiveField(15) final String? nodeApiSecret; + // @HiveField(16) + final String? trustedCertificatePem; NodeModel({ required this.host, @@ -67,6 +70,7 @@ class NodeModel { this.loginName, this.trusted, this.nodeApiSecret, + this.trustedCertificatePem, }); factory NodeModel.fromStackBackup( @@ -97,9 +101,25 @@ class NodeModel { fallback: legacyPrimaryNodeIds?.contains(id) ?? false, ), nodeApiSecret: map['nodeApiSecret'] as String?, + trustedCertificatePem: _backupCertificatePem( + map['trustedCertificatePem'], + ), ); } + /// A PEM that cannot be parsed would make every connection to this node + /// throw, so a damaged backup value is dropped. The node then uses the + /// platform roots until its certificate is reviewed again. + static String? _backupCertificatePem(Object? value) { + if (value is! String) return null; + try { + certificateDer(value); + return value; + } catch (_) { + return null; + } + } + static bool _backupBool(Object? value, {required bool fallback}) => _nullableBackupBool(value) ?? fallback; @@ -117,6 +137,14 @@ class NodeModel { return null; } + /// Whether [host], [port] and [useSSL] name this node's endpoint, the + /// identity a trusted certificate is bound to. + bool sameEndpoint({ + required String host, + required int port, + required bool useSSL, + }) => this.host == host && this.port == port && this.useSSL == useSSL; + NodeModel copyWith({ String? host, int? port, @@ -133,7 +161,14 @@ class NodeModel { bool? clearnetEnabled, bool? isPrimary, String? nodeApiSecret, + String? trustedCertificatePem, + bool clearTrustedCertificate = false, }) { + final endpointChanged = !sameEndpoint( + host: host ?? this.host, + port: port ?? this.port, + useSSL: useSSL ?? this.useSSL, + ); return NodeModel( host: host ?? this.host, port: port ?? this.port, @@ -151,6 +186,10 @@ class NodeModel { forceNoTor: forceNoTor ?? this.forceNoTor, isPrimary: isPrimary ?? this.isPrimary, nodeApiSecret: nodeApiSecret ?? this.nodeApiSecret, + trustedCertificatePem: clearTrustedCertificate + ? null + : trustedCertificatePem ?? + (endpointChanged ? null : this.trustedCertificatePem), ); } @@ -177,6 +216,7 @@ class NodeModel { map['forceNoTor'] = forceNoTor; map['isPrimary'] = isPrimary; map['nodeApiSecret'] = nodeApiSecret; + map['trustedCertificatePem'] = trustedCertificatePem; return map; } diff --git a/lib/models/type_adaptors/node_model.g.dart b/lib/models/type_adaptors/node_model.g.dart index 1ab826d553..54149c38e4 100644 --- a/lib/models/type_adaptors/node_model.g.dart +++ b/lib/models/type_adaptors/node_model.g.dart @@ -1,4 +1,4 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND +// Manually maintained Hive adapter. Append fields for backwards compatibility. part of '../node_model.dart'; @@ -33,13 +33,14 @@ class NodeModelAdapter extends TypeAdapter { forceNoTor: fields[13] as bool? ?? false, isPrimary: fields[14] as bool? ?? false, nodeApiSecret: fields[15] as String?, + trustedCertificatePem: fields[16] as String?, ); } @override void write(BinaryWriter writer, NodeModel obj) { writer - ..writeByte(16) + ..writeByte(17) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -71,7 +72,9 @@ class NodeModelAdapter extends TypeAdapter { ..writeByte(14) ..write(obj.isPrimary) ..writeByte(15) - ..write(obj.nodeApiSecret); + ..write(obj.nodeApiSecret) + ..writeByte(16) + ..write(obj.trustedCertificatePem); } @override diff --git a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart index 2d6645f641..01c2f61940 100644 --- a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart +++ b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart @@ -16,24 +16,26 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:uuid/uuid.dart'; +import '../../../../models/node_form_data.dart'; import '../../../../models/node_model.dart'; import '../../../../notifications/show_flush_bar.dart'; -import '../../../../providers/global/active_wallet_provider.dart'; import '../../../../providers/global/secure_store_provider.dart'; import '../../../../providers/providers.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/assets.dart'; import '../../../../utilities/barcode_scanner_interface.dart'; import '../../../../utilities/constants.dart'; -import '../../../../utilities/enums/sync_type_enum.dart'; import '../../../../utilities/flutter_secure_storage_interface.dart'; import '../../../../utilities/logger.dart'; +import '../../../../utilities/node_change_notifier.dart'; import '../../../../utilities/node_uri_util.dart'; import '../../../../utilities/test_node_connection.dart'; import '../../../../utilities/text_styles.dart'; +import '../../../../utilities/tls_certificate.dart'; import '../../../../utilities/tor_plain_net_option_enum.dart'; import '../../../../utilities/util.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../../wallets/crypto_currency/interfaces/electrumx_currency_interface.dart'; import '../../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; import '../../../../widgets/background.dart'; import '../../../../widgets/conditional_parent.dart'; @@ -48,7 +50,7 @@ import '../../../../widgets/stack_dialog.dart'; import '../../../../widgets/stack_text_field.dart'; import '../../../../widgets/textfield_icon_button.dart'; -// import 'package:web3dart/web3dart.dart'; +export '../../../../models/node_form_data.dart'; enum AddEditNodeViewType { add, edit } @@ -83,28 +85,50 @@ class _AddEditNodeViewState extends ConsumerState { late bool saveEnabled; late bool testConnectionEnabled; + late final NodeFormData _form; + bool _busy = false; + void _onTestSuccess(NodeFormData data) { - if (coin is Epiccash) { - ref.read(nodeFormDataProvider).host = data.host; - ref.read(nodeFormDataProvider).port = data.port; - ref.read(nodeFormDataProvider).useSSL = data.useSSL; - } else if (coin is Mimblewimblecoin) { - ref.read(nodeFormDataProvider).host = data.host; - ref.read(nodeFormDataProvider).port = data.port; - ref.read(nodeFormDataProvider).useSSL = data.useSSL; - } else if (coin is CryptonoteCurrency) { - ref.read(nodeFormDataProvider).host = data.host; + if (mounted && identical(data, _form)) setState(() {}); + } + + VoidCallback? _whileIdle(Future Function() action) => + _busy ? null : () => _runBusy(action); + + Future _runBusy(Future Function() action) async { + if (_busy) return; + setState(() => _busy = true); + try { + await WidgetsBinding.instance.endOfFrame; + await _form.passwordLoad; + if (mounted) await action(); + } catch (error, stack) { + Logging.instance.w('$error', error: error, stackTrace: stack); + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: 'Node operation failed', + context: context, + ), + ); + } + } finally { + if (mounted) setState(() => _busy = false); } } Future attemptSave() async { + final service = ref.read(nodeServiceChangeNotifierProvider); + final notify = nodeChangeNotifier(ref, coin); final canConnect = await ref.read(testNodeConnectionProvider)( context: context, onSuccess: _onTestSuccess, cryptoCurrency: coin, - nodeFormData: ref.read(nodeFormDataProvider), + nodeFormData: _form, ); + if (!mounted || ModalRoute.of(context)?.isCurrent == false) return; bool? shouldSave; if (!canConnect && mounted) { @@ -217,12 +241,12 @@ class _AddEditNodeViewState extends ConsumerState { }); } - if (!canConnect && !shouldSave!) { + if (!mounted || (!canConnect && shouldSave != true)) { // return without saving return; } - final formData = ref.read(nodeFormDataProvider); + final formData = _form; // strip unused path String address = formData.host!; @@ -255,6 +279,7 @@ class _AddEditNodeViewState extends ConsumerState { coinName: coin.identifier, isFailover: formData.isFailover!, trusted: formData.trusted!, + trustedCertificatePem: formData.trustedCertificatePem, isDown: false, torEnabled: torEnabled, clearnetEnabled: plainEnabled, @@ -263,10 +288,8 @@ class _AddEditNodeViewState extends ConsumerState { nodeApiSecret: formData.apiSecret, ); - await ref - .read(nodeServiceChangeNotifierProvider) - .save(node, formData.password, true); - await _notifyWalletsOfUpdatedNode(); + await service.save(node, formData.password, true); + await notify(); if (mounted) { Navigator.of(context) .popUntil(ModalRoute.withName(widget.routeOnSuccessOrDelete)); @@ -284,6 +307,7 @@ class _AddEditNodeViewState extends ConsumerState { coinName: coin.identifier, isFailover: formData.isFailover!, trusted: formData.trusted!, + trustedCertificatePem: formData.trustedCertificatePem, isDown: false, torEnabled: torEnabled, clearnetEnabled: plainEnabled, @@ -292,10 +316,16 @@ class _AddEditNodeViewState extends ConsumerState { nodeApiSecret: formData.apiSecret, ); - await ref - .read(nodeServiceChangeNotifierProvider) - .save(node, formData.password, true); - await _notifyWalletsOfUpdatedNode(); + if (!await service.saveEdit( + node, + formData.password, + true, + opened: formData.source!, + forgetCertificate: formData.certificateForgotten, + )) { + return; + } + await notify(); if (mounted) { Navigator.of(context) .popUntil(ModalRoute.withName(widget.routeOnSuccessOrDelete)); @@ -304,41 +334,6 @@ class _AddEditNodeViewState extends ConsumerState { } } - Future _notifyWalletsOfUpdatedNode() async { - final wallets = ref - .read(pWallets) - .wallets - .where((e) => e.info.coin == widget.coin); - final prefs = ref.read(prefsChangeNotifierProvider); - - switch (prefs.syncType) { - case SyncingType.currentWalletOnly: - for (final wallet in wallets) { - if (ref.read(currentWalletIdProvider) == wallet.walletId) { - unawaited(wallet.updateNode().then((value) => wallet.refresh())); - } else { - unawaited(wallet.updateNode()); - } - } - break; - case SyncingType.selectedWalletsAtStartup: - final List walletIdsToSync = prefs.walletIdsSyncOnStartup; - for (final wallet in wallets) { - if (walletIdsToSync.contains(wallet.walletId)) { - unawaited(wallet.updateNode().then((value) => wallet.refresh())); - } else { - unawaited(wallet.updateNode()); - } - } - break; - case SyncingType.allWalletsOnStartup: - for (final wallet in wallets) { - unawaited(wallet.updateNode().then((value) => wallet.refresh())); - } - break; - } - } - bool _scanLock = false; void _scanQr() async { @@ -433,7 +428,7 @@ class _AddEditNodeViewState extends ConsumerState { @override void initState() { isDesktop = Util.isDesktop; - ref.refresh(nodeFormDataProvider); + _form = ref.refresh(nodeFormDataProvider); viewType = widget.viewType; coin = widget.coin; @@ -517,7 +512,7 @@ class _AddEditNodeViewState extends ConsumerState { .extension()! .accentColorDark, ), - onPressed: _scanQr, + onPressed: _busy ? null : _scanQr, ), ), ), @@ -553,16 +548,20 @@ class _AddEditNodeViewState extends ConsumerState { width: 20, height: 20, ), - onPressed: () async { - Navigator.popUntil( - context, - ModalRoute.withName(widget.routeOnSuccessOrDelete), - ); - - await ref - .read(nodeServiceChangeNotifierProvider) - .delete(nodeId!, true); - }, + onPressed: _busy + ? null + : () async { + Navigator.popUntil( + context, + ModalRoute.withName( + widget.routeOnSuccessOrDelete, + ), + ); + + await ref + .read(nodeServiceChangeNotifierProvider) + .delete(nodeId!, true); + }, ), ), ), @@ -601,7 +600,7 @@ class _AddEditNodeViewState extends ConsumerState { maxWidth: 580, maxHeight: double.infinity, child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ const SizedBox(height: 8), Row( @@ -630,7 +629,7 @@ class _AddEditNodeViewState extends ConsumerState { .extension()! .background, icon: const QrCodeIcon(width: 21, height: 21), - onPressed: _scanQr, + onPressed: _busy ? null : _scanQr, ), ), ], @@ -648,7 +647,7 @@ class _AddEditNodeViewState extends ConsumerState { ), ), child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: .stretch, children: [ NodeForm( key: Key((node ?? _scannedResult?.$1)?.id ?? "none"), @@ -656,6 +655,8 @@ class _AddEditNodeViewState extends ConsumerState { scannedPw: _scannedResult?.$2, secureStore: ref.read(secureStoreProvider), readOnly: false, + busy: _busy, + data: _form, coin: widget.coin, onChanged: (canSave, canTest) { if (canSave != saveEnabled && @@ -682,16 +683,16 @@ class _AddEditNodeViewState extends ConsumerState { Expanded( child: SecondaryButton( label: "Test connection", - enabled: testConnectionEnabled, + enabled: testConnectionEnabled && !_busy, buttonHeight: isDesktop ? ButtonHeight.l : null, - onPressed: testConnectionEnabled - ? () async { + onPressed: testConnectionEnabled && !_busy + ? _whileIdle(() async { final testPassed = await ref.read(testNodeConnectionProvider)( context: context, onSuccess: _onTestSuccess, cryptoCurrency: coin, - nodeFormData: ref.read(nodeFormDataProvider), + nodeFormData: _form, ); if (context.mounted) { if (testPassed) { @@ -712,7 +713,7 @@ class _AddEditNodeViewState extends ConsumerState { ); } } - } + }) : null, ), ), @@ -721,9 +722,11 @@ class _AddEditNodeViewState extends ConsumerState { Expanded( child: PrimaryButton( label: "Save", - enabled: saveEnabled, + enabled: saveEnabled && !_busy, buttonHeight: ButtonHeight.l, - onPressed: saveEnabled ? attemptSave : null, + onPressed: saveEnabled && !_busy + ? _whileIdle(attemptSave) + : null, ), ), ], @@ -738,7 +741,9 @@ class _AddEditNodeViewState extends ConsumerState { : Theme.of(context) .extension()! .getPrimaryDisabledButtonStyle(context), - onPressed: saveEnabled ? attemptSave : null, + onPressed: saveEnabled && !_busy + ? _whileIdle(attemptSave) + : null, child: Text("Save", style: STextStyles.button(context)), ), ], @@ -748,24 +753,14 @@ class _AddEditNodeViewState extends ConsumerState { } } -class NodeFormData { - String? name, host, login, password, apiSecret; - int? port; - bool? useSSL, isFailover, trusted, forceNoTor, isPrimary; - TorPlainNetworkOption? netOption; - - @override - String toString() { - return "{ name: $name, host: $host, port: $port, useSSL: $useSSL, trusted: $trusted, netOption: $netOption, isPrimary: $isPrimary }"; - } -} - final nodeFormDataProvider = Provider((_) => NodeFormData()); class NodeForm extends ConsumerStatefulWidget { const NodeForm({ super.key, this.node, + this.data, + this.busy = false, this.scannedPw, required this.secureStore, required this.readOnly, @@ -774,6 +769,8 @@ class NodeForm extends ConsumerStatefulWidget { }); final NodeModel? node; + final NodeFormData? data; + final bool busy; final String? scannedPw; final SecureStorageInterface secureStore; final bool readOnly; @@ -785,6 +782,36 @@ class NodeForm extends ConsumerStatefulWidget { } class _NodeFormState extends ConsumerState { + late final NodeFormData _data; + bool _changing = false; + bool _passwordEdited = false; + bool get _inputsReadOnly => widget.readOnly || widget.busy || _changing; + String? get _certificate => widget.readOnly + ? widget.node?.trustedCertificatePem + : _data.trustedCertificatePem; + + Future _changeNode(Future Function() action) async { + if (widget.busy || _changing) return; + final notify = nodeChangeNotifier(ref, widget.coin); + setState(() => _changing = true); + try { + if (await action()) await notify(); + } catch (error, stack) { + Logging.instance.w('$error', error: error, stackTrace: stack); + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: 'Node operation failed', + context: context, + ), + ); + } + } finally { + if (mounted) setState(() => _changing = false); + } + } + late final TextEditingController _nameController; late final TextEditingController _hostController; late final TextEditingController _portController; @@ -834,41 +861,49 @@ class _NodeFormState extends ConsumerState { bool enableField(TextEditingController controller) { bool enable = true; - if (widget.readOnly) { + if (_inputsReadOnly) { enable = controller.text.isNotEmpty; } return enable; } bool get shouldBeReadOnly => - widget.readOnly || widget.node?.isDefault == true; + _inputsReadOnly || widget.node?.isDefault == true; void _updateState() { + if (widget.busy || _changing) return; + _copyToFormData(); + } + + void _copyToFormData() { port = int.tryParse(_portController.text); onChanged?.call(canSave, canTestConnection); - ref.read(nodeFormDataProvider).name = _nameController.text; - ref.read(nodeFormDataProvider).host = _hostController.text; - ref.read(nodeFormDataProvider).login = _usernameController.text.isEmpty + _data.name = _nameController.text; + _data.host = _hostController.text; + _data.login = _usernameController.text.isEmpty ? null : _usernameController.text; - ref.read(nodeFormDataProvider).password = _passwordController.text.isEmpty + _data.password = _passwordController.text.isEmpty ? null : _passwordController.text; - ref.read(nodeFormDataProvider).apiSecret = _apiSecretController.text.isEmpty + _data.apiSecret = _apiSecretController.text.isEmpty ? null : _apiSecretController.text; - ref.read(nodeFormDataProvider).port = port; - ref.read(nodeFormDataProvider).useSSL = _useSSL; - ref.read(nodeFormDataProvider).isFailover = _isFailover; - ref.read(nodeFormDataProvider).trusted = _trusted; - ref.read(nodeFormDataProvider).netOption = netOption; - ref.read(nodeFormDataProvider).forceNoTor = _forceNoTor; - ref.read(nodeFormDataProvider).host; - ref.read(nodeFormDataProvider).isPrimary = _isPrimary; + _data.port = port; + _data.useSSL = _useSSL; + _data.isFailover = _isFailover; + _data.trusted = _trusted; + _data.netOption = netOption; + _data.forceNoTor = _forceNoTor; + _data.host; + _data.isPrimary = _isPrimary; } @override void initState() { + _data = widget.data ?? ref.read(nodeFormDataProvider); + _data.source = widget.node; + _data.trustedCertificatePem = null; onChanged = widget.onChanged; _nameController = TextEditingController(); _hostController = TextEditingController(); @@ -883,11 +918,16 @@ class _NodeFormState extends ConsumerState { final node = widget.node!; if (enableAuthFields) { if (widget.scannedPw == null) { - node.getPassword(widget.secureStore).then((value) { - if (value is String) { - _passwordController.text = value; - } - }); + // Save and Test await this; a failed read must not block them. + _data.passwordLoad = node.getPassword(widget.secureStore).then(( + value, + ) { + if (!mounted || value is! String || _passwordEdited) return; + _passwordController.text = value; + _data.password = value; + // Tests and saves may run before any field is edited. + _updateState(); + }, onError: (Object _) {}); } else { _passwordController.text = widget.scannedPw!; } @@ -921,8 +961,12 @@ class _NodeFormState extends ConsumerState { } WidgetsBinding.instance.addPostFrameCallback((_) { - // update provider state object so test connection works without having to modify a field in the ui first - _updateState(); + if (!mounted) return; + // A replacement node must hydrate even while a previous test is busy. + // Copy its endpoint before binding its certificate to the form. + _copyToFormData(); + _data.trustedCertificatePem = node.trustedCertificatePem; + if (mounted) setState(() {}); }); } else { enableSSLCheckbox = true; @@ -1234,6 +1278,8 @@ class _NodeFormState extends ConsumerState { TextFieldIconButton( child: const XIcon(), onTap: () async { + if (widget.busy || _changing) return; + _passwordEdited = true; _passwordController.text = ""; _updateState(); }, @@ -1245,6 +1291,8 @@ class _NodeFormState extends ConsumerState { : null, ), onChanged: (newValue) { + if (widget.busy || _changing) return; + _passwordEdited = true; _updateState(); setState(() {}); }, @@ -1350,11 +1398,57 @@ class _NodeFormState extends ConsumerState { ), ], ), + if ((widget.coin is ElectrumXCurrencyInterface || + widget.coin is BitcoinFrost) && + _useSSL && + _certificate != null) + Column( + crossAxisAlignment: .start, + children: [ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: SelectableText( + 'Trusted TLS certificate\n' + 'SHA-256:\n' + '${certificatePemFingerprint(_certificate!) ?? 'Unavailable'}', + style: STextStyles.itemSubtitle12(context), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Spacer(), + SecondaryButton( + label: 'Forget certificate', + buttonHeight: .s, + padding: const .symmetric(horizontal: 10), + onPressed: widget.busy || _changing + ? null + : () async { + if (!widget.readOnly) { + setState(_data.forgetCertificate); + return; + } + await _changeNode( + () => ref + .read(nodeServiceChangeNotifierProvider) + .forgetTrustedCertificate(widget.node!), + ); + }, + ), + ], + ), + ], + ), if (widget.coin is CryptonoteCurrency) Row( children: [ GestureDetector( - onTap: !widget.readOnly /*&& trustedCheckbox*/ + onTap: !_inputsReadOnly ? () { setState(() { _trusted = !_trusted; @@ -1370,7 +1464,7 @@ class _NodeFormState extends ConsumerState { width: 20, height: 20, child: Checkbox( - fillColor: !widget.readOnly + fillColor: !_inputsReadOnly ? null : MaterialStateProperty.all( Theme.of(context) @@ -1380,7 +1474,7 @@ class _NodeFormState extends ConsumerState { materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, value: _trusted, - onChanged: !widget.readOnly + onChanged: !_inputsReadOnly ? (newValue) { setState(() { _trusted = newValue!; @@ -1411,26 +1505,24 @@ class _NodeFormState extends ConsumerState { Row( children: [ GestureDetector( - onTap: () { - setState(() { - _isFailover = !_isFailover; - }); - if (widget.readOnly) { - ref - .read(nodeServiceChangeNotifierProvider) - .save( - widget.node!.copyWith( - isFailover: _isFailover, - loginName: widget.node!.loginName, - trusted: widget.node!.trusted, - ), - null, - true, - ); - } else { - _updateState(); - } - }, + onTap: widget.busy || _changing + ? null + : () { + setState(() { + _isFailover = !_isFailover; + }); + if (widget.readOnly) { + unawaited( + _changeNode( + () => ref + .read(nodeServiceChangeNotifierProvider) + .setFailover(widget.node!.id, _isFailover), + ), + ); + } else { + _updateState(); + } + }, child: Container( color: Colors.transparent, child: Row( @@ -1442,26 +1534,29 @@ class _NodeFormState extends ConsumerState { materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, value: _isFailover, - onChanged: (newValue) { - setState(() { - _isFailover = newValue!; - }); - if (widget.readOnly) { - ref - .read(nodeServiceChangeNotifierProvider) - .save( - widget.node!.copyWith( - isFailover: _isFailover, - loginName: widget.node!.loginName, - trusted: widget.node!.trusted, - ), - null, - true, - ); - } else { - _updateState(); - } - }, + onChanged: widget.busy || _changing + ? null + : (newValue) { + setState(() { + _isFailover = newValue!; + }); + if (widget.readOnly) { + unawaited( + _changeNode( + () => ref + .read( + nodeServiceChangeNotifierProvider, + ) + .setFailover( + widget.node!.id, + _isFailover, + ), + ), + ); + } else { + _updateState(); + } + }, ), ), const SizedBox(width: 12), @@ -1480,11 +1575,11 @@ class _NodeFormState extends ConsumerState { children: [ RadioTextButton( label: "Only TOR traffic", - enabled: !widget.readOnly, + enabled: !_inputsReadOnly, value: TorPlainNetworkOption.tor, groupValue: netOption, onChanged: (value) { - if (!widget.readOnly) { + if (!_inputsReadOnly) { setState(() => netOption = TorPlainNetworkOption.tor); _updateState(); } @@ -1497,11 +1592,11 @@ class _NodeFormState extends ConsumerState { children: [ RadioTextButton( label: "Only non-TOR traffic", - enabled: !widget.readOnly, + enabled: !_inputsReadOnly, value: TorPlainNetworkOption.clear, groupValue: netOption, onChanged: (value) { - if (!widget.readOnly) { + if (!_inputsReadOnly) { setState(() => netOption = TorPlainNetworkOption.clear); _updateState(); } @@ -1514,11 +1609,11 @@ class _NodeFormState extends ConsumerState { children: [ RadioTextButton( label: "Allow both", - enabled: !widget.readOnly, + enabled: !_inputsReadOnly, value: TorPlainNetworkOption.both, groupValue: netOption, onChanged: (value) { - if (!widget.readOnly) { + if (!_inputsReadOnly) { setState(() => netOption = TorPlainNetworkOption.both); _updateState(); } @@ -1535,7 +1630,7 @@ class _NodeFormState extends ConsumerState { width: 20, height: 20, child: Checkbox( - fillColor: !widget.readOnly + fillColor: !_inputsReadOnly ? null : MaterialStateProperty.all( Theme.of(context) @@ -1544,7 +1639,7 @@ class _NodeFormState extends ConsumerState { ), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, value: _forceNoTor, - onChanged: !widget.readOnly + onChanged: !_inputsReadOnly ? (newValue) { setState(() { _forceNoTor = newValue!; @@ -1599,8 +1694,8 @@ class RadioTextButton extends StatelessWidget { child: Container( color: Colors.transparent, child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ SizedBox( width: 20, diff --git a/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart b/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart index 233ba0c6b9..c685a668d5 100644 --- a/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart +++ b/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart @@ -16,14 +16,14 @@ import 'package:flutter_svg/svg.dart'; import 'package:tuple/tuple.dart'; import '../../../../notifications/show_flush_bar.dart'; -import '../../../../providers/global/active_wallet_provider.dart'; import '../../../../providers/global/secure_store_provider.dart'; import '../../../../providers/providers.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/assets.dart'; -import '../../../../utilities/enums/sync_type_enum.dart'; import '../../../../utilities/flutter_secure_storage_interface.dart'; import '../../../../utilities/test_node_connection.dart'; +import '../../../../utilities/logger.dart'; +import '../../../../utilities/node_change_notifier.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/tor_plain_net_option_enum.dart'; import '../../../../utilities/util.dart'; @@ -62,44 +62,38 @@ class _NodeDetailsViewState extends ConsumerState { late final String popRouteName; bool _desktopReadOnly = true; + bool _busy = false; + late final NodeFormData _form; - Future _notifyWalletsOfUpdatedNode() async { - final wallets = ref - .read(pWallets) - .wallets - .where((e) => e.info.coin == widget.coin); - final prefs = ref.read(prefsChangeNotifierProvider); + VoidCallback? _whileIdle(Future Function() action) => + _busy ? null : () => _runBusy(action); - switch (prefs.syncType) { - case SyncingType.currentWalletOnly: - for (final wallet in wallets) { - if (ref.read(currentWalletIdProvider) == wallet.walletId) { - unawaited(wallet.updateNode().then((value) => wallet.refresh())); - } else { - unawaited(wallet.updateNode()); - } - } - break; - case SyncingType.selectedWalletsAtStartup: - final List walletIdsToSync = prefs.walletIdsSyncOnStartup; - for (final wallet in wallets) { - if (walletIdsToSync.contains(wallet.walletId)) { - unawaited(wallet.updateNode().then((value) => wallet.refresh())); - } else { - unawaited(wallet.updateNode()); - } - } - break; - case SyncingType.allWalletsOnStartup: - for (final wallet in wallets) { - unawaited(wallet.updateNode().then((value) => wallet.refresh())); - } - break; + Future _runBusy(Future Function() action) async { + if (_busy) return; + setState(() => _busy = true); + try { + await WidgetsBinding.instance.endOfFrame; + await _form.passwordLoad; + if (mounted) await action(); + } catch (error, stack) { + Logging.instance.w('$error', error: error, stackTrace: stack); + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: 'Node operation failed', + context: context, + ), + ); + } + } finally { + if (mounted) setState(() => _busy = false); } } @override initState() { + _form = ref.refresh(nodeFormDataProvider); secureStore = ref.read(secureStoreProvider); coin = widget.coin; nodeId = widget.nodeId; @@ -129,9 +123,9 @@ class _NodeDetailsViewState extends ConsumerState { condition: !isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () async { @@ -158,28 +152,30 @@ class _NodeDetailsViewState extends ConsumerState { key: const Key("nodeDetailsEditNodeAppBarButtonKey"), size: 36, shadows: const [], - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, icon: SvgPicture.asset( Assets.svg.pencil, - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, width: 20, height: 20, ), - onPressed: () { - Navigator.of(context).pushNamed( - AddEditNodeView.routeName, - arguments: Tuple4( - AddEditNodeViewType.edit, - coin, - nodeId, - popRouteName, - ), - ); - }, + onPressed: _busy + ? null + : () { + Navigator.of(context).pushNamed( + AddEditNodeView.routeName, + arguments: Tuple4( + AddEditNodeViewType.edit, + coin, + nodeId, + popRouteName, + ), + ); + }, ), ), ), @@ -238,6 +234,10 @@ class _NodeDetailsViewState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ NodeForm( + // Reload the form only when the stored node changes. + key: ValueKey(node), + data: _form, + busy: _busy, node: node, secureStore: secureStore, readOnly: isDesktop ? _desktopReadOnly : true, @@ -256,13 +256,16 @@ class _NodeDetailsViewState extends ConsumerState { child: DeleteButton( label: "Delete node", desktopMed: true, - onPressed: () async { - Navigator.of(context).pop(); + onPressed: _busy + ? null + : () async { + final service = ref.read( + nodeServiceChangeNotifierProvider, + ); + Navigator.of(context).pop(); - await ref - .read(nodeServiceChangeNotifierProvider) - .delete(node!.id, true); - }, + await service.delete(node!.id, true); + }, ), ), const SizedBox(width: 16), @@ -277,36 +280,27 @@ class _NodeDetailsViewState extends ConsumerState { Expanded( child: SecondaryButton( label: "Test connection", + enabled: !_busy, buttonHeight: isDesktop ? ButtonHeight.l : null, - onPressed: () async { + onPressed: _whileIdle(() async { + final service = ref.read( + nodeServiceChangeNotifierProvider, + ); + final notify = nodeChangeNotifier(ref, coin); final node = ref .read(nodeServiceChangeNotifierProvider) .getNodeById(id: nodeId)!; - final TorPlainNetworkOption netOption; - if (ref.read(nodeFormDataProvider).netOption != null) { - netOption = ref.read(nodeFormDataProvider).netOption!; - } else { - netOption = TorPlainNetworkOption.fromNodeData( - node.torEnabled, - node.clearnetEnabled, + final editing = isDesktop && !_desktopReadOnly; + final nodeFormData = editing + ? _form + : NodeFormData.fromNode(node); + if (!editing) { + nodeFormData.password = await node.getPassword( + ref.read(secureStoreProvider), ); } - final nodeFormData = NodeFormData() - ..useSSL = node.useSSL - ..trusted = node.trusted - ..name = node.name - ..host = node.host - ..login = node.loginName - ..port = node.port - ..isFailover = node.isFailover - ..netOption = netOption - ..forceNoTor = node.forceNoTor; - nodeFormData.password = await node.getPassword( - ref.read(secureStoreProvider), - ); - if (context.mounted) { final testPassed = await ref.read(testNodeConnectionProvider)( @@ -316,6 +310,14 @@ class _NodeDetailsViewState extends ConsumerState { ); if (testPassed) { + if (!editing) { + final saved = await service.saveTrustedCertificate( + node, + nodeFormData, + ); + if (saved) await notify(); + } + if (mounted) setState(() {}); if (context.mounted) { unawaited( showFloatingFlushBar( @@ -337,62 +339,58 @@ class _NodeDetailsViewState extends ConsumerState { } } } - }, + }), ), ), if (isDesktop) const SizedBox(width: 16), if (isDesktop) Expanded( - child: - // !nodeId.startsWith(DefaultNodes.defaultNodeIdPrefix) - // ? - PrimaryButton( - label: _desktopReadOnly ? "Edit" : "Save", - buttonHeight: ButtonHeight.l, - onPressed: () async { - final shouldSave = _desktopReadOnly == false; - setState(() { - _desktopReadOnly = !_desktopReadOnly; - }); - - if (shouldSave) { - final editedNode = node!.copyWith( - host: ref.read(nodeFormDataProvider).host, - port: ref.read(nodeFormDataProvider).port, - name: ref.read(nodeFormDataProvider).name, - useSSL: ref.read(nodeFormDataProvider).useSSL, - trusted: ref.read(nodeFormDataProvider).trusted, - loginName: ref.read(nodeFormDataProvider).login, - isFailover: ref - .read(nodeFormDataProvider) - .isFailover, - torEnabled: - ref.read(nodeFormDataProvider).netOption == - TorPlainNetworkOption.tor || - ref.read(nodeFormDataProvider).netOption == - TorPlainNetworkOption.both, - clearnetEnabled: - ref.read(nodeFormDataProvider).netOption == - TorPlainNetworkOption.clear || - ref.read(nodeFormDataProvider).netOption == - TorPlainNetworkOption.both, - forceNoTor: ref - .read(nodeFormDataProvider) - .forceNoTor, - ); + child: PrimaryButton( + label: _desktopReadOnly ? "Edit" : "Save", + enabled: !_busy, + buttonHeight: ButtonHeight.l, + onPressed: _whileIdle(() async { + if (_desktopReadOnly) { + setState(() => _desktopReadOnly = false); + return; + } + final service = ref.read( + nodeServiceChangeNotifierProvider, + ); + final notify = nodeChangeNotifier(ref, coin); + final editedNode = node!.copyWith( + host: _form.host, + port: _form.port, + name: _form.name, + useSSL: _form.useSSL, + trusted: _form.trusted, + trustedCertificatePem: _form.trustedCertificatePem, + clearTrustedCertificate: + _form.trustedCertificatePem == null, + loginName: _form.login, + isFailover: _form.isFailover, + torEnabled: + _form.netOption == TorPlainNetworkOption.tor || + _form.netOption == TorPlainNetworkOption.both, + clearnetEnabled: + _form.netOption == TorPlainNetworkOption.clear || + _form.netOption == TorPlainNetworkOption.both, + forceNoTor: _form.forceNoTor, + ); - await ref - .read(nodeServiceChangeNotifierProvider) - .save( - editedNode, - ref.read(nodeFormDataProvider).password, - true, - ); - await _notifyWalletsOfUpdatedNode(); - } - }, - ), - // : Container() + if (!await service.saveEdit( + editedNode, + _form.password, + true, + opened: _form.source!, + forgetCertificate: _form.certificateForgotten, + )) { + return; + } + await notify(); + if (mounted) setState(() => _desktopReadOnly = true); + }), + ), ), ], ), diff --git a/lib/services/node_service.dart b/lib/services/node_service.dart index 6a83299119..24339807f5 100644 --- a/lib/services/node_service.dart +++ b/lib/services/node_service.dart @@ -16,6 +16,7 @@ import 'package:http/http.dart'; import '../app_config.dart'; import '../db/hive/db.dart'; import '../models/epicbox_server_model.dart'; +import '../models/node_form_data.dart'; import '../models/node_model.dart'; import '../utilities/default_epicboxes.dart'; import '../utilities/default_nodes.dart'; @@ -67,11 +68,7 @@ class NodeService extends ChangeNotifier { isPrimary: false, ); - await DB.instance.put( - boxName: DB.boxNameNodeModels, - key: _id, - value: node, - ); + await _writeNode(_id, (current) => current == null ? node : null); } } } @@ -79,38 +76,33 @@ class NodeService extends ChangeNotifier { for (final defaultNode in AppConfig.coins.map( (e) => e.defaultNode(isPrimary: true), )) { - final savedNode = DB.instance.get( - boxName: DB.boxNameNodeModels, - key: defaultNode.id, - ); - if (savedNode == null) { - // save the default node to hive only if no other nodes for the specific coin exist - if (getNodesFor( - AppConfig.getCryptoCurrencyByPrettyName(defaultNode.coinName), - ).isEmpty) { - await DB.instance.put( - boxName: DB.boxNameNodeModels, - key: defaultNode.id, - value: defaultNode, - ); + await _writeNode(defaultNode.id, (savedNode) { + if (savedNode == null) { + return getNodesFor( + AppConfig.getCryptoCurrencyByPrettyName(defaultNode.coinName), + ).isEmpty + ? defaultNode + : null; } - } else { - // update all fields but copy over previously set enabled and trusted states - await DB.instance.put( - boxName: DB.boxNameNodeModels, - key: savedNode.id, - value: defaultNode.copyWith( - enabled: savedNode.enabled, - isFailover: savedNode.isFailover, - trusted: savedNode.trusted, - torEnabled: savedNode.torEnabled, - clearnetEnabled: savedNode.clearnetEnabled, - loginName: savedNode.loginName, - isPrimary: savedNode.isPrimary, - forceNoTor: savedNode.forceNoTor, - ), + return defaultNode.copyWith( + enabled: savedNode.enabled, + isFailover: savedNode.isFailover, + trusted: savedNode.trusted, + torEnabled: savedNode.torEnabled, + clearnetEnabled: savedNode.clearnetEnabled, + loginName: savedNode.loginName, + isPrimary: savedNode.isPrimary, + forceNoTor: savedNode.forceNoTor, + trustedCertificatePem: + savedNode.sameEndpoint( + host: defaultNode.host, + port: defaultNode.port, + useSSL: defaultNode.useSSL, + ) + ? savedNode.trustedCertificatePem + : null, ); - } + }); } } @@ -119,40 +111,48 @@ class NodeService extends ChangeNotifier { required NodeModel node, bool shouldNotifyListeners = false, }) async { - // current - final currentPrimaries = primaryNodes.where( - (e) => e.coinName == coin.identifier && e.id != node.id, - ); - final List toStore = []; - for (final node in currentPrimaries) { - final updated = node.copyWith( - loginName: node.loginName, - trusted: node.trusted, - isPrimary: false, + await DB.instance.mutex.protect(() async { + await _promote(getNodeById(id: node.id) ?? node); + }); + if (shouldNotifyListeners) notifyListeners(); + } + + // The caller holds DB's write lock; only primary flags change. + Future _promote(NodeModel node) async { + final box = DB.instance.hive.box(DB.boxNameNodeModels); + for (final other in primaryNodes.where( + (other) => other.coinName == node.coinName && other.id != node.id, + )) { + await box.put( + other.id, + other.copyWith( + loginName: other.loginName, + trusted: other.trusted, + isPrimary: false, + ), ); - toStore.add(updated); } - toStore.add( + await box.put( + node.id, node.copyWith( loginName: node.loginName, trusted: node.trusted, isPrimary: true, ), ); - - for (final node in toStore) { - await DB.instance.put( - boxName: DB.boxNameNodeModels, - key: node.id, - value: node, - ); - } - - if (shouldNotifyListeners) { - notifyListeners(); - } } + // Normal Hive boxes cache immutable NodeModels. Identity detects any write, + // including Forget of an empty approval, without another persisted revision. + Future _writeNode(String id, NodeModel? Function(NodeModel?) next) => + DB.instance.mutex.protect(() async { + final box = DB.instance.hive.box(DB.boxNameNodeModels); + final node = next(box.get(id)); + if (node == null) return false; + await box.put(id, node); + return true; + }); + NodeModel? getPrimaryNodeFor({required CryptoCurrency currency}) { return primaryNodes .where((e) => e.coinName == currency.identifier) @@ -198,9 +198,9 @@ class NodeService extends ChangeNotifier { } List failoverNodesFor({required CryptoCurrency currency}) { - return getNodesFor( - currency, - ).where((e) => e.isFailover && !e.isDown).toList(); + return getNodesFor(currency) + .where((e) => e.isFailover && !e.isDown) + .toList(); } /// Adds a new node to the database. @@ -218,20 +218,145 @@ class NodeService extends ChangeNotifier { value: node, ); - // Save password to secure storage. - if (password != null) { - await secureStorageInterface.write( - key: "${node.id}_nodePW", - value: password, - ); - } else { - await secureStorageInterface.delete(key: "${node.id}_nodePW"); - } + await _savePassword(node.id, password); if (shouldNotifyListeners) { notifyListeners(); } } + Future _savePassword(String id, String? password) async { + if (password == null) { + await secureStorageInterface.delete(key: "${id}_nodePW"); + } else { + await secureStorageInterface.write(key: "${id}_nodePW", value: password); + } + } + + Future saveEdit( + NodeModel node, + String? password, + bool notify, { + required NodeModel opened, + bool forgetCertificate = false, + }) async { + if (getNodeById(id: node.id) == null) return false; + await _savePassword(node.id, password); + final saved = await _writeNode(node.id, (current) { + if (current == null) return null; + final pem = identical(current, opened) || forgetCertificate + ? node.trustedCertificatePem + : node.sameEndpoint( + host: current.host, + port: current.port, + useSSL: current.useSSL, + ) + ? current.trustedCertificatePem + : null; + return node.copyWith( + loginName: node.loginName, + trusted: node.trusted, + isPrimary: current.isPrimary, + trustedCertificatePem: pem, + clearTrustedCertificate: pem == null, + ); + }); + if (saved && notify) notifyListeners(); + return saved; + } + + bool prepareCertificateTest(NodeFormData data) { + final source = data.source; + if (source == null) return true; + final current = getNodeById(id: source.id); + if (current == null) return false; + if (!identical(source, current)) { + if (!data.certificateForgotten) { + data.trustedCertificatePem = data.matchesEndpointOf(current) + ? current.trustedCertificatePem + : null; + } + data.source = current; + } + return true; + } + + Future saveTrustedCertificate(NodeModel node, NodeFormData data) async { + final pem = data.trustedCertificatePem; + final saved = await _writeNode( + node.id, + (current) => + identical(current, node) && + pem != null && + data.matchesEndpointOf(current!) && + current.trustedCertificatePem != pem + ? current.copyWith( + loginName: current.loginName, + trusted: current.trusted, + trustedCertificatePem: pem, + ) + : null, + ); + if (saved) notifyListeners(); + return saved; + } + + Future forgetTrustedCertificate(NodeModel shown) async { + final saved = await _writeNode( + shown.id, + (current) => + current != null && + shown.sameEndpoint( + host: current.host, + port: current.port, + useSSL: current.useSSL, + ) + ? current.copyWith( + loginName: current.loginName, + trusted: current.trusted, + clearTrustedCertificate: true, + ) + : null, + ); + if (saved) notifyListeners(); + return saved; + } + + Future commitConnectionTest({ + required NodeModel node, + required NodeFormData formData, + }) async { + final saved = await DB.instance.mutex.protect(() async { + if (!identical(getNodeById(id: node.id), node)) return false; + final pem = formData.matchesEndpointOf(node) + ? formData.trustedCertificatePem + : node.trustedCertificatePem; + await _promote( + node.copyWith( + loginName: node.loginName, + trusted: node.trusted, + trustedCertificatePem: pem, + clearTrustedCertificate: pem == null, + ), + ); + return true; + }); + if (saved) notifyListeners(); + return saved; + } + + Future setFailover(String id, bool value) async { + final saved = await _writeNode( + id, + (node) => node?.copyWith( + loginName: node.loginName, + trusted: node.trusted, + isFailover: value, + ), + ); + if (saved) notifyListeners(); + return saved; + } + Future delete(String id, bool shouldNotifyListeners) async { final nodeToDelete = getNodeById(id: id); @@ -271,14 +396,9 @@ class NodeService extends ChangeNotifier { bool enabled, bool shouldNotifyListeners, ) async { - final model = DB.instance.get( - boxName: DB.boxNameNodeModels, - key: id, - )!; - await DB.instance.put( - boxName: DB.boxNameNodeModels, - key: model.id, - value: model.copyWith( + await _writeNode( + id, + (model) => model?.copyWith( enabled: enabled, loginName: model.loginName, trusted: model.trusted, diff --git a/lib/services/notifications_service.dart b/lib/services/notifications_service.dart index fa7575d314..411d7a532f 100644 --- a/lib/services/notifications_service.dart +++ b/lib/services/notifications_service.dart @@ -156,11 +156,12 @@ class NotificationsService extends ChangeNotifier { name: node.name, id: node.id, useSSL: node.useSSL, + trustedCertificatePem: node.trustedCertificatePem, torEnabled: node.torEnabled, clearnetEnabled: node.clearnetEnabled, ); - final failovers = nodeService - .failoverNodesFor(currency: coin) + final fallbackNodes = nodeService.failoverNodesFor(currency: coin); + final failovers = fallbackNodes .map( (e) => ElectrumXNode( address: e.host, @@ -168,6 +169,7 @@ class NotificationsService extends ChangeNotifier { name: e.name, id: e.id, useSSL: e.useSSL, + trustedCertificatePem: e.trustedCertificatePem, torEnabled: node.torEnabled, clearnetEnabled: node.clearnetEnabled, ), @@ -180,7 +182,28 @@ class NotificationsService extends ChangeNotifier { prefs: prefs, cryptoCurrency: wallet.cryptoCurrency, ); - final tx = await client.getTransaction(txHash: txid); + // This client is not owned by a loaded wallet. Retire its pending + // setup too when an approval or node changes. + void retire() { + if (!identical( + nodeService.getPrimaryNodeFor(currency: coin), + node, + ) || + !listEquals( + nodeService.failoverNodesFor(currency: coin), + fallbackNodes, + )) { + unawaited(client.closeAdapter()); + } + } + + nodeService.addListener(retire); + late final Map tx; + try { + tx = await client.getTransaction(txHash: txid); + } finally { + nodeService.removeListener(retire); + } int confirmations = tx["confirmations"] as int? ?? 0; diff --git a/lib/utilities/connection_check/electrum_connection_check.dart b/lib/utilities/connection_check/electrum_connection_check.dart index 8d845aa01c..6af1a9293a 100644 --- a/lib/utilities/connection_check/electrum_connection_check.dart +++ b/lib/utilities/connection_check/electrum_connection_check.dart @@ -1,19 +1,83 @@ +import 'dart:async'; import 'dart:io'; import 'package:electrum_adapter/electrum_adapter.dart'; import '../../app_config.dart'; +import '../../models/node_form_data.dart'; import '../../services/event_bus/events/global/tor_connection_status_changed_event.dart'; import '../../services/tor_service.dart'; import '../logger.dart'; import '../prefs.dart'; +import '../tls_certificate.dart'; -Future checkElectrumServer({ +/// The outcome of one connection attempt. [certificate] is the server's TLS +/// certificate when the attempt failed because that certificate was rejected. +class ElectrumConnectionResult { + const ElectrumConnectionResult({ + required this.success, + this.certificate, + this.error, + }); + + final bool success; + final X509Certificate? certificate; + final Object? error; +} + +/// Connects, sends server.version and disconnects. A certificate the +/// platform rejects is accepted only when it is the approved one; any other +/// is returned for review without being accepted. +Future testElectrumConnection({ + required String host, + required int port, + required bool useSSL, + required Duration timeout, + String? trustedCertificatePem, + ({InternetAddress host, int port})? proxyInfo, +}) async { + ElectrumClient? client; + X509Certificate? rejected; + try { + final trusted = useSSL + ? trustedCertificateCheck(trustedCertificatePem) + : null; + client = await ElectrumClient.connect( + host: host, + port: port, + useSSL: useSSL, + proxyInfo: proxyInfo, + connectionTimeout: timeout, + connectionDeadline: timeout, + requestTimeout: timeout, + acceptUnverified: false, + onBadCertificate: (certificate) { + if (trusted != null && trusted(certificate)) return true; + rejected = certificate; + return false; + }, + ); + await client.request('server.version'); + return const ElectrumConnectionResult(success: true); + } catch (error) { + return ElectrumConnectionResult( + success: false, + certificate: rejected, + error: error, + ); + } finally { + await client?.close(); + } +} + +/// Applies the Tor preference, then runs [testElectrumConnection]. +Future checkElectrumServer({ required String host, required int port, required bool useSSL, Prefs? overridePrefs, TorService? overrideTorService, + String? trustedCertificatePem, }) async { final _prefs = overridePrefs ?? Prefs.instance; @@ -43,27 +107,75 @@ Future checkElectrumServer({ } } - final client = - await ElectrumClient.connect( - host: host, - port: port, - useSSL: useSSL && !host.endsWith('.onion'), - proxyInfo: proxyInfo, - acceptUnverified: false, - ).timeout( - Duration(seconds: (proxyInfo == null ? 5 : 30)), - onTimeout: () => throw Exception( - "The checkElectrumServer connect() call timed out.", - ), - ); - - await client - .request('server.version') - .timeout(Duration(seconds: (proxyInfo == null ? 5 : 30))); - - return true; + return await testElectrumConnection( + host: host, + port: port, + useSSL: useSSL, + trustedCertificatePem: trustedCertificatePem, + proxyInfo: proxyInfo, + timeout: Duration(seconds: proxyInfo == null ? 5 : 30), + ); } catch (e, s) { Logging.instance.e("$e\n$s", error: e, stackTrace: s); - return false; + return ElectrumConnectionResult(success: false, error: e); + } +} + +typedef ElectrumNodeTester = Future Function({ + required String host, + required int port, + required bool useSSL, + String? trustedCertificatePem, +}); + +/// Tests the node in [data]. A rejected certificate is offered for review +/// through [requestTrust]; after approval the test is retried with it, and +/// only a successful retry keeps the approval in the form. The UI supplies +/// the approval dialog. +Future testElectrumNodeConnection( + NodeFormData data, { + required ElectrumNodeTester testConnection, + required Future Function(X509Certificate, String, int) requestTrust, + required bool Function() isActive, + void Function(NodeFormData)? onSuccess, +}) async { + final host = data.host!; + final port = data.port!; + final useSSL = data.useSSL!; + final netOption = data.netOption; + final certificatePem = data.trustedCertificatePem; + final decision = data.certificateRevision; + bool unchanged() => + isActive() && + data.host == host && + data.port == port && + data.useSSL == useSSL && + data.netOption == netOption && + data.certificateRevision == decision && + data.trustedCertificatePem == certificatePem; + + var response = await testConnection( + host: host, + port: port, + useSSL: useSSL, + trustedCertificatePem: certificatePem, + ); + if (!unchanged()) return false; + final certificate = response.certificate; + if (certificate != null) { + final shouldTrust = await requestTrust(certificate, host, port); + if (!shouldTrust || !unchanged()) return false; + response = await testConnection( + host: host, + port: port, + useSSL: useSSL, + trustedCertificatePem: certificate.pem, + ); + if (!unchanged()) return false; + if (response.success) { + data.trustedCertificatePem = certificate.pem; + } } + if (response.success) onSuccess?.call(data); + return response.success; } diff --git a/lib/utilities/node_change_notifier.dart b/lib/utilities/node_change_notifier.dart new file mode 100644 index 0000000000..041320d6f3 --- /dev/null +++ b/lib/utilities/node_change_notifier.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../electrumx_rpc/client_manager.dart'; +import '../providers/global/active_wallet_provider.dart'; +import '../providers/providers.dart'; +import '../wallets/crypto_currency/crypto_currency.dart'; +import 'enums/sync_type_enum.dart'; + +/// Captures providers while the view is alive, then notifies the wallets +/// still loaded when the returned callback runs. +Future Function() nodeChangeNotifier(WidgetRef ref, CryptoCurrency coin) { + final wallets = ref.read(pWallets); + final prefs = ref.read(prefsChangeNotifierProvider); + final activeWallet = ref.read(currentWalletIdProvider.notifier); + + return () async { + // Retire the coin's previous connection even when its wallet was removed + // and none of the loaded wallets ever used that connection. + final (removed, _) = await ClientManager.sharedInstance.remove( + cryptoCurrency: coin, + ); + await removed?.close(); + for (final wallet in wallets.wallets.where((e) => e.info.coin == coin)) { + final shouldRefresh = switch (prefs.syncType) { + SyncingType.currentWalletOnly => activeWallet.state == wallet.walletId, + SyncingType.selectedWalletsAtStartup => + prefs.walletIdsSyncOnStartup.contains(wallet.walletId), + SyncingType.allWalletsOnStartup => true, + }; + final update = wallet.updateNode(); + unawaited(shouldRefresh ? update.then((_) => wallet.refresh()) : update); + } + }; +} diff --git a/lib/utilities/test_monero_node_connection.dart b/lib/utilities/test_monero_node_connection.dart index 6a4fbfa455..8f544d3f71 100644 --- a/lib/utilities/test_monero_node_connection.dart +++ b/lib/utilities/test_monero_node_connection.dart @@ -30,12 +30,7 @@ class MoneroNodeConnectionResponse { final int? port; final bool success; - MoneroNodeConnectionResponse( - this.cert, - this.url, - this.port, - this.success, - ); + MoneroNodeConnectionResponse(this.cert, this.url, this.port, this.success); } Future testMoneroNodeConnection( @@ -43,10 +38,7 @@ Future testMoneroNodeConnection( String? username, String? password, bool allowBadX509Certificate, { - required ({ - InternetAddress host, - int port, - })? proxyInfo, + required ({InternetAddress host, int port})? proxyInfo, }) async { if (uri.host.endsWith(".onion")) { if (proxyInfo == null) { @@ -80,8 +72,9 @@ Future testMoneroNodeConnection( // node asking us for authentication, but we don't have any crendentials. return MoneroNodeConnectionResponse(null, null, null, false); } - authenticateHeaderValue = - line.replaceFirst('WWW-authenticate: ', '').trim(); + authenticateHeaderValue = line + .replaceFirst('WWW-authenticate: ', '') + .trim(); } } // header to authenticate was present, we need to remake the request with digest @@ -91,8 +84,12 @@ Future testMoneroNodeConnection( // generate the Authorization header for the second request. final authHeader = digestAuth.getAuthString('POST', uri.path); - final rawRequestAuthenticated = - DaemonRpc.rawRequestRpc(uri, 'get_info', {}, authHeader); + final rawRequestAuthenticated = DaemonRpc.rawRequestRpc( + uri, + 'get_info', + {}, + authHeader, + ); // resend with an authenticated request response = await socket.send(rawRequestAuthenticated); } @@ -103,7 +100,7 @@ Future testMoneroNodeConnection( return MoneroNodeConnectionResponse(null, null, null, success); } catch (e, s) { - Logging.instance.w("$e\n$s", error: e, stackTrace: s,); + Logging.instance.w("$e\n$s", error: e, stackTrace: s); return MoneroNodeConnectionResponse(null, null, null, false); } finally { await socket?.close(); @@ -114,10 +111,7 @@ Future testMoneroNodeConnection( try { if (proxyInfo != null) { SocksTCPClient.assignToHttpClient(httpClient, [ - ProxySettings( - proxyInfo.host, - proxyInfo.port, - ), + ProxySettings(proxyInfo.host, proxyInfo.port), ]); } @@ -127,8 +121,12 @@ Future testMoneroNodeConnection( } if (badCertResponse == null) { - badCertResponse = - MoneroNodeConnectionResponse(cert, url, port, false); + badCertResponse = MoneroNodeConnectionResponse( + cert, + url, + port, + false, + ); } else { return false; } @@ -150,7 +148,7 @@ Future testMoneroNodeConnection( if (badCertResponse != null) { return badCertResponse!; } else { - Logging.instance.w("$e\n$s", error: e, stackTrace: s,); + Logging.instance.w("$e\n$s", error: e, stackTrace: s); return MoneroNodeConnectionResponse(null, null, null, false); } } finally { @@ -204,7 +202,7 @@ Future showBadX509CertificateDialog( extension on SOCKSSocket { /// write the raw request to the socket and return the response as String Future send(String rawRequest) async { - write(rawRequest); + await write(rawRequest); final buffer = StringBuffer(); await for (final response in inputStream) { buffer.write(utf8.decode(response)); diff --git a/lib/utilities/test_node_connection.dart b/lib/utilities/test_node_connection.dart index 80068fce59..811e74664d 100644 --- a/lib/utilities/test_node_connection.dart +++ b/lib/utilities/test_node_connection.dart @@ -11,6 +11,7 @@ import '../app_config.dart'; import '../networking/http.dart'; import '../pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart'; import '../providers/global/prefs_provider.dart'; +import '../providers/global/node_service_provider.dart'; import '../services/tor_service.dart'; import '../wallets/api/cardano/blockfrost_http_provider.dart'; import '../wallets/api/tezos/tezos_rpc_api.dart'; @@ -20,6 +21,7 @@ import '../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; import '../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../wallets/wallet/impl/solana_wallet.dart'; import '../wl_gen/interfaces/lib_xelis_interface.dart'; +import '../widgets/tls_certificate_dialog.dart' as electrum_tls; import 'connection_check/electrum_connection_check.dart'; import 'logger.dart'; import 'test_epic_box_connection.dart'; @@ -220,12 +222,40 @@ Future testNodeConnection({ case ElectrumXCurrencyInterface(): case BitcoinFrost(): try { - testPassed = await checkElectrumServer( - host: formData.host!, - port: formData.port!, - useSSL: formData.useSSL!, - overridePrefs: read(prefsChangeNotifierProvider), - overrideTorService: read(pTorService), + if (!read(nodeServiceChangeNotifierProvider) + .prepareCertificateTest(formData)) { + return false; + } + final prefs = read(prefsChangeNotifierProvider); + final torService = read(pTorService); + final useTor = prefs.useTor; + final torStatus = AppConfig.hasFeature(AppFeature.tor) && useTor + ? torService.status + : null; + testPassed = await testElectrumNodeConnection( + formData, + isActive: () => + context.mounted && + ModalRoute.of(context)?.isCurrent != false && + prefs.useTor == useTor && + (torStatus == null || torService.status == torStatus), + onSuccess: onSuccess, + requestTrust: (certificate, host, port) => electrum_tls + .showBadX509CertificateDialog(certificate, host, port, context), + testConnection: + ({ + required host, + required port, + required useSSL, + trustedCertificatePem, + }) => checkElectrumServer( + host: host, + port: port, + useSSL: useSSL, + trustedCertificatePem: trustedCertificatePem, + overridePrefs: prefs, + overrideTorService: torService, + ), ); } catch (_) { testPassed = false; diff --git a/lib/utilities/tls_certificate.dart b/lib/utilities/tls_certificate.dart new file mode 100644 index 0000000000..cfdd4a4ab6 --- /dev/null +++ b/lib/utilities/tls_certificate.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +Uint8List certificateDer(String pem) { + final match = RegExp( + r'^\s*-----BEGIN CERTIFICATE-----\s*([A-Za-z0-9+/=\s]+)-----END CERTIFICATE-----\s*$', + ).firstMatch(pem); + if (match == null || match.end != pem.length) { + throw const FormatException('Expected one PEM certificate'); + } + return base64Decode(match.group(1)!.replaceAll(RegExp(r'\s'), '')); +} + +String certificateFingerprint(List der) { + final hex = sha256.convert(der).toString().toUpperCase(); + return [for (int i = 0; i < hex.length; i += 2) hex.substring(i, i + 2)] + .join(':'); +} + +String? certificatePemFingerprint(String pem) { + try { + return certificateFingerprint(certificateDer(pem)); + } on FormatException { + return null; + } +} + +/// The check a TLS connection runs when the platform rejects the server's +/// certificate. It accepts exactly the certificate approved for the node, +/// whatever its hostname or validity, because the approval pins that +/// certificate. Null when nothing is approved, so the platform's verdict +/// stands. Throws a [FormatException] for a damaged PEM before connecting. +bool Function(X509Certificate)? trustedCertificateCheck( + String? certificatePem, +) { + if (certificatePem == null) return null; + final trusted = certificateFingerprint(certificateDer(certificatePem)); + return (certificate) => certificateFingerprint(certificate.der) == trusted; +} diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index b499b82886..1430b3d696 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -1292,6 +1292,7 @@ class BitcoinFrostWallet extends Wallet port: node.port, name: node.name, useSSL: node.useSSL, + trustedCertificatePem: node.trustedCertificatePem, id: node.id, torEnabled: node.torEnabled, clearnetEnabled: node.clearnetEnabled, @@ -1309,6 +1310,7 @@ class BitcoinFrostWallet extends Wallet name: e.name, id: e.id, useSSL: e.useSSL, + trustedCertificatePem: e.trustedCertificatePem, torEnabled: e.torEnabled, clearnetEnabled: e.clearnetEnabled, ), diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 15ecc7db61..818013e03e 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -1094,6 +1094,7 @@ mixin ElectrumXInterface port: node.port, name: node.name, useSSL: node.useSSL, + trustedCertificatePem: node.trustedCertificatePem, id: node.id, torEnabled: node.torEnabled, clearnetEnabled: node.clearnetEnabled, @@ -1110,6 +1111,7 @@ mixin ElectrumXInterface name: e.name, id: e.id, useSSL: e.useSSL, + trustedCertificatePem: e.trustedCertificatePem, torEnabled: e.torEnabled, clearnetEnabled: e.clearnetEnabled, ), diff --git a/lib/widgets/node_card.dart b/lib/widgets/node_card.dart index 7d7a829698..35c31c3c96 100644 --- a/lib/widgets/node_card.dart +++ b/lib/widgets/node_card.dart @@ -15,20 +15,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:tuple/tuple.dart'; +import '../models/node_form_data.dart'; import '../notifications/show_flush_bar.dart'; -import '../pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart'; import '../pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart'; -import '../providers/global/active_wallet_provider.dart'; import '../providers/global/secure_store_provider.dart'; import '../providers/providers.dart'; import '../themes/stack_colors.dart'; import '../utilities/assets.dart'; import '../utilities/constants.dart'; import '../utilities/default_nodes.dart'; -import '../utilities/enums/sync_type_enum.dart'; import '../utilities/test_node_connection.dart'; +import '../utilities/node_change_notifier.dart'; import '../utilities/text_styles.dart'; -import '../utilities/tor_plain_net_option_enum.dart'; import '../utilities/util.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; import 'conditional_parent.dart'; @@ -58,41 +56,6 @@ class _NodeCardState extends ConsumerState { late final String nodeId; bool _advancedIsExpanded = false; - Future _notifyWalletsOfUpdatedNode(WidgetRef ref) async { - final wallets = ref - .read(pWallets) - .wallets - .where((e) => e.info.coin == widget.coin); - final prefs = ref.read(prefsChangeNotifierProvider); - - switch (prefs.syncType) { - case SyncingType.currentWalletOnly: - for (final wallet in wallets) { - if (ref.read(currentWalletIdProvider) == wallet.walletId) { - unawaited(wallet.updateNode().then((value) => wallet.refresh())); - } else { - unawaited(wallet.updateNode()); - } - } - break; - case SyncingType.selectedWalletsAtStartup: - final List walletIdsToSync = prefs.walletIdsSyncOnStartup; - for (final wallet in wallets) { - if (walletIdsToSync.contains(wallet.walletId)) { - unawaited(wallet.updateNode().then((value) => wallet.refresh())); - } else { - unawaited(wallet.updateNode()); - } - } - break; - case SyncingType.allWalletsOnStartup: - for (final wallet in wallets) { - unawaited(wallet.updateNode().then((value) => wallet.refresh())); - } - break; - } - } - @override void initState() { nodeId = widget.nodeId; @@ -167,24 +130,13 @@ class _NodeCardState extends ConsumerState { text: "Connect", enabled: _status == "Disconnected", onTap: () async { - final TorPlainNetworkOption netOption; - if (_node.torEnabled && !_node.clearnetEnabled) { - netOption = TorPlainNetworkOption.tor; - } else if (_node.clearnetEnabled && !_node.torEnabled) { - netOption = TorPlainNetworkOption.clear; - } else { - netOption = TorPlainNetworkOption.both; - } - - final nodeFormData = NodeFormData() - ..useSSL = _node.useSSL - ..trusted = _node.trusted - ..name = _node.name - ..host = _node.host - ..login = _node.loginName - ..port = _node.port - ..netOption = netOption - ..isFailover = _node.isFailover; + // Carry the saved certificate so an approved node is + // not reviewed again, and keep a new approval below. + final service = ref.read( + nodeServiceChangeNotifierProvider, + ); + final notify = nodeChangeNotifier(ref, widget.coin); + final nodeFormData = NodeFormData.fromNode(_node); nodeFormData.password = await _node.getPassword( ref.read(secureStoreProvider), ); @@ -211,15 +163,12 @@ class _NodeCardState extends ConsumerState { return; } - await ref - .read(nodeServiceChangeNotifierProvider) - .setPrimaryNodeFor( - coin: widget.coin, - node: _node, - shouldNotifyListeners: true, - ); - - await _notifyWalletsOfUpdatedNode(ref); + if (await service.commitConnectionTest( + node: _node, + formData: nodeFormData, + )) { + await notify(); + } } }, ), @@ -251,9 +200,9 @@ class _NodeCardState extends ConsumerState { height: isDesktop ? 40 : 24, decoration: BoxDecoration( color: _node.id.startsWith(DefaultNodes.defaultNodeIdPrefix) - ? Theme.of( - context, - ).extension()!.buttonBackSecondary + ? Theme.of(context) + .extension()! + .buttonBackSecondary : Theme.of(context) .extension()! .infoItemIcons @@ -267,12 +216,12 @@ class _NodeCardState extends ConsumerState { width: isDesktop ? 20 : 14, color: _node.id.startsWith(DefaultNodes.defaultNodeIdPrefix) - ? Theme.of( - context, - ).extension()!.accentColorDark - : Theme.of( - context, - ).extension()!.infoItemIcons, + ? Theme.of(context) + .extension()! + .accentColorDark + : Theme.of(context) + .extension()! + .infoItemIcons, ), ), ), @@ -290,12 +239,12 @@ class _NodeCardState extends ConsumerState { SvgPicture.asset( Assets.svg.network, color: _status == "Connected" - ? Theme.of( - context, - ).extension()!.accentColorGreen - : Theme.of( - context, - ).extension()!.buttonBackSecondary, + ? Theme.of(context) + .extension()! + .accentColorGreen + : Theme.of(context) + .extension()! + .buttonBackSecondary, width: 20, height: 20, ), @@ -306,9 +255,9 @@ class _NodeCardState extends ConsumerState { : Assets.svg.chevronDown, width: 12, height: 6, - color: Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of(context) + .extension()! + .textSubtitle1, ), ], ), diff --git a/lib/widgets/node_options_sheet.dart b/lib/widgets/node_options_sheet.dart index 201b802cbe..45500becf8 100644 --- a/lib/widgets/node_options_sheet.dart +++ b/lib/widgets/node_options_sheet.dart @@ -8,26 +8,22 @@ * */ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:tuple/tuple.dart'; -import '../pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart'; +import '../models/node_form_data.dart'; import '../pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart'; -import '../providers/global/active_wallet_provider.dart'; import '../providers/global/secure_store_provider.dart'; import '../providers/providers.dart'; import '../themes/stack_colors.dart'; import '../utilities/assets.dart'; import '../utilities/constants.dart'; import '../utilities/default_nodes.dart'; -import '../utilities/enums/sync_type_enum.dart'; import '../utilities/test_node_connection.dart'; +import '../utilities/node_change_notifier.dart'; import '../utilities/text_styles.dart'; -import '../utilities/tor_plain_net_option_enum.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; import 'rounded_white_container.dart'; @@ -43,41 +39,6 @@ class NodeOptionsSheet extends ConsumerWidget { final CryptoCurrency coin; final String popBackToRoute; - Future _notifyWalletsOfUpdatedNode(WidgetRef ref) async { - final wallets = ref - .read(pWallets) - .wallets - .where((e) => e.info.coin == coin); - final prefs = ref.read(prefsChangeNotifierProvider); - - switch (prefs.syncType) { - case SyncingType.currentWalletOnly: - for (final wallet in wallets) { - if (ref.read(currentWalletIdProvider) == wallet.walletId) { - unawaited(wallet.updateNode().then((value) => wallet.refresh())); - } else { - unawaited(wallet.updateNode()); - } - } - break; - case SyncingType.selectedWalletsAtStartup: - final List walletIdsToSync = prefs.walletIdsSyncOnStartup; - for (final wallet in wallets) { - if (walletIdsToSync.contains(wallet.walletId)) { - unawaited(wallet.updateNode().then((value) => wallet.refresh())); - } else { - unawaited(wallet.updateNode()); - } - } - break; - case SyncingType.allWalletsOnStartup: - for (final wallet in wallets) { - unawaited(wallet.updateNode().then((value) => wallet.refresh())); - } - break; - } - } - @override Widget build(BuildContext context, WidgetRef ref) { final maxHeight = MediaQuery.of(context).size.height * 0.60; @@ -121,9 +82,9 @@ class NodeOptionsSheet extends ConsumerWidget { Center( child: Container( decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -150,9 +111,9 @@ class NodeOptionsSheet extends ConsumerWidget { node.id.startsWith( DefaultNodes.defaultNodeIdPrefix, ) - ? Theme.of( - context, - ).extension()!.textSubtitle4 + ? Theme.of(context) + .extension()! + .textSubtitle4 : Theme.of(context) .extension()! .infoItemIcons @@ -168,12 +129,12 @@ class NodeOptionsSheet extends ConsumerWidget { node.id.startsWith( DefaultNodes.defaultNodeIdPrefix, ) - ? Theme.of( - context, - ).extension()!.accentColorDark - : Theme.of( - context, - ).extension()!.infoItemIcons, + ? Theme.of(context) + .extension()! + .accentColorDark + : Theme.of(context) + .extension()! + .infoItemIcons, ), ), ), @@ -193,12 +154,12 @@ class NodeOptionsSheet extends ConsumerWidget { SvgPicture.asset( Assets.svg.network, color: status == "Connected" - ? Theme.of( - context, - ).extension()!.accentColorGreen - : Theme.of( - context, - ).extension()!.buttonBackSecondary, + ? Theme.of(context) + .extension()! + .accentColorGreen + : Theme.of(context) + .extension()! + .buttonBackSecondary, width: 18, ), ], @@ -222,9 +183,9 @@ class NodeOptionsSheet extends ConsumerWidget { child: Text( "Details", style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), ), @@ -243,50 +204,38 @@ class NodeOptionsSheet extends ConsumerWidget { onPressed: status == "Connected" ? null : () async { - final pw = await node.getPassword( + // Carry the saved certificate so an approved + // node is not reviewed again, and keep a new + // approval below. + final service = ref.read( + nodeServiceChangeNotifierProvider, + ); + final notify = nodeChangeNotifier(ref, coin); + final nodeFormData = NodeFormData.fromNode( + node, + ); + nodeFormData.password = await node.getPassword( ref.read(secureStoreProvider), ); if (context.mounted) { - final TorPlainNetworkOption netOption; - if (node.torEnabled && - !node.clearnetEnabled) { - netOption = TorPlainNetworkOption.tor; - } else if (node.clearnetEnabled && - !node.torEnabled) { - netOption = TorPlainNetworkOption.clear; - } else { - netOption = TorPlainNetworkOption.both; - } final canConnect = await ref.read( testNodeConnectionProvider, )( context: context, - nodeFormData: NodeFormData() - ..name = node.name - ..host = node.host - ..login = node.loginName - ..password = pw - ..port = node.port - ..useSSL = node.useSSL - ..isFailover = node.isFailover - ..netOption = netOption - ..trusted = node.trusted, + nodeFormData: nodeFormData, cryptoCurrency: coin, ); if (!canConnect) { return; } - await ref - .read(nodeServiceChangeNotifierProvider) - .setPrimaryNodeFor( - coin: coin, - node: node, - shouldNotifyListeners: true, - ); - - await _notifyWalletsOfUpdatedNode(ref); + if (await service.commitConnectionTest( + node: node, + formData: nodeFormData, + )) { + await notify(); + } } }, child: Text( diff --git a/lib/widgets/tls_certificate_dialog.dart b/lib/widgets/tls_certificate_dialog.dart new file mode 100644 index 0000000000..e116148bc2 --- /dev/null +++ b/lib/widgets/tls_certificate_dialog.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../utilities/tls_certificate.dart'; +import 'desktop/primary_button.dart'; +import 'desktop/secondary_button.dart'; +import 'stack_dialog.dart'; + +Future showBadX509CertificateDialog( + X509Certificate cert, + String host, + int port, + BuildContext context, +) async { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => StackDialog( + title: 'Untrusted TLS certificate', + width: 520, + message: + 'Server: $host:$port\n\n' + 'Subject: ${cert.subject}\n' + 'Issuer: ${cert.issuer}\n\n' + 'Valid from: ${cert.startValidity.toUtc()}\n' + 'Valid until: ${cert.endValidity.toUtc()}\n\n' + 'SHA-256:\n${certificateFingerprint(cert.der)}', + leftButton: SecondaryButton( + label: 'Cancel', + onPressed: () => Navigator.of(context).pop(false), + ), + rightButton: PrimaryButton( + label: 'Trust', + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ); + return result ?? false; +} diff --git a/pubspec.lock b/pubspec.lock index 0ef5e9204f..7046e5df78 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -912,11 +912,11 @@ packages: dependency: "direct main" description: path: "." - ref: "76ffad6ecc1e31de47d9187fd39c831db0aa987f" - resolved-ref: "76ffad6ecc1e31de47d9187fd39c831db0aa987f" + ref: b0d8a16c8a4d6976bd0f1f98723e222706cc567c + resolved-ref: b0d8a16c8a4d6976bd0f1f98723e222706cc567c url: "https://github.com/cypherstack/electrum_adapter.git" source: git - version: "4.0.1" + version: "5.0.0" emojis: dependency: "direct main" description: @@ -2174,10 +2174,10 @@ packages: dependency: "direct main" description: name: socks_socket - sha256: "09149ed1278584554aeacc60c7aeec5bd80154e6ce1136f00d97485d09d5a338" + sha256: "536e0876f94d878c09d0673d69d20f61894becce4476f0abd45a50a2ff387cf9" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" solana: dependency: "direct main" description: diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 6c576862ec..5c1a1907a7 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -231,7 +231,7 @@ dependencies: electrum_adapter: git: url: https://github.com/cypherstack/electrum_adapter.git - ref: 76ffad6ecc1e31de47d9187fd39c831db0aa987f + ref: b0d8a16c8a4d6976bd0f1f98723e222706cc567c # v5.0.0 stream_channel: ^2.1.0 solana: git: # TODO [prio=low]: Revert to official package once Tor support is merged upstream. diff --git a/test/client_manager_lifecycle_test.dart b/test/client_manager_lifecycle_test.dart new file mode 100644 index 0000000000..f81b6a833d --- /dev/null +++ b/test/client_manager_lifecycle_test.dart @@ -0,0 +1,301 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:electrum_adapter/electrum_adapter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:stackwallet/electrumx_rpc/client_manager.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stream_channel/stream_channel.dart'; + +class _RpcFixture { + _RpcFixture({Duration? requestTimeout}) { + final channel = StreamChannelController(); + server = channel.foreign; + serverSubscription = server.stream.listen((dynamic request) { + subscribed.complete(); + unawaited( + firstHeader.future.then((response) { + if (!closed) { + server.sink.add({ + 'jsonrpc': '2.0', + 'id': request['id'], + ...response, + }); + } + }), + ); + }); + client = ElectrumClient( + channel.local, + 'test.electrum', + 50001, + false, + null, + requestTimeout: requestTimeout, + ); + } + + late final StreamChannel server; + late final StreamSubscription serverSubscription; + late final ElectrumClient client; + bool closed = false; + final subscribed = Completer(); + final firstHeader = Completer>(); + + void reply(int height) => firstHeader.complete({ + 'result': {'height': height, 'hex': '00'}, + }); + + void reject() => firstHeader.complete({ + 'error': {'code': -32000, 'message': 'header subscription rejected'}, + }); + + void notify(Object height) => server.sink.add({ + 'jsonrpc': '2.0', + 'method': 'blockchain.headers.subscribe', + 'params': [ + {'height': height, 'hex': '00'}, + ], + }); + + Future close() async { + closed = true; + await client.close(); + await serverSubscription.cancel(); + await server.sink.close(); + } +} + +/// Allows a clean stream end before the first header, and delayed cancellation. +class _ControlledHeadersClient extends ElectrumClient { + _ControlledHeadersClient({Future Function()? onCancel}) + : headers = StreamController(onCancel: onCancel), + super( + StreamChannelController().local, + 'test.headers', + 50001, + false, + null, + ); + + final StreamController headers; + + @override + Stream subscribeNonBatch( + String methodPrefix, [ + List params = const [], + ]) => headers.stream.cast(); +} + +Future _until(bool Function() ready) async { + final watch = Stopwatch()..start(); + while (!ready()) { + if (watch.elapsed > const Duration(seconds: 2)) { + throw TimeoutException('Waiting for client lifecycle'); + } + await Future.delayed(const Duration(milliseconds: 5)); + } +} + +void main() { + final coin = Bitcoin(CryptoCurrencyNetwork.main); + final manager = ClientManager.sharedInstance; + final fixtures = <_RpcFixture>[]; + late Directory logs; + + setUpAll(() async { + logs = await Directory.systemTemp.createTemp('client_manager_lifecycle'); + await Logging.instance.initialize(logs.path, level: Level.off); + }); + tearDownAll(() => logs.delete(recursive: true)); + tearDown(() async { + await manager.closeAll(); + for (final fixture in fixtures) { + await fixture.close(); + } + fixtures.clear(); + }); + + Future<_RpcFixture> add({Duration? requestTimeout}) async { + final fixture = _RpcFixture(requestTimeout: requestTimeout); + fixtures.add(fixture); + await manager.addClient( + fixture.client, + cryptoCurrency: coin, + netType: TorPlainNetworkOption.both, + ); + await fixture.subscribed.future; + return fixture; + } + + Future expectHealthyReplacement() async { + final healthy = await add(); + healthy.reply(123); + expect(await manager.getChainHeightFor(coin), 123); + expect( + manager.getClient( + cryptoCurrency: coin, + netType: TorPlainNetworkOption.both, + ), + same(healthy.client), + ); + } + + for (final retirement in ['remove', 'replace', 'closeAll']) { + test('$retirement releases every pending first-height waiter', () async { + final original = await add(); + final waits = [ + expectLater(manager.getChainHeightFor(coin), throwsStateError), + expectLater(manager.getChainHeightFor(coin), throwsStateError), + ]; + + switch (retirement) { + case 'remove': + final removed = await manager.remove(cryptoCurrency: coin); + expect(removed.$1, same(original.client)); + await removed.$1!.close(); + await expectHealthyReplacement(); + case 'replace': + await expectHealthyReplacement(); + case 'closeAll': + await manager.closeAll(); + await expectHealthyReplacement(); + } + + await Future.wait(waits).timeout(const Duration(seconds: 2)); + expect(original.client.peer.isClosed, true); + }); + } + + test('subscription RPC rejection retires an otherwise live client', () async { + final original = await add(); + final waiting = expectLater( + manager.getChainHeightFor(coin), + throwsA( + predicate( + (error) => error.toString().contains('header subscription rejected'), + ), + ), + ); + expect(original.client.peer.isClosed, false); + original.reject(); + + await waiting.timeout(const Duration(seconds: 2)); + await _until(() => original.client.peer.isClosed); + expect( + manager.getClient( + cryptoCurrency: coin, + netType: TorPlainNetworkOption.both, + ), + isNull, + ); + await expectHealthyReplacement(); + }); + + test('subscription timeout releases its first-height waiter', () async { + final original = await add( + requestTimeout: const Duration(milliseconds: 100), + ); + await expectLater( + manager.getChainHeightFor(coin).timeout(const Duration(seconds: 2)), + throwsA( + isA().having( + (error) => error.duration, + 'adapter deadline', + const Duration(milliseconds: 100), + ), + ), + ); + await _until(() => original.client.peer.isClosed); + await expectHealthyReplacement(); + }); + + test('failure before any height consumer does not escape the zone', () async { + final original = await add(); + original.reject(); + await _until(() => original.client.peer.isClosed); + // The test runner also fails if the unobserved completer emits an error. + await expectLater(manager.getChainHeightFor(coin), throwsException); + await expectHealthyReplacement(); + }); + + test('a clean header stream end fails pending height waits', () async { + final client = _ControlledHeadersClient(); + addTearDown(client.close); + await manager.addClient( + client, + cryptoCurrency: coin, + netType: TorPlainNetworkOption.both, + ); + final waiting = expectLater( + manager.getChainHeightFor(coin), + throwsStateError, + ); + await client.headers.close(); + await waiting.timeout(const Duration(seconds: 2)); + await _until(() => client.peer.isClosed); + await expectHealthyReplacement(); + }); + + test( + 'a failed established subscription cannot return its stale height', + () async { + final original = await add(); + original.reply(10); + expect(await manager.getChainHeightFor(coin), 10); + original.notify('invalid'); + await _until(() => original.client.peer.isClosed); + await expectLater(manager.getChainHeightFor(coin), throwsException); + await expectHealthyReplacement(); + }, + ); + + test('retiring an old client cannot remove its healthy successor', () async { + final original = await add(); + final oldWait = expectLater( + manager.getChainHeightFor(coin), + throwsStateError, + ); + await expectHealthyReplacement(); + await oldWait; + final removed = await manager.remove( + cryptoCurrency: coin, + expectedClient: original.client, + ); + expect(removed, (null, null)); + expect(await manager.getChainHeightFor(coin), 123); + }); + + test( + 'shutdown finishing after replacement leaves the new client intact', + () async { + final cancellation = Completer(); + final oldClient = _ControlledHeadersClient( + onCancel: () => cancellation.future, + ); + addTearDown(() async { + if (!cancellation.isCompleted) cancellation.complete(); + await oldClient.close(); + await oldClient.headers.close(); + }); + await manager.addClient( + oldClient, + cryptoCurrency: coin, + netType: TorPlainNetworkOption.both, + ); + final oldWait = expectLater( + manager.getChainHeightFor(coin), + throwsStateError, + ); + final closing = manager.closeAll(); + await oldWait.timeout(const Duration(seconds: 2)); + await expectHealthyReplacement(); + cancellation.complete(); + await closing; + expect(await manager.getChainHeightFor(coin), 123); + }, + ); +} diff --git a/test/electrumx_deadline_test.dart b/test/electrumx_deadline_test.dart new file mode 100644 index 0000000000..4ee376387c --- /dev/null +++ b/test/electrumx_deadline_test.dart @@ -0,0 +1,208 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:event_bus/event_bus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/electrumx_rpc/client_manager.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +import 'utilities/tls_test_server.dart'; + +class _Prefs extends Mock implements Prefs { + _Prefs({this.useTor = false}); + @override + final bool useTor; + @override + bool get wifiOnly => false; + @override + bool get torKillSwitch => true; +} + +class _Tor extends Mock implements TorService { + _Tor(this.proxy); + final ({InternetAddress host, int port})? proxy; + @override + TorConnectionStatus get status => TorConnectionStatus.connected; + @override + ({InternetAddress host, int port}) getProxyInfo() => proxy!; +} + +Future _until(bool Function() ready) async { + final watch = Stopwatch()..start(); + while (!ready()) { + if (watch.elapsed > const Duration(seconds: 2)) { + throw TimeoutException('Waiting for test server'); + } + await Future.delayed(const Duration(milliseconds: 5)); + } +} + +void main() { + late Directory logDirectory; + late EventBus bus; + setUpAll(() async { + logDirectory = await Directory.systemTemp.createTemp('electrum_deadlines'); + await Logging.instance.initialize(logDirectory.path, level: Level.off); + }); + tearDownAll(() => logDirectory.delete(recursive: true)); + setUp(() => bus = EventBus()); + tearDown(() async { + await ClientManager.sharedInstance.closeAll(); + bus.destroy(); + }); + + Future start({ + bool stallHandshake = false, + Set ignoredMethods = const {}, + }) async { + final server = await TlsElectrumServer.start( + stallHandshake: stallHandshake, + ignoredMethods: ignoredMethods, + ); + addTearDown(server.close); + return server; + } + + ElectrumXClient clientFor( + TlsElectrumServer server, { + CryptoCurrency? coin, + ({InternetAddress host, int port})? proxy, + Duration connectionTimeout = const Duration(seconds: 2), + }) { + final client = ElectrumXClient( + host: proxy == null ? '127.0.0.1' : 'deadline.test.onion', + port: server.port, + useSSL: true, + trustedCertificatePem: TlsElectrumServer.certificate('node_a'), + cryptoCurrency: coin ?? Bitcoin(CryptoCurrencyNetwork.main), + prefs: _Prefs(useTor: proxy != null), + torService: _Tor(proxy), + netType: TorPlainNetworkOption.both, + globalEventBusForTesting: bus, + connectionTimeoutForSpecialCaseJsonRPCClients: connectionTimeout, + failovers: [], + ); + addTearDown(client.closeAdapter); + return client; + } + + for (final proxied in [false, true]) { + final route = proxied ? 'SOCKS TLS' : 'direct TLS'; + for (final stallHandshake in [true, false]) { + final phase = stallHandshake ? 'TLS handshake' : 'server.version'; + test('$route releases a stalled $phase', () async { + final server = await start( + stallHandshake: stallHandshake, + ignoredMethods: {'server.version'}, + ); + final proxy = proxied ? await TlsTestProxy.start(server.port) : null; + if (proxy != null) addTearDown(proxy.close); + final client = clientFor( + server, + proxy: proxy?.info, + connectionTimeout: const Duration(milliseconds: 200), + ); + + await expectLater( + client.checkElectrumAdapter().timeout(const Duration(seconds: 3)), + throwsA(isA()), + ); + expect(client.getElectrumAdapter(), isNull); + await _until(() => server.disconnections == 1); + expect(server.accepted, 1); + }); + } + } + + for (final coin in [ + Bitcoin(CryptoCurrencyNetwork.main), + Firo(CryptoCurrencyNetwork.main), + ]) { + test('${coin.runtimeType} RPC timeout closes and reconnects', () async { + final server = await start(ignoredMethods: {'probe.silent'}); + final client = clientFor(server, coin: coin); + await client.request(command: 'probe.ready'); + final original = client.getElectrumAdapter()!; + + await expectLater( + client + .request( + command: 'probe.silent', + requestTimeout: const Duration(milliseconds: 150), + ) + .timeout(const Duration(seconds: 3)), + throwsA(isA()), + ); + expect(original.peer.isClosed, true); + await _until(() => server.disconnections == 1); + expect(await client.request(command: 'probe.ready'), isEmpty); + expect(client.getElectrumAdapter(), isNot(same(original))); + expect(server.accepted, 2); + expect(server.methods.where((m) => m == 'probe.silent').length, 1); + }); + } + + test( + 'batch members use the caller timeout and close a silent peer', + () async { + final server = await start(ignoredMethods: {'probe.silent'}); + final client = clientFor(server); + await client.request(command: 'probe.ready'); + final original = client.getElectrumAdapter()!; + + await expectLater( + client + .batchRequest( + command: 'probe.silent', + args: [ + [1], + [2], + ], + requestTimeout: const Duration(milliseconds: 150), + ) + .timeout(const Duration(seconds: 3)), + throwsA(isA()), + ); + expect(original.peer.isClosed, true); + await _until(() => server.disconnections == 1); + expect(server.methods.where((m) => m == 'probe.silent').length, 2); + expect(await client.request(command: 'probe.ready'), isEmpty); + }, + ); + + test('batch replies preserve the request order and arguments', () async { + final server = await start(); + final client = clientFor(server); + expect( + await client.batchRequest( + command: 'blockchain.transaction.get', + args: [ + ['first', true], + ['second', true], + ], + ), + [ + {'txid': 'first', 'confirmations': 3}, + {'txid': 'second', 'confirmations': 3}, + ], + ); + }); + + test('an empty batch sends no requests and opens no connection', () async { + final server = await start(); + final client = clientFor(server); + expect( + await client.batchRequest(command: 'probe.empty', args: []), + isEmpty, + ); + expect(server.accepted, 0); + }); +} diff --git a/test/electrumx_duplicate_endpoint_test.dart b/test/electrumx_duplicate_endpoint_test.dart new file mode 100644 index 0000000000..d03b73a68e --- /dev/null +++ b/test/electrumx_duplicate_endpoint_test.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:event_bus/event_bus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/electrumx_rpc/client_manager.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +import 'utilities/tls_test_server.dart'; + +class _Prefs extends Mock implements Prefs { + @override + bool get useTor => false; + @override + bool get wifiOnly => false; +} + +class _Tor extends Mock implements TorService {} + +void main() { + test('repeated requests can keep an approved duplicate-endpoint failover', () async { + final logs = await Directory.systemTemp.createTemp('duplicate_endpoint'); + await Logging.instance.initialize(logs.path, level: Level.off); + final listener = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final sockets = []; + int accepted = 0; + addTearDown(() async { + await ClientManager.sharedInstance.closeAll(); + for (final s in sockets) { + s.destroy(); + } + await listener.close(); + await logs.delete(recursive: true); + }); + listener.listen((raw) async { + final first = ++accepted == 1; + sockets.add(raw); + raw.done.ignore(); + final name = first ? 'node_a' : 'node_b'; + final context = SecurityContext() + ..useCertificateChainBytes( + utf8.encode(TlsElectrumServer.certificate(name)), + ) + ..usePrivateKeyBytes( + File('test/fixtures/tls/$name.key.pem').readAsBytesSync(), + ); + try { + final socket = await SecureSocket.secureServer(raw, context); + sockets.add(socket); + socket.done.ignore(); + socket + .cast>() + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + final request = jsonDecode(line) as Map; + final method = request['method']; + final Object? result = switch (method) { + 'server.version' => ['test', '1.4'], + 'blockchain.headers.subscribe' => { + 'height': 100, + 'hex': '00' * 80, + }, + 'probe' => + first ? {'error': 'retry at failover'} : {'via': 'B'}, + _ => null, + }; + socket.write( + '${jsonEncode({'jsonrpc': '2.0', 'id': request['id'], 'result': result})}\n', + ); + }, onError: (Object _) {}); + } catch (_) { + raw.destroy(); + } + }); + final bus = EventBus(); + addTearDown(bus.destroy); + final connection = ElectrumXClient( + host: '127.0.0.1', + port: listener.port, + useSSL: true, + prefs: _Prefs(), + netType: TorPlainNetworkOption.both, + cryptoCurrency: Bitcoin(CryptoCurrencyNetwork.main), + torService: _Tor(), + globalEventBusForTesting: bus, + trustedCertificatePem: TlsElectrumServer.certificate('node_a'), + connectionTimeoutForSpecialCaseJsonRPCClients: const Duration(seconds: 2), + failovers: [ + ElectrumXNode( + address: '127.0.0.1', + port: listener.port, + name: 'B', + id: 'B', + useSSL: true, + torEnabled: true, + clearnetEnabled: true, + trustedCertificatePem: TlsElectrumServer.certificate('node_b'), + ), + ], + ); + addTearDown(connection.closeAdapter); + expect(await connection.request(command: 'probe'), {'via': 'B'}); + expect(connection.currentFailoverIndex, -1); + final selected = connection.getElectrumAdapter(); + final next = await connection.ping().then( + (value) => value, + onError: (Object e) => e, + ); + expect(next, true); + expect(connection.getElectrumAdapter(), same(selected)); + }); +} diff --git a/test/electrumx_failover_test.dart b/test/electrumx_failover_test.dart new file mode 100644 index 0000000000..1ce1008298 --- /dev/null +++ b/test/electrumx_failover_test.dart @@ -0,0 +1,492 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:event_bus/event_bus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/electrumx_rpc/client_manager.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +import 'utilities/tls_test_server.dart'; + +class _Prefs extends Mock implements Prefs { + @override + bool get useTor => false; + @override + bool get wifiOnly => false; + @override + bool get torKillSwitch => false; +} + +class _Tor extends Mock implements TorService {} + +class _RpcServer { + _RpcServer(this.server); + final ServerSocket server; + final sockets = []; + final ready = Completer(); + int pending = 0; + int requests = 0; + void Function()? fail; + int get port => server.port; + + static Future<_RpcServer> start({ + required bool batch, + bool holdRequests = true, + }) async { + final primary = _RpcServer( + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0), + ); + primary.server.listen((socket) { + primary.sockets.add(socket); + socket.done.ignore(); + socket + .cast>() + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + final decoded = jsonDecode(line); + for (final request in decoded is List ? decoded : [decoded]) { + primary.requests++; + if (holdRequests && + (request['method'] == 'probe.a' || + request['method'] == 'probe.b')) { + primary.pending++; + if (request['method'] == 'probe.a') { + primary.fail = () { + final reply = batch + ? { + 'jsonrpc': '2.0', + 'id': request['id'], + 'error': { + 'code': -32000, + 'message': 'temporary failure', + }, + } + : { + 'jsonrpc': '2.0', + 'id': request['id'], + 'result': {'error': 'temporary failure'}, + }; + socket.write('${jsonEncode(batch ? [reply] : reply)}\n'); + }; + } + if (primary.pending == 2) primary.ready.complete(); + } else { + final Object result = switch (request['method']) { + 'server.version' => ['test', '1.4'], + 'blockchain.headers.subscribe' => { + 'height': 100, + 'hex': '00' * 80, + }, + _ => {}, + }; + final reply = { + 'jsonrpc': '2.0', + 'id': request['id'], + 'result': result, + }; + socket.write( + '${jsonEncode(decoded is List ? [reply] : reply)}\n', + ); + } + } + }, onError: (Object _) {}); + }); + return primary; + } + + Future close() async { + for (final socket in sockets) { + socket.destroy(); + } + await server.close(); + } +} + +class _Rpc { + _Rpc(this.socket, this.message); + final Socket socket; + final Map message; + String get method => message['method'] as String; + void reply(Object? value) => socket.write( + '${jsonEncode({'jsonrpc': '2.0', 'id': message['id'], 'result': value})}\n', + ); +} + +/// A plain JSON-RPC server for the publication-selection regression. It +/// answers header subscriptions, optionally auto-answers server.version, and +/// hands every request to [onRequest] so a test can error or hold it. +class _PubServer { + _PubServer(this.listener, this.autoVersion); + final ServerSocket listener; + final bool autoVersion; + final sockets = []; + final requests = <_Rpc>[]; + void Function(_Rpc)? onRequest; + int get port => listener.port; + List<_Rpc> get versions => + requests.where((e) => e.method == 'server.version').toList(); + + static Future<_PubServer> start({bool autoVersion = false}) async { + final server = _PubServer( + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0), + autoVersion, + ); + server.listener.listen((socket) { + server.sockets.add(socket); + socket.done.ignore(); + socket + .cast>() + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + final decoded = jsonDecode(line); + for (final message in decoded is List ? decoded : [decoded]) { + final rpc = _Rpc(socket, message as Map); + server.requests.add(rpc); + if (rpc.method == 'blockchain.headers.subscribe') { + rpc.reply({'height': 100, 'hex': '00' * 80}); + } else if (rpc.method == 'server.version' && autoVersion) { + rpc.reply(['test', '1.4']); + } + server.onRequest?.call(rpc); + } + }, onError: (Object _) {}); + }); + return server; + } + + Future close() async { + for (final socket in sockets) { + socket.destroy(); + } + await listener.close(); + } +} + +Future _until(bool Function() ready) async { + final deadline = DateTime.now().add(const Duration(seconds: 3)); + while (!ready()) { + if (DateTime.now().isAfter(deadline)) { + throw TimeoutException('probe synchronization'); + } + await Future.delayed(const Duration(milliseconds: 5)); + } +} + +void main() { + late Directory logDirectory; + setUpAll(() async { + logDirectory = await Directory.systemTemp.createTemp('electrumx_failover'); + await Logging.instance.initialize(logDirectory.path, level: Level.off); + }); + tearDownAll(() => logDirectory.delete(recursive: true)); + final coin = Bitcoin(CryptoCurrencyNetwork.main); + final servers = []; + late EventBus bus; + + setUp(() => bus = EventBus()); + tearDown(() async { + await ClientManager.sharedInstance.closeAll(); + bus.destroy(); + for (final server in servers) { + await server.close(); + } + servers.clear(); + }); + + Future startPlain() async { + final server = await TlsElectrumServer.start(certificateName: null); + servers.add(server); + return server; + } + + /// A port nothing listens on. + Future deadPort() async { + final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = socket.port; + await socket.close(); + return port; + } + + ElectrumXNode node(int port, String id) => ElectrumXNode( + address: '127.0.0.1', + port: port, + name: id, + id: id, + useSSL: false, + torEnabled: true, + clearnetEnabled: true, + ); + + ElectrumXClient client( + ElectrumXNode primary, { + List failovers = const [], + }) => ElectrumXClient.from( + node: primary, + prefs: _Prefs(), + torService: _Tor(), + failovers: failovers, + cryptoCurrency: coin, + globalEventBusForTesting: bus, + ); + + test( + 'requests after a successful failover request stay on the failover', + () async { + final failover = await startPlain(); + final connection = client( + node(await deadPort(), 'primary'), + failovers: [node(failover.port, 'failover')], + ); + connection.currentFailoverIndex = 0; + // A successful request resets the failover index to the primary. + expect(await connection.ping(), true); + expect(connection.currentFailoverIndex, -1); + final result = await connection.getTransaction(txHash: 'a' * 64); + expect(result['confirmations'], 3); + expect(connection.getElectrumAdapter()!.port, failover.port); + // server.version, headers.subscribe, ping, transaction.get: one session. + expect(failover.requests, 4); + expect(failover.disconnections, 0); + }, + ); + + for (final primaryShadows in [false, true]) { + test( + 'duplicate endpoint approvals do not hide a successful ${primaryShadows ? 'primary' : 'failover'} fallback', + () async { + final server = await TlsElectrumServer.start(certificateName: 'node_b'); + servers.add(server); + final a = TlsElectrumServer.certificate('node_a'); + final b = TlsElectrumServer.certificate('node_b'); + ElectrumXNode approved(String id, String pem) => ElectrumXNode( + address: '127.0.0.1', + port: server.port, + name: id, + id: id, + useSSL: true, + torEnabled: true, + clearnetEnabled: true, + trustedCertificatePem: pem, + ); + final connection = client( + primaryShadows + ? approved('primary', a) + : node(await deadPort(), 'primary'), + failovers: [ + if (!primaryShadows) approved('old approval', a), + approved('selected', b), + ], + ); + addTearDown(connection.closeAdapter); + connection.currentFailoverIndex = primaryShadows ? 0 : 1; + expect(await connection.ping(), true); + final selected = connection.getElectrumAdapter(); + expect(connection.currentFailoverIndex, -1); + expect( + (await connection.getTransaction(txHash: 'a' * 64))['confirmations'], + 3, + ); + expect(connection.getElectrumAdapter(), same(selected)); + expect(server.accepted, 1); + expect(server.disconnections, 0); + }, + ); + } + + test('another client for the coin shares the failover connection', () async { + final failover = await startPlain(); + final dead = await deadPort(); + final first = client( + node(dead, 'primary'), + failovers: [node(failover.port, 'failover')], + ); + first.currentFailoverIndex = 0; + expect(await first.ping(), true); + final second = client( + node(dead, 'primary'), + failovers: [node(failover.port, 'failover')], + ); + final result = await second.getTransaction(txHash: 'b' * 64); + expect(result['confirmations'], 3); + expect(failover.disconnections, 0); + }); + + test("a failover retry reuses another owner's healthy selected connection", () async { + // A shared failover endpoint that answers setup and holds one request. + final listener = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final sockets = []; + var accepted = 0; + void Function()? release; + final held = Completer(); + listener.listen((socket) { + accepted++; + sockets.add(socket); + socket.done.ignore(); + socket + .cast>() + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + final request = jsonDecode(line) as Map; + void reply(Object? result) => socket.write( + '${jsonEncode({'jsonrpc': '2.0', 'id': request['id'], 'result': result})}\n', + ); + switch (request['method']) { + case 'blockchain.transaction.get': + release = () => reply({'confirmations': 7}); + if (!held.isCompleted) held.complete(); + case 'blockchain.headers.subscribe': + reply({'height': 100, 'hex': '00' * 80}); + default: + reply(['test', '1.4']); + } + }, onError: (Object _) {}); + }); + addTearDown(() async { + for (final socket in sockets) { + socket.destroy(); + } + await listener.close(); + }); + final dead = await deadPort(); + final failoverNode = node(listener.port, 'failover'); + final w1 = client(node(dead, 'primary'), failovers: [failoverNode]); + final w2 = client(node(dead, 'primary'), failovers: [failoverNode]); + addTearDown(w1.closeAdapter); + addTearDown(w2.closeAdapter); + + // W1 has already failed over to the shared node and is healthy on it. + w1.currentFailoverIndex = 0; + expect(await w1.ping(), true); + expect(w1.currentFailoverIndex, -1); + final shared = w1.getElectrumAdapter()!; + + // W1 keeps a request in flight on the shared connection. + final inflight = w1.getTransaction(txHash: 'a' * 64); + await held.future.timeout(const Duration(seconds: 2)); + + // W2's primary is also dead; its failover retry selects the same node. + w2.currentFailoverIndex = 0; + await w2.checkElectrumAdapter(); + expect( + w2.getElectrumAdapter(), + same(shared), + reason: 'the healthy selected failover is reused, not reopened', + ); + expect( + shared.peer.isClosed, + false, + reason: "another owner's connection must not be closed", + ); + expect(accepted, 1, reason: 'no spare session is opened'); + + release!(); + expect( + (await inflight.timeout(const Duration(seconds: 2)))['confirmations'], + 7, + reason: 'the in-flight RPC on the shared connection is not cut off', + ); + }); + + test( + 'a concurrent primary publication cannot replace the selected fallback', + () async { + final primary = await _PubServer.start(autoVersion: true); + final fallback = await _PubServer.start(); + addTearDown(primary.close); + addTearDown(fallback.close); + primary.onRequest = (rpc) { + if (rpc.method == 'probe') rpc.reply({'error': 'primary unavailable'}); + }; + fallback.onRequest = (rpc) { + if (rpc.method == 'probe') rpc.reply({'via': 'fallback'}); + }; + final first = client( + node(primary.port, 'primary'), + failovers: [node(fallback.port, 'fallback')], + ); + final second = client( + node(primary.port, 'primary'), + failovers: [node(fallback.port, 'fallback')], + ); + addTearDown(first.closeAdapter); + addTearDown(second.closeAdapter); + final pending = first + .request(command: 'probe') + .then((value) => value, onError: (Object e) => e); + await _until(() => fallback.versions.isNotEmpty); + // The second owner publishes a fresh primary while the fallback waits. + await second.checkElectrumAdapter(); + expect(second.getElectrumAdapter()!.port, primary.port); + // Let the primary's header subscription finish before it is replaced. + await ClientManager.sharedInstance.getChainHeightFor(coin); + fallback.versions.single.reply(['test', '1.4']); + final result = await pending.timeout(const Duration(seconds: 4)); + expect(result, { + 'via': 'fallback', + }, reason: 'the selected fallback must serve the retry, not the primary'); + expect(first.getElectrumAdapter()!.port, fallback.port); + expect( + fallback.requests.where((r) => r.method == 'probe').length, + 1, + reason: 'the probe reaches the healthy fallback', + ); + }, + ); + for (final batch in [false, true]) { + test( + 'failover survives another outstanding RPC closing (batch=$batch)', + () async { + final primary = await _RpcServer.start(batch: batch); + final failover = await _RpcServer.start( + batch: batch, + holdRequests: false, + ); + addTearDown(failover.close); + final connection = client( + node(primary.port, 'primary'), + failovers: [node(failover.port, 'failover')], + ); + final first = + (batch + ? connection.batchRequest( + command: 'probe.a', + args: [ + ['address'], + ], + ) + : connection.request(command: 'probe.a')) + .then( + (value) => value, + onError: (Object error) => error, + ); + final other = connection + .request(command: 'probe.b') + .then((value) => value, onError: (Object error) => error); + try { + await primary.ready.future.timeout(const Duration(seconds: 2)); + primary.fail!(); + final result = await first.timeout(const Duration(seconds: 3)); + expect( + result, + batch ? isA>() : isA>(), + ); + expect(failover.requests, greaterThanOrEqualTo(2)); + } finally { + await primary.close(); + await other.timeout(const Duration(seconds: 3)); + await connection.closeAdapter(); + } + }, + ); + } +} diff --git a/test/electrumx_late_approval_test.dart b/test/electrumx_late_approval_test.dart new file mode 100644 index 0000000000..313781f9cb --- /dev/null +++ b/test/electrumx_late_approval_test.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:event_bus/event_bus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/electrumx_rpc/client_manager.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +import 'utilities/tls_test_server.dart'; + +class _Prefs extends Mock implements Prefs { + _Prefs(this.tor); + final bool tor; + @override + bool get useTor => tor; + @override + bool get wifiOnly => false; + @override + bool get torKillSwitch => true; +} + +class _Tor extends Mock implements TorService { + _Tor(this.proxyInfo); + final ({InternetAddress host, int port}) proxyInfo; + final requested = Completer(); + @override + TorConnectionStatus get status => TorConnectionStatus.connected; + @override + ({InternetAddress host, int port}) getProxyInfo() { + if (!requested.isCompleted) requested.complete(); + return proxyInfo; + } +} + +void main() { + test( + 'late adapter completion cannot republish a retired certificate approval', + () async { + final logs = await Directory.systemTemp.createTemp( + 'tls_only_late_approval', + ); + await Logging.instance.initialize(logs.path, level: Level.off); + final oldServer = await TlsElectrumServer.start(); + final newServer = await TlsElectrumServer.start(certificateName: null); + final proxy = await TlsTestProxy.start( + oldServer.port, + greetingDelay: const Duration(milliseconds: 200), + ); + final tor = _Tor(proxy.info); + final coin = Bitcoin(CryptoCurrencyNetwork.main); + final bus = EventBus(); + final oldClient = ElectrumXClient( + host: 'old.example', + port: oldServer.port, + useSSL: true, + trustedCertificatePem: TlsElectrumServer.certificate('node_a'), + prefs: _Prefs(true), + netType: TorPlainNetworkOption.both, + failovers: [], + cryptoCurrency: coin, + torService: tor, + globalEventBusForTesting: bus, + ); + final newClient = ElectrumXClient( + host: '127.0.0.1', + port: newServer.port, + useSSL: false, + prefs: _Prefs(false), + netType: TorPlainNetworkOption.both, + failovers: [], + cryptoCurrency: coin, + torService: tor, + globalEventBusForTesting: bus, + ); + addTearDown(() async { + await oldClient.closeAdapter(); + await newClient.closeAdapter(); + await ClientManager.sharedInstance.closeAll(); + bus.destroy(); + await proxy.close(); + await oldServer.close(); + await newServer.close(); + await logs.delete(recursive: true); + }); + + final oldOutcome = oldClient.ping().then( + (value) => value, + onError: (Object error) => error, + ); + await tor.requested.future.timeout(const Duration(seconds: 2)); + await oldClient.closeAdapter(); + expect(await newClient.ping(), true); + final replacement = newClient.getElectrumAdapter(); + expect(replacement, isNotNull); + + expect( + await oldOutcome.timeout(const Duration(seconds: 3)), + isA(), + ); + expect(proxy.destinations, ['old.example']); + expect(oldServer.requests, 0); + expect(newClient.getElectrumAdapter(), same(replacement)); + expect(await newClient.ping(), true); + }, + ); +} diff --git a/test/electrumx_request_policy_test.dart b/test/electrumx_request_policy_test.dart new file mode 100644 index 0000000000..e49fa7c849 --- /dev/null +++ b/test/electrumx_request_policy_test.dart @@ -0,0 +1,574 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:event_bus/event_bus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/electrumx_rpc/client_manager.dart'; +import 'package:stackwallet/electrumx_rpc/electrum_request_timeouts.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +const _historyMethod = 'blockchain.scripthash.get_history'; +const _broadcastMethod = 'blockchain.transaction.broadcast'; +const _short = Duration(milliseconds: 100); +const _long = Duration(seconds: 1); + +class _Prefs extends Mock implements Prefs { + @override + bool get useTor => false; + @override + bool get wifiOnly => false; +} + +class _Tor extends Mock implements TorService { + @override + TorConnectionStatus get status => TorConnectionStatus.disconnected; +} + +class _ObservedTimeouts extends ElectrumRequestTimeouts { + final resolved = <({String method, Duration timeout})>[]; + + @override + Duration forMethod(String method) { + final timeout = super.forMethod(method); + resolved.add((method: method, timeout: timeout)); + return timeout; + } +} + +/// Setup and headers reply immediately; application responses take 250ms. +class _PolicyServer { + _PolicyServer(this.listener) { + listener.listen((socket) { + accepted++; + sockets.add(socket); + socket + .cast>() + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen( + (line) { + final message = jsonDecode(line); + final requests = message is List ? message : [message]; + final responses = >[]; + var delayed = false; + for (final dynamic request in requests) { + received.add(Map.from(request as Map)); + final method = request['method'] as String; + methods.add(method); + final Object result; + switch (method) { + case 'server.version': + result = ['policy-test', '1.4']; + case 'blockchain.headers.subscribe': + result = {'height': 800000, 'hex': '00'}; + case 'probe.ready': + result = {}; + case _historyMethod: + delayed = true; + result = [ + {'height': 1, 'tx_hash': request['params'][0]}, + ]; + case 'blockchain.transaction.get': + delayed = true; + result = request['params'][1] == true + ? {'txid': request['params'][0]} + : 'raw-transaction'; + case _broadcastMethod: + delayed = true; + result = 'broadcast-txid'; + case 'spark.getsparklatestcoinid': + case 'lelantus.getlatestcoinid': + delayed = true; + result = 42; + case 'blockchain.getfeerate': + delayed = true; + result = {'rate': 1000}; + case 'lelantus.getmintmetadata': + delayed = true; + result = [ + {'height': 10, 'groupId': 1}, + ]; + case 'lelantus.getanonymityset': + case 'spark.getsparkanonymityset': + delayed = true; + result = { + 'blockHash': '00', + 'setHash': '00', + 'coins': [], + }; + default: + delayed = true; + result = {'ok': true}; + } + responses.add({ + 'jsonrpc': '2.0', + 'id': request['id'], + 'result': result, + }); + } + void reply() { + if (sockets.contains(socket)) { + final payload = message is List + ? responses + : responses.single; + socket.write('${jsonEncode(payload)}\n'); + } + } + + if (delayed) { + timers.add(Timer(const Duration(milliseconds: 250), reply)); + } else { + reply(); + } + }, + onError: (Object _) {}, + onDone: () => sockets.remove(socket), + ); + }); + } + + final ServerSocket listener; + final sockets = []; + final timers = []; + final methods = []; + final received = >[]; + int accepted = 0; + int get port => listener.port; + + static Future<_PolicyServer> start() async => + _PolicyServer(await ServerSocket.bind(InternetAddress.loopbackIPv4, 0)); + + Future close() async { + for (final timer in timers) { + timer.cancel(); + } + for (final socket in sockets.toList()) { + socket.destroy(); + } + sockets.clear(); + await listener.close(); + } +} + +Matcher _deadline(Duration duration) => throwsA( + isA().having( + (error) => error.duration, + 'deadline', + duration, + ), +); + +void main() { + late Directory logs; + late EventBus bus; + setUpAll(() async { + logs = await Directory.systemTemp.createTemp('electrum_request_policy'); + await Logging.instance.initialize(logs.path, level: Level.off); + }); + tearDownAll(() => logs.delete(recursive: true)); + setUp(() => bus = EventBus()); + tearDown(() async { + await ClientManager.sharedInstance.closeAll(); + bus.destroy(); + }); + + Future<_PolicyServer> start() async { + final server = await _PolicyServer.start(); + addTearDown(server.close); + return server; + } + + ElectrumXClient clientFor( + _PolicyServer server, { + ElectrumRequestTimeouts policy = const ElectrumRequestTimeouts(), + CryptoCurrency? coin, + }) { + final client = ElectrumXClient( + host: '127.0.0.1', + port: server.port, + useSSL: false, + prefs: _Prefs(), + torService: _Tor(), + netType: TorPlainNetworkOption.both, + failovers: [], + cryptoCurrency: coin ?? Bitcoin(CryptoCurrencyNetwork.main), + globalEventBusForTesting: bus, + requestTimeouts: policy, + ); + addTearDown(client.closeAdapter); + return client; + } + + test( + 'default history limit is shared by individual and batch helpers', + () async { + final server = await start(); + final policy = _ObservedTimeouts(); + final client = clientFor(server, policy: policy); + expect(policy.standard, const Duration(seconds: 60)); + expect(policy.history, const Duration(minutes: 5)); + expect(policy.firoSet, const Duration(minutes: 2)); + + expect(await client.getHistory(scripthash: 'single'), [ + {'height': 1, 'tx_hash': 'single'}, + ]); + expect( + await client.getBatchHistory( + args: [ + ['batch'], + ], + ), + [ + [ + {'height': 1, 'tx_hash': 'batch'}, + ], + ], + ); + expect(policy.resolved, [ + (method: _historyMethod, timeout: const Duration(minutes: 5)), + (method: _historyMethod, timeout: const Duration(minutes: 5)), + ]); + }, + ); + + test('broadcast has its own five-minute default policy', () { + const policy = ElectrumRequestTimeouts(); + expect(policy.broadcast, const Duration(minutes: 5)); + expect(policy.forMethod(_broadcastMethod), policy.broadcast); + expect(policy.standard, const Duration(seconds: 60)); + expect(policy.history, const Duration(minutes: 5)); + expect(policy.firoSet, const Duration(minutes: 2)); + }); + + for (final generic in [false, true]) { + final mode = generic ? 'generic request' : 'broadcast helper'; + for (final timeout in [_short, _long]) { + test('$mode uses the broadcast policy $timeout', () async { + final server = await start(); + final client = clientFor( + server, + policy: ElectrumRequestTimeouts( + standard: timeout == _short ? _long : _short, + broadcast: timeout, + ), + ); + final result = generic + ? client.request(command: _broadcastMethod, args: ['signed-tx']) + : client.broadcastTransaction(rawTx: 'signed-tx'); + if (timeout == _short) { + await expectLater(result, _deadline(_short)); + } else { + expect(await result, 'broadcast-txid'); + } + expect(server.accepted, 1); + final sent = server.received + .where((request) => request['method'] == _broadcastMethod) + .single; + expect(sent['params'], ['signed-tx']); + }); + } + } + + for (final override in [_short, _long]) { + test( + 'explicit broadcast request timeout overrides policy: $override', + () async { + final server = await start(); + final client = clientFor( + server, + policy: ElectrumRequestTimeouts( + standard: _long, + broadcast: override == _short ? _long : _short, + ), + ); + final result = client.request( + command: _broadcastMethod, + args: ['signed-tx'], + requestTimeout: override, + ); + if (override == _short) { + await expectLater(result, _deadline(_short)); + } else { + expect(await result, 'broadcast-txid'); + } + }, + ); + } + + test('broadcast policy must be positive', () async { + final server = await start(); + for (final invalid in [Duration.zero, const Duration(milliseconds: -1)]) { + expect( + () => clientFor( + server, + policy: ElectrumRequestTimeouts(broadcast: invalid), + ), + throwsArgumentError, + ); + } + expect(server.accepted, 0); + }); + + test('history helpers share a longer limit than standard requests', () async { + final server = await start(); + final client = clientFor( + server, + policy: const ElectrumRequestTimeouts(standard: _short, history: _long), + ); + await client.request(command: 'probe.ready'); + + final results = await Future.wait([ + client.getHistory(scripthash: 'single'), + client.getBatchHistory( + args: [ + ['batch'], + ], + ), + ]); + expect(results, [ + [ + {'height': 1, 'tx_hash': 'single'}, + ], + [ + [ + {'height': 1, 'tx_hash': 'batch'}, + ], + ], + ]); + await expectLater(client.request(command: 'probe.slow'), _deadline(_short)); + }); + + for (final batch in [false, true]) { + final mode = batch ? 'batch' : 'individual'; + test('$mode history observes a shorter configured history limit', () async { + final server = await start(); + final client = clientFor( + server, + policy: const ElectrumRequestTimeouts(standard: _long, history: _short), + ); + await client.request(command: 'probe.ready'); + await expectLater( + batch + ? client.getBatchHistory( + args: [ + ['history'], + ], + ) + : client.getHistory(scripthash: 'history'), + _deadline(_short), + ); + expect( + server.methods.where((method) => method == _historyMethod), + hasLength(1), + ); + }); + + test( + '$mode explicit timeout overrides the method policy in either direction', + () async { + final server = await start(); + final client = clientFor( + server, + policy: const ElectrumRequestTimeouts( + standard: _long, + history: _short, + ), + ); + Future history(Duration timeout) => batch + ? client.batchRequest( + command: _historyMethod, + args: [ + ['history'], + ], + requestTimeout: timeout, + ) + : client.request( + command: _historyMethod, + args: ['history'], + requestTimeout: timeout, + ); + + final result = await history(_long); + expect( + result, + batch + ? [ + [ + {'height': 1, 'tx_hash': 'history'}, + ], + ] + : [ + {'height': 1, 'tx_hash': 'history'}, + ], + ); + const shorter = Duration(milliseconds: 50); + await expectLater(history(shorter), _deadline(shorter)); + }, + ); + } + + test( + 'an empty history batch opens no connection or response timer', + () async { + final server = await start(); + final client = clientFor(server); + expect(await client.getBatchHistory(args: []), isEmpty); + expect(server.accepted, 0); + expect(server.methods, isEmpty); + }, + ); + + for (final verbose in [false, true]) { + for (final callerTimeout in [_short, _long]) { + test('shared getTransaction uses caller timeout $callerTimeout ' + '(verbose: $verbose)', () async { + final server = await start(); + final coin = Bitcoin(CryptoCurrencyNetwork.main); + final owner = clientFor( + server, + coin: coin, + policy: ElectrumRequestTimeouts( + standard: callerTimeout == _short ? _long : _short, + ), + ); + final caller = clientFor( + server, + coin: coin, + policy: ElectrumRequestTimeouts(standard: callerTimeout), + ); + await owner.checkElectrumAdapter(); + await ClientManager.sharedInstance.getChainHeightFor(coin); + await caller.checkElectrumAdapter(); + expect(caller.getElectrumAdapter(), same(owner.getElectrumAdapter())); + + final result = caller.getTransaction(txHash: 'tx', verbose: verbose); + if (callerTimeout == _short) { + await expectLater(result, _deadline(_short)); + } else { + expect( + await result, + verbose ? {'txid': 'tx'} : {'rawtx': 'raw-transaction'}, + ); + } + expect(server.accepted, 1); + expect( + server.methods.where( + (method) => method == 'blockchain.transaction.get', + ), + hasLength(1), + ); + }); + } + } + + for (final (method, expected) in [ + ('spark.getsparklatestcoinid', 42), + ('blockchain.getfeerate', {'rate': 1000}), + ('lelantus.getlatestcoinid', 42), + ( + 'lelantus.getmintmetadata', + [ + {'height': 10, 'groupId': 1}, + ], + ), + ]) { + for (final callerTimeout in [_short, _long]) { + test('shared $method uses caller timeout $callerTimeout', () async { + final server = await start(); + final coin = method == 'blockchain.getfeerate' + ? Bitcoin(CryptoCurrencyNetwork.main) + : Firo(CryptoCurrencyNetwork.main); + final owner = clientFor( + server, + coin: coin, + policy: ElectrumRequestTimeouts( + standard: callerTimeout == _short ? _long : _short, + ), + ); + final caller = clientFor( + server, + coin: coin, + policy: ElectrumRequestTimeouts(standard: callerTimeout), + ); + await owner.checkElectrumAdapter(); + await ClientManager.sharedInstance.getChainHeightFor(coin); + await caller.checkElectrumAdapter(); + expect(caller.getElectrumAdapter(), same(owner.getElectrumAdapter())); + + final result = switch (method) { + 'spark.getsparklatestcoinid' => caller.getSparkLatestCoinId(), + 'blockchain.getfeerate' => caller.getFeeRate(), + 'lelantus.getlatestcoinid' => caller.getLelantusLatestCoinId(), + 'lelantus.getmintmetadata' => caller.getLelantusMintData( + mints: ['mint'], + ), + _ => throw StateError('Unexpected test method: $method'), + }; + if (callerTimeout == _short) { + await expectLater(result, _deadline(_short)); + } else { + expect(await result, expected); + } + expect(server.accepted, 1); + final sent = server.received + .where((request) => request['method'] == method) + .single; + expect( + sent['params'], + method == 'lelantus.getmintmetadata' + ? [ + ['mint'], + ] + : null, + ); + }); + } + } + + test('Firo set methods share their configurable policy', () { + const policy = ElectrumRequestTimeouts( + standard: _short, + history: _long, + firoSet: Duration(seconds: 2), + ); + for (final method in [ + 'lelantus.getanonymityset', + 'lelantus.getusedcoinserials', + 'spark.getusedcoinstags', + 'spark.getusedcoinstagstxhashes', + 'spark.getsparkanonymityset', + 'spark.getsparkanonymitysetsector', + ]) { + expect(policy.forMethod(method), policy.firoSet, reason: method); + } + expect(policy.forMethod('lelantus.getlatestcoinid'), _short); + expect(policy.forMethod('probe.unknown'), _short); + }); + + test( + 'Firo anonymity helpers pass the longer set policy to the adapter', + () async { + final server = await start(); + final client = clientFor( + server, + coin: Firo(CryptoCurrencyNetwork.main), + policy: const ElectrumRequestTimeouts(standard: _short, firoSet: _long), + ); + expect((await client.getLelantusAnonymitySet())['coins'], isEmpty); + expect((await client.getSparkAnonymitySet())['coins'], isEmpty); + await expectLater( + client.request(command: 'probe.slow'), + _deadline(_short), + ); + }, + ); +} diff --git a/test/electrumx_setup_failover_test.dart b/test/electrumx_setup_failover_test.dart new file mode 100644 index 0000000000..0cd82c096b --- /dev/null +++ b/test/electrumx_setup_failover_test.dart @@ -0,0 +1,300 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:event_bus/event_bus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/electrumx_rpc/client_manager.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +import 'utilities/tls_test_server.dart'; + +class _Prefs extends Mock implements Prefs { + _Prefs({this.useTor = false}); + @override + final bool useTor; + @override + bool get wifiOnly => false; + @override + bool get torKillSwitch => true; +} + +class _Tor extends Mock implements TorService { + @override + TorConnectionStatus get status => TorConnectionStatus.disconnected; +} + +Future _until(bool Function() ready) async { + final watch = Stopwatch()..start(); + while (!ready()) { + if (watch.elapsed > const Duration(seconds: 3)) { + throw TimeoutException('Waiting for setup test server'); + } + await Future.delayed(const Duration(milliseconds: 5)); + } +} + +void main() { + late Directory logDirectory; + late EventBus bus; + setUpAll(() async { + logDirectory = await Directory.systemTemp.createTemp('electrum_setup'); + await Logging.instance.initialize(logDirectory.path, level: Level.off); + }); + tearDownAll(() => logDirectory.delete(recursive: true)); + setUp(() => bus = EventBus()); + tearDown(() async { + await ClientManager.sharedInstance.closeAll(); + bus.destroy(); + }); + + Future start({ + String? certificateName, + bool stallHandshake = false, + bool versionError = false, + Set ignoredMethods = const {}, + }) async { + final server = await TlsElectrumServer.start( + certificateName: certificateName, + stallHandshake: stallHandshake, + versionError: versionError, + ignoredMethods: ignoredMethods, + ); + addTearDown(server.close); + return server; + } + + ElectrumXNode node( + TlsElectrumServer server, { + bool useSSL = false, + String? certificateName, + }) => ElectrumXNode( + address: '127.0.0.1', + port: server.port, + name: 'setup test', + id: '${server.port}', + useSSL: useSSL, + torEnabled: true, + clearnetEnabled: true, + trustedCertificatePem: certificateName == null + ? null + : TlsElectrumServer.certificate(certificateName), + ); + + ElectrumXClient clientFor( + int port, + List failovers, { + bool useSSL = false, + String? certificateName, + bool useTor = false, + Duration timeout = const Duration(milliseconds: 200), + }) { + final client = ElectrumXClient( + host: '127.0.0.1', + port: port, + useSSL: useSSL, + trustedCertificatePem: certificateName == null + ? null + : TlsElectrumServer.certificate(certificateName), + cryptoCurrency: Bitcoin(CryptoCurrencyNetwork.main), + prefs: _Prefs(useTor: useTor), + torService: _Tor(), + netType: TorPlainNetworkOption.both, + globalEventBusForTesting: bus, + connectionTimeoutForSpecialCaseJsonRPCClients: timeout, + failovers: failovers, + ); + addTearDown(client.closeAdapter); + return client; + } + + for (final caller in ['adapter', 'request', 'batch']) { + test('$caller reaches a failover after server.version times out', () async { + final primary = await start(ignoredMethods: {'server.version'}); + final fallback = await start(); + final client = clientFor(primary.port, [node(fallback)]); + + switch (caller) { + case 'adapter': + await client.checkElectrumAdapter().timeout( + const Duration(seconds: 3), + ); + case 'request': + expect( + await client + .request(command: 'probe') + .timeout(const Duration(seconds: 3)), + isEmpty, + ); + case 'batch': + expect( + await client + .batchRequest( + command: 'probe', + args: [[], []], + ) + .timeout(const Duration(seconds: 3)), + [{}, {}], + ); + } + expect(client.getElectrumAdapter()!.port, fallback.port); + expect(primary.methods, ['server.version']); + expect(primary.accepted, 1); + expect(fallback.accepted, 1); + await _until(() => primary.disconnections == 1); + }); + } + + test('a refused TCP connection reaches a healthy failover', () async { + final unused = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final refusedPort = unused.port; + await unused.close(); + final fallback = await start(); + final client = clientFor(refusedPort, [node(fallback)]); + + await client.checkElectrumAdapter().timeout(const Duration(seconds: 3)); + + expect(client.getElectrumAdapter()!.port, fallback.port); + expect(fallback.accepted, 1); + }); + + test('a server.version RPC rejection reaches a healthy failover', () async { + final primary = await start(versionError: true); + final fallback = await start(); + final client = clientFor(primary.port, [node(fallback)]); + + await client.checkElectrumAdapter().timeout(const Duration(seconds: 3)); + + expect(client.getElectrumAdapter()!.port, fallback.port); + expect(primary.methods, ['server.version']); + expect(primary.accepted, 1); + expect(fallback.accepted, 1); + await _until(() => primary.disconnections == 1); + }); + + test('TLS setup failovers require each endpoint own approval', () async { + final primary = await start( + certificateName: 'node_a', + stallHandshake: true, + ); + final unapproved = await start(certificateName: 'node_a'); + final approved = await start(certificateName: 'node_b'); + final client = clientFor( + primary.port, + [ + node(unapproved, useSSL: true), + node(approved, useSSL: true, certificateName: 'node_b'), + ], + useSSL: true, + certificateName: 'node_a', + ); + + await client.checkElectrumAdapter().timeout(const Duration(seconds: 3)); + + expect(client.getElectrumAdapter()!.port, approved.port); + expect(primary.accepted, 1); + expect(primary.requests, 0); + expect(unapproved.accepted, 1); + expect(unapproved.requests, 0); + expect(approved.accepted, 1); + expect( + ClientManager.sharedInstance.trustedCertificateFor(client.cryptoCurrency), + TlsElectrumServer.certificate('node_b'), + ); + await _until(() => primary.disconnections == 1); + }); + + test( + 'setup exhaustion tries each remaining endpoint once and resets selection', + () async { + final primary = await start(ignoredMethods: {'server.version'}); + final first = await start(ignoredMethods: {'server.version'}); + final last = await start(ignoredMethods: {'server.version'}); + final client = clientFor(primary.port, [node(first), node(last)]); + client.currentFailoverIndex = 0; + + await expectLater( + client.checkElectrumAdapter().timeout(const Duration(seconds: 3)), + throwsA(isA()), + ); + + expect(primary.accepted, 0); + expect(first.accepted, 1); + expect(last.accepted, 1); + expect(client.getElectrumAdapter(), isNull); + expect(client.currentFailoverIndex, -1); + await _until(() => first.disconnections == 1 && last.disconnections == 1); + + // A later independent setup operation starts again from the primary. + await expectLater( + client.checkElectrumAdapter().timeout(const Duration(seconds: 3)), + throwsA(isA()), + ); + expect(primary.accepted, 1); + expect(first.accepted, 2); + expect(last.accepted, 2); + }, + ); + + test('concurrent setup callers share the successful fallback', () async { + final primary = await start(ignoredMethods: {'server.version'}); + final fallback = await start(); + final client = clientFor(primary.port, [node(fallback)]); + + await Future.wait([ + client.checkElectrumAdapter(), + client.checkElectrumAdapter(), + ]).timeout(const Duration(seconds: 3)); + + expect(client.getElectrumAdapter()!.port, fallback.port); + expect(primary.accepted, 1); + expect(fallback.accepted, 1); + }); + + test( + 'closing during negotiation cancels setup without trying failovers', + () async { + final primary = await start(ignoredMethods: {'server.version'}); + final fallback = await start(); + final client = clientFor(primary.port, [ + node(fallback), + ], timeout: const Duration(seconds: 2)); + final result = client.checkElectrumAdapter().then( + (_) => null, + onError: (Object error) => error, + ); + await _until(() => primary.methods.contains('server.version')); + + await client.closeAdapter(); + + expect( + await result.timeout(const Duration(seconds: 3)), + isA(), + ); + expect(client.getElectrumAdapter(), isNull); + expect(fallback.accepted, 0); + await _until(() => primary.disconnections == 1); + }, + ); + + test('Tor kill switch prevents all setup attempts', () async { + final primary = await start(); + final fallback = await start(); + final client = clientFor(primary.port, [node(fallback)], useTor: true); + + await expectLater( + client.checkElectrumAdapter(), + throwsA(isA()), + ); + + expect(primary.accepted, 0); + expect(fallback.accepted, 0); + }); +} diff --git a/test/electrumx_test.dart b/test/electrumx_test.dart index 171b55cd25..7d0412a001 100644 --- a/test/electrumx_test.dart +++ b/test/electrumx_test.dart @@ -111,7 +111,7 @@ void main() { TorPlainNetworkOption netType = TorPlainNetworkOption.both, }) { return ManagedElectrumXClient( - host: 'mock.stackwallet.dev', + host: clearServer.host, port: 50002, useSSL: true, prefs: prefs, diff --git a/test/electrumx_tls_test.dart b/test/electrumx_tls_test.dart new file mode 100644 index 0000000000..6d1c3d4d43 --- /dev/null +++ b/test/electrumx_tls_test.dart @@ -0,0 +1,216 @@ +import 'dart:io'; + +import 'package:event_bus/event_bus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:stackwallet/electrumx_rpc/client_manager.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/models/node_model.dart'; +import 'package:stackwallet/services/node_service.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_wallet.dart'; + +import 'utilities/tls_test_server.dart'; + +class _Prefs extends Mock implements Prefs { + @override + bool get useTor => false; + @override + bool get wifiOnly => false; + @override + bool get torKillSwitch => false; +} + +class _Tor extends Mock implements TorService {} + +/// Serves one primary node and no failovers to a wallet under test. +class _Nodes extends NodeService { + _Nodes(this.node) : super(secureStorageInterface: FakeSecureStorage()); + final NodeModel node; + @override + NodeModel? getPrimaryNodeFor({required CryptoCurrency currency}) => node; + @override + List failoverNodesFor({required CryptoCurrency currency}) => []; +} + +void main() { + late Directory logDirectory; + setUpAll(() async { + logDirectory = await Directory.systemTemp.createTemp('electrum_tls_logs'); + await Logging.instance.initialize(logDirectory.path, level: Level.off); + }); + tearDownAll(() => logDirectory.delete(recursive: true)); + final coin = Bitcoin(CryptoCurrencyNetwork.main); + final servers = []; + late EventBus bus; + final prefs = _Prefs(); + + setUp(() => bus = EventBus()); + tearDown(() async { + await ClientManager.sharedInstance.closeAll(); + bus.destroy(); + for (final server in servers) { + await server.close(); + } + servers.clear(); + }); + + Future start(String certificateName) async { + final server = await TlsElectrumServer.start( + certificateName: certificateName, + ); + servers.add(server); + return server; + } + + ElectrumXNode node(TlsElectrumServer server, {String? certificateName}) => + ElectrumXNode( + address: '127.0.0.1', + port: server.port, + name: 'TLS node', + id: '${server.port}', + useSSL: true, + torEnabled: true, + clearnetEnabled: true, + trustedCertificatePem: certificateName == null + ? null + : TlsElectrumServer.certificate(certificateName), + ); + + ElectrumXClient client( + ElectrumXNode node, { + List failovers = const [], + }) => ElectrumXClient.from( + node: node, + prefs: prefs, + torService: _Tor(), + failovers: failovers, + cryptoCurrency: coin, + globalEventBusForTesting: bus, + ); + + test('live client connects using the approved primary certificate', () async { + final server = await start('node_a'); + final primary = node(server, certificateName: 'node_a'); + expect( + ElectrumXNode.from(primary).trustedCertificatePem, + primary.trustedCertificatePem, + ); + final connection = client(primary); + await connection.checkElectrumAdapter(); + final result = await connection.getTransaction(txHash: 'a' * 64); + expect(result['confirmations'], 3); + expect(connection.trustedCertificatePem, primary.trustedCertificatePem); + }); + + test('live client rejects an unapproved primary certificate', () async { + final server = await start('node_a'); + await expectLater( + client(node(server)).checkElectrumAdapter(), + throwsA(isA()), + ); + expect(server.requests, 0); + }); + + test('failover uses its own approved certificate', () async { + final primary = await start('node_a'); + final failover = await start('node_b'); + final connection = client( + node(primary, certificateName: 'node_a'), + failovers: [node(failover, certificateName: 'node_b')], + ); + connection.currentFailoverIndex = 0; + await connection.checkElectrumAdapter(); + await connection.getElectrumAdapter()!.request('server.version'); + expect(connection.getElectrumAdapter()!.port, failover.port); + }); + + test('primary approval is not inherited by an unapproved failover', () async { + final primary = await start('node_a'); + final failover = await start('node_b'); + final connection = client( + node(primary, certificateName: 'node_a'), + failovers: [node(failover)], + ); + connection.currentFailoverIndex = 0; + await expectLater( + connection.checkElectrumAdapter(), + throwsA(isA()), + ); + expect(failover.requests, 0); + }); + + test('a client with the same approval shares the connection', () async { + final server = await start('node_a'); + final first = client(node(server, certificateName: 'node_a')); + await first.checkElectrumAdapter(); + // Let the header subscription settle before another client joins. + await first.getElectrumAdapter()!.request('server.version'); + final second = client(node(server, certificateName: 'node_a')); + await second.checkElectrumAdapter(); + expect(second.getElectrumAdapter(), same(first.getElectrumAdapter())); + expect(server.disconnections, 0); + }); + + test( + 'revoking approval cannot reuse a previously trusted connection', + () async { + final server = await start('node_a'); + final trusted = client(node(server, certificateName: 'node_a')); + await trusted.checkElectrumAdapter(); + await trusted.getElectrumAdapter()!.request('server.version'); + final peer = trusted.getElectrumAdapter()!.peer; + // No wallet closed the connection; the next client for this endpoint + // carries the revocation itself. + final revoked = client(node(server)); + await expectLater( + revoked.checkElectrumAdapter(), + throwsA(isA()), + ); + expect(peer.isClosed, true); + expect(revoked.getElectrumAdapter(), null); + }, + ); + + test('a fresh wallet applies a revocation no loaded wallet could', () async { + final server = await start('node_a'); + // A wallet connected under the approval and was then deleted, which + // leaves the coin-wide connection open. With no wallet of the coin + // loaded, saving the revocation had no wallet to notify. + final trusted = client(node(server, certificateName: 'node_a')); + await trusted.checkElectrumAdapter(); + await trusted.getElectrumAdapter()!.request('server.version'); + final peer = trusted.getElectrumAdapter()!.peer; + final fresh = BitcoinWallet(CryptoCurrencyNetwork.main) + ..prefs = prefs + ..nodeService = _Nodes( + NodeModel( + host: '127.0.0.1', + port: server.port, + name: 'TLS node', + id: 'tls-node', + useSSL: true, + enabled: true, + coinName: 'bitcoin', + isFailover: false, + isDown: false, + trusted: false, + torEnabled: true, + clearnetEnabled: true, + isPrimary: true, + ), + ); + // Wallet.load() calls this before the wallet has any client to close. + await fresh.updateNode(); + await expectLater( + fresh.electrumXClient.request(command: 'server.version'), + throwsA(isA()), + ); + expect(peer.isClosed, true); + }); +} diff --git a/test/fixtures/tls/expired.key.pem b/test/fixtures/tls/expired.key.pem new file mode 100644 index 0000000000..5af3ba016d --- /dev/null +++ b/test/fixtures/tls/expired.key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC2Y1jMxcqqPHby ++c9On7aAdWHUy9cAaKGMzcmEDK/kuo8mjpZeVT7NnXGot65j4WWeWfo3coIYuC3+ +aA5obDMj04cAv9VNFPtJGyFVFt/UyM7kE8dVZc3/lkv+IyUMs0mBs/b09NhMHsZE +P+H4bzZuF75Sb6LJtCcl9rpztROp2GklpEnsynIuAFZA8jigUnpmvMjptPCAQhyq +QMiTyFqT2zJVfWPuuQkYa/1YQdwfjAv4+JsnQhjtYgk9eg6m/r24RZK0hp2cDTQh +Oc6juSwUgG6zKh56qo9L89g0+IH3dKk2hYOc50p9Gs5N81ohH3LbeNktjGAzWp8y +rHhe/sAxAgMBAAECggEBAInVJEuEAjeKgD35CXCahDop+yfsb+vQJp4Y+nw/dkG9 +tublhL9/XnnWzQk63qB+uuPjjZOPhr+VmLSVAKZrdm5RscmgL1sUSx6hURxTsA3H +P4rfY5mm2PmZJI7HEn25lAhf73aI/ljwPNdhC61KpnCmot77nKMf8SHghmFg655D +NezEYaUemR3W1yQAdkk/5p+Bzd+mLBj7MRY+BAvlz3WbBk7n3hvPqB6XjvPDhhMZ +aVjsnzLKsHqxiaZn/0VepR//bkVlThGNUnUkuzz/NZvdNVXu1e9gkBCN2mS2huAX +laPr73/9EjaE6XQRDWfMiItzswXCQCsBrt25ZrPh7G0CgYEA3RMDFRfMSS05aepe +432pnCSI8gcVQWamPSdguobPBo74ZwhmeRo5cLUocOW+HmbjrKCyfisQCBuEgd1g +9zMo5X/cwFTSmzurWFNpK+tC2P9u70SSou/lrjIFDW9bQGjvoux23XYlnGoQznPP +/7dWYGgzU238/K+zihYTMe01CosCgYEA0zO8I63Tqlo5nF2mDDLVCCvMXHvAi0IT +HoSR8OYG66l8qY8VyQWf9UWEvbnWeFtODdG48zKX+BhP6iyKQtlBNIno5oc8sXab +Q6upwNIJHOlwSga2JSncKGE6Uf4XDuCtSAUAwLryJyHPpdtFy9hvtIliAs6xd7fZ +Vcxhl/YyQ7MCgYEAz83GMPaMtSBIfpGqngYJ/1asT2xvA2ncy1EPcGW1hkGvpxHT +WPRYl+QBXpXl7qfiTOtEvKn3yNcs5/FS6ypwv8E/nJ5e1UHyXvUQwBLFZDo+abQ4 +beY4LFSiQS2yRcodrzS8ypD665sbryRXmNQZ4kcwL7UMf+0cQ6laY6zIFd8CgYAl +db+g9GvegzJEsbeJ4lo2oM4WeTyldoq9lvXrrGD924no6SAMD5tXfYUVwahwoIEv +oksTl52su2MxPcUoJjCSiQNuH/5723WegwwQzzAemKj5+EbGQK5z2wrlEBQ251LU +u05u4LmihzS1TjcK+NCXTOa9aLvBaDJ8LUwCEADKUQKBgQCWcdXV4zA9KrPd8pzn +U20x3RXYTLLDYygsQm0wWBOUw+aQJBJPBJuw1PzkYUoQI2icXPtjAMeMykIU4LkY +UKkZokaRmlAdddE9dTlYyaVOjVrEOdcExN0mJisoS2UDxmJG77mZ5Bb573dfsm3K +WLCf3QE5JJ/7uYfxVKuKfA/dmw== +-----END PRIVATE KEY----- diff --git a/test/fixtures/tls/expired.pem b/test/fixtures/tls/expired.pem new file mode 100644 index 0000000000..accca0526e --- /dev/null +++ b/test/fixtures/tls/expired.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC5zCCAc+gAwIBAgIBATANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAlsb2Nh +bGhvc3QwHhcNMjAwMTAxMDAwMDAwWhcNMjEwMTAxMDAwMDAwWjAUMRIwEAYDVQQD +DAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2Y1jM +xcqqPHby+c9On7aAdWHUy9cAaKGMzcmEDK/kuo8mjpZeVT7NnXGot65j4WWeWfo3 +coIYuC3+aA5obDMj04cAv9VNFPtJGyFVFt/UyM7kE8dVZc3/lkv+IyUMs0mBs/b0 +9NhMHsZEP+H4bzZuF75Sb6LJtCcl9rpztROp2GklpEnsynIuAFZA8jigUnpmvMjp +tPCAQhyqQMiTyFqT2zJVfWPuuQkYa/1YQdwfjAv4+JsnQhjtYgk9eg6m/r24RZK0 +hp2cDTQhOc6juSwUgG6zKh56qo9L89g0+IH3dKk2hYOc50p9Gs5N81ohH3LbeNkt +jGAzWp8yrHhe/sAxAgMBAAGjRDBCMA8GA1UdEwEB/wQFMAMBAf8wGgYDVR0RBBMw +EYIJbG9jYWxob3N0hwR/AAABMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA0GCSqGSIb3 +DQEBCwUAA4IBAQCZiHHwkDZtLK9qSaoKaHL5voxvbH00ggjIS7FDGUUQFpSwbYgy +kzm23UID7sQwBzWh3KVfCOAeYmNL6mEV61gcoIKd8u4W/MArklQH6gX+jJENVgRl +N/hd2TXvGV625S8/2jksNCYsHcWVYkB7hJ3IGEqSFVFtOt6HkxX2s3VSOAeoRNNa +RjPRF+H2yx6K0kw6PdLvWXw3vY399XORkmXEN+CNPXqjARgee8jgR9Byf+LJZ/Wp +mUelvexeQhhYQh6qBSDfvNUDWqM1MUwEYJ3ZwePhRivR2t4k0b+MyeuyxyLsDs5t +lUGqkNlu6y7G24L7pDYPMAUjO1HnfB3kdv3x +-----END CERTIFICATE----- diff --git a/test/fixtures/tls/node_a.key.pem b/test/fixtures/tls/node_a.key.pem new file mode 100644 index 0000000000..2e446b4b86 --- /dev/null +++ b/test/fixtures/tls/node_a.key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC+BqJm1wyIEii2 +9MO2qtBr0Ne5I0bFf1aNA5b1hz7QapEQRO8gdL93DlpAJw0pAGa5rSEdAV7A2MgV +u35rfjG8MWoZblANv5rlHprkAnMl4oIRCXt3voIyVNUM4eZJu2pifEjT5GwDiHR3 +HsovdgPM7bzUjVzdBIOhhNXRp8Fkayo6IyChzA/3Gj6ehbOhHwOW05B4o3Pzj9lr +9gf4l9X/cwihuuvL+n0ZUrBMScQtiZ73LhyqcqQvcS7azy4FFzrdzrXFwtO6nxUN +mG6C/sPAZwY1ml6Dnadroc61eF4gLFIQyL4wDpuzLb7z3ENONO2b4yjenstc6Xkz +xCJgBFulAgMBAAECggEARpMINdGgodi8rlZxaaI/DgveUrA7VJZaerzekAnO1nD9 +t2EivpcdlQYLls/qH1Q60rLuHWuo2U6p4Qhlh16y8gYYNaG2saR7zqRzOkN+qI9b +ZyBybqKekDrZdZNgh/nHJRSrp6aqRxWiy6rvxHi8KJwTCY4G8z799Dr1fowjUzul +4omUkQBA81qfHWzAU1PRQ9FyQtzM+/Y+7JIA5j7zo06GgU+NjJx+SC6jd8Ym8s5p +xdKw1JrrNcWn6Z1n/nV1WamqDI/uodFij97nKIuR2839hb4zE6rxMC4o8EYxxXP/ +VIcKQqTWysBChA6Bg0kqEB6X6yaSU51Pjl23VOylcQKBgQDkIEPBZjs8efoxU3ao +UnohQ4djZXzksdWh4GfNtT5Z9LhG8wrAiTLaPcl4bgt9wmfY8294i7S/fsvUFeMt +qfAziogi/IqyOaM42R/iRoKMSDsVhHrg/2hC24hbfpMDiPEJjEogAJJbC+DpgGC3 +cmrYxNDJXzDNgHYuoxU47wMucwKBgQDVPpsZyfdftuSJvNx/t0W2yTw1nKW9pxVw +Gb6irgrVq7YRUukPfhP2yAwwGgz7T/Cg/W3hLAnqva3AQFflTlkCtO6bTY2AL77F +AsUgajq6vIm+a5VHj+zP2jFVnPftdpDuUAHu5sEw9ykEsXEja5nP6jT/Lf8pdyXj +1P4c80FvhwKBgQClSg9tyOHwvryeEshty1y8T06AoV8t4QBWSt9LeCAWU69VkPHg +cUhMQuqAfNOMqlhOJIxG/orXGUscf0Ncqb3lymE7YFoqKoLuRLvKWqZqeEd/Hr9d +2RE1KBWTnZFtxUJIVcK7j057845iAWeSY6+xImHZFkSpEYQP5JAS8XMPqwKBgF6B +uZGLXfPwhYa5yetcWSwyMVn9S8Mqqqic8Ez+31RlHnK9EQdJtCVZ8XTcBsbWXemr +h6gUsWx0le2vVoUM3QwDDgCMNVsLTfvYR28fRVn/NvjKi0VE9woDRHwR7hsywfas +RL8RS6ucznQDBAZTqL5pqQUy6HQDFyrK+bfw4x4/AoGAI/IdlKo7jILyis9ASL14 +ckSUcXjuiyI2ha4ap1+EpDOTIpkAsbCEXDqnDfpbpjL847vs4mxsJP7+ZF50j0F/ +XQr1ZCKWYc2CXjYxdq7udM2rfWrT2WX9goj44iftF2jK/HzgzEEKhk3ldXMLDeSO +vlfmIeCu1QiTx7TY4WamPWA= +-----END PRIVATE KEY----- diff --git a/test/fixtures/tls/node_b.key.pem b/test/fixtures/tls/node_b.key.pem new file mode 100644 index 0000000000..81ebe82e2a --- /dev/null +++ b/test/fixtures/tls/node_b.key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDqOJsCQTKfiveh +k1in+TEkQndzNDuzKFSAzyZ+nWthUAvp1XxFdlcOiWBL5mwLkC0zqRrasBTW4bQu +w+Kbw4n7NzBjcs1FSgz86PBJ/5R1UznEGe3G1+83U7noxKqdrPWkwVt14Qolp9At +BvhRBciH/VjvwRfuwSMkCb6aGZgYLiFcGDvk+nvlpE0BYjq61zpd8p+U3EoFwTr4 +5mvG7uNFFC08wQzXKcU5CHNneq1vg42quUHn503q1l/RH4D9wD/sVb3UeqD6KeXp +VrOQ63dh85gdgDf39VNp23oy7BmbiUzi2h9Ir5FQejr+oAq25lIUPc2OsU1ofrKh +gBa6wKDxAgMBAAECggEACCCCsGdWvFZ0FTUppFpvLBTqVCMB10uU8ws9CLOpoCzI +3Qo9T6O1jZmSr8+E/Dm6d8psVh45+uLF/8FoQYNdbEwT779yA+931+a1P7wQiKmk +nXtQ1s9zS3YqQztfuCziMR9MbyCp23ihLo7JWvZaaO2PgnIuxGgLMhrsdChjMKOI +InI2SCAt3tnx6uRDJbgRDFsMM3zZepqZSFa7aoeSiT9QvZakCOqqwZUbv4oUdNwf +QuLeURmaFv/xJR1TbzEm7qenAZj2WxBYsDD6WP5mu0EMT6OPFOAQPTkJey4mOyeq +3vmy+q6/iZJeeTcQsnUmKTQBfsBoEuzd3gULRepYyQKBgQD2mOkVzQN0x2Dukcsq +3FIkD0LQVLG7xhECsXfqrtb3j/FSD9IA9n0b7U04Hus9A7sMAIATSnoEWYFBro/s +FyozOioPhWAdXz0/isxM4HRlkcZvg5Ypq4PRthCKBa+ZrZkjAPyZod1dKfEFgxXN +QFYO7tCD9XMUdOu/3MpMcs681wKBgQDzJuNqRvhhEFMLCOZP0KYZL9RybU3Lxv9C ++xpTBUtcatoh/0wTzs7r6c3+pOSMO/xHPI8OMkNGWQpZro2392garWBnsfMeDsy0 +t/bdWT8JhXM6/IvcQ/vZ0V6PVgfhRjW7tavEptoPMHQKEx7Z/7MXbTtzKrQcNPzo +Fhz9ghPPdwKBgQCZG9nk2lTiBgUOO2GM7BzoHL2cQmnUL6dSJ8RP51y7exkXnZED +XgfR9iXyyWe/i3hwfEMJNz3JQ5V4pZzgtoVSJzQETQifVAngURqGRPqgMcGmbWon +10FSTRu5xsv/vASP8kXkUdbyyF7I0FoSBXcpcGfw9s4MDohtqPXGP6ZTIwKBgBoS +OmIj1+/4Yx8UxjUapySeN0ZJqATIgog5ClqKf8kZ/+eTSyH4k8FB4X+DSKosqGEv +AhvAkewSEgUJrVJjabsqieK0dt/rMFHUQ9j5/8NSlsdlWs20UGXLiDsQO2oRMCO/ +V/qO9WSZimemIzG2tAo4AR7ZHlTD7bYo4NThxKQDAoGBAM34F/m4W6KqOUJ+Jq+k +pkmx3rBMIE+Qnr2pU8z9hwemWDPmZc/R5eVoOACaoMjA8+GN5hQa/FWBMxzgZZRA +lDXrzlFXv7MMTQpKl6YPNHhgfy1IHwWqtNGFCwVqQsFrbmolILuZkjixfUbrh9pl +2pqXZd1YKFsdGSLym41sGkiu +-----END PRIVATE KEY----- diff --git a/test/models/node_model_backup_test.dart b/test/models/node_model_backup_test.dart index d0259c1541..892785344d 100644 --- a/test/models/node_model_backup_test.dart +++ b/test/models/node_model_backup_test.dart @@ -1,7 +1,48 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:stackwallet/models/node_model.dart'; void main() { + test('keeps a PEM whose contents are not a certificate', () { + // It cannot match any server certificate, so the node is simply not + // approved until its certificate is reviewed again. + for (final body in ['aGVsbG8=', 'MAA=']) { + final pem = + '-----BEGIN CERTIFICATE-----\n$body\n-----END CERTIFICATE-----\n'; + final node = NodeModel.fromStackBackup({ + 'host': 'node.example.com', + 'port': 50002, + 'name': 'Node', + 'id': 'tls', + 'coinName': 'bitcoin', + 'trustedCertificatePem': pem, + }); + expect(node.trustedCertificatePem, pem); + } + }); + + test('restores a trusted certificate and drops a damaged one', () { + final pem = File('test/fixtures/tls/expired.pem').readAsStringSync(); + final map = { + 'host': 'node.example.com', + 'port': 50002, + 'name': 'Node', + 'id': 'tls', + 'coinName': 'bitcoin', + 'trustedCertificatePem': pem, + }; + expect(NodeModel.fromStackBackup(map).trustedCertificatePem, pem); + for (final damaged in ['', 'not a certificate', 42, pem.substring(40)]) { + map['trustedCertificatePem'] = damaged; + expect( + NodeModel.fromStackBackup(map).trustedCertificatePem, + null, + reason: '$damaged', + ); + } + }); + test('restores current and legacy node backup fields', () { final source = NodeModel( host: 'node.example.com', diff --git a/test/models/node_tls_trust_test.dart b/test/models/node_tls_trust_test.dart new file mode 100644 index 0000000000..2a40724ac0 --- /dev/null +++ b/test/models/node_tls_trust_test.dart @@ -0,0 +1,198 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:stackwallet/models/node_form_data.dart'; +import 'package:stackwallet/models/node_model.dart'; + +import '../utilities/tls_test_server.dart'; + +NodeModel tlsNode({String? certificatePem}) => NodeModel( + host: '127.0.0.1', + port: 50002, + name: 'TLS node', + id: 'tls-node', + useSSL: true, + enabled: true, + coinName: 'bitcoin', + isFailover: false, + isDown: false, + trusted: false, + torEnabled: true, + clearnetEnabled: true, + isPrimary: false, + trustedCertificatePem: certificatePem, +); + +void main() { + final pem = TlsElectrumServer.certificate('node_a'); + + test( + 'backup round trip preserves TLS approval independently of daemon trust', + () { + final node = tlsNode(certificatePem: pem); + final restored = NodeModel.fromStackBackup(node.toMap()); + expect(restored.trustedCertificatePem, pem); + expect(restored.trusted, false); + final legacy = node.toMap()..remove('trustedCertificatePem'); + expect(NodeModel.fromStackBackup(legacy).trustedCertificatePem, null); + }, + ); + + test( + 'node edits preserve approval for the same endpoint and can revoke it', + () { + final node = tlsNode(certificatePem: pem); + expect( + node + .copyWith(name: 'Renamed', loginName: null, trusted: false) + .trustedCertificatePem, + pem, + ); + expect( + node + .copyWith( + loginName: null, + trusted: false, + clearTrustedCertificate: true, + ) + .trustedCertificatePem, + null, + ); + expect( + node + .copyWith(host: 'another.example', loginName: null, trusted: false) + .trustedCertificatePem, + null, + ); + expect( + node + .copyWith(port: 50003, loginName: null, trusted: false) + .trustedCertificatePem, + null, + ); + expect( + node + .copyWith(useSSL: false, loginName: null, trusted: false) + .trustedCertificatePem, + null, + ); + }, + ); + + test('form edits clear approval only when the endpoint changes', () { + final data = NodeFormData.fromNode(tlsNode(certificatePem: pem)); + data.name = 'Renamed'; + data.host = data.host; + data.port = data.port; + data.useSSL = data.useSSL; + expect(data.trustedCertificatePem, pem); + data.host = 'another.example'; + expect(data.trustedCertificatePem, null); + data.trustedCertificatePem = pem; + data.port = 50003; + expect(data.trustedCertificatePem, null); + data.trustedCertificatePem = pem; + data.useSSL = false; + expect(data.trustedCertificatePem, null); + }); + + test('restoring an edited endpoint restores its approval', () { + final data = NodeFormData.fromNode(tlsNode(certificatePem: pem)); + data.host = 'another.example'; + expect(data.trustedCertificatePem, null); + data.host = '127.0.0.1'; + expect(data.trustedCertificatePem, pem); + data.port = 50003; + data.port = 50002; + data.useSSL = false; + data.useSSL = true; + expect(data.trustedCertificatePem, pem); + expect(data.matchesEndpointOf(tlsNode()), true); + data.trustedCertificatePem = null; + data.host = 'another.example'; + data.host = '127.0.0.1'; + expect(data.trustedCertificatePem, null); + }); + + test( + 'manual Hive adapter persists approval across close and reopen', + () async { + final directory = await Directory.systemTemp.createTemp('node_tls_hive'); + Hive.init(directory.path); + Hive.resetAdapters(); + Hive.registerAdapter(NodeModelAdapter()); + try { + var box = await Hive.openBox('nodes'); + await box.put('node', tlsNode(certificatePem: pem)); + await box.close(); + box = await Hive.openBox('nodes'); + expect(box.get('node')!.trustedCertificatePem, pem); + expect(box.get('node')!.trusted, false); + await box.close(); + } finally { + await Hive.close(); + Hive.resetAdapters(); + await directory.delete(recursive: true); + } + }, + ); + + test('manual Hive adapter reads legacy nodes without migration', () async { + final directory = await Directory.systemTemp.createTemp('node_tls_legacy'); + Hive.init(directory.path); + Hive.registerAdapter(_LegacyNodeAdapter(), override: true); + try { + var box = await Hive.openBox('nodes'); + await box.put('node', tlsNode()); + await box.close(); + Hive.resetAdapters(); + Hive.registerAdapter(NodeModelAdapter()); + box = await Hive.openBox('nodes'); + expect(box.get('node')!.trustedCertificatePem, null); + expect(box.get('node')!.host, '127.0.0.1'); + await box.close(); + } finally { + await Hive.close(); + Hive.resetAdapters(); + await directory.delete(recursive: true); + } + }); +} + +/// The sixteen fields written before TLS trust was introduced. +class _LegacyNodeAdapter extends TypeAdapter { + @override + int get typeId => 12; + + @override + NodeModel read(BinaryReader reader) => NodeModelAdapter().read(reader); + + @override + void write(BinaryWriter writer, NodeModel node) { + final fields = [ + node.id, + node.host, + node.port, + node.name, + node.useSSL, + node.loginName, + node.enabled, + node.coinName, + node.isFailover, + node.isDown, + node.trusted, + node.torEnabled, + node.clearnetEnabled, + node.forceNoTor, + node.isPrimary, + node.nodeApiSecret, + ]; + writer.writeByte(fields.length); + for (int i = 0; i < fields.length; i++) { + writer + ..writeByte(i) + ..write(fields[i]); + } + } +} diff --git a/test/services/node_certificate_writes_test.dart b/test/services/node_certificate_writes_test.dart new file mode 100644 index 0000000000..56dad9f4fd --- /dev/null +++ b/test/services/node_certificate_writes_test.dart @@ -0,0 +1,293 @@ +import 'dart:async'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/db/hive/db.dart'; +import 'package:stackwallet/models/node_form_data.dart'; +import 'package:stackwallet/models/node_model.dart'; +import 'package:stackwallet/services/node_service.dart'; +import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +import '../hive/hive_ce_test_utils.dart'; +import '../utilities/tls_test_server.dart'; + +class _PausedPassword extends FakeSecureStorage { + final started = Completer(); + final release = Completer(); + @override + Future delete({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + started.complete(); + await release.future; + await super.delete(key: key); + } +} + +void main() { + final coin = Bitcoin(CryptoCurrencyNetwork.main); + late NodeService service; + late FakeSecureStorage storage; + late NodeModel original; + late String pem; + NodeModel current() => service.getNodeById(id: original.id)!; + NodeFormData approved(NodeModel node) => + NodeFormData.fromNode(node)..trustedCertificatePem = pem; + + setUp(() async { + await setUpHiveCeTest(); + if (!DB.instance.hive.isAdapterRegistered(12)) { + DB.instance.hive.registerAdapter(NodeModelAdapter()); + } + final box = await DB.instance.hive.openBox(DB.boxNameNodeModels); + storage = FakeSecureStorage(); + service = NodeService(secureStorageInterface: storage); + original = NodeModel( + host: 'node.example', + port: 50002, + name: 'Node', + id: 'n', + useSSL: true, + enabled: true, + coinName: coin.identifier, + isFailover: true, + isDown: false, + torEnabled: true, + clearnetEnabled: true, + isPrimary: false, + ); + await box.put(original.id, original); + pem = TlsElectrumServer.certificate('node_a'); + }); + tearDown(tearDownHiveCeTest); + + test('a certificate-only write keeps node metadata and never reads or changes credentials', () async { + expect(identical(current(), current()), true); + expect( + await service.saveTrustedCertificate(original, approved(original)), + true, + ); + expect(current().trustedCertificatePem, pem); + expect(current().name, original.name); + expect(current().isPrimary, false); + expect(storage.interactions, 0); + }); + + for (final change in [ + 'forget empty', + 'forget approved', + 'replace', + 'endpoint', + 'delete', + ]) { + test('a late approval cannot undo $change', () async { + if (change == 'forget approved') { + await service.saveTrustedCertificate(original, approved(original)); + original = current(); + } + final result = approved(original); + switch (change) { + case 'forget empty': + case 'forget approved': + await service.forgetTrustedCertificate(original); + case 'replace': + final replacement = approved(original) + ..trustedCertificatePem = TlsElectrumServer.certificate('node_b'); + await service.saveTrustedCertificate(original, replacement); + case 'endpoint': + await DB.instance.put( + boxName: DB.boxNameNodeModels, + key: original.id, + value: original.copyWith( + host: 'elsewhere.example', + loginName: null, + trusted: null, + ), + ); + case 'delete': + await DB.instance.delete( + boxName: DB.boxNameNodeModels, + key: original.id, + ); + } + final before = service.getNodeById(id: original.id); + expect(await service.saveTrustedCertificate(original, result), false); + expect(identical(service.getNodeById(id: original.id), before), true); + }); + } + + test( + 'a queued approval checks the row after an earlier queued Forget', + () async { + await DB.instance.mutex.acquire(); + final forgotten = service.forgetTrustedCertificate(original); + final late = service.saveTrustedCertificate(original, approved(original)); + DB.instance.mutex.release(); + expect(await forgotten, true); + expect(await late, false); + expect(current().trustedCertificatePem, null); + }, + ); + + test( + 'a Save waiting for the password cannot restore a forgotten certificate', + () async { + await service.saveTrustedCertificate(original, approved(original)); + final opened = current(); + final paused = _PausedPassword(); + final editor = NodeService(secureStorageInterface: paused); + final saving = editor.saveEdit( + opened.copyWith(name: 'Edited', loginName: null, trusted: null), + null, + true, + opened: opened, + ); + await paused.started.future; + await service.forgetTrustedCertificate(opened); + paused.release.complete(); + expect(await saving, true); + expect(current().name, 'Edited'); + expect(current().trustedCertificatePem, null); + }, + ); + + test('an explicit staged Forget wins an approval made before Save', () async { + await service.saveTrustedCertificate(original, approved(original)); + final edited = original.copyWith( + loginName: null, + trusted: null, + clearTrustedCertificate: true, + ); + expect( + await service.saveEdit( + edited, + null, + true, + opened: original, + forgetCertificate: true, + ), + true, + ); + expect(current().trustedCertificatePem, null); + }); + + test( + 'a fresh test discards stale form trust and can approve after Forget', + () async { + await service.saveTrustedCertificate(original, approved(original)); + final form = NodeFormData.fromNode(current()); + await service.forgetTrustedCertificate(current()); + expect(service.prepareCertificateTest(form), true); + expect(form.trustedCertificatePem, null); + form.trustedCertificatePem = pem; + final edit = current().copyWith( + loginName: null, + trusted: null, + trustedCertificatePem: form.trustedCertificatePem, + ); + expect( + await service.saveEdit(edit, null, true, opened: form.source!), + true, + ); + expect(current().trustedCertificatePem, pem); + }, + ); + + test('a staged Forget survives preparing another test after an external approval', () async { + final form = NodeFormData.fromNode(original)..forgetCertificate(); + await service.saveTrustedCertificate(original, approved(original)); + expect(service.prepareCertificateTest(form), true); + expect(form.certificateForgotten, true); + expect(form.trustedCertificatePem, null); + }); + + for (final flag in ['primary', 'enabled', 'failover']) { + test( + 'updating $flag keeps the latest certificate and credentials', + () async { + await service.saveTrustedCertificate(original, approved(original)); + switch (flag) { + case 'primary': + await service.setPrimaryNodeFor(coin: coin, node: original); + case 'enabled': + await service.setEnabledState(original.id, false, true); + case 'failover': + await service.setFailover(original.id, false); + } + expect(current().trustedCertificatePem, pem); + expect(storage.interactions, 0); + }, + ); + } + + test('Connect stores its approval and primary flag together without touching credentials', () async { + expect( + await service.commitConnectionTest( + node: original, + formData: approved(original), + ), + true, + ); + expect(current().trustedCertificatePem, pem); + expect(current().isPrimary, true); + expect(storage.interactions, 0); + }); + + for (final change in ['Forget', 'edit', 'delete']) { + test('Connect cannot promote or restore trust after $change', () async { + final result = approved(original); + switch (change) { + case 'Forget': + await service.forgetTrustedCertificate(original); + case 'edit': + await DB.instance.put( + boxName: DB.boxNameNodeModels, + key: original.id, + value: original.copyWith( + host: 'new.example', + loginName: null, + trusted: null, + ), + ); + case 'delete': + await DB.instance.delete( + boxName: DB.boxNameNodeModels, + key: original.id, + ); + } + expect( + await service.commitConnectionTest(node: original, formData: result), + false, + ); + expect(service.getPrimaryNodeFor(currency: coin), null); + }); + } + + test( + 'an edited endpoint keeps only the approval tested for that endpoint', + () async { + final form = approved(original)..host = 'new.example'; + expect(form.trustedCertificatePem, null); + form.trustedCertificatePem = pem; + final edited = original.copyWith( + host: form.host, + loginName: null, + trusted: null, + trustedCertificatePem: form.trustedCertificatePem, + ); + expect( + await service.saveEdit(edited, null, true, opened: original), + true, + ); + expect(current().host, 'new.example'); + expect(current().trustedCertificatePem, pem); + }, + ); +} diff --git a/test/services/notification_certificate_revocation_test.dart b/test/services/notification_certificate_revocation_test.dart new file mode 100644 index 0000000000..dbe1dd0a34 --- /dev/null +++ b/test/services/notification_certificate_revocation_test.dart @@ -0,0 +1,156 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:stackwallet/db/hive/db.dart'; +import 'package:stackwallet/electrumx_rpc/client_manager.dart'; +import 'package:stackwallet/models/node_model.dart'; +import 'package:stackwallet/models/notification_model.dart'; +import 'package:stackwallet/services/node_service.dart'; +import 'package:stackwallet/services/notifications_service.dart'; +import 'package:stackwallet/services/trade_service.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; + +import '../hive/hive_ce_test_utils.dart'; +import '../utilities/tls_test_server.dart'; + +class _Wallet extends Fake implements ElectrumXInterface { + @override + String get walletId => 'watched-wallet'; + @override + Bitcoin get cryptoCurrency => Bitcoin(CryptoCurrencyNetwork.main); +} + +class _Prefs extends Fake implements Prefs { + @override + bool get useTor => false; + @override + bool get wifiOnly => false; + @override + bool get externalCalls => false; +} + +class _Trades extends Fake implements TradesService {} + +Future _until(bool Function() done, String waitingFor) async { + final watch = Stopwatch()..start(); + while (!done()) { + if (watch.elapsed > const Duration(seconds: 2)) { + fail('Timed out waiting for $waitingFor'); + } + await Future.delayed(const Duration(milliseconds: 5)); + } +} + +void main() { + test('Forget retires a pending background notification connection', () async { + await setUpHiveCeTest(); + final logs = await Directory.systemTemp.createTemp('notification_tls'); + await Logging.instance.initialize(logs.path, level: Level.off); + DB.instance.hive.registerAdapter(NodeModelAdapter()); + DB.instance.hive.registerAdapter(NotificationModelAdapter()); + final nodes = await DB.instance.hive.openBox( + DB.boxNameNodeModels, + ); + final watched = await DB.instance.hive.openBox( + DB.boxNameWatchedTransactions, + ); + final server = await TlsElectrumServer.start(respond: false); + final wallet = _Wallet(); + final coin = wallet.cryptoCurrency; + final node = NodeModel( + id: 'n', + host: '127.0.0.1', + port: server.port, + name: 'Node', + useSSL: true, + enabled: true, + coinName: coin.identifier, + isFailover: false, + isDown: false, + torEnabled: true, + clearnetEnabled: true, + isPrimary: true, + trustedCertificatePem: TlsElectrumServer.certificate('node_a'), + ); + await nodes.put(node.id, node); + await watched.put( + 0, + NotificationModel( + id: 0, + title: 'Transaction (0/${coin.minConfirms})', + description: '', + iconAssetName: '', + date: DateTime.now(), + walletId: wallet.walletId, + read: false, + shouldWatchForUpdates: true, + txid: 'a' * 64, + coinName: coin.identifier, + ), + ); + Wallets.sharedInstance.addWallet(wallet); + final service = NodeService(secureStorageInterface: FakeSecureStorage()); + final notifications = NotificationsService.instance; + await notifications.init( + nodeService: service, + tradesService: _Trades(), + prefs: _Prefs(), + ); + final interval = NotificationsService.notificationRefreshInterval; + addTearDown(() async { + notifications.stopCheckingWatchedTransactions(); + NotificationsService.notificationRefreshInterval = interval; + await ClientManager.sharedInstance.closeAll(); + await server.close(); + await tearDownHiveCeTest(); + await logs.delete(recursive: true); + }); + NotificationsService.notificationRefreshInterval = const Duration( + milliseconds: 50, + ); + notifications.startCheckingWatchedNotifications(); + await _until( + () => server.methods.contains('server.version'), + 'notification RPC setup', + ); + notifications.stopCheckingWatchedTransactions(); + expect(server.disconnections, 0); + await service.save( + NodeModel.fromStackBackup({ + ...node.toMap(), + 'id': 'other-coin', + 'coinName': 'litecoin', + }), + null, + true, + ); + await Future.delayed(const Duration(milliseconds: 20)); + expect( + server.disconnections, + 0, + reason: 'unrelated node changes keep the connection', + ); + await service.forgetTrustedCertificate(node); + await _until( + () => server.disconnections > 0, + 'Forget to disconnect the notification', + ); + expect(nodes.get(node.id)!.trustedCertificatePem, null); + expect( + ClientManager.sharedInstance.getClient( + cryptoCurrency: coin, + netType: TorPlainNetworkOption.both, + ), + null, + ); + expect(server.methods, isNot(contains('blockchain.transaction.get'))); + }); +} diff --git a/test/utilities/electrum_connection_check_test.dart b/test/utilities/electrum_connection_check_test.dart new file mode 100644 index 0000000000..c29f0387ba --- /dev/null +++ b/test/utilities/electrum_connection_check_test.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/connection_check/electrum_connection_check.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/prefs.dart'; + +import 'tls_test_server.dart'; + +class _TorPrefs extends Mock implements Prefs { + @override + bool get useTor => true; + @override + bool get torKillSwitch => false; +} + +class _ConnectedTor extends Mock implements TorService { + _ConnectedTor(this.proxy); + final ({InternetAddress host, int port}) proxy; + @override + TorConnectionStatus get status => TorConnectionStatus.connected; + @override + ({InternetAddress host, int port}) getProxyInfo() => proxy; +} + +void main() { + late Directory logDirectory; + setUpAll(() async { + logDirectory = await Directory.systemTemp.createTemp('electrum_check'); + await Logging.instance.initialize(logDirectory.path, level: Level.off); + }); + tearDownAll(() => logDirectory.delete(recursive: true)); + + // The test server and the test SOCKS proxy both listen on 127.0.0.1; the + // onion name is only ever sent to that proxy, never resolved. + Future withOnionServer( + String? certificateName, + Future Function(TlsElectrumServer server, TlsTestProxy proxy) body, + ) async { + final server = await TlsElectrumServer.start( + certificateName: certificateName, + ); + final proxy = await TlsTestProxy.start(server.port); + try { + await body(server, proxy); + } finally { + await proxy.close(); + await server.close(); + } + } + + test('an onion node with SSL is tested over TLS and can be trusted', () { + return withOnionServer('node_a', (server, proxy) async { + final rejected = await checkElectrumServer( + host: 'tlsx.test.onion', + port: server.port, + useSSL: true, + overridePrefs: _TorPrefs(), + overrideTorService: _ConnectedTor(proxy.info), + ); + expect(rejected.success, false); + expect(rejected.certificate, isNotNull); + expect(server.requests, 0); + final approved = await checkElectrumServer( + host: 'tlsx.test.onion', + port: server.port, + useSSL: true, + trustedCertificatePem: rejected.certificate!.pem, + overridePrefs: _TorPrefs(), + overrideTorService: _ConnectedTor(proxy.info), + ); + expect(approved.success, true, reason: approved.error?.toString()); + expect(server.requests, 1); + expect(proxy.destinations, everyElement('tlsx.test.onion')); + }); + }); + + test('an onion node without SSL is tested in plain text', () { + return withOnionServer(null, (server, proxy) async { + final result = await checkElectrumServer( + host: 'tlsx.test.onion', + port: server.port, + useSSL: false, + overridePrefs: _TorPrefs(), + overrideTorService: _ConnectedTor(proxy.info), + ); + expect(result.success, true, reason: result.error?.toString()); + expect(result.certificate, null); + expect(proxy.destinations, ['tlsx.test.onion']); + }); + }); +} diff --git a/test/utilities/electrum_tls_test.dart b/test/utilities/electrum_tls_test.dart new file mode 100644 index 0000000000..32b1f044fa --- /dev/null +++ b/test/utilities/electrum_tls_test.dart @@ -0,0 +1,232 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:electrum_adapter/electrum_adapter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/connection_check/electrum_connection_check.dart'; +import 'package:stackwallet/utilities/tls_certificate.dart'; + +import 'tls_test_server.dart'; + +void main() { + final servers = []; + final proxies = []; + + Future start({ + String? certificateName = 'node_a', + bool respond = true, + }) async { + final server = await TlsElectrumServer.start( + certificateName: certificateName, + respond: respond, + ); + servers.add(server); + return server; + } + + Future check( + TlsElectrumServer server, { + String? trustedCertificatePem, + String host = '127.0.0.1', + bool useSSL = true, + ({InternetAddress host, int port})? proxyInfo, + Duration timeout = const Duration(seconds: 2), + }) => testElectrumConnection( + host: host, + port: server.port, + useSSL: useSSL, + trustedCertificatePem: trustedCertificatePem, + proxyInfo: proxyInfo, + timeout: timeout, + ); + + tearDown(() async { + for (final proxy in proxies) { + await proxy.close(); + } + for (final server in servers) { + await server.close(); + } + proxies.clear(); + servers.clear(); + }); + + test('untrusted TLS returns a certificate without sending an RPC', () async { + final server = await start(); + final result = await check(server); + expect(result.success, false); + expect(result.certificate, isNotNull); + expect( + certificateDer(result.certificate!.pem), + certificateDer(TlsElectrumServer.certificate('node_a')), + ); + expect( + trustedCertificateCheck(TlsElectrumServer.certificate('node_a'))!( + result.certificate!, + ), + true, + ); + expect( + trustedCertificateCheck(TlsElectrumServer.certificate('node_b'))!( + result.certificate!, + ), + false, + ); + expect(server.requests, 0); + }); + + test('approval enables RPC and closes the test connection', () async { + final server = await start(); + final rejected = await check(server); + final approved = await check( + server, + trustedCertificatePem: rejected.certificate!.pem, + ); + expect(approved.success, true, reason: approved.error?.toString()); + expect(server.requests, 1); + await Future.delayed(const Duration(milliseconds: 30)); + expect(server.disconnections, 1); + }); + + test('approval does not change the default trust store', () async { + final server = await start(); + expect( + (await check( + server, + trustedCertificatePem: TlsElectrumServer.certificate('node_a'), + )).success, + true, + ); + expect((await check(server)).success, false); + }); + + test('another certificate is rejected and returned for review', () async { + final server = await start(certificateName: 'node_b'); + final result = await check( + server, + trustedCertificatePem: TlsElectrumServer.certificate('node_a'), + ); + expect(result.success, false); + expect( + certificateDer(result.certificate!.pem), + certificateDer(TlsElectrumServer.certificate('node_b')), + ); + expect(server.requests, 0); + }); + + test('invalid saved PEM fails without connecting', () async { + final server = await start(); + final result = await check(server, trustedCertificatePem: 'invalid'); + expect(result.success, false); + expect(result.error, isA()); + expect(server.requests, 0); + }); + + test('approval pins the certificate even after it expired', () async { + final server = await start(certificateName: 'expired'); + final rejected = await check(server); + expect(rejected.success, false); + expect(rejected.certificate, isNotNull); + final approved = await check( + server, + trustedCertificatePem: TlsElectrumServer.certificate('expired'), + ); + expect(approved.success, true, reason: approved.error?.toString()); + expect(server.requests, 1); + }); + + test('plain TCP can be tested without a certificate', () async { + final server = await start(certificateName: null); + final result = await check(server, useSSL: false); + expect(result.success, true); + expect(result.certificate, null); + }); + + test('RPC timeout fails and closes the connection', () async { + final server = await start(respond: false); + final result = await check( + server, + trustedCertificatePem: TlsElectrumServer.certificate('node_a'), + timeout: const Duration(milliseconds: 200), + ); + expect(result.success, false); + expect(result.certificate, null); + await Future.delayed(const Duration(milliseconds: 30)); + expect(server.disconnections, 1); + }); + + test('SOCKS TLS rejection and approval use the same proxy', () async { + final server = await start(); + final proxy = await TlsTestProxy.start(server.port); + proxies.add(proxy); + final rejected = await check( + server, + host: 'tlsx.test.onion', + proxyInfo: proxy.info, + ); + expect(rejected.success, false); + expect(rejected.certificate, isNotNull); + expect(server.requests, 0); + final approved = await check( + server, + host: 'tlsx.test.onion', + proxyInfo: proxy.info, + trustedCertificatePem: rejected.certificate!.pem, + ); + expect(approved.success, true, reason: approved.error?.toString()); + expect(proxy.destinations, everyElement('tlsx.test.onion')); + expect(proxy.destinations.length, 2); + }); + + test('approved certificate does not have to match the hostname', () async { + final server = await start(); + final proxy = await TlsTestProxy.start(server.port); + proxies.add(proxy); + final result = await check( + server, + host: 'wrong.example', + proxyInfo: proxy.info, + trustedCertificatePem: TlsElectrumServer.certificate('node_a'), + ); + expect(result.success, true, reason: result.error?.toString()); + expect(server.requests, 1); + }); + + for (final proxied in [false, true]) { + test( + 'a stalled ${proxied ? 'SOCKS' : 'direct'} TLS handshake releases its socket', + () async { + final stalled = await TlsElectrumServer.start(stallHandshake: true); + servers.add(stalled); + final proxy = proxied ? await TlsTestProxy.start(stalled.port) : null; + if (proxy != null) proxies.add(proxy); + final result = await check( + stalled, + proxyInfo: proxy?.info, + timeout: const Duration(milliseconds: 300), + ).timeout(const Duration(seconds: 3)); + expect(result.success, false); + expect(result.error, isA()); + expect(result.certificate, null); + final watch = Stopwatch()..start(); + while (stalled.disconnections == 0 && + watch.elapsed < const Duration(seconds: 2)) { + await Future.delayed(const Duration(milliseconds: 5)); + } + expect(stalled.accepted, 1); + expect(stalled.disconnections, 1); + }, + ); + } + + test('PEM parsing accepts one certificate and rejects bundles', () { + final pem = TlsElectrumServer.certificate('node_a'); + expect( + certificateFingerprint(certificateDer(pem)), + matches(RegExp(r'^(?:[0-9A-F]{2}:){31}[0-9A-F]{2}$')), + ); + expect(() => certificateDer('$pem$pem'), throwsFormatException); + expect(trustedCertificateCheck(null), null); + expect(() => trustedCertificateCheck('invalid'), throwsFormatException); + }); +} diff --git a/test/utilities/mock_electrum_server.dart b/test/utilities/mock_electrum_server.dart index 6802c0cec6..8bd3c62a6d 100644 --- a/test/utilities/mock_electrum_server.dart +++ b/test/utilities/mock_electrum_server.dart @@ -166,6 +166,7 @@ class ManagedElectrumXClient extends ElectrumXClient { final existing = getElectrumAdapter(); if (existing != null && !existing.peer.isClosed) { + await super.checkElectrumAdapter(); return; } if (existing != null) { @@ -184,6 +185,7 @@ class ManagedElectrumXClient extends ElectrumXClient { cryptoCurrency: cryptoCurrency, netType: netType, ); + await super.checkElectrumAdapter(); } } diff --git a/test/utilities/monero_socks_write_test.dart b/test/utilities/monero_socks_write_test.dart new file mode 100644 index 0000000000..227b9526e2 --- /dev/null +++ b/test/utilities/monero_socks_write_test.dart @@ -0,0 +1,116 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:async/async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/test_monero_node_connection.dart'; + +class _Reader { + _Reader(Socket socket) : input = StreamQueue(socket); + final StreamQueue> input; + final bytes = []; + Future> take(int count) async { + while (bytes.length < count) { + bytes.addAll(await input.next); + } + final result = bytes.sublist(0, count); + bytes.removeRange(0, count); + return result; + } +} + +void main() { + test('Monero handles SOCKS resets without uncaught write errors', () async { + final logs = await Directory.systemTemp.createTemp( + 'monero_dependency_probe', + ); + await Logging.instance.initialize(logs.path, level: Level.off); + final proxy = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final peers = []; + final handlers = >[]; + var accepted = 0; + final subscription = proxy.listen((socket) { + peers.add(socket); + final ordinal = accepted++; + final handler = () async { + final reader = _Reader(socket); + try { + final greeting = await reader.take(2); + await reader.take(greeting[1]); + socket.add([5, 0]); + final connect = await reader.take(4); + if (connect[3] == 3) { + await reader.take((await reader.take(1)).single); + } else { + await reader.take(4); + } + await reader.take(2); + socket.add([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]); + await socket.flush(); + if (ordinal % 3 != 0) { + await Future.delayed(Duration(milliseconds: ordinal % 3)); + } + final linger = Int32List.fromList([1, 0]); + socket.setRawOption( + RawSocketOption( + RawSocketOption.levelSocket, + Platform.isMacOS ? 0x0080 : 13, + linger.buffer.asUint8List(), + ), + ); + socket.destroy(); + } catch (_) { + socket.destroy(); + } finally { + await reader.input.cancel(); + } + }(); + handlers.add(handler); + }); + addTearDown(() async { + for (final peer in peers) { + peer.destroy(); + } + await subscription.cancel(); + await proxy.close(); + await Future.wait(handlers); + await logs.delete(recursive: true); + }); + + final results = >[]; + for (var i = 0; i < 15; i++) { + final uncaught = []; + final completed = Completer>(); + runZonedGuarded( + () async { + Object? outcome; + try { + outcome = (await testMoneroNodeConnection( + Uri.parse('http://node.example.onion:18081/json_rpc'), + null, + null, + false, + proxyInfo: (host: InternetAddress.loopbackIPv4, port: proxy.port), + ).timeout(const Duration(milliseconds: 500))).success; + } catch (error) { + outcome = error.runtimeType.toString(); + } + await Future.delayed(const Duration(milliseconds: 20)); + completed.complete({'outcome': outcome, 'uncaught': uncaught}); + }, + (Object error, StackTrace stack) { + uncaught.add('${error.runtimeType}: $error'); + }, + ); + results.add(await completed.future.timeout(const Duration(seconds: 2))); + } + expect(results, hasLength(15)); + expect( + results.expand((result) => result['uncaught'] as List), + isEmpty, + ); + }); +} diff --git a/test/utilities/test_electrum_node_connection_test.dart b/test/utilities/test_electrum_node_connection_test.dart new file mode 100644 index 0000000000..c4de700913 --- /dev/null +++ b/test/utilities/test_electrum_node_connection_test.dart @@ -0,0 +1,205 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/node_form_data.dart'; +import 'package:stackwallet/utilities/connection_check/electrum_connection_check.dart'; +import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; + +import 'tls_test_server.dart'; + +void main() { + late TlsElectrumServer server; + late NodeFormData data; + int approvals = 0; + int successes = 0; + + setUp(() async { + server = await TlsElectrumServer.start(); + data = NodeFormData() + ..host = '127.0.0.1' + ..port = server.port + ..useSSL = true; + approvals = 0; + successes = 0; + }); + tearDown(() => server.close()); + + Future testNode({ + bool approve = true, + Future Function()? approval, + ElectrumNodeTester? tester, + bool Function()? isActive, + }) => testElectrumNodeConnection( + data, + isActive: isActive ?? () => true, + onSuccess: (_) => successes++, + requestTrust: (certificate, host, port) async { + approvals++; + expect(host, '127.0.0.1'); + expect(port, server.port); + return approval == null ? approve : await approval(); + }, + testConnection: + tester ?? + ({ + required host, + required port, + required useSSL, + trustedCertificatePem, + }) => testElectrumConnection( + host: host, + port: port, + useSSL: useSSL, + trustedCertificatePem: trustedCertificatePem, + timeout: const Duration(seconds: 2), + ), + ); + + test('Cancel leaves the form untrusted and sends no RPC', () async { + expect(await testNode(approve: false), false); + expect(data.trustedCertificatePem, null); + expect(approvals, 1); + expect(successes, 0); + expect(server.requests, 0); + }); + + test( + 'Trust saves the reviewed certificate after a successful RPC retry', + () async { + expect(await testNode(), true); + expect( + data.trustedCertificatePem, + TlsElectrumServer.certificate('node_a'), + ); + expect(approvals, 1); + expect(successes, 1); + expect(server.requests, 1); + expect(await testNode(), true); + expect(approvals, 1); + expect(successes, 2); + }, + ); + + test('a failed retry saves no approval', () async { + final rejected = await testElectrumConnection( + host: data.host!, + port: data.port!, + useSSL: true, + timeout: const Duration(seconds: 2), + ); + expect( + await testNode( + tester: + ({ + required host, + required port, + required useSSL, + trustedCertificatePem, + }) async => trustedCertificatePem == null + ? rejected + : const ElectrumConnectionResult( + success: false, + error: 'still rejected', + ), + ), + false, + ); + expect(data.trustedCertificatePem, null); + expect(successes, 0); + }); + + test('editing the endpoint during review cancels the retry', () async { + expect( + await testNode( + approval: () async { + data.host = 'another.example'; + return true; + }, + ), + false, + ); + expect(data.trustedCertificatePem, null); + expect(server.requests, 0); + }); + + test('leaving the screen during review cancels the retry', () async { + bool active = true; + expect( + await testNode( + isActive: () => active, + approval: () async { + active = false; + return true; + }, + ), + false, + ); + expect(data.trustedCertificatePem, null); + expect(server.requests, 0); + }); + + test('changing the network option during review cancels approval', () async { + expect( + await testNode( + approval: () async { + data.netOption = TorPlainNetworkOption.tor; + return true; + }, + ), + false, + ); + expect(data.trustedCertificatePem, null); + expect(server.requests, 0); + }); + + test('a late successful retry cannot trust a changed endpoint', () async { + final retry = Completer(); + final retryStarted = Completer(); + final rejected = await testElectrumConnection( + host: data.host!, + port: data.port!, + useSSL: true, + timeout: const Duration(seconds: 2), + ); + final result = testNode( + tester: + ({ + required host, + required port, + required useSSL, + trustedCertificatePem, + }) async { + if (trustedCertificatePem == null) return rejected; + retryStarted.complete(); + return retry.future; + }, + ); + await retryStarted.future; + data.port = data.port! + 1; + retry.complete(const ElectrumConnectionResult(success: true)); + expect(await result, false); + expect(data.trustedCertificatePem, null); + expect(successes, 0); + }); + for (final clearThenRestore in [false, true]) { + test( + 'an intervening certificate decision cancels review ($clearThenRestore)', + () async { + final before = data.trustedCertificatePem; + expect( + await testNode( + approval: () async { + data.forgetCertificate(); + if (clearThenRestore) data.trustedCertificatePem = before; + return true; + }, + ), + false, + ); + expect(data.trustedCertificatePem, null); + expect(server.requests, 0); + expect(successes, 0); + }, + ); + } +} diff --git a/test/utilities/tls_test_server.dart b/test/utilities/tls_test_server.dart new file mode 100644 index 0000000000..e5af73d542 --- /dev/null +++ b/test/utilities/tls_test_server.dart @@ -0,0 +1,324 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:async/async.dart'; +import 'package:basic_utils/basic_utils.dart'; +import 'package:pointycastle/asn1.dart'; +import 'package:pointycastle/export.dart'; + +/// Certificates for the test servers, built in Dart so the tests need no +/// native tooling. The 'expired' certificate is a committed fixture; the +/// others are generated once per test process from the committed test keys. +final _certificates = {}; + +String _keyPem(String name) => + File('test/fixtures/tls/$name.key.pem').readAsStringSync(); + +String _certificatePem(String name) { + if (name == 'expired') { + return File('test/fixtures/tls/expired.pem').readAsStringSync(); + } + return _certificates[name] ??= generateServerCertificatePem( + privateKeyPem: _keyPem(name), + commonName: 'localhost', + validity: const Duration(days: 1), + ); +} + +/// A self-signed X.509 server certificate, SHA-256 with RSA, in PEM. +String generateServerCertificatePem({ + required String privateKeyPem, + required String commonName, + required Duration validity, +}) { + final privateKey = CryptoUtils.rsaPrivateKeyFromPem(privateKeyPem); + final now = DateTime.now().toUtc(); + + ASN1Sequence algorithm(String oid) => ASN1Sequence( + elements: [ASN1ObjectIdentifier.fromIdentifierString(oid), ASN1Null()], + ); + final name = ASN1Sequence( + elements: [ + ASN1Set( + elements: [ + ASN1Sequence( + elements: [ + ASN1ObjectIdentifier.fromIdentifierString('2.5.4.3'), + ASN1UTF8String(utf8StringValue: commonName), + ], + ), + ], + ), + ], + ); + final publicKeyInfo = ASN1Sequence( + elements: [ + algorithm('1.2.840.113549.1.1.1'), + ASN1BitString( + stringValues: ASN1Sequence( + elements: [ + ASN1Integer(privateKey.modulus!), + ASN1Integer(privateKey.publicExponent!), + ], + ).encode(), + ), + ], + ); + final random = Random.secure(); + final serial = + (BigInt.from(random.nextInt(1 << 31)) << 32) + + BigInt.from(random.nextInt(1 << 32)); + final tbsCertificate = ASN1Sequence( + elements: [ + ASN1Integer(serial), + algorithm('1.2.840.113549.1.1.11'), + name, + ASN1Sequence( + elements: [ + ASN1UtcTime(now.subtract(const Duration(hours: 1))), + ASN1UtcTime(now.add(validity)), + ], + ), + name, + publicKeyInfo, + ], + ); + final signer = RSASigner(SHA256Digest(), '0609608648016503040201') + ..init(true, PrivateKeyParameter(privateKey)); + final signature = signer.generateSignature(tbsCertificate.encode()); + final certificate = ASN1Sequence( + elements: [ + tbsCertificate, + algorithm('1.2.840.113549.1.1.11'), + ASN1BitString(stringValues: signature.bytes), + ], + ); + final body = base64Encode(certificate.encode()); + final lines = [ + for (var i = 0; i < body.length; i += 64) + body.substring(i, min(i + 64, body.length)), + ]; + return '-----BEGIN CERTIFICATE-----\n${lines.join('\n')}\n' + '-----END CERTIFICATE-----\n'; +} + +/// Loopback fixtures only; the private keys under fixtures/tls are test keys. +class TlsElectrumServer { + TlsElectrumServer._(this._server); + + final ServerSocket _server; + final _sockets = []; + late final StreamSubscription _subscription; + int requests = 0; + final methods = []; + int disconnections = 0; + int accepted = 0; + int get port => _server.port; + + static String certificate(String name) => _certificatePem(name); + + /// With [versionError], server.version is answered with a JSON-RPC error, + /// as a server that rejects the client would. + static Future start({ + String? certificateName = 'node_a', + bool respond = true, + bool versionError = false, + bool stallHandshake = false, + Set ignoredMethods = const {}, + }) async { + final server = TlsElectrumServer._( + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0), + ); + final context = certificateName == null + ? null + : (SecurityContext() + ..useCertificateChainBytes( + utf8.encode(_certificatePem(certificateName)), + ) + ..usePrivateKeyBytes(utf8.encode(_keyPem(certificateName)))); + server._subscription = server._server.listen((raw) async { + server.accepted++; + server._sockets.add(raw); + raw.done.ignore(); + if (stallHandshake) { + raw.listen( + (_) {}, + onError: (Object _) {}, + onDone: () => server.disconnections++, + ); + return; + } + try { + final socket = context == null + ? raw + : await SecureSocket.secureServer(raw, context); + server._sockets.add(socket); + socket.done.ignore(); + socket + .cast>() + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen( + (line) { + final decoded = jsonDecode(line); + final replies = >[]; + for (final request in decoded is List ? decoded : [decoded]) { + server.requests++; + server.methods.add(request['method'] as String); + if (!respond || ignoredMethods.contains(request['method'])) { + continue; + } + if (versionError && request['method'] == 'server.version') { + replies.add({ + 'jsonrpc': '2.0', + 'id': request['id'], + 'error': {'code': -32601, 'message': 'unsupported'}, + }); + continue; + } + final Object result; + switch (request['method']) { + case 'server.version': + result = ['ElectrumX TLS test', '1.4']; + case 'blockchain.headers.subscribe': + result = {'height': 100, 'hex': '00' * 80}; + case 'blockchain.transaction.get': + result = { + 'txid': request['params'][0], + 'confirmations': 3, + }; + default: + result = {}; + } + replies.add({ + 'jsonrpc': '2.0', + 'id': request['id'], + 'result': result, + }); + } + if (replies.isNotEmpty) { + socket.write( + '${jsonEncode(decoded is List ? replies : replies.single)}\n', + ); + } + }, + onError: (Object _) {}, + onDone: () { + server.disconnections++; + }, + ); + } catch (_) { + raw.destroy(); + } + }); + return server; + } + + void disconnectClients() { + for (final socket in _sockets) { + socket.destroy(); + } + _sockets.clear(); + } + + Future close() async { + disconnectClients(); + await _subscription.cancel(); + await _server.close(); + } +} + +/// A local SOCKS5 tunnel that records destination names without DNS lookups. +class TlsTestProxy { + TlsTestProxy._(this._server, this.destinationPort, this.greetingDelay); + + final ServerSocket _server; + final int destinationPort; + final Duration greetingDelay; + final destinations = []; + final _sockets = []; + late final StreamSubscription _subscription; + ({InternetAddress host, int port}) get info => + (host: InternetAddress.loopbackIPv4, port: _server.port); + + static Future start( + int destinationPort, { + Duration greetingDelay = Duration.zero, + }) async { + final proxy = TlsTestProxy._( + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0), + destinationPort, + greetingDelay, + ); + proxy._subscription = proxy._server.listen(proxy._forward); + return proxy; + } + + Future _forward(Socket client) async { + _sockets.add(client); + final input = _SocketReader(client); + bool inputForwarded = false; + try { + final greeting = await input.read(2); + await input.read(greeting[1]); + if (greetingDelay != Duration.zero) { + await Future.delayed(greetingDelay); + } + client.add([5, 0]); + final header = await input.read(4); + final String host; + switch (header[3]) { + case 3: + final length = (await input.read(1)).single; + host = utf8.decode(await input.read(length)); + case 1: + host = (await input.read(4)).join('.'); + default: + throw StateError('Unexpected SOCKS destination type'); + } + await input.read(2); + destinations.add(host); + final upstream = await Socket.connect( + InternetAddress.loopbackIPv4, + destinationPort, + ); + _sockets.add(upstream); + client.add([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]); + if (input.pending.isNotEmpty) upstream.add(input.pending); + final remainingInput = input.queue.rest; + inputForwarded = true; + await Future.wait([ + remainingInput.pipe(upstream), + upstream.cast>().pipe(client), + ]); + } catch (_) { + client.destroy(); + if (!inputForwarded) await input.queue.cancel(); + } + } + + Future close() async { + for (final socket in _sockets) { + socket.destroy(); + } + await _subscription.cancel(); + await _server.close(); + } +} + +class _SocketReader { + _SocketReader(Socket socket) : queue = StreamQueue(socket.cast>()); + final StreamQueue> queue; + List pending = []; + + Future> read(int count) async { + while (pending.length < count) { + pending.addAll(await queue.next); + } + final value = pending.sublist(0, count); + pending = pending.sublist(count); + return value; + } +} diff --git a/test/widget_tests/node_card_test.dart b/test/widget_tests/node_card_test.dart index f159dab708..f57a62b60b 100644 --- a/test/widget_tests/node_card_test.dart +++ b/test/widget_tests/node_card_test.dart @@ -77,9 +77,8 @@ void main() { when( nodeService.getPrimaryNodeFor(currency: bitcoin), ).thenAnswer((_) => buildNode(id: 'other node id', name: 'Stack Default')); - when( - nodeService.getNodeById(id: 'node id'), - ).thenAnswer((_) => buildNode(id: 'node id', name: 'some other name')); + when(nodeService.getNodeById(id: 'node id')) + .thenAnswer((_) => buildNode(id: 'node id', name: 'some other name')); await pumpSubject( tester, @@ -101,9 +100,8 @@ void main() { final nodeService = MockNodeService(); final activeNode = buildNode(id: 'node id', name: 'Some other node name'); - when( - nodeService.getPrimaryNodeFor(currency: bitcoin), - ).thenAnswer((_) => activeNode); + when(nodeService.getPrimaryNodeFor(currency: bitcoin)) + .thenAnswer((_) => activeNode); when(nodeService.getNodeById(id: 'node id')).thenAnswer((_) => activeNode); await pumpSubject( @@ -127,9 +125,8 @@ void main() { final nodeService = MockNodeService(); final activeNode = buildNode(id: 'node id', name: 'Stack Default'); - when( - nodeService.getPrimaryNodeFor(currency: bitcoin), - ).thenAnswer((_) => activeNode); + when(nodeService.getPrimaryNodeFor(currency: bitcoin)) + .thenAnswer((_) => activeNode); when(nodeService.getNodeById(id: 'node id')).thenAnswer((_) => activeNode); await pumpSubject( @@ -178,9 +175,8 @@ void main() { when(nodeService.getPrimaryNodeFor(currency: bitcoin)).thenAnswer( (_) => buildNode(id: 'other node id', name: 'Some other node name'), ); - when( - nodeService.getNodeById(id: 'node id'), - ).thenAnswer((_) => disconnectedNode); + when(nodeService.getNodeById(id: 'node id')) + .thenAnswer((_) => disconnectedNode); await pumpSubject( tester, @@ -211,10 +207,9 @@ void main() { expect(platformOverrides.connectionInvocations.single.host, '127.0.0.1'); verifyNever( - nodeService.setPrimaryNodeFor( - coin: bitcoin, + nodeService.commitConnectionTest( node: anyNamed('node'), - shouldNotifyListeners: anyNamed('shouldNotifyListeners'), + formData: anyNamed('formData'), ), ); }, diff --git a/test/widget_tests/node_card_test.mocks.dart b/test/widget_tests/node_card_test.mocks.dart index 73db103f60..e971d422f3 100644 --- a/test/widget_tests/node_card_test.mocks.dart +++ b/test/widget_tests/node_card_test.mocks.dart @@ -8,6 +8,7 @@ import 'dart:ui' as _i8; import 'package:mockito/mockito.dart' as _i1; import 'package:stackwallet/models/epicbox_server_model.dart' as _i7; +import 'package:stackwallet/models/node_form_data.dart' as _iTls; import 'package:stackwallet/models/node_model.dart' as _i4; import 'package:stackwallet/services/node_service.dart' as _i3; import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart' @@ -45,31 +46,25 @@ class MockNodeService extends _i1.Mock implements _i3.NodeService { } @override - _i2.SecureStorageInterface get secureStorageInterface => - (super.noSuchMethod( - Invocation.getter(#secureStorageInterface), - returnValue: _FakeSecureStorageInterface_0( - this, - Invocation.getter(#secureStorageInterface), - ), - ) - as _i2.SecureStorageInterface); + _i2.SecureStorageInterface get secureStorageInterface => (super.noSuchMethod( + Invocation.getter(#secureStorageInterface), + returnValue: _FakeSecureStorageInterface_0( + this, + Invocation.getter(#secureStorageInterface), + ), + ) as _i2.SecureStorageInterface); @override - List<_i4.NodeModel> get primaryNodes => - (super.noSuchMethod( - Invocation.getter(#primaryNodes), - returnValue: <_i4.NodeModel>[], - ) - as List<_i4.NodeModel>); + List<_i4.NodeModel> get primaryNodes => (super.noSuchMethod( + Invocation.getter(#primaryNodes), + returnValue: <_i4.NodeModel>[], + ) as List<_i4.NodeModel>); @override - List<_i4.NodeModel> get nodes => - (super.noSuchMethod( - Invocation.getter(#nodes), - returnValue: <_i4.NodeModel>[], - ) - as List<_i4.NodeModel>); + List<_i4.NodeModel> get nodes => (super.noSuchMethod( + Invocation.getter(#nodes), + returnValue: <_i4.NodeModel>[], + ) as List<_i4.NodeModel>); @override bool get hasListeners => @@ -77,45 +72,39 @@ class MockNodeService extends _i1.Mock implements _i3.NodeService { as bool); @override - _i5.Future updateDefaults() => - (super.noSuchMethod( - Invocation.method(#updateDefaults, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i5.Future updateDefaults() => (super.noSuchMethod( + Invocation.method(#updateDefaults, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override _i5.Future setPrimaryNodeFor({ required _i6.CryptoCurrency? coin, required _i4.NodeModel? node, bool? shouldNotifyListeners = false, - }) => - (super.noSuchMethod( - Invocation.method(#setPrimaryNodeFor, [], { - #coin: coin, - #node: node, - #shouldNotifyListeners: shouldNotifyListeners, - }), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + }) => (super.noSuchMethod( + Invocation.method(#setPrimaryNodeFor, [], { + #coin: coin, + #node: node, + #shouldNotifyListeners: shouldNotifyListeners, + }), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override _i4.NodeModel? getPrimaryNodeFor({required _i6.CryptoCurrency? currency}) => (super.noSuchMethod( - Invocation.method(#getPrimaryNodeFor, [], {#currency: currency}), - ) - as _i4.NodeModel?); + Invocation.method(#getPrimaryNodeFor, [], {#currency: currency}), + ) as _i4.NodeModel?); @override List<_i4.NodeModel> getNodesFor(_i6.CryptoCurrency? coin) => (super.noSuchMethod( - Invocation.method(#getNodesFor, [coin]), - returnValue: <_i4.NodeModel>[], - ) - as List<_i4.NodeModel>); + Invocation.method(#getNodesFor, [coin]), + returnValue: <_i4.NodeModel>[], + ) as List<_i4.NodeModel>); @override _i4.NodeModel? getNodeById({required String? id}) => @@ -125,83 +114,78 @@ class MockNodeService extends _i1.Mock implements _i3.NodeService { @override List<_i4.NodeModel> failoverNodesFor({ required _i6.CryptoCurrency? currency, - }) => - (super.noSuchMethod( - Invocation.method(#failoverNodesFor, [], {#currency: currency}), - returnValue: <_i4.NodeModel>[], - ) - as List<_i4.NodeModel>); + }) => (super.noSuchMethod( + Invocation.method(#failoverNodesFor, [], {#currency: currency}), + returnValue: <_i4.NodeModel>[], + ) as List<_i4.NodeModel>); @override _i5.Future save( _i4.NodeModel? node, String? password, bool? shouldNotifyListeners, - ) => - (super.noSuchMethod( - Invocation.method(#save, [node, password, shouldNotifyListeners]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + ) => (super.noSuchMethod( + Invocation.method(#save, [node, password, shouldNotifyListeners]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future commitConnectionTest({ + required _i4.NodeModel? node, + required _iTls.NodeFormData? formData, + }) => (super.noSuchMethod( + Invocation.method(#commitConnectionTest, [], { + #node: node, + #formData: formData, + }), + returnValue: _i5.Future.value(false), + ) as _i5.Future); @override _i5.Future delete(String? id, bool? shouldNotifyListeners) => (super.noSuchMethod( - Invocation.method(#delete, [id, shouldNotifyListeners]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + Invocation.method(#delete, [id, shouldNotifyListeners]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override _i5.Future setEnabledState( String? id, bool? enabled, bool? shouldNotifyListeners, - ) => - (super.noSuchMethod( - Invocation.method(#setEnabledState, [ - id, - enabled, - shouldNotifyListeners, - ]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + ) => (super.noSuchMethod( + Invocation.method(#setEnabledState, [id, enabled, shouldNotifyListeners]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i5.Future updateDefaultEpicBoxes() => - (super.noSuchMethod( - Invocation.method(#updateDefaultEpicBoxes, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i5.Future updateDefaultEpicBoxes() => (super.noSuchMethod( + Invocation.method(#updateDefaultEpicBoxes, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override _i5.Future setPrimaryEpicBox({ required _i7.EpicBoxServerModel? epicBox, bool? shouldNotifyListeners = false, - }) => - (super.noSuchMethod( - Invocation.method(#setPrimaryEpicBox, [], { - #epicBox: epicBox, - #shouldNotifyListeners: shouldNotifyListeners, - }), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + }) => (super.noSuchMethod( + Invocation.method(#setPrimaryEpicBox, [], { + #epicBox: epicBox, + #shouldNotifyListeners: shouldNotifyListeners, + }), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - List<_i7.EpicBoxServerModel> getEpicBoxes() => - (super.noSuchMethod( - Invocation.method(#getEpicBoxes, []), - returnValue: <_i7.EpicBoxServerModel>[], - ) - as List<_i7.EpicBoxServerModel>); + List<_i7.EpicBoxServerModel> getEpicBoxes() => (super.noSuchMethod( + Invocation.method(#getEpicBoxes, []), + returnValue: <_i7.EpicBoxServerModel>[], + ) as List<_i7.EpicBoxServerModel>); @override _i7.EpicBoxServerModel? getEpicBoxById({required String? id}) => @@ -212,31 +196,26 @@ class MockNodeService extends _i1.Mock implements _i3.NodeService { _i5.Future addEpicBox( _i7.EpicBoxServerModel? epicBox, bool? shouldNotifyListeners, - ) => - (super.noSuchMethod( - Invocation.method(#addEpicBox, [epicBox, shouldNotifyListeners]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + ) => (super.noSuchMethod( + Invocation.method(#addEpicBox, [epicBox, shouldNotifyListeners]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override _i5.Future deleteEpicBox(String? id, bool? shouldNotifyListeners) => (super.noSuchMethod( - Invocation.method(#deleteEpicBox, [id, shouldNotifyListeners]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + Invocation.method(#deleteEpicBox, [id, shouldNotifyListeners]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i5.Future updateCommunityNodes() => - (super.noSuchMethod( - Invocation.method(#updateCommunityNodes, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i5.Future updateCommunityNodes() => (super.noSuchMethod( + Invocation.method(#updateCommunityNodes, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override void addListener(_i8.VoidCallback? listener) => super.noSuchMethod( diff --git a/test/widget_tests/node_options_sheet_test.dart b/test/widget_tests/node_options_sheet_test.dart index 26cfffb339..afe94d5b90 100644 --- a/test/widget_tests/node_options_sheet_test.dart +++ b/test/widget_tests/node_options_sheet_test.dart @@ -62,9 +62,8 @@ void main() { when(wallets.wallets).thenReturn([]); when(prefs.syncType).thenReturn(SyncingType.currentWalletOnly); when(nodeService.getNodeById(id: node.id)).thenAnswer((_) => node); - when( - nodeService.getPrimaryNodeFor(currency: bitcoin), - ).thenAnswer((_) => primaryNode); + when(nodeService.getPrimaryNodeFor(currency: bitcoin)) + .thenAnswer((_) => primaryNode); } Future pumpSubject( @@ -209,12 +208,11 @@ void main() { primaryNode: otherPrimary, ); when( - mockNodeService.setPrimaryNodeFor( - coin: bitcoin, + mockNodeService.commitConnectionTest( node: node, - shouldNotifyListeners: true, + formData: anyNamed('formData'), ), - ).thenAnswer((_) async {}); + ).thenAnswer((_) async => true); await pumpSubject( tester, @@ -238,10 +236,9 @@ void main() { expect(platformOverrides.connectionInvocations.single.host, '127.0.0.1'); verify( - mockNodeService.setPrimaryNodeFor( - coin: bitcoin, + mockNodeService.commitConnectionTest( node: node, - shouldNotifyListeners: true, + formData: anyNamed('formData'), ), ).called(1); }); @@ -285,10 +282,9 @@ void main() { expect(platformOverrides.connectionInvocations.single.password, isNull); verifyNever( - mockNodeService.setPrimaryNodeFor( - coin: bitcoin, + mockNodeService.commitConnectionTest( node: anyNamed('node'), - shouldNotifyListeners: anyNamed('shouldNotifyListeners'), + formData: anyNamed('formData'), ), ); }, diff --git a/test/widget_tests/node_options_sheet_test.mocks.dart b/test/widget_tests/node_options_sheet_test.mocks.dart index 3b2ce90f4e..fd36e56ddc 100644 --- a/test/widget_tests/node_options_sheet_test.mocks.dart +++ b/test/widget_tests/node_options_sheet_test.mocks.dart @@ -12,6 +12,7 @@ import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i14; import 'package:stackwallet/db/isar/main_db.dart' as _i3; import 'package:stackwallet/models/epicbox_server_model.dart' as _i20; +import 'package:stackwallet/models/node_form_data.dart' as _iTls; import 'package:stackwallet/models/node_model.dart' as _i19; import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart' as _i22; @@ -88,31 +89,22 @@ class MockWallets extends _i1.Mock implements _i9.Wallets { } @override - _i2.NodeService get nodeService => - (super.noSuchMethod( - Invocation.getter(#nodeService), - returnValue: _FakeNodeService_0( - this, - Invocation.getter(#nodeService), - ), - ) - as _i2.NodeService); + _i2.NodeService get nodeService => (super.noSuchMethod( + Invocation.getter(#nodeService), + returnValue: _FakeNodeService_0(this, Invocation.getter(#nodeService)), + ) as _i2.NodeService); @override - _i3.MainDB get mainDB => - (super.noSuchMethod( - Invocation.getter(#mainDB), - returnValue: _FakeMainDB_1(this, Invocation.getter(#mainDB)), - ) - as _i3.MainDB); + _i3.MainDB get mainDB => (super.noSuchMethod( + Invocation.getter(#mainDB), + returnValue: _FakeMainDB_1(this, Invocation.getter(#mainDB)), + ) as _i3.MainDB); @override - List<_i5.Wallet<_i4.CryptoCurrency>> get wallets => - (super.noSuchMethod( - Invocation.getter(#wallets), - returnValue: <_i5.Wallet<_i4.CryptoCurrency>>[], - ) - as List<_i5.Wallet<_i4.CryptoCurrency>>); + List<_i5.Wallet<_i4.CryptoCurrency>> get wallets => (super.noSuchMethod( + Invocation.getter(#wallets), + returnValue: <_i5.Wallet<_i4.CryptoCurrency>>[], + ) as List<_i5.Wallet<_i4.CryptoCurrency>>); @override set nodeService(_i2.NodeService? value) => super.noSuchMethod( @@ -129,13 +121,12 @@ class MockWallets extends _i1.Mock implements _i9.Wallets { @override _i5.Wallet<_i4.CryptoCurrency> getWallet(String? walletId) => (super.noSuchMethod( - Invocation.method(#getWallet, [walletId]), - returnValue: _FakeWallet_2<_i4.CryptoCurrency>( - this, - Invocation.method(#getWallet, [walletId]), - ), - ) - as _i5.Wallet<_i4.CryptoCurrency>); + Invocation.method(#getWallet, [walletId]), + returnValue: _FakeWallet_2<_i4.CryptoCurrency>( + this, + Invocation.method(#getWallet, [walletId]), + ), + ) as _i5.Wallet<_i4.CryptoCurrency>); @override void addWallet(_i5.Wallet<_i4.CryptoCurrency>? wallet) => super.noSuchMethod( @@ -147,43 +138,33 @@ class MockWallets extends _i1.Mock implements _i9.Wallets { _i10.Future deleteWallet( _i11.WalletInfo? info, _i7.SecureStorageInterface? secureStorage, - ) => - (super.noSuchMethod( - Invocation.method(#deleteWallet, [info, secureStorage]), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + ) => (super.noSuchMethod( + Invocation.method(#deleteWallet, [info, secureStorage]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); @override _i10.Future load( _i12.Prefs? prefs, _i3.MainDB? mainDB, bool? isDuress, - ) => - (super.noSuchMethod( - Invocation.method(#load, [prefs, mainDB, isDuress]), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + ) => (super.noSuchMethod( + Invocation.method(#load, [prefs, mainDB, isDuress]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); @override _i10.Future loadAfterStackRestore( _i12.Prefs? prefs, List<_i5.Wallet<_i4.CryptoCurrency>>? wallets, bool? isDesktop, - ) => - (super.noSuchMethod( - Invocation.method(#loadAfterStackRestore, [ - prefs, - wallets, - isDesktop, - ]), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + ) => (super.noSuchMethod( + Invocation.method(#loadAfterStackRestore, [prefs, wallets, isDesktop]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); } /// A class which mocks [Prefs]. @@ -200,12 +181,10 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as bool); @override - int get lastUnlockedTimeout => - (super.noSuchMethod( - Invocation.getter(#lastUnlockedTimeout), - returnValue: 0, - ) - as int); + int get lastUnlockedTimeout => (super.noSuchMethod( + Invocation.getter(#lastUnlockedTimeout), + returnValue: 0, + ) as int); @override int get lastUnlocked => @@ -213,28 +192,22 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as int); @override - int get currentNotificationId => - (super.noSuchMethod( - Invocation.getter(#currentNotificationId), - returnValue: 0, - ) - as int); + int get currentNotificationId => (super.noSuchMethod( + Invocation.getter(#currentNotificationId), + returnValue: 0, + ) as int); @override - List get walletIdsSyncOnStartup => - (super.noSuchMethod( - Invocation.getter(#walletIdsSyncOnStartup), - returnValue: [], - ) - as List); + List get walletIdsSyncOnStartup => (super.noSuchMethod( + Invocation.getter(#walletIdsSyncOnStartup), + returnValue: [], + ) as List); @override - _i13.SyncingType get syncType => - (super.noSuchMethod( - Invocation.getter(#syncType), - returnValue: _i13.SyncingType.currentWalletOnly, - ) - as _i13.SyncingType); + _i13.SyncingType get syncType => (super.noSuchMethod( + Invocation.getter(#syncType), + returnValue: _i13.SyncingType.currentWalletOnly, + ) as _i13.SyncingType); @override bool get wifiOnly => @@ -242,34 +215,22 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as bool); @override - bool get showFavoriteWallets => - (super.noSuchMethod( - Invocation.getter(#showFavoriteWallets), - returnValue: false, - ) - as bool); + bool get showFavoriteWallets => (super.noSuchMethod( + Invocation.getter(#showFavoriteWallets), + returnValue: false, + ) as bool); @override - String get language => - (super.noSuchMethod( - Invocation.getter(#language), - returnValue: _i14.dummyValue( - this, - Invocation.getter(#language), - ), - ) - as String); + String get language => (super.noSuchMethod( + Invocation.getter(#language), + returnValue: _i14.dummyValue(this, Invocation.getter(#language)), + ) as String); @override - String get currency => - (super.noSuchMethod( - Invocation.getter(#currency), - returnValue: _i14.dummyValue( - this, - Invocation.getter(#currency), - ), - ) - as String); + String get currency => (super.noSuchMethod( + Invocation.getter(#currency), + returnValue: _i14.dummyValue(this, Invocation.getter(#currency)), + ) as String); @override bool get randomizePIN => @@ -292,12 +253,10 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as bool); @override - bool get biometricsDuress => - (super.noSuchMethod( - Invocation.getter(#biometricsDuress), - returnValue: false, - ) - as bool); + bool get biometricsDuress => (super.noSuchMethod( + Invocation.getter(#biometricsDuress), + returnValue: false, + ) as bool); @override int get familiarity => @@ -310,44 +269,34 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as bool); @override - bool get showTestNetCoins => - (super.noSuchMethod( - Invocation.getter(#showTestNetCoins), - returnValue: false, - ) - as bool); + bool get showTestNetCoins => (super.noSuchMethod( + Invocation.getter(#showTestNetCoins), + returnValue: false, + ) as bool); @override - bool get isAutoBackupEnabled => - (super.noSuchMethod( - Invocation.getter(#isAutoBackupEnabled), - returnValue: false, - ) - as bool); + bool get isAutoBackupEnabled => (super.noSuchMethod( + Invocation.getter(#isAutoBackupEnabled), + returnValue: false, + ) as bool); @override - _i15.BackupFrequencyType get backupFrequencyType => - (super.noSuchMethod( - Invocation.getter(#backupFrequencyType), - returnValue: _i15.BackupFrequencyType.everyTenMinutes, - ) - as _i15.BackupFrequencyType); + _i15.BackupFrequencyType get backupFrequencyType => (super.noSuchMethod( + Invocation.getter(#backupFrequencyType), + returnValue: _i15.BackupFrequencyType.everyTenMinutes, + ) as _i15.BackupFrequencyType); @override - bool get hideBlockExplorerWarning => - (super.noSuchMethod( - Invocation.getter(#hideBlockExplorerWarning), - returnValue: false, - ) - as bool); + bool get hideBlockExplorerWarning => (super.noSuchMethod( + Invocation.getter(#hideBlockExplorerWarning), + returnValue: false, + ) as bool); @override - bool get gotoWalletOnStartup => - (super.noSuchMethod( - Invocation.getter(#gotoWalletOnStartup), - returnValue: false, - ) - as bool); + bool get gotoWalletOnStartup => (super.noSuchMethod( + Invocation.getter(#gotoWalletOnStartup), + returnValue: false, + ) as bool); @override bool get externalCalls => @@ -355,53 +304,40 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as bool); @override - bool get enableCoinControl => - (super.noSuchMethod( - Invocation.getter(#enableCoinControl), - returnValue: false, - ) - as bool); + bool get enableCoinControl => (super.noSuchMethod( + Invocation.getter(#enableCoinControl), + returnValue: false, + ) as bool); @override - bool get enableSystemBrightness => - (super.noSuchMethod( - Invocation.getter(#enableSystemBrightness), - returnValue: false, - ) - as bool); + bool get enableSystemBrightness => (super.noSuchMethod( + Invocation.getter(#enableSystemBrightness), + returnValue: false, + ) as bool); @override - String get themeId => - (super.noSuchMethod( - Invocation.getter(#themeId), - returnValue: _i14.dummyValue( - this, - Invocation.getter(#themeId), - ), - ) - as String); + String get themeId => (super.noSuchMethod( + Invocation.getter(#themeId), + returnValue: _i14.dummyValue(this, Invocation.getter(#themeId)), + ) as String); @override - String get systemBrightnessLightThemeId => - (super.noSuchMethod( - Invocation.getter(#systemBrightnessLightThemeId), - returnValue: _i14.dummyValue( - this, - Invocation.getter(#systemBrightnessLightThemeId), - ), - ) - as String); + String get systemBrightnessLightThemeId => (super.noSuchMethod( + Invocation.getter(#systemBrightnessLightThemeId), + returnValue: _i14.dummyValue( + this, + Invocation.getter(#systemBrightnessLightThemeId), + ), + ) as String); @override - String get systemBrightnessDarkThemeId => - (super.noSuchMethod( - Invocation.getter(#systemBrightnessDarkThemeId), - returnValue: _i14.dummyValue( - this, - Invocation.getter(#systemBrightnessDarkThemeId), - ), - ) - as String); + String get systemBrightnessDarkThemeId => (super.noSuchMethod( + Invocation.getter(#systemBrightnessDarkThemeId), + returnValue: _i14.dummyValue( + this, + Invocation.getter(#systemBrightnessDarkThemeId), + ), + ) as String); @override bool get useTor => @@ -414,36 +350,28 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as bool); @override - bool get enableExchange => - (super.noSuchMethod( - Invocation.getter(#enableExchange), - returnValue: false, - ) - as bool); + bool get enableExchange => (super.noSuchMethod( + Invocation.getter(#enableExchange), + returnValue: false, + ) as bool); @override - bool get advancedFiroFeatures => - (super.noSuchMethod( - Invocation.getter(#advancedFiroFeatures), - returnValue: false, - ) - as bool); + bool get advancedFiroFeatures => (super.noSuchMethod( + Invocation.getter(#advancedFiroFeatures), + returnValue: false, + ) as bool); @override - _i16.Level get logLevel => - (super.noSuchMethod( - Invocation.getter(#logLevel), - returnValue: _i16.Level.all, - ) - as _i16.Level); + _i16.Level get logLevel => (super.noSuchMethod( + Invocation.getter(#logLevel), + returnValue: _i16.Level.all, + ) as _i16.Level); @override - ({bool enabled, int minutes}) get autoLockInfo => - (super.noSuchMethod( - Invocation.getter(#autoLockInfo), - returnValue: (enabled: false, minutes: 0), - ) - as ({bool enabled, int minutes})); + ({bool enabled, int minutes}) get autoLockInfo => (super.noSuchMethod( + Invocation.getter(#autoLockInfo), + returnValue: (enabled: false, minutes: 0), + ) as ({bool enabled, int minutes})); @override bool get privacyScreen => @@ -451,12 +379,10 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as bool); @override - bool get disableScreenShots => - (super.noSuchMethod( - Invocation.getter(#disableScreenShots), - returnValue: false, - ) - as bool); + bool get disableScreenShots => (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) as bool); @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( @@ -705,55 +631,43 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as bool); @override - _i10.Future init() => - (super.noSuchMethod( - Invocation.method(#init, []), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + _i10.Future init() => (super.noSuchMethod( + Invocation.method(#init, []), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); @override - _i10.Future incrementCurrentNotificationIndex() => - (super.noSuchMethod( - Invocation.method(#incrementCurrentNotificationIndex, []), - returnValue: _i10.Future.value(0), - ) - as _i10.Future); + _i10.Future incrementCurrentNotificationIndex() => (super.noSuchMethod( + Invocation.method(#incrementCurrentNotificationIndex, []), + returnValue: _i10.Future.value(0), + ) as _i10.Future); @override - _i10.Future isExternalCallsSet() => - (super.noSuchMethod( - Invocation.method(#isExternalCallsSet, []), - returnValue: _i10.Future.value(false), - ) - as _i10.Future); + _i10.Future isExternalCallsSet() => (super.noSuchMethod( + Invocation.method(#isExternalCallsSet, []), + returnValue: _i10.Future.value(false), + ) as _i10.Future); @override - _i10.Future saveUserID(String? userId) => - (super.noSuchMethod( - Invocation.method(#saveUserID, [userId]), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + _i10.Future saveUserID(String? userId) => (super.noSuchMethod( + Invocation.method(#saveUserID, [userId]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); @override - _i10.Future saveSignupEpoch(int? signupEpoch) => - (super.noSuchMethod( - Invocation.method(#saveSignupEpoch, [signupEpoch]), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + _i10.Future saveSignupEpoch(int? signupEpoch) => (super.noSuchMethod( + Invocation.method(#saveSignupEpoch, [signupEpoch]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); @override - _i17.AmountUnit amountUnit(_i4.CryptoCurrency? coin) => - (super.noSuchMethod( - Invocation.method(#amountUnit, [coin]), - returnValue: _i17.AmountUnit.normal, - ) - as _i17.AmountUnit); + _i17.AmountUnit amountUnit(_i4.CryptoCurrency? coin) => (super.noSuchMethod( + Invocation.method(#amountUnit, [coin]), + returnValue: _i17.AmountUnit.normal, + ) as _i17.AmountUnit); @override void updateAmountUnit({ @@ -768,12 +682,10 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { ); @override - int maxDecimals(_i4.CryptoCurrency? coin) => - (super.noSuchMethod( - Invocation.method(#maxDecimals, [coin]), - returnValue: 0, - ) - as int); + int maxDecimals(_i4.CryptoCurrency? coin) => (super.noSuchMethod( + Invocation.method(#maxDecimals, [coin]), + returnValue: 0, + ) as int); @override void updateMaxDecimals({ @@ -790,13 +702,12 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { @override _i6.FusionInfo getFusionServerInfo(_i4.CryptoCurrency? coin) => (super.noSuchMethod( - Invocation.method(#getFusionServerInfo, [coin]), - returnValue: _FakeFusionInfo_3( - this, - Invocation.method(#getFusionServerInfo, [coin]), - ), - ) - as _i6.FusionInfo); + Invocation.method(#getFusionServerInfo, [coin]), + returnValue: _FakeFusionInfo_3( + this, + Invocation.method(#getFusionServerInfo, [coin]), + ), + ) as _i6.FusionInfo); @override void setFusionServerInfo( @@ -841,31 +752,25 @@ class MockNodeService extends _i1.Mock implements _i2.NodeService { } @override - _i7.SecureStorageInterface get secureStorageInterface => - (super.noSuchMethod( - Invocation.getter(#secureStorageInterface), - returnValue: _FakeSecureStorageInterface_4( - this, - Invocation.getter(#secureStorageInterface), - ), - ) - as _i7.SecureStorageInterface); + _i7.SecureStorageInterface get secureStorageInterface => (super.noSuchMethod( + Invocation.getter(#secureStorageInterface), + returnValue: _FakeSecureStorageInterface_4( + this, + Invocation.getter(#secureStorageInterface), + ), + ) as _i7.SecureStorageInterface); @override - List<_i19.NodeModel> get primaryNodes => - (super.noSuchMethod( - Invocation.getter(#primaryNodes), - returnValue: <_i19.NodeModel>[], - ) - as List<_i19.NodeModel>); + List<_i19.NodeModel> get primaryNodes => (super.noSuchMethod( + Invocation.getter(#primaryNodes), + returnValue: <_i19.NodeModel>[], + ) as List<_i19.NodeModel>); @override - List<_i19.NodeModel> get nodes => - (super.noSuchMethod( - Invocation.getter(#nodes), - returnValue: <_i19.NodeModel>[], - ) - as List<_i19.NodeModel>); + List<_i19.NodeModel> get nodes => (super.noSuchMethod( + Invocation.getter(#nodes), + returnValue: <_i19.NodeModel>[], + ) as List<_i19.NodeModel>); @override bool get hasListeners => @@ -873,45 +778,39 @@ class MockNodeService extends _i1.Mock implements _i2.NodeService { as bool); @override - _i10.Future updateDefaults() => - (super.noSuchMethod( - Invocation.method(#updateDefaults, []), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + _i10.Future updateDefaults() => (super.noSuchMethod( + Invocation.method(#updateDefaults, []), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); @override _i10.Future setPrimaryNodeFor({ required _i4.CryptoCurrency? coin, required _i19.NodeModel? node, bool? shouldNotifyListeners = false, - }) => - (super.noSuchMethod( - Invocation.method(#setPrimaryNodeFor, [], { - #coin: coin, - #node: node, - #shouldNotifyListeners: shouldNotifyListeners, - }), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + }) => (super.noSuchMethod( + Invocation.method(#setPrimaryNodeFor, [], { + #coin: coin, + #node: node, + #shouldNotifyListeners: shouldNotifyListeners, + }), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); @override _i19.NodeModel? getPrimaryNodeFor({required _i4.CryptoCurrency? currency}) => (super.noSuchMethod( - Invocation.method(#getPrimaryNodeFor, [], {#currency: currency}), - ) - as _i19.NodeModel?); + Invocation.method(#getPrimaryNodeFor, [], {#currency: currency}), + ) as _i19.NodeModel?); @override List<_i19.NodeModel> getNodesFor(_i4.CryptoCurrency? coin) => (super.noSuchMethod( - Invocation.method(#getNodesFor, [coin]), - returnValue: <_i19.NodeModel>[], - ) - as List<_i19.NodeModel>); + Invocation.method(#getNodesFor, [coin]), + returnValue: <_i19.NodeModel>[], + ) as List<_i19.NodeModel>); @override _i19.NodeModel? getNodeById({required String? id}) => @@ -921,83 +820,78 @@ class MockNodeService extends _i1.Mock implements _i2.NodeService { @override List<_i19.NodeModel> failoverNodesFor({ required _i4.CryptoCurrency? currency, - }) => - (super.noSuchMethod( - Invocation.method(#failoverNodesFor, [], {#currency: currency}), - returnValue: <_i19.NodeModel>[], - ) - as List<_i19.NodeModel>); + }) => (super.noSuchMethod( + Invocation.method(#failoverNodesFor, [], {#currency: currency}), + returnValue: <_i19.NodeModel>[], + ) as List<_i19.NodeModel>); @override _i10.Future save( _i19.NodeModel? node, String? password, bool? shouldNotifyListeners, - ) => - (super.noSuchMethod( - Invocation.method(#save, [node, password, shouldNotifyListeners]), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + ) => (super.noSuchMethod( + Invocation.method(#save, [node, password, shouldNotifyListeners]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); + + @override + _i10.Future commitConnectionTest({ + required _i19.NodeModel? node, + required _iTls.NodeFormData? formData, + }) => (super.noSuchMethod( + Invocation.method(#commitConnectionTest, [], { + #node: node, + #formData: formData, + }), + returnValue: _i10.Future.value(false), + ) as _i10.Future); @override _i10.Future delete(String? id, bool? shouldNotifyListeners) => (super.noSuchMethod( - Invocation.method(#delete, [id, shouldNotifyListeners]), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + Invocation.method(#delete, [id, shouldNotifyListeners]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); @override _i10.Future setEnabledState( String? id, bool? enabled, bool? shouldNotifyListeners, - ) => - (super.noSuchMethod( - Invocation.method(#setEnabledState, [ - id, - enabled, - shouldNotifyListeners, - ]), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); - - @override - _i10.Future updateDefaultEpicBoxes() => - (super.noSuchMethod( - Invocation.method(#updateDefaultEpicBoxes, []), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + ) => (super.noSuchMethod( + Invocation.method(#setEnabledState, [id, enabled, shouldNotifyListeners]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); + + @override + _i10.Future updateDefaultEpicBoxes() => (super.noSuchMethod( + Invocation.method(#updateDefaultEpicBoxes, []), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); @override _i10.Future setPrimaryEpicBox({ required _i20.EpicBoxServerModel? epicBox, bool? shouldNotifyListeners = false, - }) => - (super.noSuchMethod( - Invocation.method(#setPrimaryEpicBox, [], { - #epicBox: epicBox, - #shouldNotifyListeners: shouldNotifyListeners, - }), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + }) => (super.noSuchMethod( + Invocation.method(#setPrimaryEpicBox, [], { + #epicBox: epicBox, + #shouldNotifyListeners: shouldNotifyListeners, + }), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); @override - List<_i20.EpicBoxServerModel> getEpicBoxes() => - (super.noSuchMethod( - Invocation.method(#getEpicBoxes, []), - returnValue: <_i20.EpicBoxServerModel>[], - ) - as List<_i20.EpicBoxServerModel>); + List<_i20.EpicBoxServerModel> getEpicBoxes() => (super.noSuchMethod( + Invocation.method(#getEpicBoxes, []), + returnValue: <_i20.EpicBoxServerModel>[], + ) as List<_i20.EpicBoxServerModel>); @override _i20.EpicBoxServerModel? getEpicBoxById({required String? id}) => @@ -1008,31 +902,26 @@ class MockNodeService extends _i1.Mock implements _i2.NodeService { _i10.Future addEpicBox( _i20.EpicBoxServerModel? epicBox, bool? shouldNotifyListeners, - ) => - (super.noSuchMethod( - Invocation.method(#addEpicBox, [epicBox, shouldNotifyListeners]), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + ) => (super.noSuchMethod( + Invocation.method(#addEpicBox, [epicBox, shouldNotifyListeners]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); @override _i10.Future deleteEpicBox(String? id, bool? shouldNotifyListeners) => (super.noSuchMethod( - Invocation.method(#deleteEpicBox, [id, shouldNotifyListeners]), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + Invocation.method(#deleteEpicBox, [id, shouldNotifyListeners]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); @override - _i10.Future updateCommunityNodes() => - (super.noSuchMethod( - Invocation.method(#updateCommunityNodes, []), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + _i10.Future updateCommunityNodes() => (super.noSuchMethod( + Invocation.method(#updateCommunityNodes, []), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); @override void addListener(_i18.VoidCallback? listener) => super.noSuchMethod( @@ -1068,26 +957,19 @@ class MockTorService extends _i1.Mock implements _i21.TorService { } @override - _i22.TorConnectionStatus get status => - (super.noSuchMethod( - Invocation.getter(#status), - returnValue: _i22.TorConnectionStatus.disconnected, - ) - as _i22.TorConnectionStatus); + _i22.TorConnectionStatus get status => (super.noSuchMethod( + Invocation.getter(#status), + returnValue: _i22.TorConnectionStatus.disconnected, + ) as _i22.TorConnectionStatus); @override - ({_i8.InternetAddress host, int port}) getProxyInfo() => - (super.noSuchMethod( - Invocation.method(#getProxyInfo, []), - returnValue: ( - host: _FakeInternetAddress_5( - this, - Invocation.method(#getProxyInfo, []), - ), - port: 0, - ), - ) - as ({_i8.InternetAddress host, int port})); + ({_i8.InternetAddress host, int port}) getProxyInfo() => (super.noSuchMethod( + Invocation.method(#getProxyInfo, []), + returnValue: ( + host: _FakeInternetAddress_5(this, Invocation.method(#getProxyInfo, [])), + port: 0, + ), + ) as ({_i8.InternetAddress host, int port})); @override void init({required String? torDataDirPath}) => super.noSuchMethod( @@ -1096,20 +978,16 @@ class MockTorService extends _i1.Mock implements _i21.TorService { ); @override - _i10.Future start() => - (super.noSuchMethod( - Invocation.method(#start, []), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + _i10.Future start() => (super.noSuchMethod( + Invocation.method(#start, []), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); @override - _i10.Future disable() => - (super.noSuchMethod( - Invocation.method(#disable, []), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), - ) - as _i10.Future); + _i10.Future disable() => (super.noSuchMethod( + Invocation.method(#disable, []), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); } diff --git a/test/widget_tests/node_tls_trust_test.dart b/test/widget_tests/node_tls_trust_test.dart new file mode 100644 index 0000000000..0cc0c83476 --- /dev/null +++ b/test/widget_tests/node_tls_trust_test.dart @@ -0,0 +1,472 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:mockito/mockito.dart'; +import 'package:platform/platform.dart'; +import 'package:stackwallet/db/hive/db.dart'; +import 'package:stackwallet/electrumx_rpc/client_manager.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/models/node_model.dart'; +import 'package:stackwallet/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart'; +import 'package:stackwallet/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart'; +import 'package:stackwallet/providers/providers.dart'; +import 'package:stackwallet/providers/global/secure_store_provider.dart'; +import 'package:stackwallet/services/node_service.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/connection_check/electrum_connection_check.dart'; +import 'package:stackwallet/utilities/enums/sync_type_enum.dart'; +import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/test_node_connection.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/node_card.dart'; +import 'package:stackwallet/widgets/node_options_sheet.dart'; + +import '../sample_data/theme_json.dart'; +import '../utilities/tls_test_server.dart'; + +class _Wallets extends Fake implements Wallets { + @override + List get wallets => []; +} + +class _Prefs extends Mock implements Prefs { + @override + bool get useTor => false; + @override + SyncingType get syncType => SyncingType.allWalletsOnStartup; +} + +void main() { + late CryptoCurrency coin; + late NodeService nodes; + late FakeSecureStorage storage; + late String pem; + late Directory logs; + String? testedHost, testedCertificate; + Completer? testGate; + int tests = 0; + NodeModel node() => nodes.getNodeById(id: 'custom-node')!; + NodeModel makeNode(String id, {bool primary = false}) => NodeModel( + host: id == 'custom-node' ? 'node.example' : 'other.example', + port: 50002, + name: id == 'custom-node' ? 'Node' : 'Other', + id: id, + useSSL: true, + enabled: true, + coinName: coin.identifier, + isFailover: false, + isDown: false, + torEnabled: true, + clearnetEnabled: true, + isPrimary: primary, + ); + + setUpAll(() async { + logs = await Directory.systemTemp.createTemp('minimal_tls_widgets'); + await Logging.instance.initialize(logs.path, level: Level.off); + }); + tearDownAll(() => logs.delete(recursive: true)); + setUp(() async { + coin = Bitcoin(CryptoCurrencyNetwork.main); + DB.instance.hive.init(logs.path); + if (!DB.instance.hive.isAdapterRegistered(12)) { + DB.instance.hive.registerAdapter(NodeModelAdapter()); + } + final box = await DB.instance.hive.openBox( + DB.boxNameNodeModels, + bytes: Uint8List(0), + ); + await box.put('custom-node', makeNode('custom-node')); + await box.put('other-node', makeNode('other-node', primary: true)); + storage = FakeSecureStorage(); + nodes = NodeService(secureStorageInterface: storage); + pem = TlsElectrumServer.certificate('node_a'); + testedHost = testedCertificate = null; + testGate = null; + tests = 0; + Util.layoutPlatform = FakePlatform(operatingSystem: 'macos'); + }); + tearDown(() async { + await ClientManager.sharedInstance.closeAll(); + await DB.instance.hive.close(); + Util.layoutPlatform = const LocalPlatform(); + }); + + Future pump( + WidgetTester tester, + Widget child, { + TestNodeConnectionCallback? testConnection, + }) async { + tester.view.physicalSize = const Size(1200, 1800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + ProviderScope( + overrides: [ + nodeServiceChangeNotifierProvider.overrideWithValue(nodes), + secureStoreProvider.overrideWithValue(storage), + prefsChangeNotifierProvider.overrideWithValue(_Prefs()), + pWallets.overrideWithValue(_Wallets()), + testNodeConnectionProvider.overrideWithValue( + testConnection ?? + ({ + required context, + required nodeFormData, + required cryptoCurrency, + onSuccess, + }) async { + if (!nodes.prepareCertificateTest(nodeFormData)) return false; + testedHost = nodeFormData.host; + testedCertificate = nodeFormData.trustedCertificatePem; + tests++; + await testGate?.future; + nodeFormData.trustedCertificatePem = pem; + onSuccess?.call(nodeFormData); + return true; + }, + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: const TextScaler.linear(.8)), + child: child!, + ), + home: child, + ), + ), + ); + await tester.pumpAndSettle(); + } + + Widget details() => + NodeDetailsView(coin: coin, nodeId: 'custom-node', popRouteName: '/'); + Future approveStored() async { + final opened = node(); + await nodes.saveTrustedCertificate( + opened, + NodeFormData.fromNode(opened)..trustedCertificatePem = pem, + ); + } + + testWidgets( + 'Details Test saves approval without rewriting the node password', + (tester) async { + await storage.write(key: 'custom-node_nodePW', value: 'secret'); + await pump(tester, details()); + final writes = storage.writes; + final deletes = storage.deletes; + await tester.tap(find.text('Test connection')); + await tester.pumpAndSettle(); + expect(node().trustedCertificatePem, pem); + expect(testedHost, 'node.example'); + expect(storage.writes, writes); + expect(storage.deletes, deletes); + expect(find.textContaining('Trusted TLS certificate'), findsOneWidget); + }, + ); + + testWidgets('desktop Test stages an edited endpoint approval until Save', ( + tester, + ) async { + await pump(tester, details()); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).at(1), 'edited.example'); + await tester.tap(find.text('Test connection')); + await tester.pumpAndSettle(); + expect(testedHost, 'edited.example'); + expect(node().host, 'node.example'); + expect(node().trustedCertificatePem, null); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(node().host, 'edited.example'); + expect(node().trustedCertificatePem, pem); + }); + + testWidgets( + 'desktop Save uses its own form after another view refreshes the provider', + (tester) async { + await pump(tester, details()); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Test connection')); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(NodeDetailsView)), + ); + container.refresh(nodeFormDataProvider) + ..host = 'different.example' + ..port = 60002 + ..useSSL = true + ..trustedCertificatePem = TlsElectrumServer.certificate('node_b'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(node().host, 'node.example'); + expect(node().trustedCertificatePem, pem); + }, + ); + + testWidgets('repeated desktop edits keep the saved RPC password', ( + tester, + ) async { + coin = Ethereum(CryptoCurrencyNetwork.main); + await nodes.save(makeNode('custom-node'), 'secret', false); + await pump(tester, details()); + Finder field(String text) => find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller?.text == text, + ); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + await tester.enterText(field('secret'), 'changed-secret'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(await storage.read(key: 'custom-node_nodePW'), 'changed-secret'); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + expect(field('changed-secret'), findsOneWidget); + await tester.enterText(field('Node'), 'Renamed'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(node().name, 'Renamed'); + expect(await storage.read(key: 'custom-node_nodePW'), 'changed-secret'); + }); + + testWidgets('options Connect commits approval and primary together', ( + tester, + ) async { + await pump( + tester, + NodeOptionsSheet(nodeId: 'custom-node', coin: coin, popBackToRoute: '/'), + ); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + expect(testedCertificate, null); + expect(node().trustedCertificatePem, pem); + expect(node().isPrimary, true); + expect(nodes.getNodeById(id: 'other-node')!.isPrimary, false); + }); + + testWidgets('node card Connect carries an already approved certificate', ( + tester, + ) async { + await approveStored(); + await pump( + tester, + NodeCard(nodeId: 'custom-node', coin: coin, popBackToRoute: '/'), + ); + await tester.tap(find.text('Node')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Connect', findRichText: true)); + await tester.pumpAndSettle(); + expect(testedCertificate, pem); + expect(node().trustedCertificatePem, pem); + expect(node().isPrimary, true); + }); + + testWidgets('add/edit Save includes the tested certificate', (tester) async { + await pump( + tester, + AddEditNodeView( + viewType: AddEditNodeViewType.edit, + coin: coin, + nodeId: 'custom-node', + routeOnSuccessOrDelete: '/', + ), + ); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(node().trustedCertificatePem, pem); + }); + + testWidgets( + 'Forget in read-only Details is an immediate certificate-only write', + (tester) async { + await approveStored(); + await storage.write(key: 'custom-node_nodePW', value: 'secret'); + await pump(tester, details()); + final writes = storage.writes; + final deletes = storage.deletes; + await tester.tap(find.text('Forget certificate')); + await tester.pumpAndSettle(); + expect(node().trustedCertificatePem, null); + expect(node().host, 'node.example'); + expect(storage.writes, writes); + expect(storage.deletes, deletes); + }, + ); + + testWidgets('an editing Forget is staged until Save', (tester) async { + await approveStored(); + await pump(tester, details()); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Forget certificate')); + await tester.pumpAndSettle(); + expect(node().trustedCertificatePem, pem); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(node().trustedCertificatePem, null); + }); + + testWidgets('desktop Save cannot overtake a running Test', (tester) async { + await pump(tester, details()); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + testGate = Completer(); + final save = tester + .widget(find.widgetWithText(PrimaryButton, 'Save')) + .onPressed!; + await tester.tap(find.text('Test connection')); + await tester.pump(); + save(); + await tester.pump(); + expect(tests, 1); + expect(node().trustedCertificatePem, null); + expect( + tester + .widget(find.widgetWithText(PrimaryButton, 'Save')) + .onPressed, + null, + ); + testGate!.complete(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(node().trustedCertificatePem, pem); + }); + + testWidgets( + 'node replaced during Test keeps its endpoint and certificate together', + (tester) async { + await approveStored(); + final started = Completer(); + final release = Completer(); + await pump( + tester, + details(), + testConnection: + ({ + required context, + required nodeFormData, + required cryptoCurrency, + onSuccess, + }) => testElectrumNodeConnection( + nodeFormData, + isActive: () => context.mounted, + requestTrust: (_, _, _) async => false, + onSuccess: onSuccess, + testConnection: + ({ + required host, + required port, + required useSSL, + trustedCertificatePem, + }) async { + started.complete(); + await release.future; + return const ElectrumConnectionResult(success: false); + }, + ), + ); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Test connection')); + await tester.pump(); + await tester.pump(); + expect(started.isCompleted, true); + + final before = node(); + final replacement = before.copyWith( + host: 'replacement.example', + port: 60002, + loginName: before.loginName, + trusted: before.trusted, + trustedCertificatePem: TlsElectrumServer.certificate('node_b'), + ); + await nodes.save(replacement, null, true); + await tester.pump(); + await tester.pump(); + + final widget = tester.widget(find.byType(NodeForm)); + final form = widget.data!; + expect(widget.busy, true); + expect(form.source, same(replacement)); + expect(form.host, replacement.host); + expect(form.port, replacement.port); + expect(form.useSSL, replacement.useSSL); + expect(form.trustedCertificatePem, replacement.trustedCertificatePem); + expect( + tester.widgetList(find.byType(TextField)), + everyElement( + isA().having((field) => field.readOnly, 'readOnly', true), + ), + ); + + release.complete(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(node().host, replacement.host); + expect(node().port, replacement.port); + expect(node().trustedCertificatePem, replacement.trustedCertificatePem); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('a readonly test cannot undo an intervening empty Forget', ( + tester, + ) async { + await pump(tester, details()); + testGate = Completer(); + await tester.tap(find.text('Test connection')); + await tester.pump(); + await nodes.forgetTrustedCertificate(node()); + testGate!.complete(); + await tester.pumpAndSettle(); + expect(node().trustedCertificatePem, null); + }); + + testWidgets('Connect ignores a node changed during its test', (tester) async { + await pump( + tester, + NodeOptionsSheet(nodeId: 'custom-node', coin: coin, popBackToRoute: '/'), + ); + testGate = Completer(); + await tester.tap(find.text('Connect')); + await tester.pump(); + final before = node(); + await DB.instance.put( + boxName: DB.boxNameNodeModels, + key: before.id, + value: before.copyWith( + host: 'new.example', + loginName: null, + trusted: null, + ), + ); + testGate!.complete(); + await tester.pumpAndSettle(); + expect(node().isPrimary, false); + expect(node().trustedCertificatePem, null); + }); +} diff --git a/test/widget_tests/tls_certificate_dialog_test.dart b/test/widget_tests/tls_certificate_dialog_test.dart new file mode 100644 index 0000000000..4257761809 --- /dev/null +++ b/test/widget_tests/tls_certificate_dialog_test.dart @@ -0,0 +1,132 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/models/node_form_data.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/connection_check/electrum_connection_check.dart'; +import 'package:stackwallet/utilities/tls_certificate.dart'; +import 'package:stackwallet/widgets/tls_certificate_dialog.dart'; + +import '../sample_data/theme_json.dart'; +import '../utilities/tls_test_server.dart'; + +void main() { + late X509Certificate certificate; + setUpAll(() async { + final server = await TlsElectrumServer.start(); + try { + certificate = (await testElectrumConnection( + host: '127.0.0.1', + port: server.port, + useSSL: true, + timeout: const Duration(seconds: 2), + )).certificate!; + } finally { + await server.close(); + } + }); + + for (final choice in ['Cancel', 'Trust']) { + testWidgets('shows certificate details and returns $choice', ( + tester, + ) async { + bool? approved; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async { + approved = await showBadX509CertificateDialog( + certificate, + 'node.example', + 50002, + context, + ); + }, + child: const Text('Review'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Review')); + await tester.pumpAndSettle(); + final message = tester + .widgetList(find.byType(SelectableText)) + .map((text) => text.data) + .join('\n'); + expect(message, contains('node.example:50002')); + expect(message, contains(certificate.subject)); + expect(message, contains(certificate.issuer)); + expect(message, contains('${certificate.endValidity.toUtc()}')); + expect(message, contains(certificateFingerprint(certificate.der))); + await tester.tap(find.text(choice)); + await tester.pumpAndSettle(); + expect(approved, choice == 'Trust'); + }); + } + testWidgets('Trust resumes the test on its current route', (tester) async { + final data = NodeFormData() + ..host = 'node.example' + ..port = 50002 + ..useSSL = true; + bool? success; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('Test'), + onPressed: () async { + success = await testElectrumNodeConnection( + data, + isActive: () => + context.mounted && + ModalRoute.of(context)?.isCurrent != false, + requestTrust: (cert, host, port) => + showBadX509CertificateDialog(cert, host, port, context), + testConnection: + ({ + required host, + required port, + required useSSL, + trustedCertificatePem, + }) async => trustedCertificatePem == null + ? ElectrumConnectionResult( + success: false, + certificate: certificate, + ) + : const ElectrumConnectionResult(success: true), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('Test')); + await tester.pumpAndSettle(); + expect(success, null); + await tester.tap(find.text('Trust')); + await tester.pumpAndSettle(); + expect(success, true); + expect(data.trustedCertificatePem, certificate.pem); + }); +} From d9655cae27be693bd9679a597f793601ca1eb063 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 12:00:51 -0600 Subject: [PATCH 02/16] Reset Electrum failover selection after an invalid approval --- lib/electrumx_rpc/electrumx_client.dart | 9 +++++- test/electrumx_setup_failover_test.dart | 42 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/lib/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart index 0d54db7f5e..c7573c87be 100644 --- a/lib/electrumx_rpc/electrumx_client.dart +++ b/lib/electrumx_rpc/electrumx_client.dart @@ -333,7 +333,14 @@ class ElectrumXClient { // Invalid saved approvals are a local configuration error. They must not // be mistaken for a failed network attempt or bypassed by failover. - final certificateCheck = trustedCertificateCheck(pem); + final bool Function(X509Certificate)? certificateCheck; + try { + certificateCheck = trustedCertificateCheck(pem); + } on FormatException { + // Fail this attempt, but let the next one retry a recovered primary. + currentFailoverIndex = -1; + rethrow; + } ElectrumClient? candidate; bool negotiated = false; bool kept = false; diff --git a/test/electrumx_setup_failover_test.dart b/test/electrumx_setup_failover_test.dart index 0cd82c096b..7cef6393fe 100644 --- a/test/electrumx_setup_failover_test.dart +++ b/test/electrumx_setup_failover_test.dart @@ -243,6 +243,48 @@ void main() { }, ); + test( + 'invalid fallback approval fails closed without trapping later setup', + () async { + final ignoredMethods = {'server.version'}; + final primary = await start(ignoredMethods: ignoredMethods); + final invalid = await start(certificateName: 'node_a'); + final healthy = await start(); + final client = clientFor(primary.port, [ + ElectrumXNode( + address: '127.0.0.1', + port: invalid.port, + name: 'invalid approval', + id: '${invalid.port}', + useSSL: true, + torEnabled: true, + clearnetEnabled: true, + trustedCertificatePem: 'invalid PEM', + ), + node(healthy), + ]); + + await expectLater( + client.checkElectrumAdapter().timeout(const Duration(seconds: 3)), + throwsA(isA()), + ); + + expect(primary.accepted, 1); + expect(invalid.accepted, 0); + expect(healthy.accepted, 0); + expect(client.getElectrumAdapter(), isNull); + expect(client.currentFailoverIndex, -1); + + ignoredMethods.clear(); + await client.checkElectrumAdapter().timeout(const Duration(seconds: 3)); + + expect(client.getElectrumAdapter()!.port, primary.port); + expect(primary.accepted, 2); + expect(invalid.accepted, 0); + expect(healthy.accepted, 0); + }, + ); + test('concurrent setup callers share the successful fallback', () async { final primary = await start(ignoredMethods: {'server.version'}); final fallback = await start(); From 534631d6643771b4b2fc1bb883b0927db830ae79 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 12:01:11 -0600 Subject: [PATCH 03/16] Give verbose Electrum transaction batches a separate timeout --- .../electrum_request_timeouts.dart | 4 + lib/electrumx_rpc/electrumx_client.dart | 2 + test/electrumx_request_policy_test.dart | 77 +++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/lib/electrumx_rpc/electrum_request_timeouts.dart b/lib/electrumx_rpc/electrum_request_timeouts.dart index ad28da75b4..29284d898e 100644 --- a/lib/electrumx_rpc/electrum_request_timeouts.dart +++ b/lib/electrumx_rpc/electrum_request_timeouts.dart @@ -3,12 +3,16 @@ class ElectrumRequestTimeouts { const ElectrumRequestTimeouts({ this.standard = const Duration(seconds: 60), this.history = const Duration(minutes: 5), + this.transactionBatch = const Duration(minutes: 5), this.firoSet = const Duration(minutes: 2), this.broadcast = const Duration(minutes: 5), }); final Duration standard; final Duration history; + + /// Deadline for the combined response to a verbose transaction batch. + final Duration transactionBatch; final Duration firoSet; final Duration broadcast; diff --git a/lib/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart index c7573c87be..b313ebbd01 100644 --- a/lib/electrumx_rpc/electrumx_client.dart +++ b/lib/electrumx_rpc/electrumx_client.dart @@ -145,6 +145,7 @@ class ElectrumXClient { for (final limit in [ requestTimeouts.standard, requestTimeouts.history, + requestTimeouts.transactionBatch, requestTimeouts.firoSet, requestTimeouts.broadcast, ]) { @@ -940,6 +941,7 @@ class ElectrumXClient { final response = await batchRequest( command: 'blockchain.transaction.get', args: txHashes.map((e) => [e, true]).toList(), + requestTimeout: requestTimeouts.transactionBatch, ); final List> result = []; for (int i = 0; i < response.length; i++) { diff --git a/test/electrumx_request_policy_test.dart b/test/electrumx_request_policy_test.dart index e49fa7c849..9fbf9e219d 100644 --- a/test/electrumx_request_policy_test.dart +++ b/test/electrumx_request_policy_test.dart @@ -240,6 +240,83 @@ void main() { }, ); + test('transaction batches have a separate five-minute default', () { + const policy = ElectrumRequestTimeouts(); + expect(policy.transactionBatch, const Duration(minutes: 5)); + expect( + policy.forMethod('blockchain.transaction.get'), + const Duration(seconds: 60), + ); + }); + + test( + '100 verbose transactions may outlive the single-request deadline', + () async { + final server = await start(); + final client = clientFor( + server, + policy: const ElectrumRequestTimeouts( + standard: _short, + transactionBatch: _long, + ), + ); + final txids = List.generate(100, (index) => 'tx-$index'); + // The server returns the complete JSON response array after 250ms. + // Each member must use the batch budget, not the adapter's short default. + expect(await client.getBatchTransactions(txHashes: txids), [ + for (final txid in txids) {'txid': txid}, + ]); + expect( + server.received + .where( + (request) => request['method'] == 'blockchain.transaction.get', + ) + .map((request) => request['params']) + .toList(), + [ + for (final txid in txids) [txid, true], + ], + ); + await expectLater( + client.getTransaction(txHash: 'single'), + _deadline(_short), + ); + }, + ); + + test( + 'a configured transaction-batch deadline can be shorter than standard', + () async { + final server = await start(); + final client = clientFor( + server, + policy: const ElectrumRequestTimeouts( + standard: _long, + transactionBatch: _short, + ), + ); + expect(await client.getTransaction(txHash: 'single'), {'txid': 'single'}); + await expectLater( + client.getBatchTransactions(txHashes: ['first', 'second']), + _deadline(_short), + ); + }, + ); + + test('transaction-batch policy must be positive', () async { + final server = await start(); + for (final invalid in [Duration.zero, const Duration(milliseconds: -1)]) { + expect( + () => clientFor( + server, + policy: ElectrumRequestTimeouts(transactionBatch: invalid), + ), + throwsArgumentError, + ); + } + expect(server.accepted, 0); + }); + test('broadcast has its own five-minute default policy', () { const policy = ElectrumRequestTimeouts(); expect(policy.broadcast, const Duration(minutes: 5)); From 1ec7b8a02462302e0e6ccbe8c97dde6bcf4aa4e8 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 12:01:30 -0600 Subject: [PATCH 04/16] Cancel Electrum Tor waits without releasing request-owned locks --- lib/electrumx_rpc/electrumx_client.dart | 52 ++++--- test/electrumx_tor_gate_test.dart | 175 ++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 29 deletions(-) create mode 100644 test/electrumx_tor_gate_test.dart diff --git a/lib/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart index b313ebbd01..efb561d7b3 100644 --- a/lib/electrumx_rpc/electrumx_client.dart +++ b/lib/electrumx_rpc/electrumx_client.dart @@ -121,8 +121,7 @@ class ElectrumXClient { StreamSubscription? _torPreferenceListener; StreamSubscription? _torStatusListener; - final Mutex _torConnectingLock = Mutex(); - bool _requireMutex = false; + Completer? _torConnectionGate; final _adapterMutex = Mutex(); @@ -165,24 +164,16 @@ class ElectrumXClient { // Listen for tor status changes. _torStatusListener = bus.on().listen(( event, - ) async { + ) { if (_closed) return; switch (event.newStatus) { case TorConnectionStatus.connecting: - await _torConnectingLock.acquire(); - if (_closed) { - _torConnectingLock.release(); - return; - } - _requireMutex = true; + _torConnectionGate ??= Completer(); break; case TorConnectionStatus.connected: case TorConnectionStatus.disconnected: - if (_torConnectingLock.isLocked) { - _torConnectingLock.release(); - } - _requireMutex = false; + _releaseTorWaiters(); break; } }); @@ -233,13 +224,26 @@ class ElectrumXClient { Future closeAdapter() async { _closed = true; - _requireMutex = false; - if (_torConnectingLock.isLocked) _torConnectingLock.release(); + _releaseTorWaiters(); await _torPreferenceListener?.cancel(); await _torStatusListener?.cancel(); await _resetAdapter(); } + void _releaseTorWaiters() { + final gate = _torConnectionGate; + _torConnectionGate = null; + gate?.complete(); + } + + Future _waitForTor() async { + // Tor readiness is a signal, not a lock held by an individual request. + // Adapter setup remains serialized by _adapterMutex. + while (_torConnectionGate != null) { + await _torConnectionGate!.future; + } + } + Future _release(ElectrumClient client) async { final removed = ClientManager.sharedInstance.remove( cryptoCurrency: cryptoCurrency, @@ -426,13 +430,8 @@ class ElectrumXClient { throw WifiOnlyException(); } - if (_requireMutex) { - await _torConnectingLock.protect( - () async => await checkElectrumAdapter(), - ); - } else { - await checkElectrumAdapter(); - } + await _waitForTor(); + await checkElectrumAdapter(); try { final response = await _adapter!.requestWithTimeout( @@ -524,13 +523,8 @@ class ElectrumXClient { throw WifiOnlyException(); } - if (_requireMutex) { - await _torConnectingLock.protect( - () async => await checkElectrumAdapter(), - ); - } else { - await checkElectrumAdapter(); - } + await _waitForTor(); + await checkElectrumAdapter(); try { final futures = >[]; diff --git a/test/electrumx_tor_gate_test.dart b/test/electrumx_tor_gate_test.dart new file mode 100644 index 0000000000..343d4e4a27 --- /dev/null +++ b/test/electrumx_tor_gate_test.dart @@ -0,0 +1,175 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:event_bus/event_bus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/electrumx_rpc/client_manager.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +import 'utilities/tls_test_server.dart'; + +class _Prefs extends Mock implements Prefs { + @override + bool get useTor => false; + @override + bool get wifiOnly => false; + @override + bool get torKillSwitch => false; +} + +class _Tor extends Mock implements TorService { + @override + TorConnectionStatus get status => TorConnectionStatus.disconnected; +} + +Future _until(bool Function() ready) async { + final watch = Stopwatch()..start(); + while (!ready()) { + if (watch.elapsed > const Duration(seconds: 3)) { + throw TimeoutException('Waiting for Tor gate test server'); + } + await Future.delayed(const Duration(milliseconds: 5)); + } +} + +Future _outcome(Future future) => + future.then((value) => value, onError: (Object error) => error); + +void main() { + late Directory logs; + late EventBus bus; + setUpAll(() async { + logs = await Directory.systemTemp.createTemp('electrum_tor_gate'); + await Logging.instance.initialize(logs.path, level: Level.off); + }); + tearDownAll(() => logs.delete(recursive: true)); + setUp(() => bus = EventBus(sync: true)); + tearDown(() async { + await ClientManager.sharedInstance.closeAll(); + bus.destroy(); + }); + + Future start({bool stallVersion = false}) async { + final server = await TlsElectrumServer.start( + certificateName: null, + ignoredMethods: stallVersion ? {'server.version'} : const {}, + ); + addTearDown(server.close); + return server; + } + + ElectrumXClient clientFor(TlsElectrumServer server) { + final client = ElectrumXClient( + host: '127.0.0.1', + port: server.port, + useSSL: false, + prefs: _Prefs(), + torService: _Tor(), + cryptoCurrency: Bitcoin(CryptoCurrencyNetwork.main), + netType: TorPlainNetworkOption.both, + failovers: [], + globalEventBusForTesting: bus, + connectionTimeoutForSpecialCaseJsonRPCClients: const Duration(seconds: 2), + ); + addTearDown(client.closeAdapter); + return client; + } + + void status(TorConnectionStatus value) { + bus.fire(TorConnectionStatusChangedEvent(value, 'test')); + } + + final cancelled = isA().having( + (error) => error.message, + 'message', + 'Electrum client changed', + ); + + for (final batch in [false, true]) { + final label = batch ? 'batch' : 'request'; + Future send(ElectrumXClient client) => batch + ? client.batchRequest(command: 'probe', args: [[]]) + : client.request(command: 'probe'); + + test('$label waiters cancel when closed while Tor is connecting', () async { + final server = await start(); + final client = clientFor(server); + status(TorConnectionStatus.connecting); + await Future.delayed(Duration.zero); + final pending = [for (var i = 0; i < 3; i++) _outcome(send(client))]; + await Future.delayed(const Duration(milliseconds: 20)); + expect(server.accepted, 0); + + await client.closeAdapter(); + + for (final result in pending) { + expect(await result.timeout(const Duration(seconds: 3)), cancelled); + } + expect(server.accepted, 0); + expect(client.getElectrumAdapter(), isNull); + }); + + test( + '$label close after Tor resumes preserves setup cancellation', + () async { + final server = await start(stallVersion: true); + final client = clientFor(server); + status(TorConnectionStatus.connecting); + await Future.delayed(Duration.zero); + final result = _outcome(send(client)); + await Future.delayed(const Duration(milliseconds: 20)); + expect(server.accepted, 0); + status(TorConnectionStatus.connected); + await _until(() => server.methods.contains('server.version')); + + await client.closeAdapter(); + + expect(await result.timeout(const Duration(seconds: 3)), cancelled); + expect(client.getElectrumAdapter(), isNull); + expect(server.methods, ['server.version']); + await _until(() => server.disconnections == 1); + }, + ); + + for (final finished in [ + TorConnectionStatus.connected, + TorConnectionStatus.disconnected, + ]) { + test( + '$label waiters resume on $finished after repeated connecting events', + () async { + final server = await start(); + final client = clientFor(server); + status(TorConnectionStatus.connecting); + status(TorConnectionStatus.connecting); + await Future.delayed(Duration.zero); + final pending = [for (var i = 0; i < 3; i++) _outcome(send(client))]; + await Future.delayed(const Duration(milliseconds: 20)); + expect(server.accepted, 0); + + status(finished); + + for (final result in pending) { + expect( + await result.timeout(const Duration(seconds: 3)), + batch ? [{}] : {}, + ); + } + expect(server.accepted, 1); + expect( + server.methods.where((method) => method == 'probe'), + hasLength(3), + ); + }, + ); + } + } +} From 2a8fcb7b52889a4b3ff3dcbb10d2fc55965ee2c0 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 12:09:10 -0600 Subject: [PATCH 05/16] Preserve pending Electrum requests across failover --- lib/electrumx_rpc/client_manager.dart | 78 ++++++++++++- .../draining_electrum_client.dart | 71 ++++++++++++ lib/electrumx_rpc/electrumx_client.dart | 80 +++++++++---- test/electrumx_failover_test.dart | 106 +++++++++++++++++- test/electrumx_late_approval_test.dart | 7 +- test/electrumx_tls_test.dart | 59 ++++++++++ test/utilities/tls_test_server.dart | 2 + 7 files changed, 374 insertions(+), 29 deletions(-) create mode 100644 lib/electrumx_rpc/draining_electrum_client.dart diff --git a/lib/electrumx_rpc/client_manager.dart b/lib/electrumx_rpc/client_manager.dart index 2c68d3abf2..6c7c915961 100644 --- a/lib/electrumx_rpc/client_manager.dart +++ b/lib/electrumx_rpc/client_manager.dart @@ -6,11 +6,13 @@ import '../utilities/logger.dart'; import '../utilities/prefs.dart'; import '../utilities/tor_plain_net_option_enum.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; +import 'draining_electrum_client.dart'; typedef _DetachedClient = ({ ElectrumClient? client, TorPlainNetworkOption? network, StreamSubscription? subscription, + String? certificate, }); class ClientManager { @@ -23,6 +25,7 @@ class ClientManager { final Map _heights = {}; final Map> _subscriptions = {}; final Map> _heightCompleters = {}; + final _retiring = {}; String _keyHelper(CryptoCurrency cryptoCurrency) { return "${cryptoCurrency.runtimeType}_${cryptoCurrency.network.name}"; @@ -50,6 +53,7 @@ class ClientManager { required CryptoCurrency cryptoCurrency, required TorPlainNetworkOption netType, String? trustedCertificatePem, + bool drainPrevious = false, }) async { final key = _keyHelper(cryptoCurrency); final previous = _detachClient( @@ -82,7 +86,7 @@ class ClientManager { StateError('Electrum header subscription for $key ended'), ), ); - await _closeDetached(previous); + await _closeDetached(previous, drainFor: drainPrevious ? key : null); } void _retireFailedClient( @@ -117,15 +121,20 @@ class ClientManager { StackTrace? stackTrace, }) { if (expectedClient != null && !identical(_map[key], expectedClient)) { - return (client: null, network: null, subscription: null); + return ( + client: null, + network: null, + subscription: null, + certificate: null, + ); } final detached = ( client: _map.remove(key), network: _mapNet.remove(key), subscription: _subscriptions.remove(key), + certificate: _certificates.remove(key), ); _heights.remove(key); - _certificates.remove(key); final firstHeight = _heightCompleters.remove(key); if (firstHeight != null && !firstHeight.isCompleted) { firstHeight.completeError(error, stackTrace); @@ -133,7 +142,27 @@ class ClientManager { return detached; } - Future _closeDetached(_DetachedClient detached) async { + Future _closeDetached( + _DetachedClient detached, { + String? drainFor, + }) async { + final client = detached.client; + if (drainFor != null && client is DrainingElectrumClient) { + _retiring[client] = (key: drainFor, certificate: detached.certificate); + unawaited( + client.done.then( + (_) => _retiring.remove(client), + onError: (Object _) { + _retiring.remove(client); + }, + ), + ); + // Retirement does not wait for the outstanding RPCs: the replacement + // must be usable immediately. Their existing deadlines bound the drain. + await client.retire(); + await detached.subscription?.cancel(); + return; + } try { await detached.subscription?.cancel(); } finally { @@ -141,6 +170,40 @@ class ClientManager { } } + Future retireClient({ + required CryptoCurrency cryptoCurrency, + required ElectrumClient client, + }) { + final key = _keyHelper(cryptoCurrency); + return _closeDetached( + _detachClient( + key, + expectedClient: client, + error: StateError('Electrum client for $key was replaced'), + ), + drainFor: key, + ); + } + + /// Retired sockets still obey certificate and network changes. With no + /// predicate, close all retiring connections for this currency immediately. + Future closeRetiredClients({ + required CryptoCurrency cryptoCurrency, + bool Function(ElectrumClient, String?)? keep, + }) async { + final key = _keyHelper(cryptoCurrency); + final clients = [ + for (final entry in _retiring.entries) + if (entry.value.key == key && + (keep == null || !keep(entry.key, entry.value.certificate))) + entry.key, + ]; + for (final client in clients) { + _retiring.remove(client); + } + await Future.wait(clients.map((client) => client.close())); + } + Future getChainHeightFor(CryptoCurrency cryptoCurrency) async { final key = _keyHelper(cryptoCurrency); @@ -195,6 +258,11 @@ class ClientManager { for (final key in _map.keys.toList()) _detachClient(key, error: StateError('Electrum clients were closed')), ]; - await Future.wait(detached.map(_closeDetached)); + final retiring = _retiring.keys.toList(); + _retiring.clear(); + await Future.wait([ + ...detached.map(_closeDetached), + ...retiring.map((client) => client.close()), + ]); } } diff --git a/lib/electrumx_rpc/draining_electrum_client.dart b/lib/electrumx_rpc/draining_electrum_client.dart new file mode 100644 index 0000000000..8df5f929e9 --- /dev/null +++ b/lib/electrumx_rpc/draining_electrum_client.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:electrum_adapter/electrum_adapter.dart'; + +/// A superseded connection finishes its outstanding RPCs before closing. +/// Every adapter helper, including subscription setup, uses this request hook. +/// Explicit close still cancels immediately for revocation and shutdown. +mixin DrainingElectrumClient on ElectrumClient { + int _requests = 0; + bool _retiring = false; + + Future retire() async { + _retiring = true; + if (_requests == 0) await close(); + } + + @override + Future requestWithTimeout( + String method, { + dynamic parameters, + Duration? timeout, + }) { + _requests++; + try { + final pending = super.requestWithTimeout( + method, + parameters: parameters, + timeout: timeout, + ); + // Keep the original future: subscription setup relies on the adapter's + // synchronous completion ordering relative to following notifications. + unawaited( + pending.then( + (_) => _requestFinished(), + onError: (Object _) => _requestFinished(), + ), + ); + return pending; + } catch (_) { + _requestFinished(); + rethrow; + } + } + + void _requestFinished() { + if (--_requests == 0 && _retiring) close().ignore(); + } +} + +class ManagedElectrumClient extends ElectrumClient with DrainingElectrumClient { + ManagedElectrumClient( + super.channel, + super.host, + super.port, + super.useSSL, + super.proxyInfo, { + required super.requestTimeout, + }); +} + +class ManagedFiroElectrumClient extends FiroElectrumClient + with DrainingElectrumClient { + ManagedFiroElectrumClient( + super.channel, + super.host, + super.port, + super.useSSL, + super.proxyInfo, { + required super.requestTimeout, + }); +} diff --git a/lib/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart index efb561d7b3..f8ae264359 100644 --- a/lib/electrumx_rpc/electrumx_client.dart +++ b/lib/electrumx_rpc/electrumx_client.dart @@ -35,6 +35,7 @@ import '../utilities/tor_plain_net_option_enum.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; import '../wallets/crypto_currency/interfaces/electrumx_currency_interface.dart'; import 'client_manager.dart'; +import 'draining_electrum_client.dart'; import 'electrum_request_timeouts.dart'; class WifiOnlyException implements Exception {} @@ -244,7 +245,14 @@ class ElectrumXClient { } } - Future _release(ElectrumClient client) async { + Future _release(ElectrumClient client, {bool drain = false}) async { + if (drain) { + await ClientManager.sharedInstance.retireClient( + cryptoCurrency: cryptoCurrency, + client: client, + ); + return; + } final removed = ClientManager.sharedInstance.remove( cryptoCurrency: cryptoCurrency, expectedClient: client, @@ -265,6 +273,9 @@ class ElectrumXClient { if (current != null && !identical(current, pending)) { await _release(current); } + await ClientManager.sharedInstance.closeRetiredClients( + cryptoCurrency: cryptoCurrency, + ); } Future checkElectrumAdapter() => _adapterMutex.protect(() async { @@ -286,6 +297,7 @@ class ElectrumXClient { } bool matches( ElectrumClient client, + String? certificate, String h, int p, bool tls, @@ -294,8 +306,34 @@ class ElectrumXClient { client.host == h && client.port == p && client.useSSL == tls && - ClientManager.sharedInstance.trustedCertificateFor(cryptoCurrency) == - (tls ? pin : null); + certificate == (tls ? pin : null); + bool sameProxy(ElectrumClient client) => + client.proxyInfo?.host.address == proxy?.host.address && + client.proxyInfo?.port == proxy?.port; + bool permitted(ElectrumClient client, String? certificate) => + sameProxy(client) && + (matches( + client, + certificate, + host, + port, + useSSL, + trustedCertificatePem, + ) || + _failovers.any( + (node) => matches( + client, + certificate, + node.address, + node.port, + node.useSSL, + node.trustedCertificatePem, + ), + )); + await ClientManager.sharedInstance.closeRetiredClients( + cryptoCurrency: cryptoCurrency, + keep: permitted, + ); // Setup fails before the caller RPC is sent. Try each remaining configured // node once, including for callers that use the adapter directly. for (var index = currentFailoverIndex; index < _failovers.length; index++) { @@ -310,29 +348,31 @@ class ElectrumXClient { ? trustedCertificatePem : failover.trustedCertificatePem) : null; + String? managedCertificate() => + ClientManager.sharedInstance.trustedCertificateFor(cryptoCurrency); bool shareable(ElectrumClient client) => !client.peer.isClosed && - client.proxyInfo?.host.address == proxy?.host.address && - client.proxyInfo?.port == proxy?.port && + sameProxy(client) && (failover != null - ? matches(client, useHost, usePort, ssl, pem) - : matches(client, host, port, useSSL, trustedCertificatePem) || - _failovers.any( - (n) => matches( - client, - n.address, - n.port, - n.useSSL, - n.trustedCertificatePem, - ), - )); + ? matches( + client, + managedCertificate(), + useHost, + usePort, + ssl, + pem, + ) + : permitted(client, managedCertificate())); final existing = getElectrumAdapter(); if (existing != null && shareable(existing)) { _adapter = existing; return; } if (existing != null) { - await _release(existing); + await _release( + existing, + drain: permitted(existing, managedCertificate()), + ); ensureCurrent(); } @@ -361,7 +401,7 @@ class ElectrumXClient { onBadCertificate: certificateCheck, ); candidate = cryptoCurrency is Firo - ? FiroElectrumClient( + ? ManagedFiroElectrumClient( channel, useHost, usePort, @@ -369,7 +409,7 @@ class ElectrumXClient { proxy, requestTimeout: requestTimeouts.standard, ) - : ElectrumClient( + : ManagedElectrumClient( channel, useHost, usePort, @@ -395,6 +435,8 @@ class ElectrumXClient { cryptoCurrency: cryptoCurrency, netType: netType, trustedCertificatePem: pem, + drainPrevious: + other != null && permitted(other, managedCertificate()), ); ensureCurrent(); kept = true; diff --git a/test/electrumx_failover_test.dart b/test/electrumx_failover_test.dart index 1ce1008298..13ea7b9b64 100644 --- a/test/electrumx_failover_test.dart +++ b/test/electrumx_failover_test.dart @@ -229,15 +229,73 @@ void main() { ElectrumXClient client( ElectrumXNode primary, { List failovers = const [], + CryptoCurrency? currency, }) => ElectrumXClient.from( node: primary, prefs: _Prefs(), torService: _Tor(), failovers: failovers, - cryptoCurrency: coin, + cryptoCurrency: currency ?? coin, globalEventBusForTesting: bus, ); + for (final currency in [coin, Firo(CryptoCurrencyNetwork.main)]) { + for (final sameOwner in [false, true]) { + for (final timeout in [false, true]) { + test('${currency.runtimeType} failover preserves pending work ' + '(same owner=$sameOwner, timeout=$timeout)', () async { + final primary = await _PubServer.start(autoVersion: true); + final fallback = await _PubServer.start(autoVersion: true); + addTearDown(primary.close); + addTearDown(fallback.close); + primary.onRequest = (rpc) { + if (rpc.method == 'probe' && !timeout) { + rpc.reply({'error': 'try the fallback'}); + } + }; + fallback.onRequest = (rpc) { + if (rpc.method == 'probe') rpc.reply({'via': 'fallback'}); + }; + final actor = client( + node(primary.port, 'primary'), + currency: currency, + failovers: [node(fallback.port, 'fallback')], + ); + final reader = sameOwner + ? actor + : client(node(primary.port, 'primary'), currency: currency); + addTearDown(actor.closeAdapter); + if (!sameOwner) addTearDown(reader.closeAdapter); + await actor.checkElectrumAdapter(); + await ClientManager.sharedInstance.getChainHeightFor(currency); + final previous = actor.getElectrumAdapter()!; + final pending = reader + .getTransaction(txHash: 'a' * 64) + .then((value) => value, onError: (Object e) => e); + await _until( + () => primary.requests.any( + (r) => r.method == 'blockchain.transaction.get', + ), + ); + + expect( + await actor.request( + command: 'probe', + requestTimeout: const Duration(milliseconds: 150), + ), + {'via': 'fallback'}, + ); + expect(previous.peer.isClosed, false); + primary.requests + .singleWhere((r) => r.method == 'blockchain.transaction.get') + .reply({'confirmations': 7}); + expect(await pending, {'confirmations': 7}); + await _until(() => previous.peer.isClosed); + }); + } + } + } + test( 'requests after a successful failover request stay on the failover', () async { @@ -259,6 +317,37 @@ void main() { }, ); + test('a draining connection closes when its last RPC times out', () async { + final primary = await _PubServer.start(autoVersion: true); + final fallback = await _PubServer.start(autoVersion: true); + addTearDown(primary.close); + addTearDown(fallback.close); + final connection = client( + node(primary.port, 'primary'), + failovers: [node(fallback.port, 'fallback')], + ); + addTearDown(connection.closeAdapter); + await connection.checkElectrumAdapter(); + await ClientManager.sharedInstance.getChainHeightFor(coin); + final previous = connection.getElectrumAdapter()!; + final pending = previous + .requestWithTimeout( + 'probe.held', + timeout: const Duration(milliseconds: 300), + ) + .then((value) => value, onError: (Object e) => e); + await _until(() => primary.requests.any((r) => r.method == 'probe.held')); + // Received traffic makes this an individual timeout: the adapter itself + // would leave the transport alive, so retirement must finish the cleanup. + await previous.request('server.version'); + connection.currentFailoverIndex = 0; + await connection.checkElectrumAdapter(); + expect(previous.peer.isClosed, false); + expect(await pending, isA()); + await _until(() => previous.peer.isClosed); + expect(connection.getElectrumAdapter()!.port, fallback.port); + }); + for (final primaryShadows in [false, true]) { test( 'duplicate endpoint approvals do not hide a successful ${primaryShadows ? 'primary' : 'failover'} fallback', @@ -429,12 +518,27 @@ void main() { expect(second.getElectrumAdapter()!.port, primary.port); // Let the primary's header subscription finish before it is replaced. await ClientManager.sharedInstance.getChainHeightFor(coin); + final publishedPrimary = second.getElectrumAdapter()!; + final reading = second + .getTransaction(txHash: 'a' * 64) + .then((value) => value, onError: (Object e) => e); + await _until( + () => primary.requests.any( + (rpc) => rpc.method == 'blockchain.transaction.get', + ), + ); fallback.versions.single.reply(['test', '1.4']); final result = await pending.timeout(const Duration(seconds: 4)); expect(result, { 'via': 'fallback', }, reason: 'the selected fallback must serve the retry, not the primary'); expect(first.getElectrumAdapter()!.port, fallback.port); + expect(publishedPrimary.peer.isClosed, false); + primary.requests + .singleWhere((rpc) => rpc.method == 'blockchain.transaction.get') + .reply({'confirmations': 7}); + expect(await reading, {'confirmations': 7}); + await _until(() => publishedPrimary.peer.isClosed); expect( fallback.requests.where((r) => r.method == 'probe').length, 1, diff --git a/test/electrumx_late_approval_test.dart b/test/electrumx_late_approval_test.dart index 313781f9cb..2fa63a95e4 100644 --- a/test/electrumx_late_approval_test.dart +++ b/test/electrumx_late_approval_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'package:event_bus/event_bus.dart'; @@ -30,12 +29,10 @@ class _Prefs extends Mock implements Prefs { class _Tor extends Mock implements TorService { _Tor(this.proxyInfo); final ({InternetAddress host, int port}) proxyInfo; - final requested = Completer(); @override TorConnectionStatus get status => TorConnectionStatus.connected; @override ({InternetAddress host, int port}) getProxyInfo() { - if (!requested.isCompleted) requested.complete(); return proxyInfo; } } @@ -95,7 +92,9 @@ void main() { (value) => value, onError: (Object error) => error, ); - await tor.requested.future.timeout(const Duration(seconds: 2)); + // Wait for actual socket setup, not just a proxy-settings lookup. The + // connection must finish late to exercise stale approval cleanup. + await proxy.connected.future.timeout(const Duration(seconds: 2)); await oldClient.closeAdapter(); expect(await newClient.ping(), true); final replacement = newClient.getElectrumAdapter(); diff --git a/test/electrumx_tls_test.dart b/test/electrumx_tls_test.dart index 6d1c3d4d43..653ff3e793 100644 --- a/test/electrumx_tls_test.dart +++ b/test/electrumx_tls_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:event_bus/event_bus.dart'; @@ -8,6 +9,7 @@ import 'package:stackwallet/electrumx_rpc/client_manager.dart'; import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; import 'package:stackwallet/models/node_model.dart'; import 'package:stackwallet/services/node_service.dart'; +import 'package:stackwallet/services/event_bus/events/global/tor_status_changed_event.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/services/tor_service.dart'; @@ -213,4 +215,61 @@ void main() { ); expect(peer.isClosed, true); }); + + for (final action in ['revoke', 'Tor change', 'close owner', 'close all']) { + test('$action also closes a connection draining after failover', () async { + final primary = await TlsElectrumServer.start( + ignoredMethods: {'blockchain.transaction.get'}, + ); + servers.add(primary); + final fallback = await start('node_b'); + final fallbackNode = node(fallback, certificateName: 'node_b'); + final connection = client( + node(primary, certificateName: 'node_a'), + failovers: [fallbackNode], + ); + addTearDown(connection.closeAdapter); + await connection.checkElectrumAdapter(); + await ClientManager.sharedInstance.getChainHeightFor(coin); + final previous = connection.getElectrumAdapter()!; + final pending = connection + .getTransaction(txHash: 'a' * 64) + .then((value) => value, onError: (Object e) => e); + await _until( + () => primary.methods.contains('blockchain.transaction.get'), + ); + connection.currentFailoverIndex = 0; + await connection.checkElectrumAdapter(); + expect(connection.getElectrumAdapter()!.port, fallback.port); + expect(previous.peer.isClosed, false); + + switch (action) { + case 'revoke': + final revoked = client(node(primary), failovers: [fallbackNode]); + addTearDown(revoked.closeAdapter); + // The current fallback is still permitted, but the retired primary + // must immediately lose its old certificate approval. + await revoked.checkElectrumAdapter(); + expect(revoked.getElectrumAdapter()!.port, fallback.port); + case 'Tor change': + bus.fire(TorPreferenceChangedEvent(status: TorStatus.enabled)); + case 'close owner': + await connection.closeAdapter(); + case 'close all': + await ClientManager.sharedInstance.closeAll(); + } + await _until(() => previous.peer.isClosed); + expect(await pending, isA()); + }); + } +} + +Future _until(bool Function() ready) async { + final watch = Stopwatch()..start(); + while (!ready()) { + if (watch.elapsed > const Duration(seconds: 3)) { + throw TimeoutException('Waiting for TLS lifecycle'); + } + await Future.delayed(const Duration(milliseconds: 5)); + } } diff --git a/test/utilities/tls_test_server.dart b/test/utilities/tls_test_server.dart index e5af73d542..ae17e1f0de 100644 --- a/test/utilities/tls_test_server.dart +++ b/test/utilities/tls_test_server.dart @@ -238,6 +238,7 @@ class TlsTestProxy { final int destinationPort; final Duration greetingDelay; final destinations = []; + final connected = Completer(); final _sockets = []; late final StreamSubscription _subscription; ({InternetAddress host, int port}) get info => @@ -258,6 +259,7 @@ class TlsTestProxy { Future _forward(Socket client) async { _sockets.add(client); + if (!connected.isCompleted) connected.complete(); final input = _SocketReader(client); bool inputForwarded = false; try { From 7beb9f561b12d3bfff86aa18a2cfbb7791735fd1 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 13:45:15 -0600 Subject: [PATCH 06/16] Drain the shared Electrum client when its header subscription fails A failed header subscription detached and closed the coin's shared client at once, failing other wallets' requests that were still waiting on it. Retire it like a failover replacement instead, so those requests finish before the connection closes. --- lib/electrumx_rpc/client_manager.dart | 7 +++- test/client_manager_lifecycle_test.dart | 54 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/electrumx_rpc/client_manager.dart b/lib/electrumx_rpc/client_manager.dart index 6c7c915961..eb12848244 100644 --- a/lib/electrumx_rpc/client_manager.dart +++ b/lib/electrumx_rpc/client_manager.dart @@ -103,8 +103,13 @@ class ClientManager { ); // A rejected header subscription can leave its RPC connection open. Retire // it as well, so the next wallet request can establish a healthy client. + // Other wallets' requests already sent on it finish first: a failed + // subscription does not mean those responses will not arrive. unawaited( - _closeDetached(detached).catchError((Object err, StackTrace stack) { + _closeDetached(detached, drainFor: key).catchError(( + Object err, + StackTrace stack, + ) { Logging.instance.e( 'ClientManager cleanup', error: err, diff --git a/test/client_manager_lifecycle_test.dart b/test/client_manager_lifecycle_test.dart index f81b6a833d..e9334a220f 100644 --- a/test/client_manager_lifecycle_test.dart +++ b/test/client_manager_lifecycle_test.dart @@ -5,6 +5,7 @@ import 'package:electrum_adapter/electrum_adapter.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:logger/logger.dart' show Level; import 'package:stackwallet/electrumx_rpc/client_manager.dart'; +import 'package:stackwallet/electrumx_rpc/draining_electrum_client.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; @@ -195,6 +196,59 @@ void main() { await expectHealthyReplacement(); }); + test('subscription failure lets other in-flight requests finish', () async { + final channel = StreamChannelController(); + final server = channel.foreign; + final ids = {}; + final serverSubscription = server.stream.listen((dynamic request) { + ids[request['method'] as String] = request['id']; + }); + addTearDown(() async { + await serverSubscription.cancel(); + await server.sink.close(); + }); + final client = ManagedElectrumClient( + channel.local, + 'test.electrum', + 50001, + false, + null, + requestTimeout: const Duration(seconds: 5), + ); + addTearDown(client.close); + await manager.addClient( + client, + cryptoCurrency: coin, + netType: TorPlainNetworkOption.both, + ); + // Another wallet's request is waiting on the same connection. + final probe = client.requestWithTimeout('probe'); + await _until( + () => + ids.containsKey('blockchain.headers.subscribe') && + ids.containsKey('probe'), + ); + + server.sink.add({ + 'jsonrpc': '2.0', + 'id': ids['blockchain.headers.subscribe'], + 'error': {'code': -32000, 'message': 'header subscription rejected'}, + }); + await _until( + () => + manager.getClient( + cryptoCurrency: coin, + netType: TorPlainNetworkOption.both, + ) == + null, + ); + expect(client.peer.isClosed, false); + + server.sink.add({'jsonrpc': '2.0', 'id': ids['probe'], 'result': 'done'}); + expect(await probe.timeout(const Duration(seconds: 2)), 'done'); + await _until(() => client.peer.isClosed); + }); + test('subscription timeout releases its first-height waiter', () async { final original = await add( requestTimeout: const Duration(milliseconds: 100), From cc4c8c93877b65c063bddb608c5dea79575faf47 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 13:45:15 -0600 Subject: [PATCH 07/16] Check a failover approval before retiring the shared Electrum client A damaged saved certificate on a failover was only detected after the coin's working connection had been retired, so every primary failure cost other wallets a reconnect for an attempt that could not succeed. Validate the approval first. --- lib/electrumx_rpc/electrumx_client.dart | 16 +++++---- test/electrumx_setup_failover_test.dart | 45 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/lib/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart index f8ae264359..4c69900e08 100644 --- a/lib/electrumx_rpc/electrumx_client.dart +++ b/lib/electrumx_rpc/electrumx_client.dart @@ -368,16 +368,11 @@ class ElectrumXClient { _adapter = existing; return; } - if (existing != null) { - await _release( - existing, - drain: permitted(existing, managedCertificate()), - ); - ensureCurrent(); - } // Invalid saved approvals are a local configuration error. They must not // be mistaken for a failed network attempt or bypassed by failover. + // Check before retiring the shared connection: an attempt that cannot + // connect must not take the coin's working connection down with it. final bool Function(X509Certificate)? certificateCheck; try { certificateCheck = trustedCertificateCheck(pem); @@ -386,6 +381,13 @@ class ElectrumXClient { currentFailoverIndex = -1; rethrow; } + if (existing != null) { + await _release( + existing, + drain: permitted(existing, managedCertificate()), + ); + ensureCurrent(); + } ElectrumClient? candidate; bool negotiated = false; bool kept = false; diff --git a/test/electrumx_setup_failover_test.dart b/test/electrumx_setup_failover_test.dart index 7cef6393fe..b02d0ce1d9 100644 --- a/test/electrumx_setup_failover_test.dart +++ b/test/electrumx_setup_failover_test.dart @@ -285,6 +285,51 @@ void main() { }, ); + test( + 'invalid fallback approval leaves the shared connection in place', + () async { + final primary = await start(); + final invalid = await start(certificateName: 'node_a'); + final healthy = await start(); + final client = clientFor(primary.port, [ + ElectrumXNode( + address: '127.0.0.1', + port: invalid.port, + name: 'invalid approval', + id: '${invalid.port}', + useSSL: true, + torEnabled: true, + clearnetEnabled: true, + trustedCertificatePem: 'invalid PEM', + ), + node(healthy), + ]); + await client.checkElectrumAdapter().timeout(const Duration(seconds: 3)); + final shared = client.getElectrumAdapter()!; + expect(shared.port, primary.port); + + // A request on the primary failed, so the next attempt starts at the + // first failover, whose saved approval is damaged. + client.currentFailoverIndex = 0; + await expectLater( + client.checkElectrumAdapter().timeout(const Duration(seconds: 3)), + throwsA(isA()), + ); + + expect(client.getElectrumAdapter(), same(shared)); + expect(shared.peer.isClosed, false); + expect(client.currentFailoverIndex, -1); + expect(invalid.accepted, 0); + expect(healthy.accepted, 0); + + // The next attempt reuses the primary connection without reconnecting. + await client.checkElectrumAdapter().timeout(const Duration(seconds: 3)); + expect(client.getElectrumAdapter(), same(shared)); + expect(primary.accepted, 1); + expect(primary.disconnections, 0); + }, + ); + test('concurrent setup callers share the successful fallback', () async { final primary = await start(ignoredMethods: {'server.version'}); final fallback = await start(); From 0db8c6a9a4ef479e55cd2b4038f114ee81056560 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 13:45:15 -0600 Subject: [PATCH 08/16] Close draining Electrum clients when a node changes Connections still finishing requests after a failover could keep using a changed or revoked approval when no wallet of the coin was loaded to close them. The node change now closes them itself. --- lib/utilities/node_change_notifier.dart | 6 +++ test/widget_tests/node_tls_trust_test.dart | 45 ++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/lib/utilities/node_change_notifier.dart b/lib/utilities/node_change_notifier.dart index 041320d6f3..df484a59a3 100644 --- a/lib/utilities/node_change_notifier.dart +++ b/lib/utilities/node_change_notifier.dart @@ -22,6 +22,12 @@ Future Function() nodeChangeNotifier(WidgetRef ref, CryptoCurrency coin) { cryptoCurrency: coin, ); await removed?.close(); + // Connections still finishing requests after a failover may use the old + // or revoked approval. Loaded wallets close them while updating, but no + // wallet of this coin may be loaded. + await ClientManager.sharedInstance.closeRetiredClients( + cryptoCurrency: coin, + ); for (final wallet in wallets.wallets.where((e) => e.info.coin == coin)) { final shouldRefresh = switch (prefs.syncType) { SyncingType.currentWalletOnly => activeWallet.state == wallet.walletId, diff --git a/test/widget_tests/node_tls_trust_test.dart b/test/widget_tests/node_tls_trust_test.dart index 0cc0c83476..7f7db62160 100644 --- a/test/widget_tests/node_tls_trust_test.dart +++ b/test/widget_tests/node_tls_trust_test.dart @@ -10,6 +10,7 @@ import 'package:mockito/mockito.dart'; import 'package:platform/platform.dart'; import 'package:stackwallet/db/hive/db.dart'; import 'package:stackwallet/electrumx_rpc/client_manager.dart'; +import 'package:stackwallet/electrumx_rpc/draining_electrum_client.dart'; import 'package:stackwallet/models/isar/stack_theme.dart'; import 'package:stackwallet/models/node_model.dart'; import 'package:stackwallet/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart'; @@ -25,12 +26,14 @@ import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/utilities/test_node_connection.dart'; +import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; import 'package:stackwallet/utilities/util.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'package:stackwallet/wallets/wallet/wallet.dart'; import 'package:stackwallet/widgets/desktop/primary_button.dart'; import 'package:stackwallet/widgets/node_card.dart'; import 'package:stackwallet/widgets/node_options_sheet.dart'; +import 'package:stream_channel/stream_channel.dart'; import '../sample_data/theme_json.dart'; import '../utilities/tls_test_server.dart'; @@ -266,6 +269,48 @@ void main() { expect(nodes.getNodeById(id: 'other-node')!.isPrimary, false); }); + testWidgets('a node change closes connections still finishing requests', ( + tester, + ) async { + // A failed-over connection is still waiting on a request. No wallet of + // this coin is loaded, so only the node change itself can close it. + final channel = StreamChannelController(); + final draining = ManagedElectrumClient( + channel.local, + 'node.example', + 50002, + true, + null, + requestTimeout: null, + ); + final manager = ClientManager.sharedInstance; + late final Future pending; + // The client's plumbing needs the real event loop, not the fake clock. + await tester.runAsync(() async { + await manager.addClient( + draining, + cryptoCurrency: coin, + netType: TorPlainNetworkOption.both, + ); + pending = draining + .requestWithTimeout('probe') + .then((_) => null, onError: (Object error) => error); + await manager.retireClient(cryptoCurrency: coin, client: draining); + }); + expect(draining.peer.isClosed, false); + + await pump( + tester, + NodeOptionsSheet(nodeId: 'custom-node', coin: coin, popBackToRoute: '/'), + ); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + + expect(draining.peer.isClosed, true); + expect(await tester.runAsync(() => pending), isA()); + await tester.runAsync(() => channel.foreign.sink.close()); + }, timeout: const Timeout(Duration(seconds: 30))); + testWidgets('node card Connect carries an already approved certificate', ( tester, ) async { From ab7e963e0aac9991efae2fe4eb34c87f8d494ce2 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 14:07:43 -0600 Subject: [PATCH 09/16] Give each ElectrumXClient its own Electrum connection Replace the per-coin ClientManager registry with a connection owned by each ElectrumXClient instance, so each loaded wallet has its own socket. - getElectrumAdapter() returns the instance's own client. - Failover retires the previous connection with DrainingElectrumClient, tracked per instance, so this client's in-flight requests finish. A connection on an outdated Tor route is closed instead of drained. - closeAdapter() and Tor preference changes close the current, pending and draining connections at once. - A corrupt saved PEM is still checked before the working connection is retired, resets the failover index and is rethrown. - fetchChainHeight() requests the tip on the wallet's own connection instead of reading the shared header subscription, and keeps its Tor/clearnet-only node check. - openConnection() is a protected hook so tests can supply a transport. - node_change_notifier only updates loaded wallets; there is no shared connection left to close. --- lib/electrumx_rpc/client_manager.dart | 273 ------------------ lib/electrumx_rpc/electrumx_client.dart | 252 ++++++++-------- lib/utilities/node_change_notifier.dart | 16 +- .../electrumx_interface.dart | 20 +- 4 files changed, 132 insertions(+), 429 deletions(-) delete mode 100644 lib/electrumx_rpc/client_manager.dart diff --git a/lib/electrumx_rpc/client_manager.dart b/lib/electrumx_rpc/client_manager.dart deleted file mode 100644 index eb12848244..0000000000 --- a/lib/electrumx_rpc/client_manager.dart +++ /dev/null @@ -1,273 +0,0 @@ -import 'dart:async'; - -import 'package:electrum_adapter/electrum_adapter.dart'; - -import '../utilities/logger.dart'; -import '../utilities/prefs.dart'; -import '../utilities/tor_plain_net_option_enum.dart'; -import '../wallets/crypto_currency/crypto_currency.dart'; -import 'draining_electrum_client.dart'; - -typedef _DetachedClient = ({ - ElectrumClient? client, - TorPlainNetworkOption? network, - StreamSubscription? subscription, - String? certificate, -}); - -class ClientManager { - ClientManager._(); - static final ClientManager sharedInstance = ClientManager._(); - - final Map _map = {}; - final Map _mapNet = {}; - final Map _certificates = {}; - final Map _heights = {}; - final Map> _subscriptions = {}; - final Map> _heightCompleters = {}; - final _retiring = {}; - - String _keyHelper(CryptoCurrency cryptoCurrency) { - return "${cryptoCurrency.runtimeType}_${cryptoCurrency.network.name}"; - } - - final Finalizer _finalizer = Finalizer((manager) async { - await manager._kill(); - }); - - ElectrumClient? getClient({ - required CryptoCurrency cryptoCurrency, - required TorPlainNetworkOption netType, - }) { - final _key = _keyHelper(cryptoCurrency); - - if (netType == _mapNet[_key]) { - return _map[_key]; - } else { - return null; - } - } - - Future addClient( - ElectrumClient client, { - required CryptoCurrency cryptoCurrency, - required TorPlainNetworkOption netType, - String? trustedCertificatePem, - bool drainPrevious = false, - }) async { - final key = _keyHelper(cryptoCurrency); - final previous = _detachClient( - key, - error: StateError('Electrum client for $key was replaced'), - ); - _map[key] = client; - _mapNet[key] = netType; - _heights.remove(key); - _certificates[key] = trustedCertificatePem; - final firstHeight = _heightCompleters[key] = Completer(); - // A subscription can fail before a wallet starts waiting for its height. - // Observe the error now without changing what later consumers receive. - firstHeight.future.ignore(); - _subscriptions[key] = client.subscribeHeaders().listen( - (event) { - if (!identical(_map[key], client)) return; - _heights[key] = event.height; - if (!firstHeight.isCompleted) { - firstHeight.complete(event.height); - } - }, - onError: (Object err, StackTrace s) { - Logging.instance.e("ClientManager listen", error: err, stackTrace: s); - _retireFailedClient(key, client, err, s); - }, - onDone: () => _retireFailedClient( - key, - client, - StateError('Electrum header subscription for $key ended'), - ), - ); - await _closeDetached(previous, drainFor: drainPrevious ? key : null); - } - - void _retireFailedClient( - String key, - ElectrumClient client, - Object error, [ - StackTrace? stackTrace, - ]) { - final detached = _detachClient( - key, - expectedClient: client, - error: error, - stackTrace: stackTrace, - ); - // A rejected header subscription can leave its RPC connection open. Retire - // it as well, so the next wallet request can establish a healthy client. - // Other wallets' requests already sent on it finish first: a failed - // subscription does not mean those responses will not arrive. - unawaited( - _closeDetached(detached, drainFor: key).catchError(( - Object err, - StackTrace stack, - ) { - Logging.instance.e( - 'ClientManager cleanup', - error: err, - stackTrace: stack, - ); - }), - ); - } - - _DetachedClient _detachClient( - String key, { - ElectrumClient? expectedClient, - required Object error, - StackTrace? stackTrace, - }) { - if (expectedClient != null && !identical(_map[key], expectedClient)) { - return ( - client: null, - network: null, - subscription: null, - certificate: null, - ); - } - final detached = ( - client: _map.remove(key), - network: _mapNet.remove(key), - subscription: _subscriptions.remove(key), - certificate: _certificates.remove(key), - ); - _heights.remove(key); - final firstHeight = _heightCompleters.remove(key); - if (firstHeight != null && !firstHeight.isCompleted) { - firstHeight.completeError(error, stackTrace); - } - return detached; - } - - Future _closeDetached( - _DetachedClient detached, { - String? drainFor, - }) async { - final client = detached.client; - if (drainFor != null && client is DrainingElectrumClient) { - _retiring[client] = (key: drainFor, certificate: detached.certificate); - unawaited( - client.done.then( - (_) => _retiring.remove(client), - onError: (Object _) { - _retiring.remove(client); - }, - ), - ); - // Retirement does not wait for the outstanding RPCs: the replacement - // must be usable immediately. Their existing deadlines bound the drain. - await client.retire(); - await detached.subscription?.cancel(); - return; - } - try { - await detached.subscription?.cancel(); - } finally { - await detached.client?.close(); - } - } - - Future retireClient({ - required CryptoCurrency cryptoCurrency, - required ElectrumClient client, - }) { - final key = _keyHelper(cryptoCurrency); - return _closeDetached( - _detachClient( - key, - expectedClient: client, - error: StateError('Electrum client for $key was replaced'), - ), - drainFor: key, - ); - } - - /// Retired sockets still obey certificate and network changes. With no - /// predicate, close all retiring connections for this currency immediately. - Future closeRetiredClients({ - required CryptoCurrency cryptoCurrency, - bool Function(ElectrumClient, String?)? keep, - }) async { - final key = _keyHelper(cryptoCurrency); - final clients = [ - for (final entry in _retiring.entries) - if (entry.value.key == key && - (keep == null || !keep(entry.key, entry.value.certificate))) - entry.key, - ]; - for (final client in clients) { - _retiring.remove(client); - } - await Future.wait(clients.map((client) => client.close())); - } - - Future getChainHeightFor(CryptoCurrency cryptoCurrency) async { - final key = _keyHelper(cryptoCurrency); - - if (_map[key] == null) { - throw Exception("No managed ElectrumClient for $key found."); - } - if (_heightCompleters[key] == null) { - throw Exception("No managed _heightCompleters for $key found."); - } - - if (Prefs.instance.useTor) { - if (_mapNet[key]! == TorPlainNetworkOption.clear) { - throw Exception("Non-TOR only client for $key found."); - } - } else { - if (_mapNet[key]! == TorPlainNetworkOption.tor) { - throw Exception("TOR only client for $key found."); - } - } - - return _heights[key] ?? await _heightCompleters[key]!.future; - } - - /// The certificate the managed connection for [cryptoCurrency] was opened - /// with, or null when it relies on the platform's trusted roots. - String? trustedCertificateFor(CryptoCurrency cryptoCurrency) => - _certificates[_keyHelper(cryptoCurrency)]; - - Future<(ElectrumClient?, TorPlainNetworkOption?)> remove({ - required CryptoCurrency cryptoCurrency, - ElectrumClient? expectedClient, - }) async { - final key = _keyHelper(cryptoCurrency); - final detached = _detachClient( - key, - expectedClient: expectedClient, - error: StateError('Electrum client for $key was removed'), - ); - await detached.subscription?.cancel(); - return (detached.client, detached.network); - } - - Future closeAll() async { - await _kill(); - _finalizer.detach(this); - } - - Future _kill() async { - // Detach synchronously before awaiting cancellation. Old callbacks must - // never remove a successor that is added while cleanup is in progress. - final detached = [ - for (final key in _map.keys.toList()) - _detachClient(key, error: StateError('Electrum clients were closed')), - ]; - final retiring = _retiring.keys.toList(); - _retiring.clear(); - await Future.wait([ - ...detached.map(_closeDetached), - ...retiring.map((client) => client.close()), - ]); - } -} diff --git a/lib/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart index 4c69900e08..34c39d8255 100644 --- a/lib/electrumx_rpc/electrumx_client.dart +++ b/lib/electrumx_rpc/electrumx_client.dart @@ -17,6 +17,7 @@ import 'package:electrum_adapter/electrum_adapter.dart' as electrum_adapter; import 'package:electrum_adapter/electrum_adapter.dart'; import 'package:electrum_adapter/methods/specific/firo.dart'; import 'package:event_bus/event_bus.dart'; +import 'package:meta/meta.dart' show protected; import 'package:mutex/mutex.dart'; import '../app_config.dart'; @@ -34,7 +35,6 @@ import '../utilities/tls_certificate.dart'; import '../utilities/tor_plain_net_option_enum.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; import '../wallets/crypto_currency/interfaces/electrumx_currency_interface.dart'; -import 'client_manager.dart'; import 'draining_electrum_client.dart'; import 'electrum_request_timeouts.dart'; @@ -101,15 +101,19 @@ class ElectrumXClient { bool _closed = false; int _connectionGeneration = 0; + + /// This client's own connection. No other [ElectrumXClient] uses it. ElectrumClient? _adapter; + + /// The certificate [_adapter] was opened with; null for platform roots. + String? _adapterCertificate; ElectrumClient? _pendingClient; - ElectrumClient? getElectrumAdapter() => _closed - ? null - : ClientManager.sharedInstance.getClient( - cryptoCurrency: cryptoCurrency, - netType: netType, - ); + /// This client's superseded connections that are still finishing requests + /// it sent before a failover. + final _retiring = {}; + + ElectrumClient? getElectrumAdapter() => _closed ? null : _adapter; late Prefs _prefs; late TorService _torService; @@ -245,37 +249,75 @@ class ElectrumXClient { } } - Future _release(ElectrumClient client, {bool drain = false}) async { - if (drain) { - await ClientManager.sharedInstance.retireClient( - cryptoCurrency: cryptoCurrency, - client: client, - ); - return; - } - final removed = ClientManager.sharedInstance.remove( - cryptoCurrency: cryptoCurrency, - expectedClient: client, + /// Requests this client already sent on a superseded connection still + /// finish; the connection closes after the last one. Their own deadlines + /// bound the drain. + Future _retire(ElectrumClient client) async { + if (client is! DrainingElectrumClient) return client.close(); + _retiring.add(client); + unawaited( + client.done.then( + (_) => _retiring.remove(client), + onError: (Object _) { + _retiring.remove(client); + }, + ), ); - try { - await client.close(); - } finally { - await removed; - } + await client.retire(); } + /// Closes the current, pending and draining connections at once, for + /// closing the owner and for Tor preference changes. Future _resetAdapter() async { _connectionGeneration++; - final pending = _pendingClient; - final current = _adapter; + final clients = { + if (_pendingClient != null) _pendingClient!, + if (_adapter != null) _adapter!, + ..._retiring, + }; _pendingClient = _adapter = null; - if (pending != null) await _release(pending); - if (current != null && !identical(current, pending)) { - await _release(current); - } - await ClientManager.sharedInstance.closeRetiredClients( - cryptoCurrency: cryptoCurrency, + _adapterCertificate = null; + _retiring.clear(); + await Future.wait(clients.map((client) => client.close())); + } + + /// Opens one connection for [checkElectrumAdapter]. Tests override this to + /// supply an in-memory transport; the setup and failover logic is shared. + @protected + Future openConnection({ + required String host, + required int port, + required bool useSSL, + required ({InternetAddress host, int port})? proxy, + required bool Function(X509Certificate)? onBadCertificate, + }) async { + final channel = await electrum_adapter.connect( + host, + port: port, + useSSL: useSSL, + proxyInfo: proxy, + connectionTimeout: connectionTimeoutForSpecialCaseJsonRPCClients, + connectionDeadline: connectionTimeoutForSpecialCaseJsonRPCClients, + acceptUnverified: false, + onBadCertificate: onBadCertificate, ); + return cryptoCurrency is Firo + ? ManagedFiroElectrumClient( + channel, + host, + port, + useSSL, + proxy, + requestTimeout: requestTimeouts.standard, + ) + : ManagedElectrumClient( + channel, + host, + port, + useSSL, + proxy, + requestTimeout: requestTimeouts.standard, + ); } Future checkElectrumAdapter() => _adapterMutex.protect(() async { @@ -295,45 +337,17 @@ class ElectrumXClient { throw StateError('Tor is not connected'); } } - bool matches( - ElectrumClient client, - String? certificate, - String h, - int p, - bool tls, - String? pin, - ) => - client.host == h && - client.port == p && - client.useSSL == tls && - certificate == (tls ? pin : null); - bool sameProxy(ElectrumClient client) => + // Only this client's own nodes are ever connected, and they cannot change + // for its lifetime. A connection therefore needs to be checked against + // the selected endpoint and approval, and against the current Tor route. + bool sameRoute(ElectrumClient client) => client.proxyInfo?.host.address == proxy?.host.address && client.proxyInfo?.port == proxy?.port; - bool permitted(ElectrumClient client, String? certificate) => - sameProxy(client) && - (matches( - client, - certificate, - host, - port, - useSSL, - trustedCertificatePem, - ) || - _failovers.any( - (node) => matches( - client, - certificate, - node.address, - node.port, - node.useSSL, - node.trustedCertificatePem, - ), - )); - await ClientManager.sharedInstance.closeRetiredClients( - cryptoCurrency: cryptoCurrency, - keep: permitted, - ); + // Draining connections still obey Tor route changes. + final offRoute = _retiring.where((client) => !sameRoute(client)).toList(); + _retiring.removeAll(offRoute); + await Future.wait(offRoute.map((client) => client.close())); + ensureCurrent(); // Setup fails before the caller RPC is sent. Try each remaining configured // node once, including for callers that use the adapter directly. for (var index = currentFailoverIndex; index < _failovers.length; index++) { @@ -348,31 +362,26 @@ class ElectrumXClient { ? trustedCertificatePem : failover.trustedCertificatePem) : null; - String? managedCertificate() => - ClientManager.sharedInstance.trustedCertificateFor(cryptoCurrency); - bool shareable(ElectrumClient client) => - !client.peer.isClosed && - sameProxy(client) && - (failover != null - ? matches( - client, - managedCertificate(), - useHost, - usePort, - ssl, - pem, - ) - : permitted(client, managedCertificate())); - final existing = getElectrumAdapter(); - if (existing != null && shareable(existing)) { - _adapter = existing; + final existing = _adapter; + // At the primary index any open connection of this client is kept: a + // successful failover request resets the index, and later requests stay + // on the working failover. A failover index selects that endpoint and + // its own approval. + if (existing != null && + !existing.peer.isClosed && + sameRoute(existing) && + (failover == null || + (existing.host == useHost && + existing.port == usePort && + existing.useSSL == ssl && + _adapterCertificate == pem))) { return; } // Invalid saved approvals are a local configuration error. They must not // be mistaken for a failed network attempt or bypassed by failover. - // Check before retiring the shared connection: an attempt that cannot - // connect must not take the coin's working connection down with it. + // Check before retiring the current connection: an attempt that cannot + // connect must not take this client's working connection down with it. final bool Function(X509Certificate)? certificateCheck; try { certificateCheck = trustedCertificateCheck(pem); @@ -382,43 +391,21 @@ class ElectrumXClient { rethrow; } if (existing != null) { - await _release( - existing, - drain: permitted(existing, managedCertificate()), - ); + _adapter = null; + _adapterCertificate = null; + // Requests already sent on it finish, unless the Tor route changed. + await (sameRoute(existing) ? _retire(existing) : existing.close()); ensureCurrent(); } ElectrumClient? candidate; - bool negotiated = false; - bool kept = false; try { - final channel = await electrum_adapter.connect( - useHost, + candidate = await openConnection( + host: useHost, port: usePort, useSSL: ssl, - proxyInfo: proxy, - connectionTimeout: connectionTimeoutForSpecialCaseJsonRPCClients, - connectionDeadline: connectionTimeoutForSpecialCaseJsonRPCClients, - acceptUnverified: false, + proxy: proxy, onBadCertificate: certificateCheck, ); - candidate = cryptoCurrency is Firo - ? ManagedFiroElectrumClient( - channel, - useHost, - usePort, - ssl, - proxy, - requestTimeout: requestTimeouts.standard, - ) - : ManagedElectrumClient( - channel, - useHost, - usePort, - ssl, - proxy, - requestTimeout: requestTimeouts.standard, - ); _pendingClient = candidate; ensureCurrent(); await candidate.requestWithTimeout( @@ -426,39 +413,26 @@ class ElectrumXClient { timeout: connectionTimeoutForSpecialCaseJsonRPCClients, ); ensureCurrent(); - negotiated = true; - final other = getElectrumAdapter(); - if (other != null && shareable(other)) { - _adapter = other; - } else { - _adapter = candidate; - await ClientManager.sharedInstance.addClient( - candidate, - cryptoCurrency: cryptoCurrency, - netType: netType, - trustedCertificatePem: pem, - drainPrevious: - other != null && permitted(other, managedCertificate()), - ); - ensureCurrent(); - kept = true; - } + _adapter = candidate; + _adapterCertificate = pem; } catch (error) { // Closing the owner or changing its network settings cancels the whole - // operation. Publication failures and programming errors are not node - // faults; StateError can instead mean the negotiating peer has closed. + // operation. Programming errors are not node faults; StateError can + // instead mean the negotiating peer has closed. ensureCurrent(); - if (negotiated || (error is Error && error is! StateError)) rethrow; + if (error is Error && error is! StateError) rethrow; if (index == _failovers.length - 1) { currentFailoverIndex = -1; rethrow; } } finally { if (identical(_pendingClient, candidate)) _pendingClient = null; - if (!kept && candidate != null) await _release(candidate); + if (candidate != null && !identical(_adapter, candidate)) { + await candidate.close(); + } } ensureCurrent(); - if (negotiated) return; + if (_adapter != null) return; } }); diff --git a/lib/utilities/node_change_notifier.dart b/lib/utilities/node_change_notifier.dart index df484a59a3..43a890b777 100644 --- a/lib/utilities/node_change_notifier.dart +++ b/lib/utilities/node_change_notifier.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../electrumx_rpc/client_manager.dart'; import '../providers/global/active_wallet_provider.dart'; import '../providers/providers.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; @@ -16,18 +15,9 @@ Future Function() nodeChangeNotifier(WidgetRef ref, CryptoCurrency coin) { final activeWallet = ref.read(currentWalletIdProvider.notifier); return () async { - // Retire the coin's previous connection even when its wallet was removed - // and none of the loaded wallets ever used that connection. - final (removed, _) = await ClientManager.sharedInstance.remove( - cryptoCurrency: coin, - ); - await removed?.close(); - // Connections still finishing requests after a failover may use the old - // or revoked approval. Loaded wallets close them while updating, but no - // wallet of this coin may be loaded. - await ClientManager.sharedInstance.closeRetiredClients( - cryptoCurrency: coin, - ); + // Every Electrum connection belongs to one client. updateNode() closes the + // wallet's previous client, with any connection it still drains, so a + // revoked approval or replaced node outlives no loaded wallet. for (final wallet in wallets.wallets.where((e) => e.info.coin == coin)) { final shouldRefresh = switch (prefs.syncType) { SyncingType.currentWalletOnly => activeWallet.state == wallet.walletId, diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 818013e03e..982c3ff2cc 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -9,7 +9,6 @@ import 'package:meta/meta.dart'; import '../../../db/drift/database.dart'; import '../../../electrumx_rpc/cached_electrumx_client.dart'; -import '../../../electrumx_rpc/client_manager.dart'; import '../../../electrumx_rpc/electrumx_client.dart'; import '../../../models/coinlib/exp2pkh_address.dart'; import '../../../models/input.dart'; @@ -25,6 +24,7 @@ import '../../../utilities/enums/fee_rate_type_enum.dart'; import '../../../utilities/extensions/extensions.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/paynym_is_api.dart'; +import '../../../utilities/tor_plain_net_option_enum.dart'; import '../../crypto_currency/coins/firo.dart'; import '../../crypto_currency/interfaces/electrumx_currency_interface.dart'; import '../../isar/models/wallet_info.dart'; @@ -1033,9 +1033,21 @@ mixin ElectrumXInterface await _initializeServerVersionAndCheckGenesisHash(); } - return await ClientManager.sharedInstance.getChainHeightFor( - cryptoCurrency, - ); + // The network restriction the shared header subscription enforced. + if (prefs.useTor) { + if (electrumXClient.netType == TorPlainNetworkOption.clear) { + throw Exception( + "Non-TOR only client for ${cryptoCurrency.identifier} found.", + ); + } + } else if (electrumXClient.netType == TorPlainNetworkOption.tor) { + throw Exception( + "TOR only client for ${cryptoCurrency.identifier} found.", + ); + } + + final tip = await electrumXClient.getBlockHeadTip(); + return tip["height"] as int; } catch (e, s) { if (retries > 0) { retries--; From 32425eb95f984780c68d9f776f9e0e9e4433f1be Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 14:07:43 -0600 Subject: [PATCH 10/16] Close the notification check's Electrum client after each check The watched-transaction check builds its own ElectrumXClient every interval. It no longer shares a per-coin connection, so close it once the check finishes. The node-change listener stays: it still closes a setup that is pending when an approval or node changes. --- lib/services/notifications_service.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/services/notifications_service.dart b/lib/services/notifications_service.dart index 411d7a532f..9c1a0d0b98 100644 --- a/lib/services/notifications_service.dart +++ b/lib/services/notifications_service.dart @@ -182,8 +182,8 @@ class NotificationsService extends ChangeNotifier { prefs: prefs, cryptoCurrency: wallet.cryptoCurrency, ); - // This client is not owned by a loaded wallet. Retire its pending - // setup too when an approval or node changes. + // This client and its connection belong to this check alone. A + // node or approval change closes it before the check finishes. void retire() { if (!identical( nodeService.getPrimaryNodeFor(currency: coin), @@ -203,6 +203,7 @@ class NotificationsService extends ChangeNotifier { tx = await client.getTransaction(txHash: txid); } finally { nodeService.removeListener(retire); + await client.closeAdapter(); } int confirmations = tx["confirmations"] as int? ?? 0; From 4902461275922325bc210acd0ae2cffce3c76431 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 14:07:43 -0600 Subject: [PATCH 11/16] Close a deleted wallet's Electrum client A wallet's Electrum connection is now its own, and no later client of the coin replaces it. Close it when the wallet is deleted. --- lib/services/wallets.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/services/wallets.dart b/lib/services/wallets.dart index f93fe56115..4f64485550 100644 --- a/lib/services/wallets.dart +++ b/lib/services/wallets.dart @@ -33,6 +33,7 @@ import '../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../wallets/wallet/intermediate/lib_salvium_wallet.dart'; import '../wallets/wallet/wallet.dart'; +import '../wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; import 'event_bus/events/wallet_added_event.dart'; import 'event_bus/global_event_bus.dart'; import 'node_service.dart'; @@ -117,6 +118,21 @@ class Wallets { final wallet = _wallets[walletId]; _wallets.remove(walletId); await wallet?.exit(); + // An Electrum wallet owns its connection; no other client will close it. + try { + if (wallet is ElectrumXInterface) { + await wallet.electrumXClient.closeAdapter(); + } else if (wallet is BitcoinFrostWallet) { + await wallet.electrumXClient.closeAdapter(); + } + } catch (e, s) { + // A wallet that never loaded has no client. + Logging.instance.w( + "Closing the Electrum client of $walletId", + error: e, + stackTrace: s, + ); + } if (info.coin is Xelis) { // Remove unlocked native storage before forgetting its password. From 7ea0485fc691b42a2b50ad46fe9af4908476f629 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 14:11:51 -0600 Subject: [PATCH 12/16] Serve the mock Electrum client through the connection hook The in-memory mock used to publish its client into ClientManager. It now overrides ElectrumXClient.openConnection, so setup, server.version and reuse follow the production path. The mock server answers server.version, and no header subscription request is expected. --- test/electrumx_test.dart | 12 +++- test/utilities/mock_electrum_server.dart | 83 ++++++++++-------------- 2 files changed, 42 insertions(+), 53 deletions(-) diff --git a/test/electrumx_test.dart b/test/electrumx_test.dart index 7d0412a001..8c69a0b2a0 100644 --- a/test/electrumx_test.dart +++ b/test/electrumx_test.dart @@ -87,6 +87,7 @@ void main() { late FakeTorService torService; late EventBus eventBus; final servers = []; + final clients = []; setUpAll(() async { logDir = await Directory.systemTemp.createTemp('electrumx_test_logs'); @@ -110,7 +111,7 @@ void main() { required CryptoCurrency coin, TorPlainNetworkOption netType = TorPlainNetworkOption.both, }) { - return ManagedElectrumXClient( + final client = ManagedElectrumXClient( host: clearServer.host, port: 50002, useSSL: true, @@ -123,6 +124,8 @@ void main() { torServer: torServer, globalEventBusForTesting: eventBus, ); + clients.add(client); + return client; } Matcher throwsCurrentCastError() => throwsA( @@ -138,6 +141,7 @@ void main() { torService = FakeTorService(); eventBus = EventBus(); servers.clear(); + clients.clear(); when(prefs.wifiOnly).thenReturn(false); when(prefs.useTor).thenReturn(false); @@ -145,7 +149,7 @@ void main() { }); tearDown(() async { - await tearDownManagedElectrum(servers: servers); + await tearDownManagedElectrum(clients: clients, servers: servers); }); group('factory constructors and getters', () { @@ -206,7 +210,9 @@ void main() { final result = await client.ping(requestID: 'ping-1'); expect(result, isTrue); - expect(server.requestCount('blockchain.headers.subscribe'), 1); + // Setup negotiates once; no header subscription is opened per client. + expect(server.requestCount('server.version'), 1); + expect(server.requestCount('blockchain.headers.subscribe'), 0); expect(server.requestCount('server.ping'), 1); }); diff --git a/test/utilities/mock_electrum_server.dart b/test/utilities/mock_electrum_server.dart index 8bd3c62a6d..8d6b5e0973 100644 --- a/test/utilities/mock_electrum_server.dart +++ b/test/utilities/mock_electrum_server.dart @@ -5,12 +5,10 @@ import 'package:electrum_adapter/electrum_adapter.dart'; import 'package:event_bus/event_bus.dart'; import 'package:json_rpc_2/json_rpc_2.dart' as rpc; import 'package:stackwallet/app_config.dart'; -import 'package:stackwallet/electrumx_rpc/client_manager.dart'; import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; import 'package:stackwallet/services/tor_service.dart'; import 'package:stackwallet/utilities/prefs.dart'; -import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/firo.dart'; import 'package:stream_channel/stream_channel.dart'; @@ -26,6 +24,11 @@ class MockElectrumServer { this.useSSL = true, }) : _handlers = Map.from(handlers), _latestHeader = initialHeader ?? BlockHeader('00', 1) { + _handlers.putIfAbsent( + 'server.version', + () => + (_) => ['mock', '1.4'], + ); _handlers.putIfAbsent( 'blockchain.headers.subscribe', () => @@ -137,62 +140,42 @@ class ManagedElectrumXClient extends ElectrumXClient { @override Future checkElectrumAdapter() async { - ({InternetAddress host, int port})? proxyInfo; - - if (AppConfig.hasFeature(AppFeature.tor)) { - if (_prefsForTest.useTor) { - if (_torServiceForTest.status != TorConnectionStatus.connected) { - if (_prefsForTest.torKillSwitch) { - throw Exception( - 'Tor preference and killswitch set but Tor is not enabled, ' - 'not connecting to Electrum adapter', - ); - } - } else { - proxyInfo = _torServiceForTest.getProxyInfo(); - } - - if (netType == TorPlainNetworkOption.clear) { - await (await ClientManager.sharedInstance.remove( - cryptoCurrency: cryptoCurrency, - )).$1?.close(); - } - } else if (netType == TorPlainNetworkOption.tor) { - await (await ClientManager.sharedInstance.remove( - cryptoCurrency: cryptoCurrency, - )).$1?.close(); - } + if (AppConfig.hasFeature(AppFeature.tor) && + _prefsForTest.useTor && + _torServiceForTest.status != TorConnectionStatus.connected && + _prefsForTest.torKillSwitch) { + throw Exception( + 'Tor preference and killswitch set but Tor is not enabled, ' + 'not connecting to Electrum adapter', + ); } - - final existing = getElectrumAdapter(); - if (existing != null && !existing.peer.isClosed) { - await super.checkElectrumAdapter(); - return; - } - if (existing != null) { - await (await ClientManager.sharedInstance.remove( - cryptoCurrency: cryptoCurrency, - )).$1?.close(); - } - - final server = proxyInfo != null ? (torServer ?? clearServer) : clearServer; - final adapter = cryptoCurrency is Firo - ? await server.createFiroElectrumClient(proxyInfo: proxyInfo) - : await server.createElectrumClient(proxyInfo: proxyInfo); - - await ClientManager.sharedInstance.addClient( - adapter, - cryptoCurrency: cryptoCurrency, - netType: netType, - ); await super.checkElectrumAdapter(); } + + /// Serves this client from the in-memory mock: the Tor server when the + /// client chose a proxy, otherwise the clearnet server. + @override + Future openConnection({ + required String host, + required int port, + required bool useSSL, + required ({InternetAddress host, int port})? proxy, + required bool Function(X509Certificate)? onBadCertificate, + }) async { + final server = proxy != null ? (torServer ?? clearServer) : clearServer; + return cryptoCurrency is Firo + ? await server.createFiroElectrumClient(proxyInfo: proxy) + : await server.createElectrumClient(proxyInfo: proxy); + } } Future tearDownManagedElectrum({ + Iterable clients = const [], Iterable servers = const [], }) async { - await ClientManager.sharedInstance.closeAll(); + for (final client in clients) { + await client.closeAdapter(); + } for (final server in servers) { await server.close(); } From 7308e45331323a5bb612c837fc7b5144a71e156f Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 14:11:51 -0600 Subject: [PATCH 13/16] Rewrite Electrum tests that assumed a shared connection - Delete client_manager_lifecycle_test: it covered the ClientManager registry and its header subscription, which no longer exist. - Failover: keep the same-owner drain cases; turn the cross-owner reuse and publication cases into isolation checks; drop the sharing test. - TLS: a client with the same approval opens its own connection; a revoked client never reuses a trusted one; updateNode() closes the wallet connection a revocation replaces. Drop the "revoke" and "close all" draining variants that relied on the registry. - Request policy: each client keeps its own deadline on its own connection. - Remaining files only lose ClientManager teardown and wording. --- test/client_manager_lifecycle_test.dart | 355 -------------------- test/electrumx_deadline_test.dart | 7 +- test/electrumx_duplicate_endpoint_test.dart | 2 - test/electrumx_failover_test.dart | 183 +++++----- test/electrumx_late_approval_test.dart | 2 - test/electrumx_request_policy_test.dart | 31 +- test/electrumx_setup_failover_test.dart | 23 +- test/electrumx_tls_test.dart | 152 +++++---- test/electrumx_tor_gate_test.dart | 7 +- 9 files changed, 189 insertions(+), 573 deletions(-) delete mode 100644 test/client_manager_lifecycle_test.dart diff --git a/test/client_manager_lifecycle_test.dart b/test/client_manager_lifecycle_test.dart deleted file mode 100644 index e9334a220f..0000000000 --- a/test/client_manager_lifecycle_test.dart +++ /dev/null @@ -1,355 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:electrum_adapter/electrum_adapter.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:logger/logger.dart' show Level; -import 'package:stackwallet/electrumx_rpc/client_manager.dart'; -import 'package:stackwallet/electrumx_rpc/draining_electrum_client.dart'; -import 'package:stackwallet/utilities/logger.dart'; -import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; -import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; -import 'package:stream_channel/stream_channel.dart'; - -class _RpcFixture { - _RpcFixture({Duration? requestTimeout}) { - final channel = StreamChannelController(); - server = channel.foreign; - serverSubscription = server.stream.listen((dynamic request) { - subscribed.complete(); - unawaited( - firstHeader.future.then((response) { - if (!closed) { - server.sink.add({ - 'jsonrpc': '2.0', - 'id': request['id'], - ...response, - }); - } - }), - ); - }); - client = ElectrumClient( - channel.local, - 'test.electrum', - 50001, - false, - null, - requestTimeout: requestTimeout, - ); - } - - late final StreamChannel server; - late final StreamSubscription serverSubscription; - late final ElectrumClient client; - bool closed = false; - final subscribed = Completer(); - final firstHeader = Completer>(); - - void reply(int height) => firstHeader.complete({ - 'result': {'height': height, 'hex': '00'}, - }); - - void reject() => firstHeader.complete({ - 'error': {'code': -32000, 'message': 'header subscription rejected'}, - }); - - void notify(Object height) => server.sink.add({ - 'jsonrpc': '2.0', - 'method': 'blockchain.headers.subscribe', - 'params': [ - {'height': height, 'hex': '00'}, - ], - }); - - Future close() async { - closed = true; - await client.close(); - await serverSubscription.cancel(); - await server.sink.close(); - } -} - -/// Allows a clean stream end before the first header, and delayed cancellation. -class _ControlledHeadersClient extends ElectrumClient { - _ControlledHeadersClient({Future Function()? onCancel}) - : headers = StreamController(onCancel: onCancel), - super( - StreamChannelController().local, - 'test.headers', - 50001, - false, - null, - ); - - final StreamController headers; - - @override - Stream subscribeNonBatch( - String methodPrefix, [ - List params = const [], - ]) => headers.stream.cast(); -} - -Future _until(bool Function() ready) async { - final watch = Stopwatch()..start(); - while (!ready()) { - if (watch.elapsed > const Duration(seconds: 2)) { - throw TimeoutException('Waiting for client lifecycle'); - } - await Future.delayed(const Duration(milliseconds: 5)); - } -} - -void main() { - final coin = Bitcoin(CryptoCurrencyNetwork.main); - final manager = ClientManager.sharedInstance; - final fixtures = <_RpcFixture>[]; - late Directory logs; - - setUpAll(() async { - logs = await Directory.systemTemp.createTemp('client_manager_lifecycle'); - await Logging.instance.initialize(logs.path, level: Level.off); - }); - tearDownAll(() => logs.delete(recursive: true)); - tearDown(() async { - await manager.closeAll(); - for (final fixture in fixtures) { - await fixture.close(); - } - fixtures.clear(); - }); - - Future<_RpcFixture> add({Duration? requestTimeout}) async { - final fixture = _RpcFixture(requestTimeout: requestTimeout); - fixtures.add(fixture); - await manager.addClient( - fixture.client, - cryptoCurrency: coin, - netType: TorPlainNetworkOption.both, - ); - await fixture.subscribed.future; - return fixture; - } - - Future expectHealthyReplacement() async { - final healthy = await add(); - healthy.reply(123); - expect(await manager.getChainHeightFor(coin), 123); - expect( - manager.getClient( - cryptoCurrency: coin, - netType: TorPlainNetworkOption.both, - ), - same(healthy.client), - ); - } - - for (final retirement in ['remove', 'replace', 'closeAll']) { - test('$retirement releases every pending first-height waiter', () async { - final original = await add(); - final waits = [ - expectLater(manager.getChainHeightFor(coin), throwsStateError), - expectLater(manager.getChainHeightFor(coin), throwsStateError), - ]; - - switch (retirement) { - case 'remove': - final removed = await manager.remove(cryptoCurrency: coin); - expect(removed.$1, same(original.client)); - await removed.$1!.close(); - await expectHealthyReplacement(); - case 'replace': - await expectHealthyReplacement(); - case 'closeAll': - await manager.closeAll(); - await expectHealthyReplacement(); - } - - await Future.wait(waits).timeout(const Duration(seconds: 2)); - expect(original.client.peer.isClosed, true); - }); - } - - test('subscription RPC rejection retires an otherwise live client', () async { - final original = await add(); - final waiting = expectLater( - manager.getChainHeightFor(coin), - throwsA( - predicate( - (error) => error.toString().contains('header subscription rejected'), - ), - ), - ); - expect(original.client.peer.isClosed, false); - original.reject(); - - await waiting.timeout(const Duration(seconds: 2)); - await _until(() => original.client.peer.isClosed); - expect( - manager.getClient( - cryptoCurrency: coin, - netType: TorPlainNetworkOption.both, - ), - isNull, - ); - await expectHealthyReplacement(); - }); - - test('subscription failure lets other in-flight requests finish', () async { - final channel = StreamChannelController(); - final server = channel.foreign; - final ids = {}; - final serverSubscription = server.stream.listen((dynamic request) { - ids[request['method'] as String] = request['id']; - }); - addTearDown(() async { - await serverSubscription.cancel(); - await server.sink.close(); - }); - final client = ManagedElectrumClient( - channel.local, - 'test.electrum', - 50001, - false, - null, - requestTimeout: const Duration(seconds: 5), - ); - addTearDown(client.close); - await manager.addClient( - client, - cryptoCurrency: coin, - netType: TorPlainNetworkOption.both, - ); - // Another wallet's request is waiting on the same connection. - final probe = client.requestWithTimeout('probe'); - await _until( - () => - ids.containsKey('blockchain.headers.subscribe') && - ids.containsKey('probe'), - ); - - server.sink.add({ - 'jsonrpc': '2.0', - 'id': ids['blockchain.headers.subscribe'], - 'error': {'code': -32000, 'message': 'header subscription rejected'}, - }); - await _until( - () => - manager.getClient( - cryptoCurrency: coin, - netType: TorPlainNetworkOption.both, - ) == - null, - ); - expect(client.peer.isClosed, false); - - server.sink.add({'jsonrpc': '2.0', 'id': ids['probe'], 'result': 'done'}); - expect(await probe.timeout(const Duration(seconds: 2)), 'done'); - await _until(() => client.peer.isClosed); - }); - - test('subscription timeout releases its first-height waiter', () async { - final original = await add( - requestTimeout: const Duration(milliseconds: 100), - ); - await expectLater( - manager.getChainHeightFor(coin).timeout(const Duration(seconds: 2)), - throwsA( - isA().having( - (error) => error.duration, - 'adapter deadline', - const Duration(milliseconds: 100), - ), - ), - ); - await _until(() => original.client.peer.isClosed); - await expectHealthyReplacement(); - }); - - test('failure before any height consumer does not escape the zone', () async { - final original = await add(); - original.reject(); - await _until(() => original.client.peer.isClosed); - // The test runner also fails if the unobserved completer emits an error. - await expectLater(manager.getChainHeightFor(coin), throwsException); - await expectHealthyReplacement(); - }); - - test('a clean header stream end fails pending height waits', () async { - final client = _ControlledHeadersClient(); - addTearDown(client.close); - await manager.addClient( - client, - cryptoCurrency: coin, - netType: TorPlainNetworkOption.both, - ); - final waiting = expectLater( - manager.getChainHeightFor(coin), - throwsStateError, - ); - await client.headers.close(); - await waiting.timeout(const Duration(seconds: 2)); - await _until(() => client.peer.isClosed); - await expectHealthyReplacement(); - }); - - test( - 'a failed established subscription cannot return its stale height', - () async { - final original = await add(); - original.reply(10); - expect(await manager.getChainHeightFor(coin), 10); - original.notify('invalid'); - await _until(() => original.client.peer.isClosed); - await expectLater(manager.getChainHeightFor(coin), throwsException); - await expectHealthyReplacement(); - }, - ); - - test('retiring an old client cannot remove its healthy successor', () async { - final original = await add(); - final oldWait = expectLater( - manager.getChainHeightFor(coin), - throwsStateError, - ); - await expectHealthyReplacement(); - await oldWait; - final removed = await manager.remove( - cryptoCurrency: coin, - expectedClient: original.client, - ); - expect(removed, (null, null)); - expect(await manager.getChainHeightFor(coin), 123); - }); - - test( - 'shutdown finishing after replacement leaves the new client intact', - () async { - final cancellation = Completer(); - final oldClient = _ControlledHeadersClient( - onCancel: () => cancellation.future, - ); - addTearDown(() async { - if (!cancellation.isCompleted) cancellation.complete(); - await oldClient.close(); - await oldClient.headers.close(); - }); - await manager.addClient( - oldClient, - cryptoCurrency: coin, - netType: TorPlainNetworkOption.both, - ); - final oldWait = expectLater( - manager.getChainHeightFor(coin), - throwsStateError, - ); - final closing = manager.closeAll(); - await oldWait.timeout(const Duration(seconds: 2)); - await expectHealthyReplacement(); - cancellation.complete(); - await closing; - expect(await manager.getChainHeightFor(coin), 123); - }, - ); -} diff --git a/test/electrumx_deadline_test.dart b/test/electrumx_deadline_test.dart index 4ee376387c..b1bc128ffe 100644 --- a/test/electrumx_deadline_test.dart +++ b/test/electrumx_deadline_test.dart @@ -5,7 +5,6 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:logger/logger.dart' show Level; import 'package:mockito/mockito.dart'; -import 'package:stackwallet/electrumx_rpc/client_manager.dart'; import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; import 'package:stackwallet/services/tor_service.dart'; @@ -54,10 +53,8 @@ void main() { }); tearDownAll(() => logDirectory.delete(recursive: true)); setUp(() => bus = EventBus()); - tearDown(() async { - await ClientManager.sharedInstance.closeAll(); - bus.destroy(); - }); + // Each client closes itself through addTearDown in clientFor. + tearDown(() => bus.destroy()); Future start({ bool stallHandshake = false, diff --git a/test/electrumx_duplicate_endpoint_test.dart b/test/electrumx_duplicate_endpoint_test.dart index d03b73a68e..db076d13ca 100644 --- a/test/electrumx_duplicate_endpoint_test.dart +++ b/test/electrumx_duplicate_endpoint_test.dart @@ -6,7 +6,6 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:logger/logger.dart' show Level; import 'package:mockito/mockito.dart'; -import 'package:stackwallet/electrumx_rpc/client_manager.dart'; import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; import 'package:stackwallet/services/tor_service.dart'; import 'package:stackwallet/utilities/logger.dart'; @@ -33,7 +32,6 @@ void main() { final sockets = []; int accepted = 0; addTearDown(() async { - await ClientManager.sharedInstance.closeAll(); for (final s in sockets) { s.destroy(); } diff --git a/test/electrumx_failover_test.dart b/test/electrumx_failover_test.dart index 13ea7b9b64..5c8ac3ba54 100644 --- a/test/electrumx_failover_test.dart +++ b/test/electrumx_failover_test.dart @@ -6,7 +6,6 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:logger/logger.dart' show Level; import 'package:mockito/mockito.dart'; -import 'package:stackwallet/electrumx_rpc/client_manager.dart'; import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; import 'package:stackwallet/services/tor_service.dart'; import 'package:stackwallet/utilities/logger.dart'; @@ -192,9 +191,13 @@ void main() { final servers = []; late EventBus bus; + final clients = []; setUp(() => bus = EventBus()); tearDown(() async { - await ClientManager.sharedInstance.closeAll(); + for (final client in clients) { + await client.closeAdapter(); + } + clients.clear(); bus.destroy(); for (final server in servers) { await server.close(); @@ -230,69 +233,66 @@ void main() { ElectrumXNode primary, { List failovers = const [], CryptoCurrency? currency, - }) => ElectrumXClient.from( - node: primary, - prefs: _Prefs(), - torService: _Tor(), - failovers: failovers, - cryptoCurrency: currency ?? coin, - globalEventBusForTesting: bus, - ); + }) { + final client = ElectrumXClient.from( + node: primary, + prefs: _Prefs(), + torService: _Tor(), + failovers: failovers, + cryptoCurrency: currency ?? coin, + globalEventBusForTesting: bus, + ); + clients.add(client); + return client; + } for (final currency in [coin, Firo(CryptoCurrencyNetwork.main)]) { - for (final sameOwner in [false, true]) { - for (final timeout in [false, true]) { - test('${currency.runtimeType} failover preserves pending work ' - '(same owner=$sameOwner, timeout=$timeout)', () async { - final primary = await _PubServer.start(autoVersion: true); - final fallback = await _PubServer.start(autoVersion: true); - addTearDown(primary.close); - addTearDown(fallback.close); - primary.onRequest = (rpc) { - if (rpc.method == 'probe' && !timeout) { - rpc.reply({'error': 'try the fallback'}); - } - }; - fallback.onRequest = (rpc) { - if (rpc.method == 'probe') rpc.reply({'via': 'fallback'}); - }; - final actor = client( - node(primary.port, 'primary'), - currency: currency, - failovers: [node(fallback.port, 'fallback')], - ); - final reader = sameOwner - ? actor - : client(node(primary.port, 'primary'), currency: currency); - addTearDown(actor.closeAdapter); - if (!sameOwner) addTearDown(reader.closeAdapter); - await actor.checkElectrumAdapter(); - await ClientManager.sharedInstance.getChainHeightFor(currency); - final previous = actor.getElectrumAdapter()!; - final pending = reader - .getTransaction(txHash: 'a' * 64) - .then((value) => value, onError: (Object e) => e); - await _until( - () => primary.requests.any( - (r) => r.method == 'blockchain.transaction.get', - ), - ); + for (final timeout in [false, true]) { + test('${currency.runtimeType} failover preserves its own pending work ' + '(timeout=$timeout)', () async { + final primary = await _PubServer.start(autoVersion: true); + final fallback = await _PubServer.start(autoVersion: true); + addTearDown(primary.close); + addTearDown(fallback.close); + primary.onRequest = (rpc) { + if (rpc.method == 'probe' && !timeout) { + rpc.reply({'error': 'try the fallback'}); + } + }; + fallback.onRequest = (rpc) { + if (rpc.method == 'probe') rpc.reply({'via': 'fallback'}); + }; + final actor = client( + node(primary.port, 'primary'), + currency: currency, + failovers: [node(fallback.port, 'fallback')], + ); + await actor.checkElectrumAdapter(); + final previous = actor.getElectrumAdapter()!; + final pending = actor + .getTransaction(txHash: 'a' * 64) + .then((value) => value, onError: (Object e) => e); + await _until( + () => primary.requests.any( + (r) => r.method == 'blockchain.transaction.get', + ), + ); - expect( - await actor.request( - command: 'probe', - requestTimeout: const Duration(milliseconds: 150), - ), - {'via': 'fallback'}, - ); - expect(previous.peer.isClosed, false); - primary.requests - .singleWhere((r) => r.method == 'blockchain.transaction.get') - .reply({'confirmations': 7}); - expect(await pending, {'confirmations': 7}); - await _until(() => previous.peer.isClosed); - }); - } + expect( + await actor.request( + command: 'probe', + requestTimeout: const Duration(milliseconds: 150), + ), + {'via': 'fallback'}, + ); + expect(actor.getElectrumAdapter()!.port, fallback.port); + expect(previous.peer.isClosed, false); + primary.requests + .singleWhere((r) => r.method == 'blockchain.transaction.get') + .reply({'confirmations': 7}); + expect(await pending, {'confirmations': 7}); + await _until(() => previous.peer.isClosed); + }); } } @@ -311,8 +311,8 @@ void main() { final result = await connection.getTransaction(txHash: 'a' * 64); expect(result['confirmations'], 3); expect(connection.getElectrumAdapter()!.port, failover.port); - // server.version, headers.subscribe, ping, transaction.get: one session. - expect(failover.requests, 4); + // server.version, ping, transaction.get: one session. + expect(failover.requests, 3); expect(failover.disconnections, 0); }, ); @@ -326,9 +326,7 @@ void main() { node(primary.port, 'primary'), failovers: [node(fallback.port, 'fallback')], ); - addTearDown(connection.closeAdapter); await connection.checkElectrumAdapter(); - await ClientManager.sharedInstance.getChainHeightFor(coin); final previous = connection.getElectrumAdapter()!; final pending = previous .requestWithTimeout( @@ -391,26 +389,8 @@ void main() { ); } - test('another client for the coin shares the failover connection', () async { - final failover = await startPlain(); - final dead = await deadPort(); - final first = client( - node(dead, 'primary'), - failovers: [node(failover.port, 'failover')], - ); - first.currentFailoverIndex = 0; - expect(await first.ping(), true); - final second = client( - node(dead, 'primary'), - failovers: [node(failover.port, 'failover')], - ); - final result = await second.getTransaction(txHash: 'b' * 64); - expect(result['confirmations'], 3); - expect(failover.disconnections, 0); - }); - - test("a failover retry reuses another owner's healthy selected connection", () async { - // A shared failover endpoint that answers setup and holds one request. + test("a failover to another owner's node opens its own connection", () async { + // A failover endpoint that answers setup and holds one request. final listener = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); final sockets = []; var accepted = 0; @@ -450,8 +430,6 @@ void main() { final failoverNode = node(listener.port, 'failover'); final w1 = client(node(dead, 'primary'), failovers: [failoverNode]); final w2 = client(node(dead, 'primary'), failovers: [failoverNode]); - addTearDown(w1.closeAdapter); - addTearDown(w2.closeAdapter); // W1 has already failed over to the shared node and is healthy on it. w1.currentFailoverIndex = 0; @@ -459,35 +437,38 @@ void main() { expect(w1.currentFailoverIndex, -1); final shared = w1.getElectrumAdapter()!; - // W1 keeps a request in flight on the shared connection. + // W1 keeps a request in flight on its connection. final inflight = w1.getTransaction(txHash: 'a' * 64); await held.future.timeout(const Duration(seconds: 2)); // W2's primary is also dead; its failover retry selects the same node. w2.currentFailoverIndex = 0; await w2.checkElectrumAdapter(); + expect(w2.getElectrumAdapter()!.port, listener.port); expect( w2.getElectrumAdapter(), - same(shared), - reason: 'the healthy selected failover is reused, not reopened', + isNot(same(shared)), + reason: 'each owner connects for itself', ); + expect(accepted, 2); expect( shared.peer.isClosed, false, reason: "another owner's connection must not be closed", ); - expect(accepted, 1, reason: 'no spare session is opened'); + // W2 closing cannot affect W1's in-flight request either. + await w2.closeAdapter(); release!(); expect( (await inflight.timeout(const Duration(seconds: 2)))['confirmations'], 7, - reason: 'the in-flight RPC on the shared connection is not cut off', + reason: "W1's in-flight RPC is not cut off", ); }); test( - 'a concurrent primary publication cannot replace the selected fallback', + "another owner's primary connection cannot change the selected fallback", () async { final primary = await _PubServer.start(autoVersion: true); final fallback = await _PubServer.start(); @@ -507,18 +488,14 @@ void main() { node(primary.port, 'primary'), failovers: [node(fallback.port, 'fallback')], ); - addTearDown(first.closeAdapter); - addTearDown(second.closeAdapter); final pending = first .request(command: 'probe') .then((value) => value, onError: (Object e) => e); await _until(() => fallback.versions.isNotEmpty); - // The second owner publishes a fresh primary while the fallback waits. + // The second owner connects to the primary while the fallback waits. await second.checkElectrumAdapter(); expect(second.getElectrumAdapter()!.port, primary.port); - // Let the primary's header subscription finish before it is replaced. - await ClientManager.sharedInstance.getChainHeightFor(coin); - final publishedPrimary = second.getElectrumAdapter()!; + final secondPrimary = second.getElectrumAdapter()!; final reading = second .getTransaction(txHash: 'a' * 64) .then((value) => value, onError: (Object e) => e); @@ -533,12 +510,14 @@ void main() { 'via': 'fallback', }, reason: 'the selected fallback must serve the retry, not the primary'); expect(first.getElectrumAdapter()!.port, fallback.port); - expect(publishedPrimary.peer.isClosed, false); + expect(secondPrimary.peer.isClosed, false); primary.requests .singleWhere((rpc) => rpc.method == 'blockchain.transaction.get') .reply({'confirmations': 7}); expect(await reading, {'confirmations': 7}); - await _until(() => publishedPrimary.peer.isClosed); + // The second owner keeps its own primary connection. + expect(second.getElectrumAdapter(), same(secondPrimary)); + expect(secondPrimary.peer.isClosed, false); expect( fallback.requests.where((r) => r.method == 'probe').length, 1, diff --git a/test/electrumx_late_approval_test.dart b/test/electrumx_late_approval_test.dart index 2fa63a95e4..1e721e8a92 100644 --- a/test/electrumx_late_approval_test.dart +++ b/test/electrumx_late_approval_test.dart @@ -4,7 +4,6 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:logger/logger.dart' show Level; import 'package:mockito/mockito.dart'; -import 'package:stackwallet/electrumx_rpc/client_manager.dart'; import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; import 'package:stackwallet/services/tor_service.dart'; @@ -80,7 +79,6 @@ void main() { addTearDown(() async { await oldClient.closeAdapter(); await newClient.closeAdapter(); - await ClientManager.sharedInstance.closeAll(); bus.destroy(); await proxy.close(); await oldServer.close(); diff --git a/test/electrumx_request_policy_test.dart b/test/electrumx_request_policy_test.dart index 9fbf9e219d..1292927890 100644 --- a/test/electrumx_request_policy_test.dart +++ b/test/electrumx_request_policy_test.dart @@ -6,7 +6,6 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:logger/logger.dart' show Level; import 'package:mockito/mockito.dart'; -import 'package:stackwallet/electrumx_rpc/client_manager.dart'; import 'package:stackwallet/electrumx_rpc/electrum_request_timeouts.dart'; import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; @@ -176,10 +175,8 @@ void main() { }); tearDownAll(() => logs.delete(recursive: true)); setUp(() => bus = EventBus()); - tearDown(() async { - await ClientManager.sharedInstance.closeAll(); - bus.destroy(); - }); + // Each client closes itself through addTearDown in clientFor. + tearDown(() => bus.destroy()); Future<_PolicyServer> start() async { final server = await _PolicyServer.start(); @@ -506,7 +503,7 @@ void main() { for (final verbose in [false, true]) { for (final callerTimeout in [_short, _long]) { - test('shared getTransaction uses caller timeout $callerTimeout ' + test('getTransaction uses its own client timeout $callerTimeout ' '(verbose: $verbose)', () async { final server = await start(); final coin = Bitcoin(CryptoCurrencyNetwork.main); @@ -522,10 +519,14 @@ void main() { coin: coin, policy: ElectrumRequestTimeouts(standard: callerTimeout), ); + // Another client of the coin with a different policy has its own + // connection; it cannot lend the caller its deadline. await owner.checkElectrumAdapter(); - await ClientManager.sharedInstance.getChainHeightFor(coin); await caller.checkElectrumAdapter(); - expect(caller.getElectrumAdapter(), same(owner.getElectrumAdapter())); + expect( + caller.getElectrumAdapter(), + isNot(same(owner.getElectrumAdapter())), + ); final result = caller.getTransaction(txHash: 'tx', verbose: verbose); if (callerTimeout == _short) { @@ -536,7 +537,7 @@ void main() { verbose ? {'txid': 'tx'} : {'rawtx': 'raw-transaction'}, ); } - expect(server.accepted, 1); + expect(server.accepted, 2); expect( server.methods.where( (method) => method == 'blockchain.transaction.get', @@ -559,7 +560,7 @@ void main() { ), ]) { for (final callerTimeout in [_short, _long]) { - test('shared $method uses caller timeout $callerTimeout', () async { + test('$method uses its own client timeout $callerTimeout', () async { final server = await start(); final coin = method == 'blockchain.getfeerate' ? Bitcoin(CryptoCurrencyNetwork.main) @@ -576,10 +577,14 @@ void main() { coin: coin, policy: ElectrumRequestTimeouts(standard: callerTimeout), ); + // Another client of the coin with a different policy has its own + // connection; it cannot lend the caller its deadline. await owner.checkElectrumAdapter(); - await ClientManager.sharedInstance.getChainHeightFor(coin); await caller.checkElectrumAdapter(); - expect(caller.getElectrumAdapter(), same(owner.getElectrumAdapter())); + expect( + caller.getElectrumAdapter(), + isNot(same(owner.getElectrumAdapter())), + ); final result = switch (method) { 'spark.getsparklatestcoinid' => caller.getSparkLatestCoinId(), @@ -595,7 +600,7 @@ void main() { } else { expect(await result, expected); } - expect(server.accepted, 1); + expect(server.accepted, 2); final sent = server.received .where((request) => request['method'] == method) .single; diff --git a/test/electrumx_setup_failover_test.dart b/test/electrumx_setup_failover_test.dart index b02d0ce1d9..76ca1b15d0 100644 --- a/test/electrumx_setup_failover_test.dart +++ b/test/electrumx_setup_failover_test.dart @@ -5,7 +5,6 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:logger/logger.dart' show Level; import 'package:mockito/mockito.dart'; -import 'package:stackwallet/electrumx_rpc/client_manager.dart'; import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; import 'package:stackwallet/services/tor_service.dart'; @@ -50,10 +49,8 @@ void main() { }); tearDownAll(() => logDirectory.delete(recursive: true)); setUp(() => bus = EventBus()); - tearDown(() async { - await ClientManager.sharedInstance.closeAll(); - bus.destroy(); - }); + // Each client closes itself through addTearDown in clientFor. + tearDown(() => bus.destroy()); Future start({ String? certificateName, @@ -204,10 +201,6 @@ void main() { expect(unapproved.accepted, 1); expect(unapproved.requests, 0); expect(approved.accepted, 1); - expect( - ClientManager.sharedInstance.trustedCertificateFor(client.cryptoCurrency), - TlsElectrumServer.certificate('node_b'), - ); await _until(() => primary.disconnections == 1); }); @@ -286,7 +279,7 @@ void main() { ); test( - 'invalid fallback approval leaves the shared connection in place', + 'invalid fallback approval leaves the working connection in place', () async { final primary = await start(); final invalid = await start(certificateName: 'node_a'); @@ -305,8 +298,8 @@ void main() { node(healthy), ]); await client.checkElectrumAdapter().timeout(const Duration(seconds: 3)); - final shared = client.getElectrumAdapter()!; - expect(shared.port, primary.port); + final working = client.getElectrumAdapter()!; + expect(working.port, primary.port); // A request on the primary failed, so the next attempt starts at the // first failover, whose saved approval is damaged. @@ -316,15 +309,15 @@ void main() { throwsA(isA()), ); - expect(client.getElectrumAdapter(), same(shared)); - expect(shared.peer.isClosed, false); + expect(client.getElectrumAdapter(), same(working)); + expect(working.peer.isClosed, false); expect(client.currentFailoverIndex, -1); expect(invalid.accepted, 0); expect(healthy.accepted, 0); // The next attempt reuses the primary connection without reconnecting. await client.checkElectrumAdapter().timeout(const Duration(seconds: 3)); - expect(client.getElectrumAdapter(), same(shared)); + expect(client.getElectrumAdapter(), same(working)); expect(primary.accepted, 1); expect(primary.disconnections, 0); }, diff --git a/test/electrumx_tls_test.dart b/test/electrumx_tls_test.dart index 653ff3e793..0266cda656 100644 --- a/test/electrumx_tls_test.dart +++ b/test/electrumx_tls_test.dart @@ -5,7 +5,6 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:logger/logger.dart' show Level; -import 'package:stackwallet/electrumx_rpc/client_manager.dart'; import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; import 'package:stackwallet/models/node_model.dart'; import 'package:stackwallet/services/node_service.dart'; @@ -33,7 +32,7 @@ class _Tor extends Mock implements TorService {} /// Serves one primary node and no failovers to a wallet under test. class _Nodes extends NodeService { _Nodes(this.node) : super(secureStorageInterface: FakeSecureStorage()); - final NodeModel node; + NodeModel node; @override NodeModel? getPrimaryNodeFor({required CryptoCurrency currency}) => node; @override @@ -52,9 +51,13 @@ void main() { late EventBus bus; final prefs = _Prefs(); + final clients = []; setUp(() => bus = EventBus()); tearDown(() async { - await ClientManager.sharedInstance.closeAll(); + for (final client in clients) { + await client.closeAdapter(); + } + clients.clear(); bus.destroy(); for (final server in servers) { await server.close(); @@ -87,14 +90,18 @@ void main() { ElectrumXClient client( ElectrumXNode node, { List failovers = const [], - }) => ElectrumXClient.from( - node: node, - prefs: prefs, - torService: _Tor(), - failovers: failovers, - cryptoCurrency: coin, - globalEventBusForTesting: bus, - ); + }) { + final client = ElectrumXClient.from( + node: node, + prefs: prefs, + torService: _Tor(), + failovers: failovers, + cryptoCurrency: coin, + globalEventBusForTesting: bus, + ); + clients.add(client); + return client; + } test('live client connects using the approved primary certificate', () async { final server = await start('node_a'); @@ -147,76 +154,83 @@ void main() { expect(failover.requests, 0); }); - test('a client with the same approval shares the connection', () async { + test('a client with the same approval opens its own connection', () async { final server = await start('node_a'); final first = client(node(server, certificateName: 'node_a')); await first.checkElectrumAdapter(); - // Let the header subscription settle before another client joins. - await first.getElectrumAdapter()!.request('server.version'); final second = client(node(server, certificateName: 'node_a')); await second.checkElectrumAdapter(); - expect(second.getElectrumAdapter(), same(first.getElectrumAdapter())); + expect( + second.getElectrumAdapter(), + isNot(same(first.getElectrumAdapter())), + ); + expect(server.accepted, 2); expect(server.disconnections, 0); }); - test( - 'revoking approval cannot reuse a previously trusted connection', - () async { - final server = await start('node_a'); - final trusted = client(node(server, certificateName: 'node_a')); - await trusted.checkElectrumAdapter(); - await trusted.getElectrumAdapter()!.request('server.version'); - final peer = trusted.getElectrumAdapter()!.peer; - // No wallet closed the connection; the next client for this endpoint - // carries the revocation itself. - final revoked = client(node(server)); - await expectLater( - revoked.checkElectrumAdapter(), - throwsA(isA()), - ); - expect(peer.isClosed, true); - expect(revoked.getElectrumAdapter(), null); - }, - ); - - test('a fresh wallet applies a revocation no loaded wallet could', () async { + test('a revoked client never reuses a trusted connection', () async { final server = await start('node_a'); - // A wallet connected under the approval and was then deleted, which - // leaves the coin-wide connection open. With no wallet of the coin - // loaded, saving the revocation had no wallet to notify. final trusted = client(node(server, certificateName: 'node_a')); await trusted.checkElectrumAdapter(); - await trusted.getElectrumAdapter()!.request('server.version'); final peer = trusted.getElectrumAdapter()!.peer; - final fresh = BitcoinWallet(CryptoCurrencyNetwork.main) - ..prefs = prefs - ..nodeService = _Nodes( - NodeModel( - host: '127.0.0.1', - port: server.port, - name: 'TLS node', - id: 'tls-node', - useSSL: true, - enabled: true, - coinName: 'bitcoin', - isFailover: false, - isDown: false, - trusted: false, - torEnabled: true, - clearnetEnabled: true, - isPrimary: true, - ), - ); - // Wallet.load() calls this before the wallet has any client to close. - await fresh.updateNode(); + final revoked = client(node(server)); await expectLater( - fresh.electrumXClient.request(command: 'server.version'), + revoked.checkElectrumAdapter(), throwsA(isA()), ); + expect(revoked.getElectrumAdapter(), null); + // The trusted connection belongs to its own client. A revocation reaches + // it through that client being replaced, as updateNode() does. + expect(peer.isClosed, false); + await trusted.closeAdapter(); expect(peer.isClosed, true); }); - for (final action in ['revoke', 'Tor change', 'close owner', 'close all']) { + test( + 'updateNode closes the wallet connection a revocation replaces', + () async { + final server = await start('node_a'); + NodeModel nodeModel({String? pem}) => NodeModel( + host: '127.0.0.1', + port: server.port, + name: 'TLS node', + id: 'tls-node', + useSSL: true, + enabled: true, + coinName: 'bitcoin', + isFailover: false, + isDown: false, + trusted: false, + torEnabled: true, + clearnetEnabled: true, + isPrimary: true, + trustedCertificatePem: pem, + ); + final nodes = _Nodes( + nodeModel(pem: TlsElectrumServer.certificate('node_a')), + ); + final wallet = BitcoinWallet(CryptoCurrencyNetwork.main) + ..prefs = prefs + ..nodeService = nodes; + // Wallet.load() calls this before the wallet has any client to close. + await wallet.updateNode(); + final trusted = wallet.electrumXClient; + clients.add(trusted); + await trusted.request(command: 'server.version'); + final peer = trusted.getElectrumAdapter()!.peer; + + nodes.node = nodeModel(); + await wallet.updateNode(); + clients.add(wallet.electrumXClient); + expect(peer.isClosed, true); + await expectLater( + wallet.electrumXClient.request(command: 'server.version'), + throwsA(isA()), + ); + }, + ); + + for (final action in ['Tor change', 'close owner']) { test('$action also closes a connection draining after failover', () async { final primary = await TlsElectrumServer.start( ignoredMethods: {'blockchain.transaction.get'}, @@ -228,9 +242,7 @@ void main() { node(primary, certificateName: 'node_a'), failovers: [fallbackNode], ); - addTearDown(connection.closeAdapter); await connection.checkElectrumAdapter(); - await ClientManager.sharedInstance.getChainHeightFor(coin); final previous = connection.getElectrumAdapter()!; final pending = connection .getTransaction(txHash: 'a' * 64) @@ -244,19 +256,11 @@ void main() { expect(previous.peer.isClosed, false); switch (action) { - case 'revoke': - final revoked = client(node(primary), failovers: [fallbackNode]); - addTearDown(revoked.closeAdapter); - // The current fallback is still permitted, but the retired primary - // must immediately lose its old certificate approval. - await revoked.checkElectrumAdapter(); - expect(revoked.getElectrumAdapter()!.port, fallback.port); case 'Tor change': bus.fire(TorPreferenceChangedEvent(status: TorStatus.enabled)); case 'close owner': + // A revocation or node change replaces the owner via updateNode(). await connection.closeAdapter(); - case 'close all': - await ClientManager.sharedInstance.closeAll(); } await _until(() => previous.peer.isClosed); expect(await pending, isA()); diff --git a/test/electrumx_tor_gate_test.dart b/test/electrumx_tor_gate_test.dart index 343d4e4a27..f075e44c24 100644 --- a/test/electrumx_tor_gate_test.dart +++ b/test/electrumx_tor_gate_test.dart @@ -5,7 +5,6 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:logger/logger.dart' show Level; import 'package:mockito/mockito.dart'; -import 'package:stackwallet/electrumx_rpc/client_manager.dart'; import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; import 'package:stackwallet/services/tor_service.dart'; @@ -52,10 +51,8 @@ void main() { }); tearDownAll(() => logs.delete(recursive: true)); setUp(() => bus = EventBus(sync: true)); - tearDown(() async { - await ClientManager.sharedInstance.closeAll(); - bus.destroy(); - }); + // Each client closes itself through addTearDown in clientFor. + tearDown(() => bus.destroy()); Future start({bool stallVersion = false}) async { final server = await TlsElectrumServer.start( From db1b03082bb1693d5f847fa0f0d9ac8b5d887349 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 14:11:51 -0600 Subject: [PATCH 14/16] Test Electrum connection isolation between wallets Two clients of the same coin and node get distinct connections, and one client's failover, request timeout or closeAdapter() never fails another client's in-flight request. Wallet chain height is read on each wallet's own connection without a cached value, and a clearnet-only node still fails it under Tor. --- test/electrumx_wallet_isolation_test.dart | 316 ++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 test/electrumx_wallet_isolation_test.dart diff --git a/test/electrumx_wallet_isolation_test.dart b/test/electrumx_wallet_isolation_test.dart new file mode 100644 index 0000000000..fdb1c97f95 --- /dev/null +++ b/test/electrumx_wallet_isolation_test.dart @@ -0,0 +1,316 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:event_bus/event_bus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/models/node_model.dart'; +import 'package:stackwallet/services/node_service.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_wallet.dart'; + +class _Prefs extends Mock implements Prefs { + _Prefs({this.useTor = false}); + @override + final bool useTor; + @override + bool get wifiOnly => false; + @override + bool get torKillSwitch => false; +} + +class _Tor extends Mock implements TorService {} + +class _Nodes extends NodeService { + _Nodes(this.node) : super(secureStorageInterface: FakeSecureStorage()); + final NodeModel node; + @override + NodeModel? getPrimaryNodeFor({required CryptoCurrency currency}) => node; + @override + List failoverNodesFor({required CryptoCurrency currency}) => []; +} + +/// A plain JSON-RPC server. It answers setup and header requests, holds +/// `probe.held` until [release], fails `probe.fail` on [failing] servers and +/// never answers `probe.silent`. +class _Server { + _Server(this.listener, {required this.failing}); + final ServerSocket listener; + final bool failing; + final sockets = []; + final methods = []; + final _held = []; + int accepted = 0; + int height = 100; + int get port => listener.port; + + static Future<_Server> start({bool failing = false}) async { + final server = _Server( + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0), + failing: failing, + ); + server.listener.listen((socket) { + server.accepted++; + server.sockets.add(socket); + socket.done.ignore(); + socket + .cast>() + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + final request = jsonDecode(line) as Map; + final method = request['method'] as String; + server.methods.add(method); + void reply(Object? result) { + final response = { + 'jsonrpc': '2.0', + 'id': request['id'], + 'result': result, + }; + socket.write('${jsonEncode(response)}\n'); + } + + switch (method) { + case 'server.version': + reply(['test', '1.4']); + case 'blockchain.headers.subscribe': + reply({'height': server.height, 'hex': '00' * 80}); + case 'probe.held': + server._held.add(() => reply({'held': 'done'})); + case 'probe.fail': + reply( + server.failing + ? {'error': 'try the fallback'} + : {'via': 'fallback'}, + ); + case 'probe.silent': + break; + default: + reply({}); + } + }, onError: (Object _) {}); + }); + return server; + } + + bool get holding => _held.isNotEmpty; + + void release() { + for (final reply in _held) { + reply(); + } + _held.clear(); + } + + Future close() async { + for (final socket in sockets) { + socket.destroy(); + } + await listener.close(); + } +} + +Future _until(bool Function() ready) async { + final watch = Stopwatch()..start(); + while (!ready()) { + if (watch.elapsed > const Duration(seconds: 3)) { + throw TimeoutException('Waiting for isolation test server'); + } + await Future.delayed(const Duration(milliseconds: 5)); + } +} + +void main() { + late Directory logs; + late EventBus bus; + final clients = []; + final coin = Bitcoin(CryptoCurrencyNetwork.main); + + setUpAll(() async { + logs = await Directory.systemTemp.createTemp('electrum_isolation'); + await Logging.instance.initialize(logs.path, level: Level.off); + }); + tearDownAll(() => logs.delete(recursive: true)); + setUp(() => bus = EventBus()); + tearDown(() async { + for (final client in clients) { + await client.closeAdapter(); + } + clients.clear(); + bus.destroy(); + }); + + Future<_Server> start({bool failing = false}) async { + final server = await _Server.start(failing: failing); + addTearDown(server.close); + return server; + } + + ElectrumXNode node(_Server server) => ElectrumXNode( + address: '127.0.0.1', + port: server.port, + name: '${server.port}', + id: '${server.port}', + useSSL: false, + torEnabled: true, + clearnetEnabled: true, + ); + + ElectrumXClient client(_Server primary, {_Server? fallback}) { + final client = ElectrumXClient.from( + node: node(primary), + prefs: _Prefs(), + torService: _Tor(), + failovers: [if (fallback != null) node(fallback)], + cryptoCurrency: coin, + globalEventBusForTesting: bus, + ); + clients.add(client); + return client; + } + + test( + 'two clients of the same coin and node have distinct connections', + () async { + final server = await start(); + final first = client(server); + final second = client(server); + + await first.checkElectrumAdapter(); + await second.checkElectrumAdapter(); + + expect(first.getElectrumAdapter(), isNotNull); + expect(second.getElectrumAdapter(), isNotNull); + expect( + first.getElectrumAdapter(), + isNot(same(second.getElectrumAdapter())), + ); + expect(server.accepted, 2); + }, + ); + + for (final action in ['failover', 'timeout', 'closeAdapter']) { + test("another client's $action never fails this client's in-flight " + 'request', () async { + final primary = await start(failing: true); + final fallback = await start(); + final actor = client(primary, fallback: fallback); + final bystander = client(primary, fallback: fallback); + await actor.checkElectrumAdapter(); + final actorConnection = actor.getElectrumAdapter()!; + + final inFlight = bystander + .request(command: 'probe.held') + .then((value) => value, onError: (Object e) => e); + await _until(() => primary.holding); + final bystanderConnection = bystander.getElectrumAdapter()!; + expect(bystanderConnection, isNot(same(actorConnection))); + + switch (action) { + case 'failover': + expect(await actor.request(command: 'probe.fail'), { + 'via': 'fallback', + }); + expect(actor.getElectrumAdapter()!.port, fallback.port); + case 'timeout': + await expectLater( + actor.request( + command: 'probe.silent', + requestTimeout: const Duration(milliseconds: 150), + ), + throwsA(isA()), + ); + case 'closeAdapter': + await actor.closeAdapter(); + } + // The action really ended the actor's connection. + await _until(() => actorConnection.peer.isClosed); + + expect(bystanderConnection.peer.isClosed, false); + expect(bystander.getElectrumAdapter(), same(bystanderConnection)); + primary.release(); + expect(await inFlight.timeout(const Duration(seconds: 2)), { + 'held': 'done', + }); + expect(bystander.currentFailoverIndex, -1); + }); + } + + group('wallet chain height', () { + NodeModel nodeModel( + _Server server, { + bool torEnabled = true, + bool clearnetEnabled = true, + }) => NodeModel( + host: '127.0.0.1', + port: server.port, + name: 'node', + id: 'node', + useSSL: false, + enabled: true, + coinName: coin.identifier, + isFailover: false, + isDown: false, + trusted: false, + torEnabled: torEnabled, + clearnetEnabled: clearnetEnabled, + isPrimary: true, + ); + + Future> wallet(NodeModel node, Prefs prefs) async { + final wallet = BitcoinWallet(CryptoCurrencyNetwork.main) + ..prefs = prefs + ..nodeService = _Nodes(node); + // Wallet.load() calls this before the wallet has any client to close. + await wallet.updateNode(); + clients.add(wallet.electrumXClient); + return wallet; + } + + test('each wallet reads the current tip on its own connection', () async { + final server = await start(); + final first = await wallet(nodeModel(server), _Prefs()); + final second = await wallet(nodeModel(server), _Prefs()); + + expect(await first.fetchChainHeight(), 100); + expect(await second.fetchChainHeight(), 100); + expect(server.accepted, 2); + + // Nothing is cached between calls: a new block is seen at once. + server.height = 101; + expect(await first.fetchChainHeight(), 101); + expect( + server.methods.where((m) => m == 'blockchain.headers.subscribe'), + hasLength(3), + ); + expect(server.accepted, 2); + }); + + test('a clearnet-only node still fails chain height under Tor', () async { + final server = await start(); + final clearOnly = await wallet( + nodeModel(server, torEnabled: false), + _Prefs(useTor: true), + ); + + await expectLater( + clearOnly.fetchChainHeight(), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('Non-TOR only client'), + ), + ), + ); + expect(server.methods, isNot(contains('blockchain.headers.subscribe'))); + }); + }); +} From 8b00fcf9d4a9c0d6485ae77911b308476aa79339 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Wed, 11 Nov 2026 14:11:51 -0600 Subject: [PATCH 15/16] Update node-change and notification tests for per-wallet connections - The node change test now checks that loaded wallets of the coin are updated and refreshed, which is what closes their old connections. - The notification test no longer reads the registry, and a new case checks that a finished check closes its own connection. --- ...ification_certificate_revocation_test.dart | 68 +++++++++++++---- test/widget_tests/node_tls_trust_test.dart | 75 +++++++++---------- 2 files changed, 88 insertions(+), 55 deletions(-) diff --git a/test/services/notification_certificate_revocation_test.dart b/test/services/notification_certificate_revocation_test.dart index dbe1dd0a34..0c17a99caa 100644 --- a/test/services/notification_certificate_revocation_test.dart +++ b/test/services/notification_certificate_revocation_test.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:logger/logger.dart' show Level; import 'package:stackwallet/db/hive/db.dart'; -import 'package:stackwallet/electrumx_rpc/client_manager.dart'; import 'package:stackwallet/models/node_model.dart'; import 'package:stackwallet/models/notification_model.dart'; import 'package:stackwallet/services/node_service.dart'; @@ -14,7 +13,6 @@ import 'package:stackwallet/services/wallets.dart'; import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; -import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; @@ -50,19 +48,33 @@ Future _until(bool Function() done, String waitingFor) async { } void main() { - test('Forget retires a pending background notification connection', () async { - await setUpHiveCeTest(); - final logs = await Directory.systemTemp.createTemp('notification_tls'); + late Directory logs; + setUpAll(() async { + logs = await Directory.systemTemp.createTemp('notification_tls'); await Logging.instance.initialize(logs.path, level: Level.off); - DB.instance.hive.registerAdapter(NodeModelAdapter()); - DB.instance.hive.registerAdapter(NotificationModelAdapter()); + }); + tearDownAll(() => logs.delete(recursive: true)); + + /// Watches one transaction of a Bitcoin wallet whose primary node is + /// [server], checking every 50ms until the test stops it. + Future<({NodeService service, NodeModel node})> watch( + TlsElectrumServer server, + ) async { + await setUpHiveCeTest(); + final nodeAdapter = NodeModelAdapter(); + if (!DB.instance.hive.isAdapterRegistered(nodeAdapter.typeId)) { + DB.instance.hive.registerAdapter(nodeAdapter); + } + final notificationAdapter = NotificationModelAdapter(); + if (!DB.instance.hive.isAdapterRegistered(notificationAdapter.typeId)) { + DB.instance.hive.registerAdapter(notificationAdapter); + } final nodes = await DB.instance.hive.openBox( DB.boxNameNodeModels, ); final watched = await DB.instance.hive.openBox( DB.boxNameWatchedTransactions, ); - final server = await TlsElectrumServer.start(respond: false); final wallet = _Wallet(); final coin = wallet.cryptoCurrency; final node = NodeModel( @@ -96,7 +108,12 @@ void main() { coinName: coin.identifier, ), ); - Wallets.sharedInstance.addWallet(wallet); + // The shared registry keeps the wallet added by an earlier test. + if (!Wallets.sharedInstance.wallets.any( + (e) => e.walletId == wallet.walletId, + )) { + Wallets.sharedInstance.addWallet(wallet); + } final service = NodeService(secureStorageInterface: FakeSecureStorage()); final notifications = NotificationsService.instance; await notifications.init( @@ -108,15 +125,20 @@ void main() { addTearDown(() async { notifications.stopCheckingWatchedTransactions(); NotificationsService.notificationRefreshInterval = interval; - await ClientManager.sharedInstance.closeAll(); await server.close(); await tearDownHiveCeTest(); - await logs.delete(recursive: true); }); NotificationsService.notificationRefreshInterval = const Duration( milliseconds: 50, ); notifications.startCheckingWatchedNotifications(); + return (service: service, node: node); + } + + test('Forget retires a pending background notification connection', () async { + final server = await TlsElectrumServer.start(respond: false); + final (:service, :node) = await watch(server); + final notifications = NotificationsService.instance; await _until( () => server.methods.contains('server.version'), 'notification RPC setup', @@ -143,14 +165,28 @@ void main() { () => server.disconnections > 0, 'Forget to disconnect the notification', ); - expect(nodes.get(node.id)!.trustedCertificatePem, null); expect( - ClientManager.sharedInstance.getClient( - cryptoCurrency: coin, - netType: TorPlainNetworkOption.both, - ), + DB.instance.hive + .box(DB.boxNameNodeModels) + .get(node.id)! + .trustedCertificatePem, null, ); expect(server.methods, isNot(contains('blockchain.transaction.get'))); }); + + test('a finished notification check closes its own connection', () async { + final server = await TlsElectrumServer.start(); + await watch(server); + await _until( + () => server.methods.contains('blockchain.transaction.get'), + 'the notification check', + ); + await _until( + () => server.disconnections == server.accepted, + 'the check to close its connection', + ); + NotificationsService.instance.stopCheckingWatchedTransactions(); + expect(server.accepted, greaterThanOrEqualTo(1)); + }); } diff --git a/test/widget_tests/node_tls_trust_test.dart b/test/widget_tests/node_tls_trust_test.dart index 7f7db62160..100a781afd 100644 --- a/test/widget_tests/node_tls_trust_test.dart +++ b/test/widget_tests/node_tls_trust_test.dart @@ -9,8 +9,6 @@ import 'package:logger/logger.dart' show Level; import 'package:mockito/mockito.dart'; import 'package:platform/platform.dart'; import 'package:stackwallet/db/hive/db.dart'; -import 'package:stackwallet/electrumx_rpc/client_manager.dart'; -import 'package:stackwallet/electrumx_rpc/draining_electrum_client.dart'; import 'package:stackwallet/models/isar/stack_theme.dart'; import 'package:stackwallet/models/node_model.dart'; import 'package:stackwallet/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart'; @@ -26,21 +24,41 @@ import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/utilities/test_node_connection.dart'; -import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; import 'package:stackwallet/utilities/util.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; import 'package:stackwallet/wallets/wallet/wallet.dart'; import 'package:stackwallet/widgets/desktop/primary_button.dart'; import 'package:stackwallet/widgets/node_card.dart'; import 'package:stackwallet/widgets/node_options_sheet.dart'; -import 'package:stream_channel/stream_channel.dart'; import '../sample_data/theme_json.dart'; import '../utilities/tls_test_server.dart'; class _Wallets extends Fake implements Wallets { + _Wallets([this.wallets = const []]); @override - List get wallets => []; + final List wallets; +} + +/// A loaded wallet that records the node-change calls it receives. +class _LoadedWallet extends Fake implements Wallet { + _LoadedWallet(CryptoCurrency coin, this.walletId) + : info = WalletInfo.createNew( + coin: coin, + name: walletId, + walletIdOverride: walletId, + ); + @override + final String walletId; + @override + final WalletInfo info; + int updates = 0; + int refreshes = 0; + @override + Future updateNode() async => updates++; + @override + Future refresh() async => refreshes++; } class _Prefs extends Mock implements Prefs { @@ -101,7 +119,6 @@ void main() { Util.layoutPlatform = FakePlatform(operatingSystem: 'macos'); }); tearDown(() async { - await ClientManager.sharedInstance.closeAll(); await DB.instance.hive.close(); Util.layoutPlatform = const LocalPlatform(); }); @@ -110,6 +127,7 @@ void main() { WidgetTester tester, Widget child, { TestNodeConnectionCallback? testConnection, + List wallets = const [], }) async { tester.view.physicalSize = const Size(1200, 1800); tester.view.devicePixelRatio = 1; @@ -121,7 +139,7 @@ void main() { nodeServiceChangeNotifierProvider.overrideWithValue(nodes), secureStoreProvider.overrideWithValue(storage), prefsChangeNotifierProvider.overrideWithValue(_Prefs()), - pWallets.overrideWithValue(_Wallets()), + pWallets.overrideWithValue(_Wallets(wallets)), testNodeConnectionProvider.overrideWithValue( testConnection ?? ({ @@ -269,47 +287,26 @@ void main() { expect(nodes.getNodeById(id: 'other-node')!.isPrimary, false); }); - testWidgets('a node change closes connections still finishing requests', ( + testWidgets('a node change updates every loaded wallet of the coin', ( tester, ) async { - // A failed-over connection is still waiting on a request. No wallet of - // this coin is loaded, so only the node change itself can close it. - final channel = StreamChannelController(); - final draining = ManagedElectrumClient( - channel.local, - 'node.example', - 50002, - true, - null, - requestTimeout: null, - ); - final manager = ClientManager.sharedInstance; - late final Future pending; - // The client's plumbing needs the real event loop, not the fake clock. - await tester.runAsync(() async { - await manager.addClient( - draining, - cryptoCurrency: coin, - netType: TorPlainNetworkOption.both, - ); - pending = draining - .requestWithTimeout('probe') - .then((_) => null, onError: (Object error) => error); - await manager.retireClient(cryptoCurrency: coin, client: draining); - }); - expect(draining.peer.isClosed, false); - + // Each wallet owns its Electrum connection. Replacing the wallet's client + // in updateNode() is what closes the old connection, including one still + // finishing requests after a failover. + final first = _LoadedWallet(coin, 'first'); + final second = _LoadedWallet(coin, 'second'); + final other = _LoadedWallet(Litecoin(CryptoCurrencyNetwork.main), 'ltc'); await pump( tester, NodeOptionsSheet(nodeId: 'custom-node', coin: coin, popBackToRoute: '/'), + wallets: [first, second, other], ); await tester.tap(find.text('Connect')); await tester.pumpAndSettle(); - expect(draining.peer.isClosed, true); - expect(await tester.runAsync(() => pending), isA()); - await tester.runAsync(() => channel.foreign.sink.close()); - }, timeout: const Timeout(Duration(seconds: 30))); + expect([first.updates, second.updates, other.updates], [1, 1, 0]); + expect([first.refreshes, second.refreshes, other.refreshes], [1, 1, 0]); + }); testWidgets('node card Connect carries an already approved certificate', ( tester, From 4716c65953e2640cd1ca8f36e211e819b14b7d85 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 10 Nov 2026 17:26:11 -0600 Subject: [PATCH 16/16] feat(xmr): sign and verify messages Implement SignVerifyInterface on LibMoneroWallet so Monero wallets get the existing Sign / Verify view. cs_monero 4.0.0 already wraps MONERO_Wallet_signMessage and MONERO_Wallet_verifySignedMessage, so this only forwards them through CsMoneroInterface. Signatures are wallet2 SigV2 spend-key signatures for the chosen primary address or subaddress, compatible with monero-wallet-cli/rpc sign and verify. wallet2 returns an empty string instead of throwing when signing fails, so treat that as an error. View-only wallets cannot sign. --- .../intermediate/lib_monero_wallet.dart | 44 ++++++++++++++++++- .../interfaces/cs_monero_interface.dart | 13 ++++++ ...XMR_cs_monero_interface_impl.template.dart | 19 ++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index bf8914a836..144fa6613d 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -42,13 +42,14 @@ import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; import '../wallet.dart'; import '../wallet_mixin_interfaces/multi_address_interface.dart'; +import '../wallet_mixin_interfaces/sign_verify_interface.dart'; import '../wallet_mixin_interfaces/view_only_option_interface.dart'; import 'cryptonote_wallet.dart'; abstract class LibMoneroWallet extends CryptonoteWallet with ViewOnlyOptionInterface - implements MultiAddressInterface { + implements MultiAddressInterface, SignVerifyInterface { @override int get isarTransactionVersion => 2; @@ -175,6 +176,47 @@ abstract class LibMoneroWallet return csMonero.getTxKey(wallet!, txid); } + @override + Future signMessage(String message, {required Address address}) async { + if (isViewOnly) { + throw Exception("Cannot sign a message in a view only wallet"); + } + if (wallet == null) { + throw Exception("Wallet not open"); + } + + final signature = await csMonero.signMessage( + wallet!, + message: message, + address: address.value, + ); + + // wallet2 returns an empty string on failure instead of throwing + if (signature.isEmpty) { + throw Exception("Failed to sign message with ${address.value}"); + } + + return signature; + } + + @override + Future verifyMessage( + String message, { + required String address, + required String signature, + }) async { + if (wallet == null) { + throw Exception("Wallet not open"); + } + + return await csMonero.verifyMessage( + wallet!, + message: message, + address: address.trim(), + signature: signature.trim(), + ); + } + void _setListener() { if (wallet != null && !csMonero.hasListeners(wallet!)) { csMonero.addListener( diff --git a/lib/wl_gen/interfaces/cs_monero_interface.dart b/lib/wl_gen/interfaces/cs_monero_interface.dart index 8386cb6654..4989bf5c68 100644 --- a/lib/wl_gen/interfaces/cs_monero_interface.dart +++ b/lib/wl_gen/interfaces/cs_monero_interface.dart @@ -60,6 +60,19 @@ abstract class CsMoneroInterface { Future getTxKey(WrappedWallet wallet, String txid); + Future signMessage( + WrappedWallet wallet, { + required String message, + required String address, + }); + + Future verifyMessage( + WrappedWallet wallet, { + required String message, + required String address, + required String signature, + }); + Future save(WrappedWallet wallet); Future getPublicViewKey(WrappedWallet wallet); diff --git a/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart b/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart index e7967e0e83..ad63def12b 100644 --- a/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart +++ b/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart @@ -177,6 +177,25 @@ class _CsMoneroInterfaceImpl extends CsMoneroInterface { Future getTxKey(WrappedWallet wallet, String txid) => wallet.get().getTxKey(txid); + @override + Future signMessage( + WrappedWallet wallet, { + required String message, + required String address, + }) => wallet.get().signMessage(message, address); + + @override + Future verifyMessage( + WrappedWallet wallet, { + required String message, + required String address, + required String signature, + }) => wallet.get().verifyMessage( + message, + address, + signature, + ); + @override Future save(WrappedWallet wallet) => wallet.get().save();