From 186247d84966fd36ca7a46b59d21c20ccf37c84c Mon Sep 17 00:00:00 2001 From: David Adegoke <64401859+Blazebrain@users.noreply.github.com> Date: Thu, 31 Jul 2025 15:24:54 +0100 Subject: [PATCH] CW-1078: Electrum Wallets not reconencting correctly if the OS closes the connection (#2405) * fix: Electrum Wallets not reconencting correctly if the OS closes the connection * fix: Electrum connectivity bug. Enahnced checks and reconnect when needed, and Socket health logs page for logs * fix: Add extra checks * chore: Migrate to static log list instead of file storage * fix: Electrum connectivity review comments This change: - Adds reset count for used trusted nodes - Adds context guards for ScaffoldMessenger calls * fix: Reset the state for _resetCount when there is a wallet switch --- cw_bitcoin/lib/electrum.dart | 41 ++- cw_bitcoin/lib/electrum_wallet.dart | 155 +++++++- cw_core/lib/utils/socket_health_logger.dart | 88 +++++ cw_core/lib/wallet_base.dart | 5 + .../components/common_test_flows.dart | 2 +- lib/core/node_switching_service.dart | 17 + lib/di.dart | 5 + ...39_wallet_utils.dart => wallet_utils.dart} | 11 + lib/router.dart | 6 + lib/routes.dart | 1 + .../screens/dev/socket_health_logs_page.dart | 333 ++++++++++++++++++ .../new_wallet/new_wallet_type_page.dart | 2 +- lib/src/screens/root/root.dart | 26 +- .../screens/settings/other_settings_page.dart | 6 + .../dev/socket_health_logs_view_model.dart | 57 +++ .../wallet_groups_display_view_model.dart | 2 +- 16 files changed, 740 insertions(+), 17 deletions(-) create mode 100644 cw_core/lib/utils/socket_health_logger.dart rename lib/reactions/{bip39_wallet_utils.dart => wallet_utils.dart} (72%) create mode 100644 lib/src/screens/dev/socket_health_logs_page.dart create mode 100644 lib/view_model/dev/socket_health_logs_view_model.dart diff --git a/cw_bitcoin/lib/electrum.dart b/cw_bitcoin/lib/electrum.dart index 4cd42a0fe..bd4a36c72 100644 --- a/cw_bitcoin/lib/electrum.dart +++ b/cw_bitcoin/lib/electrum.dart @@ -43,7 +43,7 @@ class ElectrumClient { static const connectionTimeout = Duration(seconds: 5); static const aliveTimerDuration = Duration(seconds: 4); - bool get isConnected => _isConnected; + bool get isConnected => _isConnected && socket != null; ProxySocket? socket; void Function(ConnectionStatus)? onConnectionStatusChange; int _id; @@ -69,6 +69,9 @@ class ElectrumClient { Future connect({required String host, required int port}) async { _setConnectionStatus(ConnectionStatus.connecting); + // Reset internal state to ensure clean connection + _resetInternalState(); + try { await socket?.close(); } catch (_) {} @@ -76,7 +79,8 @@ class ElectrumClient { final ssl = !(useSSL == false || (useSSL == null && uri.toString().contains("btc-electrum"))); try { - socket = await ProxyWrapper().getSocksSocket(ssl, host, port, connectionTimeout: connectionTimeout); + socket = await ProxyWrapper() + .getSocksSocket(ssl, host, port, connectionTimeout: connectionTimeout); } catch (e) { printV("connect: $e"); if (e is HandshakeException) { @@ -120,6 +124,7 @@ class ElectrumClient { printV(errorMsg); unterminatedString = ''; socket = null; + _setConnectionStatus(ConnectionStatus.disconnected); }, onDone: () { printV("SOCKET CLOSED!!!!!"); @@ -209,11 +214,11 @@ class ElectrumClient { if (result is Map) { return result; } - + if (throwOnError) { throw Exception('Invalid response format for getBalance'); } - + return {}; } catch (e) { if (throwOnError) { @@ -446,9 +451,8 @@ class ElectrumClient { Future call( {required String method, List params = const [], Function(int)? idCallback}) async { - if (socket == null) { - return null; - } + if (!_isConnected || socket == null) return null; + final completer = Completer(); _id += 1; final id = _id; @@ -462,9 +466,8 @@ class ElectrumClient { Future callWithTimeout( {required String method, List params = const [], int timeout = 5000}) async { try { - if (socket == null) { - return null; - } + if (!_isConnected || socket == null) return null; + final completer = Completer(); _id += 1; final id = _id; @@ -490,6 +493,22 @@ class ElectrumClient { socket = null; } catch (_) {} onConnectionStatusChange = null; + // Reset internal state when closing + _resetInternalStateCompletely(); + } + + void _resetInternalState() { + // Only clears errors and unterminated string, leaves tasks or reset ID + // This preserves active subscriptions while clearing error state + _errors.clear(); + unterminatedString = ''; + } + + void _resetInternalStateCompletely() { + _id = 0; + _tasks.clear(); + _errors.clear(); + unterminatedString = ''; } void _registryTask(int id, Completer completer) => @@ -587,6 +606,8 @@ class ElectrumClient { } String getErrorMessage(int id) => _errors[id.toString()] ?? ''; + + bool get isInternalStateConsistent => _errors.isEmpty; } // FIXME: move me diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 5e5aa61a5..e12a9d4b1 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -50,6 +50,7 @@ import 'package:mobx/mobx.dart'; import 'package:rxdart/subjects.dart'; import 'package:sp_scanner/sp_scanner.dart'; import 'package:hex/hex.dart'; +import 'package:cw_core/utils/socket_health_logger.dart'; part 'electrum_wallet.g.dart'; @@ -2355,13 +2356,14 @@ abstract class ElectrumWalletBase Future checkNodeHealth() async { try { final addresses = walletAddresses.allAddresses - .where((address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress) + .where( + (address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress) .toList(); - + if (addresses.isEmpty) { return false; } - + final firstAddress = addresses.first; final sh = firstAddress.getScriptHash(network); await electrumClient.getBalance(sh, throwOnError: true); @@ -2592,6 +2594,153 @@ abstract class ElectrumWalletBase final amountInt = int.parse(amount); return bitcoinAmountToString(amount: amountInt); } + + /// Checks the health of the socket connection + /// and triggers a full reconnection if needed + @override + Future checkSocketHealth() async { + try { + SocketHealthLogger().logHealthCheck( + walletType: type, + walletName: name, + syncStatus: syncStatus.toString(), + wasReconnected: false, + trigger: 'socket_health_check_start', + ); + + if (!electrumClient.isConnected || !electrumClient.isInternalStateConsistent) { + if (!electrumClient.isConnected) { + SocketHealthLogger().logHealthCheck( + walletType: type, + walletName: name, + isHealthy: false, + syncStatus: syncStatus.toString(), + wasReconnected: false, + trigger: 'socket_health_check_socket_not_connected', + ); + } + + if (!electrumClient.isInternalStateConsistent) { + SocketHealthLogger().logHealthCheck( + walletType: type, + walletName: name, + isHealthy: false, + syncStatus: syncStatus.toString(), + wasReconnected: false, + trigger: 'socket_health_check_internal_state_inconsistent', + ); + } + + await _performFullReconnection(); + + SocketHealthLogger().logHealthCheck( + walletType: type, + walletName: name, + isHealthy: true, + syncStatus: syncStatus.toString(), + wasReconnected: true, + trigger: + 'socket_health_check_reconnection_success_for_unhealthy_basic_check_or_internal_state_inconsistent', + ); + + return true; + } + + // Make a call to the server to check if the connection is healthy + // If the call fails, we need to reconnect + try { + final result = await electrumClient.call( + method: 'server.version', + params: ['', '1.4'], + ); + + if (result == null) { + throw Exception('Call mechanism test returned null'); + } + + SocketHealthLogger().logHealthCheck( + walletType: type, + walletName: name, + isHealthy: true, + syncStatus: syncStatus.toString(), + wasReconnected: false, + trigger: 'socket_health_check_server_state_ok', + ); + + return true; + } catch (e) { + SocketHealthLogger().logHealthCheck( + walletType: type, + walletName: name, + isHealthy: false, + error: e.toString(), + syncStatus: syncStatus.toString(), + wasReconnected: false, + trigger: 'socket_health_check_server_state_failed', + ); + + await _performFullReconnection(); + + SocketHealthLogger().logHealthCheck( + walletType: type, + walletName: name, + isHealthy: true, + syncStatus: syncStatus.toString(), + wasReconnected: true, + trigger: 'socket_health_check_reconnection_success_for_server_state_failed', + ); + + return true; + } + } catch (e) { + return false; + } + } + + Future _performFullReconnection() async { + try { + SocketHealthLogger().logHealthCheck( + walletType: type, + walletName: name, + syncStatus: syncStatus.toString(), + wasReconnected: true, + trigger: 'full_reconnection_start', + ); + + await _receiveStream?.cancel(); + + await electrumClient.close(); + + if (node != null) { + electrumClient.onConnectionStatusChange = _onConnectionStatusChange; + + await electrumClient.connectToUri(node!.uri, useSSL: node!.useSSL); + + await startSync(); + + SocketHealthLogger().logHealthCheck( + walletType: type, + walletName: name, + isHealthy: true, + syncStatus: syncStatus.toString(), + wasReconnected: true, + trigger: 'full_reconnection_success', + ); + } + } catch (e) { + SocketHealthLogger().logHealthCheck( + walletType: type, + walletName: name, + isHealthy: false, + error: e.toString(), + syncStatus: syncStatus.toString(), + wasReconnected: false, + trigger: 'full_reconnection_failed', + ); + + syncStatus = FailedSyncStatus(); + } + } } class ScanNode { diff --git a/cw_core/lib/utils/socket_health_logger.dart b/cw_core/lib/utils/socket_health_logger.dart new file mode 100644 index 000000000..60d31178f --- /dev/null +++ b/cw_core/lib/utils/socket_health_logger.dart @@ -0,0 +1,88 @@ +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_type.dart'; + +class SocketHealthLogEntry { + final DateTime timestamp; + final WalletType? walletType; + final String? walletName; + final bool? isHealthy; + final String? error; + final String? syncStatus; + final bool wasReconnected; + final String trigger; + + SocketHealthLogEntry({ + required this.timestamp, + required this.walletType, + required this.walletName, + required this.isHealthy, + this.error, + this.syncStatus, + required this.wasReconnected, + required this.trigger, + }); + + String toLogString() { + final dateStr = timestamp.toIso8601String(); + final status = isHealthy != null ? (isHealthy! ? 'HEALTHY' : 'UNHEALTHY') : 'UNKNOWN'; + final reconnect = wasReconnected ? 'RECONNECTED' : 'NO_RECONNECT'; + final errorStr = error != null ? ' | Error: $error' : ''; + final syncStr = syncStatus != null ? ' | Sync: $syncStatus' : ''; + + return '[$dateStr] $trigger | ${walletType?.name ?? 'UNKNOWN'} | $walletName | $status | $reconnect$errorStr$syncStr'; + } +} + +class SocketHealthLogger { + static final SocketHealthLogger _instance = SocketHealthLogger._internal(); + factory SocketHealthLogger() => _instance; + SocketHealthLogger._internal(); + + static const int _maxLogs = 100; + + static final List logs = []; + + void addLog(SocketHealthLogEntry entry) { + try { + printV('Adding log entry: ${entry.toLogString()}'); + + logs.add(entry); + + if (logs.length > _maxLogs * 2) { + final excessCount = logs.length - _maxLogs; + logs.removeRange(0, excessCount); + } + } catch (e) { + printV('Failed to add log: $e'); + } + } + + void clearLogs() { + logs.clear(); + printV('Cleared all logs'); + } + + /// Log a health check with the given parameters + void logHealthCheck({ + WalletType? walletType, + String? walletName, + bool? isHealthy, + String? error, + String? syncStatus, + required bool wasReconnected, + required String trigger, + }) { + addLog( + SocketHealthLogEntry( + timestamp: DateTime.now(), + walletType: walletType, + walletName: walletName, + isHealthy: isHealthy, + error: error, + syncStatus: syncStatus, + wasReconnected: wasReconnected, + trigger: trigger, + ), + ); + } +} diff --git a/cw_core/lib/wallet_base.dart b/cw_core/lib/wallet_base.dart index 0d7650e15..7d0fc4f04 100644 --- a/cw_core/lib/wallet_base.dart +++ b/cw_core/lib/wallet_base.dart @@ -113,6 +113,11 @@ abstract class WalletBase true; + /// Check if the wallet's socket connection is healthy. + /// Returns true if the connection is alive, false otherwise. + /// Default implementation returns true (no-op for wallets without socket connections). + Future checkSocketHealth() async => true; + /// This is used to check if the current node is healthy by making a lightweight RPC call /// Each wallet implementation should override this to make a single, efficient call /// Returns true if the node is healthy, false otherwise diff --git a/integration_test/components/common_test_flows.dart b/integration_test/components/common_test_flows.dart index 355c76742..5229df3a2 100644 --- a/integration_test/components/common_test_flows.dart +++ b/integration_test/components/common_test_flows.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:cake_wallet/entities/seed_type.dart'; -import 'package:cake_wallet/reactions/bip39_wallet_utils.dart'; +import 'package:cake_wallet/reactions/wallet_utils.dart'; import 'package:cake_wallet/wallet_types.g.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:flutter/material.dart'; diff --git a/lib/core/node_switching_service.dart b/lib/core/node_switching_service.dart index ae5d21762..92574944c 100644 --- a/lib/core/node_switching_service.dart +++ b/lib/core/node_switching_service.dart @@ -15,6 +15,15 @@ class NodeSwitchingService { static const int _healthCheckIntervalSeconds = 30; + // The number of times we want to reset the overall used trusted nodes list. + // We don't want an infinite loop if all trusted nodes are down. + static const int _usedTrustedNodeResetCount = 2; + + // State to manage the reset count + int _resetCount = 0; + + String walletName = ''; + Timer? _healthCheckTimer; bool _isSwitching = false; @@ -64,6 +73,10 @@ class NodeSwitchingService { Future _switchToNextTrustedNode() async { _isSwitching = true; + if (walletName.isNotEmpty && (walletName != appStore.wallet!.name)) _resetCount = 0; + + walletName = appStore.wallet!.name; + try { final walletType = appStore.wallet!.type; final currentNode = settingsStore.getCurrentNode(walletType); @@ -98,6 +111,10 @@ class NodeSwitchingService { // If all trusted nodes have been used, reset the list and start over if (nextNode == null) { printV('All trusted nodes have been tried, resetting and starting over'); + _resetCount++; + + if (_resetCount > _usedTrustedNodeResetCount) return; + _usedNodeKeys[walletType]!.clear(); nextNode = trustedNodes.first; } diff --git a/lib/di.dart b/lib/di.dart index 7cf32ed0b..5c9a44d63 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -278,6 +278,8 @@ import 'package:cake_wallet/view_model/dev/background_sync_logs_view_model.dart' import 'package:cake_wallet/src/screens/dev/background_sync_logs_page.dart'; import 'package:cake_wallet/core/trade_monitor.dart'; import 'package:cake_wallet/core/reset_service.dart'; +import 'package:cake_wallet/view_model/dev/socket_health_logs_view_model.dart'; +import 'package:cake_wallet/src/screens/dev/socket_health_logs_page.dart'; final getIt = GetIt.instance; @@ -1504,6 +1506,9 @@ Future setup({ getIt.registerFactory(() => DevBackgroundSyncLogsPage(getIt.get())); + getIt.registerFactory(() => SocketHealthLogsViewModel()); + getIt.registerFactory(() => DevSocketHealthLogsPage(getIt.get())); + getIt.registerFactory(() => DevNetworkRequests()); getIt.registerFactory(() => StartTorPage(StartTorViewModel(),)); diff --git a/lib/reactions/bip39_wallet_utils.dart b/lib/reactions/wallet_utils.dart similarity index 72% rename from lib/reactions/bip39_wallet_utils.dart rename to lib/reactions/wallet_utils.dart index a46adb6b1..d560f01bb 100644 --- a/lib/reactions/bip39_wallet_utils.dart +++ b/lib/reactions/wallet_utils.dart @@ -21,3 +21,14 @@ bool isBIP39Wallet(WalletType walletType) { return false; } } + +bool isElectrumWallet(WalletType walletType) { + switch (walletType) { + case WalletType.bitcoin: + case WalletType.litecoin: + case WalletType.bitcoinCash: + return true; + default: + return false; + } +} \ No newline at end of file diff --git a/lib/router.dart b/lib/router.dart index 1d9691fa3..ad335d348 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -41,6 +41,7 @@ import 'package:cake_wallet/src/screens/dev/network_requests.dart'; import 'package:cake_wallet/src/screens/dev/secure_preferences_page.dart'; import 'package:cake_wallet/src/screens/dev/shared_preferences_page.dart'; import 'package:cake_wallet/src/screens/dev/background_sync_logs_page.dart'; +import 'package:cake_wallet/src/screens/dev/socket_health_logs_page.dart'; import 'package:cake_wallet/src/screens/disclaimer/disclaimer_page.dart'; import 'package:cake_wallet/src/screens/disclaimer/third_party_disclaimer_page.dart'; import 'package:cake_wallet/src/screens/exchange/exchange_page.dart'; @@ -907,6 +908,11 @@ Route createRoute(RouteSettings settings) { builder: (_) => getIt.get(), ); + case Routes.devSocketHealthLogs: + return CupertinoPageRoute( + builder: (_) => getIt.get(), + ); + case Routes.devNetworkRequests: return MaterialPageRoute( builder: (_) => getIt.get(), diff --git a/lib/routes.dart b/lib/routes.dart index 8d37becfa..35cabcfc9 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -119,6 +119,7 @@ class Routes { static const devSharedPreferences = '/dev/shared_preferences'; static const devSecurePreferences = '/dev/secure_preferences'; static const devBackgroundSyncLogs = '/dev/background_sync_logs'; + static const devSocketHealthLogs = '/dev/socket_health_logs'; static const devNetworkRequests = '/dev/network_requests'; static const signPage = '/sign_page'; diff --git a/lib/src/screens/dev/socket_health_logs_page.dart b/lib/src/screens/dev/socket_health_logs_page.dart new file mode 100644 index 000000000..ddd61f4c4 --- /dev/null +++ b/lib/src/screens/dev/socket_health_logs_page.dart @@ -0,0 +1,333 @@ +import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/view_model/dev/socket_health_logs_view_model.dart'; +import 'package:cw_core/utils/socket_health_logger.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:intl/intl.dart'; +import 'package:cake_wallet/store/app_store.dart'; +import 'package:cake_wallet/reactions/wallet_utils.dart'; + +class DevSocketHealthLogsPage extends BasePage { + final SocketHealthLogsViewModel viewModel; + + DevSocketHealthLogsPage(this.viewModel) { + viewModel.loadLogs(); + } + + @override + String? get title => "[dev] socket health logs"; + + @override + Widget body(BuildContext context) { + return Observer( + builder: (_) { + if (viewModel.logs.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("No logs available"), + SizedBox(height: 16), + ElevatedButton( + onPressed: () => viewModel.loadLogs(), + child: Text("Load Logs"), + ), + ], + ), + ); + } + + return Column( + children: [ + _StatsCard(viewModel), + Expanded( + child: _LogsList(viewModel), + ), + _ActionButtons(viewModel), + ], + ); + }, + ); + } +} + +class _StatsCard extends StatelessWidget { + const _StatsCard(this.viewModel); + + final SocketHealthLogsViewModel viewModel; + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.all(8), + child: Padding( + padding: EdgeInsets.all(16), + child: Observer( + builder: (_) => Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _StatItem('Total', viewModel.totalLogs.toString(), Colors.blue), + _StatItem('Healthy', viewModel.healthyLogs.toString(), Colors.green), + _StatItem('Unhealthy', viewModel.unhealthyLogs.toString(), Colors.red), + ], + ), + SizedBox(height: 8), + if (viewModel.error != null) + Text( + 'Error: ${viewModel.error}', + style: TextStyle(fontSize: 12, color: Colors.red), + ), + ], + ), + ), + ), + ); + } +} + +class _StatItem extends StatelessWidget { + const _StatItem(this.label, this.value, this.color); + + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text( + value, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ); + } +} + +class _LogsList extends StatelessWidget { + const _LogsList(this.viewModel); + + final SocketHealthLogsViewModel viewModel; + + @override + Widget build(BuildContext context) { + return Observer( + builder: (_) { + final logs = viewModel.logs; + if (logs.isEmpty) { + return Center(child: Text("No logs available")); + } + + return ListView.builder( + itemCount: logs.length, + itemBuilder: (context, index) { + final log = logs[index]; + return _LogItem(log); + }, + ); + }, + ); + } +} + +class _LogItem extends StatelessWidget { + const _LogItem(this.log); + + final SocketHealthLogEntry log; + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat('MM-dd HH:mm:ss.SSS'); + final isHealthy = log.isHealthy; + + return Card( + margin: EdgeInsets.symmetric(horizontal: 8, vertical: 2), + color: isHealthy != null ? (isHealthy ? Colors.green[50] : Colors.red[50]) : Colors.grey[50], + child: ListTile( + title: Text( + log.toLogString(), + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontFamily: 'Monospace', + fontSize: 11, + color: isHealthy != null ? (isHealthy ? Colors.green : Colors.red) : Colors.grey, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${dateFormat.format(log.timestamp)} | ${log.trigger}', + style: TextStyle(fontSize: 10, color: Colors.grey[600]), + ), + if (log.error != null) + Text( + 'Error: ${log.error}', + style: TextStyle(fontSize: 10, color: Colors.red), + ), + ], + ), + leading: Icon( + isHealthy != null ? (isHealthy ? Icons.check_circle : Icons.error) : Icons.help, + color: isHealthy != null ? (isHealthy ? Colors.green : Colors.red) : Colors.grey, + ), + ), + ); + } +} + +class _ActionButtons extends StatelessWidget { + const _ActionButtons(this.viewModel); + + final SocketHealthLogsViewModel viewModel; + + void _copyLogsToClipboard(BuildContext context) { + final logsText = viewModel.getLogsAsText(); + Clipboard.setData(ClipboardData(text: logsText)); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Logs copied to clipboard'), + duration: Duration(seconds: 2), + ), + ); + } + } + + void _testEnhancedHealthCheck(BuildContext context) async { + final wallet = getIt.get().wallet; + if (wallet == null || !isElectrumWallet(wallet.type)) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('No Electrum wallet found'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + try { + final isHealthy = await wallet.checkSocketHealth(); + + SocketHealthLogger().logHealthCheck( + walletType: wallet.type, + walletName: wallet.name, + isHealthy: isHealthy, + syncStatus: wallet.syncStatus.toString(), + wasReconnected: true, + trigger: 'manual_enhanced_health_check', + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Enhanced health check completed: ${isHealthy ? "Healthy" : "Unhealthy"}'), + backgroundColor: isHealthy ? Colors.green : Colors.red, + ), + ); + + viewModel.loadLogs(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Enhanced health check failed: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + void _refreshLogs(BuildContext context) { + viewModel.loadLogs(); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Logs refreshed'), + duration: Duration(seconds: 1), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () => viewModel.clearLogs(), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: Text('Clear Logs'), + ), + ), + SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () => _copyLogsToClipboard(context), + child: Text('Copy to Clipboard'), + ), + ), + ], + ), + SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () => _testEnhancedHealthCheck(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.purple, + foregroundColor: Colors.white, + ), + child: Text('Enhanced Health Check'), + ), + ), + ], + ), + SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () => _refreshLogs(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + child: Text('Refresh'), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/src/screens/new_wallet/new_wallet_type_page.dart b/lib/src/screens/new_wallet/new_wallet_type_page.dart index 558ffd123..dbdcd910c 100644 --- a/lib/src/screens/new_wallet/new_wallet_type_page.dart +++ b/lib/src/screens/new_wallet/new_wallet_type_page.dart @@ -3,7 +3,7 @@ import 'package:cake_wallet/core/new_wallet_type_arguments.dart'; import 'dart:io'; import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/reactions/bip39_wallet_utils.dart'; +import 'package:cake_wallet/reactions/wallet_utils.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; import 'package:cake_wallet/src/screens/new_wallet/widgets/select_button.dart'; diff --git a/lib/src/screens/root/root.dart b/lib/src/screens/root/root.dart index a73c20fb8..cfedbc36d 100644 --- a/lib/src/screens/root/root.dart +++ b/lib/src/screens/root/root.dart @@ -5,7 +5,6 @@ import 'package:cake_wallet/core/auth_service.dart'; import 'package:cake_wallet/core/node_switching_service.dart'; import 'package:cake_wallet/core/totp_request_details.dart'; import 'package:cake_wallet/core/trade_monitor.dart'; -import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/utils/device_info.dart'; import 'package:cake_wallet/view_model/link_view_model.dart'; import 'package:cw_core/utils/print_verbose.dart'; @@ -20,6 +19,8 @@ import 'package:cake_wallet/entities/qr_scanner.dart'; import 'package:mobx/mobx.dart'; import 'package:uni_links/uni_links.dart'; import 'package:cake_wallet/src/screens/setup_2fa/setup_2fa_enter_code_page.dart'; +import 'package:cake_wallet/reactions/wallet_utils.dart'; +import 'package:cw_core/utils/socket_health_logger.dart'; class Root extends StatefulWidget { Root({ @@ -169,6 +170,29 @@ class RootState extends State with WidgetsBindingObserver { // Trigger node health check when app resumes widget.nodeSwitchingService.performHealthCheck(); + // Electrum Wallet socket health check and reconnection flow + final wallet = widget.appStore.wallet; + if (wallet != null && isElectrumWallet(wallet.type)) { + SocketHealthLogger().logHealthCheck( + walletType: wallet.type, + walletName: wallet.name, + syncStatus: wallet.syncStatus.toString(), + wasReconnected: false, + trigger: 'app_resume', + ); + + wallet.checkSocketHealth().then((isHealthy) { + SocketHealthLogger().logHealthCheck( + walletType: wallet.type, + walletName: wallet.name, + isHealthy: isHealthy, + syncStatus: wallet.syncStatus.toString(), + wasReconnected: true, + trigger: 'app_resume_socket_health_check', + ); + }); + } + break; default: break; diff --git a/lib/src/screens/settings/other_settings_page.dart b/lib/src/screens/settings/other_settings_page.dart index 78125fbeb..1991ee289 100644 --- a/lib/src/screens/settings/other_settings_page.dart +++ b/lib/src/screens/settings/other_settings_page.dart @@ -93,6 +93,12 @@ class OtherSettingsPage extends BasePage { handler: (BuildContext context) => Navigator.of(context).pushNamed(Routes.devBackgroundSyncLogs), ), + if (FeatureFlag.hasDevOptions) + SettingsCellWithArrow( + title: '[dev] socket health logs', + handler: (BuildContext context) => + Navigator.of(context).pushNamed(Routes.devSocketHealthLogs), + ), if (FeatureFlag.hasDevOptions) SettingsCellWithArrow( title: '[dev] network requests logs', diff --git a/lib/view_model/dev/socket_health_logs_view_model.dart b/lib/view_model/dev/socket_health_logs_view_model.dart new file mode 100644 index 000000000..28157579e --- /dev/null +++ b/lib/view_model/dev/socket_health_logs_view_model.dart @@ -0,0 +1,57 @@ +import 'package:cw_core/utils/socket_health_logger.dart'; +import 'package:mobx/mobx.dart'; + +part 'socket_health_logs_view_model.g.dart'; + +class SocketHealthLogsViewModel = SocketHealthLogsViewModelBase with _$SocketHealthLogsViewModel; + +abstract class SocketHealthLogsViewModelBase with Store { + + @observable + bool isLoading = false; + + @observable + String? error; + + @observable + ObservableList logs = ObservableList(); + + @computed + int get totalLogs => logs.length; + + @computed + int get unhealthyLogs => logs.where((log) => log.isHealthy == false).length; + + @computed + int get healthyLogs => totalLogs - unhealthyLogs; + + String getLogsAsText() { + if (logs.isEmpty) return 'No logs available'; + + final buffer = StringBuffer(); + buffer.writeln('Socket Health Logs'); + buffer.writeln('Generated: ${DateTime.now().toIso8601String()}'); + buffer.writeln('Total logs: $totalLogs'); + buffer.writeln('Unhealthy logs: $unhealthyLogs'); + buffer.writeln('Healthy logs: $healthyLogs'); + buffer.writeln(''); + + for (final log in logs) { + buffer.writeln(log.toLogString()); + } + + return buffer.toString(); + } + + @action + void loadLogs() { + this.logs.clear(); + this.logs.addAll(SocketHealthLogger.logs); + } + + @action + void clearLogs() { + SocketHealthLogger().clearLogs(); + this.logs.clear(); + } +} diff --git a/lib/view_model/wallet_groups_display_view_model.dart b/lib/view_model/wallet_groups_display_view_model.dart index ef5b245ba..ff63850a0 100644 --- a/lib/view_model/wallet_groups_display_view_model.dart +++ b/lib/view_model/wallet_groups_display_view_model.dart @@ -1,7 +1,7 @@ import 'package:cake_wallet/core/wallet_loading_service.dart'; import 'package:cake_wallet/entities/wallet_group.dart'; import 'package:cake_wallet/entities/wallet_manager.dart'; -import 'package:cake_wallet/reactions/bip39_wallet_utils.dart'; +import 'package:cake_wallet/reactions/wallet_utils.dart'; import 'package:cake_wallet/store/app_store.dart'; import 'package:cake_wallet/view_model/wallet_list/wallet_list_item.dart'; import 'package:cake_wallet/view_model/wallet_list/wallet_list_view_model.dart';