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
This commit is contained in:
David Adegoke
2025-07-31 17:24:54 +03:00
committed by GitHub
parent 723103d429
commit 186247d849
16 changed files with 740 additions and 17 deletions
+17
View File
@@ -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<void> _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;
}
+5
View File
@@ -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<void> setup({
getIt.registerFactory(() => DevBackgroundSyncLogsPage(getIt.get<BackgroundSyncLogsViewModel>()));
getIt.registerFactory(() => SocketHealthLogsViewModel());
getIt.registerFactory(() => DevSocketHealthLogsPage(getIt.get<SocketHealthLogsViewModel>()));
getIt.registerFactory(() => DevNetworkRequests());
getIt.registerFactory(() => StartTorPage(StartTorViewModel(),));
@@ -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;
}
}
+6
View File
@@ -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<dynamic> createRoute(RouteSettings settings) {
builder: (_) => getIt.get<DevBackgroundSyncLogsPage>(),
);
case Routes.devSocketHealthLogs:
return CupertinoPageRoute<void>(
builder: (_) => getIt.get<DevSocketHealthLogsPage>(),
);
case Routes.devNetworkRequests:
return MaterialPageRoute<void>(
builder: (_) => getIt.get<DevNetworkRequests>(),
+1
View File
@@ -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';
@@ -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<AppStore>().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'),
),
),
],
),
],
),
);
}
}
@@ -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';
+25 -1
View File
@@ -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<Root> 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;
@@ -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',
@@ -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<SocketHealthLogEntry> logs = ObservableList<SocketHealthLogEntry>();
@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();
}
}
@@ -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';