option to set restore/scan height for epiccash and a hacked in rescan
What changed, and why it matters
This commit adds a user-facing option to set a restore/scan height and perform a rescan for the Epic Cash (Mimblewimble) wallet in Stack Wallet. The code itself is a feature addition, but it uses a risky workaround: during a rescan it temporarily deletes and recovers the wallet from the seed phrase, and it rewrites existing transaction IDs by appending a counter so they don't collide in the local database. There is no direct evidence in the commit that this is a security fix or that it fixes a known vulnerability; it appears to be a functional improvement with some fragile data-handling code.
Treat this as a feature commit rather than a security patch. Reviewers should audit the rescan path for race conditions, verify that deleteEpicWallet() truly only removes local wallet files and not seed backups, validate that the txid-mutation scheme cannot collide with real txids or corrupt transaction notes, and add tests for restore-height edge cases and duplicate detection. End users should ensure they have verified backups before using the new rescan feature.
Security signals we found
Temporary deletion and recovery of the Epic Cash wallet during rescan relies on secure storage of mnemonic and password
Transaction ID mutation with hardcoded delimiters may break uniqueness assumptions or downstream lookups
Fuzzy duplicate detection based on amount/fee/height/type/slateId could incorrectly merge or skip distinct transactions
No input validation or bounds checking on the user-supplied restore height beyond int.tryParse and >= 0
UI now exposes rescan/height editing for Epic Cash, increasing attack surface for social-engineering or accidental destructive actions
Evidence from the diff
The patch wires Epic Cash into the existing ‘edit refresh/restore height’ and ‘rescan’ UI flows that were previously limited to Cryptonote wallets. The Epic Cash rescan implementation is described by the author as ‘hacked in’: it calls deleteEpicWallet(), then libEpic.recoverWallet() with the stored mnemonic and password, then reopens the wallet. To preserve user transaction notes across rescans, the code mutates existing TransactionV2 records by appending a rescan counter to the txid string (using delimiters mid=’:’” and end=”’:”), and uses a fuzzy equality helper (_fuzzyEquals) to detect duplicates. The commit also adds a toString() helper for EpicTransaction/EpicMessage and adjusts UI copy for Cryptonote/Epic rescans.
Changed components
lib/wallets/wallet/impl/epiccash_wallet.dartlib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dartlib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dartlib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dartlib/wl_gen/interfaces/libepiccash_interface.dartInspect captured patch +273 / −28
diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart
index b347431..3eccb79 100644
--- a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart
+++ b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart
@@ -154,6 +154,13 @@ class _WalletNetworkSettingsViewState
// pop rescanning dialog
Navigator.of(context, rootNavigator: isDesktop).pop();
+ final String message;
+ if (wallet is CryptonoteWallet || wallet is EpiccashWallet) {
+ message = "Rescan started";
+ } else {
+ message = "Rescan completed";
+ }
+
// show success
await showDialog<dynamic>(
context: context,
@@ -164,7 +171,7 @@ class _WalletNetworkSettingsViewState
builder: (child) =>
DesktopDialog(maxHeight: 150, maxWidth: 500, child: child),
child: StackDialog(
- title: "Rescan completed",
+ title: message,
rightButton: TextButton(
style: Theme.of(context)
.extension<StackColors>()!
@@ -380,11 +387,8 @@ class _WalletNetworkSettingsViewState
),
title: Text("Network", style: STextStyles.navBarTitle(context)),
actions: [
- if (ref.watch(pWalletCoin(widget.walletId)) is! Epiccash &&
- ref.watch(pWalletCoin(widget.walletId))
- is! Mimblewimblecoin ||
- ref.watch(pWalletCoin(widget.walletId))
- is! Mimblewimblecoin)
+ if (ref.watch(pWalletCoin(widget.walletId))
+ is! Mimblewimblecoin)
Padding(
padding: const EdgeInsets.only(
top: 10,
@@ -984,7 +988,6 @@ class _WalletNetworkSettingsViewState
),
),
if (isDesktop &&
- ref.watch(pWalletCoin(widget.walletId)) is! Epiccash &&
ref.watch(pWalletCoin(widget.walletId)) is! Mimblewimblecoin)
RoundedWhiteContainer(
borderColor: isDesktop
diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart
index 70ca0a4..b83be7e 100644
--- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart
+++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart
@@ -11,7 +11,9 @@ import '../../../../utilities/constants.dart';
import '../../../../utilities/text_styles.dart';
import '../../../../utilities/util.dart';
import '../../../../wallets/isar/providers/wallet_info_provider.dart';
+import '../../../../wallets/wallet/impl/epiccash_wallet.dart';
import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart';
+import '../../../../wallets/wallet/supporting/epiccash_wallet_info_extension.dart';
import '../../../../widgets/background.dart';
import '../../../../widgets/conditional_parent.dart';
import '../../../../widgets/custom_buttons/app_bar_icon_button.dart';
@@ -48,13 +50,19 @@ class _EditRefreshHeightViewState extends ConsumerState<EditRefreshHeightView> {
try {
final newHeight = int.tryParse(_controller.text);
if (newHeight != null && newHeight >= 0) {
- await ref
- .read(pWalletInfo(widget.walletId))
- .updateRestoreHeight(
- newRestoreHeight: newHeight,
- isar: ref.read(mainDBProvider).isar,
- );
final wallet = ref.read(pWallets).getWallet(widget.walletId);
+
+ if (wallet is EpiccashWallet) {
+ await wallet.updateRestoreHeight(newHeight);
+ } else {
+ await ref
+ .read(pWalletInfo(widget.walletId))
+ .updateRestoreHeight(
+ newRestoreHeight: newHeight,
+ isar: ref.read(mainDBProvider).isar,
+ );
+ }
+
if (wallet is CryptonoteWallet && wallet.wallet != null) {
wallet.setRefreshFromBlockHeight(newHeight);
}
@@ -95,7 +103,13 @@ class _EditRefreshHeightViewState extends ConsumerState<EditRefreshHeightView> {
super.initState();
_controller = TextEditingController();
final wallet = ref.read(pWallets).getWallet(widget.walletId);
- if (wallet is CryptonoteWallet && wallet.wallet != null) {
+ if (wallet is EpiccashWallet) {
+ _controller.text = ref
+ .read(pWalletInfo(widget.walletId))
+ .epicData!
+ .restoreHeight
+ .toString();
+ } else if (wallet is CryptonoteWallet && wallet.wallet != null) {
_controller.text = wallet.getRefreshFromBlockHeight().toString();
} else {
_controller.text = ref
diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart
index a5464cd..6a266b2 100644
--- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart
+++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart
@@ -23,6 +23,7 @@ import '../../../../utilities/text_styles.dart';
import '../../../../wallets/isar/models/wallet_info.dart';
import '../../../../wallets/isar/providers/wallet_info_provider.dart';
import '../../../../wallets/wallet/impl/bitcoin_wallet.dart';
+import '../../../../wallets/wallet/impl/epiccash_wallet.dart';
import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart';
@@ -591,8 +592,9 @@ class _WalletSettingsWalletSettingsViewState
),
),
),
- if (wallet is CryptonoteWallet) const SizedBox(height: 8),
- if (wallet is CryptonoteWallet)
+ if (wallet is CryptonoteWallet || wallet is EpiccashWallet)
+ const SizedBox(height: 8),
+ if (wallet is CryptonoteWallet || wallet is EpiccashWallet)
RoundedWhiteContainer(
padding: const EdgeInsets.all(0),
child: RawMaterialButton(
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart
index fc34800..4beb825 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart
@@ -31,6 +31,7 @@ import '../../../../utilities/util.dart';
import '../../../../wallets/crypto_currency/intermediate/frost_currency.dart';
import '../../../../wallets/crypto_currency/intermediate/nano_currency.dart';
import '../../../../wallets/isar/providers/wallet_info_provider.dart';
+import '../../../../wallets/wallet/impl/epiccash_wallet.dart';
import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart';
@@ -478,8 +479,9 @@ class WalletOptionsPopupMenu extends ConsumerWidget {
),
),
),
- if (isCN) const SizedBox(height: 8),
- if (isCN)
+ if (isCN || wallet is EpiccashWallet)
+ const SizedBox(height: 8),
+ if (isCN || wallet is EpiccashWallet)
TransparentButton(
onPressed: onRefreshHeightPressed,
child: Padding(
diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart
index a734626..3fc6b56 100644
--- a/lib/wallets/wallet/impl/epiccash_wallet.dart
+++ b/lib/wallets/wallet/impl/epiccash_wallet.dart
@@ -16,6 +16,7 @@ import '../../../models/isar/models/blockchain_data/transaction.dart';
import '../../../models/isar/models/blockchain_data/v2/input_v2.dart';
import '../../../models/isar/models/blockchain_data/v2/output_v2.dart';
import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
+import '../../../models/isar/models/transaction_note.dart';
import '../../../models/node_model.dart';
import '../../../models/paymint/fee_object_model.dart';
import '../../../pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart';
@@ -131,6 +132,15 @@ class EpiccashWallet extends Bip39Wallet {
return _epicBoxConfig;
}
+ Future<void> updateRestoreHeight(int height) async {
+ final epicData = info.epicData!.copyWith(restoreHeight: height);
+
+ await info.updateExtraEpiccashWalletInfo(
+ epicData: epicData,
+ isar: mainDB.isar,
+ );
+ }
+
// ================= Private =================================================
Future<String> _getConfig() async {
@@ -444,6 +454,48 @@ class EpiccashWallet extends Bip39Wallet {
return height;
}
+ static const _mid = "_:'", _end = "':";
+
+ /// eeehhhhhhhhhhhhhhh
+ bool _fuzzyEquals(TransactionV2 a, TransactionV2 b) {
+ final isAmountReceivedMatches =
+ a.getAmountReceivedInThisWallet(
+ fractionDigits: cryptoCurrency.fractionDigits,
+ ) ==
+ b.getAmountReceivedInThisWallet(
+ fractionDigits: cryptoCurrency.fractionDigits,
+ );
+
+ final isFeeMatches =
+ a.getFee(fractionDigits: cryptoCurrency.fractionDigits) ==
+ b.getFee(fractionDigits: cryptoCurrency.fractionDigits);
+
+ final isAmountSentMatches =
+ a.getAmountSentFromThisWallet(
+ fractionDigits: cryptoCurrency.fractionDigits,
+ subtractFee: false,
+ ) ==
+ b.getAmountSentFromThisWallet(
+ fractionDigits: cryptoCurrency.fractionDigits,
+ subtractFee: false,
+ );
+
+ final isHeightMatches = a.height == b.height;
+ final isTxTypeMatches = a.type == b.type && a.subType == b.subType;
+ final isSlateIdMatches = a.slateId == b.slateId;
+
+ if (isHeightMatches &&
+ isTxTypeMatches &&
+ isFeeMatches &&
+ isSlateIdMatches &&
+ isAmountSentMatches &&
+ isAmountReceivedMatches) {
+ return true;
+ }
+
+ return false;
+ }
+
// ============== Overrides ==================================================
@override
@@ -663,8 +715,67 @@ class EpiccashWallet extends Bip39Wallet {
_hackedCheckTorNodePrefs();
await refreshMutex.protect(() async {
if (isRescan) {
- // clear blockchain info
- await mainDB.deleteWalletBlockchainData(walletId);
+ // keep old transactions but id them somehow
+ // with the current db, there is no other way besides editing the
+ // unique key (txid+walletId). Since we cannot change the wallet id we
+ // must therefore hack some stupid stuff into the txid...
+ final currentTxns1 = await mainDB.isar.transactionV2s
+ .where()
+ .walletIdEqualTo(walletId)
+ .findAll();
+
+ final List<TransactionV2> currentTxns = [];
+
+ for (final current in currentTxns1) {
+ if (currentTxns.where((e) => _fuzzyEquals(e, current)).isNotEmpty) {
+ Logging.instance.f("DELETING: $current");
+ await mainDB.isar.writeTxn(() async {
+ await mainDB.isar.transactionV2s.delete(current.id);
+ });
+ } else {
+ currentTxns.add(current);
+ }
+ }
+
+ for (final current in currentTxns) {
+ // check notes first
+ final note = await mainDB.isar.transactionNotes
+ .where()
+ .txidWalletIdEqualTo(current.slateId ?? current.txid, walletId)
+ .findFirst();
+
+ // now handle transaction
+ final firstTime =
+ !(current.txid.contains(_mid) && current.txid.endsWith(_end));
+
+ final String txid;
+ if (firstTime) {
+ txid = "${current.txid}${_mid}0$_end";
+ } else {
+ // this should always be 2 parts if we've gotten this far
+ final parts = current.txid.split(_mid);
+ final rescanCount =
+ int.parse(parts.last.replaceFirst(_end, "")) + 1;
+ txid = "${parts.first}$_mid$rescanCount$_end";
+ }
+
+ // finally update in db
+ await mainDB.isar.writeTxn(() async {
+ final updated = current.copyWith(txid: txid);
+ if (note != null) {
+ final updatedNote = TransactionNote(
+ walletId: walletId,
+ txid: current.slateId ?? txid,
+ value: note.value,
+ );
+ await mainDB.isar.transactionNotes.delete(note.id);
+ await mainDB.isar.transactionNotes.put(updatedNote);
+ }
+
+ await mainDB.isar.transactionV2s.delete(current.id);
+ await mainDB.isar.transactionV2s.put(updated);
+ });
+ }
await info.updateExtraEpiccashWalletInfo(
epicData: info.epicData!.copyWith(
@@ -673,7 +784,35 @@ class EpiccashWallet extends Bip39Wallet {
isar: mainDB.isar,
);
- unawaited(refresh(doScan: true));
+ final stringConfig = await _getRealConfig();
+ final password = await secureStorageInterface.read(
+ key: '${walletId}_password',
+ );
+
+ // maybe there is some way to tel epic-wallet rust to fully rescan...
+ final result = await deleteEpicWallet(
+ walletId: walletId,
+ secureStore: secureStorageInterface,
+ );
+ Logging.instance.w("Epic rescan temporary delete result: $result");
+ await libEpic.recoverWallet(
+ 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();
final String password = generatePassword();
@@ -731,8 +870,9 @@ class EpiccashWallet extends Bip39Wallet {
epicData.receivingIndex,
);
}
- unawaited(refresh(doScan: false));
});
+
+ unawaited(refresh(doScan: isRescan));
} catch (e, s) {
Logging.instance.e(
"Exception rethrown from electrumx_mixin recover(): ",
@@ -1018,15 +1158,70 @@ class EpiccashWallet extends Bip39Wallet {
otherData: jsonEncode(otherData),
);
- txns.add(txn);
+ if (txns.where((e) => _fuzzyEquals(e, txn)).isEmpty) {
+ txns.add(txn);
+ }
}
+ final existingTxns = await mainDB.isar.transactionV2s
+ .where()
+ .walletIdEqualTo(walletId)
+ .findAll();
+
await mainDB.isar.writeTxn(() async {
- await mainDB.isar.transactionV2s
- .where()
- .walletIdEqualTo(walletId)
- .deleteAll();
- await mainDB.isar.transactionV2s.putAll(txns);
+ for (final tx in txns) {
+ final existingMatches = existingTxns.where(
+ (e) => _fuzzyEquals(e, tx),
+ );
+ TransactionNote? note;
+ if (existingMatches.isNotEmpty) {
+ // there should only ever be one. If more then something is\
+ // wrong somewhere, probably
+ if (existingMatches.length > 1) {
+ Logging.instance.w(
+ "existingMatches length: ${existingMatches.length}",
+ );
+ }
+ for (final match in existingMatches) {
+ if (await mainDB.isar.transactionV2s
+ .where()
+ .txidWalletIdEqualTo(match.txid, walletId)
+ .idProperty()
+ .findFirst() !=
+ null) {
+ note = await mainDB.isar.transactionNotes
+ .where()
+ .txidWalletIdEqualTo(match.slateId ?? match.txid, walletId)
+ .findFirst();
+
+ await mainDB.isar.transactionV2s.delete(match.id);
+ }
+ }
+ }
+
+ final id = await mainDB.isar.transactionV2s
+ .where()
+ .txidWalletIdEqualTo(tx.txid, walletId)
+ .idProperty()
+ .findFirst();
+
+ if (id != null) {
+ await mainDB.isar.transactionV2s.delete(id);
+ }
+
+ if (note != null) {
+ await mainDB.isar.transactionNotes.delete(note.id);
+ await mainDB.isar.transactionNotes.put(
+ TransactionNote(
+ walletId: walletId,
+ txid: tx.slateId ?? tx.txid,
+ value: note.value,
+ ),
+ );
+ }
+
+ await mainDB.isar.transactionV2s.put(tx);
+ }
});
} catch (e, s) {
Logging.instance.e(
diff --git a/lib/wl_gen/interfaces/libepiccash_interface.dart b/lib/wl_gen/interfaces/libepiccash_interface.dart
index af5e653..287993f 100644
--- a/lib/wl_gen/interfaces/libepiccash_interface.dart
+++ b/lib/wl_gen/interfaces/libepiccash_interface.dart
@@ -1,3 +1,5 @@
+import 'dart:math';
+
export '../generated/libepiccash_interface_impl.dart';
abstract class LibEpicCashInterface {
@@ -141,6 +143,24 @@ class EpicTransaction {
this.kernelLookupMinHeight,
this.paymentProof,
});
+
+ @override
+ String toString() {
+ return 'EpicTransaction('
+ 'id: $id, '
+ 'txSlateId: $txSlateId, '
+ 'type: $txType, '
+ 'confirmed: $confirmed, '
+ 'inputs: $numInputs, '
+ 'outputs: $numOutputs, '
+ 'credited: $amountCredited, '
+ 'debited: $amountDebited, '
+ 'fee: $fee, '
+ 'created: $creationTs, '
+ 'confirmed: $confirmationTs, '
+ 'messages: ${messages?.length ?? 0}'
+ ')';
+ }
}
class EpicMessage {
@@ -155,6 +175,15 @@ class EpicMessage {
this.message,
this.messageSig,
});
+
+ @override
+ String toString() {
+ return 'EpicMessage('
+ 'id: $id, '
+ 'publicKey: ${publicKey.substring(0, 8)}..., '
+ 'message: ${message != null ? '"${message!.substring(0, min(20, message!.length))}..."' : 'null'}'
+ ')';
+ }
}
class BadHttpAddressException implements Exception {}
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.