What changed, and why it matters
This commit changes how Stack Wallet manages background listeners for Epic Cash wallets. Previously, a single global listener was stopped and restarted during every wallet refresh, which could cause unnecessary reconnections and possibly interfere with other wallets' listeners in multi-wallet setups. The update makes listeners per-wallet, only stops the listener when there are actually new blocks to scan, and checks whether the listener is still alive before restarting it. The main security-relevant concern is that the old behavior may have left wallets briefly without a listener or restarted listeners unnecessarily, potentially causing missed transaction notifications or race conditions; the new behavior is a hardening improvement rather than an active vulnerability fix.
Treat as a defensive hardening/feature improvement. Review the corresponding `crypto_plugins/flutter_libepiccash` subproject commit to confirm the Rust-side listener map correctly isolates wallets and cleans up listeners on wallet exit. No immediate incident response is indicated, but users running multi-wallet Epic Cash setups should update to avoid missed notifications or listener races.
Security signals we found
Per-wallet listener isolation reduces cross-wallet interference
Avoids stopping/restarting listener when wallet is already at chain tip
Adds health check (`isEpicboxListenerRunning`) before restarting listener to avoid stale pointer issues
Removes unconditional global listener stop in `_startScans`
Subproject update to `crypto_plugins/flutter_libepiccash` likely implements Rust-side per-wallet listener state
Evidence from the diff
The patch refactors Epic Cash (epiccash) wallet listener management from a global singleton pattern to a per-wallet model. Key changes: (1) stopEpicboxListener() now requires a walletId and only stops the listener when lastScannedBlock < chainHeight; (2) after scanning, _startScans() calls isEpicboxListenerRunning(walletId) and starts the listener only if it is not already running; (3) startEpicboxListener() now takes walletId; (4) exit() stops only this wallet’s listener; (5) the interface adds stopAllEpicboxListeners(), isEpicboxListenerRunning(), and getActiveListenerWalletIds(). The subproject crypto_plugins/flutter_libepiccash is updated (diff not shown) to implement these per-wallet APIs. The commit message and comments frame this as a feature to avoid unnecessary reconnections and potential conflicts during scanning.
Changed components
lib/wallets/wallet/impl/epiccash_wallet.dartlib/wl_gen/interfaces/libepiccash_interface.dartcrypto_plugins/flutter_libepiccash (subproject)Inspect captured patch +30 / −7
diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart
index 3fc6b56..9a3e611 100644
--- a/lib/wallets/wallet/impl/epiccash_wallet.dart
+++ b/lib/wallets/wallet/impl/epiccash_wallet.dart
@@ -363,8 +363,6 @@ class EpiccashWallet extends Bip39Wallet {
Future<void> _startScans() async {
try {
- //First stop the current listener
- libEpic.stopEpicboxListener();
final wallet = await secureStorageInterface.read(
key: '${walletId}_wallet',
);
@@ -380,6 +378,15 @@ class EpiccashWallet extends Bip39Wallet {
int chainHeight = await this.chainHeight;
int lastScannedBlock = info.epicData!.lastScannedBlock;
+ // Only stop the listener if we actually have blocks to scan.
+ // This avoids unnecessary reconnections during periodic refresh
+ // when the wallet is already synced to the tip.
+ final needsScanning = lastScannedBlock < chainHeight;
+ if (needsScanning) {
+ // Stop listener during active scanning to avoid potential conflicts
+ libEpic.stopEpicboxListener(walletId: walletId);
+ }
+
// loop while scanning in chain in chunks (of blocks?)
while (lastScannedBlock < chainHeight) {
Logging.instance.d(
@@ -407,8 +414,16 @@ class EpiccashWallet extends Bip39Wallet {
}
Logging.instance.d("_startScans successfully at the tip");
- //Once scanner completes restart listener
- await _listenToEpicbox();
+
+ // Ensure listener is running after refresh.
+ // Use health check to verify the Rust listener task is actually alive,
+ // not just that we have a pointer (which could be stale).
+ if (!libEpic.isEpicboxListenerRunning(walletId: walletId)) {
+ Logging.instance.d("Listener not running, starting it...");
+ await _listenToEpicbox();
+ } else {
+ Logging.instance.d("Listener already running, no restart needed");
+ }
} catch (e, s) {
Logging.instance.e("_startScans failed: ", error: e, stackTrace: s);
rethrow;
@@ -420,6 +435,7 @@ class EpiccashWallet extends Bip39Wallet {
final wallet = await secureStorageInterface.read(key: '${walletId}_wallet');
final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig();
libEpic.startEpicboxListener(
+ walletId: walletId,
wallet: wallet!,
epicboxConfig: epicboxConfig.toString(),
);
@@ -1324,7 +1340,7 @@ class EpiccashWallet extends Bip39Wallet {
@override
Future<void> exit() async {
- libEpic.stopEpicboxListener();
+ libEpic.stopEpicboxListener(walletId: walletId);
timer?.cancel();
timer = null;
await super.exit();
diff --git a/lib/wl_gen/interfaces/libepiccash_interface.dart b/lib/wl_gen/interfaces/libepiccash_interface.dart
index 287993f..77eecbd 100644
--- a/lib/wl_gen/interfaces/libepiccash_interface.dart
+++ b/lib/wl_gen/interfaces/libepiccash_interface.dart
@@ -55,11 +55,18 @@ abstract class LibEpicCashInterface {
});
void startEpicboxListener({
+ required String walletId,
required String wallet,
required String epicboxConfig,
});
- void stopEpicboxListener();
+ void stopEpicboxListener({required String walletId});
+
+ void stopAllEpicboxListeners();
+
+ bool isEpicboxListenerRunning({required String walletId});
+
+ List<String> getActiveListenerWalletIds();
bool validateSendAddress({required String address});
Why this scored 35/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.