fix(epic): fix wallet opening and handling, add open, remove ensureWalletOpen
What changed, and why it matters
This commit restructures how the Epic Cash wallet is opened in the Stack Wallet mobile app. It replaces an automatic 'open if needed' helper with a single explicit open() step that must be called first. The change also makes wallet recovery and re-initialization close the old wallet handle before creating a new one, and it starts the Epic Box listener in more places. The commit looks like a bug-fix/refactoring change rather than a clear security patch, but it removes a pattern where wallet operations could silently re-load the wallet on demand, which could have helped avoid inconsistent or duplicate wallet states.
Treat as a routine bug-fix/refactor with possible defensive-security side effects. Review that all call sites invoke open() before wallet operations and that _wallet is not accessed after close(). Verify that secure-storage writes of the wallet handle are intentional and that recovery no longer leaks the prior native wallet handle. No urgent action is indicated absent additional context.
Security signals we found
Removal of lazy wallet open helper that loaded credentials and re-created wallet state on demand
Addition of explicit open() lifecycle method to prevent repeated wallet loading
Recovery paths now close old wallet handle before creating a new one, reducing risk of duplicate/dangling handles
Wallet password is still read from secure storage and passed to native EpicWallet.load/recover
No explicit security advisory, CVE, or vulnerability description in commit or supplied references
Evidence from the diff
The diff removes _ensureWalletOpen(), which lazily loaded the Epic wallet and wrote its handle to secure storage whenever a method needed it. A new open() method performs the same load once during initialization and stores the handle. Callers now check _wallet == null and throw ‘Wallet not opened. Call open() first.’ instead of auto-opening. init() now skips work if _wallet is already set. Recovery paths now explicitly close/null the old _wallet before calling EpicWallet.recover(), and _listenToEpicbox() is invoked after open, init, and recovery. The change centralizes wallet lifecycle management and reduces repeated secure-storage reads/password loads during normal operations.
Changed components
lib/wallets/wallet/impl/epiccash_wallet.dartEpic Cash wallet operations (createSlatepack, receiveSlatepack, finalizeSlatepack, send, sync, balances, address generation)Wallet initialization and recovery flowsInspect captured patch +76 / −46
diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart
index 7d451d2..7d1e500 100644
--- a/lib/wallets/wallet/impl/epiccash_wallet.dart
+++ b/lib/wallets/wallet/impl/epiccash_wallet.dart
@@ -73,6 +73,47 @@ class EpiccashWallet extends Bip39Wallet {
return restorePercent < 0 ? 0.0 : restorePercent;
}
+ /// Opens and initializes the Epic wallet instance.
+ /// Should only be called once during wallet initialization.
+ Future<void> open() async {
+ if (_wallet != null) {
+ Logging.instance.d("Wallet already open, skipping");
+ return;
+ }
+
+ try {
+ final config = await _getRealConfig();
+ final password = await secureStorageInterface.read(
+ key: '${walletId}_password',
+ );
+ if (password == null) {
+ throw Exception('Wallet password not found');
+ }
+
+ final epicboxConfig = await getEpicBoxConfig();
+
+ _wallet = await epic.EpicWallet.load(
+ config: config,
+ password: password,
+ epicboxConfig: epicboxConfig.toString(),
+ );
+
+ // Store wallet handle
+ await secureStorageInterface.write(
+ key: '${walletId}_wallet',
+ value: _wallet!.handle,
+ );
+
+ await _listenToEpicbox();
+
+ Logging.instance.d("Epic wallet opened successfully with persistent isolate");
+ } catch (e, s) {
+ Logging.instance.e("Failed to open Epic wallet", error: e, stackTrace: s);
+ _wallet = null;
+ rethrow;
+ }
+ }
+
Future<void> updateEpicboxConfig(String host, int port) async {
final String stringConfig = jsonEncode({
"epicbox_domain": host,
@@ -147,35 +188,6 @@ class EpiccashWallet extends Bip39Wallet {
// ================= Slatepack Operations ===================================
- Future<String> _ensureWalletOpen() async {
- if (_wallet != null) {
- return _wallet!.handle;
- }
-
- final config = await _getRealConfig();
- final password = await secureStorageInterface.read(
- key: '${walletId}_password',
- );
- if (password == null) {
- throw Exception('Wallet password not found');
- }
-
- final epicboxConfig = await getEpicBoxConfig();
-
- _wallet = await epic.EpicWallet.load(
- config: config,
- password: password,
- epicboxConfig: epicboxConfig.toString(),
- );
-
- final handle = _wallet!.handle;
- await secureStorageInterface.write(
- key: '${walletId}_wallet',
- value: handle,
- );
- return handle;
- }
-
/// Create a slatepack for sending Epic Cash.
Future<EpicSlatepackResult> createSlatepack({
required Amount amount,
@@ -185,9 +197,8 @@ class EpiccashWallet extends Bip39Wallet {
}) async {
try {
_hackedCheckTorNodePrefs();
- await _ensureWalletOpen();
if (_wallet == null) {
- throw Exception('Wallet not initialized');
+ throw Exception('Wallet not opened. Call open() first.');
}
// Create transaction with returnSlate: true for slatepack mode.
final result = await _wallet!.createTransaction(
@@ -266,9 +277,8 @@ class EpiccashWallet extends Bip39Wallet {
Future<EpicReceiveResult> receiveSlatepack(String slateJson) async {
try {
_hackedCheckTorNodePrefs();
- await _ensureWalletOpen();
if (_wallet == null) {
- throw Exception('Wallet not initialized');
+ throw Exception('Wallet not opened. Call open() first.');
}
// Receive and get updated slate JSON.
@@ -294,9 +304,8 @@ class EpiccashWallet extends Bip39Wallet {
Future<EpicFinalizeResult> finalizeSlatepack(String slateJson) async {
try {
_hackedCheckTorNodePrefs();
- await _ensureWalletOpen();
if (_wallet == null) {
- throw Exception('Wallet not initialized');
+ throw Exception('Wallet not opened. Call open() first.');
}
// Finalize transaction.
@@ -465,9 +474,8 @@ class EpiccashWallet extends Bip39Wallet {
int satoshiAmount, {
bool ifErrorEstimateFee = false,
}) async {
- await _ensureWalletOpen();
if (_wallet == null) {
- throw Exception('Wallet not initialized');
+ throw Exception('Wallet not opened. Call open() first.');
}
try {
_hackedCheckTorNodePrefs();
@@ -497,9 +505,8 @@ class EpiccashWallet extends Bip39Wallet {
Future<void> _startSync() async {
_hackedCheckTorNodePrefs();
Logging.instance.d("request start sync");
- await _ensureWalletOpen();
if (_wallet == null) {
- throw Exception('Wallet not initialized');
+ throw Exception('Wallet not opened. Call open() first.');
}
const int refreshFromNode = 1;
if (!syncMutex.isLocked) {
@@ -525,9 +532,8 @@ class EpiccashWallet extends Bip39Wallet {
>
_allWalletBalances() async {
_hackedCheckTorNodePrefs();
- await _ensureWalletOpen();
if (_wallet == null) {
- throw Exception('Wallet not initialized');
+ throw Exception('Wallet not opened. Call open() first.');
}
const refreshFromNode = 0;
return (await _wallet!.getBalances(
@@ -625,9 +631,8 @@ class EpiccashWallet extends Bip39Wallet {
int index,
EpicBoxConfigModel epicboxConfig,
) async {
- await _ensureWalletOpen();
if (_wallet == null) {
- throw Exception('Wallet not initialized');
+ throw Exception('Wallet not opened. Call open() first.');
}
final walletAddress = await _wallet!.getAddressInfo(
@@ -815,6 +820,11 @@ class EpiccashWallet extends Bip39Wallet {
@override
Future<void> init({bool? isRestore}) async {
+ if (_wallet != null) {
+ Logging.instance.d("Wallet already initialized, skipping init");
+ return await super.init();
+ }
+
if (isRestore != true) {
String? encodedWallet = await secureStorageInterface.read(
key: "${walletId}_wallet",
@@ -881,6 +891,8 @@ class EpiccashWallet extends Bip39Wallet {
epicData: epicData,
isar: mainDB.isar,
);
+
+ await _listenToEpicbox();
} else {
try {
Logging.instance.d(
@@ -906,6 +918,8 @@ class EpiccashWallet extends Bip39Wallet {
);
await updateNode();
+
+ await _listenToEpicbox();
} catch (e, s) {
// do nothing, still allow user into wallet
Logging.instance.w(
@@ -1100,7 +1114,12 @@ class EpiccashWallet extends Bip39Wallet {
);
Logging.instance.w("Epic rescan temporary delete result: $result");
- await _wallet?.close();
+ // Close old wallet before recovery
+ if (_wallet != null) {
+ await _wallet!.close();
+ _wallet = null;
+ }
+
_wallet = await epic.EpicWallet.recover(
config: stringConfig,
password: password!,
@@ -1115,6 +1134,8 @@ class EpiccashWallet extends Bip39Wallet {
value: _wallet!.handle,
);
+ await _listenToEpicbox();
+
highestPercent = 0;
} else {
await updateNode();
@@ -1137,7 +1158,12 @@ class EpiccashWallet extends Bip39Wallet {
value: epicboxConfig.toString(),
);
- await _wallet?.close();
+ // Close old wallet before recovery
+ if (_wallet != null) {
+ await _wallet!.close();
+ _wallet = null;
+ }
+
_wallet = await epic.EpicWallet.recover(
config: stringConfig,
password: password,
@@ -1152,6 +1178,8 @@ class EpiccashWallet extends Bip39Wallet {
value: _wallet!.handle,
);
+ await _listenToEpicbox();
+
final epicData = ExtraEpiccashWalletInfo(
receivingIndex: 0,
changeIndex: 0,
@@ -1216,7 +1244,9 @@ class EpiccashWallet extends Bip39Wallet {
final int curAdd = await _getCurrentIndex();
await _generateAndStoreReceivingAddressForIndex(curAdd);
- await _ensureWalletOpen();
+ if (_wallet == null) {
+ throw Exception('Wallet not opened. Call open() first.');
+ }
if (doScan) {
await _startScans();
Why this scored 34/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.