What changed, and why it matters
This commit fixes several bugs in Stack Wallet's backup restore flow. The most notable change is that retrying a failed wallet restore now properly re-initializes the wallet (including opening it for Cryptonote-based coins like Monero) before attempting recovery, instead of calling recover() on a wallet that may not be ready. It also moves an Android file-write operation to a background isolate to avoid blocking the user interface, and makes a wallet ID lookup more forgiving when a backed-up wallet no longer exists.
Treat this as a routine bug-fix patch with possible reliability/security side effects. Review the new _retry() flow to confirm wallet.exit() is always called on error paths (currently it is not, which may leave wallet resources open). Consider adding automated tests for failed-restore retry scenarios for Monero/Wownero/Cryptonote wallets. No immediate security response is indicated by the diff alone.
Security signals we found
Fixes backup restore retry path that previously could operate on an uninitialized wallet object
Adds proper wallet lifecycle management (init/open/exit) before recovery operations
Moves blocking I/O to a background isolate, reducing UI freeze and potential user-induced corruption
Adds defensive null fallback for wallet ID remapping during backup restoration
Evidence from the diff
The patch refactors SWB (Stack Wallet Backup) restore logic. In restore_create_backup.dart, the runtimeType switch is replaced with a Dart 3 pattern-matching switch on wallet instances, grouping Monero/Wownero under CryptonoteWallet. A didExit flag is introduced so wallet.exit() is not called twice when the restore succeeds. In restoring_wallet_card.dart, the inline retry logic is extracted into _retry(), which now calls wallet.init(isRestore: true) and, for CryptonoteWallet, wallet.open() before wallet.recover(isRescan: true). This addresses a likely bug where retrying a failed restore would call recover() on an uninitialized/closed wallet. fs.dart moves the Android SAF write to a compute() isolate using RootIsolateToken/BackgroundIsolateBinaryMessenger to prevent UI jank. A null-safety fallback is added to an oldToNewWalletIdMap lookup.
Changed components
lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dartlib/pages/settings_views/global_settings_view/stack_backup_views/sub_widgets/restoring_wallet_card.dartlib/utilities/fs.dartInspect captured patch +155 / −165
diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart
index 4a9cfd3..5f93a9d 100644
--- a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart
+++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart
@@ -53,8 +53,6 @@ import '../../../../../wallets/isar/models/wallet_info.dart';
import '../../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart';
import '../../../../../wallets/wallet/impl/epiccash_wallet.dart';
import '../../../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart';
-import '../../../../../wallets/wallet/impl/monero_wallet.dart';
-import '../../../../../wallets/wallet/impl/wownero_wallet.dart';
import '../../../../../wallets/wallet/impl/xelis_wallet.dart';
import '../../../../../wallets/wallet/intermediate/cryptonote_wallet.dart';
import '../../../../../wallets/wallet/wallet.dart';
@@ -414,6 +412,7 @@ abstract class SWB {
mnemonicPassphrase: mnemonicPassphrase,
);
Wallet? wallet;
+ bool didExit = false;
try {
String? serializedKeys;
String? multisigConfig;
@@ -458,25 +457,21 @@ abstract class SWB {
viewOnlyData: viewOnlyData,
);
- switch (wallet.runtimeType) {
- case const (EpiccashWallet):
- await (wallet as EpiccashWallet).init(isRestore: true);
+ switch (wallet) {
+ case EpiccashWallet():
+ await wallet.init(isRestore: true);
break;
- case const (MimblewimblecoinWallet):
- await (wallet as MimblewimblecoinWallet).init(isRestore: true);
+ case MimblewimblecoinWallet():
+ await wallet.init(isRestore: true);
break;
- case const (MoneroWallet):
- await (wallet as MoneroWallet).init(isRestore: true);
+ case CryptonoteWallet():
+ await wallet.init(isRestore: true);
break;
- case const (WowneroWallet):
- await (wallet as WowneroWallet).init(isRestore: true);
- break;
-
- case const (XelisWallet):
- await (wallet as XelisWallet).init(isRestore: true);
+ case XelisWallet():
+ await wallet.init(isRestore: true);
break;
default:
@@ -556,11 +551,14 @@ abstract class SWB {
await restoringFuture;
+ final currentAddress = await wallet.getCurrentReceivingAddress();
+
+ await wallet.exit();
+ didExit = true;
+
Logging.instance.i(
"SWB restored: ${info.walletId} ${info.name} ${info.coin.prettyName}",
);
-
- final currentAddress = await wallet.getCurrentReceivingAddress();
uiState?.update(
walletId: info.walletId,
restoringStatus: StackRestoringStatus.success,
@@ -571,7 +569,11 @@ abstract class SWB {
mnemonicPassphrase: mnemonicPassphrase,
);
} catch (e, s) {
- Logging.instance.i("", error: e, stackTrace: s);
+ Logging.instance.e(
+ "${wallet?.runtimeType} _asyncRestore failed",
+ error: e,
+ stackTrace: s,
+ );
uiState?.update(
walletId: info.walletId,
restoringStatus: StackRestoringStatus.failed,
@@ -580,7 +582,9 @@ abstract class SWB {
);
return false;
} finally {
- await wallet?.exit();
+ if (!didExit) {
+ await wallet?.exit();
+ }
}
return true;
}
@@ -1231,7 +1235,8 @@ abstract class SWB {
TradeWalletLookup lookup = TradeWalletLookup.fromJson(json);
// update walletIds
final List<String> walletIds = lookup.walletIds
- .map((e) => oldToNewWalletIdMap[e]!)
+ // fallback to e as that wallet may have been deleted in the past
+ .map((e) => oldToNewWalletIdMap[e] ?? e)
.toList();
lookup = lookup.copyWith(walletIds: walletIds);
diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_widgets/restoring_wallet_card.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_widgets/restoring_wallet_card.dart
index f38104c..9a792c4 100644
--- a/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_widgets/restoring_wallet_card.dart
+++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_widgets/restoring_wallet_card.dart
@@ -22,18 +22,20 @@ import '../../../../../themes/stack_colors.dart';
import '../../../../../themes/theme_providers.dart';
import '../../../../../utilities/assets.dart';
import '../../../../../utilities/enums/stack_restoring_status.dart';
+import '../../../../../utilities/logger.dart';
import '../../../../../utilities/text_styles.dart';
import '../../../../../utilities/util.dart';
+import '../../../../../wallets/wallet/impl/epiccash_wallet.dart';
+import '../../../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart';
+import '../../../../../wallets/wallet/impl/xelis_wallet.dart';
+import '../../../../../wallets/wallet/intermediate/cryptonote_wallet.dart';
import '../../../../../widgets/loading_indicator.dart';
import '../../../../../widgets/rounded_container.dart';
import '../sub_views/recovery_phrase_view.dart';
import 'restoring_item_card.dart';
class RestoringWalletCard extends ConsumerStatefulWidget {
- const RestoringWalletCard({
- super.key,
- required this.provider,
- });
+ const RestoringWalletCard({super.key, required this.provider});
final ChangeNotifierProvider<WalletRestoreState> provider;
@@ -45,13 +47,78 @@ class RestoringWalletCard extends ConsumerStatefulWidget {
class _RestoringWalletCardState extends ConsumerState<RestoringWalletCard> {
late final ChangeNotifierProvider<WalletRestoreState> provider;
+ Future<void> _retry() async {
+ final wallet = ref.read(provider).wallet!;
+ try {
+ ref
+ .read(stackRestoringUIStateProvider)
+ .update(
+ walletId: wallet.walletId,
+ restoringStatus: StackRestoringStatus.restoring,
+ );
+
+ switch (wallet) {
+ case EpiccashWallet():
+ await wallet.init(isRestore: true);
+ break;
+
+ case MimblewimblecoinWallet():
+ await wallet.init(isRestore: true);
+ break;
+
+ case CryptonoteWallet():
+ await wallet.init(isRestore: true);
+ await wallet.open();
+ break;
+
+ case XelisWallet():
+ await wallet.init(isRestore: true);
+ break;
+
+ default:
+ await wallet.init();
+ }
+
+ await wallet.recover(isRescan: true);
+
+ final address = await wallet.getCurrentReceivingAddress();
+
+ await wallet.exit();
+
+ if (mounted) {
+ ref
+ .read(stackRestoringUIStateProvider)
+ .update(
+ walletId: wallet.walletId,
+ restoringStatus: StackRestoringStatus.success,
+ address: address?.value,
+ );
+ }
+ } catch (e, s) {
+ Logging.instance.e(
+ "retry SWB single wallet tapped",
+ error: e,
+ stackTrace: s,
+ );
+ if (mounted) {
+ ref
+ .read(stackRestoringUIStateProvider)
+ .update(
+ walletId: wallet.walletId,
+ restoringStatus: StackRestoringStatus.failed,
+ );
+ }
+ }
+ }
+
Widget _getIconForState(StackRestoringStatus state) {
switch (state) {
case StackRestoringStatus.waiting:
return SvgPicture.asset(
Assets.svg.loader,
- color:
- Theme.of(context).extension<StackColors>()!.buttonBackSecondary,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.buttonBackSecondary,
);
case StackRestoringStatus.restoring:
return const LoadingIndicator();
@@ -81,8 +148,9 @@ class _RestoringWalletCardState extends ConsumerState<RestoringWalletCard> {
@override
Widget build(BuildContext context) {
final coin = ref.watch(provider.select((value) => value.coin));
- final restoringStatus =
- ref.watch(provider.select((value) => value.restoringState));
+ final restoringStatus = ref.watch(
+ provider.select((value) => value.restoringState),
+ );
return !Util.isDesktop
? RestoringItemCard(
left: SizedBox(
@@ -93,9 +161,7 @@ class _RestoringWalletCardState extends ConsumerState<RestoringWalletCard> {
color: ref.watch(pCoinColor(coin)),
child: Center(
child: SvgPicture.file(
- File(
- ref.watch(coinIconProvider(coin)),
- ),
+ File(ref.watch(coinIconProvider(coin))),
height: 20,
width: 20,
),
@@ -103,36 +169,7 @@ class _RestoringWalletCardState extends ConsumerState<RestoringWalletCard> {
),
),
onRightTapped: restoringStatus == StackRestoringStatus.failed
- ? () async {
- final wallet = ref.read(provider).wallet!;
-
- ref.read(stackRestoringUIStateProvider).update(
- walletId: wallet.walletId,
- restoringStatus: StackRestoringStatus.restoring,
- );
-
- try {
- await wallet.recover(isRescan: true);
-
- if (mounted) {
- final address =
- await wallet.getCurrentReceivingAddress();
-
- ref.read(stackRestoringUIStateProvider).update(
- walletId: wallet.walletId,
- restoringStatus: StackRestoringStatus.success,
- address: address!.value,
- );
- }
- } catch (_) {
- if (mounted) {
- ref.read(stackRestoringUIStateProvider).update(
- walletId: wallet.walletId,
- restoringStatus: StackRestoringStatus.failed,
- );
- }
- }
- }
+ ? _retry
: null,
right: SizedBox(
width: 20,
@@ -149,30 +186,27 @@ class _RestoringWalletCardState extends ConsumerState<RestoringWalletCard> {
style: STextStyles.errorSmall(context),
)
: ref.watch(provider.select((value) => value.address)) != null
- ? Text(
- ref.watch(provider.select((value) => value.address))!,
- style: STextStyles.infoSmall(context),
- )
- : null,
+ ? Text(
+ ref.watch(provider.select((value) => value.address))!,
+ style: STextStyles.infoSmall(context),
+ )
+ : null,
button: restoringStatus == StackRestoringStatus.failed
? Container(
height: 20,
decoration: BoxDecoration(
- color: Theme.of(context)
- .extension<StackColors>()!
- .buttonBackSecondary,
- borderRadius: BorderRadius.circular(
- 1000,
- ),
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.buttonBackSecondary,
+ borderRadius: BorderRadius.circular(1000),
),
child: RawMaterialButton(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
- splashColor:
- Theme.of(context).extension<StackColors>()!.highlight,
+ splashColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.highlight,
shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(
- 1000,
- ),
+ borderRadius: BorderRadius.circular(1000),
),
onPressed: () async {
final mnemonic = ref.read(provider).mnemonic;
@@ -193,9 +227,9 @@ class _RestoringWalletCardState extends ConsumerState<RestoringWalletCard> {
child: Text(
"Show recovery phrase",
style: STextStyles.infoSmall(context).copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .accentColorDark,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorDark,
),
),
),
@@ -216,11 +250,7 @@ class _RestoringWalletCardState extends ConsumerState<RestoringWalletCard> {
color: ref.watch(pCoinColor(coin)),
child: Center(
child: SvgPicture.file(
- File(
- ref.watch(
- coinIconProvider(coin),
- ),
- ),
+ File(ref.watch(coinIconProvider(coin))),
height: 20,
width: 20,
),
@@ -228,60 +258,7 @@ class _RestoringWalletCardState extends ConsumerState<RestoringWalletCard> {
),
),
onRightTapped: restoringStatus == StackRestoringStatus.failed
- ? () async {
- final wallet = ref.read(provider).wallet!;
-
- ref.read(stackRestoringUIStateProvider).update(
- walletId: wallet.walletId,
- restoringStatus: StackRestoringStatus.restoring,
- );
-
- try {
- // final mnemonicList = await manager.mnemonic;
- // int maxUnusedAddressGap = 20;
- // if (coin is Firo) {
- // maxUnusedAddressGap = 50;
- // }
- // const maxNumberOfIndexesToCheck = 1000;
- //
- // if (mnemonicList.isEmpty) {
- // await manager.recoverFromMnemonic(
- // mnemonic: ref.read(provider).mnemonic!,
- // mnemonicPassphrase:
- // ref.read(provider).mnemonicPassphrase!,
- // maxUnusedAddressGap: maxUnusedAddressGap,
- // maxNumberOfIndexesToCheck:
- // maxNumberOfIndexesToCheck,
- // height: ref.read(provider).height ?? 0,
- // );
- // } else {
- // await manager.fullRescan(
- // maxUnusedAddressGap,
- // maxNumberOfIndexesToCheck,
- // );
- // }
-
- await wallet.recover(isRescan: true);
-
- if (mounted) {
- final address =
- await wallet.getCurrentReceivingAddress();
-
- ref.read(stackRestoringUIStateProvider).update(
- walletId: wallet.walletId,
- restoringStatus: StackRestoringStatus.success,
- address: address!.value,
- );
- }
- } catch (_) {
- if (mounted) {
- ref.read(stackRestoringUIStateProvider).update(
- walletId: wallet.walletId,
- restoringStatus: StackRestoringStatus.failed,
- );
- }
- }
- }
+ ? _retry
: null,
right: SizedBox(
width: 20,
@@ -298,31 +275,27 @@ class _RestoringWalletCardState extends ConsumerState<RestoringWalletCard> {
style: STextStyles.errorSmall(context),
)
: ref.watch(provider.select((value) => value.address)) != null
- ? Text(
- ref.watch(provider.select((value) => value.address))!,
- style: STextStyles.infoSmall(context),
- )
- : null,
+ ? Text(
+ ref.watch(provider.select((value) => value.address))!,
+ style: STextStyles.infoSmall(context),
+ )
+ : null,
button: restoringStatus == StackRestoringStatus.failed
? Container(
height: 20,
decoration: BoxDecoration(
- color: Theme.of(context)
- .extension<StackColors>()!
- .buttonBackSecondary,
- borderRadius: BorderRadius.circular(
- 1000,
- ),
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.buttonBackSecondary,
+ borderRadius: BorderRadius.circular(1000),
),
child: RawMaterialButton(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
- splashColor: Theme.of(context)
- .extension<StackColors>()!
- .highlight,
+ splashColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.highlight,
shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(
- 1000,
- ),
+ borderRadius: BorderRadius.circular(1000),
),
onPressed: () async {
final mnemonic = ref.read(provider).mnemonic;
@@ -343,9 +316,9 @@ class _RestoringWalletCardState extends ConsumerState<RestoringWalletCard> {
child: Text(
"Show recovery phrase",
style: STextStyles.infoSmall(context).copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .accentColorDark,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorDark,
),
),
),
diff --git a/lib/utilities/fs.dart b/lib/utilities/fs.dart
index b1c1b84..fc6f087 100644
--- a/lib/utilities/fs.dart
+++ b/lib/utilities/fs.dart
@@ -2,6 +2,8 @@ import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
import 'package:path/path.dart';
import 'package:saf_stream/saf_stream.dart';
import 'package:saf_util/saf_util.dart';
@@ -33,14 +35,24 @@ abstract final class FS {
String fileName,
) {
if (Platform.isAndroid && dirPath.startsWith("content://")) {
- return SafStream().writeFileBytes(
- dirPath,
- fileName,
- "txt",
- utf8.encode(content),
- );
+ final token = ServicesBinding.rootIsolateToken!;
+ return compute(_androidSafWriteComputeWrapper, (
+ dirPath: dirPath,
+ fileName: fileName,
+ content: content,
+ isoToken: token,
+ ));
} else {
return File(join(dirPath, fileName)).writeAsString(content, flush: true);
}
}
}
+
+Future<void> _androidSafWriteComputeWrapper(
+ ({String dirPath, String fileName, String content, RootIsolateToken isoToken})
+ args,
+) async {
+ BackgroundIsolateBinaryMessenger.ensureInitialized(args.isoToken);
+ final bytes = utf8.encode(args.content);
+ await SafStream().writeFileBytes(args.dirPath, args.fileName, "txt", bytes);
+}
Why this scored 32/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.