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';