diff --git a/lib/electrumx_rpc/client_manager.dart b/lib/electrumx_rpc/client_manager.dart deleted file mode 100644 index 8cd9d53241..0000000000 --- a/lib/electrumx_rpc/client_manager.dart +++ /dev/null @@ -1,142 +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'; - -class ClientManager { - ClientManager._(); - static final ClientManager sharedInstance = ClientManager._(); - - final Map _map = {}; - final Map _mapNet = {}; - final Map _heights = {}; - final Map> _subscriptions = {}; - final Map> _heightCompleters = {}; - - 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, - }) 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(); - _subscriptions[key] = client.subscribeHeaders().listen( - (event) { - _heights[key] = event.height; - - if (!_heightCompleters[key]!.isCompleted) { - _heightCompleters[key]!.complete(event.height); - } - }, - onError: (Object err, StackTrace s) => Logging.instance.e( - "ClientManager listen", - error: err, - stackTrace: s, - ), - ); - } - - 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; - } - - Future<(ElectrumClient?, TorPlainNetworkOption?)> remove({ - required CryptoCurrency cryptoCurrency, - }) 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)); - } - - Future closeAll() async { - await _kill(); - _finalizer.detach(this); - } - - 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(); - } -} 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/electrum_request_timeouts.dart b/lib/electrumx_rpc/electrum_request_timeouts.dart new file mode 100644 index 0000000000..29284d898e --- /dev/null +++ b/lib/electrumx_rpc/electrum_request_timeouts.dart @@ -0,0 +1,30 @@ +/// 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.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; + + 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..34c39d8255 100644 --- a/lib/electrumx_rpc/electrumx_client.dart +++ b/lib/electrumx_rpc/electrumx_client.dart @@ -17,8 +17,8 @@ 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 'package:stream_channel/stream_channel.dart'; import '../app_config.dart'; import '../exceptions/electrumx/no_such_transaction.dart'; @@ -31,10 +31,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 'draining_electrum_client.dart'; +import 'electrum_request_timeouts.dart'; class WifiOnlyException implements Exception {} @@ -51,6 +53,7 @@ class ElectrumXNode { required this.useSSL, required this.torEnabled, required this.clearnetEnabled, + this.trustedCertificatePem, }); final String address; final int port; @@ -59,6 +62,7 @@ class ElectrumXNode { final bool useSSL; final bool torEnabled; final bool clearnetEnabled; + final String? trustedCertificatePem; factory ElectrumXNode.from(ElectrumXNode node) { return ElectrumXNode( @@ -69,6 +73,7 @@ class ElectrumXNode { useSSL: node.useSSL, torEnabled: node.torEnabled, clearnetEnabled: node.clearnetEnabled, + trustedCertificatePem: node.trustedCertificatePem, ); } @@ -79,6 +84,8 @@ class ElectrumXNode { } class ElectrumXClient { + final ElectrumRequestTimeouts requestTimeouts; + final CryptoCurrency cryptoCurrency; final TorPlainNetworkOption netType; @@ -90,12 +97,23 @@ class ElectrumXClient { bool get useSSL => _useSSL; late bool _useSSL; + final String? trustedCertificatePem; + + bool _closed = false; + int _connectionGeneration = 0; + + /// This client's own connection. No other [ElectrumXClient] uses it. + ElectrumClient? _adapter; - // StreamChannel? get electrumAdapterChannel => _electrumAdapterChannel; - StreamChannel? _electrumAdapterChannel; + /// The certificate [_adapter] was opened with; null for platform roots. + String? _adapterCertificate; + ElectrumClient? _pendingClient; - ElectrumClient? getElectrumAdapter() => 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; @@ -105,17 +123,10 @@ 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; - final Mutex _torConnectingLock = Mutex(); - bool _requireMutex = false; + Completer? _torConnectionGate; final _adapterMutex = Mutex(); @@ -132,7 +143,20 @@ class ElectrumXClient { ), TorService? torService, EventBus? globalEventBusForTesting, + this.trustedCertificatePem, + this.requestTimeouts = const ElectrumRequestTimeouts(), }) { + for (final limit in [ + requestTimeouts.standard, + requestTimeouts.history, + requestTimeouts.transactionBatch, + requestTimeouts.firoSet, + requestTimeouts.broadcast, + ]) { + if (limit <= Duration.zero) { + throw ArgumentError.value(limit, 'requestTimeouts', 'Must be positive'); + } + } _prefs = prefs; _torService = torService ?? TorService.sharedInstance; _host = host; @@ -145,19 +169,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(); - _requireMutex = true; + _torConnectionGate ??= Completer(); break; case TorConnectionStatus.connected: case TorConnectionStatus.disconnected: - if (_torConnectingLock.isLocked) { - _torConnectingLock.release(); - } - _requireMutex = false; + _releaseTorWaiters(); break; } }); @@ -166,21 +187,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 +198,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 +228,213 @@ class ElectrumXClient { } Future closeAdapter() async { - await getElectrumAdapter()?.close(); + _closed = true; + _releaseTorWaiters(); + 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, - ); - } + void _releaseTorWaiters() { + final gate = _torConnectionGate; + _torConnectionGate = null; + gate?.complete(); + } - final String useHost; - final int usePort; - final bool useUseSSL; + 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; + } + } - 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; - } + /// 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); + }, + ), + ); + await client.retire(); + } - _electrumAdapterChannel ??= await electrum_adapter.connect( - useHost, - port: usePort, - connectionTimeout: connectionTimeoutForSpecialCaseJsonRPCClients, - aliveTimerDuration: connectionTimeoutForSpecialCaseJsonRPCClients, - acceptUnverified: false, - useSSL: useUseSSL, - proxyInfo: proxyInfo, - ); + /// Closes the current, pending and draining connections at once, for + /// closing the owner and for Tor preference changes. + Future _resetAdapter() async { + _connectionGeneration++; + final clients = { + if (_pendingClient != null) _pendingClient!, + if (_adapter != null) _adapter!, + ..._retiring, + }; + _pendingClient = _adapter = null; + _adapterCertificate = null; + _retiring.clear(); + await Future.wait(clients.map((client) => client.close())); + } - if (getElectrumAdapter() == null) { - final ElectrumClient newClient; - if (cryptoCurrency is Firo) { - newClient = FiroElectrumClient( - _electrumAdapterChannel!, - useHost, - usePort, - useUseSSL, - proxyInfo, - ); - } else { - newClient = ElectrumClient( - _electrumAdapterChannel!, - useHost, - usePort, - useUseSSL, - proxyInfo, + /// 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, ); - } + } - await newClient.request('server.version'); + Future checkElectrumAdapter() => _adapterMutex.protect(() async { + final generation = _connectionGeneration; + void ensureCurrent() { + if (_closed || generation != _connectionGeneration) { + throw StateError('Electrum client changed'); + } + } - await ClientManager.sharedInstance.addClient( - newClient, - cryptoCurrency: cryptoCurrency, - netType: netType, + 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'); + } + } + // 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; + // 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++) { + 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; + 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 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); + } on FormatException { + // Fail this attempt, but let the next one retry a recovered primary. + currentFailoverIndex = -1; + rethrow; + } + if (existing != null) { + _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; + try { + candidate = await openConnection( + host: useHost, + port: usePort, + useSSL: ssl, + proxy: proxy, + onBadCertificate: certificateCheck, + ); + _pendingClient = candidate; + ensureCurrent(); + await candidate.requestWithTimeout( + 'server.version', + timeout: connectionTimeoutForSpecialCaseJsonRPCClients, ); + ensureCurrent(); + _adapter = candidate; + _adapterCertificate = pem; + } catch (error) { + // Closing the owner or changing its network settings cancels the whole + // operation. Programming errors are not node faults; StateError can + // instead mean the negotiating peer has closed. + ensureCurrent(); + if (error is Error && error is! StateError) rethrow; + if (index == _failovers.length - 1) { + currentFailoverIndex = -1; + rethrow; + } + } finally { + if (identical(_pendingClient, candidate)) _pendingClient = null; + if (candidate != null && !identical(_adapter, candidate)) { + await candidate.close(); + } } - }); - } + ensureCurrent(); + if (_adapter != null) return; + } + }); /// Send raw rpc command Future request({ @@ -339,22 +442,21 @@ class ElectrumXClient { List args = const [], String? requestID, int retries = 2, - Duration requestTimeout = const Duration(seconds: 60), + Duration? requestTimeout, }) async { if (!(await _allow())) { throw WifiOnlyException(); } - if (_requireMutex) { - await _torConnectingLock.protect( - () async => await checkElectrumAdapter(), - ); - } else { - await checkElectrumAdapter(); - } + await _waitForTor(); + await checkElectrumAdapter(); 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,26 +533,28 @@ 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(); } - if (_requireMutex) { - await _torConnectingLock.protect( - () async => await checkElectrumAdapter(), - ); - } else { - await checkElectrumAdapter(); - } + await _waitForTor(); + await checkElectrumAdapter(); 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 +718,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 +777,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 +928,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"); @@ -853,6 +953,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++) { @@ -885,9 +986,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 +1006,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 +1028,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 +1047,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 +1079,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 +1107,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 +1154,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 +1174,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 +1477,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..9c1a0d0b98 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,29 @@ class NotificationsService extends ChangeNotifier { prefs: prefs, cryptoCurrency: wallet.cryptoCurrency, ); - final tx = await client.getTransaction(txHash: txid); + // 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), + 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); + await client.closeAdapter(); + } int confirmations = tx["confirmations"] as int? ?? 0; 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. 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..43a890b777 --- /dev/null +++ b/lib/utilities/node_change_notifier.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.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 { + // 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, + 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/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/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 15ecc7db61..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--; @@ -1094,6 +1106,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 +1123,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/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/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/electrumx_deadline_test.dart b/test/electrumx_deadline_test.dart new file mode 100644 index 0000000000..b1bc128ffe --- /dev/null +++ b/test/electrumx_deadline_test.dart @@ -0,0 +1,205 @@ +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/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()); + // Each client closes itself through addTearDown in clientFor. + tearDown(() => 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..db076d13ca --- /dev/null +++ b/test/electrumx_duplicate_endpoint_test.dart @@ -0,0 +1,119 @@ +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/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 { + 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..5c8ac3ba54 --- /dev/null +++ b/test/electrumx_failover_test.dart @@ -0,0 +1,575 @@ +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/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; + + final clients = []; + setUp(() => bus = EventBus()); + tearDown(() async { + for (final client in clients) { + await client.closeAdapter(); + } + clients.clear(); + 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 [], + CryptoCurrency? currency, + }) { + 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 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(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); + }); + } + } + + 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, ping, transaction.get: one session. + expect(failover.requests, 3); + expect(failover.disconnections, 0); + }, + ); + + 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')], + ); + await connection.checkElectrumAdapter(); + 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', + () 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("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; + 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]); + + // 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 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(), + 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", + ); + + // 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: "W1's in-flight RPC is not cut off", + ); + }); + + test( + "another owner's primary connection cannot change 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')], + ); + final pending = first + .request(command: 'probe') + .then((value) => value, onError: (Object e) => e); + await _until(() => fallback.versions.isNotEmpty); + // The second owner connects to the primary while the fallback waits. + await second.checkElectrumAdapter(); + expect(second.getElectrumAdapter()!.port, primary.port); + final secondPrimary = 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(secondPrimary.peer.isClosed, false); + primary.requests + .singleWhere((rpc) => rpc.method == 'blockchain.transaction.get') + .reply({'confirmations': 7}); + expect(await reading, {'confirmations': 7}); + // 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, + 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..1e721e8a92 --- /dev/null +++ b/test/electrumx_late_approval_test.dart @@ -0,0 +1,111 @@ +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/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; + @override + TorConnectionStatus get status => TorConnectionStatus.connected; + @override + ({InternetAddress host, int port}) getProxyInfo() { + 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(); + 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, + ); + // 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(); + 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..1292927890 --- /dev/null +++ b/test/electrumx_request_policy_test.dart @@ -0,0 +1,656 @@ +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/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()); + // Each client closes itself through addTearDown in clientFor. + tearDown(() => 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('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)); + 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('getTransaction uses its own client 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), + ); + // Another client of the coin with a different policy has its own + // connection; it cannot lend the caller its deadline. + await owner.checkElectrumAdapter(); + await caller.checkElectrumAdapter(); + expect( + caller.getElectrumAdapter(), + isNot(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, 2); + 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('$method uses its own client 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), + ); + // Another client of the coin with a different policy has its own + // connection; it cannot lend the caller its deadline. + await owner.checkElectrumAdapter(); + await caller.checkElectrumAdapter(); + expect( + caller.getElectrumAdapter(), + isNot(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, 2); + 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..76ca1b15d0 --- /dev/null +++ b/test/electrumx_setup_failover_test.dart @@ -0,0 +1,380 @@ +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/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()); + // Each client closes itself through addTearDown in clientFor. + tearDown(() => 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); + 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( + '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( + 'invalid fallback approval leaves the working 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 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. + client.currentFailoverIndex = 0; + await expectLater( + client.checkElectrumAdapter().timeout(const Duration(seconds: 3)), + throwsA(isA()), + ); + + 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(working)); + 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(); + 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..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,8 +111,8 @@ void main() { required CryptoCurrency coin, TorPlainNetworkOption netType = TorPlainNetworkOption.both, }) { - return ManagedElectrumXClient( - host: 'mock.stackwallet.dev', + final client = ManagedElectrumXClient( + host: clearServer.host, port: 50002, useSSL: true, prefs: prefs, @@ -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/electrumx_tls_test.dart b/test/electrumx_tls_test.dart new file mode 100644 index 0000000000..0266cda656 --- /dev/null +++ b/test/electrumx_tls_test.dart @@ -0,0 +1,279 @@ +import 'dart:async'; +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/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'; +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()); + 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(); + + final clients = []; + setUp(() => bus = EventBus()); + tearDown(() async { + for (final client in clients) { + await client.closeAdapter(); + } + clients.clear(); + 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 [], + }) { + 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'); + 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 opens its own connection', () async { + final server = await start('node_a'); + final first = client(node(server, certificateName: 'node_a')); + await first.checkElectrumAdapter(); + final second = client(node(server, certificateName: 'node_a')); + await second.checkElectrumAdapter(); + expect( + second.getElectrumAdapter(), + isNot(same(first.getElectrumAdapter())), + ); + expect(server.accepted, 2); + expect(server.disconnections, 0); + }); + + test('a revoked client never reuses a trusted connection', () async { + final server = await start('node_a'); + final trusted = client(node(server, certificateName: 'node_a')); + await trusted.checkElectrumAdapter(); + final peer = trusted.getElectrumAdapter()!.peer; + final revoked = client(node(server)); + await expectLater( + 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); + }); + + 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'}, + ); + 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], + ); + await connection.checkElectrumAdapter(); + 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 'Tor change': + bus.fire(TorPreferenceChangedEvent(status: TorStatus.enabled)); + case 'close owner': + // A revocation or node change replaces the owner via updateNode(). + await connection.closeAdapter(); + } + 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/electrumx_tor_gate_test.dart b/test/electrumx_tor_gate_test.dart new file mode 100644 index 0000000000..f075e44c24 --- /dev/null +++ b/test/electrumx_tor_gate_test.dart @@ -0,0 +1,172 @@ +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/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)); + // Each client closes itself through addTearDown in clientFor. + tearDown(() => 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), + ); + }, + ); + } + } +} 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'))); + }); + }); +} 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..0c17a99caa --- /dev/null +++ b/test/services/notification_certificate_revocation_test.dart @@ -0,0 +1,192 @@ +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/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/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() { + late Directory logs; + setUpAll(() async { + logs = await Directory.systemTemp.createTemp('notification_tls'); + await Logging.instance.initialize(logs.path, level: Level.off); + }); + 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 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, + ), + ); + // 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( + nodeService: service, + tradesService: _Trades(), + prefs: _Prefs(), + ); + final interval = NotificationsService.notificationRefreshInterval; + addTearDown(() async { + notifications.stopCheckingWatchedTransactions(); + NotificationsService.notificationRefreshInterval = interval; + await server.close(); + await tearDownHiveCeTest(); + }); + 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', + ); + 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( + 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/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..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,60 +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(); - } - } - - final existing = getElectrumAdapter(); - if (existing != null && !existing.peer.isClosed) { - return; - } - if (existing != null) { - 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', + ); } + await super.checkElectrumAdapter(); + } - 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, - ); + /// 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(); } 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..ae17e1f0de --- /dev/null +++ b/test/utilities/tls_test_server.dart @@ -0,0 +1,326 @@ +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 connected = Completer(); + 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); + if (!connected.isCompleted) connected.complete(); + 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..100a781afd --- /dev/null +++ b/test/widget_tests/node_tls_trust_test.dart @@ -0,0 +1,514 @@ +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/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/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 '../sample_data/theme_json.dart'; +import '../utilities/tls_test_server.dart'; + +class _Wallets extends Fake implements Wallets { + _Wallets([this.wallets = const []]); + @override + 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 { + @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 DB.instance.hive.close(); + Util.layoutPlatform = const LocalPlatform(); + }); + + Future pump( + WidgetTester tester, + Widget child, { + TestNodeConnectionCallback? testConnection, + List wallets = const [], + }) 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(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('a node change updates every loaded wallet of the coin', ( + tester, + ) async { + // 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([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, + ) 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); + }); +} 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();