From 8216c78be6cc97f43f66f8c2ae33e0da26d6ded8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 16:17:47 -0500 Subject: [PATCH 1/2] feat: add cryptonote wallet lifecycle coordinator Serializes open, node updates, native replacement and shutdown on a single mutex, and rejects queued work once shutdown has begun. The Tor transition gate parks a node update while Tor is bootstrapping and abandons it as soon as the operation is superseded. --- .../cryptonote_wallet_lifecycle.dart | 138 ++++++++++ .../cryptonote_wallet_lifecycle_test.dart | 238 ++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 lib/wallets/wallet/intermediate/cryptonote_wallet_lifecycle.dart create mode 100644 test/wallets/cryptonote_wallet_lifecycle_test.dart diff --git a/lib/wallets/wallet/intermediate/cryptonote_wallet_lifecycle.dart b/lib/wallets/wallet/intermediate/cryptonote_wallet_lifecycle.dart new file mode 100644 index 0000000000..bad8fb77ba --- /dev/null +++ b/lib/wallets/wallet/intermediate/cryptonote_wallet_lifecycle.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +import 'package:mutex/mutex.dart'; + +Future cancelCryptonoteWalletSubscriptions( + Iterable?> subscriptions, +) async { + Object? firstError; + StackTrace? firstStackTrace; + for (final subscription in subscriptions) { + try { + await subscription?.cancel(); + } catch (error, stackTrace) { + firstError ??= error; + firstStackTrace ??= stackTrace; + } + } + if (firstError != null) { + Error.throwWithStackTrace(firstError, firstStackTrace!); + } +} + +class CryptonoteTorTransitionGate { + Completer? _transition; + + void block() { + _transition ??= Completer(); + } + + void release() { + final transition = _transition; + _transition = null; + if (transition != null && !transition.isCompleted) { + transition.complete(); + } + } + + Future wait({ + required bool Function() isBlocked, + required bool Function() isCurrent, + }) async { + while (isBlocked()) { + // A superseded operation must not wait for a Tor event that may never + // arrive (its listener may already be detached by exit()). + if (!isCurrent()) { + return false; + } + final transition = _transition ??= Completer(); + await transition.future; + if (!isCurrent()) { + return false; + } + } + return true; + } +} + +/// Serializes native wallet lifecycle operations and rejects node updates once +/// shutdown begins. +class CryptonoteWalletLifecycle { + final _mutex = Mutex(); + bool _allowsNodeUpdates = true; + + bool get allowsNodeUpdates => _allowsNodeUpdates; + + Future open( + Future Function(bool Function() isCurrent) operation, + ) => _mutex.protect(() async { + _allowsNodeUpdates = true; + try { + await operation(() => _allowsNodeUpdates); + } catch (_) { + _allowsNodeUpdates = false; + rethrow; + } + }); + + Future updateNode( + Future Function(bool Function() isCurrent) operation, + ) => _mutex.protect(() async { + if (_allowsNodeUpdates) { + await operation(() => _allowsNodeUpdates); + } + }); + + Future runIfCurrent(Future Function() operation) => + _mutex.protect(() async { + if (_allowsNodeUpdates) { + await operation(); + } + }); + + Future replaceNative(Future Function() operation) => + _mutex.protect(() { + if (!_allowsNodeUpdates) { + throw StateError("Native wallet lifecycle is closing"); + } + return operation(); + }); + + Future close({ + required Future Function() stopEventSources, + required Future Function() closeNative, + }) async { + _allowsNodeUpdates = false; + + Object? stopError; + StackTrace? stopStackTrace; + Future stopSources() async { + try { + await stopEventSources(); + } catch (error, stackTrace) { + stopError ??= error; + stopStackTrace ??= stackTrace; + } + } + + // Reserve shutdown's place in the queue immediately. Sources are stopped + // before native close, then checked again for a concurrent open. + final firstStopCompleted = Completer(); + final serializedClose = _mutex.protect(() async { + await firstStopCompleted.future; + // An open() queued ahead of this close may have re-enabled updates in + // the meantime; close was requested later, so the closed state wins. + _allowsNodeUpdates = false; + await stopSources(); + await closeNative(); + }); + + await stopSources(); + firstStopCompleted.complete(); + await serializedClose; + + if (stopError != null) { + Error.throwWithStackTrace(stopError!, stopStackTrace!); + } + } +} diff --git a/test/wallets/cryptonote_wallet_lifecycle_test.dart b/test/wallets/cryptonote_wallet_lifecycle_test.dart new file mode 100644 index 0000000000..5334067f8c --- /dev/null +++ b/test/wallets/cryptonote_wallet_lifecycle_test.dart @@ -0,0 +1,238 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/intermediate/cryptonote_wallet_lifecycle.dart'; + +void main() { + test("Tor transition gate waits for a status change", () async { + final gate = CryptonoteTorTransitionGate()..block(); + var blocked = true; + var completed = false; + + final wait = gate + .wait(isBlocked: () => blocked, isCurrent: () => true) + .then((value) { + completed = true; + return value; + }); + await Future.delayed(Duration.zero); + expect(completed, isFalse); + + blocked = false; + gate.release(); + expect(await wait, isTrue); + }); + + test("Tor transition gate aborts a superseded operation", () async { + final gate = CryptonoteTorTransitionGate()..block(); + var current = true; + + final wait = gate.wait(isBlocked: () => true, isCurrent: () => current); + current = false; + gate.release(); + + expect(await wait, isFalse); + }); + + test("Tor transition gate aborts an already superseded operation", () async { + final gate = CryptonoteTorTransitionGate()..block(); + + // exit() detached this wallet's Tor listeners before the update reached the + // gate, so nothing will ever release it on this wallet's behalf. + final wait = gate + .wait(isBlocked: () => true, isCurrent: () => false) + .timeout(const Duration(seconds: 5)); + + expect(await wait, isFalse); + }); + + test("subscription cleanup continues after one cancellation fails", () async { + var secondCancellation = 0; + final first = StreamController( + onCancel: () async => throw Exception("cancel failed"), + ); + final second = StreamController( + onCancel: () async => secondCancellation++, + ); + final subscriptions = [ + first.stream.listen((_) {}), + second.stream.listen((_) {}), + ]; + + await expectLater( + cancelCryptonoteWalletSubscriptions(subscriptions), + throwsA(isA()), + ); + expect(secondCancellation, 1); + await first.close(); + await second.close(); + }); + + test("close stops sources before waiting for an active update", () async { + final lifecycle = CryptonoteWalletLifecycle(); + final updateStarted = Completer(); + final releaseUpdate = Completer(); + final calls = []; + + final update = lifecycle.updateNode((_) async { + calls.add("update start"); + updateStarted.complete(); + await releaseUpdate.future; + calls.add("update end"); + }); + await updateStarted.future; + + final close = lifecycle.close( + stopEventSources: () async => calls.add("stop sources"), + closeNative: () async => calls.add("close native"), + ); + await Future.delayed(Duration.zero); + + expect(calls, ["update start", "stop sources"]); + releaseUpdate.complete(); + await Future.wait([update, close]); + expect(calls, [ + "update start", + "stop sources", + "update end", + "stop sources", + "close native", + ]); + }); + + test("queued node update is rejected after close begins", () async { + final lifecycle = CryptonoteWalletLifecycle(); + final openStarted = Completer(); + final releaseOpen = Completer(); + var updates = 0; + + final open = lifecycle.open((_) async { + openStarted.complete(); + await releaseOpen.future; + }); + await openStarted.future; + final queuedUpdate = lifecycle.updateNode((_) async => updates++); + final close = lifecycle.close( + stopEventSources: () async {}, + closeNative: () async {}, + ); + + releaseOpen.complete(); + await Future.wait([open, queuedUpdate, close]); + expect(updates, 0); + }); + + test("close requested after a queued open still ends closed", () async { + final lifecycle = CryptonoteWalletLifecycle(); + final releaseUpdate = Completer(); + + // Something already holds the mutex, so the open queues behind it. + final update = lifecycle.updateNode((_) => releaseUpdate.future); + final open = lifecycle.open((_) async {}); + final close = lifecycle.close( + stopEventSources: () async {}, + closeNative: () async {}, + ); + releaseUpdate.complete(); + await Future.wait([update, open, close]); + + expect(lifecycle.allowsNodeUpdates, isFalse); + var updates = 0; + await lifecycle.updateNode((_) async => updates++); + expect(updates, 0); + }); + + test("close in the same tick as open wins", () async { + final lifecycle = CryptonoteWalletLifecycle(); + + final open = lifecycle.open((_) async {}); + final close = lifecycle.close( + stopEventSources: () async {}, + closeNative: () async {}, + ); + await Future.wait([open, close]); + + expect(lifecycle.allowsNodeUpdates, isFalse); + }); + + test("failed open rejects updates until a successful reopen", () async { + final lifecycle = CryptonoteWalletLifecycle(); + + await expectLater( + lifecycle.open((_) async => throw Exception("open failed")), + throwsException, + ); + await lifecycle.updateNode((_) async => fail("update must be rejected")); + + await lifecycle.open((_) async {}); + var updates = 0; + await lifecycle.updateNode((_) async => updates++); + expect(updates, 1); + }); + + test("close catches event sources attached by an active open", () async { + final lifecycle = CryptonoteWalletLifecycle(); + final openStarted = Completer(); + final releaseOpen = Completer(); + var sourceAttached = false; + var stops = 0; + + final open = lifecycle.open((isCurrent) async { + openStarted.complete(); + await releaseOpen.future; + sourceAttached = true; + expect(isCurrent(), isFalse); + }); + await openStarted.future; + final close = lifecycle.close( + stopEventSources: () async { + if (sourceAttached) { + sourceAttached = false; + stops++; + } + }, + closeNative: () async {}, + ); + + releaseOpen.complete(); + await Future.wait([open, close]); + expect(sourceAttached, isFalse); + expect(stops, 1); + }); + + test("queued native work is rejected after close begins", () async { + final lifecycle = CryptonoteWalletLifecycle(); + final stopStarted = Completer(); + final releaseStop = Completer(); + + final close = lifecycle.close( + stopEventSources: () async { + if (!stopStarted.isCompleted) { + stopStarted.complete(); + await releaseStop.future; + } + }, + closeNative: () async {}, + ); + await stopStarted.future; + final nativeWork = lifecycle.replaceNative(() async {}); + + releaseStop.complete(); + await close; + await expectLater(nativeWork, throwsStateError); + }); + + test("source cancellation failure does not skip native close", () async { + final lifecycle = CryptonoteWalletLifecycle(); + var closeCalls = 0; + + await expectLater( + lifecycle.close( + stopEventSources: () async => throw Exception("cancel failed"), + closeNative: () async => closeCalls++, + ), + throwsA(isA()), + ); + expect(closeCalls, 1); + }); +} From 914326d424eb6695d87d3666a2fe3682a863137a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 16:17:47 -0500 Subject: [PATCH 2/2] fix: attach cryptonote Tor listeners only while the wallet is open Every LibMonero/Wownero/Salvium instance subscribed to the Tor status and preference events from its constructor, so toggling Tor ran updateNode() on the dozens of wallets Wallets.load() constructs but never opens. With a Tor-only or clearnet-only node the TOR/clearnet mismatch escaped the async listener as an unhandled exception. Listeners are now attached in open() and detached on exit() or a failed open, and the native lifecycle runs through CryptonoteWalletLifecycle so concurrent open/exit calls cannot load or tear down the native wallet twice. Because an exited wallet no longer follows node or Tor changes, open() re-runs the node update whenever the native daemon session was torn down; otherwise a reopened wallet resumes syncing over its stale clearnet session. Also corrects the inverted null guard in recoverViewOnly(). --- .../intermediate/lib_monero_wallet.dart | 273 ++++++++---- .../intermediate/lib_salvium_wallet.dart | 269 ++++++++---- .../intermediate/lib_wownero_wallet.dart | 271 ++++++++---- test/util/isar_test_core.dart | 40 ++ .../lib_monero_wallet_lifecycle_test.dart | 407 ++++++++++++++++++ 5 files changed, 996 insertions(+), 264 deletions(-) create mode 100644 test/util/isar_test_core.dart create mode 100644 test/wallets/lib_monero_wallet_lifecycle_test.dart diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index 6c0c49884e..ae963e8615 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -43,6 +43,7 @@ import '../../models/tx_data.dart'; import '../wallet.dart'; import '../wallet_mixin_interfaces/multi_address_interface.dart'; import '../wallet_mixin_interfaces/view_only_option_interface.dart'; +import 'cryptonote_wallet_lifecycle.dart'; import 'cryptonote_wallet.dart'; abstract class LibMoneroWallet @@ -52,27 +53,27 @@ abstract class LibMoneroWallet @override int get isarTransactionVersion => 2; - LibMoneroWallet(super.currency, this.compatType) { + LibMoneroWallet(super.currency, this.compatType); + + void _attachTorListeners() { + if (_torStatusListener != null || _torPreferenceListener != null) { + return; + } + final bus = GlobalEventBus.instance; // Listen for tor status changes. _torStatusListener = bus.on().listen(( event, - ) async { + ) { switch (event.newStatus) { case TorConnectionStatus.connecting: - if (!_torConnectingLock.isLocked) { - await _torConnectingLock.acquire(); - } - _requireMutex = true; + _torTransitionGate.block(); break; case TorConnectionStatus.connected: case TorConnectionStatus.disconnected: - if (_torConnectingLock.isLocked) { - _torConnectingLock.release(); - } - _requireMutex = false; + _torTransitionGate.release(); break; } }); @@ -81,33 +82,63 @@ abstract class LibMoneroWallet _torPreferenceListener = bus.on().listen(( event, ) async { - await updateNode(); + await _updateNodeFromTor(); }); - // Potentially dangerous hack. See comments in _startInit() - _startInit(); + if (TorService.sharedInstance.status == TorConnectionStatus.connecting) { + _torTransitionGate.block(); + } + } + + Future _updateNodeFromTor() async { + try { + await updateNode(); + } catch (e, s) { + Logging.instance.w( + "Tor-triggered node update failed", + error: e, + stackTrace: s, + ); + } + } + + Future _stopEventSources() async { + try { + await cancelCryptonoteWalletSubscriptions([ + _torStatusListener, + _torPreferenceListener, + _streamSub, + ]); + } finally { + _torStatusListener = null; + _torPreferenceListener = null; + _streamSub = null; + _torTransitionGate.release(); + } } + // cw based wallet listener to handle synchronization of utxo frozen states - late final StreamSubscription> _streamSub; - Future _startInit() async { - // Delay required as `mainDB` is not initialized in constructor. - // This is a hack and could lead to a race condition. - Future.delayed(const Duration(seconds: 2), () { - _streamSub = mainDB.isar.utxos - .where() - .walletIdEqualTo(walletId) - .watch(fireImmediately: true) - .listen((utxos) async { - try { - await onUTXOsChanged(utxos); - await updateBalance(shouldUpdateUtxos: false); - } catch (e, s) { - Logging.instance.e("_startInit", error: e, stackTrace: s); - } - }); - }); + StreamSubscription>? _streamSub; + void _attachUtxoListener() { + _streamSub ??= mainDB.isar.utxos + .where() + .walletIdEqualTo(walletId) + .watch(fireImmediately: true) + .listen((utxos) async { + try { + await _handleUtxosChanged(utxos); + } catch (e, s) { + Logging.instance.e("UTXO listener failed", error: e, stackTrace: s); + } + }); } + Future _handleUtxosChanged(List utxos) => + _lifecycle.runIfCurrent(() async { + await onUTXOsChanged(utxos); + await updateBalance(shouldUpdateUtxos: false); + }); + final lib_monero_compat.WalletType compatType; lib_monero_compat.SyncStatus? get syncStatus => _syncStatus; @@ -192,12 +223,39 @@ abstract class LibMoneroWallet } @override - Future open() async { - bool wasNull = false; + Future open() => _lifecycle.open(_open); + + Future _open(bool Function() isCurrent) async { + try { + await _openNative(isCurrent); + } catch (error, stackTrace) { + try { + await _stopEventSources(); + } catch (cleanupError, cleanupStackTrace) { + Logging.instance.e( + "Failed to stop wallet event sources after open failure", + error: cleanupError, + stackTrace: cleanupStackTrace, + ); + } + try { + await _exitNative(); + } catch (cleanupError, cleanupStackTrace) { + Logging.instance.e( + "Failed to clean up native wallet after open failure", + error: cleanupError, + stackTrace: cleanupStackTrace, + ); + } + Error.throwWithStackTrace(error, stackTrace); + } + } + + Future _openNative(bool Function() isCurrent) async { + var wasNull = false; if (wallet == null) { wasNull = true; - // libMoneroWalletT?.close(); final path = await pathForWallet(name: walletId, type: compatType); final String password; @@ -210,10 +268,15 @@ abstract class LibMoneroWallet } wallet = await loadWallet(path: path, password: password); - _setListener(); + } - await updateNode(); + _attachTorListeners(); + // A node/Tor preference change that arrived while this wallet was exited + // (or after a failed open) was rejected by the lifecycle, so the native + // wallet may still hold stale daemon/proxy settings and a stopped sync. + if (wasNull || _nativeNeedsReconnect) { + await _updateNode(isCurrent); } Address? currentAddress = await getCurrentReceivingAddress(); @@ -234,14 +297,16 @@ abstract class LibMoneroWallet await csMonero.startSyncing(wallet!); } catch (_) { _setSyncStatus(lib_monero_compat.FailedSyncStatus()); - // TODO log } } _setListener(); await csMonero.startListeners(wallet!); csMonero.startAutoSaving(wallet!); - unawaited(refresh()); + if (isCurrent()) { + _attachUtxoListener(); + unawaited(refresh()); + } } @Deprecated("Only used in the case of older wallets") @@ -374,9 +439,7 @@ abstract class LibMoneroWallet ); this.wallet = wallet; - await updateNode(); - await csMonero.close(wallet, save: true); - this.wallet = null; + await _initializeAndClose(wallet); } catch (e, s) { Logging.instance.f("", error: e, stackTrace: s); } @@ -385,8 +448,21 @@ abstract class LibMoneroWallet return super.init(); } + Future _initializeAndClose(WrappedWallet wallet) async { + try { + await updateNode(); + await csMonero.close(wallet, save: true); + } finally { + await _stopEventSources(); + this.wallet = null; + } + } + @override - Future recover({required bool isRescan}) async { + Future recover({required bool isRescan}) => + _lifecycle.replaceNative(() => _recover(isRescan: isRescan)); + + Future _recover({required bool isRescan}) async { if (isRescan) { await refreshMutex.protect(() async { // clear blockchain info @@ -402,7 +478,7 @@ abstract class LibMoneroWallet } if (isViewOnly) { - await recoverViewOnly(); + await _recoverViewOnly(); return; } @@ -441,7 +517,7 @@ abstract class LibMoneroWallet ); if (this.wallet != null) { - await exit(); + await _exitNative(); } this.wallet = wallet; @@ -469,7 +545,7 @@ abstract class LibMoneroWallet Logging.instance.f("", error: e, stackTrace: s); rethrow; } - await updateNode(); + await _updateNode(() => _lifecycle.allowsNodeUpdates); _setListener(); // libMoneroWallet?.setRecoveringFromSeed(isRecovery: true); @@ -503,7 +579,22 @@ abstract class LibMoneroWallet } @override - Future updateNode() async { + Future updateNode() => _lifecycle.updateNode(_updateNode); + + Future _updateNode(bool Function() isCurrent) async { + if (wallet == null) { + return; + } + + _attachTorListeners(); + if (!await _torTransitionGate.wait( + isBlocked: () => + TorService.sharedInstance.status == TorConnectionStatus.connecting, + isCurrent: isCurrent, + )) { + return; + } + final node = getCurrentNode(); if (await _torNodeMismatchGuard(node)) { @@ -513,48 +604,39 @@ abstract class LibMoneroWallet final host = node.host.endsWith(".onion") ? node.host : Uri.parse(node.host).host; - final ({InternetAddress host, int port})? proxy = - AppConfig.hasFeature(AppFeature.tor) && prefs.useTor && !node.forceNoTor - ? TorService.sharedInstance.getProxyInfo() - : null; _setSyncStatus(lib_monero_compat.ConnectingSyncStatus()); try { - if (_requireMutex) { - await _torConnectingLock.protect(() async { - await csMonero.connect( - wallet!, - daemonAddress: "$host:${node.port}", - daemonUsername: node.loginName, - daemonPassword: await node.getPassword(secureStorageInterface), - trusted: node.trusted ?? false, - useSSL: node.useSSL, - socksProxyAddress: node.forceNoTor - ? null - : proxy == null - ? null - : "${proxy.host.address}:${proxy.port}", - ); - }); - } else { - await csMonero.connect( - wallet!, - daemonAddress: "$host:${node.port}", - daemonUsername: node.loginName, - daemonPassword: await node.getPassword(secureStorageInterface), - trusted: node.trusted ?? false, - useSSL: node.useSSL, - socksProxyAddress: node.forceNoTor - ? null - : proxy == null - ? null - : "${proxy.host.address}:${proxy.port}", - ); + if (!isCurrent() || wallet == null) { + return; + } + + final ({InternetAddress host, int port})? proxy = + AppConfig.hasFeature(AppFeature.tor) && + prefs.useTor && + !node.forceNoTor + ? TorService.sharedInstance.getProxyInfo() + : null; + await csMonero.connect( + wallet!, + daemonAddress: "$host:${node.port}", + daemonUsername: node.loginName, + daemonPassword: await node.getPassword(secureStorageInterface), + trusted: node.trusted ?? false, + useSSL: node.useSSL, + socksProxyAddress: proxy == null + ? null + : "${proxy.host.address}:${proxy.port}", + ); + + if (!isCurrent() || wallet == null) { + return; } await csMonero.startSyncing(wallet!); await csMonero.startListeners(wallet!); csMonero.startAutoSaving(wallet!); + _nativeNeedsReconnect = false; _setSyncStatus(lib_monero_compat.ConnectedSyncStatus()); } catch (e, s) { _setSyncStatus(lib_monero_compat.FailedSyncStatus()); @@ -564,8 +646,6 @@ abstract class LibMoneroWallet stackTrace: s, ); } - - return; } @override @@ -732,13 +812,21 @@ abstract class LibMoneroWallet @override Future exit() async { Logging.instance.i("exit called on monero $walletId!"); + await _lifecycle.close( + stopEventSources: _stopEventSources, + closeNative: _exitNative, + ); + Logging.instance.i("exit call completed monero $walletId!"); + } + + Future _exitNative() async { + _nativeNeedsReconnect = true; if (wallet != null) { csMonero.stopAutoSaving(wallet!); await csMonero.stopListeners(wallet!); await csMonero.stopSyncing(wallet!); await csMonero.save(wallet!); } - Logging.instance.i("exit call completed monero $walletId!"); } Future pathForWalletDir({ @@ -1546,7 +1634,9 @@ abstract class LibMoneroWallet // ============== View only ================================================== @override - Future recoverViewOnly() async { + Future recoverViewOnly() => _lifecycle.replaceNative(_recoverViewOnly); + + Future _recoverViewOnly() async { await refreshMutex.protect(() async { final data = await getViewOnlyWalletData() as CryptonoteViewOnlyWalletData; @@ -1577,8 +1667,8 @@ abstract class LibMoneroWallet height: height, ); - if (this.wallet == null) { - await exit(); + if (this.wallet != null) { + await _exitNative(); } this.wallet = wallet; @@ -1602,7 +1692,7 @@ abstract class LibMoneroWallet isar: mainDB.isar, ); - await updateNode(); + await _updateNode(() => _lifecycle.allowsNodeUpdates); _setListener(); await csMonero.rescanBlockchain(this.wallet!); @@ -1627,6 +1717,11 @@ abstract class LibMoneroWallet StreamSubscription? _torStatusListener; StreamSubscription? _torPreferenceListener; - final Mutex _torConnectingLock = Mutex(); - bool _requireMutex = false; + final _torTransitionGate = CryptonoteTorTransitionGate(); + + final _lifecycle = CryptonoteWalletLifecycle(); + + /// True once the native daemon session was torn down (exit or failed open) + /// so the next open() reconnects instead of trusting stale settings. + bool _nativeNeedsReconnect = false; } diff --git a/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart b/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart index 03e11f74c0..2d612642e0 100644 --- a/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart @@ -41,6 +41,7 @@ import '../../models/tx_data.dart'; import '../wallet.dart'; import '../wallet_mixin_interfaces/multi_address_interface.dart'; import '../wallet_mixin_interfaces/view_only_option_interface.dart'; +import 'cryptonote_wallet_lifecycle.dart'; import 'cryptonote_wallet.dart'; abstract class LibSalviumWallet @@ -50,27 +51,27 @@ abstract class LibSalviumWallet @override int get isarTransactionVersion => 2; - LibSalviumWallet(super.currency) { + LibSalviumWallet(super.currency); + + void _attachTorListeners() { + if (_torStatusListener != null || _torPreferenceListener != null) { + return; + } + final bus = GlobalEventBus.instance; // Listen for tor status changes. _torStatusListener = bus.on().listen(( event, - ) async { + ) { switch (event.newStatus) { case TorConnectionStatus.connecting: - if (!_torConnectingLock.isLocked) { - await _torConnectingLock.acquire(); - } - _requireMutex = true; + _torTransitionGate.block(); break; case TorConnectionStatus.connected: case TorConnectionStatus.disconnected: - if (_torConnectingLock.isLocked) { - _torConnectingLock.release(); - } - _requireMutex = false; + _torTransitionGate.release(); break; } }); @@ -79,33 +80,63 @@ abstract class LibSalviumWallet _torPreferenceListener = bus.on().listen(( event, ) async { - await updateNode(); + await _updateNodeFromTor(); }); - // Potentially dangerous hack. See comments in _startInit() - _startInit(); + if (TorService.sharedInstance.status == TorConnectionStatus.connecting) { + _torTransitionGate.block(); + } } + + Future _updateNodeFromTor() async { + try { + await updateNode(); + } catch (e, s) { + Logging.instance.w( + "Tor-triggered node update failed", + error: e, + stackTrace: s, + ); + } + } + + Future _stopEventSources() async { + try { + await cancelCryptonoteWalletSubscriptions([ + _torStatusListener, + _torPreferenceListener, + _streamSub, + ]); + } finally { + _torStatusListener = null; + _torPreferenceListener = null; + _streamSub = null; + _torTransitionGate.release(); + } + } + // cw based wallet listener to handle synchronization of utxo frozen states - late final StreamSubscription> _streamSub; - Future _startInit() async { - // Delay required as `mainDB` is not initialized in constructor. - // This is a hack and could lead to a race condition. - Future.delayed(const Duration(seconds: 2), () { - _streamSub = mainDB.isar.utxos - .where() - .walletIdEqualTo(walletId) - .watch(fireImmediately: true) - .listen((utxos) async { - try { - await onUTXOsChanged(utxos); - await updateBalance(shouldUpdateUtxos: false); - } catch (e, s) { - Logging.instance.e("_startInit", error: e, stackTrace: s); - } - }); - }); + StreamSubscription>? _streamSub; + void _attachUtxoListener() { + _streamSub ??= mainDB.isar.utxos + .where() + .walletIdEqualTo(walletId) + .watch(fireImmediately: true) + .listen((utxos) async { + try { + await _handleUtxosChanged(utxos); + } catch (e, s) { + Logging.instance.e("UTXO listener failed", error: e, stackTrace: s); + } + }); } + Future _handleUtxosChanged(List utxos) => + _lifecycle.runIfCurrent(() async { + await onUTXOsChanged(utxos); + await updateBalance(shouldUpdateUtxos: false); + }); + SyncStatus? get syncStatus => _syncStatus; SyncStatus? _syncStatus; int _syncedCount = 0; @@ -188,12 +219,39 @@ abstract class LibSalviumWallet } @override - Future open() async { - bool wasNull = false; + Future open() => _lifecycle.open(_open); + + Future _open(bool Function() isCurrent) async { + try { + await _openNative(isCurrent); + } catch (error, stackTrace) { + try { + await _stopEventSources(); + } catch (cleanupError, cleanupStackTrace) { + Logging.instance.e( + "Failed to stop wallet event sources after open failure", + error: cleanupError, + stackTrace: cleanupStackTrace, + ); + } + try { + await _exitNative(); + } catch (cleanupError, cleanupStackTrace) { + Logging.instance.e( + "Failed to clean up native wallet after open failure", + error: cleanupError, + stackTrace: cleanupStackTrace, + ); + } + Error.throwWithStackTrace(error, stackTrace); + } + } + + Future _openNative(bool Function() isCurrent) async { + var wasNull = false; if (wallet == null) { wasNull = true; - // await libSalviumWallet?.close(); final path = await pathForWallet(name: walletId); final String password; @@ -206,10 +264,15 @@ abstract class LibSalviumWallet } wallet = await loadWallet(path: path, password: password); - _setListener(); + } - await updateNode(); + _attachTorListeners(); + // A node/Tor preference change that arrived while this wallet was exited + // (or after a failed open) was rejected by the lifecycle, so the native + // wallet may still hold stale daemon/proxy settings and a stopped sync. + if (wasNull || _nativeNeedsReconnect) { + await _updateNode(isCurrent); } Address? currentAddress = await getCurrentReceivingAddress(); @@ -230,14 +293,16 @@ abstract class LibSalviumWallet csSalvium.startSyncing(wallet!); } catch (_) { _setSyncStatus(FailedSyncStatus()); - // TODO log } } _setListener(); csSalvium.startListeners(wallet!); csSalvium.startAutoSaving(wallet!); - unawaited(refresh()); + if (isCurrent()) { + _attachUtxoListener(); + unawaited(refresh()); + } } Future save() async { @@ -352,9 +417,7 @@ abstract class LibSalviumWallet ); this.wallet = wallet; - await updateNode(); - await csSalvium.close(wallet, save: true); - this.wallet = null; + await _initializeAndClose(wallet); } catch (e, s) { Logging.instance.f("", error: e, stackTrace: s); } @@ -363,8 +426,21 @@ abstract class LibSalviumWallet return super.init(); } + Future _initializeAndClose(WrappedWallet wallet) async { + try { + await updateNode(); + await csSalvium.close(wallet, save: true); + } finally { + await _stopEventSources(); + this.wallet = null; + } + } + @override - Future recover({required bool isRescan}) async { + Future recover({required bool isRescan}) => + _lifecycle.replaceNative(() => _recover(isRescan: isRescan)); + + Future _recover({required bool isRescan}) async { if (isRescan) { await refreshMutex.protect(() async { // clear blockchain info @@ -380,7 +456,7 @@ abstract class LibSalviumWallet } if (isViewOnly) { - await recoverViewOnly(); + await _recoverViewOnly(); return; } @@ -419,7 +495,7 @@ abstract class LibSalviumWallet ); if (this.wallet != null) { - await exit(); + await _exitNative(); } this.wallet = wallet; @@ -447,7 +523,7 @@ abstract class LibSalviumWallet Logging.instance.f("", error: e, stackTrace: s); rethrow; } - await updateNode(); + await _updateNode(() => _lifecycle.allowsNodeUpdates); _setListener(); // libSalviumWallet?.setRecoveringFromSeed(isRecovery: true); @@ -480,7 +556,22 @@ abstract class LibSalviumWallet } @override - Future updateNode() async { + Future updateNode() => _lifecycle.updateNode(_updateNode); + + Future _updateNode(bool Function() isCurrent) async { + if (wallet == null) { + return; + } + + _attachTorListeners(); + if (!await _torTransitionGate.wait( + isBlocked: () => + TorService.sharedInstance.status == TorConnectionStatus.connecting, + isCurrent: isCurrent, + )) { + return; + } + final node = getCurrentNode(); if (_torNodeMismatchGuard(node)) { @@ -490,48 +581,39 @@ abstract class LibSalviumWallet final host = node.host.endsWith(".onion") ? node.host : Uri.parse(node.host).host; - final ({InternetAddress host, int port})? proxy = - AppConfig.hasFeature(AppFeature.tor) && prefs.useTor && !node.forceNoTor - ? TorService.sharedInstance.getProxyInfo() - : null; _setSyncStatus(ConnectingSyncStatus()); try { - if (_requireMutex) { - await _torConnectingLock.protect(() async { - await csSalvium.connect( - wallet!, - daemonAddress: "$host:${node.port}", - daemonUsername: node.loginName, - daemonPassword: await node.getPassword(secureStorageInterface), - trusted: node.trusted ?? false, - useSSL: node.useSSL, - socksProxyAddress: node.forceNoTor - ? null - : proxy == null - ? null - : "${proxy.host.address}:${proxy.port}", - ); - }); - } else { - await csSalvium.connect( - wallet!, - daemonAddress: "$host:${node.port}", - daemonUsername: node.loginName, - daemonPassword: await node.getPassword(secureStorageInterface), - trusted: node.trusted ?? false, - useSSL: node.useSSL, - socksProxyAddress: node.forceNoTor - ? null - : proxy == null - ? null - : "${proxy.host.address}:${proxy.port}", - ); + if (!isCurrent() || wallet == null) { + return; + } + + final ({InternetAddress host, int port})? proxy = + AppConfig.hasFeature(AppFeature.tor) && + prefs.useTor && + !node.forceNoTor + ? TorService.sharedInstance.getProxyInfo() + : null; + await csSalvium.connect( + wallet!, + daemonAddress: "$host:${node.port}", + daemonUsername: node.loginName, + daemonPassword: await node.getPassword(secureStorageInterface), + trusted: node.trusted ?? false, + useSSL: node.useSSL, + socksProxyAddress: proxy == null + ? null + : "${proxy.host.address}:${proxy.port}", + ); + + if (!isCurrent() || wallet == null) { + return; } csSalvium.startSyncing(wallet!); csSalvium.startListeners(wallet!); csSalvium.startAutoSaving(wallet!); + _nativeNeedsReconnect = false; _setSyncStatus(ConnectedSyncStatus()); } catch (e, s) { _setSyncStatus(FailedSyncStatus()); @@ -541,8 +623,6 @@ abstract class LibSalviumWallet stackTrace: s, ); } - - return; } @override @@ -727,6 +807,14 @@ abstract class LibSalviumWallet @override Future exit() async { Logging.instance.i("exit called on $walletId"); + await _lifecycle.close( + stopEventSources: _stopEventSources, + closeNative: _exitNative, + ); + } + + Future _exitNative() async { + _nativeNeedsReconnect = true; if (wallet != null) { csSalvium.stopAutoSaving(wallet!); csSalvium.stopListeners(wallet!); @@ -1512,7 +1600,9 @@ abstract class LibSalviumWallet // ============== View only ================================================== @override - Future recoverViewOnly() async { + Future recoverViewOnly() => _lifecycle.replaceNative(_recoverViewOnly); + + Future _recoverViewOnly() async { await refreshMutex.protect(() async { final data = await getViewOnlyWalletData() as CryptonoteViewOnlyWalletData; @@ -1544,7 +1634,7 @@ abstract class LibSalviumWallet ); if (this.wallet != null) { - await exit(); + await _exitNative(); } this.wallet = wallet; @@ -1569,7 +1659,7 @@ abstract class LibSalviumWallet isar: mainDB.isar, ); - await updateNode(); + await _updateNode(() => _lifecycle.allowsNodeUpdates); _setListener(); unawaited(csSalvium.rescanBlockchain(this.wallet!)); @@ -1594,8 +1684,13 @@ abstract class LibSalviumWallet StreamSubscription? _torStatusListener; StreamSubscription? _torPreferenceListener; - final Mutex _torConnectingLock = Mutex(); - bool _requireMutex = false; + final _torTransitionGate = CryptonoteTorTransitionGate(); + + final _lifecycle = CryptonoteWalletLifecycle(); + + /// True once the native daemon session was torn down (exit or failed open) + /// so the next open() reconnects instead of trusting stale settings. + bool _nativeNeedsReconnect = false; } String _libSalviumWalletPasswordKey(String walletName) => diff --git a/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart b/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart index 5ebd2191a3..01003fc7eb 100644 --- a/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart @@ -45,6 +45,7 @@ import '../../models/tx_data.dart'; import '../wallet.dart'; import '../wallet_mixin_interfaces/multi_address_interface.dart'; import '../wallet_mixin_interfaces/view_only_option_interface.dart'; +import 'cryptonote_wallet_lifecycle.dart'; import 'cryptonote_wallet.dart'; abstract class LibWowneroWallet @@ -54,27 +55,27 @@ abstract class LibWowneroWallet @override int get isarTransactionVersion => 2; - LibWowneroWallet(super.currency, this.compatType) { + LibWowneroWallet(super.currency, this.compatType); + + void _attachTorListeners() { + if (_torStatusListener != null || _torPreferenceListener != null) { + return; + } + final bus = GlobalEventBus.instance; // Listen for tor status changes. _torStatusListener = bus.on().listen(( event, - ) async { + ) { switch (event.newStatus) { case TorConnectionStatus.connecting: - if (!_torConnectingLock.isLocked) { - await _torConnectingLock.acquire(); - } - _requireMutex = true; + _torTransitionGate.block(); break; case TorConnectionStatus.connected: case TorConnectionStatus.disconnected: - if (_torConnectingLock.isLocked) { - _torConnectingLock.release(); - } - _requireMutex = false; + _torTransitionGate.release(); break; } }); @@ -83,33 +84,63 @@ abstract class LibWowneroWallet _torPreferenceListener = bus.on().listen(( event, ) async { - await updateNode(); + await _updateNodeFromTor(); }); - // Potentially dangerous hack. See comments in _startInit() - _startInit(); + if (TorService.sharedInstance.status == TorConnectionStatus.connecting) { + _torTransitionGate.block(); + } } + + Future _updateNodeFromTor() async { + try { + await updateNode(); + } catch (e, s) { + Logging.instance.w( + "Tor-triggered node update failed", + error: e, + stackTrace: s, + ); + } + } + + Future _stopEventSources() async { + try { + await cancelCryptonoteWalletSubscriptions([ + _torStatusListener, + _torPreferenceListener, + _streamSub, + ]); + } finally { + _torStatusListener = null; + _torPreferenceListener = null; + _streamSub = null; + _torTransitionGate.release(); + } + } + // cw based wallet listener to handle synchronization of utxo frozen states - late final StreamSubscription> _streamSub; - Future _startInit() async { - // Delay required as `mainDB` is not initialized in constructor. - // This is a hack and could lead to a race condition. - Future.delayed(const Duration(seconds: 2), () { - _streamSub = mainDB.isar.utxos - .where() - .walletIdEqualTo(walletId) - .watch(fireImmediately: true) - .listen((utxos) async { - try { - await onUTXOsChanged(utxos); - await updateBalance(shouldUpdateUtxos: false); - } catch (e, s) { - Logging.instance.e("_startInit", error: e, stackTrace: s); - } - }); - }); + StreamSubscription>? _streamSub; + void _attachUtxoListener() { + _streamSub ??= mainDB.isar.utxos + .where() + .walletIdEqualTo(walletId) + .watch(fireImmediately: true) + .listen((utxos) async { + try { + await _handleUtxosChanged(utxos); + } catch (e, s) { + Logging.instance.e("UTXO listener failed", error: e, stackTrace: s); + } + }); } + Future _handleUtxosChanged(List utxos) => + _lifecycle.runIfCurrent(() async { + await onUTXOsChanged(utxos); + await updateBalance(shouldUpdateUtxos: false); + }); + final lib_monero_compat.WalletType compatType; lib_monero_compat.SyncStatus? get syncStatus => _syncStatus; @@ -194,12 +225,39 @@ abstract class LibWowneroWallet } @override - Future open() async { - bool wasNull = false; + Future open() => _lifecycle.open(_open); + + Future _open(bool Function() isCurrent) async { + try { + await _openNative(isCurrent); + } catch (error, stackTrace) { + try { + await _stopEventSources(); + } catch (cleanupError, cleanupStackTrace) { + Logging.instance.e( + "Failed to stop wallet event sources after open failure", + error: cleanupError, + stackTrace: cleanupStackTrace, + ); + } + try { + await _exitNative(); + } catch (cleanupError, cleanupStackTrace) { + Logging.instance.e( + "Failed to clean up native wallet after open failure", + error: cleanupError, + stackTrace: cleanupStackTrace, + ); + } + Error.throwWithStackTrace(error, stackTrace); + } + } + + Future _openNative(bool Function() isCurrent) async { + var wasNull = false; if (wallet == null) { wasNull = true; - // LibWowneroWalletT?.close(); final path = await pathForWallet(name: walletId, type: compatType); final String password; @@ -212,10 +270,15 @@ abstract class LibWowneroWallet } wallet = await loadWallet(path: path, password: password); - _setListener(); + } - await updateNode(); + _attachTorListeners(); + // A node/Tor preference change that arrived while this wallet was exited + // (or after a failed open) was rejected by the lifecycle, so the native + // wallet may still hold stale daemon/proxy settings and a stopped sync. + if (wasNull || _nativeNeedsReconnect) { + await _updateNode(isCurrent); } Address? currentAddress = await getCurrentReceivingAddress(); @@ -236,14 +299,16 @@ abstract class LibWowneroWallet csWownero.startSyncing(wallet!); } catch (_) { _setSyncStatus(lib_monero_compat.FailedSyncStatus()); - // TODO log } } _setListener(); csWownero.startListeners(wallet!); csWownero.startAutoSaving(wallet!); - unawaited(refresh()); + if (isCurrent()) { + _attachUtxoListener(); + unawaited(refresh()); + } } @Deprecated("Only used in the case of older wallets") @@ -376,9 +441,7 @@ abstract class LibWowneroWallet ); this.wallet = wallet; - await updateNode(); - await csWownero.close(wallet, save: true); - this.wallet = null; + await _initializeAndClose(wallet); } catch (e, s) { Logging.instance.f("", error: e, stackTrace: s); } @@ -387,8 +450,21 @@ abstract class LibWowneroWallet return super.init(); } + Future _initializeAndClose(WrappedWallet wallet) async { + try { + await updateNode(); + await csWownero.close(wallet, save: true); + } finally { + await _stopEventSources(); + this.wallet = null; + } + } + @override - Future recover({required bool isRescan}) async { + Future recover({required bool isRescan}) => + _lifecycle.replaceNative(() => _recover(isRescan: isRescan)); + + Future _recover({required bool isRescan}) async { if (isRescan) { await refreshMutex.protect(() async { // clear blockchain info @@ -404,7 +480,7 @@ abstract class LibWowneroWallet } if (isViewOnly) { - await recoverViewOnly(); + await _recoverViewOnly(); return; } @@ -443,7 +519,7 @@ abstract class LibWowneroWallet ); if (this.wallet != null) { - await exit(); + await _exitNative(); } this.wallet = wallet; @@ -471,7 +547,7 @@ abstract class LibWowneroWallet Logging.instance.f("", error: e, stackTrace: s); rethrow; } - await updateNode(); + await _updateNode(() => _lifecycle.allowsNodeUpdates); _setListener(); // LibWowneroWallet?.setRecoveringFromSeed(isRecovery: true); @@ -505,7 +581,22 @@ abstract class LibWowneroWallet } @override - Future updateNode() async { + Future updateNode() => _lifecycle.updateNode(_updateNode); + + Future _updateNode(bool Function() isCurrent) async { + if (wallet == null) { + return; + } + + _attachTorListeners(); + if (!await _torTransitionGate.wait( + isBlocked: () => + TorService.sharedInstance.status == TorConnectionStatus.connecting, + isCurrent: isCurrent, + )) { + return; + } + final node = getCurrentNode(); if (_torNodeMismatchGuard(node)) { @@ -515,48 +606,39 @@ abstract class LibWowneroWallet final host = node.host.endsWith(".onion") ? node.host : Uri.parse(node.host).host; - final ({InternetAddress host, int port})? proxy = - AppConfig.hasFeature(AppFeature.tor) && prefs.useTor && !node.forceNoTor - ? TorService.sharedInstance.getProxyInfo() - : null; _setSyncStatus(lib_monero_compat.ConnectingSyncStatus()); try { - if (_requireMutex) { - await _torConnectingLock.protect(() async { - await csWownero.connect( - wallet!, - daemonAddress: "$host:${node.port}", - daemonUsername: node.loginName, - daemonPassword: await node.getPassword(secureStorageInterface), - trusted: node.trusted ?? false, - useSSL: node.useSSL, - socksProxyAddress: node.forceNoTor - ? null - : proxy == null - ? null - : "${proxy.host.address}:${proxy.port}", - ); - }); - } else { - await csWownero.connect( - wallet!, - daemonAddress: "$host:${node.port}", - daemonUsername: node.loginName, - daemonPassword: await node.getPassword(secureStorageInterface), - trusted: node.trusted ?? false, - useSSL: node.useSSL, - socksProxyAddress: node.forceNoTor - ? null - : proxy == null - ? null - : "${proxy.host.address}:${proxy.port}", - ); + if (!isCurrent() || wallet == null) { + return; + } + + final ({InternetAddress host, int port})? proxy = + AppConfig.hasFeature(AppFeature.tor) && + prefs.useTor && + !node.forceNoTor + ? TorService.sharedInstance.getProxyInfo() + : null; + await csWownero.connect( + wallet!, + daemonAddress: "$host:${node.port}", + daemonUsername: node.loginName, + daemonPassword: await node.getPassword(secureStorageInterface), + trusted: node.trusted ?? false, + useSSL: node.useSSL, + socksProxyAddress: proxy == null + ? null + : "${proxy.host.address}:${proxy.port}", + ); + + if (!isCurrent() || wallet == null) { + return; } csWownero.startSyncing(wallet!); csWownero.startListeners(wallet!); csWownero.startAutoSaving(wallet!); + _nativeNeedsReconnect = false; _setSyncStatus(lib_monero_compat.ConnectedSyncStatus()); } catch (e, s) { _setSyncStatus(lib_monero_compat.FailedSyncStatus()); @@ -566,8 +648,6 @@ abstract class LibWowneroWallet stackTrace: s, ); } - - return; } @override @@ -750,6 +830,14 @@ abstract class LibWowneroWallet @override Future exit() async { Logging.instance.i("exit called on $wallet!"); + await _lifecycle.close( + stopEventSources: _stopEventSources, + closeNative: _exitNative, + ); + } + + Future _exitNative() async { + _nativeNeedsReconnect = true; if (wallet != null) { csWownero.stopAutoSaving(wallet!); csWownero.stopListeners(wallet!); @@ -1524,7 +1612,9 @@ abstract class LibWowneroWallet // ============== View only ================================================== @override - Future recoverViewOnly() async { + Future recoverViewOnly() => _lifecycle.replaceNative(_recoverViewOnly); + + Future _recoverViewOnly() async { await refreshMutex.protect(() async { final data = await getViewOnlyWalletData() as CryptonoteViewOnlyWalletData; @@ -1555,8 +1645,8 @@ abstract class LibWowneroWallet height: height, ); - if (this.wallet == null) { - await exit(); + if (this.wallet != null) { + await _exitNative(); } this.wallet = wallet; @@ -1580,7 +1670,7 @@ abstract class LibWowneroWallet isar: mainDB.isar, ); - await updateNode(); + await _updateNode(() => _lifecycle.allowsNodeUpdates); _setListener(); unawaited(csWownero.rescanBlockchain(this.wallet!)); @@ -1605,6 +1695,11 @@ abstract class LibWowneroWallet StreamSubscription? _torStatusListener; StreamSubscription? _torPreferenceListener; - final Mutex _torConnectingLock = Mutex(); - bool _requireMutex = false; + final _torTransitionGate = CryptonoteTorTransitionGate(); + + final _lifecycle = CryptonoteWalletLifecycle(); + + /// True once the native daemon session was torn down (exit or failed open) + /// so the next open() reconnects instead of trusting stale settings. + bool _nativeNeedsReconnect = false; } diff --git a/test/util/isar_test_core.dart b/test/util/isar_test_core.dart new file mode 100644 index 0000000000..f66b607fcd --- /dev/null +++ b/test/util/isar_test_core.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; + +import 'package:isar_community/isar.dart'; + +/// Points Isar at the core binary bundled in `isar_community_flutter_libs` so +/// tests can open a real Isar anywhere `flutter pub get` has run. Isar's own +/// initialisation only tries `dlopen("libisar.so")` and `/libisar.so`, +/// neither of which exists on a fresh checkout or on CI. +Future initializeIsarCoreForTests() async { + final packageConfig = File(".dart_tool/package_config.json"); + final json = + jsonDecode(await packageConfig.readAsString()) as Map; + final packages = (json["packages"] as List).cast>(); + final libs = packages.firstWhere( + (p) => p["name"] == "isar_community_flutter_libs", + ); + // rootUri has no trailing slash; without one, resolve() would replace the + // last path segment instead of descending into it. + var rootUri = libs["rootUri"] as String; + if (!rootUri.endsWith("/")) { + rootUri = "$rootUri/"; + } + final root = packageConfig.absolute.parent.uri.resolve(rootUri); + + final String? relative = switch (Abi.current()) { + Abi.linuxX64 || Abi.linuxArm64 => "linux/libisar.so", + Abi.macosX64 || Abi.macosArm64 => "macos/libisar.dylib", + Abi.windowsX64 || Abi.windowsArm64 => "windows/libisar.dll", + _ => null, + }; + if (relative == null) { + throw UnsupportedError("No bundled Isar core for ${Abi.current()}"); + } + + await Isar.initializeIsarCore( + libraries: {Abi.current(): root.resolve(relative).toFilePath()}, + ); +} diff --git a/test/wallets/lib_monero_wallet_lifecycle_test.dart b/test/wallets/lib_monero_wallet_lifecycle_test.dart new file mode 100644 index 0000000000..0a4ac7aee3 --- /dev/null +++ b/test/wallets/lib_monero_wallet_lifecycle_test.dart @@ -0,0 +1,407 @@ +// Wallet-level lifecycle tests for the Monero family. The native cs_monero +// layer is faked so no native libraries are needed; everything above it — +// LibMoneroWallet, the lifecycle coordinator, the Tor listeners and a real +// Isar for mainDB — is the production code. + +import 'dart:io'; + +import 'package:compat/compat.dart' as lib_monero_compat; +import 'package:cs_monero/cs_monero.dart' as cs; +import 'package:flutter_test/flutter_test.dart'; +import 'package:isar_community/isar.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/transaction.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import 'package:stackwallet/models/node_model.dart'; +import 'package:stackwallet/services/event_bus/events/global/tor_status_changed_event.dart'; +import 'package:stackwallet/services/event_bus/global_event_bus.dart'; +import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/wallet/impl/monero_wallet.dart'; +import 'package:stackwallet/wl_gen/interfaces/cs_salvium_interface.dart' + show WrappedWallet; + +import '../util/isar_test_core.dart'; + +class FakeCsWallet implements cs.Wallet { + FakeCsWallet(this.calls); + + final List calls; + final List _listeners = []; + + @override + void addListener(cs.WalletListener listener) => _listeners.add(listener); + + @override + void removeListener(cs.WalletListener listener) => + _listeners.remove(listener); + + @override + List getListeners() => List.unmodifiable(_listeners); + + @override + Future startListeners() async => calls.add("startListeners"); + + @override + Future stopListeners() async => calls.add("stopListeners"); + + @override + void startAutoSaving() => calls.add("startAutoSaving"); + + @override + void stopAutoSaving() => calls.add("stopAutoSaving"); + + @override + Future startSyncing({ + Duration interval = const Duration(seconds: 20), + }) async => calls.add("startSyncing"); + + @override + Future stopSyncing() async => calls.add("stopSyncing"); + + @override + Future save() async => calls.add("save"); + + @override + bool isClosed() => false; + + @override + Future connect({ + required String daemonAddress, + required bool trusted, + String? daemonUsername, + String? daemonPassword, + bool useSSL = false, + bool isLightWallet = false, + String? socksProxyAddress, + }) async { + calls.add("connect:$daemonAddress:${socksProxyAddress ?? "clearnet"}"); + return true; + } + + @override + Future> getOutputs({ + bool includeSpent = false, + bool refresh = false, + }) async { + calls.add("getOutputs"); + return []; + } + + @override + Future getBalance({int accountIndex = 0}) async => BigInt.zero; + + @override + Future getUnlockedBalance({int accountIndex = 0}) async => + BigInt.zero; + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError("FakeCsWallet: ${invocation.memberName}"); +} + +class FakeSecureStorage implements SecureStorageInterface { + final Map _store = {}; + + @override + dynamic get store => null; + + @override + Future read({ + required String key, + iOptions, + aOptions, + lOptions, + webOptions, + mOptions, + wOptions, + }) async { + if (key.endsWith("_nodePW")) return null; + return _store[key] ?? "password"; + } + + @override + Future write({ + required String key, + required String? value, + iOptions, + aOptions, + lOptions, + webOptions, + mOptions, + wOptions, + }) async { + _store[key] = value; + } + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError("FakeSecureStorage: ${invocation.memberName}"); +} + +NodeModel makeNode({required bool torEnabled, required bool clearnetEnabled}) => + NodeModel( + host: "https://node.example", + port: 18081, + name: "test", + id: "test-node", + useSSL: true, + enabled: true, + coinName: "monero", + isFailover: false, + isDown: false, + torEnabled: torEnabled, + clearnetEnabled: clearnetEnabled, + isPrimary: true, + trusted: true, + ); + +const kAddress = + "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9" + "mtmXCqY45DJ852K5Jv2684Rge"; + +class TestMoneroWallet extends MoneroWallet { + TestMoneroWallet(this._id, this.node) : super(CryptoCurrencyNetwork.main) { + _info = WalletInfo( + walletId: _id, + name: "test wallet", + mainAddressType: AddressType.cryptonote, + coinName: "monero", + cachedReceivingAddress: kAddress, + ); + } + + final String _id; + NodeModel node; + late final WalletInfo _info; + final calls = []; + late final fakeNative = FakeCsWallet(calls); + int updateNodeCalls = 0; + + int get connectCount => calls.where((e) => e.startsWith("connect:")).length; + + @override + String get walletId => _id; + + @override + WalletInfo get info => _info; + + @override + NodeModel getCurrentNode() => node; + + @override + Future pathForWallet({ + required String name, + required lib_monero_compat.WalletType type, + }) async => "/nonexistent/$name"; + + @override + Future loadWallet({ + required String path, + required String password, + }) async { + calls.add("loadWallet"); + return WrappedWallet(fakeNative); + } + + @override + Future getCurrentReceivingAddress() async => Address( + walletId: _id, + derivationIndex: 0, + derivationPath: null, + value: kAddress, + publicKey: [], + type: AddressType.cryptonote, + subType: AddressSubType.receiving, + ); + + @override + Future refresh() async {} + + @override + Future updateNode() { + updateNodeCalls++; + return super.updateNode(); + } +} + +Future pump([int ms = 60]) => + Future.delayed(Duration(milliseconds: ms)); + +Future waitFor(bool Function() condition, {int timeoutMs = 5000}) async { + final stopwatch = Stopwatch()..start(); + while (!condition()) { + if (stopwatch.elapsedMilliseconds > timeoutMs) { + fail("timed out waiting for condition"); + } + await pump(5); + } +} + +void fireTorPreferenceChanged(bool enabled) => GlobalEventBus.instance.fire( + TorPreferenceChangedEvent( + status: enabled ? TorStatus.enabled : TorStatus.disabled, + message: "test", + ), +); + +void main() { + late Directory tempDir; + late Isar isar; + var walletCount = 0; + + setUpAll(() async { + await initializeIsarCoreForTests(); + tempDir = await Directory.systemTemp.createTemp("monero-lifecycle-test-"); + isar = await Isar.open( + [ + UTXOSchema, + AddressSchema, + TransactionSchema, + TransactionV2Schema, + WalletInfoSchema, + ], + directory: tempDir.path, + inspector: false, + name: "monero-lifecycle-test", + ); + await MainDB.instance.initMainDB(mock: isar); + }); + + tearDownAll(() async { + await isar.close(deleteFromDisk: true); + await tempDir.delete(recursive: true); + }); + + final wallets = []; + + tearDown(() async { + // A failing test must not leave event bus listeners attached. + for (final wallet in wallets) { + await wallet.exit(); + } + wallets.clear(); + }); + + TestMoneroWallet makeWallet({bool torOnlyNode = false}) { + final wallet = TestMoneroWallet( + "wallet-${walletCount++}", + makeNode(torEnabled: true, clearnetEnabled: !torOnlyNode), + ); + wallet.mainDB = MainDB.instance; + wallet.secureStorageInterface = FakeSecureStorage(); + wallet.prefs = Prefs.instance; // useTor defaults to false + wallets.add(wallet); + return wallet; + } + + test( + "a wallet that was never opened ignores Tor preference changes", + () async { + // useTor is false and the node is Tor only, so a node update here would + // throw the TOR/clearnet mismatch out of the event bus listener. + final wallet = makeWallet(torOnlyNode: true); + + fireTorPreferenceChanged(true); + await pump(200); + + expect(wallet.updateNodeCalls, 0); + expect(wallet.calls, isEmpty); + }, + ); + + test( + "reopening after a Tor preference change reconnects the native wallet", + () async { + final wallet = makeWallet(); + await wallet.open(); + expect( + wallet.calls, + containsAllInOrder([ + "loadWallet", + "connect:node.example:18081:clearnet", + "startSyncing", + ]), + ); + + await wallet.exit(); + wallet.calls.clear(); + + // The preference flips while the wallet is exited but still loaded, so no + // listener is attached to react to it. + fireTorPreferenceChanged(true); + await pump(200); + expect(wallet.connectCount, 0); + + await wallet.open(); + expect( + wallet.connectCount, + 1, + reason: "the reopened wallet must not keep its stale daemon session", + ); + }, + ); + + test( + "reopening after a failed open reconnects to the corrected node", + () async { + final wallet = makeWallet(torOnlyNode: true); + + await expectLater( + wallet.open(), + throwsA(predicate((e) => "$e".contains("mismatch"))), + ); + expect(wallet.connectCount, 0); + + // The node management UI calls updateNode() on every wallet of the + // coin; this one is closed, so the update is rejected and the reopen + // has to cover it. + wallet.node = makeNode(torEnabled: true, clearnetEnabled: true); + await wallet.updateNode(); + expect(wallet.connectCount, 0); + + await wallet.open(); + expect(wallet.connectCount, 1); + }, + ); + + test("concurrent open() calls load and connect exactly once", () async { + final wallet = makeWallet(); + + await Future.wait([wallet.open(), wallet.open(), wallet.open()]); + + expect(wallet.calls.where((e) => e == "loadWallet").length, 1); + expect(wallet.connectCount, 1); + }); + + test("exit() requested during open() rejects later node updates", () async { + final wallet = makeWallet(); + + final open = wallet.open(); + final exit = wallet.exit(); + await Future.wait([open, exit]); + wallet.calls.clear(); + + await wallet.updateNode(); + expect( + wallet.connectCount, + 0, + reason: "exit() was requested after open(); the wallet is not in use", + ); + }); + + test("a Tor preference change while open reconnects immediately", () async { + final wallet = makeWallet(); + await wallet.open(); + expect(wallet.connectCount, 1); + + fireTorPreferenceChanged(true); + await waitFor(() => wallet.connectCount == 2); + + expect(wallet.updateNodeCalls, 1); + }); +}