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

David Adegoke committed Jul 31, 2025 at 15:24 UTC 186247d84966fd36ca7a46b59d21c20ccf37c84c
16 files changed +740 -17
cw_bitcoin/lib/electrum.dart
+31 -10
@@ -43,7 +43,7 @@ class ElectrumClient {
43 static const connectionTimeout = Duration(seconds: 5);
44 static const aliveTimerDuration = Duration(seconds: 4);
45
46 - bool get isConnected => _isConnected;
46 + bool get isConnected => _isConnected && socket != null;
47 ProxySocket? socket;
48 void Function(ConnectionStatus)? onConnectionStatusChange;
49 int _id;
@@ -69,6 +69,9 @@ class ElectrumClient {
69 Future<void> connect({required String host, required int port}) async {
70 _setConnectionStatus(ConnectionStatus.connecting);
71
72 + // Reset internal state to ensure clean connection
73 + _resetInternalState();
74 +
75 try {
76 await socket?.close();
77 } catch (_) {}
@@ -76,7 +79,8 @@ class ElectrumClient {
79
80 final ssl = !(useSSL == false || (useSSL == null && uri.toString().contains("btc-electrum")));
81 try {
79 - socket = await ProxyWrapper().getSocksSocket(ssl, host, port, connectionTimeout: connectionTimeout);
82 + socket = await ProxyWrapper()
83 + .getSocksSocket(ssl, host, port, connectionTimeout: connectionTimeout);
84 } catch (e) {
85 printV("connect: $e");
86 if (e is HandshakeException) {
@@ -120,6 +124,7 @@ class ElectrumClient {
124 printV(errorMsg);
125 unterminatedString = '';
126 socket = null;
127 + _setConnectionStatus(ConnectionStatus.disconnected);
128 },
129 onDone: () {
130 printV("SOCKET CLOSED!!!!!");
@@ -209,11 +214,11 @@ class ElectrumClient {
214 if (result is Map<String, dynamic>) {
215 return result;
216 }
212 -
217 +
218 if (throwOnError) {
219 throw Exception('Invalid response format for getBalance');
220 }
216 -
221 +
222 return <String, dynamic>{};
223 } catch (e) {
224 if (throwOnError) {
@@ -446,9 +451,8 @@ class ElectrumClient {
451
452 Future<dynamic> call(
453 {required String method, List<Object> params = const [], Function(int)? idCallback}) async {
449 - if (socket == null) {
450 - return null;
451 - }
454 + if (!_isConnected || socket == null) return null;
455 +
456 final completer = Completer<dynamic>();
457 _id += 1;
458 final id = _id;
@@ -462,9 +466,8 @@ class ElectrumClient {
466 Future<dynamic> callWithTimeout(
467 {required String method, List<Object> params = const [], int timeout = 5000}) async {
468 try {
465 - if (socket == null) {
466 - return null;
467 - }
469 + if (!_isConnected || socket == null) return null;
470 +
471 final completer = Completer<dynamic>();
472 _id += 1;
473 final id = _id;
@@ -490,6 +493,22 @@ class ElectrumClient {
493 socket = null;
494 } catch (_) {}
495 onConnectionStatusChange = null;
496 + // Reset internal state when closing
497 + _resetInternalStateCompletely();
498 + }
499 +
500 + void _resetInternalState() {
501 + // Only clears errors and unterminated string, leaves tasks or reset ID
502 + // This preserves active subscriptions while clearing error state
503 + _errors.clear();
504 + unterminatedString = '';
505 + }
506 +
507 + void _resetInternalStateCompletely() {
508 + _id = 0;
509 + _tasks.clear();
510 + _errors.clear();
511 + unterminatedString = '';
512 }
513
514 void _registryTask(int id, Completer<dynamic> completer) =>
@@ -587,6 +606,8 @@ class ElectrumClient {
606 }
607
608 String getErrorMessage(int id) => _errors[id.toString()] ?? '';
609 +
610 + bool get isInternalStateConsistent => _errors.isEmpty;
611 }
612
613 // FIXME: move me
cw_bitcoin/lib/electrum_wallet.dart
+152 -3
@@ -50,6 +50,7 @@ import 'package:mobx/mobx.dart';
50 import 'package:rxdart/subjects.dart';
51 import 'package:sp_scanner/sp_scanner.dart';
52 import 'package:hex/hex.dart';
53 +import 'package:cw_core/utils/socket_health_logger.dart';
54
55 part 'electrum_wallet.g.dart';
56
@@ -2355,13 +2356,14 @@ abstract class ElectrumWalletBase
2356 Future<bool> checkNodeHealth() async {
2357 try {
2358 final addresses = walletAddresses.allAddresses
2358 - .where((address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress)
2359 + .where(
2360 + (address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress)
2361 .toList();
2360 -
2362 +
2363 if (addresses.isEmpty) {
2364 return false;
2365 }
2364 -
2366 +
2367 final firstAddress = addresses.first;
2368 final sh = firstAddress.getScriptHash(network);
2369 await electrumClient.getBalance(sh, throwOnError: true);
@@ -2592,6 +2594,153 @@ abstract class ElectrumWalletBase
2594 final amountInt = int.parse(amount);
2595 return bitcoinAmountToString(amount: amountInt);
2596 }
2597 +
2598 + /// Checks the health of the socket connection
2599 + /// and triggers a full reconnection if needed
2600 + @override
2601 + Future<bool> checkSocketHealth() async {
2602 + try {
2603 + SocketHealthLogger().logHealthCheck(
2604 + walletType: type,
2605 + walletName: name,
2606 + syncStatus: syncStatus.toString(),
2607 + wasReconnected: false,
2608 + trigger: 'socket_health_check_start',
2609 + );
2610 +
2611 + if (!electrumClient.isConnected || !electrumClient.isInternalStateConsistent) {
2612 + if (!electrumClient.isConnected) {
2613 + SocketHealthLogger().logHealthCheck(
2614 + walletType: type,
2615 + walletName: name,
2616 + isHealthy: false,
2617 + syncStatus: syncStatus.toString(),
2618 + wasReconnected: false,
2619 + trigger: 'socket_health_check_socket_not_connected',
2620 + );
2621 + }
2622 +
2623 + if (!electrumClient.isInternalStateConsistent) {
2624 + SocketHealthLogger().logHealthCheck(
2625 + walletType: type,
2626 + walletName: name,
2627 + isHealthy: false,
2628 + syncStatus: syncStatus.toString(),
2629 + wasReconnected: false,
2630 + trigger: 'socket_health_check_internal_state_inconsistent',
2631 + );
2632 + }
2633 +
2634 + await _performFullReconnection();
2635 +
2636 + SocketHealthLogger().logHealthCheck(
2637 + walletType: type,
2638 + walletName: name,
2639 + isHealthy: true,
2640 + syncStatus: syncStatus.toString(),
2641 + wasReconnected: true,
2642 + trigger:
2643 + 'socket_health_check_reconnection_success_for_unhealthy_basic_check_or_internal_state_inconsistent',
2644 + );
2645 +
2646 + return true;
2647 + }
2648 +
2649 + // Make a call to the server to check if the connection is healthy
2650 + // If the call fails, we need to reconnect
2651 + try {
2652 + final result = await electrumClient.call(
2653 + method: 'server.version',
2654 + params: ['', '1.4'],
2655 + );
2656 +
2657 + if (result == null) {
2658 + throw Exception('Call mechanism test returned null');
2659 + }
2660 +
2661 + SocketHealthLogger().logHealthCheck(
2662 + walletType: type,
2663 + walletName: name,
2664 + isHealthy: true,
2665 + syncStatus: syncStatus.toString(),
2666 + wasReconnected: false,
2667 + trigger: 'socket_health_check_server_state_ok',
2668 + );
2669 +
2670 + return true;
2671 + } catch (e) {
2672 + SocketHealthLogger().logHealthCheck(
2673 + walletType: type,
2674 + walletName: name,
2675 + isHealthy: false,
2676 + error: e.toString(),
2677 + syncStatus: syncStatus.toString(),
2678 + wasReconnected: false,
2679 + trigger: 'socket_health_check_server_state_failed',
2680 + );
2681 +
2682 + await _performFullReconnection();
2683 +
2684 + SocketHealthLogger().logHealthCheck(
2685 + walletType: type,
2686 + walletName: name,
2687 + isHealthy: true,
2688 + syncStatus: syncStatus.toString(),
2689 + wasReconnected: true,
2690 + trigger: 'socket_health_check_reconnection_success_for_server_state_failed',
2691 + );
2692 +
2693 + return true;
2694 + }
2695 + } catch (e) {
2696 + return false;
2697 + }
2698 + }
2699 +
2700 + Future<void> _performFullReconnection() async {
2701 + try {
2702 + SocketHealthLogger().logHealthCheck(
2703 + walletType: type,
2704 + walletName: name,
2705 + syncStatus: syncStatus.toString(),
2706 + wasReconnected: true,
2707 + trigger: 'full_reconnection_start',
2708 + );
2709 +
2710 + await _receiveStream?.cancel();
2711 +
2712 + await electrumClient.close();
2713 +
2714 + if (node != null) {
2715 + electrumClient.onConnectionStatusChange = _onConnectionStatusChange;
2716 +
2717 + await electrumClient.connectToUri(node!.uri, useSSL: node!.useSSL);
2718 +
2719 + await startSync();
2720 +
2721 + SocketHealthLogger().logHealthCheck(
2722 + walletType: type,
2723 + walletName: name,
2724 + isHealthy: true,
2725 + syncStatus: syncStatus.toString(),
2726 + wasReconnected: true,
2727 + trigger: 'full_reconnection_success',
2728 + );
2729 + }
2730 + } catch (e) {
2731 + SocketHealthLogger().logHealthCheck(
2732 + walletType: type,
2733 + walletName: name,
2734 + isHealthy: false,
2735 + error: e.toString(),
2736 + syncStatus: syncStatus.toString(),
2737 + wasReconnected: false,
2738 + trigger: 'full_reconnection_failed',
2739 + );
2740 +
2741 + syncStatus = FailedSyncStatus();
2742 + }
2743 + }
2744 }
2745
2746 class ScanNode {
cw_core/lib/utils/socket_health_logger.dart new
+88
@@ -0,0 +1,88 @@
1 +import 'package:cw_core/utils/print_verbose.dart';
2 +import 'package:cw_core/wallet_type.dart';
3 +
4 +class SocketHealthLogEntry {
5 + final DateTime timestamp;
6 + final WalletType? walletType;
7 + final String? walletName;
8 + final bool? isHealthy;
9 + final String? error;
10 + final String? syncStatus;
11 + final bool wasReconnected;
12 + final String trigger;
13 +
14 + SocketHealthLogEntry({
15 + required this.timestamp,
16 + required this.walletType,
17 + required this.walletName,
18 + required this.isHealthy,
19 + this.error,
20 + this.syncStatus,
21 + required this.wasReconnected,
22 + required this.trigger,
23 + });
24 +
25 + String toLogString() {
26 + final dateStr = timestamp.toIso8601String();
27 + final status = isHealthy != null ? (isHealthy! ? 'HEALTHY' : 'UNHEALTHY') : 'UNKNOWN';
28 + final reconnect = wasReconnected ? 'RECONNECTED' : 'NO_RECONNECT';
29 + final errorStr = error != null ? ' | Error: $error' : '';
30 + final syncStr = syncStatus != null ? ' | Sync: $syncStatus' : '';
31 +
32 + return '[$dateStr] $trigger | ${walletType?.name ?? 'UNKNOWN'} | $walletName | $status | $reconnect$errorStr$syncStr';
33 + }
34 +}
35 +
36 +class SocketHealthLogger {
37 + static final SocketHealthLogger _instance = SocketHealthLogger._internal();
38 + factory SocketHealthLogger() => _instance;
39 + SocketHealthLogger._internal();
40 +
41 + static const int _maxLogs = 100;
42 +
43 + static final List<SocketHealthLogEntry> logs = [];
44 +
45 + void addLog(SocketHealthLogEntry entry) {
46 + try {
47 + printV('Adding log entry: ${entry.toLogString()}');
48 +
49 + logs.add(entry);
50 +
51 + if (logs.length > _maxLogs * 2) {
52 + final excessCount = logs.length - _maxLogs;
53 + logs.removeRange(0, excessCount);
54 + }
55 + } catch (e) {
56 + printV('Failed to add log: $e');
57 + }
58 + }
59 +
60 + void clearLogs() {
61 + logs.clear();
62 + printV('Cleared all logs');
63 + }
64 +
65 + /// Log a health check with the given parameters
66 + void logHealthCheck({
67 + WalletType? walletType,
68 + String? walletName,
69 + bool? isHealthy,
70 + String? error,
71 + String? syncStatus,
72 + required bool wasReconnected,
73 + required String trigger,
74 + }) {
75 + addLog(
76 + SocketHealthLogEntry(
77 + timestamp: DateTime.now(),
78 + walletType: walletType,
79 + walletName: walletName,
80 + isHealthy: isHealthy,
81 + error: error,
82 + syncStatus: syncStatus,
83 + wasReconnected: wasReconnected,
84 + trigger: trigger,
85 + ),
86 + );
87 + }
88 +}
cw_core/lib/wallet_base.dart
+5
@@ -113,6 +113,11 @@ abstract class WalletBase<BalanceType extends Balance, HistoryType extends Trans
113
114 bool canSend() => true;
115
116 + /// Check if the wallet's socket connection is healthy.
117 + /// Returns true if the connection is alive, false otherwise.
118 + /// Default implementation returns true (no-op for wallets without socket connections).
119 + Future<bool> checkSocketHealth() async => true;
120 +
121 /// This is used to check if the current node is healthy by making a lightweight RPC call
122 /// Each wallet implementation should override this to make a single, efficient call
123 /// Returns true if the node is healthy, false otherwise
integration_test/components/common_test_flows.dart
+1 -1
@@ -1,7 +1,7 @@
1 import 'dart:io';
2
3 import 'package:cake_wallet/entities/seed_type.dart';
4 -import 'package:cake_wallet/reactions/bip39_wallet_utils.dart';
4 +import 'package:cake_wallet/reactions/wallet_utils.dart';
5 import 'package:cake_wallet/wallet_types.g.dart';
6 import 'package:cw_core/wallet_type.dart';
7 import 'package:flutter/material.dart';
lib/core/node_switching_service.dart
+17
@@ -15,6 +15,15 @@ class NodeSwitchingService {
15
16 static const int _healthCheckIntervalSeconds = 30;
17
18 + // The number of times we want to reset the overall used trusted nodes list.
19 + // We don't want an infinite loop if all trusted nodes are down.
20 + static const int _usedTrustedNodeResetCount = 2;
21 +
22 + // State to manage the reset count
23 + int _resetCount = 0;
24 +
25 + String walletName = '';
26 +
27 Timer? _healthCheckTimer;
28
29 bool _isSwitching = false;
@@ -64,6 +73,10 @@ class NodeSwitchingService {
73 Future<void> _switchToNextTrustedNode() async {
74 _isSwitching = true;
75
76 + if (walletName.isNotEmpty && (walletName != appStore.wallet!.name)) _resetCount = 0;
77 +
78 + walletName = appStore.wallet!.name;
79 +
80 try {
81 final walletType = appStore.wallet!.type;
82 final currentNode = settingsStore.getCurrentNode(walletType);
@@ -98,6 +111,10 @@ class NodeSwitchingService {
111 // If all trusted nodes have been used, reset the list and start over
112 if (nextNode == null) {
113 printV('All trusted nodes have been tried, resetting and starting over');
114 + _resetCount++;
115 +
116 + if (_resetCount > _usedTrustedNodeResetCount) return;
117 +
118 _usedNodeKeys[walletType]!.clear();
119 nextNode = trustedNodes.first;
120 }
lib/di.dart
+5
@@ -278,6 +278,8 @@ import 'package:cake_wallet/view_model/dev/background_sync_logs_view_model.dart'
278 import 'package:cake_wallet/src/screens/dev/background_sync_logs_page.dart';
279 import 'package:cake_wallet/core/trade_monitor.dart';
280 import 'package:cake_wallet/core/reset_service.dart';
281 +import 'package:cake_wallet/view_model/dev/socket_health_logs_view_model.dart';
282 +import 'package:cake_wallet/src/screens/dev/socket_health_logs_page.dart';
283
284 final getIt = GetIt.instance;
285
@@ -1504,6 +1506,9 @@ Future<void> setup({
1506
1507 getIt.registerFactory(() => DevBackgroundSyncLogsPage(getIt.get<BackgroundSyncLogsViewModel>()));
1508
1509 + getIt.registerFactory(() => SocketHealthLogsViewModel());
1510 + getIt.registerFactory(() => DevSocketHealthLogsPage(getIt.get<SocketHealthLogsViewModel>()));
1511 +
1512 getIt.registerFactory(() => DevNetworkRequests());
1513
1514 getIt.registerFactory(() => StartTorPage(StartTorViewModel(),));
lib/reactions/wallet_utils.dart renamed
+11
@@ -21,3 +21,14 @@ bool isBIP39Wallet(WalletType walletType) {
21 return false;
22 }
23 }
24 +
25 +bool isElectrumWallet(WalletType walletType) {
26 + switch (walletType) {
27 + case WalletType.bitcoin:
28 + case WalletType.litecoin:
29 + case WalletType.bitcoinCash:
30 + return true;
31 + default:
32 + return false;
33 + }
34 +}
\ No newline at end of file
lib/router.dart
+6
@@ -41,6 +41,7 @@ import 'package:cake_wallet/src/screens/dev/network_requests.dart';
41 import 'package:cake_wallet/src/screens/dev/secure_preferences_page.dart';
42 import 'package:cake_wallet/src/screens/dev/shared_preferences_page.dart';
43 import 'package:cake_wallet/src/screens/dev/background_sync_logs_page.dart';
44 +import 'package:cake_wallet/src/screens/dev/socket_health_logs_page.dart';
45 import 'package:cake_wallet/src/screens/disclaimer/disclaimer_page.dart';
46 import 'package:cake_wallet/src/screens/disclaimer/third_party_disclaimer_page.dart';
47 import 'package:cake_wallet/src/screens/exchange/exchange_page.dart';
@@ -907,6 +908,11 @@ Route<dynamic> createRoute(RouteSettings settings) {
908 builder: (_) => getIt.get<DevBackgroundSyncLogsPage>(),
909 );
910
911 + case Routes.devSocketHealthLogs:
912 + return CupertinoPageRoute<void>(
913 + builder: (_) => getIt.get<DevSocketHealthLogsPage>(),
914 + );
915 +
916 case Routes.devNetworkRequests:
917 return MaterialPageRoute<void>(
918 builder: (_) => getIt.get<DevNetworkRequests>(),
lib/routes.dart
+1
@@ -119,6 +119,7 @@ class Routes {
119 static const devSharedPreferences = '/dev/shared_preferences';
120 static const devSecurePreferences = '/dev/secure_preferences';
121 static const devBackgroundSyncLogs = '/dev/background_sync_logs';
122 + static const devSocketHealthLogs = '/dev/socket_health_logs';
123 static const devNetworkRequests = '/dev/network_requests';
124
125 static const signPage = '/sign_page';
lib/src/screens/dev/socket_health_logs_page.dart new
+333
@@ -0,0 +1,333 @@
1 +import 'package:cake_wallet/di.dart';
2 +import 'package:cake_wallet/src/screens/base_page.dart';
3 +import 'package:cake_wallet/view_model/dev/socket_health_logs_view_model.dart';
4 +import 'package:cw_core/utils/socket_health_logger.dart';
5 +import 'package:flutter/material.dart';
6 +import 'package:flutter/services.dart';
7 +import 'package:flutter_mobx/flutter_mobx.dart';
8 +import 'package:intl/intl.dart';
9 +import 'package:cake_wallet/store/app_store.dart';
10 +import 'package:cake_wallet/reactions/wallet_utils.dart';
11 +
12 +class DevSocketHealthLogsPage extends BasePage {
13 + final SocketHealthLogsViewModel viewModel;
14 +
15 + DevSocketHealthLogsPage(this.viewModel) {
16 + viewModel.loadLogs();
17 + }
18 +
19 + @override
20 + String? get title => "[dev] socket health logs";
21 +
22 + @override
23 + Widget body(BuildContext context) {
24 + return Observer(
25 + builder: (_) {
26 + if (viewModel.logs.isEmpty) {
27 + return Center(
28 + child: Column(
29 + mainAxisAlignment: MainAxisAlignment.center,
30 + children: [
31 + Text("No logs available"),
32 + SizedBox(height: 16),
33 + ElevatedButton(
34 + onPressed: () => viewModel.loadLogs(),
35 + child: Text("Load Logs"),
36 + ),
37 + ],
38 + ),
39 + );
40 + }
41 +
42 + return Column(
43 + children: [
44 + _StatsCard(viewModel),
45 + Expanded(
46 + child: _LogsList(viewModel),
47 + ),
48 + _ActionButtons(viewModel),
49 + ],
50 + );
51 + },
52 + );
53 + }
54 +}
55 +
56 +class _StatsCard extends StatelessWidget {
57 + const _StatsCard(this.viewModel);
58 +
59 + final SocketHealthLogsViewModel viewModel;
60 +
61 + @override
62 + Widget build(BuildContext context) {
63 + return Card(
64 + margin: EdgeInsets.all(8),
65 + child: Padding(
66 + padding: EdgeInsets.all(16),
67 + child: Observer(
68 + builder: (_) => Column(
69 + children: [
70 + Row(
71 + mainAxisAlignment: MainAxisAlignment.spaceAround,
72 + children: [
73 + _StatItem('Total', viewModel.totalLogs.toString(), Colors.blue),
74 + _StatItem('Healthy', viewModel.healthyLogs.toString(), Colors.green),
75 + _StatItem('Unhealthy', viewModel.unhealthyLogs.toString(), Colors.red),
76 + ],
77 + ),
78 + SizedBox(height: 8),
79 + if (viewModel.error != null)
80 + Text(
81 + 'Error: ${viewModel.error}',
82 + style: TextStyle(fontSize: 12, color: Colors.red),
83 + ),
84 + ],
85 + ),
86 + ),
87 + ),
88 + );
89 + }
90 +}
91 +
92 +class _StatItem extends StatelessWidget {
93 + const _StatItem(this.label, this.value, this.color);
94 +
95 + final String label;
96 + final String value;
97 + final Color color;
98 +
99 + @override
100 + Widget build(BuildContext context) {
101 + return Column(
102 + children: [
103 + Text(
104 + value,
105 + style: TextStyle(
106 + fontSize: 24,
107 + fontWeight: FontWeight.bold,
108 + color: color,
109 + ),
110 + ),
111 + Text(
112 + label,
113 + style: TextStyle(
114 + fontSize: 12,
115 + color: Colors.grey[600],
116 + ),
117 + ),
118 + ],
119 + );
120 + }
121 +}
122 +
123 +class _LogsList extends StatelessWidget {
124 + const _LogsList(this.viewModel);
125 +
126 + final SocketHealthLogsViewModel viewModel;
127 +
128 + @override
129 + Widget build(BuildContext context) {
130 + return Observer(
131 + builder: (_) {
132 + final logs = viewModel.logs;
133 + if (logs.isEmpty) {
134 + return Center(child: Text("No logs available"));
135 + }
136 +
137 + return ListView.builder(
138 + itemCount: logs.length,
139 + itemBuilder: (context, index) {
140 + final log = logs[index];
141 + return _LogItem(log);
142 + },
143 + );
144 + },
145 + );
146 + }
147 +}
148 +
149 +class _LogItem extends StatelessWidget {
150 + const _LogItem(this.log);
151 +
152 + final SocketHealthLogEntry log;
153 +
154 + @override
155 + Widget build(BuildContext context) {
156 + final dateFormat = DateFormat('MM-dd HH:mm:ss.SSS');
157 + final isHealthy = log.isHealthy;
158 +
159 + return Card(
160 + margin: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
161 + color: isHealthy != null ? (isHealthy ? Colors.green[50] : Colors.red[50]) : Colors.grey[50],
162 + child: ListTile(
163 + title: Text(
164 + log.toLogString(),
165 + style: Theme.of(context).textTheme.bodySmall!.copyWith(
166 + fontFamily: 'Monospace',
167 + fontSize: 11,
168 + color: isHealthy != null ? (isHealthy ? Colors.green : Colors.red) : Colors.grey,
169 + ),
170 + ),
171 + subtitle: Column(
172 + crossAxisAlignment: CrossAxisAlignment.start,
173 + children: [
174 + Text(
175 + '${dateFormat.format(log.timestamp)} | ${log.trigger}',
176 + style: TextStyle(fontSize: 10, color: Colors.grey[600]),
177 + ),
178 + if (log.error != null)
179 + Text(
180 + 'Error: ${log.error}',
181 + style: TextStyle(fontSize: 10, color: Colors.red),
182 + ),
183 + ],
184 + ),
185 + leading: Icon(
186 + isHealthy != null ? (isHealthy ? Icons.check_circle : Icons.error) : Icons.help,
187 + color: isHealthy != null ? (isHealthy ? Colors.green : Colors.red) : Colors.grey,
188 + ),
189 + ),
190 + );
191 + }
192 +}
193 +
194 +class _ActionButtons extends StatelessWidget {
195 + const _ActionButtons(this.viewModel);
196 +
197 + final SocketHealthLogsViewModel viewModel;
198 +
199 + void _copyLogsToClipboard(BuildContext context) {
200 + final logsText = viewModel.getLogsAsText();
201 + Clipboard.setData(ClipboardData(text: logsText));
202 +
203 + if (context.mounted) {
204 + ScaffoldMessenger.of(context).showSnackBar(
205 + SnackBar(
206 + content: Text('Logs copied to clipboard'),
207 + duration: Duration(seconds: 2),
208 + ),
209 + );
210 + }
211 + }
212 +
213 + void _testEnhancedHealthCheck(BuildContext context) async {
214 + final wallet = getIt.get<AppStore>().wallet;
215 + if (wallet == null || !isElectrumWallet(wallet.type)) {
216 + if (context.mounted) {
217 + ScaffoldMessenger.of(context).showSnackBar(
218 + SnackBar(
219 + content: Text('No Electrum wallet found'),
220 + backgroundColor: Colors.red,
221 + ),
222 + );
223 + }
224 + return;
225 + }
226 +
227 + try {
228 + final isHealthy = await wallet.checkSocketHealth();
229 +
230 + SocketHealthLogger().logHealthCheck(
231 + walletType: wallet.type,
232 + walletName: wallet.name,
233 + isHealthy: isHealthy,
234 + syncStatus: wallet.syncStatus.toString(),
235 + wasReconnected: true,
236 + trigger: 'manual_enhanced_health_check',
237 + );
238 +
239 + ScaffoldMessenger.of(context).showSnackBar(
240 + SnackBar(
241 + content: Text('Enhanced health check completed: ${isHealthy ? "Healthy" : "Unhealthy"}'),
242 + backgroundColor: isHealthy ? Colors.green : Colors.red,
243 + ),
244 + );
245 +
246 + viewModel.loadLogs();
247 + } catch (e) {
248 + if (context.mounted) {
249 + ScaffoldMessenger.of(context).showSnackBar(
250 + SnackBar(
251 + content: Text('Enhanced health check failed: $e'),
252 + backgroundColor: Colors.red,
253 + ),
254 + );
255 + }
256 + }
257 + }
258 +
259 + void _refreshLogs(BuildContext context) {
260 + viewModel.loadLogs();
261 +
262 + if (context.mounted) {
263 + ScaffoldMessenger.of(context).showSnackBar(
264 + SnackBar(
265 + content: Text('Logs refreshed'),
266 + duration: Duration(seconds: 1),
267 + ),
268 + );
269 + }
270 + }
271 +
272 + @override
273 + Widget build(BuildContext context) {
274 + return Padding(
275 + padding: EdgeInsets.all(16),
276 + child: Column(
277 + children: [
278 + Row(
279 + children: [
280 + Expanded(
281 + child: ElevatedButton(
282 + onPressed: () => viewModel.clearLogs(),
283 + style: ElevatedButton.styleFrom(
284 + backgroundColor: Colors.red,
285 + foregroundColor: Colors.white,
286 + ),
287 + child: Text('Clear Logs'),
288 + ),
289 + ),
290 + SizedBox(width: 16),
291 + Expanded(
292 + child: ElevatedButton(
293 + onPressed: () => _copyLogsToClipboard(context),
294 + child: Text('Copy to Clipboard'),
295 + ),
296 + ),
297 + ],
298 + ),
299 + SizedBox(height: 8),
300 + Row(
301 + children: [
302 + Expanded(
303 + child: ElevatedButton(
304 + onPressed: () => _testEnhancedHealthCheck(context),
305 + style: ElevatedButton.styleFrom(
306 + backgroundColor: Colors.purple,
307 + foregroundColor: Colors.white,
308 + ),
309 + child: Text('Enhanced Health Check'),
310 + ),
311 + ),
312 + ],
313 + ),
314 + SizedBox(height: 8),
315 + Row(
316 + children: [
317 + Expanded(
318 + child: ElevatedButton(
319 + onPressed: () => _refreshLogs(context),
320 + style: ElevatedButton.styleFrom(
321 + backgroundColor: Colors.green,
322 + foregroundColor: Colors.white,
323 + ),
324 + child: Text('Refresh'),
325 + ),
326 + ),
327 + ],
328 + ),
329 + ],
330 + ),
331 + );
332 + }
333 +}
lib/src/screens/new_wallet/new_wallet_type_page.dart
+1 -1
@@ -3,7 +3,7 @@ import 'package:cake_wallet/core/new_wallet_type_arguments.dart';
3 import 'dart:io';
4
5 import 'package:cake_wallet/generated/i18n.dart';
6 -import 'package:cake_wallet/reactions/bip39_wallet_utils.dart';
6 +import 'package:cake_wallet/reactions/wallet_utils.dart';
7 import 'package:cake_wallet/routes.dart';
8 import 'package:cake_wallet/src/screens/base_page.dart';
9 import 'package:cake_wallet/src/screens/new_wallet/widgets/select_button.dart';
lib/src/screens/root/root.dart
+25 -1
@@ -5,7 +5,6 @@ import 'package:cake_wallet/core/auth_service.dart';
5 import 'package:cake_wallet/core/node_switching_service.dart';
6 import 'package:cake_wallet/core/totp_request_details.dart';
7 import 'package:cake_wallet/core/trade_monitor.dart';
8 -import 'package:cake_wallet/di.dart';
8 import 'package:cake_wallet/utils/device_info.dart';
9 import 'package:cake_wallet/view_model/link_view_model.dart';
10 import 'package:cw_core/utils/print_verbose.dart';
@@ -20,6 +19,8 @@ import 'package:cake_wallet/entities/qr_scanner.dart';
19 import 'package:mobx/mobx.dart';
20 import 'package:uni_links/uni_links.dart';
21 import 'package:cake_wallet/src/screens/setup_2fa/setup_2fa_enter_code_page.dart';
22 +import 'package:cake_wallet/reactions/wallet_utils.dart';
23 +import 'package:cw_core/utils/socket_health_logger.dart';
24
25 class Root extends StatefulWidget {
26 Root({
@@ -169,6 +170,29 @@ class RootState extends State<Root> with WidgetsBindingObserver {
170 // Trigger node health check when app resumes
171 widget.nodeSwitchingService.performHealthCheck();
172
173 + // Electrum Wallet socket health check and reconnection flow
174 + final wallet = widget.appStore.wallet;
175 + if (wallet != null && isElectrumWallet(wallet.type)) {
176 + SocketHealthLogger().logHealthCheck(
177 + walletType: wallet.type,
178 + walletName: wallet.name,
179 + syncStatus: wallet.syncStatus.toString(),
180 + wasReconnected: false,
181 + trigger: 'app_resume',
182 + );
183 +
184 + wallet.checkSocketHealth().then((isHealthy) {
185 + SocketHealthLogger().logHealthCheck(
186 + walletType: wallet.type,
187 + walletName: wallet.name,
188 + isHealthy: isHealthy,
189 + syncStatus: wallet.syncStatus.toString(),
190 + wasReconnected: true,
191 + trigger: 'app_resume_socket_health_check',
192 + );
193 + });
194 + }
195 +
196 break;
197 default:
198 break;
lib/src/screens/settings/other_settings_page.dart
+6
@@ -93,6 +93,12 @@ class OtherSettingsPage extends BasePage {
93 handler: (BuildContext context) =>
94 Navigator.of(context).pushNamed(Routes.devBackgroundSyncLogs),
95 ),
96 + if (FeatureFlag.hasDevOptions)
97 + SettingsCellWithArrow(
98 + title: '[dev] socket health logs',
99 + handler: (BuildContext context) =>
100 + Navigator.of(context).pushNamed(Routes.devSocketHealthLogs),
101 + ),
102 if (FeatureFlag.hasDevOptions)
103 SettingsCellWithArrow(
104 title: '[dev] network requests logs',
lib/view_model/dev/socket_health_logs_view_model.dart new
+57
@@ -0,0 +1,57 @@
1 +import 'package:cw_core/utils/socket_health_logger.dart';
2 +import 'package:mobx/mobx.dart';
3 +
4 +part 'socket_health_logs_view_model.g.dart';
5 +
6 +class SocketHealthLogsViewModel = SocketHealthLogsViewModelBase with _$SocketHealthLogsViewModel;
7 +
8 +abstract class SocketHealthLogsViewModelBase with Store {
9 +
10 + @observable
11 + bool isLoading = false;
12 +
13 + @observable
14 + String? error;
15 +
16 + @observable
17 + ObservableList<SocketHealthLogEntry> logs = ObservableList<SocketHealthLogEntry>();
18 +
19 + @computed
20 + int get totalLogs => logs.length;
21 +
22 + @computed
23 + int get unhealthyLogs => logs.where((log) => log.isHealthy == false).length;
24 +
25 + @computed
26 + int get healthyLogs => totalLogs - unhealthyLogs;
27 +
28 + String getLogsAsText() {
29 + if (logs.isEmpty) return 'No logs available';
30 +
31 + final buffer = StringBuffer();
32 + buffer.writeln('Socket Health Logs');
33 + buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
34 + buffer.writeln('Total logs: $totalLogs');
35 + buffer.writeln('Unhealthy logs: $unhealthyLogs');
36 + buffer.writeln('Healthy logs: $healthyLogs');
37 + buffer.writeln('');
38 +
39 + for (final log in logs) {
40 + buffer.writeln(log.toLogString());
41 + }
42 +
43 + return buffer.toString();
44 + }
45 +
46 + @action
47 + void loadLogs() {
48 + this.logs.clear();
49 + this.logs.addAll(SocketHealthLogger.logs);
50 + }
51 +
52 + @action
53 + void clearLogs() {
54 + SocketHealthLogger().clearLogs();
55 + this.logs.clear();
56 + }
57 +}
lib/view_model/wallet_groups_display_view_model.dart
+1 -1
@@ -1,7 +1,7 @@
1 import 'package:cake_wallet/core/wallet_loading_service.dart';
2 import 'package:cake_wallet/entities/wallet_group.dart';
3 import 'package:cake_wallet/entities/wallet_manager.dart';
4 -import 'package:cake_wallet/reactions/bip39_wallet_utils.dart';
4 +import 'package:cake_wallet/reactions/wallet_utils.dart';
5 import 'package:cake_wallet/store/app_store.dart';
6 import 'package:cake_wallet/view_model/wallet_list/wallet_list_item.dart';
7 import 'package:cake_wallet/view_model/wallet_list/wallet_list_view_model.dart';