fix(epic): mirror cs_monero isolate pattern for flutter_epiccash & wallet impl
What changed, and why it matters
This commit refactors how the Epic Cash wallet in Stack Wallet talks to its underlying Rust library. Instead of repeatedly reading a wallet handle from secure storage and passing it to a global library interface, it now keeps an in-memory wallet object (an 'EpicWallet') and runs operations on that object, often inside a separate worker isolate. The change also adds explicit wallet-close calls on shutdown. The overall direction improves isolation and cleanup, but the commit is described as a work-in-progress ('TODO: test more'), so it may not be a complete or fully tested security fix.
Treat this as a defensive refactor rather than a confirmed vulnerability fix. Review the updated `flutter_libepiccash` submodule changes separately, complete the author's TODOs (more testing, upstream PR, submodule reference update), and verify that isolate lifecycle handling does not introduce race conditions or use-after-close bugs.
Security signals we found
Moves wallet handle from secure storage into an in-memory object, reducing the attack surface where a stale or leaked handle could be reused
Adds explicit wallet resource cleanup (`_wallet?.close()`) on wallet exit, which can help prevent memory leaks or dangling native resources
Uses worker isolates for wallet operations, mirroring the cs_monero pattern, which can limit the impact of some memory-safety issues in the Rust FFI layer
Commit message and TODOs indicate the change is incomplete and needs more testing and upstream merge
No explicit security bug, CVE, or vulnerability description is present in the commit or supplied references
Evidence from the diff
The patch migrates epiccash_wallet.dart from the old libEpic.* static-style API to a new epic.EpicWallet object model imported from flutter_libepiccash. It replaces direct secure-storage reads of the wallet handle with an _wallet field, calls EpicWallet.load/create/recover (which spawn worker isolates), and updates call sites for cancelTransaction, createTransaction, txReceive, txFinalize, getTransactionFees, getBalances, scanOutputs, getAddressInfo, txHttpSend, getTransactions, and recoverWallet. It also changes transaction message access from tx.messages?.first.message to tx.messages?.messages.first.message and adds _wallet?.close() plus _wallet = null in exit(). The submodule crypto_plugins/flutter_libepiccash is updated but its diff is not shown.
Changed components
lib/wallets/wallet/impl/epiccash_wallet.dartcrypto_plugins/flutter_libepiccash (submodule reference)Inspect captured patch +82 / −89
diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart
index 86ca76a..f7a2e69 100644
--- a/lib/wallets/wallet/impl/epiccash_wallet.dart
+++ b/lib/wallets/wallet/impl/epiccash_wallet.dart
@@ -39,6 +39,8 @@ import '../../models/tx_data.dart';
import '../intermediate/bip39_wallet.dart';
import '../supporting/epiccash_wallet_info_extension.dart';
+import 'package:flutter_libepiccash/flutter_libepiccash.dart' as epic;
+
//
// refactor of https://github.com/cypherstack/stack_wallet/blob/1d9fb4cd069f22492ece690ac788e05b8f8b1209/lib/services/coins/epiccash/epiccash_wallet.dart
//
@@ -49,6 +51,8 @@ class EpiccashWallet extends Bip39Wallet {
NodeModel? _epicNode;
Timer? timer;
+ epic.EpicWallet? _wallet;
+
double highestPercent = 0;
Future<double> get getSyncPercent async {
final int lastScannedBlock = info.epicData?.lastScannedBlock ?? 0;
@@ -87,12 +91,11 @@ class EpiccashWallet extends Bip39Wallet {
Future<String> cancelPendingTransactionAndPost(String txSlateId) async {
try {
_hackedCheckTorNodePrefs();
- final String wallet = (await secureStorageInterface.read(
- key: '${walletId}_wallet',
- ))!;
+ if (_wallet == null) {
+ throw Exception('Wallet not initialized');
+ }
- final result = await libEpic.cancelTransaction(
- wallet: wallet,
+ final result = await _wallet!.cancelTransaction(
transactionId: txSlateId,
);
Logging.instance.d("cancel $txSlateId result: $result");
@@ -145,10 +148,9 @@ class EpiccashWallet extends Bip39Wallet {
// ================= Slatepack Operations ===================================
Future<String> _ensureWalletOpen() async {
- final existing = await secureStorageInterface.read(
- key: '${walletId}_wallet',
- );
- if (existing != null && existing.isNotEmpty) return existing;
+ if (_wallet != null) {
+ return _wallet!.handle;
+ }
final config = await _getRealConfig();
final password = await secureStorageInterface.read(
@@ -157,12 +159,18 @@ class EpiccashWallet extends Bip39Wallet {
if (password == null) {
throw Exception('Wallet password not found');
}
- final opened = await libEpic.openWallet(config: config, password: password);
+
+ _wallet = await epic.EpicWallet.load(
+ config: config,
+ password: password,
+ );
+
+ final handle = _wallet!.handle;
await secureStorageInterface.write(
key: '${walletId}_wallet',
- value: opened,
+ value: handle,
);
- return opened;
+ return handle;
}
/// Create a slatepack for sending Epic Cash.
@@ -174,12 +182,14 @@ class EpiccashWallet extends Bip39Wallet {
}) async {
try {
_hackedCheckTorNodePrefs();
- final handle = await _ensureWalletOpen();
+ await _ensureWalletOpen();
+ if (_wallet == null) {
+ throw Exception('Wallet not initialized');
+ }
final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig();
// Create transaction with returnSlate: true for slatepack mode.
- final result = await libEpic.createTransaction(
- wallet: handle,
+ final result = await _wallet!.createTransaction(
amount: amount.raw.toInt(),
address: 'slate', // Not used in slate mode.
secretKeyIndex: 0,
@@ -256,11 +266,13 @@ class EpiccashWallet extends Bip39Wallet {
Future<EpicReceiveResult> receiveSlatepack(String slateJson) async {
try {
_hackedCheckTorNodePrefs();
- final handle = await _ensureWalletOpen();
+ await _ensureWalletOpen();
+ if (_wallet == null) {
+ throw Exception('Wallet not initialized');
+ }
// Receive and get updated slate JSON.
- final received = await libEpic.txReceive(
- wallet: handle,
+ final received = await _wallet!.txReceive(
slateJson: slateJson,
);
@@ -282,11 +294,13 @@ class EpiccashWallet extends Bip39Wallet {
Future<EpicFinalizeResult> finalizeSlatepack(String slateJson) async {
try {
_hackedCheckTorNodePrefs();
- final handle = await _ensureWalletOpen();
+ await _ensureWalletOpen();
+ if (_wallet == null) {
+ throw Exception('Wallet not initialized');
+ }
// Finalize transaction.
- final finalized = await libEpic.txFinalize(
- wallet: handle,
+ final finalized = await _wallet!.txFinalize(
slateJson: slateJson,
);
@@ -451,16 +465,17 @@ class EpiccashWallet extends Bip39Wallet {
int satoshiAmount, {
bool ifErrorEstimateFee = false,
}) async {
- final wallet = await secureStorageInterface.read(key: '${walletId}_wallet');
+ await _ensureWalletOpen();
+ if (_wallet == null) {
+ throw Exception('Wallet not initialized');
+ }
try {
_hackedCheckTorNodePrefs();
final available = info.cachedBalance.spendable.raw.toInt();
- final transactionFees = await libEpic.getTransactionFees(
- wallet: wallet!,
+ final transactionFees = await _wallet!.getTransactionFees(
amount: satoshiAmount,
minimumConfirmations: cryptoCurrency.minConfirms,
- available: available,
);
int realFee = 0;
@@ -482,13 +497,15 @@ class EpiccashWallet extends Bip39Wallet {
Future<void> _startSync() async {
_hackedCheckTorNodePrefs();
Logging.instance.d("request start sync");
- final wallet = await secureStorageInterface.read(key: '${walletId}_wallet');
+ await _ensureWalletOpen();
+ if (_wallet == null) {
+ throw Exception('Wallet not initialized');
+ }
const int refreshFromNode = 1;
if (!syncMutex.isLocked) {
await syncMutex.protect(() async {
// How does getWalletBalances start syncing????
- await libEpic.getWalletBalances(
- wallet: wallet!,
+ await _wallet!.getBalances(
refreshFromNode: refreshFromNode,
minimumConfirmations: 10,
);
@@ -508,13 +525,15 @@ class EpiccashWallet extends Bip39Wallet {
>
_allWalletBalances() async {
_hackedCheckTorNodePrefs();
- final wallet = await secureStorageInterface.read(key: '${walletId}_wallet');
+ await _ensureWalletOpen();
+ if (_wallet == null) {
+ throw Exception('Wallet not initialized');
+ }
const refreshFromNode = 0;
- return await libEpic.getWalletBalances(
- wallet: wallet!,
+ return (await _wallet!.getBalances(
refreshFromNode: refreshFromNode,
minimumConfirmations: cryptoCurrency.minConfirms,
- );
+ )).toRecord();
}
Future<bool> _testEpicboxServer(EpicBoxConfigModel epicboxConfig) async {
@@ -606,10 +625,12 @@ class EpiccashWallet extends Bip39Wallet {
int index,
EpicBoxConfigModel epicboxConfig,
) async {
- final wallet = await secureStorageInterface.read(key: '${walletId}_wallet');
+ await _ensureWalletOpen();
+ if (_wallet == null) {
+ throw Exception('Wallet not initialized');
+ }
- final walletAddress = await libEpic.getAddressInfo(
- wallet: wallet!,
+ final walletAddress = await _wallet!.getAddressInfo(
index: index,
epicboxConfig: epicboxConfig.toString(),
);
@@ -631,10 +652,6 @@ class EpiccashWallet extends Bip39Wallet {
Future<void> _startScans() async {
try {
- final wallet = await secureStorageInterface.read(
- key: '${walletId}_wallet',
- );
-
// max number of blocks to scan per loop iteration
const scanChunkSize = 10000;
@@ -661,8 +678,7 @@ class EpiccashWallet extends Bip39Wallet {
"chainHeight: $chainHeight, lastScannedBlock: $lastScannedBlock",
);
- final int nextScannedBlock = await libEpic.scanOutputs(
- wallet: wallet!,
+ final int nextScannedBlock = await _wallet!.scanOutputs(
startHeight: lastScannedBlock,
numberOfBlocks: scanChunkSize,
);
@@ -829,18 +845,15 @@ class EpiccashWallet extends Bip39Wallet {
final String name = walletId;
- await libEpic.initializeNewWallet(
+ _wallet = await epic.EpicWallet.create(
config: stringConfig,
mnemonic: mnemonicString,
password: password,
name: name,
- );
+ ); // Spawns worker isolate
- //Open wallet
- encodedWallet = await libEpic.openWallet(
- config: stringConfig,
- password: password,
- );
+ // Store the wallet handle for listeners
+ encodedWallet = _wallet!.handle;
await secureStorageInterface.write(
key: '${walletId}_wallet',
value: encodedWallet,
@@ -879,13 +892,15 @@ class EpiccashWallet extends Bip39Wallet {
key: '${walletId}_password',
);
- final walletOpen = await libEpic.openWallet(
+ _wallet = await epic.EpicWallet.load(
config: config,
password: password!,
- );
+ ); // Spawns worker isolate
+
+ // Store the wallet handle for listeners
await secureStorageInterface.write(
key: '${walletId}_wallet',
- value: walletOpen,
+ value: _wallet!.handle,
);
await updateNode();
@@ -907,9 +922,6 @@ class EpiccashWallet extends Bip39Wallet {
Future<TxData> confirmSend({required TxData txData}) async {
try {
_hackedCheckTorNodePrefs();
- final wallet = await secureStorageInterface.read(
- key: '${walletId}_wallet',
- );
final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig();
// TODO determine whether it is worth sending change to a change address.
@@ -928,8 +940,7 @@ class EpiccashWallet extends Bip39Wallet {
if (receiverAddress.startsWith("http://") ||
receiverAddress.startsWith("https://")) {
- final httpResult = await libEpic.txHttpSend(
- wallet: wallet!,
+ final httpResult = await _wallet!.txHttpSend(
selectionStrategyIsAll: 0,
minimumConfirmations: cryptoCurrency.minConfirms,
message: txData.noteOnChain ?? "",
@@ -942,15 +953,14 @@ class EpiccashWallet extends Bip39Wallet {
slateJson: '',
);
} else {
- transaction = await libEpic.createTransaction(
- wallet: wallet!,
+ transaction = (await _wallet!.createTransaction(
amount: txData.recipients!.first.amount.raw.toInt(),
address: txData.recipients!.first.address,
secretKeyIndex: 0,
epicboxConfig: epicboxConfig.toString(),
minimumConfirmations: cryptoCurrency.minConfirms,
note: txData.noteOnChain!,
- );
+ )).toRecord();
}
final Map<String, String> txAddressInfo = {};
@@ -1087,23 +1097,15 @@ class EpiccashWallet extends Bip39Wallet {
secureStore: secureStorageInterface,
);
Logging.instance.w("Epic rescan temporary delete result: $result");
- await libEpic.recoverWallet(
+
+ await _wallet?.close();
+ _wallet = await epic.EpicWallet.recover(
config: stringConfig,
password: password!,
mnemonic: await getMnemonic(),
name: info.walletId,
);
- //Open Wallet
- final walletOpen = await libEpic.openWallet(
- config: stringConfig,
- password: password,
- );
- await secureStorageInterface.write(
- key: '${walletId}_wallet',
- value: walletOpen,
- );
-
highestPercent = 0;
} else {
await updateNode();
@@ -1126,7 +1128,8 @@ class EpiccashWallet extends Bip39Wallet {
value: epicboxConfig.toString(),
);
- await libEpic.recoverWallet(
+ await _wallet?.close();
+ _wallet = await epic.EpicWallet.recover(
config: stringConfig,
password: password,
mnemonic: await getMnemonic(),
@@ -1148,16 +1151,6 @@ class EpiccashWallet extends Bip39Wallet {
isar: mainDB.isar,
);
- //Open Wallet
- final walletOpen = await libEpic.openWallet(
- config: stringConfig,
- password: password,
- );
- await secureStorageInterface.write(
- key: '${walletId}_wallet',
- value: walletOpen,
- );
-
await _generateAndStoreReceivingAddressForIndex(
epicData.receivingIndex,
);
@@ -1333,9 +1326,6 @@ class EpiccashWallet extends Bip39Wallet {
Future<void> updateTransactions() async {
try {
_hackedCheckTorNodePrefs();
- final wallet = await secureStorageInterface.read(
- key: '${walletId}_wallet',
- );
const refreshFromNode = 1;
final myAddresses = await mainDB
@@ -1350,8 +1340,7 @@ class EpiccashWallet extends Bip39Wallet {
.findAll();
final myAddressesSet = myAddresses.toSet();
- final transactions = await libEpic.getTransactions(
- wallet: wallet!,
+ final transactions = await _wallet!.getTransactions(
refreshFromNode: refreshFromNode,
);
@@ -1365,8 +1354,8 @@ class EpiccashWallet extends Bip39Wallet {
libEpic.txTypeIsReceiveCancelled(tx.txType);
final slateId = tx.txSlateId;
final commitId = slatesToCommits[slateId]?['commitId'] as String?;
- final numberOfMessages = tx.messages?.length;
- final onChainNote = tx.messages?.first.message;
+ final numberOfMessages = tx.messages?.messages.length;
+ final onChainNote = tx.messages?.messages.first.message;
final addressFrom = slatesToCommits[slateId]?["from"] as String?;
final addressTo = slatesToCommits[slateId]?["to"] as String?;
@@ -1619,6 +1608,10 @@ class EpiccashWallet extends Bip39Wallet {
libEpic.stopEpicboxListener(walletId: walletId);
timer?.cancel();
timer = null;
+
+ await _wallet?.close();
+ _wallet = null;
+
await super.exit();
Logging.instance.d("EpicCash_wallet exit finished");
}
Why this scored 31/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.