From 93e6aefbe1fa1c2ae34db8187d7f9145a0452cb6 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 21 Feb 2026 11:02:39 -0600 Subject: [PATCH 01/19] feat: add key-based wallet restore interface and data model --- lib/wallets/isar/models/wallet_info.dart | 1 + lib/wallets/wallet/impl/monero_wallet.dart | 18 +++++++++++++ lib/wallets/wallet/wallet.dart | 12 +++++++++ .../interfaces/cs_monero_interface.dart | 10 +++++++ ...XMR_cs_monero_interface_impl.template.dart | 27 +++++++++++++++++++ 5 files changed, 68 insertions(+) diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart index 12329f8ceb..4eb5d577d4 100644 --- a/lib/wallets/isar/models/wallet_info.dart +++ b/lib/wallets/isar/models/wallet_info.dart @@ -584,4 +584,5 @@ abstract class WalletInfoKeys { "solanaCustomTokenMintAddressesKey"; static const String firoMasternodeCollateralDismissed = "firoMasternodeCollateralDismissedKey"; + static const String isRestoredFromKeysKey = "isRestoredFromKeysKey"; } diff --git a/lib/wallets/wallet/impl/monero_wallet.dart b/lib/wallets/wallet/impl/monero_wallet.dart index 935d5ad3aa..469382b91d 100644 --- a/lib/wallets/wallet/impl/monero_wallet.dart +++ b/lib/wallets/wallet/impl/monero_wallet.dart @@ -88,6 +88,24 @@ class MoneroWallet extends LibMoneroWallet { height: height, ); + @override + Future getRestoredFromKeysWallet({ + required String path, + required String password, + required String address, + required String privateViewKey, + required String privateSpendKey, + int height = 0, + }) => csMonero.getRestoredFromKeysWallet( + walletId: walletId, + path: path, + password: password, + address: address, + privateViewKey: privateViewKey, + privateSpendKey: privateSpendKey, + height: height, + ); + @override void invalidSeedLengthCheck(int length) { if (length != 25 && length != 16) { diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 1aa40ef6a7..0850cd6f4f 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -153,6 +153,7 @@ abstract class Wallet { String? mnemonicPassphrase, String? privateKey, ViewOnlyWalletData? viewOnlyData, + String? keysRestoreData, }) async { // TODO: rework soon? if (walletInfo.isViewOnly && viewOnlyData == null) { @@ -223,6 +224,13 @@ abstract class Wallet { ); } + if (keysRestoreData != null) { + await secureStorageInterface.write( + key: keysRestoreDataKey(walletId: walletInfo.walletId), + value: keysRestoreData, + ); + } + // Store in db after wallet creation await wallet.mainDB.isar.writeTxn(() async { await wallet.mainDB.isar.walletInfo.put(walletInfo); @@ -321,6 +329,10 @@ abstract class Wallet { static String getViewOnlyWalletDataSecStoreKey({required String walletId}) => "${walletId}_viewOnlyWalletData"; + // secure storage key + static String keysRestoreDataKey({required String walletId}) => + "${walletId}_keysRestoreData"; + //============================================================================ // ========== Private ======================================================== diff --git a/lib/wl_gen/interfaces/cs_monero_interface.dart b/lib/wl_gen/interfaces/cs_monero_interface.dart index f9f30d5c83..284fe17a50 100644 --- a/lib/wl_gen/interfaces/cs_monero_interface.dart +++ b/lib/wl_gen/interfaces/cs_monero_interface.dart @@ -58,6 +58,16 @@ abstract class CsMoneroInterface { int height = 0, }); + Future getRestoredFromKeysWallet({ + required String walletId, + required String path, + required String password, + required String address, + required String privateViewKey, + required String privateSpendKey, + int height = 0, + }); + Future getTxKey(WrappedWallet wallet, String txid); Future save(WrappedWallet wallet); diff --git a/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart b/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart index b957ad36dd..e053bf9d19 100644 --- a/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart +++ b/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart @@ -173,6 +173,33 @@ class _CsMoneroInterfaceImpl extends CsMoneroInterface { ); } + @override + Future getRestoredFromKeysWallet({ + required String walletId, + required String path, + required String password, + required String address, + required String privateViewKey, + required String privateSpendKey, + int network = 0, // default to mainnet + int height = 0, + }) async { + return WrappedWallet( + await lib_monero.MoneroWallet.restoreWalletFromKeys( + path: path, + password: password, + language: "", + address: address, + viewKey: privateViewKey, + spendKey: privateSpendKey, + restoreHeight: height, + networkType: lib_monero.Network.values.firstWhere( + (e) => e.value == network, + ), + ), + ); + } + @override Future getTxKey(WrappedWallet wallet, String txid) => wallet.get().getTxKey(txid); From 67e68e7e8a7f42d66907e5bb7af14ea79becd3bb Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 21 Feb 2026 11:45:25 -0600 Subject: [PATCH 02/19] feat: add key-based recovery path for Monero wallets --- .../intermediate/lib_monero_wallet.dart | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index 6c0c49884e..c29b5c8b2b 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -163,6 +163,15 @@ abstract class LibMoneroWallet int height = 0, }); + Future getRestoredFromKeysWallet({ + required String path, + required String password, + required String address, + required String privateViewKey, + required String privateSpendKey, + int height = 0, + }); + void invalidSeedLengthCheck(int length); bool walletExists(String path); @@ -406,6 +415,14 @@ abstract class LibMoneroWallet return; } + final keysDataJson = await secureStorageInterface.read( + key: Wallet.keysRestoreDataKey(walletId: walletId), + ); + if (keysDataJson != null) { + await _recoverFromKeys(keysDataJson); + return; + } + await refreshMutex.protect(() async { final mnemonic = await getMnemonic(); final seedOffset = await getMnemonicPassphrase(); @@ -1543,6 +1560,102 @@ abstract class LibMoneroWallet csMonero.setRefreshFromBlockHeight(wallet!, newHeight); } + // ============== Key-based restore ========================================== + + Future _recoverFromKeys(String keysDataJson) async { + await refreshMutex.protect(() async { + final data = jsonDecode(keysDataJson) as Map; + final address = data["address"] as String; + final viewKey = data["viewKey"] as String; + final spendKey = data["spendKey"] as String; + + try { + final height = max(info.restoreHeight, 0); + + await info.updateRestoreHeight( + newRestoreHeight: height, + isar: mainDB.isar, + ); + + final String name = walletId; + final path = await pathForWallet(name: name, type: compatType); + + final password = generatePassword(); + await secureStorageInterface.write( + key: lib_monero_compat.libMoneroWalletPasswordKey(walletId), + value: password, + ); + + final wallet = await getRestoredFromKeysWallet( + path: path, + password: password, + address: address, + privateViewKey: viewKey, + privateSpendKey: spendKey, + height: height, + ); + + if (this.wallet != null) { + await exit(); + } + this.wallet = wallet; + + _setListener(); + + // Try to recover the mnemonic from the restored wallet + try { + final seed = await csMonero.getSeed(wallet); + if (seed.isNotEmpty) { + await secureStorageInterface.write( + key: Wallet.mnemonicKey(walletId: walletId), + value: seed, + ); + await secureStorageInterface.write( + key: Wallet.mnemonicPassphraseKey(walletId: walletId), + value: "", + ); + } + } catch (_) { + // Not all key-restored wallets can recover the seed + } + + final newReceivingAddress = + await getCurrentReceivingAddress() ?? + Address( + walletId: walletId, + derivationIndex: 0, + derivationPath: null, + value: await csMonero.getAddress(this.wallet!), + publicKey: [], + type: AddressType.cryptonote, + subType: AddressSubType.receiving, + ); + + await mainDB.updateOrPutAddresses([newReceivingAddress]); + await info.updateReceivingAddress( + newAddress: newReceivingAddress.value, + isar: mainDB.isar, + ); + + await updateNode(); + _setListener(); + + await csMonero.rescanBlockchain(this.wallet!); + await csMonero.startSyncing(this.wallet!); + + await csMonero.startListeners(this.wallet!); + csMonero.startAutoSaving(this.wallet!); + } catch (e, s) { + Logging.instance.e( + "Exception rethrown from _recoverFromKeys(): ", + error: e, + stackTrace: s, + ); + rethrow; + } + }); + } + // ============== View only ================================================== @override From 82c567f3cb9538639a056808167f1beeb3baf58b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 21 Feb 2026 15:32:58 -0600 Subject: [PATCH 03/19] feat: add URI restore option to Monero wallet restore UI --- .../restore_options_view.dart | 579 ++++++++++++++++-- 1 file changed, 512 insertions(+), 67 deletions(-) diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart index e650564a18..626d7b4d7a 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart @@ -8,6 +8,10 @@ * */ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -15,10 +19,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:logger/logger.dart'; import 'package:tuple/tuple.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; +import '../../../../models/keys/view_only_wallet_data.dart'; +import '../../../../pages_desktop_specific/desktop_home_view.dart'; import '../../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import '../../../../providers/global/secure_store_provider.dart'; +import '../../../../providers/providers.dart'; import '../../../../providers/ui/verify_recovery_phrase/mnemonic_word_count_state_provider.dart'; import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/address_utils.dart'; import '../../../../utilities/assets.dart'; import '../../../../utilities/constants.dart'; import '../../../../utilities/format.dart'; @@ -28,6 +38,9 @@ import '../../../../utilities/util.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/crypto_currency/interfaces/view_only_option_currency_interface.dart'; import '../../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; +import '../../../../wallets/isar/models/wallet_info.dart'; +import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; +import '../../../../wallets/wallet/wallet.dart'; import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../../widgets/custom_buttons/blue_text_button.dart'; @@ -36,6 +49,7 @@ import '../../../../widgets/desktop/desktop_app_bar.dart'; import '../../../../widgets/desktop/desktop_scaffold.dart'; import '../../../../widgets/expandable.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; +import '../../../../widgets/options.dart'; import '../../../../widgets/rounded_white_container.dart'; import '../../../../widgets/stack_text_field.dart'; import '../../../../widgets/textfield_icon_button.dart'; @@ -43,10 +57,15 @@ import '../../../../widgets/toggle.dart'; import '../../../../wl_gen/interfaces/cs_monero_interface.dart'; import '../../../../wl_gen/interfaces/cs_salvium_interface.dart'; import '../../../../wl_gen/interfaces/cs_wownero_interface.dart'; +import '../../../home_view/home_view.dart'; import '../../create_or_restore_wallet_view/sub_widgets/coin_image.dart'; +import '../confirm_recovery_dialog.dart'; import '../restore_view_only_wallet_view.dart'; import '../restore_wallet_view.dart'; import '../sub_widgets/mnemonic_word_count_select_sheet.dart'; +import '../sub_widgets/restore_failed_dialog.dart'; +import '../sub_widgets/restore_succeeded_dialog.dart'; +import '../sub_widgets/restoring_dialog.dart'; import 'sub_widgets/mobile_mnemonic_length_selector.dart'; import 'sub_widgets/restore_from_date_picker.dart'; import 'sub_widgets/restore_options_next_button.dart'; @@ -85,6 +104,7 @@ class _RestoreOptionsViewState extends ConsumerState { bool _hasBlockHeight = false; DateTime? _restoreFromDate; bool hidePassword = true; + WalletUriData? _uriData; @override void initState() { @@ -143,26 +163,32 @@ class _RestoreOptionsViewState extends ConsumerState { } else { height = int.tryParse(_blockHeightController.text) ?? 0; } - if (!_showViewOnlyOption) { - await Navigator.of(context).pushNamed( - RestoreWalletView.routeName, - arguments: Tuple5( - walletName, - coin, - ref.read(mnemonicWordCountStateProvider.state).state, - height, - passwordController.text, - ), - ); - } else { - await Navigator.of(context).pushNamed( - RestoreViewOnlyWalletView.routeName, - arguments: ( - walletName: walletName, - coin: coin, - restoreBlockHeight: height, - ), - ); + switch (_restoreMode) { + case 0: // Seed + await Navigator.of(context).pushNamed( + RestoreWalletView.routeName, + arguments: Tuple5( + walletName, + coin, + ref.read(mnemonicWordCountStateProvider.state).state, + height, + passwordController.text, + ), + ); + break; + case 1: // View Only + await Navigator.of(context).pushNamed( + RestoreViewOnlyWalletView.routeName, + arguments: ( + walletName: walletName, + coin: coin, + restoreBlockHeight: height, + ), + ); + break; + case 2: // URI + await _attemptUriRestore(height); + break; } } } finally { @@ -254,7 +280,193 @@ class _RestoreOptionsViewState extends ConsumerState { } } - bool _showViewOnlyOption = false; + Future _attemptUriRestore(int fallbackHeight) async { + final data = _uriData; + if (data == null) return; + + if (!isDesktop) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 100)); + } + + if (!mounted) return; + + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return ConfirmRecoveryDialog( + onConfirm: () => _doUriRestore(data, fallbackHeight), + ); + }, + ); + } + + Future _doUriRestore(WalletUriData data, int fallbackHeight) async { + if (!Platform.isLinux && !isDesktop) await WakelockPlus.enable(); + + final restoreHeight = data.height ?? fallbackHeight; + + try { + final Map otherDataJson; + if (data.seed != null) { + otherDataJson = {}; + } else if (data.isViewOnly) { + otherDataJson = { + WalletInfoKeys.isViewOnlyKey: true, + WalletInfoKeys.viewOnlyTypeIndexKey: + ViewOnlyWalletType.cryptonote.index, + }; + } else { + otherDataJson = {WalletInfoKeys.isRestoredFromKeysKey: true}; + } + + final info = WalletInfo.createNew( + coin: coin, + name: walletName, + restoreHeight: restoreHeight, + otherDataJsonString: jsonEncode(otherDataJson), + ); + + bool isRestoring = true; + if (mounted) { + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return RestoringDialog( + onCancel: () async { + isRestoring = false; + await ref + .read(pWallets) + .deleteWallet(info, ref.read(secureStoreProvider)); + }, + ); + }, + ), + ); + } + + try { + var node = ref + .read(nodeServiceChangeNotifierProvider) + .getPrimaryNodeFor(currency: coin); + + if (node == null) { + node = coin.defaultNode(isPrimary: true); + await ref + .read(nodeServiceChangeNotifierProvider) + .save(node, null, false); + } + + final Wallet wallet; + if (data.seed != null) { + wallet = await Wallet.create( + walletInfo: info, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + mnemonic: data.seed, + ); + } else if (data.isViewOnly) { + final viewOnlyData = CryptonoteViewOnlyWalletData( + walletId: info.walletId, + address: data.address ?? "", + privateViewKey: data.viewKey!, + ); + wallet = await Wallet.create( + walletInfo: info, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + viewOnlyData: viewOnlyData, + ); + } else { + final keysRestoreData = jsonEncode({ + "address": data.address ?? "", + "viewKey": data.viewKey!, + "spendKey": data.spendKey!, + }); + wallet = await Wallet.create( + walletInfo: info, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + keysRestoreData: keysRestoreData, + ); + } + + if (wallet is CryptonoteWallet) { + await wallet.init(isRestore: true); + } else { + await wallet.init(); + } + + await wallet.recover(isRescan: false); + + if (mounted) { + await wallet.info.setMnemonicVerified( + isar: ref.read(mainDBProvider).isar, + ); + + if (ref.read(pDuress)) { + await wallet.info.updateDuressVisibilityStatus( + isDuressVisible: true, + isar: ref.read(mainDBProvider).isar, + ); + } + + ref.read(pWallets).addWallet(wallet); + + if (mounted) { + if (isDesktop) { + Navigator.of( + context, + ).popUntil(ModalRoute.withName(DesktopHomeView.routeName)); + } else { + unawaited( + Navigator.of( + context, + ).pushNamedAndRemoveUntil(HomeView.routeName, (route) => false), + ); + } + + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) => const RestoreSucceededDialog(), + ); + } + } + } catch (e) { + if (mounted && isRestoring) { + Navigator.pop(context); + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) => RestoreFailedDialog( + errorMessage: e.toString(), + walletId: info.walletId, + walletName: info.name, + ), + ); + } + } + } finally { + if (!Platform.isLinux && !isDesktop) await WakelockPlus.disable(); + } + } + + // 0 = Seed, 1 = View Only, 2 = URI (Monero only) + int _restoreMode = 0; @override Widget build(BuildContext context) { @@ -306,59 +518,96 @@ class _RestoreOptionsViewState extends ConsumerState { SizedBox( height: isDesktop ? 56 : 48, width: isDesktop ? 490 : null, - child: Toggle( - key: UniqueKey(), - onText: "Seed", - offText: "View Only", - onColor: Theme.of( - context, - ).extension()!.popupBG, - offColor: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - isOn: _showViewOnlyOption, - onValueChanged: (value) { - setState(() { - _showViewOnlyOption = value; - }); - }, - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), + child: coin is Monero + ? Options( + key: UniqueKey(), + texts: const ["Seed", "View Only", "URI"], + onColor: Theme.of( + context, + ).extension()!.popupBG, + offColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + selectedIndex: _restoreMode, + onValueChanged: (value) { + setState(() { + _restoreMode = value; + }); + }, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ) + : Toggle( + key: UniqueKey(), + onText: "Seed", + offText: "View Only", + onColor: Theme.of( + context, + ).extension()!.popupBG, + offColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + isOn: _restoreMode == 1, + onValueChanged: (value) { + setState(() { + _restoreMode = value ? 1 : 0; + }); + }, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), ), if (coin is ViewOnlyOptionCurrencyInterface) SizedBox(height: isDesktop ? 40 : 24), - _showViewOnlyOption - ? ViewOnlyRestoreOption( - coin: coin, - dateController: _dateController, - dateChooserFunction: isDesktop - ? chooseDesktopDate - : chooseDate, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, - ) - : SeedRestoreOption( - coin: coin, - dateController: _dateController, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, - pwController: passwordController, - pwFocusNode: passwordFocusNode, - dateChooserFunction: isDesktop - ? chooseDesktopDate - : chooseDate, - chooseMnemonicLength: chooseMnemonicLength, - ), + if (_restoreMode == 1) + ViewOnlyRestoreOption( + coin: coin, + dateController: _dateController, + dateChooserFunction: isDesktop + ? chooseDesktopDate + : chooseDate, + blockHeightController: _blockHeightController, + blockHeightFocusNode: _blockHeightFocusNode, + ) + else if (_restoreMode == 2) + UriRestoreOption( + coin: coin, + dateController: _dateController, + dateChooserFunction: isDesktop + ? chooseDesktopDate + : chooseDate, + blockHeightController: _blockHeightController, + blockHeightFocusNode: _blockHeightFocusNode, + onParsed: (data) => setState(() => _uriData = data), + ) + else + SeedRestoreOption( + coin: coin, + dateController: _dateController, + blockHeightController: _blockHeightController, + blockHeightFocusNode: _blockHeightFocusNode, + pwController: passwordController, + pwFocusNode: passwordFocusNode, + dateChooserFunction: isDesktop + ? chooseDesktopDate + : chooseDate, + chooseMnemonicLength: chooseMnemonicLength, + ), if (!isDesktop) const Spacer(flex: 3), SizedBox(height: isDesktop ? 32 : 12), RestoreOptionsNextButton( isDesktop: isDesktop, - onPressed: ref.watch(_pIsUsingDate) || _hasBlockHeight + onPressed: _restoreMode == 2 + ? (_uriData != null ? nextPressed : null) + : ref.watch(_pIsUsingDate) || _hasBlockHeight ? nextPressed : null, ), @@ -906,3 +1155,199 @@ class _ViewOnlyRestoreOptionState extends ConsumerState { _blockFieldEmpty = widget.blockHeightController.text.isEmpty; } } + +class UriRestoreOption extends ConsumerStatefulWidget { + const UriRestoreOption({ + super.key, + required this.coin, + required this.dateController, + required this.dateChooserFunction, + required this.blockHeightController, + required this.blockHeightFocusNode, + required this.onParsed, + }); + + final CryptoCurrency coin; + final TextEditingController dateController; + final TextEditingController blockHeightController; + final FocusNode blockHeightFocusNode; + final void Function(WalletUriData?) onParsed; + + final Future Function() dateChooserFunction; + + @override + ConsumerState createState() => _UriRestoreOptionState(); +} + +class _UriRestoreOptionState extends ConsumerState { + bool _blockFieldEmpty = true; + late final TextEditingController _uriController; + + @override + void initState() { + super.initState(); + _blockFieldEmpty = widget.blockHeightController.text.isEmpty; + _uriController = TextEditingController(); + } + + @override + void dispose() { + _uriController.dispose(); + super.dispose(); + } + + void _onUriChanged(String value) { + WalletUriData? parsed; + try { + parsed = WalletUriData.fromUriString(value); + } catch (_) { + parsed = null; + } + widget.onParsed(parsed); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Paste wallet URI", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of(context).extension()!.textDark3, + ) + : STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + SizedBox(height: Util.isDesktop ? 16 : 8), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + controller: _uriController, + style: Util.isDesktop + ? STextStyles.desktopTextMedium(context).copyWith(height: 2) + : STextStyles.field(context), + decoration: + standardInputDecoration( + "monero_wallet:
?seed=...", + FocusNode(), + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: TextFieldIconButton( + child: _uriController.text.isNotEmpty + ? XIcon( + width: Util.isDesktop ? 24 : 16, + height: Util.isDesktop ? 24 : 16, + ) + : const SizedBox.shrink(), + onTap: () { + _uriController.clear(); + _onUriChanged(""); + }, + ), + ), + ), + maxLines: 3, + minLines: 1, + onChanged: _onUriChanged, + ), + ), + SizedBox(height: Util.isDesktop ? 24 : 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + ref.watch(_pIsUsingDate) ? "Choose start date" : "Block height", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ) + : STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + CustomTextButton( + text: ref.watch(_pIsUsingDate) ? "Use block height" : "Use date", + onTap: () => ref.read(_pIsUsingDate.notifier).state = !ref.read( + _pIsUsingDate, + ), + ), + ], + ), + SizedBox(height: Util.isDesktop ? 16 : 8), + ref.watch(_pIsUsingDate) + ? RestoreFromDatePicker( + onTap: widget.dateChooserFunction, + controller: widget.dateController, + ) + : ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + focusNode: widget.blockHeightFocusNode, + controller: widget.blockHeightController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textInputAction: TextInputAction.done, + style: Util.isDesktop + ? STextStyles.desktopTextMedium( + context, + ).copyWith(height: 2) + : STextStyles.field(context), + onChanged: (value) { + setState(() { + _blockFieldEmpty = value.isEmpty; + }); + }, + decoration: + standardInputDecoration( + "Start scanning from...", + widget.blockHeightFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: TextFieldIconButton( + child: !_blockFieldEmpty + ? XIcon( + width: Util.isDesktop ? 24 : 16, + height: Util.isDesktop ? 24 : 16, + ) + : const SizedBox.shrink(), + onTap: () { + widget.blockHeightController.text = ""; + setState(() { + _blockFieldEmpty = true; + }); + }, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + RoundedWhiteContainer( + child: Center( + child: Text( + ref.watch(_pIsUsingDate) + ? "Choose the date you made the wallet (approximate is fine)" + : "Enter the initial block height of the wallet", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ) + : STextStyles.smallMed12(context).copyWith(fontSize: 10), + ), + ), + ), + ], + ); + } +} From 0c2bbc23b1f8fec1d885fe21ac2af8e9ad9b6704 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 21 Feb 2026 16:33:29 -0600 Subject: [PATCH 04/19] feat: add generic WalletUriData class and wallet URI parser Co-Authored-By: detherminal <76167420+detherminal@users.noreply.github.com> --- lib/utilities/address_utils.dart | 191 +++++++++++++++++++++++++++++-- 1 file changed, 181 insertions(+), 10 deletions(-) diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index ff0880cec7..57b1e765b7 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -85,34 +85,51 @@ class AddressUtils { return result; } + /// Strips surrounding single or double quotes from a string. + static String _stripQuotes(String value) { + if (value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")))) { + return value.substring(1, value.length - 1); + } + return value; + } + /// Helper method to parse and normalize query parameters. + /// + /// Keys are lowercased and dashes are replaced with underscores so that + /// e.g. `spend-key` and `spend_key` are treated identically. + /// Surrounding quotation marks on values are stripped. static Map _parseQueryParameters(Map params) { final Map result = {}; params.forEach((key, value) { - final lowerKey = key.toLowerCase(); - if (recognizedParams.contains(lowerKey)) { - switch (lowerKey) { + // Normalize: lowercase + dashes -> underscores. + final normalizedKey = key.toLowerCase().replaceAll('-', '_'); + final strippedValue = _stripQuotes(value); + + if (recognizedParams.contains(normalizedKey)) { + switch (normalizedKey) { case 'amount': case 'tx_amount': - result['amount'] = _normalizeAmount(value); + result['amount'] = _normalizeAmount(strippedValue); break; case 'label': case 'recipient_name': - result['label'] = Uri.decodeComponent(value); + result['label'] = Uri.decodeComponent(strippedValue); break; case 'message': case 'tx_description': - result['message'] = Uri.decodeComponent(value); + result['message'] = Uri.decodeComponent(strippedValue); break; case 'tx_payment_id': - result['tx_payment_id'] = Uri.decodeComponent(value); + result['tx_payment_id'] = Uri.decodeComponent(strippedValue); break; default: - result[lowerKey] = Uri.decodeComponent(value); + result[normalizedKey] = Uri.decodeComponent(strippedValue); } } else { - // Include unrecognized parameters as-is. - result[key] = Uri.decodeComponent(value); + // Include unrecognized parameters with normalized key. + result[normalizedKey] = Uri.decodeComponent(strippedValue); } }); return result; @@ -174,6 +191,39 @@ class AddressUtils { } } + /// Parses a wallet URI and returns a Map. + /// + /// Returns null on failure to parse. + static Map? _parseWalletUri(String uri) { + final String scheme; + final Map parsedData = {}; + if (uri.split(":")[0].contains("_")) { + // We need to check if the uri is compatible because RFC 3986 + // does not allow underscores in the scheme. + final String compatibleUri = uri.replaceFirst("_", ""); + scheme = uri.split(":")[0]; + parsedData.addAll(_parseUri(compatibleUri)); + } else { + parsedData.addAll(_parseUri(uri)); + scheme = parsedData['scheme'] as String? ?? ''; + } + + // Match the normalized wallet-uri scheme exactly. A bare payment scheme + // (e.g. "monero") must not be accepted here as a wallet uri; only the + // "_wallet" form is valid. + final possibleCoins = AppConfig.coins.where( + (e) => "${e.uriScheme}_wallet" == scheme, + ); + + if (possibleCoins.length != 1) { + return null; + } + + parsedData["coin"] = possibleCoins.first; + + return parsedData; + } + /// Builds a uri string with the given address and query parameters (if any) static String buildUriString( String scheme, @@ -324,3 +374,124 @@ class PaymentUriData { "additionalParams: $additionalParams" " }"; } + +class WalletUriData { + final CryptoCurrency coin; + final String? address; + final String? seed; + final String? spendKey; + final String? viewKey; + final int? height; + final List? txids; + + bool get isViewOnly => spendKey == null && seed == null; + + WalletUriData({ + required this.coin, + this.address, + this.seed, + this.spendKey, + this.viewKey, + this.height, + this.txids, + }); + + factory WalletUriData.fromUriString(String uri) { + final map = AddressUtils._parseWalletUri(uri); + + if (map == null) { + throw Exception("Invalid wallet URI"); + } + + return WalletUriData.fromJson(map, map["coin"] as CryptoCurrency); + } + + /// Factory constructor with validation logic according to the spec: + /// https://github.com/monero-project/monero/wiki/URI-Formatting#wallet-definition-scheme + factory WalletUriData.fromJson( + Map json, + CryptoCurrency coin, + ) { + final address = json["address"] as String?; + final spendKey = json["spend_key"] as String?; + final viewKey = json["view_key"] as String?; + final seed = json["seed"] as String?; + final height = json["height"] != null + ? int.tryParse(json["height"].toString()) + : null; + final txid = json["txid"] as String?; + + // Must have seed XOR view_key (spend_key is optional). + // May have seed only, view_key + spend_key, or view_key only. + final hasSeed = seed != null; + final hasKeys = viewKey != null; + + if (hasSeed && hasKeys) { + throw const FormatException( + "Invalid: cannot specify both seed and keys.", + ); + } + if (!hasSeed && !hasKeys) { + throw const FormatException( + "Invalid: must specify either seed or view_key.", + ); + } + + // Spend_key requires view_key. + if (spendKey != null && viewKey == null) { + throw const FormatException("Invalid: spend_key requires view_key."); + } + + // Height requires absence of txid. + if (height != null && txid != null) { + throw const FormatException( + "Invalid: cannot specify both height and txid.", + ); + } + + // Parse txids if present. + List? txids; + if (txid != null && txid.isNotEmpty) { + txids = txid + .split(";") + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + } + + return WalletUriData( + coin: coin, + address: address, + spendKey: spendKey, + viewKey: viewKey, + seed: seed, + height: height, + txids: txids, + ); + } + + @override + String toString() { + return "WalletUriData { " + "coin: $coin, " + "address: $address, " + "seed: $seed, " + "spendKey: $spendKey, " + "viewKey: $viewKey, " + "height: $height, " + "txids: $txids" + " }"; + } + + String toJson() { + return jsonEncode({ + "coin": coin.prettyName, + "address": address, + "seed": seed, + "spendKey": spendKey, + "viewKey": viewKey, + "height": height, + "txids": txids, + }); + } +} From 237dc00b71c5fdcf7485268c4c70d0fbff3b06e8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 22 Feb 2026 12:05:16 -0600 Subject: [PATCH 05/19] fix: guard against short addresses these shouldn't exist/happen, but do/can --- lib/utilities/address_utils.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index 57b1e765b7..b97acd4036 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -27,6 +27,7 @@ class AddressUtils { }; static String condenseAddress(String address) { + if (address.length < 10) return address; return '${address.substring(0, 5)}...${address.substring(address.length - 5)}'; } From 85d22d2f05c5be4b95b088ddeeb8df6878e52f75 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 22 Feb 2026 12:17:12 -0600 Subject: [PATCH 06/19] feat: use height param --- .../restore_options_view/restore_options_view.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart index 626d7b4d7a..30118d8868 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart @@ -1203,6 +1203,13 @@ class _UriRestoreOptionState extends ConsumerState { } catch (_) { parsed = null; } + + // If the URI contains a height, switch to block height mode and populate. + if (parsed?.height != null) { + ref.read(_pIsUsingDate.notifier).state = false; + widget.blockHeightController.text = parsed!.height.toString(); + } + widget.onParsed(parsed); } From cb0110ebe4d66c62520c6146f9d8c1f4f4882794 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 22 Feb 2026 12:51:35 -0600 Subject: [PATCH 07/19] fix: fix non-view-only keys-only wallet keys dialog --- .../wallet_settings_view/wallet_settings_view.dart | 6 +++++- .../delete_wallet_warning_view.dart | 6 +++++- .../sub_widgets/desktop_attention_delete_wallet.dart | 7 ++++++- .../sub_widgets/unlock_wallet_keys_desktop.dart | 6 +++++- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart index 4fd2e8f95d..516dde6413 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart @@ -200,7 +200,11 @@ class _WalletSettingsViewState extends ConsumerState { (wallet as ViewOnlyOptionInterface).isViewOnly) { // TODO: is something needed here? } else { - mnemonic = await wallet.getMnemonicAsWords(); + try { + mnemonic = await wallet.getMnemonicAsWords(); + } catch (_) { + // Key-restored wallets may not have a mnemonic. + } } } } diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart index d6bc5f2e2a..a054054656 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart @@ -141,7 +141,11 @@ class DeleteWalletWarningView extends ConsumerWidget { wallet.isViewOnly) { viewOnlyData = await wallet.getViewOnlyWalletData(); } else if (wallet is MnemonicInterface) { - mnemonic = await wallet.getMnemonicAsWords(); + try { + mnemonic = await wallet.getMnemonicAsWords(); + } catch (_) { + // Key-restored wallets may not have a mnemonic. + } } } if (context.mounted) { diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart index 17de7cd217..6611a3e8d0 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart @@ -166,7 +166,12 @@ class _DesktopAttentionDeleteWallet // TODO: [prio=med] handle other types wallet deletion // All wallets currently are mnemonic based if (wallet is MnemonicInterface) { - final words = await wallet.getMnemonicAsWords(); + List words = []; + try { + words = await wallet.getMnemonicAsWords(); + } catch (_) { + // Key-restored wallets may not have a mnemonic. + } if (context.mounted) { await Navigator.of(context).pushNamed( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart index dc15771830..66363d13f7 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart @@ -119,7 +119,11 @@ class _UnlockWalletKeysDesktopState (wallet as ViewOnlyOptionInterface).isViewOnly) { // TODO: is something needed here? } else { - words = await wallet.getMnemonicAsWords(); + try { + words = await wallet.getMnemonicAsWords(); + } catch (_) { + // Key-restored wallets may not have a mnemonic. + } } } From c8e50d718c9884aca7db42d4740428d4edce578b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 22 Feb 2026 14:23:41 -0600 Subject: [PATCH 08/19] fix: time wallet uri previously if it had a newline at the beginning it would choke --- .../restore_options_view/restore_options_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart index 30118d8868..2e76fc4e8d 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart @@ -1199,7 +1199,7 @@ class _UriRestoreOptionState extends ConsumerState { void _onUriChanged(String value) { WalletUriData? parsed; try { - parsed = WalletUriData.fromUriString(value); + parsed = WalletUriData.fromUriString(value.trim()); } catch (_) { parsed = null; } From 2d2a1ddcd321cabef2c55e7d489f5a7bac84e18e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 22 Feb 2026 14:25:56 -0600 Subject: [PATCH 09/19] feat: allow dashes in wallet uri scheme monero_wallet worked. now monero-wallet works, too there's a frustrating variety of xmr wallet uri schemes: some with underscores, some with dashes --- lib/utilities/address_utils.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index b97acd4036..74524c1d6a 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -198,6 +198,13 @@ class AddressUtils { static Map? _parseWalletUri(String uri) { final String scheme; final Map parsedData = {}; + + final rawScheme = uri.split(":")[0]; + final normalizedScheme = rawScheme.replaceAll("-", "_"); + if (normalizedScheme != rawScheme) { + uri = normalizedScheme + uri.substring(rawScheme.length); + } + if (uri.split(":")[0].contains("_")) { // We need to check if the uri is compatible because RFC 3986 // does not allow underscores in the scheme. From 871e5b7ebfcb221304b211d674ff9486ad22e9ed Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 15 May 2026 10:51:33 -0500 Subject: [PATCH 10/19] chore: dart format --- .../wallet_settings_view.dart | 7 ++--- .../delete_wallet_warning_view.dart | 28 ++++++++----------- lib/utilities/address_utils.dart | 22 ++++++++++----- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart index 516dde6413..a3c31d170d 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart @@ -421,10 +421,9 @@ class _WalletSettingsViewState extends ConsumerState { iconSize: 16, title: "Epicbox Servers", onPressed: () { - Navigator.of(context).pushNamed( - ManageEpicboxView.routeName, - arguments: walletId, - ); + Navigator.of( + context, + ).pushNamed(ManageEpicboxView.routeName, arguments: walletId); }, ), if (canBackup) const SizedBox(height: 8), diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart index a054054656..4562ee8fd6 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart @@ -59,10 +59,9 @@ class DeleteWalletWarningView extends ConsumerWidget { ), const SizedBox(height: 16), RoundedContainer( - color: - Theme.of( - context, - ).extension()!.warningBackground, + color: Theme.of( + context, + ).extension()!.warningBackground, child: Text( "You are going to permanently delete your wallet.\n\n" "If you delete your wallet, the only way you can have access" @@ -70,10 +69,9 @@ class DeleteWalletWarningView extends ConsumerWidget { "${AppConfig.appName} does not keep nor is able to restore " "your backup key or your wallet.\n\nPLEASE SAVE YOUR BACKUP KEY.", style: STextStyles.baseXS(context).copyWith( - color: - Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of( + context, + ).extension()!.warningForeground, ), ), ), @@ -88,10 +86,9 @@ class DeleteWalletWarningView extends ConsumerWidget { child: Text( "Cancel", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -130,10 +127,9 @@ class DeleteWalletWarningView extends ConsumerWidget { myName: wallet.frostInfo.myName, config: results[1]!, keys: results[0]!, - prevGen: - results[2] == null || results[3] == null - ? null - : (config: results[3]!, keys: results[2]!), + prevGen: results[2] == null || results[3] == null + ? null + : (config: results[3]!, keys: results[2]!), ); } } else { diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index 74524c1d6a..e21bd6c714 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -326,21 +326,29 @@ class AddressUtils { if ((mimblewimblecoinAddress.startsWith("http://") || mimblewimblecoinAddress.startsWith("https://")) && mimblewimblecoinAddress.contains("@")) { - mimblewimblecoinAddress = - mimblewimblecoinAddress.replaceAll("http://", ""); - mimblewimblecoinAddress = - mimblewimblecoinAddress.replaceAll("https://", ""); + mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll( + "http://", + "", + ); + mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll( + "https://", + "", + ); } // strip mailto: prefix if (mimblewimblecoinAddress.startsWith("mailto:")) { - mimblewimblecoinAddress = - mimblewimblecoinAddress.replaceAll("mailto:", ""); + mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll( + "mailto:", + "", + ); } // strip / suffix if the address contains an @ symbol (and is thus an mwcmqs address) if (mimblewimblecoinAddress.endsWith("/") && mimblewimblecoinAddress.contains("@")) { mimblewimblecoinAddress = mimblewimblecoinAddress.substring( - 0, mimblewimblecoinAddress.length - 1); + 0, + mimblewimblecoinAddress.length - 1, + ); } return mimblewimblecoinAddress; } From 57dc729d4186123b7973c9031a9934b996c5328d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Aug 2026 09:43:59 -0500 Subject: [PATCH 11/19] fix: handle key-restored wallet recovery --- lib/models/keys/cw_key_data.dart | 2 + .../wallet_backup_view.dart | 93 ++++++++------ .../wallet_settings_view.dart | 33 +++-- .../delete_wallet_recovery_phrase_view.dart | 72 ++++++++++- .../delete_wallet_warning_view.dart | 24 +++- .../sub_widgets/delete_wallet_keys_popup.dart | 120 ++++++++++++------ .../desktop_attention_delete_wallet.dart | 34 +++-- .../unlock_wallet_keys_desktop.dart | 24 ++-- lib/route_generator.dart | 41 +++++- lib/wallets/isar/models/wallet_info.dart | 4 + test/models/keys/cw_key_data_test.dart | 30 +++++ ...lete_wallet_recovery_phrase_view_test.dart | 113 +++++++++++++++++ .../wallets/isar/models/wallet_info_test.dart | 30 +++++ 13 files changed, 488 insertions(+), 132 deletions(-) create mode 100644 test/models/keys/cw_key_data_test.dart create mode 100644 test/pages/delete_wallet_recovery_phrase_view_test.dart create mode 100644 test/wallets/isar/models/wallet_info_test.dart diff --git a/lib/models/keys/cw_key_data.dart b/lib/models/keys/cw_key_data.dart index c20c7938c1..018bd7e04f 100644 --- a/lib/models/keys/cw_key_data.dart +++ b/lib/models/keys/cw_key_data.dart @@ -18,4 +18,6 @@ class CWKeyData with KeyDataInterface { final String walletId; final List<({String label, String key})> keys; + + bool get hasError => keys.any((entry) => entry.key == "ERROR"); } diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_backup_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_backup_view.dart index dded7630c0..ecddb26534 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_backup_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_backup_view.dart @@ -84,7 +84,7 @@ class WalletBackupView extends ConsumerWidget { ), title: Text("Wallet backup", style: STextStyles.navBarTitle(context)), actions: [ - if (keyData != null) + if (keyData != null && mnemonic.isNotEmpty) Padding( padding: const EdgeInsets.all(10), child: CustomTextButton( @@ -94,7 +94,8 @@ class WalletBackupView extends ConsumerWidget { final ViewOnlyWalletData _ => "keys", _ => throw UnimplementedError( - "Don't forget to add your KeyDataInterface here! ${keyData.runtimeType}", + "Don't forget to add your KeyDataInterface here! " + "${keyData.runtimeType}", ), }, onTap: () { @@ -117,7 +118,11 @@ class WalletBackupView extends ConsumerWidget { frostWalletData: frostWalletData, walletId: walletId, ) - : _Mnemonic(walletId: walletId, mnemonic: mnemonic), + : mnemonic.isNotEmpty + ? _Mnemonic(walletId: walletId, mnemonic: mnemonic) + : keyData != null + ? _KeyData(walletId: walletId, keyData: keyData!) + : throw StateError("Wallet has no recovery data"), ), ), ), @@ -125,6 +130,51 @@ class WalletBackupView extends ConsumerWidget { } } +class _KeyData extends StatelessWidget { + const _KeyData({required this.walletId, required this.keyData}); + + final String walletId; + final KeyDataInterface keyData; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: + (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: switch (keyData) { + final XPrivData e => WalletXPrivs( + walletId: walletId, + xprivData: e, + ), + final CWKeyData e => CNWalletKeys( + walletId: walletId, + cwKeyData: e, + ), + final ViewOnlyWalletData e => + ViewOnlyWalletDataWidget(data: e), + _ => throw UnimplementedError( + "Don't forget to add your KeyDataInterface here! " + "${keyData.runtimeType}", + ), + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ); + } +} + class _Mnemonic extends ConsumerWidget { const _Mnemonic({ super.key, @@ -420,42 +470,7 @@ class MobileKeyDataView extends ConsumerWidget { body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: LayoutBuilder( - builder: - (context, constraints) => SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: switch (keyData) { - final XPrivData e => WalletXPrivs( - walletId: walletId, - xprivData: e, - ), - final CWKeyData e => CNWalletKeys( - walletId: walletId, - cwKeyData: e, - ), - final ViewOnlyWalletData e => - ViewOnlyWalletDataWidget(data: e), - _ => - throw UnimplementedError( - "Don't forget to add your KeyDataInterface here!", - ), - }, - ), - const SizedBox(height: 16), - ], - ), - ), - ), - ), - ), + child: _KeyData(walletId: walletId, keyData: keyData), ), ), ), diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart index a3c31d170d..d1941f2e7c 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart @@ -162,8 +162,6 @@ class _WalletSettingsViewState extends ConsumerState { } Future _walletBackupPressedHelper() async { - // TODO: [prio=med] take wallets that don't have a mnemonic into account - final wallet = ref.read(pWallets).getWallet(widget.walletId); List? mnemonic; @@ -194,19 +192,11 @@ class _WalletSettingsViewState extends ConsumerState { : (config: results[3]!, keys: results[2]!), ); } - } else { - if (wallet is MnemonicInterface) { - if (wallet is ViewOnlyOptionInterface && - (wallet as ViewOnlyOptionInterface).isViewOnly) { - // TODO: is something needed here? - } else { - try { - mnemonic = await wallet.getMnemonicAsWords(); - } catch (_) { - // Key-restored wallets may not have a mnemonic. - } - } - } + } else if (wallet is MnemonicInterface && + !wallet.info.isRestoredFromKeys && + !(wallet is ViewOnlyOptionInterface && + (wallet as ViewOnlyOptionInterface).isViewOnly)) { + mnemonic = await wallet.getMnemonicAsWords(); } KeyDataInterface? keyData; @@ -215,7 +205,12 @@ class _WalletSettingsViewState extends ConsumerState { } else if (wallet is ExtendedKeysInterface) { keyData = await wallet.getXPrivs(); } else if (wallet is CryptonoteWallet) { - keyData = await wallet.getKeys(); + final keys = await wallet.getKeys(); + if (wallet.info.isRestoredFromKeys && + (keys == null || keys.hasError)) { + throw StateError("Wallet keys are unavailable"); + } + keyData = keys; } if (mounted) { @@ -237,7 +232,9 @@ class _WalletSettingsViewState extends ConsumerState { settings: const RouteSettings(name: "/viewRecoveryDataLockscreen"), ), ); - } else { + } else if (mnemonic != null || + frostWalletData != null || + keyData != null) { await Navigator.push( context, RouteGenerator.getRoute( @@ -258,6 +255,8 @@ class _WalletSettingsViewState extends ConsumerState { settings: const RouteSettings(name: "/viewRecoverPhraseLockscreen"), ), ); + } else { + throw StateError("Wallet has no recovery data"); } } } diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart index 2bcf90827f..cfc9154ce5 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart @@ -16,6 +16,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../../../app_config.dart'; +import '../../../../models/keys/cw_key_data.dart'; +import '../../../../models/keys/key_data_interface.dart'; import '../../../../notifications/show_flush_bar.dart'; import '../../../../providers/global/secure_store_provider.dart'; import '../../../../providers/global/wallets_provider.dart'; @@ -37,6 +39,7 @@ import '../../../add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mn import '../../../home_view/home_view.dart'; import '../../../wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart' as tdv; +import '../wallet_backup_views/cn_wallet_keys.dart'; class DeleteWalletRecoveryPhraseView extends ConsumerStatefulWidget { const DeleteWalletRecoveryPhraseView({ @@ -44,6 +47,7 @@ class DeleteWalletRecoveryPhraseView extends ConsumerStatefulWidget { required this.walletId, required this.mnemonic, this.frostWalletData, + this.keyData, this.clipboardInterface = const ClipboardWrapper(), }); @@ -58,6 +62,7 @@ class DeleteWalletRecoveryPhraseView extends ConsumerStatefulWidget { ({String config, String keys})? prevGen, })? frostWalletData; + final KeyDataInterface? keyData; final ClipboardInterface clipboardInterface; @@ -140,7 +145,11 @@ class _DeleteWalletRecoveryPhraseViewState debugPrint("BUILD: $runtimeType"); final bool frost = widget.frostWalletData != null; + final bool keyBased = widget.keyData is CWKeyData; final prevGen = widget.frostWalletData?.prevGen != null; + if (!frost && !keyBased && _mnemonic.isEmpty) { + throw StateError("Wallet has no recovery data"); + } return Background( child: Scaffold( @@ -152,7 +161,8 @@ class _DeleteWalletRecoveryPhraseViewState }, ), actions: [ - Padding( + if (_mnemonic.isNotEmpty) + Padding( padding: const EdgeInsets.all(10), child: AspectRatio( aspectRatio: 1, @@ -336,6 +346,66 @@ class _DeleteWalletRecoveryPhraseViewState ); }, ) + : keyBased + ? LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 4), + Text( + ref.watch(pWalletName(widget.walletId)), + textAlign: TextAlign.center, + style: STextStyles.label( + context, + ).copyWith(fontSize: 12), + ), + const SizedBox(height: 4), + Text( + "Wallet Keys", + textAlign: TextAlign.center, + style: STextStyles.pageTitleH1(context), + ), + const SizedBox(height: 16), + RoundedWhiteContainer( + child: Text( + "Save these keys before deleting your " + "wallet. They are required to restore " + "access to your funds.", + style: STextStyles.label(context), + ), + ), + const SizedBox(height: 8), + Expanded( + child: CNWalletKeys( + cwKeyData: widget.keyData as CWKeyData, + walletId: widget.walletId, + ), + ), + const SizedBox(height: 16), + TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: _continuePressed, + child: Text( + "Continue", + style: STextStyles.button(context), + ), + ), + ], + ), + ), + ), + ); + }, + ) : Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart index 4562ee8fd6..d9facb8857 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart @@ -12,11 +12,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../app_config.dart'; +import '../../../../models/keys/key_data_interface.dart'; import '../../../../models/keys/view_only_wallet_data.dart'; import '../../../../providers/providers.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/background.dart'; @@ -100,8 +102,6 @@ class DeleteWalletWarningView extends ConsumerWidget { onPressed: () async { final wallet = ref.read(pWallets).getWallet(walletId); - // TODO: [prio=med] take wallets that don't have a mnemonic into account - List? mnemonic; ({ String myName, @@ -110,6 +110,7 @@ class DeleteWalletWarningView extends ConsumerWidget { ({String config, String keys})? prevGen, })? frostWalletData; + KeyDataInterface? keyData; ViewOnlyWalletData? viewOnlyData; if (wallet is BitcoinFrostWallet) { @@ -136,12 +137,20 @@ class DeleteWalletWarningView extends ConsumerWidget { if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { viewOnlyData = await wallet.getViewOnlyWalletData(); - } else if (wallet is MnemonicInterface) { - try { - mnemonic = await wallet.getMnemonicAsWords(); - } catch (_) { - // Key-restored wallets may not have a mnemonic. + } else if (wallet.info.isRestoredFromKeys) { + if (wallet is! CryptonoteWallet) { + throw StateError( + "Unsupported key-restored wallet: " + "${wallet.runtimeType}", + ); } + final keys = await wallet.getKeys(); + if (keys == null || keys.hasError) { + throw StateError("Wallet keys are unavailable"); + } + keyData = keys; + } else if (wallet is MnemonicInterface) { + mnemonic = await wallet.getMnemonicAsWords(); } } if (context.mounted) { @@ -157,6 +166,7 @@ class DeleteWalletWarningView extends ConsumerWidget { walletId: walletId, mnemonicWords: mnemonic ?? [], frostWalletData: frostWalletData, + keyData: keyData, ), ); } diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart index c9a0c50740..56e63bf788 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart @@ -14,8 +14,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../models/keys/cw_key_data.dart'; +import '../../../../models/keys/key_data_interface.dart'; import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart'; +import '../../../../pages/settings_views/wallet_settings_view/wallet_backup_views/cn_wallet_keys.dart'; import '../../../../providers/global/secure_store_provider.dart'; import '../../../../providers/global/wallets_provider.dart'; import '../../../../route_generator.dart'; @@ -35,11 +38,13 @@ class DeleteWalletKeysPopup extends ConsumerStatefulWidget { super.key, required this.walletId, required this.words, + this.keyData, this.clipboardInterface = const ClipboardWrapper(), }); final String walletId; final List words; + final KeyDataInterface? keyData; final ClipboardInterface clipboardInterface; static const String routeName = "/desktopDeleteWalletKeysPopup"; @@ -70,9 +75,13 @@ class _DeleteWalletKeysPopup extends ConsumerState { @override Widget build(BuildContext context) { + if (_words.isEmpty && widget.keyData is! CWKeyData) { + throw StateError("Wallet has no recovery data"); + } + return DesktopDialog( maxWidth: 614, - maxHeight: double.infinity, + maxHeight: null, child: Column( children: [ Row( @@ -92,48 +101,75 @@ class _DeleteWalletKeysPopup extends ConsumerState { ), ], ), - const SizedBox(height: 28), - Text( - "Recovery phrase", - style: STextStyles.desktopTextMedium(context), - ), - const SizedBox(height: 8), - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Text( - _recoveryPhraseInfo, - style: STextStyles.desktopTextExtraExtraSmall(context), - textAlign: TextAlign.center, - ), - ), - ), - const SizedBox(height: 24), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: RawMaterialButton( - hoverColor: Colors.transparent, - onPressed: () async { - await _clipboardInterface.setData( - ClipboardData(text: _words.join(" ")), - ); - if (context.mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.info, - message: "Copied to clipboard", - iconAsset: Assets.svg.copy, - context: context, + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + if (_words.isNotEmpty) ...[ + const SizedBox(height: 28), + Text( + "Recovery phrase", + style: STextStyles.desktopTextMedium(context), ), - ); - } - }, - child: MnemonicTable( - words: widget.words, - isDesktop: true, - itemBorderColor: Theme.of( - context, - ).extension()!.buttonBackSecondary, + const SizedBox(height: 8), + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + _recoveryPhraseInfo, + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + textAlign: TextAlign.center, + ), + ), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: RawMaterialButton( + hoverColor: Colors.transparent, + onPressed: () async { + await _clipboardInterface.setData( + ClipboardData(text: _words.join(" ")), + ); + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ), + ); + } + }, + child: MnemonicTable( + words: widget.words, + isDesktop: true, + itemBorderColor: Theme.of( + context, + ).extension()!.buttonBackSecondary, + ), + ), + ), + ] else ...[ + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + "Save these keys before deleting your wallet. They are " + "required to restore access to your funds.", + style: STextStyles.desktopTextExtraExtraSmall(context), + textAlign: TextAlign.center, + ), + ), + CNWalletKeys( + cwKeyData: widget.keyData as CWKeyData, + walletId: widget.walletId, + ), + ], + ], ), ), ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart index 6611a3e8d0..7b363a9252 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart @@ -11,15 +11,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:stack_wallet_backup/secure_storage.dart'; -import 'package:tuple/tuple.dart'; import '../../../../app_config.dart'; +import '../../../../models/keys/key_data_interface.dart'; import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart'; import '../../../../providers/global/wallets_provider.dart'; import '../../../../route_generator.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/logger.dart'; import '../../../../utilities/text_styles.dart'; +import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; @@ -162,21 +163,34 @@ class _DesktopAttentionDeleteWallet ), ); } - } else - // TODO: [prio=med] handle other types wallet deletion - // All wallets currently are mnemonic based - if (wallet is MnemonicInterface) { - List words = []; - try { + } else if (wallet is MnemonicInterface) { + final List words; + KeyDataInterface? keyData; + if (wallet.info.isRestoredFromKeys) { + words = []; + if (wallet is! CryptonoteWallet) { + throw StateError( + "Unsupported key-restored wallet: " + "${wallet.runtimeType}", + ); + } + final keys = await wallet.getKeys(); + if (keys == null || keys.hasError) { + throw StateError("Wallet keys are unavailable"); + } + keyData = keys; + } else { words = await wallet.getMnemonicAsWords(); - } catch (_) { - // Key-restored wallets may not have a mnemonic. } if (context.mounted) { await Navigator.of(context).pushNamed( DeleteWalletKeysPopup.routeName, - arguments: Tuple2(widget.walletId, words), + arguments: ( + walletId: widget.walletId, + words: words, + keyData: keyData, + ), ); } } diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart index 66363d13f7..8a884526ef 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart @@ -89,8 +89,6 @@ class _UnlockWalletKeysDesktopState frostWalletData; List? words; - // TODO: [prio=low] handle wallets that don't have a mnemonic - // All wallets currently are mnemonic based if (wallet is! MnemonicInterface) { if (wallet is BitcoinFrostWallet) { final futures = [ @@ -114,17 +112,10 @@ class _UnlockWalletKeysDesktopState } else { throw Exception("FIXME ~= see todo in code"); } - } else { - if (wallet is ViewOnlyOptionInterface && - (wallet as ViewOnlyOptionInterface).isViewOnly) { - // TODO: is something needed here? - } else { - try { - words = await wallet.getMnemonicAsWords(); - } catch (_) { - // Key-restored wallets may not have a mnemonic. - } - } + } else if (!wallet.info.isRestoredFromKeys && + !(wallet is ViewOnlyOptionInterface && + (wallet as ViewOnlyOptionInterface).isViewOnly)) { + words = await wallet.getMnemonicAsWords(); } KeyDataInterface? keyData; @@ -133,7 +124,12 @@ class _UnlockWalletKeysDesktopState } else if (wallet is ExtendedKeysInterface) { keyData = await wallet.getXPrivs(); } else if (wallet is CryptonoteWallet) { - keyData = await wallet.getKeys(); + final keys = await wallet.getKeys(); + if (wallet.info.isRestoredFromKeys && + (keys == null || keys.hasError)) { + throw StateError("Wallet keys are unavailable"); + } + keyData = keys; } if (mounted) { diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 6197874811..c43f8497f4 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -2268,7 +2268,30 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case DeleteWalletRecoveryPhraseView.routeName: - if (args is ({String walletId, List mnemonicWords})) { + if (args + is ({ + String walletId, + List mnemonicWords, + ({ + String myName, + String config, + String keys, + ({String config, String keys})? prevGen, + })? + frostWalletData, + KeyDataInterface? keyData, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => DeleteWalletRecoveryPhraseView( + mnemonic: args.mnemonicWords, + walletId: args.walletId, + frostWalletData: args.frostWalletData, + keyData: args.keyData, + ), + settings: RouteSettings(name: settings.name), + ); + } else if (args is ({String walletId, List mnemonicWords})) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, builder: (_) => DeleteWalletRecoveryPhraseView( @@ -2855,7 +2878,21 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case DeleteWalletKeysPopup.routeName: - if (args is Tuple2>) { + if (args + is ({ + String walletId, + List words, + KeyDataInterface? keyData, + })) { + return FadePageRoute( + DeleteWalletKeysPopup( + walletId: args.walletId, + words: args.words, + keyData: args.keyData, + ), + RouteSettings(name: settings.name), + ); + } else if (args is Tuple2>) { return FadePageRoute( DeleteWalletKeysPopup(walletId: args.item1, words: args.item2), RouteSettings(name: settings.name), diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart index 4eb5d577d4..36a134416c 100644 --- a/lib/wallets/isar/models/wallet_info.dart +++ b/lib/wallets/isar/models/wallet_info.dart @@ -144,6 +144,10 @@ class WalletInfo implements IsarId { bool get isViewOnly => otherData[WalletInfoKeys.isViewOnlyKey] as bool? ?? false; + @ignore + bool get isRestoredFromKeys => + otherData[WalletInfoKeys.isRestoredFromKeysKey] as bool? ?? false; + @ignore ViewOnlyWalletType? get viewOnlyWalletType { final index = otherData[WalletInfoKeys.viewOnlyTypeIndexKey] as int?; diff --git a/test/models/keys/cw_key_data_test.dart b/test/models/keys/cw_key_data_test.dart new file mode 100644 index 0000000000..4df1968979 --- /dev/null +++ b/test/models/keys/cw_key_data_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/keys/cw_key_data.dart'; + +void main() { + group("CWKeyData.hasError", () { + test("is false for complete key data", () { + final data = CWKeyData( + walletId: "wallet-id", + privateSpendKey: "private-spend", + privateViewKey: "private-view", + publicSpendKey: "public-spend", + publicViewKey: "public-view", + ); + + expect(data.hasError, isFalse); + }); + + test("is true when key retrieval failed", () { + final data = CWKeyData( + walletId: "wallet-id", + privateSpendKey: "ERROR", + privateViewKey: "ERROR", + publicSpendKey: "ERROR", + publicViewKey: "ERROR", + ); + + expect(data.hasError, isTrue); + }); + }); +} diff --git a/test/pages/delete_wallet_recovery_phrase_view_test.dart b/test/pages/delete_wallet_recovery_phrase_view_test.dart new file mode 100644 index 0000000000..a8bd167ecd --- /dev/null +++ b/test/pages/delete_wallet_recovery_phrase_view_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/models/keys/cw_key_data.dart'; +import 'package:stackwallet/pages/add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_backup_views/cn_wallet_keys.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_backup_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; + +import '../sample_data/theme_json.dart'; + +void main() { + const walletId = "wallet-id"; + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + final keyData = CWKeyData( + walletId: walletId, + privateSpendKey: "private-spend", + privateViewKey: "private-view", + publicSpendKey: "public-spend", + publicViewKey: "public-view", + ); + + tearDown(() => Util.screenWidth = null); + + Widget testApp(Widget view) { + return ProviderScope( + overrides: [ + themeProvider.overrideWithValue(StateController(theme)), + pWalletName(walletId).overrideWithValue("wallet"), + ], + child: MaterialApp( + theme: ThemeData(extensions: [StackColors.fromStackColorTheme(theme)]), + home: view, + ), + ); + } + + testWidgets("shows keys instead of an empty mnemonic", (tester) async { + Util.screenWidth = 400; + await tester.pumpWidget( + testApp( + DeleteWalletRecoveryPhraseView( + walletId: walletId, + mnemonic: const [], + keyData: keyData, + ), + ), + ); + + expect(find.byType(CNWalletKeys), findsOneWidget); + expect(find.byType(MnemonicTable), findsNothing); + expect(find.text("Wallet Keys"), findsOneWidget); + }); + + testWidgets("rejects missing recovery data", (tester) async { + await tester.pumpWidget( + testApp( + const DeleteWalletRecoveryPhraseView(walletId: walletId, mnemonic: []), + ), + ); + + expect(tester.takeException(), isA()); + expect(find.byType(MnemonicTable), findsNothing); + }); + + testWidgets("shows keys directly in wallet backup", (tester) async { + Util.screenWidth = 400; + await tester.pumpWidget( + testApp( + WalletBackupView( + walletId: walletId, + mnemonic: const [], + keyData: keyData, + ), + ), + ); + + expect(find.byType(CNWalletKeys), findsOneWidget); + expect(find.byType(MnemonicTable), findsNothing); + }); + + testWidgets("shows keys in desktop wallet deletion", (tester) async { + await tester.pumpWidget( + testApp( + DeleteWalletKeysPopup( + walletId: walletId, + words: const [], + keyData: keyData, + ), + ), + ); + + expect(find.byType(CNWalletKeys), findsOneWidget); + expect(find.byType(MnemonicTable), findsNothing); + }); + + testWidgets("keeps mnemonic desktop deletion", (tester) async { + await tester.pumpWidget( + testApp( + const DeleteWalletKeysPopup(walletId: walletId, words: ["one", "two"]), + ), + ); + + expect(find.byType(MnemonicTable), findsOneWidget); + expect(find.byType(CNWalletKeys), findsNothing); + }); +} diff --git a/test/wallets/isar/models/wallet_info_test.dart b/test/wallets/isar/models/wallet_info_test.dart new file mode 100644 index 0000000000..56cd3b1d5f --- /dev/null +++ b/test/wallets/isar/models/wallet_info_test.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/app_config.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; + +void main() { + group("WalletInfo.isRestoredFromKeys", () { + test("defaults to false", () { + final info = WalletInfo.createNew( + coin: AppConfig.coins.first, + name: "wallet", + ); + + expect(info.isRestoredFromKeys, isFalse); + }); + + test("reads the persisted recovery type", () { + final info = WalletInfo.createNew( + coin: AppConfig.coins.first, + name: "wallet", + otherDataJsonString: jsonEncode({ + WalletInfoKeys.isRestoredFromKeysKey: true, + }), + ); + + expect(info.isRestoredFromKeys, isTrue); + }); + }); +} From 003234c482d46ad6979633a9d23d0bbb4307a494 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Aug 2026 11:01:38 -0500 Subject: [PATCH 12/19] fix: harden wallet recovery --- .../keys/cryptonote_key_restore_data.dart | 36 + lib/models/keys/cw_key_data.dart | 20 +- lib/models/keys/wallet_recovery_material.dart | 57 ++ .../confirm_recovery_dialog.dart | 34 +- .../restore_options_view.dart | 171 +++-- .../restore_view_only_wallet_view.dart | 7 +- .../restore_wallet_view.dart | 7 +- .../sub_widgets/restoring_dialog.dart | 121 ++-- .../wallet_backup_view.dart | 175 +++-- .../wallet_settings_view.dart | 82 +-- .../delete_wallet_recovery_phrase_view.dart | 638 +++++++++--------- .../delete_wallet_warning_view.dart | 74 +- .../firo_rescan_recovery_error_dialog.dart | 62 +- .../sub_widgets/delete_wallet_keys_popup.dart | 105 ++- .../desktop_attention_delete_wallet.dart | 42 +- .../unlock_wallet_keys_desktop.dart | 66 +- .../wallet_keys_desktop_popup.dart | 289 ++++---- lib/route_generator.dart | 201 +----- lib/services/wallet_recovery_service.dart | 87 +++ lib/services/wallets.dart | 10 +- lib/utilities/address_utils.dart | 140 ++-- lib/wallets/isar/models/wallet_info.dart | 19 +- .../intermediate/cryptonote_wallet.dart | 2 +- .../intermediate/lib_monero_wallet.dart | 45 +- .../intermediate/lib_salvium_wallet.dart | 12 +- .../intermediate/lib_wownero_wallet.dart | 12 +- lib/wallets/wallet/wallet.dart | 24 +- test/address_utils_test.dart | 129 ++++ .../cryptonote_key_restore_data_test.dart | 31 + test/models/keys/cw_key_data_test.dart | 41 +- .../keys/wallet_recovery_material_test.dart | 24 + ...lete_wallet_recovery_phrase_view_test.dart | 64 +- test/pages/restore_options_uri_test.dart | 128 ++++ .../wallets/isar/models/wallet_info_test.dart | 21 +- .../wallet/wallet_secure_storage_test.dart | 23 + 35 files changed, 1614 insertions(+), 1385 deletions(-) create mode 100644 lib/models/keys/cryptonote_key_restore_data.dart create mode 100644 lib/models/keys/wallet_recovery_material.dart create mode 100644 lib/services/wallet_recovery_service.dart create mode 100644 test/models/keys/cryptonote_key_restore_data_test.dart create mode 100644 test/models/keys/wallet_recovery_material_test.dart create mode 100644 test/pages/restore_options_uri_test.dart create mode 100644 test/wallets/wallet/wallet_secure_storage_test.dart diff --git a/lib/models/keys/cryptonote_key_restore_data.dart b/lib/models/keys/cryptonote_key_restore_data.dart new file mode 100644 index 0000000000..c71d62dbb0 --- /dev/null +++ b/lib/models/keys/cryptonote_key_restore_data.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; + +class CryptonoteKeyRestoreData { + const CryptonoteKeyRestoreData({ + required this.address, + required this.privateViewKey, + required this.privateSpendKey, + }); + + final String address; + final String privateViewKey; + final String privateSpendKey; + + factory CryptonoteKeyRestoreData.fromJsonEncodedString(String value) { + final json = jsonDecode(value); + if (json is! Map) { + throw const FormatException("Invalid Cryptonote key restore data"); + } + + return CryptonoteKeyRestoreData( + address: json["address"] as String, + privateViewKey: json["privateViewKey"] as String, + privateSpendKey: json["privateSpendKey"] as String, + ); + } + + String toJsonEncodedString() => jsonEncode({ + "address": address, + "privateViewKey": privateViewKey, + "privateSpendKey": privateSpendKey, + }); + + @override + String toString() => + "CryptonoteKeyRestoreData(address: $address, private keys: )"; +} diff --git a/lib/models/keys/cw_key_data.dart b/lib/models/keys/cw_key_data.dart index 018bd7e04f..8d8b1e0323 100644 --- a/lib/models/keys/cw_key_data.dart +++ b/lib/models/keys/cw_key_data.dart @@ -3,21 +3,19 @@ import 'key_data_interface.dart'; class CWKeyData with KeyDataInterface { CWKeyData({ required this.walletId, - required String? privateSpendKey, - required String? privateViewKey, - required String? publicSpendKey, - required String? publicViewKey, + required String privateSpendKey, + required String privateViewKey, + required String publicSpendKey, + required String publicViewKey, }) : keys = List.unmodifiable([ - (label: "Public View Key", key: publicViewKey), - (label: "Private View Key", key: privateViewKey), - (label: "Public Spend Key", key: publicSpendKey), - (label: "Private Spend Key", key: privateSpendKey), - ]); + (label: "Public View Key", key: publicViewKey), + (label: "Private View Key", key: privateViewKey), + (label: "Public Spend Key", key: publicSpendKey), + (label: "Private Spend Key", key: privateSpendKey), + ]); @override final String walletId; final List<({String label, String key})> keys; - - bool get hasError => keys.any((entry) => entry.key == "ERROR"); } diff --git a/lib/models/keys/wallet_recovery_material.dart b/lib/models/keys/wallet_recovery_material.dart new file mode 100644 index 0000000000..6335b2ed45 --- /dev/null +++ b/lib/models/keys/wallet_recovery_material.dart @@ -0,0 +1,57 @@ +import 'key_data_interface.dart'; +import 'view_only_wallet_data.dart'; + +typedef FrostWalletRecoveryData = ({ + String myName, + String config, + String keys, + ({String config, String keys})? prevGen, +}); + +sealed class WalletRecoveryMaterial { + const WalletRecoveryMaterial({required this.walletId}); + + final String walletId; +} + +final class MnemonicWalletRecoveryMaterial extends WalletRecoveryMaterial { + MnemonicWalletRecoveryMaterial({ + required super.walletId, + required List words, + this.supplementalKeyData, + }) : words = List.unmodifiable(words) { + if (words.isEmpty) { + throw ArgumentError.value(words, "words", "Mnemonic cannot be empty"); + } + } + + final List words; + final KeyDataInterface? supplementalKeyData; +} + +final class PrivateKeyWalletRecoveryMaterial extends WalletRecoveryMaterial { + const PrivateKeyWalletRecoveryMaterial({ + required super.walletId, + required this.keyData, + }); + + final KeyDataInterface keyData; +} + +final class ViewOnlyWalletRecoveryMaterial extends WalletRecoveryMaterial { + const ViewOnlyWalletRecoveryMaterial({ + required super.walletId, + required this.keyData, + }); + + final ViewOnlyWalletData keyData; +} + +final class FrostWalletRecoveryMaterial extends WalletRecoveryMaterial { + const FrostWalletRecoveryMaterial({ + required super.walletId, + required this.data, + }); + + final FrostWalletRecoveryData data; +} diff --git a/lib/pages/add_wallet_views/restore_wallet_view/confirm_recovery_dialog.dart b/lib/pages/add_wallet_views/restore_wallet_view/confirm_recovery_dialog.dart index fe11d35026..5c1cfe967c 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/confirm_recovery_dialog.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/confirm_recovery_dialog.dart @@ -21,9 +21,7 @@ import '../../../widgets/desktop/secondary_button.dart'; import '../../../widgets/stack_dialog.dart'; class ConfirmRecoveryDialog extends StatelessWidget { - const ConfirmRecoveryDialog({super.key, required this.onConfirm}); - - final VoidCallback onConfirm; + const ConfirmRecoveryDialog({super.key}); @override Widget build(BuildContext context) { @@ -32,23 +30,15 @@ class ConfirmRecoveryDialog extends StatelessWidget { child: Column( children: [ const DesktopDialogCloseButton(), - const SizedBox( - height: 5, - ), - SvgPicture.asset( - Assets.svg.drd, - width: 99, - height: 70, - ), + const SizedBox(height: 5), + SvgPicture.asset(Assets.svg.drd, width: 99, height: 70), const Spacer(), Text( "Restore wallet", style: STextStyles.desktopH2(context), textAlign: TextAlign.center, ), - const SizedBox( - height: 16, - ), + const SizedBox(height: 16), Text( "Restoring your wallet may take a while.\nPlease do not exit this screen once the process is started.", style: STextStyles.desktopTextMedium(context).copyWith( @@ -58,11 +48,7 @@ class ConfirmRecoveryDialog extends StatelessWidget { ), const Spacer(), Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, - ), + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), child: Row( children: [ Expanded( @@ -73,15 +59,12 @@ class ConfirmRecoveryDialog extends StatelessWidget { }, ), ), - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), Expanded( child: PrimaryButton( label: "Restore", onPressed: () { - Navigator.of(context).pop(); - onConfirm.call(); + Navigator.of(context).pop(true); }, ), ), @@ -109,8 +92,7 @@ class ConfirmRecoveryDialog extends StatelessWidget { rightButton: PrimaryButton( label: "Restore", onPressed: () { - Navigator.of(context).pop(); - onConfirm.call(); + Navigator.of(context).pop(true); }, ), ), diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart index 2e76fc4e8d..4b344fbb96 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart @@ -21,6 +21,7 @@ import 'package:logger/logger.dart'; import 'package:tuple/tuple.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; +import '../../../../models/keys/cryptonote_key_restore_data.dart'; import '../../../../models/keys/view_only_wallet_data.dart'; import '../../../../pages_desktop_specific/desktop_home_view.dart'; import '../../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; @@ -291,16 +292,15 @@ class _RestoreOptionsViewState extends ConsumerState { if (!mounted) return; - await showDialog( + final confirmed = await showDialog( context: context, useSafeArea: false, barrierDismissible: true, - builder: (context) { - return ConfirmRecoveryDialog( - onConfirm: () => _doUriRestore(data, fallbackHeight), - ); - }, + builder: (context) => const ConfirmRecoveryDialog(), ); + if (confirmed == true && mounted) { + await _doUriRestore(data, fallbackHeight); + } } Future _doUriRestore(WalletUriData data, int fallbackHeight) async { @@ -319,7 +319,10 @@ class _RestoreOptionsViewState extends ConsumerState { ViewOnlyWalletType.cryptonote.index, }; } else { - otherDataJson = {WalletInfoKeys.isRestoredFromKeysKey: true}; + otherDataJson = { + WalletInfoKeys.recoveryTypeIndexKey: + WalletRecoveryType.privateKeys.index, + }; } final info = WalletInfo.createNew( @@ -329,27 +332,27 @@ class _RestoreOptionsViewState extends ConsumerState { otherDataJsonString: jsonEncode(otherDataJson), ); - bool isRestoring = true; + bool restoringDialogOpen = false; + void closeRestoringDialog() { + if (restoringDialogOpen && mounted) { + Navigator.of(context, rootNavigator: true).pop(); + restoringDialogOpen = false; + } + } + if (mounted) { + restoringDialogOpen = true; unawaited( showDialog( context: context, useSafeArea: false, barrierDismissible: false, - builder: (context) { - return RestoringDialog( - onCancel: () async { - isRestoring = false; - await ref - .read(pWallets) - .deleteWallet(info, ref.read(secureStoreProvider)); - }, - ); - }, + builder: (context) => const RestoringDialog(), ), ); } + late final Wallet wallet; try { var node = ref .read(nodeServiceChangeNotifierProvider) @@ -362,7 +365,6 @@ class _RestoreOptionsViewState extends ConsumerState { .save(node, null, false); } - final Wallet wallet; if (data.seed != null) { wallet = await Wallet.create( walletInfo: info, @@ -375,7 +377,7 @@ class _RestoreOptionsViewState extends ConsumerState { } else if (data.isViewOnly) { final viewOnlyData = CryptonoteViewOnlyWalletData( walletId: info.walletId, - address: data.address ?? "", + address: data.address!, privateViewKey: data.viewKey!, ); wallet = await Wallet.create( @@ -387,18 +389,17 @@ class _RestoreOptionsViewState extends ConsumerState { viewOnlyData: viewOnlyData, ); } else { - final keysRestoreData = jsonEncode({ - "address": data.address ?? "", - "viewKey": data.viewKey!, - "spendKey": data.spendKey!, - }); wallet = await Wallet.create( walletInfo: info, mainDB: ref.read(mainDBProvider), secureStorageInterface: ref.read(secureStoreProvider), nodeService: ref.read(nodeServiceChangeNotifierProvider), prefs: ref.read(prefsChangeNotifierProvider), - keysRestoreData: keysRestoreData, + cryptonoteKeyRestoreData: CryptonoteKeyRestoreData( + address: data.address!, + privateViewKey: data.viewKey!, + privateSpendKey: data.spendKey!, + ), ); } @@ -410,44 +411,24 @@ class _RestoreOptionsViewState extends ConsumerState { await wallet.recover(isRescan: false); - if (mounted) { - await wallet.info.setMnemonicVerified( + await wallet.info.setMnemonicVerified( + isar: ref.read(mainDBProvider).isar, + ); + + if (ref.read(pDuress)) { + await wallet.info.updateDuressVisibilityStatus( + isDuressVisible: true, isar: ref.read(mainDBProvider).isar, ); - - if (ref.read(pDuress)) { - await wallet.info.updateDuressVisibilityStatus( - isDuressVisible: true, - isar: ref.read(mainDBProvider).isar, - ); - } - - ref.read(pWallets).addWallet(wallet); - - if (mounted) { - if (isDesktop) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(DesktopHomeView.routeName)); - } else { - unawaited( - Navigator.of( - context, - ).pushNamedAndRemoveUntil(HomeView.routeName, (route) => false), - ); - } - - await showDialog( - context: context, - useSafeArea: false, - barrierDismissible: true, - builder: (context) => const RestoreSucceededDialog(), - ); - } } - } catch (e) { - if (mounted && isRestoring) { - Navigator.pop(context); + } catch (e, s) { + Logging.instance.e( + "Wallet URI restore failed", + error: e, + stackTrace: s, + ); + closeRestoringDialog(); + if (mounted) { await showDialog( context: context, useSafeArea: false, @@ -459,6 +440,31 @@ class _RestoreOptionsViewState extends ConsumerState { ), ); } + return; + } + + if (!mounted) return; + + ref.read(pWallets).addWallet(wallet); + closeRestoringDialog(); + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) => const RestoreSucceededDialog(), + ); + + if (!mounted) return; + if (isDesktop) { + Navigator.of( + context, + ).popUntil(ModalRoute.withName(DesktopHomeView.routeName)); + } else { + unawaited( + Navigator.of( + context, + ).pushNamedAndRemoveUntil(HomeView.routeName, (route) => false), + ); } } finally { if (!Platform.isLinux && !isDesktop) await WakelockPlus.disable(); @@ -532,6 +538,9 @@ class _RestoreOptionsViewState extends ConsumerState { onValueChanged: (value) { setState(() { _restoreMode = value; + if (value != 2) { + _uriData = null; + } }); }, decoration: BoxDecoration( @@ -1182,28 +1191,50 @@ class UriRestoreOption extends ConsumerStatefulWidget { class _UriRestoreOptionState extends ConsumerState { bool _blockFieldEmpty = true; late final TextEditingController _uriController; + late final FocusNode _uriFocusNode; + String? _uriError; @override void initState() { super.initState(); _blockFieldEmpty = widget.blockHeightController.text.isEmpty; _uriController = TextEditingController(); + _uriFocusNode = FocusNode(); } @override void dispose() { _uriController.dispose(); + _uriFocusNode.dispose(); super.dispose(); } void _onUriChanged(String value) { + final uri = value.trim(); + if (uri.isEmpty) { + setState(() => _uriError = null); + widget.onParsed(null); + return; + } + WalletUriData? parsed; + String? error; try { - parsed = WalletUriData.fromUriString(value.trim()); + parsed = WalletUriData.fromUriString( + uri, + addressValidator: widget.coin.validateAddress, + ); + } on FormatException catch (e) { + error = e.message; + } on UnsupportedError catch (e) { + error = e.message; } catch (_) { + error = "Invalid wallet URI"; parsed = null; } + setState(() => _uriError = error); + // If the URI contains a height, switch to block height mode and populate. if (parsed?.height != null) { ref.read(_pIsUsingDate.notifier).state = false; @@ -1234,13 +1265,18 @@ class _UriRestoreOptionState extends ConsumerState { ), child: TextField( controller: _uriController, + focusNode: _uriFocusNode, + autocorrect: false, + enableSuggestions: false, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, style: Util.isDesktop ? STextStyles.desktopTextMedium(context).copyWith(height: 2) : STextStyles.field(context), decoration: standardInputDecoration( "monero_wallet:
?seed=...", - FocusNode(), + _uriFocusNode, context, ).copyWith( suffixIcon: UnconstrainedBox( @@ -1263,6 +1299,15 @@ class _UriRestoreOptionState extends ConsumerState { onChanged: _onUriChanged, ), ), + if (_uriError != null) ...[ + const SizedBox(height: 6), + Text( + _uriError!, + style: STextStyles.smallMed12(context).copyWith( + color: Theme.of(context).extension()!.textError, + ), + ), + ], SizedBox(height: Util.isDesktop ? 24 : 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart index 4dd084f8c2..f42c337af9 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart @@ -87,14 +87,17 @@ class _RestoreViewOnlyWalletViewState } if (mounted) { - await showDialog( + final confirmed = await showDialog( context: context, useSafeArea: false, barrierDismissible: true, builder: (context) { - return ConfirmRecoveryDialog(onConfirm: _attemptRestore); + return const ConfirmRecoveryDialog(); }, ); + if (confirmed == true) { + await _attemptRestore(); + } } } finally { _buttonLock = false; diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart index a1ea19c405..a47066820d 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart @@ -671,14 +671,17 @@ class _RestoreWalletViewState extends ConsumerState { await Future.delayed(const Duration(milliseconds: 100)); if (mounted) { - await showDialog( + final confirmed = await showDialog( context: context, useSafeArea: false, barrierDismissible: true, builder: (context) { - return ConfirmRecoveryDialog(onConfirm: attemptRestore); + return const ConfirmRecoveryDialog(); }, ); + if (confirmed == true) { + await attemptRestore(); + } } } diff --git a/lib/pages/add_wallet_views/restore_wallet_view/sub_widgets/restoring_dialog.dart b/lib/pages/add_wallet_views/restore_wallet_view/sub_widgets/restoring_dialog.dart index 38004caad4..2b329aa947 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/sub_widgets/restoring_dialog.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/sub_widgets/restoring_dialog.dart @@ -19,58 +19,41 @@ import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/stack_dialog.dart'; class RestoringDialog extends StatefulWidget { - const RestoringDialog({ - super.key, - required this.onCancel, - }); + const RestoringDialog({super.key, this.onCancel}); - final Future Function() onCancel; + final Future Function()? onCancel; @override State createState() => _RestoringDialogState(); } class _RestoringDialogState extends State { - late final Future Function() onCancel; - @override - void initState() { - onCancel = widget.onCancel; - - super.initState(); - } - @override Widget build(BuildContext context) { if (Util.isDesktop) { return DesktopDialog( child: Column( children: [ - DesktopDialogCloseButton( - onPressedOverride: () async { - await onCancel.call(); - if (mounted) { - Navigator.of(context).pop(); - } - }, - ), - const Spacer( - flex: 1, - ), - const RotatingArrows( - width: 40, - height: 40, - ), - const Spacer( - flex: 2, - ), + if (widget.onCancel != null) + DesktopDialogCloseButton( + onPressedOverride: () async { + await widget.onCancel!.call(); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ) + else + const SizedBox(height: 64), + const Spacer(flex: 1), + const RotatingArrows(width: 40, height: 40), + const Spacer(flex: 2), Text( "Restoring wallet...", style: STextStyles.desktopH2(context), textAlign: TextAlign.center, ), - const SizedBox( - height: 16, - ), + const SizedBox(height: 16), Text( "Restoring your wallet may take a while.\nPlease do not exit this screen.", style: STextStyles.desktopTextMedium(context).copyWith( @@ -78,26 +61,21 @@ class _RestoringDialogState extends State { ), textAlign: TextAlign.center, ), - const Spacer( - flex: 2, - ), - Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, + const Spacer(flex: 2), + if (widget.onCancel != null) + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: SecondaryButton( + label: "Cancel", + width: 272.5, + onPressed: () async { + await widget.onCancel!.call(); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), ), - child: SecondaryButton( - label: "Cancel", - width: 272.5, - onPressed: () async { - await onCancel.call(); - if (mounted) { - Navigator.of(context).pop(); - } - }, - ), - ), ], ), ); @@ -109,25 +87,24 @@ class _RestoringDialogState extends State { child: StackDialog( title: "Restoring wallet", message: "This may take a while. Please do not exit this screen.", - icon: const RotatingArrows( - width: 24, - height: 24, - ), - rightButton: TextButton( - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), - child: Text( - "Cancel", - style: STextStyles.itemSubtitle12(context), - ), - onPressed: () async { - await onCancel.call(); - if (mounted) { - Navigator.of(context).pop(); - } - }, - ), + icon: const RotatingArrows(width: 24, height: 24), + rightButton: widget.onCancel == null + ? null + : TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Cancel", + style: STextStyles.itemSubtitle12(context), + ), + onPressed: () async { + await widget.onCancel!.call(); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), ), ); } diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_backup_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_backup_view.dart index ecddb26534..21501a3da7 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_backup_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_backup_view.dart @@ -18,6 +18,7 @@ import '../../../../app_config.dart'; import '../../../../models/keys/cw_key_data.dart'; import '../../../../models/keys/key_data_interface.dart'; import '../../../../models/keys/view_only_wallet_data.dart'; +import '../../../../models/keys/wallet_recovery_material.dart'; import '../../../../models/keys/xpriv_data.dart'; import '../../../../notifications/show_flush_bar.dart'; import '../../../../themes/stack_colors.dart'; @@ -46,26 +47,27 @@ import 'cn_wallet_keys.dart'; import 'wallet_xprivs.dart'; class WalletBackupView extends ConsumerWidget { - const WalletBackupView({ - super.key, - required this.walletId, - required this.mnemonic, - this.frostWalletData, - this.keyData, - }); + const WalletBackupView({super.key, required this.recoveryMaterial}); static const String routeName = "/walletBackup"; - final String walletId; - final List mnemonic; - final ({ - String myName, - String config, - String keys, - ({String config, String keys})? prevGen, - })? - frostWalletData; - final KeyDataInterface? keyData; + final WalletRecoveryMaterial recoveryMaterial; + + String get walletId => recoveryMaterial.walletId; + List? get mnemonic => switch (recoveryMaterial) { + final MnemonicWalletRecoveryMaterial data => data.words, + _ => null, + }; + FrostWalletRecoveryData? get frostWalletData => switch (recoveryMaterial) { + final FrostWalletRecoveryMaterial data => data.data, + _ => null, + }; + KeyDataInterface? get keyData => switch (recoveryMaterial) { + final MnemonicWalletRecoveryMaterial data => data.supplementalKeyData, + final PrivateKeyWalletRecoveryMaterial data => data.keyData, + final ViewOnlyWalletRecoveryMaterial data => data.keyData, + _ => null, + }; @override Widget build(BuildContext context, WidgetRef ref) { @@ -84,7 +86,7 @@ class WalletBackupView extends ConsumerWidget { ), title: Text("Wallet backup", style: STextStyles.navBarTitle(context)), actions: [ - if (keyData != null && mnemonic.isNotEmpty) + if (keyData != null && mnemonic != null) Padding( padding: const EdgeInsets.all(10), child: CustomTextButton( @@ -92,11 +94,10 @@ class WalletBackupView extends ConsumerWidget { final XPrivData _ => "xpriv(s)", final CWKeyData _ => "keys", final ViewOnlyWalletData _ => "keys", - _ => - throw UnimplementedError( - "Don't forget to add your KeyDataInterface here! " - "${keyData.runtimeType}", - ), + _ => throw UnimplementedError( + "Don't forget to add your KeyDataInterface here! " + "${keyData.runtimeType}", + ), }, onTap: () { Navigator.pushNamed( @@ -112,17 +113,16 @@ class WalletBackupView extends ConsumerWidget { body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), - child: - frost - ? _FrostKeys( - frostWalletData: frostWalletData, - walletId: walletId, - ) - : mnemonic.isNotEmpty - ? _Mnemonic(walletId: walletId, mnemonic: mnemonic) - : keyData != null - ? _KeyData(walletId: walletId, keyData: keyData!) - : throw StateError("Wallet has no recovery data"), + child: frost + ? _FrostKeys( + frostWalletData: frostWalletData, + walletId: walletId, + ) + : mnemonic != null + ? _Mnemonic(walletId: walletId, mnemonic: mnemonic!) + : keyData != null + ? _KeyData(walletId: walletId, keyData: keyData!) + : throw StateError("Wallet has no recovery data"), ), ), ), @@ -139,38 +139,38 @@ class _KeyData extends StatelessWidget { @override Widget build(BuildContext context) { return LayoutBuilder( - builder: - (context, constraints) => SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: IntrinsicHeight( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: switch (keyData) { - final XPrivData e => WalletXPrivs( - walletId: walletId, - xprivData: e, - ), - final CWKeyData e => CNWalletKeys( - walletId: walletId, - cwKeyData: e, - ), - final ViewOnlyWalletData e => - ViewOnlyWalletDataWidget(data: e), - _ => throw UnimplementedError( - "Don't forget to add your KeyDataInterface here! " - "${keyData.runtimeType}", - ), - }, + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: switch (keyData) { + final XPrivData e => WalletXPrivs( + walletId: walletId, + xprivData: e, + ), + final CWKeyData e => CNWalletKeys( + walletId: walletId, + cwKeyData: e, + ), + final ViewOnlyWalletData e => ViewOnlyWalletDataWidget( + data: e, + ), + _ => throw UnimplementedError( + "Don't forget to add your KeyDataInterface here! " + "${keyData.runtimeType}", ), - const SizedBox(height: 16), - ], + }, ), - ), + const SizedBox(height: 16), + ], ), ), + ), + ), ); } } @@ -296,10 +296,9 @@ class _Mnemonic extends ConsumerWidget { child: Text( "Cancel", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -372,19 +371,17 @@ class _FrostKeys extends StatelessWidget { DetailItem( title: "Multisig config", detail: frostWalletData!.config, - button: - Util.isDesktop - ? tdv.IconCopyButton(data: frostWalletData!.config) - : SimpleCopyButton(data: frostWalletData!.config), + button: Util.isDesktop + ? tdv.IconCopyButton(data: frostWalletData!.config) + : SimpleCopyButton(data: frostWalletData!.config), ), const SizedBox(height: 16), DetailItem( title: "Keys", detail: frostWalletData!.keys, - button: - Util.isDesktop - ? tdv.IconCopyButton(data: frostWalletData!.keys) - : SimpleCopyButton(data: frostWalletData!.keys), + button: Util.isDesktop + ? tdv.IconCopyButton(data: frostWalletData!.keys) + : SimpleCopyButton(data: frostWalletData!.keys), ), if (prevGen) const SizedBox(height: 24), if (prevGen) @@ -399,28 +396,26 @@ class _FrostKeys extends StatelessWidget { DetailItem( title: "Previous multisig config", detail: frostWalletData!.prevGen!.config, - button: - Util.isDesktop - ? tdv.IconCopyButton( - data: frostWalletData!.prevGen!.config, - ) - : SimpleCopyButton( - data: frostWalletData!.prevGen!.config, - ), + button: Util.isDesktop + ? tdv.IconCopyButton( + data: frostWalletData!.prevGen!.config, + ) + : SimpleCopyButton( + data: frostWalletData!.prevGen!.config, + ), ), if (prevGen) const SizedBox(height: 16), if (prevGen) DetailItem( title: "Previous keys", detail: frostWalletData!.prevGen!.keys, - button: - Util.isDesktop - ? tdv.IconCopyButton( - data: frostWalletData!.prevGen!.keys, - ) - : SimpleCopyButton( - data: frostWalletData!.prevGen!.keys, - ), + button: Util.isDesktop + ? tdv.IconCopyButton( + data: frostWalletData!.prevGen!.keys, + ) + : SimpleCopyButton( + data: frostWalletData!.prevGen!.keys, + ), ), ], ), diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart index d1941f2e7c..5dd47c9956 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart @@ -18,8 +18,8 @@ import 'package:tuple/tuple.dart'; import '../../../db/hive/db.dart'; import '../../../db/sqlite/firo_cache.dart'; import '../../../models/epicbox_config_model.dart'; -import '../../../models/keys/key_data_interface.dart'; import '../../../models/keys/view_only_wallet_data.dart'; +import '../../../models/keys/wallet_recovery_material.dart'; import '../../../models/mwcmqs_config_model.dart'; import '../../../notifications/show_flush_bar.dart'; import '../../../providers/global/wallets_provider.dart'; @@ -28,6 +28,7 @@ import '../../../route_generator.dart'; import '../../../services/event_bus/events/global/node_connection_status_changed_event.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../../services/event_bus/global_event_bus.dart'; +import '../../../services/wallet_recovery_service.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/if_not_already.dart'; @@ -37,12 +38,9 @@ import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../wallets/crypto_currency/intermediate/frost_currency.dart'; import '../../../wallets/crypto_currency/intermediate/nano_currency.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/intermediate/cryptonote_wallet.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; -import '../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../widgets/background.dart'; @@ -163,66 +161,19 @@ class _WalletSettingsViewState extends ConsumerState { Future _walletBackupPressedHelper() async { final wallet = ref.read(pWallets).getWallet(widget.walletId); - - List? mnemonic; - ({ - String myName, - String config, - String keys, - ({String config, String keys})? prevGen, - })? - frostWalletData; - if (wallet is BitcoinFrostWallet) { - final futures = [ - wallet.getSerializedKeys(), - wallet.getMultisigConfig(), - wallet.getSerializedKeysPrevGen(), - wallet.getMultisigConfigPrevGen(), - ]; - - final results = await Future.wait(futures); - - if (results.length == 4) { - frostWalletData = ( - myName: wallet.frostInfo.myName, - config: results[1]!, - keys: results[0]!, - prevGen: results[2] == null || results[3] == null - ? null - : (config: results[3]!, keys: results[2]!), - ); - } - } else if (wallet is MnemonicInterface && - !wallet.info.isRestoredFromKeys && - !(wallet is ViewOnlyOptionInterface && - (wallet as ViewOnlyOptionInterface).isViewOnly)) { - mnemonic = await wallet.getMnemonicAsWords(); - } - - KeyDataInterface? keyData; - if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { - keyData = await wallet.getViewOnlyWalletData(); - } else if (wallet is ExtendedKeysInterface) { - keyData = await wallet.getXPrivs(); - } else if (wallet is CryptonoteWallet) { - final keys = await wallet.getKeys(); - if (wallet.info.isRestoredFromKeys && - (keys == null || keys.hasError)) { - throw StateError("Wallet keys are unavailable"); - } - keyData = keys; - } + final recoveryMaterial = await WalletRecoveryService.getMaterial(wallet); if (mounted) { - if (keyData != null && - wallet is ViewOnlyOptionInterface && - wallet.isViewOnly) { + if (recoveryMaterial is ViewOnlyWalletRecoveryMaterial) { await Navigator.push( context, - RouteGenerator.getRoute( + RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, builder: (_) => LockscreenView( - routeOnSuccessArguments: (walletId: walletId, keyData: keyData), + routeOnSuccessArguments: ( + walletId: walletId, + keyData: recoveryMaterial.keyData, + ), showBackButton: true, routeOnSuccess: MobileKeyDataView.routeName, biometricsCancelButtonString: "CANCEL", @@ -232,20 +183,13 @@ class _WalletSettingsViewState extends ConsumerState { settings: const RouteSettings(name: "/viewRecoveryDataLockscreen"), ), ); - } else if (mnemonic != null || - frostWalletData != null || - keyData != null) { + } else { await Navigator.push( context, - RouteGenerator.getRoute( + RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, builder: (_) => LockscreenView( - routeOnSuccessArguments: ( - walletId: walletId, - mnemonic: mnemonic ?? [], - frostWalletData: frostWalletData, - keyData: keyData, - ), + routeOnSuccessArguments: recoveryMaterial, showBackButton: true, routeOnSuccess: WalletBackupView.routeName, biometricsCancelButtonString: "CANCEL", @@ -255,8 +199,6 @@ class _WalletSettingsViewState extends ConsumerState { settings: const RouteSettings(name: "/viewRecoverPhraseLockscreen"), ), ); - } else { - throw StateError("Wallet has no recovery data"); } } } diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart index cfc9154ce5..6b36847412 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart @@ -18,6 +18,7 @@ import 'package:flutter_svg/svg.dart'; import '../../../../app_config.dart'; import '../../../../models/keys/cw_key_data.dart'; import '../../../../models/keys/key_data_interface.dart'; +import '../../../../models/keys/wallet_recovery_material.dart'; import '../../../../notifications/show_flush_bar.dart'; import '../../../../providers/global/secure_store_provider.dart'; import '../../../../providers/global/wallets_provider.dart'; @@ -44,25 +45,27 @@ import '../wallet_backup_views/cn_wallet_keys.dart'; class DeleteWalletRecoveryPhraseView extends ConsumerStatefulWidget { const DeleteWalletRecoveryPhraseView({ super.key, - required this.walletId, - required this.mnemonic, - this.frostWalletData, - this.keyData, + required this.recoveryMaterial, this.clipboardInterface = const ClipboardWrapper(), }); static const routeName = "/deleteWalletRecoveryPhrase"; - final String walletId; - final List mnemonic; - final ({ - String myName, - String config, - String keys, - ({String config, String keys})? prevGen, - })? - frostWalletData; - final KeyDataInterface? keyData; + final WalletRecoveryMaterial recoveryMaterial; + + String get walletId => recoveryMaterial.walletId; + List? get mnemonic => switch (recoveryMaterial) { + final MnemonicWalletRecoveryMaterial data => data.words, + _ => null, + }; + FrostWalletRecoveryData? get frostWalletData => switch (recoveryMaterial) { + final FrostWalletRecoveryMaterial data => data.data, + _ => null, + }; + KeyDataInterface? get keyData => switch (recoveryMaterial) { + final PrivateKeyWalletRecoveryMaterial data => data.keyData, + _ => null, + }; final ClipboardInterface clipboardInterface; @@ -73,7 +76,7 @@ class DeleteWalletRecoveryPhraseView extends ConsumerStatefulWidget { class _DeleteWalletRecoveryPhraseViewState extends ConsumerState { - late List _mnemonic; + late final List? _mnemonic; late ClipboardInterface _clipboardInterface; bool _lock = false; @@ -86,47 +89,45 @@ class _DeleteWalletRecoveryPhraseViewState showDialog( barrierDismissible: true, context: context, - builder: - (_) => StackDialog( - title: "Thanks! Your wallet will be deleted.", - leftButton: TextButton( - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), - onPressed: () { - Navigator.pop(context); - }, - child: Text( - "Cancel", - style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, - ), - ), - ), - rightButton: TextButton( - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - onPressed: () async { - await ref - .read(pWallets) - .deleteWallet( - ref.read(pWalletInfo(widget.walletId)), - ref.read(secureStoreProvider), - ); - - if (mounted) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(HomeView.routeName)); - } - }, - child: Text("Ok", style: STextStyles.button(context)), + builder: (_) => StackDialog( + title: "Thanks! Your wallet will be deleted.", + leftButton: TextButton( + style: Theme.of( + context, + ).extension()!.getSecondaryEnabledButtonStyle(context), + onPressed: () { + Navigator.pop(context); + }, + child: Text( + "Cancel", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), + ), + rightButton: TextButton( + style: Theme.of( + context, + ).extension()!.getPrimaryEnabledButtonStyle(context), + onPressed: () async { + await ref + .read(pWallets) + .deleteWallet( + ref.read(pWalletInfo(widget.walletId)), + ref.read(secureStoreProvider), + ); + + if (mounted) { + Navigator.of( + context, + ).popUntil(ModalRoute.withName(HomeView.routeName)); + } + }, + child: Text("Ok", style: STextStyles.button(context)), + ), + ), ); } finally { _lock = false; @@ -147,7 +148,7 @@ class _DeleteWalletRecoveryPhraseViewState final bool frost = widget.frostWalletData != null; final bool keyBased = widget.keyData is CWKeyData; final prevGen = widget.frostWalletData?.prevGen != null; - if (!frost && !keyBased && _mnemonic.isEmpty) { + if (!frost && !keyBased && _mnemonic == null) { throw StateError("Wallet has no recovery data"); } @@ -161,314 +162,293 @@ class _DeleteWalletRecoveryPhraseViewState }, ), actions: [ - if (_mnemonic.isNotEmpty) + if (_mnemonic != null) Padding( - padding: const EdgeInsets.all(10), - child: AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - color: Theme.of(context).extension()!.background, - shadows: const [], - icon: SvgPicture.asset( - Assets.svg.copy, - width: 20, - height: 20, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, - ), - onPressed: () async { - await _clipboardInterface.setData( - ClipboardData(text: _mnemonic.join(" ")), - ); - if (context.mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.info, - message: "Copied to clipboard", - iconAsset: Assets.svg.copy, - context: context, - ), + padding: const EdgeInsets.all(10), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + color: Theme.of( + context, + ).extension()!.background, + shadows: const [], + icon: SvgPicture.asset( + Assets.svg.copy, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + ), + onPressed: () async { + await _clipboardInterface.setData( + ClipboardData(text: _mnemonic.join(" ")), ); - } - }, + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ), + ); + } + }, + ), ), ), - ), ], ), body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), - child: - frost - ? LayoutBuilder( - builder: (builderContext, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + child: frost + ? LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RoundedWhiteContainer( + child: Text( + "Please write down your backup data. Keep it safe and " + "never share it with anyone. " + "Your backup data is the only way you can access your " + "funds if you forget your PIN, lose your phone, etc." + "\n\n" + "${AppConfig.appName} does not keep nor is able to restore " + "your backup data. " + "Only you have access to your wallet.", + style: STextStyles.label(context), + ), + ), + const SizedBox(height: 24), + // DetailItem( + // title: "My name", + // detail: frostWalletData!.myName, + // button: Util.isDesktop + // ? IconCopyButton( + // data: frostWalletData!.myName, + // ) + // : SimpleCopyButton( + // data: frostWalletData!.myName, + // ), + // ), + // const SizedBox( + // height: 16, + // ), + DetailItem( + title: "Multisig config", + detail: widget.frostWalletData!.config, + button: Util.isDesktop + ? tdv.IconCopyButton( + data: widget.frostWalletData!.config, + ) + : SimpleCopyButton( + data: widget.frostWalletData!.config, + ), + ), + const SizedBox(height: 16), + DetailItem( + title: "Keys", + detail: widget.frostWalletData!.keys, + button: Util.isDesktop + ? tdv.IconCopyButton( + data: widget.frostWalletData!.keys, + ) + : SimpleCopyButton( + data: widget.frostWalletData!.keys, + ), + ), + if (prevGen) const SizedBox(height: 24), + if (prevGen) RoundedWhiteContainer( child: Text( - "Please write down your backup data. Keep it safe and " - "never share it with anyone. " - "Your backup data is the only way you can access your " - "funds if you forget your PIN, lose your phone, etc." - "\n\n" - "${AppConfig.appName} does not keep nor is able to restore " - "your backup data. " - "Only you have access to your wallet.", + "Previous generation info", style: STextStyles.label(context), ), ), - const SizedBox(height: 24), - // DetailItem( - // title: "My name", - // detail: frostWalletData!.myName, - // button: Util.isDesktop - // ? IconCopyButton( - // data: frostWalletData!.myName, - // ) - // : SimpleCopyButton( - // data: frostWalletData!.myName, - // ), - // ), - // const SizedBox( - // height: 16, - // ), + if (prevGen) const SizedBox(height: 12), + if (prevGen) DetailItem( - title: "Multisig config", - detail: widget.frostWalletData!.config, - button: - Util.isDesktop - ? tdv.IconCopyButton( - data: - widget - .frostWalletData! - .config, - ) - : SimpleCopyButton( - data: - widget - .frostWalletData! - .config, - ), + title: "Previous multisig config", + detail: + widget.frostWalletData!.prevGen!.config, + button: Util.isDesktop + ? tdv.IconCopyButton( + data: widget + .frostWalletData! + .prevGen! + .config, + ) + : SimpleCopyButton( + data: widget + .frostWalletData! + .prevGen! + .config, + ), ), - const SizedBox(height: 16), + if (prevGen) const SizedBox(height: 16), + if (prevGen) DetailItem( - title: "Keys", - detail: widget.frostWalletData!.keys, - button: - Util.isDesktop - ? tdv.IconCopyButton( - data: - widget.frostWalletData!.keys, - ) - : SimpleCopyButton( - data: - widget.frostWalletData!.keys, - ), + title: "Previous keys", + detail: + widget.frostWalletData!.prevGen!.keys, + button: Util.isDesktop + ? tdv.IconCopyButton( + data: widget + .frostWalletData! + .prevGen! + .keys, + ) + : SimpleCopyButton( + data: widget + .frostWalletData! + .prevGen! + .keys, + ), ), - if (prevGen) const SizedBox(height: 24), - if (prevGen) - RoundedWhiteContainer( - child: Text( - "Previous generation info", - style: STextStyles.label(context), - ), - ), - if (prevGen) const SizedBox(height: 12), - if (prevGen) - DetailItem( - title: "Previous multisig config", - detail: - widget - .frostWalletData! - .prevGen! - .config, - button: - Util.isDesktop - ? tdv.IconCopyButton( - data: - widget - .frostWalletData! - .prevGen! - .config, - ) - : SimpleCopyButton( - data: - widget - .frostWalletData! - .prevGen! - .config, - ), - ), - if (prevGen) const SizedBox(height: 16), - if (prevGen) - DetailItem( - title: "Previous keys", - detail: - widget.frostWalletData!.prevGen!.keys, - button: - Util.isDesktop - ? tdv.IconCopyButton( - data: - widget - .frostWalletData! - .prevGen! - .keys, - ) - : SimpleCopyButton( - data: - widget - .frostWalletData! - .prevGen! - .keys, - ), - ), - const Spacer(), - const SizedBox(height: 16), - PrimaryButton( - label: "Continue", - onPressed: _continuePressed, - ), - ], - ), + const Spacer(), + const SizedBox(height: 16), + PrimaryButton( + label: "Continue", + onPressed: _continuePressed, + ), + ], ), ), - ); - }, - ) - : keyBased - ? LayoutBuilder( - builder: (builderContext, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 4), - Text( - ref.watch(pWalletName(widget.walletId)), - textAlign: TextAlign.center, - style: STextStyles.label( - context, - ).copyWith(fontSize: 12), - ), - const SizedBox(height: 4), - Text( - "Wallet Keys", - textAlign: TextAlign.center, - style: STextStyles.pageTitleH1(context), - ), - const SizedBox(height: 16), - RoundedWhiteContainer( - child: Text( - "Save these keys before deleting your " - "wallet. They are required to restore " - "access to your funds.", - style: STextStyles.label(context), - ), + ), + ); + }, + ) + : keyBased + ? LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 4), + Text( + ref.watch(pWalletName(widget.walletId)), + textAlign: TextAlign.center, + style: STextStyles.label( + context, + ).copyWith(fontSize: 12), + ), + const SizedBox(height: 4), + Text( + "Wallet Keys", + textAlign: TextAlign.center, + style: STextStyles.pageTitleH1(context), + ), + const SizedBox(height: 16), + RoundedWhiteContainer( + child: Text( + "Save these keys before deleting your " + "wallet. They are required to restore " + "access to your funds.", + style: STextStyles.label(context), ), - const SizedBox(height: 8), - Expanded( - child: CNWalletKeys( - cwKeyData: widget.keyData as CWKeyData, - walletId: widget.walletId, - ), + ), + const SizedBox(height: 8), + Expanded( + child: CNWalletKeys( + cwKeyData: widget.keyData as CWKeyData, + walletId: widget.walletId, ), - const SizedBox(height: 16), - TextButton( - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - onPressed: _continuePressed, - child: Text( - "Continue", - style: STextStyles.button(context), - ), + ), + const SizedBox(height: 16), + TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: _continuePressed, + child: Text( + "Continue", + style: STextStyles.button(context), ), - ], - ), + ), + ], ), ), - ); - }, - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 4), - Text( - ref.watch(pWalletName(widget.walletId)), - textAlign: TextAlign.center, - style: STextStyles.label( - context, - ).copyWith(fontSize: 12), ), - const SizedBox(height: 4), - Text( - "Recovery Phrase", - textAlign: TextAlign.center, - style: STextStyles.pageTitleH1(context), - ), - const SizedBox(height: 16), - Container( - decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.popupBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Text( - "Please write down your recovery phrase in the correct order and save it to keep your funds secure. You will also be asked to verify the words on the next screen.", - style: STextStyles.label(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, - ), - ), + ); + }, + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 4), + Text( + ref.watch(pWalletName(widget.walletId)), + textAlign: TextAlign.center, + style: STextStyles.label( + context, + ).copyWith(fontSize: 12), + ), + const SizedBox(height: 4), + Text( + "Recovery Phrase", + textAlign: TextAlign.center, + style: STextStyles.pageTitleH1(context), + ), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, ), ), - const SizedBox(height: 8), - Expanded( - child: SingleChildScrollView( - child: MnemonicTable( - words: _mnemonic, - isDesktop: false, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + "Please write down your recovery phrase in the correct order and save it to keep your funds secure. You will also be asked to verify the words on the next screen.", + style: STextStyles.label(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), - const SizedBox(height: 16), - TextButton( - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - onPressed: _continuePressed, - child: Text( - "Continue", - style: STextStyles.button(context), + ), + const SizedBox(height: 8), + Expanded( + child: SingleChildScrollView( + child: MnemonicTable( + words: _mnemonic!, + isDesktop: false, ), ), - ], - ), + ), + const SizedBox(height: 16), + TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: _continuePressed, + child: Text( + "Continue", + style: STextStyles.button(context), + ), + ), + ], + ), ), ), ), diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart index d9facb8857..bcfd026abe 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart @@ -12,15 +12,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../app_config.dart'; -import '../../../../models/keys/key_data_interface.dart'; -import '../../../../models/keys/view_only_wallet_data.dart'; +import '../../../../models/keys/wallet_recovery_material.dart'; import '../../../../providers/providers.dart'; +import '../../../../services/wallet_recovery_service.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/text_styles.dart'; -import '../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; -import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; -import '../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; -import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/background.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../../widgets/rounded_container.dart'; @@ -101,73 +97,19 @@ class DeleteWalletWarningView extends ConsumerWidget { .getPrimaryEnabledButtonStyle(context), onPressed: () async { final wallet = ref.read(pWallets).getWallet(walletId); - - List? mnemonic; - ({ - String myName, - String config, - String keys, - ({String config, String keys})? prevGen, - })? - frostWalletData; - KeyDataInterface? keyData; - ViewOnlyWalletData? viewOnlyData; - - if (wallet is BitcoinFrostWallet) { - final futures = [ - wallet.getSerializedKeys(), - wallet.getMultisigConfig(), - wallet.getSerializedKeysPrevGen(), - wallet.getMultisigConfigPrevGen(), - ]; - - final results = await Future.wait(futures); - - if (results.length == 4) { - frostWalletData = ( - myName: wallet.frostInfo.myName, - config: results[1]!, - keys: results[0]!, - prevGen: results[2] == null || results[3] == null - ? null - : (config: results[3]!, keys: results[2]!), - ); - } - } else { - if (wallet is ViewOnlyOptionInterface && - wallet.isViewOnly) { - viewOnlyData = await wallet.getViewOnlyWalletData(); - } else if (wallet.info.isRestoredFromKeys) { - if (wallet is! CryptonoteWallet) { - throw StateError( - "Unsupported key-restored wallet: " - "${wallet.runtimeType}", - ); - } - final keys = await wallet.getKeys(); - if (keys == null || keys.hasError) { - throw StateError("Wallet keys are unavailable"); - } - keyData = keys; - } else if (wallet is MnemonicInterface) { - mnemonic = await wallet.getMnemonicAsWords(); - } - } + final recoveryMaterial = + await WalletRecoveryService.getMaterial(wallet); if (context.mounted) { - if (viewOnlyData != null) { + if (recoveryMaterial + case final ViewOnlyWalletRecoveryMaterial data) { await Navigator.of(context).pushNamed( DeleteViewOnlyWalletKeysView.routeName, - arguments: (walletId: walletId, data: viewOnlyData), + arguments: (walletId: walletId, data: data.keyData), ); } else { await Navigator.of(context).pushNamed( DeleteWalletRecoveryPhraseView.routeName, - arguments: ( - walletId: walletId, - mnemonicWords: mnemonic ?? [], - frostWalletData: frostWalletData, - keyData: keyData, - ), + arguments: recoveryMaterial, ); } } diff --git a/lib/pages/special/firo_rescan_recovery_error_dialog.dart b/lib/pages/special/firo_rescan_recovery_error_dialog.dart index 8e8c21f6a1..551de60e12 100644 --- a/lib/pages/special/firo_rescan_recovery_error_dialog.dart +++ b/lib/pages/special/firo_rescan_recovery_error_dialog.dart @@ -2,19 +2,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import '../../models/keys/key_data_interface.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_delete_wallet_dialog.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart'; import '../../providers/global/wallets_provider.dart'; import '../../route_generator.dart'; +import '../../services/wallet_recovery_service.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; -import '../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; -import '../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -258,43 +255,30 @@ class _FiroRescanRecoveryErrorViewState final wallet = ref .read(pWallets) .getWallet(widget.walletId); - // TODO: [prio=low] take wallets that don't have a mnemonic into account - if (wallet is MnemonicInterface) { - final mnemonic = await wallet.getMnemonicAsWords(); + final recoveryMaterial = + await WalletRecoveryService.getMaterial(wallet); - KeyDataInterface? keyData; - if (wallet is ExtendedKeysInterface) { - keyData = await wallet.getXPrivs(); - } else if (wallet is CryptonoteWallet) { - keyData = await wallet.getKeys(); - } - - if (context.mounted) { - await Navigator.push( - context, - RouteGenerator.getRoute( - shouldUseMaterialRoute: - RouteGenerator.useMaterialPageRoute, - builder: (_) => LockscreenView( - routeOnSuccessArguments: ( - walletId: widget.walletId, - mnemonic: mnemonic, - keyData: keyData, - ), - showBackButton: true, - routeOnSuccess: WalletBackupView.routeName, - biometricsCancelButtonString: "CANCEL", - biometricsLocalizedReason: - "Authenticate to view recovery phrase", - biometricsAuthenticationTitle: - "View recovery phrase", - ), - settings: const RouteSettings( - name: "/viewRecoverPhraseLockscreen", - ), + if (context.mounted) { + await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: + RouteGenerator.useMaterialPageRoute, + builder: (_) => LockscreenView( + routeOnSuccessArguments: recoveryMaterial, + showBackButton: true, + routeOnSuccess: WalletBackupView.routeName, + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: + "Authenticate to view recovery phrase", + biometricsAuthenticationTitle: + "View recovery phrase", ), - ); - } + settings: const RouteSettings( + name: "/viewRecoverPhraseLockscreen", + ), + ), + ); } } }, diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart index 56e63bf788..cb5c28547a 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart @@ -16,6 +16,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../models/keys/cw_key_data.dart'; import '../../../../models/keys/key_data_interface.dart'; +import '../../../../models/keys/wallet_recovery_material.dart'; import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart'; import '../../../../pages/settings_views/wallet_settings_view/wallet_backup_views/cn_wallet_keys.dart'; @@ -32,21 +33,32 @@ import '../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../../../widgets/desktop/primary_button.dart'; import '../../../../widgets/desktop/secondary_button.dart'; +import '../../../../widgets/rounded_white_container.dart'; class DeleteWalletKeysPopup extends ConsumerStatefulWidget { const DeleteWalletKeysPopup({ super.key, - required this.walletId, - required this.words, - this.keyData, + required this.recoveryMaterial, this.clipboardInterface = const ClipboardWrapper(), }); - final String walletId; - final List words; - final KeyDataInterface? keyData; + final WalletRecoveryMaterial recoveryMaterial; final ClipboardInterface clipboardInterface; + String get walletId => recoveryMaterial.walletId; + List? get words => switch (recoveryMaterial) { + final MnemonicWalletRecoveryMaterial data => data.words, + _ => null, + }; + KeyDataInterface? get keyData => switch (recoveryMaterial) { + final PrivateKeyWalletRecoveryMaterial data => data.keyData, + _ => null, + }; + FrostWalletRecoveryData? get frostData => switch (recoveryMaterial) { + final FrostWalletRecoveryMaterial data => data.data, + _ => null, + }; + static const String routeName = "/desktopDeleteWalletKeysPopup"; @override @@ -56,7 +68,7 @@ class DeleteWalletKeysPopup extends ConsumerStatefulWidget { class _DeleteWalletKeysPopup extends ConsumerState { late final String _walletId; - late final List _words; + late final List? _words; late final ClipboardInterface _clipboardInterface; static const _recoveryPhraseInfo = @@ -75,7 +87,9 @@ class _DeleteWalletKeysPopup extends ConsumerState { @override Widget build(BuildContext context) { - if (_words.isEmpty && widget.keyData is! CWKeyData) { + if (_words == null && + widget.keyData is! CWKeyData && + widget.frostData == null) { throw StateError("Wallet has no recovery data"); } @@ -105,7 +119,7 @@ class _DeleteWalletKeysPopup extends ConsumerState { child: SingleChildScrollView( child: Column( children: [ - if (_words.isNotEmpty) ...[ + if (_words != null) ...[ const SizedBox(height: 28), Text( "Recovery phrase", @@ -145,7 +159,7 @@ class _DeleteWalletKeysPopup extends ConsumerState { } }, child: MnemonicTable( - words: widget.words, + words: widget.words!, isDesktop: true, itemBorderColor: Theme.of( context, @@ -153,7 +167,7 @@ class _DeleteWalletKeysPopup extends ConsumerState { ), ), ), - ] else ...[ + ] else if (widget.keyData is CWKeyData) ...[ const SizedBox(height: 20), Padding( padding: const EdgeInsets.symmetric(horizontal: 32), @@ -168,6 +182,8 @@ class _DeleteWalletKeysPopup extends ConsumerState { cwKeyData: widget.keyData as CWKeyData, walletId: widget.walletId, ), + ] else ...[ + _FrostRecoveryData(data: widget.frostData!), ], ], ), @@ -183,7 +199,7 @@ class _DeleteWalletKeysPopup extends ConsumerState { label: "Continue", onPressed: () async { await Navigator.of(context).push( - RouteGenerator.getRoute( + RouteGenerator.getRoute( builder: (context) { return ConfirmDelete(walletId: _walletId); }, @@ -205,6 +221,71 @@ class _DeleteWalletKeysPopup extends ConsumerState { } } +class _FrostRecoveryData extends StatelessWidget { + const _FrostRecoveryData({required this.data}); + + final FrostWalletRecoveryData data; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + children: [ + const SizedBox(height: 20), + Text( + "Save this FROST backup before deleting your wallet.", + style: STextStyles.desktopTextExtraExtraSmall(context), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + _FrostRecoveryField(label: "Multisig config", value: data.config), + const SizedBox(height: 16), + _FrostRecoveryField(label: "Keys", value: data.keys), + if (data.prevGen case final previous?) ...[ + const SizedBox(height: 24), + Text( + "Previous generation", + style: STextStyles.desktopTextMedium(context), + ), + const SizedBox(height: 16), + _FrostRecoveryField( + label: "Multisig config", + value: previous.config, + ), + const SizedBox(height: 16), + _FrostRecoveryField(label: "Keys", value: previous.keys), + ], + ], + ), + ); + } +} + +class _FrostRecoveryField extends StatelessWidget { + const _FrostRecoveryField({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text(label, style: STextStyles.desktopTextMedium(context)), + const SizedBox(height: 8), + RoundedWhiteContainer( + child: SelectableText( + value, + style: STextStyles.desktopTextExtraExtraSmall(context), + textAlign: TextAlign.center, + ), + ), + ], + ); + } +} + class ConfirmDelete extends ConsumerStatefulWidget { const ConfirmDelete({super.key, required this.walletId}); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart index 7b363a9252..62ed489b26 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart @@ -13,16 +13,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:stack_wallet_backup/secure_storage.dart'; import '../../../../app_config.dart'; -import '../../../../models/keys/key_data_interface.dart'; +import '../../../../models/keys/wallet_recovery_material.dart'; import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart'; import '../../../../providers/global/wallets_provider.dart'; import '../../../../route_generator.dart'; +import '../../../../services/wallet_recovery_service.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/logger.dart'; import '../../../../utilities/text_styles.dart'; -import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; -import '../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; -import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../../../widgets/desktop/primary_button.dart'; @@ -113,10 +111,11 @@ class _DesktopAttentionDeleteWallet final wallet = ref .read(pWallets) .getWallet(widget.walletId); + final recoveryMaterial = + await WalletRecoveryService.getMaterial(wallet); - if (wallet is ViewOnlyOptionInterface && - wallet.isViewOnly) { - final data = await wallet.getViewOnlyWalletData(); + if (recoveryMaterial + case final ViewOnlyWalletRecoveryMaterial data) { if (context.mounted) { await Navigator.of(context).push( MaterialPageRoute( @@ -154,7 +153,7 @@ class _DesktopAttentionDeleteWallet padding: const EdgeInsets.all(32), child: DeleteViewOnlyWalletKeysView( walletId: widget.walletId, - data: data, + data: data.keyData, ), ), ], @@ -163,34 +162,11 @@ class _DesktopAttentionDeleteWallet ), ); } - } else if (wallet is MnemonicInterface) { - final List words; - KeyDataInterface? keyData; - if (wallet.info.isRestoredFromKeys) { - words = []; - if (wallet is! CryptonoteWallet) { - throw StateError( - "Unsupported key-restored wallet: " - "${wallet.runtimeType}", - ); - } - final keys = await wallet.getKeys(); - if (keys == null || keys.hasError) { - throw StateError("Wallet keys are unavailable"); - } - keyData = keys; - } else { - words = await wallet.getMnemonicAsWords(); - } - + } else { if (context.mounted) { await Navigator.of(context).pushNamed( DeleteWalletKeysPopup.routeName, - arguments: ( - walletId: widget.walletId, - words: words, - keyData: keyData, - ), + arguments: recoveryMaterial, ); } } diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart index 8a884526ef..7260aca133 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart @@ -14,19 +14,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import '../../../../models/keys/key_data_interface.dart'; import '../../../../notifications/show_flush_bar.dart'; import '../../../../providers/desktop/storage_crypto_handler_provider.dart'; import '../../../../providers/providers.dart'; +import '../../../../services/wallet_recovery_service.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/assets.dart'; import '../../../../utilities/constants.dart'; import '../../../../utilities/text_styles.dart'; -import '../../../../wallets/wallet/impl/bitcoin_frost_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/mnemonic_interface.dart'; -import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../../../widgets/desktop/primary_button.dart'; @@ -80,67 +75,12 @@ class _UnlockWalletKeysDesktopState } final wallet = ref.read(pWallets).getWallet(widget.walletId); - ({ - String myName, - String config, - String keys, - ({String config, String keys})? prevGen, - })? - frostWalletData; - List? words; - - if (wallet is! MnemonicInterface) { - if (wallet is BitcoinFrostWallet) { - final futures = [ - wallet.getSerializedKeys(), - wallet.getMultisigConfig(), - wallet.getSerializedKeysPrevGen(), - wallet.getMultisigConfigPrevGen(), - ]; - - final results = await Future.wait(futures); - if (results.length == 4) { - frostWalletData = ( - myName: wallet.frostInfo.myName, - config: results[1]!, - keys: results[0]!, - prevGen: results[2] == null || results[3] == null - ? null - : (config: results[3]!, keys: results[2]!), - ); - } - } else { - throw Exception("FIXME ~= see todo in code"); - } - } else if (!wallet.info.isRestoredFromKeys && - !(wallet is ViewOnlyOptionInterface && - (wallet as ViewOnlyOptionInterface).isViewOnly)) { - words = await wallet.getMnemonicAsWords(); - } - - KeyDataInterface? keyData; - if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { - keyData = await wallet.getViewOnlyWalletData(); - } else if (wallet is ExtendedKeysInterface) { - keyData = await wallet.getXPrivs(); - } else if (wallet is CryptonoteWallet) { - final keys = await wallet.getKeys(); - if (wallet.info.isRestoredFromKeys && - (keys == null || keys.hasError)) { - throw StateError("Wallet keys are unavailable"); - } - keyData = keys; - } + final recoveryMaterial = await WalletRecoveryService.getMaterial(wallet); if (mounted) { await Navigator.of(context).pushReplacementNamed( WalletKeysDesktopPopup.routeName, - arguments: ( - mnemonic: words ?? [], - walletId: widget.walletId, - frostData: frostWalletData, - keyData: keyData, - ), + arguments: recoveryMaterial, ); } } else { diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart index 2c31098a2d..6714f260ef 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart @@ -17,6 +17,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../models/keys/cw_key_data.dart'; import '../../../../models/keys/key_data_interface.dart'; import '../../../../models/keys/view_only_wallet_data.dart'; +import '../../../../models/keys/wallet_recovery_material.dart'; import '../../../../models/keys/xpriv_data.dart'; import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart'; @@ -40,24 +41,28 @@ import 'qr_code_desktop_popup_content.dart'; class WalletKeysDesktopPopup extends ConsumerWidget { const WalletKeysDesktopPopup({ super.key, - required this.words, - required this.walletId, - this.frostData, + required this.recoveryMaterial, this.clipboardInterface = const ClipboardWrapper(), - this.keyData, }); - final List words; - final String walletId; - final ({ - String myName, - String config, - String keys, - ({String config, String keys})? prevGen, - })? - frostData; + final WalletRecoveryMaterial recoveryMaterial; final ClipboardInterface clipboardInterface; - final KeyDataInterface? keyData; + + List? get words => switch (recoveryMaterial) { + final MnemonicWalletRecoveryMaterial data => data.words, + _ => null, + }; + String get walletId => recoveryMaterial.walletId; + FrostWalletRecoveryData? get frostData => switch (recoveryMaterial) { + final FrostWalletRecoveryMaterial data => data.data, + _ => null, + }; + KeyDataInterface? get keyData => switch (recoveryMaterial) { + final MnemonicWalletRecoveryMaterial data => data.supplementalKeyData, + final PrivateKeyWalletRecoveryMaterial data => data.keyData, + final ViewOnlyWalletRecoveryMaterial data => data.keyData, + _ => null, + }; static const String routeName = "walletKeysDesktopPopup"; @@ -88,90 +93,16 @@ class WalletKeysDesktopPopup extends ConsumerWidget { const SizedBox(height: 6), frostData != null ? Column( - children: [ - Text("Keys", style: STextStyles.desktopTextMedium(context)), - const SizedBox(height: 8), - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 9, - ), - child: Row( - children: [ - Flexible( - child: SelectableText( - frostData!.keys, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(width: 10), - IconCopyButton(data: frostData!.keys), - // TODO [prio=low: Add QR code button and dialog. - ], - ), - ), - ), - ), - const SizedBox(height: 24), - Text("Config", style: STextStyles.desktopTextMedium(context)), - const SizedBox(height: 8), - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 9, - ), - child: Row( - children: [ - Flexible( - child: SelectableText( - frostData!.config, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(width: 10), - IconCopyButton(data: frostData!.config), - // TODO [prio=low: Add QR code button and dialog. - ], - ), - ), - ), - ), - if (frostData?.prevGen != null) const SizedBox(height: 24), - if (frostData?.prevGen != null) - Text( - "Previous generation Keys", - style: STextStyles.desktopTextMedium(context), - ), - if (frostData?.prevGen != null) const SizedBox(height: 8), - if (frostData?.prevGen != null) + children: [ + Text("Keys", style: STextStyles.desktopTextMedium(context)), + const SizedBox(height: 8), Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 9, @@ -195,22 +126,19 @@ class WalletKeysDesktopPopup extends ConsumerWidget { ), ), ), - if (frostData?.prevGen != null) const SizedBox(height: 24), - if (frostData?.prevGen != null) + const SizedBox(height: 24), Text( - "Previous generation Config", + "Config", style: STextStyles.desktopTextMedium(context), ), - if (frostData?.prevGen != null) const SizedBox(height: 8), - if (frostData?.prevGen != null) + const SizedBox(height: 8), Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 9, @@ -219,7 +147,7 @@ class WalletKeysDesktopPopup extends ConsumerWidget { children: [ Flexible( child: SelectableText( - frostData!.prevGen!.config, + frostData!.config, style: STextStyles.desktopTextExtraExtraSmall( context, ), @@ -227,49 +155,129 @@ class WalletKeysDesktopPopup extends ConsumerWidget { ), ), const SizedBox(width: 10), - IconCopyButton(data: frostData!.prevGen!.config), + IconCopyButton(data: frostData!.config), // TODO [prio=low: Add QR code button and dialog. ], ), ), ), ), - const SizedBox(height: 24), - ], - ) - : keyData != null - ? keyData is ViewOnlyWalletData - ? Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: ViewOnlyWalletDataWidget( - data: keyData as ViewOnlyWalletData, - ), - ) - : CustomTabView( - titles: [ - if (words.isNotEmpty) "Mnemonic", - if (keyData is XPrivData) "XPriv(s)", - if (keyData is CWKeyData) "Keys", - ], - children: [ - if (words.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 16), - child: _Mnemonic(words: words), + if (frostData?.prevGen != null) const SizedBox(height: 24), + if (frostData?.prevGen != null) + Text( + "Previous generation Keys", + style: STextStyles.desktopTextMedium(context), + ), + if (frostData?.prevGen != null) const SizedBox(height: 8), + if (frostData?.prevGen != null) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: RoundedWhiteContainer( + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 9, + ), + child: Row( + children: [ + Flexible( + child: SelectableText( + frostData!.keys, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 10), + IconCopyButton(data: frostData!.keys), + // TODO [prio=low: Add QR code button and dialog. + ], + ), + ), ), - if (keyData is XPrivData) - WalletXPrivs( - xprivData: keyData as XPrivData, - walletId: walletId, + ), + if (frostData?.prevGen != null) const SizedBox(height: 24), + if (frostData?.prevGen != null) + Text( + "Previous generation Config", + style: STextStyles.desktopTextMedium(context), + ), + if (frostData?.prevGen != null) const SizedBox(height: 8), + if (frostData?.prevGen != null) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: RoundedWhiteContainer( + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 9, + ), + child: Row( + children: [ + Flexible( + child: SelectableText( + frostData!.prevGen!.config, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 10), + IconCopyButton( + data: frostData!.prevGen!.config, + ), + // TODO [prio=low: Add QR code button and dialog. + ], + ), + ), ), - if (keyData is CWKeyData) - CNWalletKeys( - cwKeyData: keyData as CWKeyData, - walletId: walletId, + ), + const SizedBox(height: 24), + ], + ) + : keyData != null + ? keyData is ViewOnlyWalletData + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ViewOnlyWalletDataWidget( + data: keyData as ViewOnlyWalletData, ), - ], - ) - : _Mnemonic(words: words), + ) + : CustomTabView( + titles: [ + if (words != null) "Mnemonic", + if (keyData is XPrivData) "XPriv(s)", + if (keyData is CWKeyData) "Keys", + ], + children: [ + if (words != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: _Mnemonic(words: words!), + ), + if (keyData is XPrivData) + WalletXPrivs( + xprivData: keyData as XPrivData, + walletId: walletId, + ), + if (keyData is CWKeyData) + CNWalletKeys( + cwKeyData: keyData as CWKeyData, + walletId: walletId, + ), + ], + ) + : _Mnemonic(words: words!), const SizedBox(height: 32), ], ), @@ -311,8 +319,9 @@ class _Mnemonic extends StatelessWidget { child: MnemonicTable( words: words, isDesktop: true, - itemBorderColor: - Theme.of(context).extension()!.buttonBackSecondary, + itemBorderColor: Theme.of( + context, + ).extension()!.buttonBackSecondary, ), ), const SizedBox(height: 24), diff --git a/lib/route_generator.dart b/lib/route_generator.dart index c43f8497f4..65ee356640 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -28,6 +28,7 @@ import 'models/isar/models/isar_models.dart'; import 'models/isar/ordinal.dart'; import 'models/keys/key_data_interface.dart'; import 'models/keys/view_only_wallet_data.dart'; +import 'models/keys/wallet_recovery_material.dart'; import 'models/paynym/paynym_account_lite.dart'; import 'models/send_view_auto_fill_data.dart'; import 'models/shopinbit/shopinbit_enums.dart'; @@ -1706,72 +1707,10 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case WalletBackupView.routeName: - if (args is ({String walletId, List mnemonic})) { + if (args is WalletRecoveryMaterial) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => WalletBackupView( - walletId: args.walletId, - mnemonic: args.mnemonic, - ), - settings: RouteSettings(name: settings.name), - ); - } else if (args - is ({ - String walletId, - List mnemonic, - ({ - String myName, - String config, - String keys, - ({String config, String keys})? prevGen, - })? - frostWalletData, - })) { - return getRoute( - shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => WalletBackupView( - walletId: args.walletId, - mnemonic: args.mnemonic, - frostWalletData: args.frostWalletData, - ), - settings: RouteSettings(name: settings.name), - ); - } else if (args - is ({ - String walletId, - List mnemonic, - KeyDataInterface? keyData, - })) { - return getRoute( - shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => WalletBackupView( - walletId: args.walletId, - mnemonic: args.mnemonic, - keyData: args.keyData, - ), - settings: RouteSettings(name: settings.name), - ); - } else if (args - is ({ - String walletId, - List mnemonic, - KeyDataInterface? keyData, - ({ - String myName, - String config, - String keys, - ({String config, String keys})? prevGen, - })? - frostWalletData, - })) { - return getRoute( - shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => WalletBackupView( - walletId: args.walletId, - mnemonic: args.mnemonic, - frostWalletData: args.frostWalletData, - keyData: args.keyData, - ), + builder: (_) => WalletBackupView(recoveryMaterial: args), settings: RouteSettings(name: settings.name), ); } @@ -2268,57 +2207,11 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case DeleteWalletRecoveryPhraseView.routeName: - if (args - is ({ - String walletId, - List mnemonicWords, - ({ - String myName, - String config, - String keys, - ({String config, String keys})? prevGen, - })? - frostWalletData, - KeyDataInterface? keyData, - })) { - return getRoute( - shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => DeleteWalletRecoveryPhraseView( - mnemonic: args.mnemonicWords, - walletId: args.walletId, - frostWalletData: args.frostWalletData, - keyData: args.keyData, - ), - settings: RouteSettings(name: settings.name), - ); - } else if (args is ({String walletId, List mnemonicWords})) { - return getRoute( - shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => DeleteWalletRecoveryPhraseView( - mnemonic: args.mnemonicWords, - walletId: args.walletId, - ), - settings: RouteSettings(name: settings.name), - ); - } else if (args - is ({ - String walletId, - List mnemonicWords, - ({ - String myName, - String config, - String keys, - ({String config, String keys})? prevGen, - })? - frostWalletData, - })) { + if (args is WalletRecoveryMaterial) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => DeleteWalletRecoveryPhraseView( - mnemonic: args.mnemonicWords, - walletId: args.walletId, - frostWalletData: args.frostWalletData, - ), + builder: (_) => + DeleteWalletRecoveryPhraseView(recoveryMaterial: args), settings: RouteSettings(name: settings.name), ); } @@ -2764,60 +2657,9 @@ class RouteGenerator { ); case WalletKeysDesktopPopup.routeName: - if (args - is ({ - List mnemonic, - String walletId, - ({ - String myName, - String config, - String keys, - ({String config, String keys})? prevGen, - })? - frostData, - })) { - return FadePageRoute( - WalletKeysDesktopPopup( - words: args.mnemonic, - walletId: args.walletId, - frostData: args.frostData, - ), - RouteSettings(name: settings.name), - ); - } else if (args - is ({ - List mnemonic, - String walletId, - ({ - String myName, - String config, - String keys, - ({String config, String keys})? prevGen, - })? - frostData, - KeyDataInterface? keyData, - })) { - return FadePageRoute( - WalletKeysDesktopPopup( - words: args.mnemonic, - walletId: args.walletId, - frostData: args.frostData, - keyData: args.keyData, - ), - RouteSettings(name: settings.name), - ); - } else if (args - is ({ - List mnemonic, - String walletId, - KeyDataInterface? keyData, - })) { + if (args is WalletRecoveryMaterial) { return FadePageRoute( - WalletKeysDesktopPopup( - words: args.mnemonic, - walletId: args.walletId, - keyData: args.keyData, - ), + WalletKeysDesktopPopup(recoveryMaterial: args), RouteSettings(name: settings.name), ); } @@ -2878,34 +2720,11 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case DeleteWalletKeysPopup.routeName: - if (args - is ({ - String walletId, - List words, - KeyDataInterface? keyData, - })) { - return FadePageRoute( - DeleteWalletKeysPopup( - walletId: args.walletId, - words: args.words, - keyData: args.keyData, - ), - RouteSettings(name: settings.name), - ); - } else if (args is Tuple2>) { + if (args is WalletRecoveryMaterial) { return FadePageRoute( - DeleteWalletKeysPopup(walletId: args.item1, words: args.item2), + DeleteWalletKeysPopup(recoveryMaterial: args), RouteSettings(name: settings.name), ); - // return getRoute( - // shouldUseMaterialRoute: useMaterialPageRoute, - // builder: (_) => WalletKeysDesktopPopup( - // words: args, - // ), - // settings: RouteSettings( - // name: settings.name, - // ), - // ); } return _routeError("${settings.name} invalid args: ${args.toString()}"); diff --git a/lib/services/wallet_recovery_service.dart b/lib/services/wallet_recovery_service.dart new file mode 100644 index 0000000000..a5745676f9 --- /dev/null +++ b/lib/services/wallet_recovery_service.dart @@ -0,0 +1,87 @@ +import '../models/keys/key_data_interface.dart'; +import '../models/keys/wallet_recovery_material.dart'; +import '../wallets/isar/models/wallet_info.dart'; +import '../wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import '../wallets/wallet/intermediate/cryptonote_wallet.dart'; +import '../wallets/wallet/wallet.dart'; +import '../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; +import '../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; +import '../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; + +class WalletRecoveryService { + const WalletRecoveryService._(); + + static Future getMaterial(Wallet wallet) async { + if (wallet is BitcoinFrostWallet) { + final results = await Future.wait([ + wallet.getSerializedKeys(), + wallet.getMultisigConfig(), + wallet.getSerializedKeysPrevGen(), + wallet.getMultisigConfigPrevGen(), + ]); + final keys = results[0]; + final config = results[1]; + if (keys == null || config == null) { + throw StateError("FROST recovery data is unavailable"); + } + + return FrostWalletRecoveryMaterial( + walletId: wallet.walletId, + data: ( + myName: wallet.frostInfo.myName, + config: config, + keys: keys, + prevGen: results[2] == null || results[3] == null + ? null + : (config: results[3]!, keys: results[2]!), + ), + ); + } + + if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { + final data = await wallet.getViewOnlyWalletData(); + return ViewOnlyWalletRecoveryMaterial( + walletId: wallet.walletId, + keyData: data, + ); + } + + if (wallet.info.recoveryType == WalletRecoveryType.privateKeys) { + if (wallet is! CryptonoteWallet) { + throw UnsupportedError( + "Unsupported private-key wallet: ${wallet.runtimeType}", + ); + } + return PrivateKeyWalletRecoveryMaterial( + walletId: wallet.walletId, + keyData: await wallet.getKeys(), + ); + } + + if (wallet is! MnemonicInterface) { + throw UnsupportedError( + "Unsupported wallet recovery type: ${wallet.runtimeType}", + ); + } + + final words = await wallet.getMnemonicAsWords(); + if (words.isEmpty) { + throw StateError("Wallet mnemonic is unavailable"); + } + + final KeyDataInterface? supplementalKeyData; + if (wallet is ExtendedKeysInterface) { + supplementalKeyData = await wallet.getXPrivs(); + } else if (wallet is CryptonoteWallet) { + supplementalKeyData = await wallet.getKeys(); + } else { + supplementalKeyData = null; + } + + return MnemonicWalletRecoveryMaterial( + walletId: wallet.walletId, + words: words, + supplementalKeyData: supplementalKeyData, + ); + } +} diff --git a/lib/services/wallets.dart b/lib/services/wallets.dart index e1d38149b8..4a195300ec 100644 --- a/lib/services/wallets.dart +++ b/lib/services/wallets.dart @@ -114,13 +114,9 @@ class Wallets { _wallets.remove(walletId); await wallet?.exit(); - await secureStorage.delete(key: Wallet.mnemonicKey(walletId: walletId)); - await secureStorage.delete( - key: Wallet.mnemonicPassphraseKey(walletId: walletId), - ); - await secureStorage.delete(key: Wallet.privateKeyKey(walletId: walletId)); - await secureStorage.delete( - key: Wallet.getViewOnlyWalletDataSecStoreKey(walletId: walletId), + await Wallet.deleteSecureStorageData( + walletId: walletId, + secureStorage: secureStorage, ); if (info.coin is CryptonoteCurrency) { diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index 655a4ff487..a9e0a835c8 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -42,7 +42,10 @@ class AddressUtils { } /// Parses a URI string and returns a map with parsed components. - static Map _parseUri(String uri) { + static Map _parseUri( + String uri, { + bool redactUriInLogs = false, + }) { final Map result = {}; try { final u = Uri.parse(uri); @@ -79,7 +82,8 @@ class AddressUtils { } } catch (e, s) { Logging.instance.d( - "Exception caught in parseUri($uri): $e", + "Exception caught in parseUri(" + "${redactUriInLogs ? '' : uri}): $e", error: e, stackTrace: s, ); @@ -197,25 +201,18 @@ class AddressUtils { /// /// Returns null on failure to parse. static Map? _parseWalletUri(String uri) { - final String scheme; final Map parsedData = {}; - final rawScheme = uri.split(":")[0]; - final normalizedScheme = rawScheme.replaceAll("-", "_"); - if (normalizedScheme != rawScheme) { - uri = normalizedScheme + uri.substring(rawScheme.length); - } + final separatorIndex = uri.indexOf(":"); + if (separatorIndex <= 0) return null; - if (uri.split(":")[0].contains("_")) { - // We need to check if the uri is compatible because RFC 3986 - // does not allow underscores in the scheme. - final String compatibleUri = uri.replaceFirst("_", ""); - scheme = uri.split(":")[0]; - parsedData.addAll(_parseUri(compatibleUri)); - } else { - parsedData.addAll(_parseUri(uri)); - scheme = parsedData['scheme'] as String? ?? ''; - } + final scheme = uri + .substring(0, separatorIndex) + .toLowerCase() + .replaceAll("-", "_"); + final compatibleScheme = scheme.replaceAll("_", ""); + final compatibleUri = compatibleScheme + uri.substring(separatorIndex); + parsedData.addAll(_parseUri(compatibleUri, redactUriInLogs: true)); // Match the normalized wallet-uri scheme exactly. A bare payment scheme // (e.g. "monero") must not be accepted here as a wallet uri; only the @@ -474,7 +471,6 @@ class WalletUriData { final String? spendKey; final String? viewKey; final int? height; - final List? txids; bool get isViewOnly => spendKey == null && seed == null; @@ -485,38 +481,65 @@ class WalletUriData { this.spendKey, this.viewKey, this.height, - this.txids, }); - factory WalletUriData.fromUriString(String uri) { + factory WalletUriData.fromUriString( + String uri, { + bool Function(String address)? addressValidator, + }) { final map = AddressUtils._parseWalletUri(uri); if (map == null) { - throw Exception("Invalid wallet URI"); + throw const FormatException("Invalid wallet URI"); } - return WalletUriData.fromJson(map, map["coin"] as CryptoCurrency); + return WalletUriData.fromJson( + map, + map["coin"] as CryptoCurrency, + addressValidator: addressValidator, + ); } /// Factory constructor with validation logic according to the spec: /// https://github.com/monero-project/monero/wiki/URI-Formatting#wallet-definition-scheme factory WalletUriData.fromJson( Map json, - CryptoCurrency coin, - ) { - final address = json["address"] as String?; - final spendKey = json["spend_key"] as String?; - final viewKey = json["view_key"] as String?; - final seed = json["seed"] as String?; - final height = json["height"] != null - ? int.tryParse(json["height"].toString()) - : null; - final txid = json["txid"] as String?; + CryptoCurrency coin, { + bool Function(String address)? addressValidator, + }) { + String? optionalString(String key) { + final value = json[key]; + return value is String && value.trim().isNotEmpty ? value.trim() : null; + } + + final address = optionalString("address"); + final spendKey = optionalString("spend_key"); + final viewKey = optionalString("view_key"); + final seed = optionalString("seed") ?? optionalString("mnemonic_seed"); + final heightValue = json["height"]; + final rawHeight = heightValue == null + ? null + : heightValue.toString().trim(); + final txid = optionalString("txid"); + + if (json.containsKey("height") && + (rawHeight == null || rawHeight.isEmpty)) { + throw const FormatException("Invalid restore height."); + } + + final height = rawHeight == null ? null : int.tryParse(rawHeight); + if (rawHeight != null && (height == null || height < 0)) { + throw const FormatException("Invalid restore height."); + } + + if (txid != null) { + throw UnsupportedError("Transaction-ID wallet restores are unsupported"); + } // Must have seed XOR view_key (spend_key is optional). // May have seed only, view_key + spend_key, or view_key only. final hasSeed = seed != null; - final hasKeys = viewKey != null; + final hasKeys = viewKey != null || spendKey != null; if (hasSeed && hasKeys) { throw const FormatException( @@ -529,26 +552,31 @@ class WalletUriData { ); } - // Spend_key requires view_key. if (spendKey != null && viewKey == null) { throw const FormatException("Invalid: spend_key requires view_key."); } - // Height requires absence of txid. - if (height != null && txid != null) { + if (hasKeys && address == null) { throw const FormatException( - "Invalid: cannot specify both height and txid.", + "Invalid: an address is required with private keys.", ); } + final addressPattern = RegExp( + r"^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{95}$", + ); + if (hasKeys && !addressPattern.hasMatch(address!)) { + throw const FormatException("Invalid wallet address."); + } + if (hasKeys && addressValidator != null && !addressValidator(address!)) { + throw const FormatException("Invalid wallet address."); + } - // Parse txids if present. - List? txids; - if (txid != null && txid.isNotEmpty) { - txids = txid - .split(";") - .map((s) => s.trim()) - .where((s) => s.isNotEmpty) - .toList(); + final privateKeyPattern = RegExp(r"^[0-9a-fA-F]{64}$"); + if (viewKey != null && !privateKeyPattern.hasMatch(viewKey)) { + throw const FormatException("Invalid private view key."); + } + if (spendKey != null && !privateKeyPattern.hasMatch(spendKey)) { + throw const FormatException("Invalid private spend key."); } return WalletUriData( @@ -558,7 +586,6 @@ class WalletUriData { viewKey: viewKey, seed: seed, height: height, - txids: txids, ); } @@ -567,23 +594,10 @@ class WalletUriData { return "WalletUriData { " "coin: $coin, " "address: $address, " - "seed: $seed, " - "spendKey: $spendKey, " - "viewKey: $viewKey, " + "seed: ${seed == null ? null : ''}, " + "spendKey: ${spendKey == null ? null : ''}, " + "viewKey: ${viewKey == null ? null : ''}, " "height: $height, " - "txids: $txids" " }"; } - - String toJson() { - return jsonEncode({ - "coin": coin.prettyName, - "address": address, - "seed": seed, - "spendKey": spendKey, - "viewKey": viewKey, - "height": height, - "txids": txids, - }); - } } diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart index 36a134416c..61c4022946 100644 --- a/lib/wallets/isar/models/wallet_info.dart +++ b/lib/wallets/isar/models/wallet_info.dart @@ -13,6 +13,8 @@ import 'wallet_info_meta.dart'; part 'wallet_info.g.dart'; +enum WalletRecoveryType { mnemonic, privateKeys } + @Collection(accessor: "walletInfo", inheritance: false) class WalletInfo implements IsarId { @override @@ -145,8 +147,20 @@ class WalletInfo implements IsarId { otherData[WalletInfoKeys.isViewOnlyKey] as bool? ?? false; @ignore - bool get isRestoredFromKeys => - otherData[WalletInfoKeys.isRestoredFromKeysKey] as bool? ?? false; + WalletRecoveryType get recoveryType { + final index = otherData[WalletInfoKeys.recoveryTypeIndexKey] as int?; + if (index != null && + index >= 0 && + index < WalletRecoveryType.values.length) { + return WalletRecoveryType.values[index]; + } + + if (otherData[WalletInfoKeys.isRestoredFromKeysKey] == true) { + return WalletRecoveryType.privateKeys; + } + + return WalletRecoveryType.mnemonic; + } @ignore ViewOnlyWalletType? get viewOnlyWalletType { @@ -589,4 +603,5 @@ abstract class WalletInfoKeys { static const String firoMasternodeCollateralDismissed = "firoMasternodeCollateralDismissedKey"; static const String isRestoredFromKeysKey = "isRestoredFromKeysKey"; + static const String recoveryTypeIndexKey = "recoveryTypeIndexKey"; } diff --git a/lib/wallets/wallet/intermediate/cryptonote_wallet.dart b/lib/wallets/wallet/intermediate/cryptonote_wallet.dart index 62a08ce3a1..026d2f5cd0 100644 --- a/lib/wallets/wallet/intermediate/cryptonote_wallet.dart +++ b/lib/wallets/wallet/intermediate/cryptonote_wallet.dart @@ -24,7 +24,7 @@ abstract class CryptonoteWallet @override Future init({bool? isRestore, int? wordCount}); - Future getKeys(); + Future getKeys(); Future getTxKeyFor({required String txid}); diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index c29b5c8b2b..a85f766f85 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -12,6 +12,7 @@ import '../../../app_config.dart'; import '../../../db/hive/db.dart'; import '../../../models/balance.dart'; import '../../../models/input.dart'; +import '../../../models/keys/cryptonote_key_restore_data.dart'; import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../models/isar/models/blockchain_data/transaction.dart'; import '../../../models/isar/models/blockchain_data/utxo.dart'; @@ -301,10 +302,10 @@ abstract class LibMoneroWallet } @override - Future getKeys() async { + Future getKeys() async { final oldInfo = getLibMoneroWalletInfo(walletId); if (wallet == null || (oldInfo != null && oldInfo.name != walletId)) { - return null; + throw StateError("Monero wallet is not loaded"); } try { return CWKeyData( @@ -316,13 +317,7 @@ abstract class LibMoneroWallet ); } catch (e, s) { Logging.instance.f("getKeys failed: ", error: e, stackTrace: s); - return CWKeyData( - walletId: walletId, - publicViewKey: "ERROR", - privateViewKey: "ERROR", - publicSpendKey: "ERROR", - privateSpendKey: "ERROR", - ); + rethrow; } } @@ -1564,10 +1559,7 @@ abstract class LibMoneroWallet Future _recoverFromKeys(String keysDataJson) async { await refreshMutex.protect(() async { - final data = jsonDecode(keysDataJson) as Map; - final address = data["address"] as String; - final viewKey = data["viewKey"] as String; - final spendKey = data["spendKey"] as String; + final data = CryptonoteKeyRestoreData.fromJsonEncodedString(keysDataJson); try { final height = max(info.restoreHeight, 0); @@ -1589,9 +1581,9 @@ abstract class LibMoneroWallet final wallet = await getRestoredFromKeysWallet( path: path, password: password, - address: address, - privateViewKey: viewKey, - privateSpendKey: spendKey, + address: data.address, + privateViewKey: data.privateViewKey, + privateSpendKey: data.privateSpendKey, height: height, ); @@ -1602,23 +1594,6 @@ abstract class LibMoneroWallet _setListener(); - // Try to recover the mnemonic from the restored wallet - try { - final seed = await csMonero.getSeed(wallet); - if (seed.isNotEmpty) { - await secureStorageInterface.write( - key: Wallet.mnemonicKey(walletId: walletId), - value: seed, - ); - await secureStorageInterface.write( - key: Wallet.mnemonicPassphraseKey(walletId: walletId), - value: "", - ); - } - } catch (_) { - // Not all key-restored wallets can recover the seed - } - final newReceivingAddress = await getCurrentReceivingAddress() ?? Address( @@ -1645,6 +1620,10 @@ abstract class LibMoneroWallet await csMonero.startListeners(this.wallet!); csMonero.startAutoSaving(this.wallet!); + + await secureStorageInterface.delete( + key: Wallet.keysRestoreDataKey(walletId: walletId), + ); } catch (e, s) { Logging.instance.e( "Exception rethrown from _recoverFromKeys(): ", diff --git a/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart b/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart index 03e11f74c0..42cbc865b5 100644 --- a/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart @@ -273,9 +273,9 @@ abstract class LibSalviumWallet } @override - Future getKeys() async { + Future getKeys() async { if (wallet == null) { - return null; + throw StateError("Salvium wallet is not loaded"); } try { return CWKeyData( @@ -287,13 +287,7 @@ abstract class LibSalviumWallet ); } catch (e, s) { Logging.instance.f("getKeys failed: ", error: e, stackTrace: s); - return CWKeyData( - walletId: walletId, - publicViewKey: "ERROR", - privateViewKey: "ERROR", - publicSpendKey: "ERROR", - privateSpendKey: "ERROR", - ); + rethrow; } } diff --git a/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart b/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart index 5ebd2191a3..9d8e8cd2b0 100644 --- a/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart @@ -294,10 +294,10 @@ abstract class LibWowneroWallet } @override - Future getKeys() async { + Future getKeys() async { final oldInfo = getLibWowneroWalletInfo(walletId); if (wallet == null || (oldInfo != null && oldInfo.name != walletId)) { - return null; + throw StateError("Wownero wallet is not loaded"); } try { return CWKeyData( @@ -309,13 +309,7 @@ abstract class LibWowneroWallet ); } catch (e, s) { Logging.instance.f("getKeys failed: ", error: e, stackTrace: s); - return CWKeyData( - walletId: walletId, - publicViewKey: "ERROR", - privateViewKey: "ERROR", - publicSpendKey: "ERROR", - privateSpendKey: "ERROR", - ); + rethrow; } } diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 0850cd6f4f..a37ec18d33 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -8,6 +8,7 @@ import '../../db/isar/main_db.dart'; import '../../models/isar/models/blockchain_data/address.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../models/isar/models/solana/sol_contract.dart'; +import '../../models/keys/cryptonote_key_restore_data.dart'; import '../../models/keys/view_only_wallet_data.dart'; import '../../models/node_model.dart'; import '../../models/paymint/fee_object_model.dart'; @@ -153,7 +154,7 @@ abstract class Wallet { String? mnemonicPassphrase, String? privateKey, ViewOnlyWalletData? viewOnlyData, - String? keysRestoreData, + CryptonoteKeyRestoreData? cryptonoteKeyRestoreData, }) async { // TODO: rework soon? if (walletInfo.isViewOnly && viewOnlyData == null) { @@ -224,10 +225,10 @@ abstract class Wallet { ); } - if (keysRestoreData != null) { + if (cryptonoteKeyRestoreData != null) { await secureStorageInterface.write( key: keysRestoreDataKey(walletId: walletInfo.walletId), - value: keysRestoreData, + value: cryptonoteKeyRestoreData.toJsonEncodedString(), ); } @@ -333,6 +334,23 @@ abstract class Wallet { static String keysRestoreDataKey({required String walletId}) => "${walletId}_keysRestoreData"; + static List secureStorageKeys({required String walletId}) => [ + mnemonicKey(walletId: walletId), + mnemonicPassphraseKey(walletId: walletId), + privateKeyKey(walletId: walletId), + getViewOnlyWalletDataSecStoreKey(walletId: walletId), + keysRestoreDataKey(walletId: walletId), + ]; + + static Future deleteSecureStorageData({ + required String walletId, + required SecureStorageInterface secureStorage, + }) async { + for (final key in secureStorageKeys(walletId: walletId)) { + await secureStorage.delete(key: key); + } + } + //============================================================================ // ========== Private ======================================================== diff --git a/test/address_utils_test.dart b/test/address_utils_test.dart index c3ce3cbad0..aa1be7edaa 100644 --- a/test/address_utils_test.dart +++ b/test/address_utils_test.dart @@ -4,6 +4,10 @@ import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; void main() { const String firoAddress = "a6ESWKz7szru5syLtYAPRhHLdKvMq3Yt1j"; + const moneroAddress = + "4AeRgkWZsMJhAWKMeCZ3h4ZSPnAcW5VBtRFyLd6gBEf6GgJU2FHXDA6i1DnQTd6h8R3VU5AkbGcWSNhtSwNNPgaD48gp4nn"; + const privateKey = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; test("condense address", () { final condensedAddress = AddressUtils.condenseAddress(firoAddress); @@ -132,4 +136,129 @@ void main() { "firo:$firoAddress?amount=10.0123&message=Some+kind+of+message%21", ); }); + + group("wallet URI", () { + test("parses a private-key restore", () { + final result = WalletUriData.fromUriString( + "monero_wallet:$moneroAddress" + "?view_key=$privateKey&spend_key=$privateKey&height=123", + ); + + expect(result.address, moneroAddress); + expect(result.viewKey, privateKey); + expect(result.spendKey, privateKey); + expect(result.height, 123); + expect(result.isViewOnly, isFalse); + }); + + test("accepts the legacy mnemonic_seed parameter", () { + final result = WalletUriData.fromUriString( + "MONERO-WALLET:?mnemonic_seed=alpha%20beta", + ); + + expect(result.seed, "alpha beta"); + }); + + test("requires an address for key-based restores", () { + expect( + () => WalletUriData.fromUriString( + "monero_wallet:?view_key=$privateKey&spend_key=$privateKey", + ), + throwsFormatException, + ); + }); + + test("rejects a payment URI", () { + expect( + () => WalletUriData.fromUriString( + "monero:$moneroAddress?view_key=$privateKey", + ), + throwsFormatException, + ); + }); + + test("requires a view key with a spend key", () { + expect( + () => WalletUriData.fromUriString( + "monero_wallet:$moneroAddress?spend_key=$privateKey", + ), + throwsFormatException, + ); + }); + + test("rejects seed and private keys together", () { + expect( + () => WalletUriData.fromUriString( + "monero_wallet:$moneroAddress" + "?seed=alpha%20beta&view_key=$privateKey", + ), + throwsFormatException, + ); + }); + + test("uses the supplied address validator", () { + expect( + () => WalletUriData.fromUriString( + "monero_wallet:$moneroAddress?view_key=$privateKey", + addressValidator: (_) => false, + ), + throwsFormatException, + ); + }); + + test("rejects empty recovery material", () { + expect( + () => WalletUriData.fromUriString("monero_wallet:?seed="), + throwsFormatException, + ); + }); + + test("rejects malformed private keys", () { + expect( + () => WalletUriData.fromUriString( + "monero_wallet:$moneroAddress?view_key=not-a-key", + ), + throwsFormatException, + ); + }); + + test("rejects invalid restore heights", () { + for (final height in ["abc", "-1"]) { + expect( + () => WalletUriData.fromUriString( + "monero_wallet:?seed=alpha%20beta&height=$height", + ), + throwsFormatException, + ); + } + }); + + test("accepts numeric restore heights from JSON", () { + final result = WalletUriData.fromJson({ + "seed": "alpha beta", + "height": 123, + }, Monero(CryptoCurrencyNetwork.main)); + + expect(result.height, 123); + }); + + test("rejects transaction-ID restores until they are implemented", () { + expect( + () => WalletUriData.fromUriString( + "monero_wallet:?seed=alpha%20beta&txid=$privateKey", + ), + throwsUnsupportedError, + ); + }); + + test("does not expose secrets in diagnostics", () { + final result = WalletUriData.fromUriString( + "monero_wallet:$moneroAddress" + "?view_key=$privateKey&spend_key=$privateKey", + ); + + expect(result.toString(), isNot(contains(privateKey))); + expect(result.toString(), contains("redacted")); + }); + }); } diff --git a/test/models/keys/cryptonote_key_restore_data_test.dart b/test/models/keys/cryptonote_key_restore_data_test.dart new file mode 100644 index 0000000000..d70a06d4b5 --- /dev/null +++ b/test/models/keys/cryptonote_key_restore_data_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/keys/cryptonote_key_restore_data.dart'; + +void main() { + test("round trips through secure-storage encoding", () { + const data = CryptonoteKeyRestoreData( + address: "address", + privateViewKey: "view-key", + privateSpendKey: "spend-key", + ); + + final decoded = CryptonoteKeyRestoreData.fromJsonEncodedString( + data.toJsonEncodedString(), + ); + + expect(decoded.address, data.address); + expect(decoded.privateViewKey, data.privateViewKey); + expect(decoded.privateSpendKey, data.privateSpendKey); + }); + + test("does not expose keys in diagnostics", () { + const data = CryptonoteKeyRestoreData( + address: "address", + privateViewKey: "view-key", + privateSpendKey: "spend-key", + ); + + expect(data.toString(), isNot(contains("view-key"))); + expect(data.toString(), isNot(contains("spend-key"))); + }); +} diff --git a/test/models/keys/cw_key_data_test.dart b/test/models/keys/cw_key_data_test.dart index 4df1968979..18e60873b8 100644 --- a/test/models/keys/cw_key_data_test.dart +++ b/test/models/keys/cw_key_data_test.dart @@ -2,29 +2,24 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:stackwallet/models/keys/cw_key_data.dart'; void main() { - group("CWKeyData.hasError", () { - test("is false for complete key data", () { - final data = CWKeyData( - walletId: "wallet-id", - privateSpendKey: "private-spend", - privateViewKey: "private-view", - publicSpendKey: "public-spend", - publicViewKey: "public-view", - ); + test("stores complete key data in display order", () { + final data = CWKeyData( + walletId: "wallet-id", + privateSpendKey: "private-spend", + privateViewKey: "private-view", + publicSpendKey: "public-spend", + publicViewKey: "public-view", + ); - expect(data.hasError, isFalse); - }); - - test("is true when key retrieval failed", () { - final data = CWKeyData( - walletId: "wallet-id", - privateSpendKey: "ERROR", - privateViewKey: "ERROR", - publicSpendKey: "ERROR", - publicViewKey: "ERROR", - ); - - expect(data.hasError, isTrue); - }); + expect(data.keys, [ + (label: "Public View Key", key: "public-view"), + (label: "Private View Key", key: "private-view"), + (label: "Public Spend Key", key: "public-spend"), + (label: "Private Spend Key", key: "private-spend"), + ]); + expect( + () => data.keys.add((label: "key", key: "value")), + throwsUnsupportedError, + ); }); } diff --git a/test/models/keys/wallet_recovery_material_test.dart b/test/models/keys/wallet_recovery_material_test.dart new file mode 100644 index 0000000000..ad1ba4798b --- /dev/null +++ b/test/models/keys/wallet_recovery_material_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/keys/wallet_recovery_material.dart'; + +void main() { + test("rejects empty mnemonic material", () { + expect( + () => MnemonicWalletRecoveryMaterial(walletId: "wallet-id", words: []), + throwsArgumentError, + ); + }); + + test("defensively copies mnemonic words", () { + final words = ["one", "two"]; + final material = MnemonicWalletRecoveryMaterial( + walletId: "wallet-id", + words: words, + ); + + words.clear(); + + expect(material.words, ["one", "two"]); + expect(() => material.words.add("three"), throwsUnsupportedError); + }); +} diff --git a/test/pages/delete_wallet_recovery_phrase_view_test.dart b/test/pages/delete_wallet_recovery_phrase_view_test.dart index a8bd167ecd..78f1436d9f 100644 --- a/test/pages/delete_wallet_recovery_phrase_view_test.dart +++ b/test/pages/delete_wallet_recovery_phrase_view_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stackwallet/models/isar/stack_theme.dart'; import 'package:stackwallet/models/keys/cw_key_data.dart'; +import 'package:stackwallet/models/keys/wallet_recovery_material.dart'; import 'package:stackwallet/pages/add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart'; import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_backup_views/cn_wallet_keys.dart'; import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_backup_view.dart'; @@ -41,14 +42,15 @@ void main() { ); } - testWidgets("shows keys instead of an empty mnemonic", (tester) async { + testWidgets("shows private-key recovery material", (tester) async { Util.screenWidth = 400; await tester.pumpWidget( testApp( DeleteWalletRecoveryPhraseView( - walletId: walletId, - mnemonic: const [], - keyData: keyData, + recoveryMaterial: PrivateKeyWalletRecoveryMaterial( + walletId: walletId, + keyData: keyData, + ), ), ), ); @@ -58,25 +60,15 @@ void main() { expect(find.text("Wallet Keys"), findsOneWidget); }); - testWidgets("rejects missing recovery data", (tester) async { - await tester.pumpWidget( - testApp( - const DeleteWalletRecoveryPhraseView(walletId: walletId, mnemonic: []), - ), - ); - - expect(tester.takeException(), isA()); - expect(find.byType(MnemonicTable), findsNothing); - }); - testWidgets("shows keys directly in wallet backup", (tester) async { Util.screenWidth = 400; await tester.pumpWidget( testApp( WalletBackupView( - walletId: walletId, - mnemonic: const [], - keyData: keyData, + recoveryMaterial: PrivateKeyWalletRecoveryMaterial( + walletId: walletId, + keyData: keyData, + ), ), ), ); @@ -89,9 +81,10 @@ void main() { await tester.pumpWidget( testApp( DeleteWalletKeysPopup( - walletId: walletId, - words: const [], - keyData: keyData, + recoveryMaterial: PrivateKeyWalletRecoveryMaterial( + walletId: walletId, + keyData: keyData, + ), ), ), ); @@ -103,11 +96,38 @@ void main() { testWidgets("keeps mnemonic desktop deletion", (tester) async { await tester.pumpWidget( testApp( - const DeleteWalletKeysPopup(walletId: walletId, words: ["one", "two"]), + DeleteWalletKeysPopup( + recoveryMaterial: MnemonicWalletRecoveryMaterial( + walletId: walletId, + words: const ["one", "two"], + ), + ), ), ); expect(find.byType(MnemonicTable), findsOneWidget); expect(find.byType(CNWalletKeys), findsNothing); }); + + testWidgets("shows FROST data in desktop deletion", (tester) async { + await tester.pumpWidget( + testApp( + const DeleteWalletKeysPopup( + recoveryMaterial: FrostWalletRecoveryMaterial( + walletId: walletId, + data: ( + myName: "name", + config: "config", + keys: "keys", + prevGen: null, + ), + ), + ), + ), + ); + + expect(find.text("config"), findsOneWidget); + expect(find.text("keys"), findsOneWidget); + expect(find.byType(MnemonicTable), findsNothing); + }); } diff --git a/test/pages/restore_options_uri_test.dart b/test/pages/restore_options_uri_test.dart new file mode 100644 index 0000000000..3655b64132 --- /dev/null +++ b/test/pages/restore_options_uri_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart'; +import 'package:stackwallet/pages/add_wallet_views/restore_wallet_view/restore_options_view/sub_widgets/restore_options_next_button.dart'; +import 'package:stackwallet/pages/add_wallet_views/restore_wallet_view/sub_widgets/restoring_dialog.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/address_utils.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/widgets/options.dart'; + +import '../sample_data/theme_json.dart'; + +void main() { + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + final coin = Monero(CryptoCurrencyNetwork.main); + + Widget testApp(Widget child) => ProviderScope( + overrides: [themeProvider.overrideWithValue(StateController(theme))], + child: MaterialApp( + theme: ThemeData(extensions: [StackColors.fromStackColorTheme(theme)]), + home: Scaffold(body: child), + ), + ); + + setUp(() { + Util.screenWidth = 400; + }); + + tearDown(() { + Util.screenWidth = null; + }); + + testWidgets("clears parsed URI state after changing restore modes", ( + tester, + ) async { + await tester.pumpWidget( + testApp(RestoreOptionsView(walletName: "wallet", coin: coin)), + ); + + Future selectOption(double horizontalFraction) async { + final rect = tester.getRect(find.byType(Options)); + await tester.tapAt( + Offset(rect.left + rect.width * horizontalFraction, rect.center.dy), + ); + await tester.pumpAndSettle(); + } + + await selectOption(5 / 6); + await tester.enterText( + find.byType(TextField).first, + "monero_wallet:?seed=alpha%20beta", + ); + await tester.pump(); + + expect( + tester + .widget( + find.byType(RestoreOptionsNextButton), + ) + .onPressed, + isNotNull, + ); + + await selectOption(1 / 6); + await selectOption(5 / 6); + + expect( + tester.widget(find.byType(TextField).first).controller!.text, + isEmpty, + ); + expect( + tester + .widget( + find.byType(RestoreOptionsNextButton), + ) + .onPressed, + isNull, + ); + }); + + testWidgets("shows URI validation errors", (tester) async { + final dateController = TextEditingController(); + final blockController = TextEditingController(); + final blockFocusNode = FocusNode(); + addTearDown(dateController.dispose); + addTearDown(blockController.dispose); + addTearDown(blockFocusNode.dispose); + + WalletUriData? parsed; + await tester.pumpWidget( + testApp( + UriRestoreOption( + coin: coin, + dateController: dateController, + dateChooserFunction: () async {}, + blockHeightController: blockController, + blockHeightFocusNode: blockFocusNode, + onParsed: (value) => parsed = value, + ), + ), + ); + + await tester.enterText( + find.byType(TextField).first, + "monero_wallet:?seed=alpha%20beta&height=-1", + ); + await tester.pump(); + + expect(parsed, isNull); + expect(find.text("Invalid restore height."), findsOneWidget); + + await tester.enterText(find.byType(TextField).first, ""); + await tester.pump(); + + expect(find.text("Invalid restore height."), findsNothing); + }); + + testWidgets("key restore progress cannot be cancelled", (tester) async { + await tester.pumpWidget(testApp(const RestoringDialog())); + + expect(find.text("Restoring wallet"), findsOneWidget); + expect(find.text("Cancel"), findsNothing); + }); +} diff --git a/test/wallets/isar/models/wallet_info_test.dart b/test/wallets/isar/models/wallet_info_test.dart index 56cd3b1d5f..04deb6c5e7 100644 --- a/test/wallets/isar/models/wallet_info_test.dart +++ b/test/wallets/isar/models/wallet_info_test.dart @@ -5,17 +5,30 @@ import 'package:stackwallet/app_config.dart'; import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; void main() { - group("WalletInfo.isRestoredFromKeys", () { - test("defaults to false", () { + group("WalletInfo.recoveryType", () { + test("defaults to mnemonic", () { final info = WalletInfo.createNew( coin: AppConfig.coins.first, name: "wallet", ); - expect(info.isRestoredFromKeys, isFalse); + expect(info.recoveryType, WalletRecoveryType.mnemonic); }); test("reads the persisted recovery type", () { + final info = WalletInfo.createNew( + coin: AppConfig.coins.first, + name: "wallet", + otherDataJsonString: jsonEncode({ + WalletInfoKeys.recoveryTypeIndexKey: + WalletRecoveryType.privateKeys.index, + }), + ); + + expect(info.recoveryType, WalletRecoveryType.privateKeys); + }); + + test("migrates the former private-key flag", () { final info = WalletInfo.createNew( coin: AppConfig.coins.first, name: "wallet", @@ -24,7 +37,7 @@ void main() { }), ); - expect(info.isRestoredFromKeys, isTrue); + expect(info.recoveryType, WalletRecoveryType.privateKeys); }); }); } diff --git a/test/wallets/wallet/wallet_secure_storage_test.dart b/test/wallets/wallet/wallet_secure_storage_test.dart new file mode 100644 index 0000000000..aff10c8d7c --- /dev/null +++ b/test/wallets/wallet/wallet_secure_storage_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; + +void main() { + test("deletes all wallet-owned recovery material", () async { + const walletId = "wallet-id"; + final storage = FakeSecureStorage(); + final keys = Wallet.secureStorageKeys(walletId: walletId); + + for (final key in keys) { + await storage.write(key: key, value: "secret"); + } + + await Wallet.deleteSecureStorageData( + walletId: walletId, + secureStorage: storage, + ); + + expect(await storage.keys, isEmpty); + expect(keys, contains(Wallet.keysRestoreDataKey(walletId: walletId))); + }); +} From abffcddef884d2eb3966230f87606658cec76b4b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Aug 2026 11:01:43 -0500 Subject: [PATCH 13/19] build: update devicelocale revision --- pubspec.lock | 6 +++--- scripts/app_config/templates/pubspec.template.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index e85d3e8109..8fac7f9737 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -801,11 +801,11 @@ packages: dependency: "direct main" description: path: "." - ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce - resolved-ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce + ref: "73c4ed946816c5ec032d13a44f8f510e2ced9886" + resolved-ref: "73c4ed946816c5ec032d13a44f8f510e2ced9886" url: "https://github.com/cypherstack/flutter-devicelocale" source: git - version: "0.8.1" + version: "0.9.0" digest_auth: dependency: "direct main" description: diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 61556b0174..5acace6286 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -173,7 +173,7 @@ dependencies: devicelocale: git: url: https://github.com/cypherstack/flutter-devicelocale - ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce + ref: 73c4ed946816c5ec032d13a44f8f510e2ced9886 device_info_plus: ^10.1.2 keyboard_dismisser: ^3.0.0 another_flushbar: ^1.10.28 From c58d12a9a55a0023d34a90ea9c5983485d1e6eaa Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Aug 2026 13:50:47 -0500 Subject: [PATCH 14/19] fix(backup): preserve Cryptonote key recovery --- .../keys/cryptonote_key_restore_data.dart | 28 ++++++++-- lib/models/keys/cw_key_data.dart | 13 +++-- .../keys/wallet_backup_recovery_data.dart | 28 ++++++++++ lib/models/keys/wallet_recovery_material.dart | 3 ++ .../helpers/restore_create_backup.dart | 28 ++++++++++ lib/services/wallet_recovery_service.dart | 36 ++++++++++++- .../intermediate/lib_monero_wallet.dart | 4 -- .../cryptonote_key_restore_data_test.dart | 45 ++++++++++++++++ test/models/keys/cw_key_data_test.dart | 4 ++ .../wallet_backup_recovery_data_test.dart | 51 +++++++++++++++++++ 10 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 lib/models/keys/wallet_backup_recovery_data.dart create mode 100644 test/models/keys/wallet_backup_recovery_data_test.dart diff --git a/lib/models/keys/cryptonote_key_restore_data.dart b/lib/models/keys/cryptonote_key_restore_data.dart index c71d62dbb0..63fc2bcc53 100644 --- a/lib/models/keys/cryptonote_key_restore_data.dart +++ b/lib/models/keys/cryptonote_key_restore_data.dart @@ -1,6 +1,8 @@ import 'dart:convert'; class CryptonoteKeyRestoreData { + static const int currentVersion = 1; + const CryptonoteKeyRestoreData({ required this.address, required this.privateViewKey, @@ -17,14 +19,34 @@ class CryptonoteKeyRestoreData { throw const FormatException("Invalid Cryptonote key restore data"); } + final version = json["version"]; + if (version != null && version != currentVersion) { + throw const FormatException( + "Unsupported Cryptonote key restore data version", + ); + } + + final address = json["address"]; + final privateViewKey = json["privateViewKey"]; + final privateSpendKey = json["privateSpendKey"]; + if (address is! String || + address.isEmpty || + privateViewKey is! String || + privateViewKey.isEmpty || + privateSpendKey is! String || + privateSpendKey.isEmpty) { + throw const FormatException("Invalid Cryptonote key restore data"); + } + return CryptonoteKeyRestoreData( - address: json["address"] as String, - privateViewKey: json["privateViewKey"] as String, - privateSpendKey: json["privateSpendKey"] as String, + address: address, + privateViewKey: privateViewKey, + privateSpendKey: privateSpendKey, ); } String toJsonEncodedString() => jsonEncode({ + "version": currentVersion, "address": address, "privateViewKey": privateViewKey, "privateSpendKey": privateSpendKey, diff --git a/lib/models/keys/cw_key_data.dart b/lib/models/keys/cw_key_data.dart index 8d8b1e0323..0b12293843 100644 --- a/lib/models/keys/cw_key_data.dart +++ b/lib/models/keys/cw_key_data.dart @@ -3,10 +3,10 @@ import 'key_data_interface.dart'; class CWKeyData with KeyDataInterface { CWKeyData({ required this.walletId, - required String privateSpendKey, - required String privateViewKey, - required String publicSpendKey, - required String publicViewKey, + required this.privateSpendKey, + required this.privateViewKey, + required this.publicSpendKey, + required this.publicViewKey, }) : keys = List.unmodifiable([ (label: "Public View Key", key: publicViewKey), (label: "Private View Key", key: privateViewKey), @@ -17,5 +17,10 @@ class CWKeyData with KeyDataInterface { @override final String walletId; + final String privateSpendKey; + final String privateViewKey; + final String publicSpendKey; + final String publicViewKey; + final List<({String label, String key})> keys; } diff --git a/lib/models/keys/wallet_backup_recovery_data.dart b/lib/models/keys/wallet_backup_recovery_data.dart new file mode 100644 index 0000000000..5cf29b3b9d --- /dev/null +++ b/lib/models/keys/wallet_backup_recovery_data.dart @@ -0,0 +1,28 @@ +import 'cryptonote_key_restore_data.dart'; + +const cryptonoteKeyRestoreDataBackupKey = "cryptonoteKeyRestoreData"; + +void writeCryptonoteKeyRestoreDataToBackup( + Map walletBackup, + CryptonoteKeyRestoreData data, +) { + walletBackup[cryptonoteKeyRestoreDataBackupKey] = data.toJsonEncodedString(); +} + +CryptonoteKeyRestoreData? readCryptonoteKeyRestoreDataFromBackup( + Map walletBackup, +) { + final encoded = walletBackup[cryptonoteKeyRestoreDataBackupKey]; + if (encoded == null) { + return null; + } + if (encoded is! String) { + throw const FormatException("Invalid Cryptonote backup recovery data"); + } + if (walletBackup["mnemonic"] != null || + walletBackup["privateKey"] != null || + walletBackup["viewOnlyWalletDataKey"] != null) { + throw const FormatException("Conflicting wallet backup recovery data"); + } + return CryptonoteKeyRestoreData.fromJsonEncodedString(encoded); +} diff --git a/lib/models/keys/wallet_recovery_material.dart b/lib/models/keys/wallet_recovery_material.dart index 6335b2ed45..190d2b9cec 100644 --- a/lib/models/keys/wallet_recovery_material.dart +++ b/lib/models/keys/wallet_recovery_material.dart @@ -1,3 +1,4 @@ +import 'cryptonote_key_restore_data.dart'; import 'key_data_interface.dart'; import 'view_only_wallet_data.dart'; @@ -33,9 +34,11 @@ final class PrivateKeyWalletRecoveryMaterial extends WalletRecoveryMaterial { const PrivateKeyWalletRecoveryMaterial({ required super.walletId, required this.keyData, + this.cryptonoteKeyRestoreData, }); final KeyDataInterface keyData; + final CryptonoteKeyRestoreData? cryptonoteKeyRestoreData; } final class ViewOnlyWalletRecoveryMaterial extends WalletRecoveryMaterial { 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 cb4ec42a6a..ca4e8aeabf 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 @@ -29,6 +29,7 @@ import '../../../../../models/exchange/response_objects/trade.dart'; import '../../../../../models/isar/models/contact_entry.dart'; import '../../../../../models/isar/models/transaction_note.dart'; import '../../../../../models/keys/view_only_wallet_data.dart'; +import '../../../../../models/keys/wallet_backup_recovery_data.dart'; import '../../../../../models/node_model.dart'; import '../../../../../models/stack_restoring_ui_state.dart'; import '../../../../../models/trade_wallet_lookup.dart'; @@ -42,6 +43,7 @@ import '../../../../../services/trade_notes_service.dart'; import '../../../../../services/trade_sent_from_stack_service.dart'; import '../../../../../services/trade_service.dart'; import '../../../../../services/wallets.dart'; +import '../../../../../services/wallet_recovery_service.dart'; import '../../../../../utilities/enums/backup_frequency_type.dart'; import '../../../../../utilities/enums/stack_restoring_status.dart'; import '../../../../../utilities/enums/sync_type_enum.dart'; @@ -50,6 +52,7 @@ import '../../../../../utilities/format.dart'; import '../../../../../utilities/logger.dart'; import '../../../../../utilities/prefs.dart'; import '../../../../../utilities/util.dart'; +import '../../../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; import '../../../../../wallets/crypto_currency/intermediate/frost_currency.dart'; import '../../../../../wallets/isar/models/frost_wallet_info.dart'; import '../../../../../wallets/isar/models/wallet_info.dart'; @@ -299,6 +302,16 @@ abstract class SWB { if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { backupWallet['viewOnlyWalletDataKey'] = (await wallet.getViewOnlyWalletData()).toJsonEncodedString(); + } else if (wallet.info.recoveryType == WalletRecoveryType.privateKeys) { + if (wallet is! CryptonoteWallet) { + throw UnsupportedError( + "Unsupported private-key wallet: ${wallet.runtimeType}", + ); + } + writeCryptonoteKeyRestoreDataToBackup( + backupWallet, + await WalletRecoveryService.getCryptonoteKeyRestoreData(wallet), + ); } else if (wallet is MnemonicInterface) { backupWallet['mnemonic'] = await wallet.getMnemonic(); backupWallet['mnemonicPassphrase'] = await wallet @@ -397,6 +410,14 @@ abstract class SWB { final walletbackup = tuple.item1; String? mnemonic, mnemonicPassphrase, privateKey; + final cryptonoteKeyRestoreData = readCryptonoteKeyRestoreDataFromBackup( + Map.from(walletbackup as Map), + ); + if (cryptonoteKeyRestoreData != null && info.coin is! CryptonoteCurrency) { + throw const FormatException( + "Cryptonote recovery data belongs to a non-Cryptonote wallet", + ); + } ViewOnlyWalletData? viewOnlyData; if (info.isViewOnly) { @@ -476,6 +497,7 @@ abstract class SWB { mnemonicPassphrase: mnemonicPassphrase, privateKey: privateKey, viewOnlyData: viewOnlyData, + cryptonoteKeyRestoreData: cryptonoteKeyRestoreData, ); switch (wallet) { @@ -810,6 +832,12 @@ abstract class SWB { ); } + if (walletbackup[cryptonoteKeyRestoreDataBackupKey] != null) { + otherData ??= {}; + otherData[WalletInfoKeys.recoveryTypeIndexKey] = + WalletRecoveryType.privateKeys.index; + } + final info = WalletInfo( coinName: coin.identifier, walletId: walletId, diff --git a/lib/services/wallet_recovery_service.dart b/lib/services/wallet_recovery_service.dart index a5745676f9..a74362724f 100644 --- a/lib/services/wallet_recovery_service.dart +++ b/lib/services/wallet_recovery_service.dart @@ -1,3 +1,5 @@ +import '../models/keys/cryptonote_key_restore_data.dart'; +import '../models/keys/cw_key_data.dart'; import '../models/keys/key_data_interface.dart'; import '../models/keys/wallet_recovery_material.dart'; import '../wallets/isar/models/wallet_info.dart'; @@ -52,9 +54,14 @@ class WalletRecoveryService { "Unsupported private-key wallet: ${wallet.runtimeType}", ); } + final keyData = await wallet.getKeys(); return PrivateKeyWalletRecoveryMaterial( walletId: wallet.walletId, - keyData: await wallet.getKeys(), + keyData: keyData, + cryptonoteKeyRestoreData: await getCryptonoteKeyRestoreData( + wallet, + keyData: keyData, + ), ); } @@ -84,4 +91,31 @@ class WalletRecoveryService { supplementalKeyData: supplementalKeyData, ); } + + static Future getCryptonoteKeyRestoreData( + CryptonoteWallet wallet, { + CWKeyData? keyData, + }) async { + final storageKey = Wallet.keysRestoreDataKey(walletId: wallet.walletId); + final stored = await wallet.secureStorageInterface.read(key: storageKey); + if (stored != null) { + return CryptonoteKeyRestoreData.fromJsonEncodedString(stored); + } + + final keys = keyData ?? await wallet.getKeys(); + + final data = CryptonoteKeyRestoreData( + address: await wallet.internalGetAddress( + accountIndex: 0, + addressIndex: 0, + ), + privateViewKey: keys.privateViewKey, + privateSpendKey: keys.privateSpendKey, + ); + await wallet.secureStorageInterface.write( + key: storageKey, + value: data.toJsonEncodedString(), + ); + return data; + } } diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index a85f766f85..f0f452739e 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -1620,10 +1620,6 @@ abstract class LibMoneroWallet await csMonero.startListeners(this.wallet!); csMonero.startAutoSaving(this.wallet!); - - await secureStorageInterface.delete( - key: Wallet.keysRestoreDataKey(walletId: walletId), - ); } catch (e, s) { Logging.instance.e( "Exception rethrown from _recoverFromKeys(): ", diff --git a/test/models/keys/cryptonote_key_restore_data_test.dart b/test/models/keys/cryptonote_key_restore_data_test.dart index d70a06d4b5..6539aaa446 100644 --- a/test/models/keys/cryptonote_key_restore_data_test.dart +++ b/test/models/keys/cryptonote_key_restore_data_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:stackwallet/models/keys/cryptonote_key_restore_data.dart'; @@ -13,11 +15,54 @@ void main() { data.toJsonEncodedString(), ); + expect( + jsonDecode(data.toJsonEncodedString()), + containsPair("version", CryptonoteKeyRestoreData.currentVersion), + ); expect(decoded.address, data.address); expect(decoded.privateViewKey, data.privateViewKey); expect(decoded.privateSpendKey, data.privateSpendKey); }); + test("reads legacy unversioned data", () { + final decoded = CryptonoteKeyRestoreData.fromJsonEncodedString( + jsonEncode({ + "address": "address", + "privateViewKey": "view-key", + "privateSpendKey": "spend-key", + }), + ); + + expect(decoded.address, "address"); + expect(decoded.privateViewKey, "view-key"); + expect(decoded.privateSpendKey, "spend-key"); + }); + + test("rejects unsupported versions and incomplete data", () { + expect( + () => CryptonoteKeyRestoreData.fromJsonEncodedString( + jsonEncode({ + "version": CryptonoteKeyRestoreData.currentVersion + 1, + "address": "address", + "privateViewKey": "view-key", + "privateSpendKey": "spend-key", + }), + ), + throwsFormatException, + ); + expect( + () => CryptonoteKeyRestoreData.fromJsonEncodedString( + jsonEncode({ + "version": CryptonoteKeyRestoreData.currentVersion, + "address": "address", + "privateViewKey": "", + "privateSpendKey": "spend-key", + }), + ), + throwsFormatException, + ); + }); + test("does not expose keys in diagnostics", () { const data = CryptonoteKeyRestoreData( address: "address", diff --git a/test/models/keys/cw_key_data_test.dart b/test/models/keys/cw_key_data_test.dart index 18e60873b8..83a90a52fc 100644 --- a/test/models/keys/cw_key_data_test.dart +++ b/test/models/keys/cw_key_data_test.dart @@ -17,6 +17,10 @@ void main() { (label: "Public Spend Key", key: "public-spend"), (label: "Private Spend Key", key: "private-spend"), ]); + expect(data.privateSpendKey, "private-spend"); + expect(data.privateViewKey, "private-view"); + expect(data.publicSpendKey, "public-spend"); + expect(data.publicViewKey, "public-view"); expect( () => data.keys.add((label: "key", key: "value")), throwsUnsupportedError, diff --git a/test/models/keys/wallet_backup_recovery_data_test.dart b/test/models/keys/wallet_backup_recovery_data_test.dart new file mode 100644 index 0000000000..bd02a4f103 --- /dev/null +++ b/test/models/keys/wallet_backup_recovery_data_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/keys/cryptonote_key_restore_data.dart'; +import 'package:stackwallet/models/keys/wallet_backup_recovery_data.dart'; + +void main() { + test("round trips Cryptonote key material through a wallet backup", () { + const data = CryptonoteKeyRestoreData( + address: "address", + privateViewKey: "view-key", + privateSpendKey: "spend-key", + ); + final backup = {}; + + writeCryptonoteKeyRestoreDataToBackup(backup, data); + final restored = readCryptonoteKeyRestoreDataFromBackup(backup)!; + + expect(restored.address, data.address); + expect(restored.privateViewKey, data.privateViewKey); + expect(restored.privateSpendKey, data.privateSpendKey); + }); + + test("accepts backups without Cryptonote key material", () { + expect(readCryptonoteKeyRestoreDataFromBackup({}), isNull); + }); + + test("rejects malformed Cryptonote backup material", () { + expect( + () => readCryptonoteKeyRestoreDataFromBackup({ + cryptonoteKeyRestoreDataBackupKey: {}, + }), + throwsFormatException, + ); + }); + + test("rejects conflicting recovery material", () { + final backup = {"mnemonic": "seed words"}; + writeCryptonoteKeyRestoreDataToBackup( + backup, + const CryptonoteKeyRestoreData( + address: "address", + privateViewKey: "view-key", + privateSpendKey: "spend-key", + ), + ); + + expect( + () => readCryptonoteKeyRestoreDataFromBackup(backup), + throwsFormatException, + ); + }); +} From f5c55bd77201a6ac3758c7ea716159e28f350a6c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Aug 2026 13:50:52 -0500 Subject: [PATCH 15/19] chore: drop unrelated devicelocale update --- pubspec.lock | 6 +++--- scripts/app_config/templates/pubspec.template.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 8fac7f9737..e85d3e8109 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -801,11 +801,11 @@ packages: dependency: "direct main" description: path: "." - ref: "73c4ed946816c5ec032d13a44f8f510e2ced9886" - resolved-ref: "73c4ed946816c5ec032d13a44f8f510e2ced9886" + ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce + resolved-ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce url: "https://github.com/cypherstack/flutter-devicelocale" source: git - version: "0.9.0" + version: "0.8.1" digest_auth: dependency: "direct main" description: diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 5acace6286..61556b0174 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -173,7 +173,7 @@ dependencies: devicelocale: git: url: https://github.com/cypherstack/flutter-devicelocale - ref: 73c4ed946816c5ec032d13a44f8f510e2ced9886 + ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce device_info_plus: ^10.1.2 keyboard_dismisser: ^3.0.0 another_flushbar: ^1.10.28 From 428b2657cfaac9bdbefaf43868645be02914f965 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 25 Aug 2026 12:23:12 -0500 Subject: [PATCH 16/19] refactor: extract the restore start height picker into a shared widget The date/block-height control was duplicated three times in the restore options view, once per restore mode, over a single set of controllers. StartHeightPicker replaces all three with one widget and gives each mode its own StartHeightPickerController, so a height chosen in one mode can no longer follow the user into a mode that shows no control. The controller reports a nullable height: null means nothing was chosen, which is distinct from a deliberate height of 0. Salvium now converts the date the user picked rather than one week ago, and Mimblewimblecoin gets the same date to height estimate Epic Cash already had. restore_from_date_picker.dart moves to lib/widgets so the shared widget does not have to import from lib/pages. --- .../restore_options_view.dart | 547 ++---------------- .../restore_from_date_picker.dart | 22 +- lib/widgets/start_height_picker.dart | 292 ++++++++++ test/pages/restore_options_uri_test.dart | 84 ++- test/widgets/start_height_picker_test.dart | 139 +++++ 5 files changed, 568 insertions(+), 516 deletions(-) rename lib/{pages/add_wallet_views/restore_wallet_view/restore_options_view/sub_widgets => widgets/date_picker}/restore_from_date_picker.dart (81%) create mode 100644 lib/widgets/start_height_picker.dart create mode 100644 test/widgets/start_height_picker_test.dart diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart index 4b344fbb96..326de97a95 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart @@ -14,10 +14,8 @@ import 'dart:io'; import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:logger/logger.dart'; import 'package:tuple/tuple.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -32,7 +30,6 @@ import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; import '../../../../utilities/assets.dart'; import '../../../../utilities/constants.dart'; -import '../../../../utilities/format.dart'; import '../../../../utilities/logger.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; @@ -44,20 +41,16 @@ import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/wallet.dart'; import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; -import '../../../../widgets/custom_buttons/blue_text_button.dart'; -import '../../../../widgets/date_picker/date_picker.dart'; import '../../../../widgets/desktop/desktop_app_bar.dart'; import '../../../../widgets/desktop/desktop_scaffold.dart'; import '../../../../widgets/expandable.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; import '../../../../widgets/options.dart'; import '../../../../widgets/rounded_white_container.dart'; +import '../../../../widgets/start_height_picker.dart'; import '../../../../widgets/stack_text_field.dart'; import '../../../../widgets/textfield_icon_button.dart'; import '../../../../widgets/toggle.dart'; -import '../../../../wl_gen/interfaces/cs_monero_interface.dart'; -import '../../../../wl_gen/interfaces/cs_salvium_interface.dart'; -import '../../../../wl_gen/interfaces/cs_wownero_interface.dart'; import '../../../home_view/home_view.dart'; import '../../create_or_restore_wallet_view/sub_widgets/coin_image.dart'; import '../confirm_recovery_dialog.dart'; @@ -68,12 +61,9 @@ import '../sub_widgets/restore_failed_dialog.dart'; import '../sub_widgets/restore_succeeded_dialog.dart'; import '../sub_widgets/restoring_dialog.dart'; import 'sub_widgets/mobile_mnemonic_length_selector.dart'; -import 'sub_widgets/restore_from_date_picker.dart'; import 'sub_widgets/restore_options_next_button.dart'; import 'sub_widgets/restore_options_platform_layout.dart'; -final _pIsUsingDate = StateProvider.autoDispose((_) => true); - class RestoreOptionsView extends ConsumerStatefulWidget { const RestoreOptionsView({ super.key, @@ -95,18 +85,22 @@ class _RestoreOptionsViewState extends ConsumerState { late final CryptoCurrency coin; late final bool isDesktop; - late TextEditingController _dateController; - late TextEditingController _blockHeightController; - late FocusNode _blockHeightFocusNode; late FocusNode textFieldFocusNode; late final FocusNode passwordFocusNode; late final TextEditingController passwordController; - bool _hasBlockHeight = false; - DateTime? _restoreFromDate; + /// One controller per restore mode. A single shared controller would carry a + /// height into a mode whose panel shows no picker. + final _heightControllers = { + for (final mode in [0, 1, 2]) mode: StartHeightPickerController(), + }; + bool hidePassword = true; WalletUriData? _uriData; + StartHeightPickerController get _heightController => + _heightControllers[_restoreMode]!; + @override void initState() { super.initState(); @@ -114,30 +108,16 @@ class _RestoreOptionsViewState extends ConsumerState { coin = widget.coin; isDesktop = Util.isDesktop; - _dateController = TextEditingController(); textFieldFocusNode = FocusNode(); passwordController = TextEditingController(); passwordFocusNode = FocusNode(); - _blockHeightController = TextEditingController(); - _blockHeightFocusNode = FocusNode(); - - _blockHeightController.addListener(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - if (!ref.read(_pIsUsingDate)) { - setState(() { - _hasBlockHeight = _blockHeightController.text.isNotEmpty; - }); - } - } - }); - }); } @override void dispose() { - _dateController.dispose(); - _blockHeightController.dispose(); + for (final controller in _heightControllers.values) { + controller.dispose(); + } textFieldFocusNode.dispose(); passwordController.dispose(); passwordFocusNode.dispose(); @@ -158,12 +138,7 @@ class _RestoreOptionsViewState extends ConsumerState { } if (mounted) { - int height = 0; - if (ref.read(_pIsUsingDate)) { - height = getBlockHeightFromDate(_restoreFromDate); - } else { - height = int.tryParse(_blockHeightController.text) ?? 0; - } + final height = _heightController.height ?? 0; switch (_restoreMode) { case 0: // Seed await Navigator.of(context).pushNamed( @@ -197,30 +172,6 @@ class _RestoreOptionsViewState extends ConsumerState { } } - Future chooseDate() async { - // check and hide keyboard - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed(const Duration(milliseconds: 125)); - } - - if (mounted) { - final date = (await showSWDatePicker(context))?.first; - if (date != null) { - _restoreFromDate = date; - _dateController.text = Format.formatDate(date); - } - } - } - - Future chooseDesktopDate() async { - final date = (await showSWDatePicker(context))?.first; - if (date != null) { - _restoreFromDate = date; - _dateController.text = Format.formatDate(date); - } - } - Future chooseMnemonicLength() async { await showModalBottomSheet( backgroundColor: Colors.transparent, @@ -236,51 +187,6 @@ class _RestoreOptionsViewState extends ConsumerState { ); } - int getBlockHeightFromDate(DateTime? date) { - try { - int height = 0; - if (date != null) { - if (widget.coin is Monero) { - height = csMonero.getHeightByDate(date); - } - if (widget.coin is Wownero) { - height = csWownero.getHeightByDate(date); - } - if (widget.coin is Salvium) { - height = csSalvium.getHeightByDate( - DateTime.now().subtract(const Duration(days: 7)), - ); - } - if (height < 0) { - height = 0; - } - - if (widget.coin is Epiccash) { - final int secondsSinceEpoch = date.millisecondsSinceEpoch ~/ 1000; - const int epicCashFirstBlock = 1565370278; - const double overestimateSecondsPerBlock = 61; - final int chosenSeconds = secondsSinceEpoch - epicCashFirstBlock; - final int approximateHeight = - chosenSeconds ~/ overestimateSecondsPerBlock; - - height = approximateHeight; - if (height < 0) { - height = 0; - } - } - } else { - height = 0; - } - return height; - } catch (e) { - Logging.instance.log( - Level.info, - "Error getting block height from date: $e", - ); - return 0; - } - } - Future _attemptUriRestore(int fallbackHeight) async { final data = _uriData; if (data == null) return; @@ -474,6 +380,14 @@ class _RestoreOptionsViewState extends ConsumerState { // 0 = Seed, 1 = View Only, 2 = URI (Monero only) int _restoreMode = 0; + bool get _canProceed { + if (_restoreMode == 2 && _uriData == null) { + return false; + } + // Date mode is permissive: no date chosen means scan from the start. + return _heightController.isUsingDate || _heightController.height != null; + } + @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType with ${coin.identifier} $walletName"); @@ -579,46 +493,30 @@ class _RestoreOptionsViewState extends ConsumerState { if (_restoreMode == 1) ViewOnlyRestoreOption( coin: coin, - dateController: _dateController, - dateChooserFunction: isDesktop - ? chooseDesktopDate - : chooseDate, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, + heightController: _heightController, ) else if (_restoreMode == 2) UriRestoreOption( coin: coin, - dateController: _dateController, - dateChooserFunction: isDesktop - ? chooseDesktopDate - : chooseDate, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, + heightController: _heightController, onParsed: (data) => setState(() => _uriData = data), ) else SeedRestoreOption( coin: coin, - dateController: _dateController, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, + heightController: _heightController, pwController: passwordController, pwFocusNode: passwordFocusNode, - dateChooserFunction: isDesktop - ? chooseDesktopDate - : chooseDate, chooseMnemonicLength: chooseMnemonicLength, ), if (!isDesktop) const Spacer(flex: 3), SizedBox(height: isDesktop ? 32 : 12), - RestoreOptionsNextButton( - isDesktop: isDesktop, - onPressed: _restoreMode == 2 - ? (_uriData != null ? nextPressed : null) - : ref.watch(_pIsUsingDate) || _hasBlockHeight - ? nextPressed - : null, + ListenableBuilder( + listenable: _heightController, + builder: (context, _) => RestoreOptionsNextButton( + isDesktop: isDesktop, + onPressed: _canProceed ? nextPressed : null, + ), ), if (isDesktop) const Spacer(flex: 15), ], @@ -633,23 +531,17 @@ class SeedRestoreOption extends ConsumerStatefulWidget { const SeedRestoreOption({ super.key, required this.coin, - required this.dateController, - required this.blockHeightController, - required this.blockHeightFocusNode, + required this.heightController, required this.pwController, required this.pwFocusNode, - required this.dateChooserFunction, required this.chooseMnemonicLength, }); final CryptoCurrency coin; - final TextEditingController dateController; - final TextEditingController blockHeightController; - final FocusNode blockHeightFocusNode; + final StartHeightPickerController heightController; final TextEditingController pwController; final FocusNode pwFocusNode; - final Future Function() dateChooserFunction; final Future Function() chooseMnemonicLength; @override @@ -659,7 +551,6 @@ class SeedRestoreOption extends ConsumerStatefulWidget { class _SeedRestoreOptionState extends ConsumerState { bool _hidePassword = true; bool _expandedAdvanced = false; - bool _blockFieldEmpty = true; @override Widget build(BuildContext context) { @@ -684,120 +575,13 @@ class _SeedRestoreOptionState extends ConsumerState { children: [ if (isCnAnd25 || widget.coin is Epiccash || - widget.coin is Mimblewimblecoin) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - ref.watch(_pIsUsingDate) ? "Choose start date" : "Block height", - style: Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, - ) - : STextStyles.smallMed12(context), - textAlign: TextAlign.left, - ), - CustomTextButton( - text: ref.watch(_pIsUsingDate) - ? "Use block height" - : "Use date", - onTap: () => ref.read(_pIsUsingDate.notifier).state = !ref.read( - _pIsUsingDate, - ), - ), - ], - ), - if (isCnAnd25 || - widget.coin is Epiccash || - widget.coin is Mimblewimblecoin) - SizedBox(height: Util.isDesktop ? 16 : 8), - if (isCnAnd25 || - widget.coin is Epiccash || - widget.coin is Mimblewimblecoin) - ref.watch(_pIsUsingDate) - ? RestoreFromDatePicker( - onTap: widget.dateChooserFunction, - controller: widget.dateController, - ) - : ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - focusNode: widget.blockHeightFocusNode, - controller: widget.blockHeightController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - textInputAction: TextInputAction.done, - style: Util.isDesktop - ? STextStyles.desktopTextMedium( - context, - ).copyWith(height: 2) - : STextStyles.field(context), - onChanged: (value) { - setState(() { - _blockFieldEmpty = value.isEmpty; - }); - }, - decoration: - standardInputDecoration( - "Start scanning from...", - widget.blockHeightFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: TextFieldIconButton( - child: Semantics( - label: - "Clear Block Height Field Button. Clears the block height field", - excludeSemantics: true, - child: !_blockFieldEmpty - ? XIcon( - width: Util.isDesktop ? 24 : 16, - height: Util.isDesktop ? 24 : 16, - ) - : const SizedBox.shrink(), - ), - onTap: () { - widget.blockHeightController.text = ""; - setState(() { - _blockFieldEmpty = true; - }); - }, - ), - ), - ), - ), - ), - if (isCnAnd25 || - widget.coin is Epiccash || - widget.coin is Mimblewimblecoin) - const SizedBox(height: 8), - if (isCnAnd25 || - widget.coin is Epiccash || - widget.coin is Mimblewimblecoin) - RoundedWhiteContainer( - child: Center( - child: Text( - ref.watch(_pIsUsingDate) - ? "Choose the date you made the wallet (approximate is fine)" - : "Enter the initial block height of the wallet", - style: Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, - ) - : STextStyles.smallMed12(context).copyWith(fontSize: 10), - ), - ), + widget.coin is Mimblewimblecoin) ...[ + StartHeightPicker( + coin: widget.coin, + controller: widget.heightController, ), - if (isCnAnd25 || - widget.coin is Epiccash || - widget.coin is Mimblewimblecoin) SizedBox(height: Util.isDesktop ? 24 : 16), + ], Text( "Choose recovery phrase length", style: Util.isDesktop @@ -1011,185 +795,49 @@ class _SeedRestoreOptionState extends ConsumerState { ], ); } - - @override - void initState() { - super.initState(); - - _blockFieldEmpty = widget.blockHeightController.text.isEmpty; - } } -class ViewOnlyRestoreOption extends ConsumerStatefulWidget { +class ViewOnlyRestoreOption extends StatelessWidget { const ViewOnlyRestoreOption({ super.key, required this.coin, - required this.dateController, - required this.dateChooserFunction, - required this.blockHeightController, - required this.blockHeightFocusNode, + required this.heightController, }); final CryptoCurrency coin; - final TextEditingController dateController; - final TextEditingController blockHeightController; - final FocusNode blockHeightFocusNode; - - final Future Function() dateChooserFunction; - - @override - ConsumerState createState() => - _ViewOnlyRestoreOptionState(); -} - -class _ViewOnlyRestoreOptionState extends ConsumerState { - bool _blockFieldEmpty = true; + final StartHeightPickerController heightController; @override Widget build(BuildContext context) { - final showDateOption = widget.coin is CryptonoteCurrency; + if (coin is! CryptonoteCurrency) { + return const SizedBox.shrink(); + } return Column( children: [ - if (showDateOption) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - ref.watch(_pIsUsingDate) ? "Choose start date" : "Block height", - style: Util.isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, - ) - : STextStyles.smallMed12(context), - textAlign: TextAlign.left, - ), - CustomTextButton( - text: ref.watch(_pIsUsingDate) - ? "Use block height" - : "Use date", - onTap: () { - ref.read(_pIsUsingDate.notifier).state = !ref.read( - _pIsUsingDate, - ); - }, - ), - ], - ), - if (showDateOption) SizedBox(height: Util.isDesktop ? 16 : 8), - if (showDateOption) - ref.watch(_pIsUsingDate) - ? RestoreFromDatePicker( - onTap: widget.dateChooserFunction, - controller: widget.dateController, - ) - : ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - focusNode: widget.blockHeightFocusNode, - controller: widget.blockHeightController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - textInputAction: TextInputAction.done, - style: Util.isDesktop - ? STextStyles.desktopTextMedium( - context, - ).copyWith(height: 2) - : STextStyles.field(context), - onChanged: (value) { - setState(() { - _blockFieldEmpty = value.isEmpty; - }); - }, - decoration: - standardInputDecoration( - "Start scanning from...", - widget.blockHeightFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: TextFieldIconButton( - child: Semantics( - label: - "Clear Block Height Field Button. Clears the block height field", - excludeSemantics: true, - child: !_blockFieldEmpty - ? XIcon( - width: Util.isDesktop ? 24 : 16, - height: Util.isDesktop ? 24 : 16, - ) - : const SizedBox.shrink(), - ), - onTap: () { - widget.blockHeightController.text = ""; - setState(() { - _blockFieldEmpty = true; - }); - }, - ), - ), - ), - ), - ), - if (showDateOption) const SizedBox(height: 8), - if (showDateOption) - RoundedWhiteContainer( - child: Center( - child: Text( - ref.watch(_pIsUsingDate) - ? "Choose the date you made the wallet (approximate is fine)" - : "Enter the initial block height of the wallet", - style: Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, - ) - : STextStyles.smallMed12(context).copyWith(fontSize: 10), - ), - ), - ), - if (showDateOption) SizedBox(height: Util.isDesktop ? 24 : 16), + StartHeightPicker(coin: coin, controller: heightController), + SizedBox(height: Util.isDesktop ? 24 : 16), ], ); } - - @override - void initState() { - super.initState(); - - _blockFieldEmpty = widget.blockHeightController.text.isEmpty; - } } class UriRestoreOption extends ConsumerStatefulWidget { const UriRestoreOption({ super.key, required this.coin, - required this.dateController, - required this.dateChooserFunction, - required this.blockHeightController, - required this.blockHeightFocusNode, + required this.heightController, required this.onParsed, }); final CryptoCurrency coin; - final TextEditingController dateController; - final TextEditingController blockHeightController; - final FocusNode blockHeightFocusNode; + final StartHeightPickerController heightController; final void Function(WalletUriData?) onParsed; - final Future Function() dateChooserFunction; - @override ConsumerState createState() => _UriRestoreOptionState(); } class _UriRestoreOptionState extends ConsumerState { - bool _blockFieldEmpty = true; late final TextEditingController _uriController; late final FocusNode _uriFocusNode; String? _uriError; @@ -1197,7 +845,6 @@ class _UriRestoreOptionState extends ConsumerState { @override void initState() { super.initState(); - _blockFieldEmpty = widget.blockHeightController.text.isEmpty; _uriController = TextEditingController(); _uriFocusNode = FocusNode(); } @@ -1235,10 +882,10 @@ class _UriRestoreOptionState extends ConsumerState { setState(() => _uriError = error); - // If the URI contains a height, switch to block height mode and populate. + // The URI's height only prefills the picker; the picker stays editable and + // remains what the restore actually uses. if (parsed?.height != null) { - ref.read(_pIsUsingDate.notifier).state = false; - widget.blockHeightController.text = parsed!.height.toString(); + widget.heightController.setHeight(parsed!.height!); } widget.onParsed(parsed); @@ -1309,95 +956,9 @@ class _UriRestoreOptionState extends ConsumerState { ), ], SizedBox(height: Util.isDesktop ? 24 : 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - ref.watch(_pIsUsingDate) ? "Choose start date" : "Block height", - style: Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, - ) - : STextStyles.smallMed12(context), - textAlign: TextAlign.left, - ), - CustomTextButton( - text: ref.watch(_pIsUsingDate) ? "Use block height" : "Use date", - onTap: () => ref.read(_pIsUsingDate.notifier).state = !ref.read( - _pIsUsingDate, - ), - ), - ], - ), - SizedBox(height: Util.isDesktop ? 16 : 8), - ref.watch(_pIsUsingDate) - ? RestoreFromDatePicker( - onTap: widget.dateChooserFunction, - controller: widget.dateController, - ) - : ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - focusNode: widget.blockHeightFocusNode, - controller: widget.blockHeightController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - textInputAction: TextInputAction.done, - style: Util.isDesktop - ? STextStyles.desktopTextMedium( - context, - ).copyWith(height: 2) - : STextStyles.field(context), - onChanged: (value) { - setState(() { - _blockFieldEmpty = value.isEmpty; - }); - }, - decoration: - standardInputDecoration( - "Start scanning from...", - widget.blockHeightFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: TextFieldIconButton( - child: !_blockFieldEmpty - ? XIcon( - width: Util.isDesktop ? 24 : 16, - height: Util.isDesktop ? 24 : 16, - ) - : const SizedBox.shrink(), - onTap: () { - widget.blockHeightController.text = ""; - setState(() { - _blockFieldEmpty = true; - }); - }, - ), - ), - ), - ), - ), - const SizedBox(height: 8), - RoundedWhiteContainer( - child: Center( - child: Text( - ref.watch(_pIsUsingDate) - ? "Choose the date you made the wallet (approximate is fine)" - : "Enter the initial block height of the wallet", - style: Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, - ) - : STextStyles.smallMed12(context).copyWith(fontSize: 10), - ), - ), + StartHeightPicker( + coin: widget.coin, + controller: widget.heightController, ), ], ); diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/sub_widgets/restore_from_date_picker.dart b/lib/widgets/date_picker/restore_from_date_picker.dart similarity index 81% rename from lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/sub_widgets/restore_from_date_picker.dart rename to lib/widgets/date_picker/restore_from_date_picker.dart index 55aa6ff630..b4dfec1ea1 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/sub_widgets/restore_from_date_picker.dart +++ b/lib/widgets/date_picker/restore_from_date_picker.dart @@ -10,10 +10,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; -import '../../../../../themes/stack_colors.dart'; -import '../../../../../utilities/assets.dart'; -import '../../../../../utilities/text_styles.dart'; -import '../../../../../utilities/util.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; class RestoreFromDatePicker extends StatefulWidget { const RestoreFromDatePicker({ @@ -54,25 +54,21 @@ class _RestoreFromDatePickerState extends State { decoration: InputDecoration( hintText: "Restore from...", hintStyle: STextStyles.fieldLabel(context).copyWith( - color: Theme.of(context) - .extension()! - .textFieldDefaultSearchIconLeft, + color: Theme.of( + context, + ).extension()!.textFieldDefaultSearchIconLeft, ), suffixIcon: UnconstrainedBox( child: Row( children: [ - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), SvgPicture.asset( Assets.svg.calendar, color: Theme.of(context).extension()!.textDark3, width: 16, height: 16, ), - const SizedBox( - width: 12, - ), + const SizedBox(width: 12), ], ), ), diff --git a/lib/widgets/start_height_picker.dart b/lib/widgets/start_height_picker.dart new file mode 100644 index 0000000000..2a8a03265e --- /dev/null +++ b/lib/widgets/start_height_picker.dart @@ -0,0 +1,292 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2026 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../themes/stack_colors.dart'; +import '../utilities/constants.dart'; +import '../utilities/format.dart'; +import '../utilities/logger.dart'; +import '../utilities/text_styles.dart'; +import '../utilities/util.dart'; +import '../wallets/crypto_currency/crypto_currency.dart'; +import '../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; +import '../wl_gen/interfaces/cs_monero_interface.dart'; +import '../wl_gen/interfaces/cs_salvium_interface.dart'; +import '../wl_gen/interfaces/cs_wownero_interface.dart'; +import 'custom_buttons/blue_text_button.dart'; +import 'date_picker/date_picker.dart'; +import 'date_picker/restore_from_date_picker.dart'; +import 'icon_widgets/x_icon.dart'; +import 'rounded_white_container.dart'; +import 'stack_text_field.dart'; +import 'textfield_icon_button.dart'; + +/// The selection made in a [StartHeightPicker]. +/// +/// Use one controller per picker. Sharing a controller between panels leaks the +/// height into panels that show no picker at all. +class StartHeightPickerController extends ChangeNotifier { + int? _height; + bool _isUsingDate = true; + + /// The chosen block height, or null while nothing has been chosen yet. + int? get height => _height; + + bool get isUsingDate => _isUsingDate; + + /// Fills the block height field in and switches to block height mode. + /// + /// The field stays editable afterwards; whatever the user leaves in it is + /// what [height] reports. + void setHeight(int height) => _update(height: height, isUsingDate: false); + + void _update({required int? height, required bool isUsingDate}) { + if (_height == height && _isUsingDate == isUsingDate) { + return; + } + _height = height; + _isUsingDate = isUsingDate; + notifyListeners(); + } +} + +/// Lets the user choose where a wallet scan starts, as either a calendar date +/// or a raw block height, and reports the result through [controller]. +class StartHeightPicker extends StatefulWidget { + const StartHeightPicker({ + super.key, + required this.coin, + required this.controller, + }); + + final CryptoCurrency coin; + final StartHeightPickerController controller; + + /// Whether a chosen start height can actually be applied to [coin]. Coins + /// that cannot honour one must not be offered the control. + static bool isSupported(CryptoCurrency coin) => + coin is CryptonoteCurrency || + coin is Epiccash || + coin is Mimblewimblecoin; + + /// The block height [coin] was at on [date], or null if [coin] has no date to + /// height mapping. + static int? heightFromDate(CryptoCurrency coin, DateTime date) { + try { + final int height; + if (coin is Monero) { + height = csMonero.getHeightByDate(date); + } else if (coin is Wownero) { + height = csWownero.getHeightByDate(date); + } else if (coin is Salvium) { + height = csSalvium.getHeightByDate(date); + } else if (coin is Epiccash || coin is Mimblewimblecoin) { + // Epic Cash and Mimblewimblecoin share a genesis timestamp and target + // block time. Seconds per block is deliberately overestimated so the + // result scans from slightly too early rather than skipping history. + const genesisEpochSeconds = 1565370278; + const overestimatedSecondsPerBlock = 61; + height = + (date.millisecondsSinceEpoch ~/ 1000 - genesisEpochSeconds) ~/ + overestimatedSecondsPerBlock; + } else { + return null; + } + return max(height, 0); + } catch (e, s) { + Logging.instance.w( + "Failed to convert a date to a ${coin.identifier} block height", + error: e, + stackTrace: s, + ); + return null; + } + } + + @override + State createState() => _StartHeightPickerState(); +} + +class _StartHeightPickerState extends State { + final _dateController = TextEditingController(); + final _blockHeightController = TextEditingController(); + final _blockHeightFocusNode = FocusNode(); + + DateTime? _date; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onControllerChanged); + _syncBlockHeightField(); + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerChanged); + _dateController.dispose(); + _blockHeightController.dispose(); + _blockHeightFocusNode.dispose(); + super.dispose(); + } + + void _onControllerChanged() { + if (mounted) { + setState(_syncBlockHeightField); + } + } + + void _syncBlockHeightField() { + if (widget.controller.isUsingDate) { + return; + } + final height = widget.controller.height; + // Compare parsed values so that typing does not fight the field over + // leading zeroes. + if (int.tryParse(_blockHeightController.text) != height) { + _blockHeightController.text = height?.toString() ?? ""; + } + } + + /// Pushes the current selection into the controller, optionally switching + /// between date and block height mode first. + void _apply({bool? isUsingDate}) { + final usingDate = isUsingDate ?? widget.controller.isUsingDate; + widget.controller._update( + height: usingDate + ? (_date == null + ? null + : StartHeightPicker.heightFromDate(widget.coin, _date!)) + : int.tryParse(_blockHeightController.text), + isUsingDate: usingDate, + ); + } + + Future _chooseDate() async { + if (!Util.isDesktop && FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 125)); + } + if (!mounted) { + return; + } + + final date = (await showSWDatePicker(context))?.first; + if (date == null || !mounted) { + return; + } + + setState(() { + _date = date; + _dateController.text = Format.formatDate(date); + }); + _apply(); + } + + @override + Widget build(BuildContext context) { + final isUsingDate = widget.controller.isUsingDate; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + isUsingDate ? "Choose start date" : "Block height", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ) + : STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + CustomTextButton( + text: isUsingDate ? "Use block height" : "Use date", + onTap: () => _apply(isUsingDate: !isUsingDate), + ), + ], + ), + SizedBox(height: Util.isDesktop ? 16 : 8), + if (isUsingDate) + RestoreFromDatePicker(onTap: _chooseDate, controller: _dateController) + else + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("startHeightPickerBlockHeightFieldKey"), + focusNode: _blockHeightFocusNode, + controller: _blockHeightController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textInputAction: TextInputAction.done, + style: Util.isDesktop + ? STextStyles.desktopTextMedium(context).copyWith(height: 2) + : STextStyles.field(context), + onChanged: (_) { + setState(_apply); + }, + decoration: + standardInputDecoration( + "Start scanning from...", + _blockHeightFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: TextFieldIconButton( + child: Semantics( + label: + "Clear Block Height Field Button. " + "Clears the block height field", + excludeSemantics: true, + child: _blockHeightController.text.isNotEmpty + ? XIcon( + width: Util.isDesktop ? 24 : 16, + height: Util.isDesktop ? 24 : 16, + ) + : const SizedBox.shrink(), + ), + onTap: () { + _blockHeightController.text = ""; + setState(_apply); + }, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + RoundedWhiteContainer( + child: Center( + child: Text( + isUsingDate + ? "Choose the date you made the wallet (approximate is fine)" + : "Enter the initial block height of the wallet", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ) + : STextStyles.smallMed12(context).copyWith(fontSize: 10), + ), + ), + ), + ], + ); + } +} diff --git a/test/pages/restore_options_uri_test.dart b/test/pages/restore_options_uri_test.dart index 3655b64132..b7eeadf8a6 100644 --- a/test/pages/restore_options_uri_test.dart +++ b/test/pages/restore_options_uri_test.dart @@ -11,6 +11,8 @@ import 'package:stackwallet/utilities/address_utils.dart'; import 'package:stackwallet/utilities/util.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'package:stackwallet/widgets/options.dart'; +import 'package:stackwallet/widgets/start_height_picker.dart'; +import 'package:tuple/tuple.dart'; import '../sample_data/theme_json.dart'; @@ -83,22 +85,15 @@ void main() { }); testWidgets("shows URI validation errors", (tester) async { - final dateController = TextEditingController(); - final blockController = TextEditingController(); - final blockFocusNode = FocusNode(); - addTearDown(dateController.dispose); - addTearDown(blockController.dispose); - addTearDown(blockFocusNode.dispose); + final heightController = StartHeightPickerController(); + addTearDown(heightController.dispose); WalletUriData? parsed; await tester.pumpWidget( testApp( UriRestoreOption( coin: coin, - dateController: dateController, - dateChooserFunction: () async {}, - blockHeightController: blockController, - blockHeightFocusNode: blockFocusNode, + heightController: heightController, onParsed: (value) => parsed = value, ), ), @@ -119,6 +114,75 @@ void main() { expect(find.text("Invalid restore height."), findsNothing); }); + testWidgets("a URI height does not leak into a mode with no picker", ( + tester, + ) async { + RouteSettings? pushed; + await tester.pumpWidget( + ProviderScope( + overrides: [themeProvider.overrideWithValue(StateController(theme))], + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + onGenerateRoute: (settings) { + pushed = settings; + return MaterialPageRoute( + builder: (_) => const SizedBox.shrink(), + ); + }, + home: Scaffold( + body: RestoreOptionsView(walletName: "wallet", coin: coin), + ), + ), + ), + ); + + Future selectOption(double horizontalFraction) async { + final rect = tester.getRect(find.byType(Options)); + await tester.tapAt( + Offset(rect.left + rect.width * horizontalFraction, rect.center.dy), + ); + await tester.pumpAndSettle(); + } + + await selectOption(5 / 6); + await tester.enterText( + find.byType(TextField).first, + "monero_wallet:?seed=alpha%20beta&height=3000000", + ); + await tester.pumpAndSettle(); + + expect( + tester + .widget( + find.byKey(const Key("startHeightPickerBlockHeightFieldKey")), + ) + .controller! + .text, + "3000000", + ); + + // Monero's default 16 word seed restore shows no height control at all, so + // the height chosen in URI mode must not follow the user over to it. + await selectOption(1 / 6); + expect( + find.byKey(const Key("startHeightPickerBlockHeightFieldKey")), + findsNothing, + ); + + tester + .widget(find.byType(RestoreOptionsNextButton)) + .onPressed!(); + await tester.pumpAndSettle(); + + expect(pushed?.arguments, isA>()); + expect( + (pushed!.arguments! as Tuple5).item4, + 0, + ); + }); + testWidgets("key restore progress cannot be cancelled", (tester) async { await tester.pumpWidget(testApp(const RestoringDialog())); diff --git a/test/widgets/start_height_picker_test.dart b/test/widgets/start_height_picker_test.dart new file mode 100644 index 0000000000..d9ed6657c6 --- /dev/null +++ b/test/widgets/start_height_picker_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; +import 'package:stackwallet/widgets/start_height_picker.dart'; + +import '../sample_data/theme_json.dart'; + +void main() { + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + final mwc = Mimblewimblecoin(CryptoCurrencyNetwork.main); + final epic = Epiccash(CryptoCurrencyNetwork.main); + + Widget testApp(Widget child) => MaterialApp( + theme: ThemeData(extensions: [StackColors.fromStackColorTheme(theme)]), + home: Scaffold(body: child), + ); + + setUp(() { + Util.screenWidth = 400; + }); + + tearDown(() { + Util.screenWidth = null; + }); + + group("StartHeightPicker.isSupported", () { + test("covers exactly the coins whose rescan honours a start height", () { + expect( + StartHeightPicker.isSupported(Monero(CryptoCurrencyNetwork.main)), + isTrue, + ); + expect( + StartHeightPicker.isSupported(Wownero(CryptoCurrencyNetwork.main)), + isTrue, + ); + expect( + StartHeightPicker.isSupported(Salvium(CryptoCurrencyNetwork.main)), + isTrue, + ); + expect(StartHeightPicker.isSupported(epic), isTrue); + expect(StartHeightPicker.isSupported(mwc), isTrue); + expect( + StartHeightPicker.isSupported(Bitcoin(CryptoCurrencyNetwork.main)), + isFalse, + ); + }); + }); + + group("StartHeightPicker.heightFromDate", () { + test("Mimblewimblecoin gets the same estimate Epic Cash does", () { + final date = DateTime.utc(2024, 6, 1); + final height = StartHeightPicker.heightFromDate(mwc, date); + expect(height, isNotNull); + expect(height, greaterThan(0)); + expect(height, StartHeightPicker.heightFromDate(epic, date)); + }); + + test("clamps dates before the genesis block to zero", () { + expect(StartHeightPicker.heightFromDate(mwc, DateTime.utc(2010)), 0); + }); + + test("returns null for a coin with no date to height mapping", () { + expect( + StartHeightPicker.heightFromDate( + Bitcoin(CryptoCurrencyNetwork.main), + DateTime.utc(2024), + ), + isNull, + ); + }); + }); + + group("StartHeightPickerController", () { + test("reports no height until something is chosen", () { + final controller = StartHeightPickerController(); + addTearDown(controller.dispose); + + expect(controller.height, isNull); + expect(controller.isUsingDate, isTrue); + }); + + test("setHeight switches to block height mode", () { + final controller = StartHeightPickerController(); + addTearDown(controller.dispose); + + var notifications = 0; + controller.addListener(() => notifications++); + + controller.setHeight(3000000); + + expect(controller.height, 3000000); + expect(controller.isUsingDate, isFalse); + expect(notifications, 1); + }); + }); + + testWidgets("setHeight prefills a field the user can still edit", ( + tester, + ) async { + final controller = StartHeightPickerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + testApp(StartHeightPicker(coin: mwc, controller: controller)), + ); + + controller.setHeight(3000000); + await tester.pump(); + + final field = find.byKey(const Key("startHeightPickerBlockHeightFieldKey")); + expect(field, findsOneWidget); + expect(tester.widget(field).controller!.text, "3000000"); + + await tester.enterText(field, "2500000"); + await tester.pump(); + + expect(controller.height, 2500000); + }); + + testWidgets("an empty block height field reports no height", (tester) async { + final controller = StartHeightPickerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + testApp(StartHeightPicker(coin: mwc, controller: controller)), + ); + + // The mode toggle renders as a RichText span, so drive its callback. + tester.widget(find.byType(CustomTextButton)).onTap!(); + await tester.pump(); + + expect(controller.isUsingDate, isFalse); + expect(controller.height, isNull); + }); +} From 69e982e1b6949d08de23a8bad5aa715e5ca50a51 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 25 Aug 2026 12:26:32 -0500 Subject: [PATCH 17/19] feat: rescan a wallet from a chosen block height The rescan confirmation dialog now offers the start height picker to the coins whose rescan can act on it, and hands the choice to the caller, which persists it before starting the rescan: the wallet info restore height for every coin, plus the native refresh height for Cryptonote wallets and the per-coin restore height that Epic Cash and Mimblewimblecoin reset their last scanned block to. Mimblewimblecoin gains the updateRestoreHeight that Epic Cash already had, without which its rescan would have ignored the height. Coins that cannot apply a start height are not shown the control, and choosing nothing leaves the stored restore height untouched. setRefreshFromBlockHeight becomes a Future so the native height is set before the rescan begins rather than racing it. --- .../sub_widgets/confirm_full_rescan.dart | 171 ++++++++++++------ .../wallet_network_settings_view.dart | 29 ++- .../edit_refresh_height_view.dart | 2 +- .../wallet/impl/mimblewimblecoin_wallet.dart | 14 ++ .../intermediate/cryptonote_wallet.dart | 2 +- .../intermediate/lib_monero_wallet.dart | 8 +- .../intermediate/lib_salvium_wallet.dart | 6 +- .../intermediate/lib_wownero_wallet.dart | 6 +- test/widgets/confirm_full_rescan_test.dart | 143 +++++++++++++++ 9 files changed, 315 insertions(+), 66 deletions(-) create mode 100644 test/widgets/confirm_full_rescan_test.dart diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/sub_widgets/confirm_full_rescan.dart b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/sub_widgets/confirm_full_rescan.dart index 530233ec4d..15625ea30b 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/sub_widgets/confirm_full_rescan.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/sub_widgets/confirm_full_rescan.dart @@ -12,22 +12,65 @@ import 'package:flutter/material.dart'; import '../../../../../themes/stack_colors.dart'; import '../../../../../utilities/text_styles.dart'; import '../../../../../utilities/util.dart'; +import '../../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../../../../widgets/desktop/primary_button.dart'; import '../../../../../widgets/desktop/secondary_button.dart'; import '../../../../../widgets/stack_dialog.dart'; +import '../../../../../widgets/start_height_picker.dart'; -class ConfirmFullRescanDialog extends StatelessWidget { - const ConfirmFullRescanDialog({super.key, required this.onConfirm}); +class ConfirmFullRescanDialog extends StatefulWidget { + const ConfirmFullRescanDialog({ + super.key, + required this.coin, + required this.onConfirm, + }); - final VoidCallback onConfirm; + final CryptoCurrency coin; + + /// [startHeight] is null when the user chose no start height, in which case + /// the wallet's stored restore height is left alone. + final void Function(int? startHeight) onConfirm; + + @override + State createState() => + _ConfirmFullRescanDialogState(); +} + +class _ConfirmFullRescanDialogState extends State { + static const _warning = + "Warning! It may take a while. If you exit before completion, you will " + "have to redo the process."; + + final _heightController = StartHeightPickerController(); + + late final bool _showHeightPicker; + + @override + void initState() { + super.initState(); + _showHeightPicker = StartHeightPicker.isSupported(widget.coin); + } + + @override + void dispose() { + _heightController.dispose(); + super.dispose(); + } + + void _confirm() { + final startHeight = _showHeightPicker ? _heightController.height : null; + Navigator.of(context).pop(); + widget.onConfirm(startHeight); + } @override Widget build(BuildContext context) { if (Util.isDesktop) { return DesktopDialog( maxWidth: 576, + maxHeight: double.infinity, child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -35,9 +78,7 @@ class ConfirmFullRescanDialog extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( - padding: const EdgeInsets.only( - left: 32, - ), + padding: const EdgeInsets.only(left: 32), child: Text( "Rescan blockchain", style: STextStyles.desktopH3(context), @@ -56,13 +97,15 @@ class ConfirmFullRescanDialog extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - "Warning! It may take a while. If you exit before completion, you will have to redo the process.", - style: STextStyles.desktopTextSmall(context), - ), - const SizedBox( - height: 43, - ), + Text(_warning, style: STextStyles.desktopTextSmall(context)), + if (_showHeightPicker) ...[ + const SizedBox(height: 24), + StartHeightPicker( + coin: widget.coin, + controller: _heightController, + ), + ], + const SizedBox(height: 43), Row( children: [ Expanded( @@ -72,16 +115,11 @@ class ConfirmFullRescanDialog extends StatelessWidget { label: "Cancel", ), ), - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), Expanded( child: PrimaryButton( buttonHeight: ButtonHeight.l, - onPressed: () { - Navigator.of(context).pop(); - onConfirm.call(); - }, + onPressed: _confirm, label: "Rescan", ), ), @@ -93,42 +131,63 @@ class ConfirmFullRescanDialog extends StatelessWidget { ], ), ); - } else { - return WillPopScope( - onWillPop: () async { - return true; - }, - child: StackDialog( - title: "Rescan blockchain", - message: - "Warning! It may take a while. If you exit before completion, you will have to redo the process.", - leftButton: TextButton( - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), - child: Text( - "Cancel", - style: STextStyles.itemSubtitle12(context), - ), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - rightButton: TextButton( - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - child: Text( - "Rescan", - style: STextStyles.button(context), - ), - onPressed: () { - Navigator.of(context).pop(); - onConfirm.call(); - }, - ), - ), - ); } + + final leftButton = TextButton( + style: Theme.of( + context, + ).extension()!.getSecondaryEnabledButtonStyle(context), + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text("Cancel", style: STextStyles.itemSubtitle12(context)), + ); + final rightButton = TextButton( + style: Theme.of( + context, + ).extension()!.getPrimaryEnabledButtonStyle(context), + onPressed: _confirm, + child: Text("Rescan", style: STextStyles.button(context)), + ); + + return WillPopScope( + onWillPop: () async { + return true; + }, + child: _showHeightPicker + ? StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Rescan blockchain", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + Text(_warning, style: STextStyles.smallMed14(context)), + const SizedBox(height: 16), + StartHeightPicker( + coin: widget.coin, + controller: _heightController, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded(child: leftButton), + const SizedBox(width: 16), + Expanded(child: rightButton), + ], + ), + ], + ), + ) + : StackDialog( + title: "Rescan blockchain", + message: _warning, + leftButton: leftButton, + rightButton: rightButton, + ), + ); } } 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 3eccb79285..d4d539ee73 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 @@ -44,6 +44,7 @@ import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; +import '../../../../wallets/wallet/wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; import '../../../../widgets/animated_text.dart'; @@ -131,7 +132,27 @@ class _WalletNetworkSettingsViewState } } - Future _attemptRescan() async { + /// Persists [height] where the coin's own rescan reads it back from, and + /// pushes it into an open Cryptonote wallet, which keeps the height in the + /// native wallet rather than in the wallet info. + Future _applyStartHeight(Wallet wallet, int height) async { + await ref + .read(pWalletInfo(widget.walletId)) + .updateRestoreHeight( + newRestoreHeight: height, + isar: ref.read(mainDBProvider).isar, + ); + + if (wallet is EpiccashWallet) { + await wallet.updateRestoreHeight(height); + } else if (wallet is MimblewimblecoinWallet) { + await wallet.updateRestoreHeight(height); + } else if (wallet is CryptonoteWallet) { + await wallet.setRefreshFromBlockHeight(height); + } + } + + Future _attemptRescan(int? startHeight) async { if (!Platform.isLinux) await WakelockPlus.enable(); try { @@ -148,6 +169,10 @@ class _WalletNetworkSettingsViewState try { final wallet = ref.read(pWallets).getWallet(widget.walletId); + if (startHeight != null) { + await _applyStartHeight(wallet, startHeight); + } + await wallet.recover(isRescan: true); if (mounted) { @@ -449,6 +474,7 @@ class _WalletNetworkSettingsViewState barrierDismissible: true, builder: (context) { return ConfirmFullRescanDialog( + coin: coin, onConfirm: _attemptRescan, ); }, @@ -1078,6 +1104,7 @@ class _WalletNetworkSettingsViewState await Navigator.of(context).push( FadePageRoute( ConfirmFullRescanDialog( + coin: coin, onConfirm: _attemptRescan, ), const RouteSettings(), 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 05d51ac80e..0162f51802 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 @@ -64,7 +64,7 @@ class _EditRefreshHeightViewState extends ConsumerState { } if (wallet is CryptonoteWallet && wallet.wallet != null) { - wallet.setRefreshFromBlockHeight(newHeight); + await wallet.setRefreshFromBlockHeight(newHeight); } } else { errMessage = "Invalid height: ${_controller.text}"; diff --git a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart index d31be377f1..ddcb61b199 100644 --- a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart +++ b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart @@ -832,6 +832,20 @@ class MimblewimblecoinWallet extends Bip39Wallet { return config!; } + /// Sets the height a rescan starts from: recover(isRescan: true) resets + /// lastScannedBlock to this value. Mirrors EpiccashWallet. + Future updateRestoreHeight(int height) async { + final data = info.mimblewimblecoinData; + if (data == null) { + throw Exception("Cannot update restore height before the wallet exists"); + } + + await info.updateExtraMimblewimblecoinWalletInfo( + mimblewimblecoinData: data.copyWith(restoreHeight: height), + isar: mainDB.isar, + ); + } + int _calculateRestoreHeightFrom({required DateTime date}) { final int secondsSinceEpoch = date.millisecondsSinceEpoch ~/ 1000; const int mimblewimblecoinFirstBlock = 1565370278; diff --git a/lib/wallets/wallet/intermediate/cryptonote_wallet.dart b/lib/wallets/wallet/intermediate/cryptonote_wallet.dart index 026d2f5cd0..e9ca4c6a41 100644 --- a/lib/wallets/wallet/intermediate/cryptonote_wallet.dart +++ b/lib/wallets/wallet/intermediate/cryptonote_wallet.dart @@ -31,7 +31,7 @@ abstract class CryptonoteWallet Future<(String, String)> hackToCreateNewViewOnlyWalletDataFromNewlyCreatedWalletThisFunctionShouldNotBeCalledUnlessYouKnowWhatYouAreDoing(); - void setRefreshFromBlockHeight(int newHeight); + Future setRefreshFromBlockHeight(int newHeight); Future getRefreshFromBlockHeight(); diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index f0f452739e..0fa21dc91d 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -1548,11 +1548,13 @@ abstract class LibMoneroWallet } @override - void setRefreshFromBlockHeight(int newHeight) { + Future setRefreshFromBlockHeight(int newHeight) async { if (wallet == null) { - throw Exception("Cannot internalCommitTx when wallet is not open"); + throw Exception( + "Cannot setRefreshFromBlockHeight when wallet is not open", + ); } - csMonero.setRefreshFromBlockHeight(wallet!, newHeight); + await csMonero.setRefreshFromBlockHeight(wallet!, newHeight); } // ============== Key-based restore ========================================== diff --git a/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart b/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart index 42cbc865b5..5d487a67ee 100644 --- a/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart @@ -1496,9 +1496,11 @@ abstract class LibSalviumWallet } @override - void setRefreshFromBlockHeight(int newHeight) { + Future setRefreshFromBlockHeight(int newHeight) async { if (wallet == null) { - throw Exception("Cannot internalCommitTx when wallet is not open"); + throw Exception( + "Cannot setRefreshFromBlockHeight when wallet is not open", + ); } csSalvium.setRefreshFromBlockHeight(wallet!, newHeight); } diff --git a/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart b/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart index 9d8e8cd2b0..f902d6e95d 100644 --- a/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart @@ -1508,9 +1508,11 @@ abstract class LibWowneroWallet } @override - void setRefreshFromBlockHeight(int newHeight) { + Future setRefreshFromBlockHeight(int newHeight) async { if (wallet == null) { - throw Exception("Cannot internalCommitTx when wallet is not open"); + throw Exception( + "Cannot setRefreshFromBlockHeight when wallet is not open", + ); } csWownero.setRefreshFromBlockHeight(wallet!, newHeight); } diff --git a/test/widgets/confirm_full_rescan_test.dart b/test/widgets/confirm_full_rescan_test.dart new file mode 100644 index 0000000000..03fc8c01ff --- /dev/null +++ b/test/widgets/confirm_full_rescan_test.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_network_settings_view/sub_widgets/confirm_full_rescan.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; + +import '../sample_data/theme_json.dart'; + +void main() { + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + const blockHeightFieldKey = Key("startHeightPickerBlockHeightFieldKey"); + + setUp(() { + Util.screenWidth = 400; + }); + + tearDown(() { + Util.screenWidth = null; + }); + + Future openDialog( + WidgetTester tester, + CryptoCurrency coin, + void Function(int?) onConfirm, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StackColors.fromStackColorTheme(theme)]), + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => + ConfirmFullRescanDialog(coin: coin, onConfirm: onConfirm), + ), + child: const Text("open"), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text("open")); + await tester.pumpAndSettle(); + } + + testWidgets("a Mimblewimblecoin rescan height reaches the caller", ( + tester, + ) async { + int? got; + var called = false; + await openDialog(tester, Mimblewimblecoin(CryptoCurrencyNetwork.main), ( + height, + ) { + got = height; + called = true; + }); + + tester.widget(find.byType(CustomTextButton)).onTap!(); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(blockHeightFieldKey), "12345"); + await tester.pumpAndSettle(); + + await tester.tap(find.text("Rescan")); + await tester.pumpAndSettle(); + + expect(called, isTrue); + expect(got, 12345); + }); + + testWidgets("Epic Cash is offered the height control", (tester) async { + await openDialog(tester, Epiccash(CryptoCurrencyNetwork.main), (_) {}); + + expect(find.text("Choose start date"), findsOneWidget); + }); + + testWidgets("a coin that cannot use a start height is not offered one", ( + tester, + ) async { + int? got = 1; + var called = false; + await openDialog(tester, Bitcoin(CryptoCurrencyNetwork.main), (height) { + got = height; + called = true; + }); + + expect(find.text("Choose start date"), findsNothing); + expect(find.byKey(blockHeightFieldKey), findsNothing); + + await tester.tap(find.text("Rescan")); + await tester.pumpAndSettle(); + + expect(called, isTrue); + expect(got, isNull); + }); + + testWidgets("the desktop dialog fits the height picker", (tester) async { + // A Linux test host reports desktop unless a phone-sized width is set. + Util.screenWidth = null; + + int? got; + await openDialog(tester, Epiccash(CryptoCurrencyNetwork.main), (height) { + got = height; + }); + + expect(find.text("Choose start date"), findsOneWidget); + + tester.widget(find.byType(CustomTextButton)).onTap!(); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(blockHeightFieldKey), "777"); + await tester.pumpAndSettle(); + + await tester.tap(find.text("Rescan")); + await tester.pumpAndSettle(); + + expect(got, 777); + }); + + testWidgets("no height chosen leaves the stored restore height alone", ( + tester, + ) async { + int? got = 1; + var called = false; + await openDialog(tester, Mimblewimblecoin(CryptoCurrencyNetwork.main), ( + height, + ) { + got = height; + called = true; + }); + + await tester.tap(find.text("Rescan")); + await tester.pumpAndSettle(); + + expect(called, isTrue); + expect(got, isNull); + }); +} From 5000fa66369544b6ea6c6403997b1014691c1a86 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 25 Aug 2026 12:27:19 -0500 Subject: [PATCH 18/19] feat: prefill the restore height picker from a wallet URI A URI's `height=` now fills the visible picker in and stops there: the restore uses whatever the picker reports, so a correction the user makes after pasting is the value that takes effect. A URI whose scheme names a different coin than the page is refused with a message naming that coin, instead of being restored under the page's coin from another chain's recovery material. --- .../restore_options_view.dart | 17 +++-- test/pages/restore_options_uri_test.dart | 63 +++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart index 326de97a95..530c859a15 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart @@ -187,7 +187,7 @@ class _RestoreOptionsViewState extends ConsumerState { ); } - Future _attemptUriRestore(int fallbackHeight) async { + Future _attemptUriRestore(int restoreHeight) async { final data = _uriData; if (data == null) return; @@ -205,15 +205,15 @@ class _RestoreOptionsViewState extends ConsumerState { builder: (context) => const ConfirmRecoveryDialog(), ); if (confirmed == true && mounted) { - await _doUriRestore(data, fallbackHeight); + await _doUriRestore(data, restoreHeight); } } - Future _doUriRestore(WalletUriData data, int fallbackHeight) async { + /// [restoreHeight] comes from the visible picker, which the URI's own + /// `height=` only prefills. + Future _doUriRestore(WalletUriData data, int restoreHeight) async { if (!Platform.isLinux && !isDesktop) await WakelockPlus.enable(); - final restoreHeight = data.height ?? fallbackHeight; - try { final Map otherDataJson; if (data.seed != null) { @@ -232,7 +232,7 @@ class _RestoreOptionsViewState extends ConsumerState { } final info = WalletInfo.createNew( - coin: coin, + coin: data.coin, name: walletName, restoreHeight: restoreHeight, otherDataJsonString: jsonEncode(otherDataJson), @@ -880,6 +880,11 @@ class _UriRestoreOptionState extends ConsumerState { parsed = null; } + if (parsed != null && parsed.coin.identifier != widget.coin.identifier) { + error = "This is a ${parsed.coin.prettyName} wallet URI."; + parsed = null; + } + setState(() => _uriError = error); // The URI's height only prefills the picker; the picker stays editable and diff --git a/test/pages/restore_options_uri_test.dart b/test/pages/restore_options_uri_test.dart index b7eeadf8a6..72d31987ff 100644 --- a/test/pages/restore_options_uri_test.dart +++ b/test/pages/restore_options_uri_test.dart @@ -183,6 +183,69 @@ void main() { ); }); + testWidgets("a URI for another coin is refused on this page", (tester) async { + final heightController = StartHeightPickerController(); + addTearDown(heightController.dispose); + + WalletUriData? parsed; + var parsedCalls = 0; + await tester.pumpWidget( + testApp( + UriRestoreOption( + coin: coin, + heightController: heightController, + onParsed: (value) { + parsed = value; + parsedCalls++; + }, + ), + ), + ); + + await tester.enterText( + find.byType(TextField).first, + "wownero_wallet:?seed=alpha%20beta", + ); + await tester.pump(); + + expect(parsedCalls, greaterThan(0)); + expect(parsed, isNull); + expect(find.text("This is a Wownero wallet URI."), findsOneWidget); + }); + + testWidgets("a URI height prefills a field the user still controls", ( + tester, + ) async { + final heightController = StartHeightPickerController(); + addTearDown(heightController.dispose); + + await tester.pumpWidget( + testApp( + UriRestoreOption( + coin: coin, + heightController: heightController, + onParsed: (_) {}, + ), + ), + ); + + await tester.enterText( + find.byType(TextField).first, + "monero_wallet:?seed=alpha%20beta&height=3000000", + ); + await tester.pumpAndSettle(); + + expect(heightController.height, 3000000); + + await tester.enterText( + find.byKey(const Key("startHeightPickerBlockHeightFieldKey")), + "2500000", + ); + await tester.pumpAndSettle(); + + expect(heightController.height, 2500000); + }); + testWidgets("key restore progress cannot be cancelled", (tester) async { await tester.pumpWidget(testApp(const RestoringDialog())); From 839132d5728d01d906a7e77c124be432c1d0f751 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 25 Aug 2026 12:28:35 -0500 Subject: [PATCH 19/19] fix: keep wallet URI secrets out of the parse failure log Uri.parse throws a FormatException whose source is the string it was given, and FormatException.toString() quotes a window of that source. The redacted branch of the URI parser interpolated the exception and passed it to the logger as well, so a paste the parser choked on -- a label in front of the URI, a stray bracket, a leading space -- wrote the pasted seed or private keys to the console and, at debug log level, to the exportable log file. Log the exception's message alone instead. --- lib/utilities/address_utils.dart | 22 ++++-- test/utilities/wallet_uri_secrets_test.dart | 83 +++++++++++++++++++++ 2 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 test/utilities/wallet_uri_secrets_test.dart diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index a9e0a835c8..4bc610324a 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -81,12 +81,22 @@ class AddressUtils { } } } catch (e, s) { - Logging.instance.d( - "Exception caught in parseUri(" - "${redactUriInLogs ? '' : uri}): $e", - error: e, - stackTrace: s, - ); + if (redactUriInLogs) { + // FormatException.toString() quotes a window of its source, and + // Uri.parse's source is the pasted uri, so log the message alone and + // never hand the exception object to the logger. + Logging.instance.d( + "Exception caught in parseUri(): " + "${e is FormatException ? e.message : e.runtimeType}", + stackTrace: s, + ); + } else { + Logging.instance.d( + "Exception caught in parseUri($uri): $e", + error: e, + stackTrace: s, + ); + } } return result; } diff --git a/test/utilities/wallet_uri_secrets_test.dart b/test/utilities/wallet_uri_secrets_test.dart new file mode 100644 index 0000000000..e661d8f164 --- /dev/null +++ b/test/utilities/wallet_uri_secrets_test.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/address_utils.dart'; + +void main() { + const seed = + "abandon ability able about above absent absorb abstract absurd abuse " + "access accident account accuse achieve acid acoustic acquire across act " + "action actor actress actual"; + const viewKey = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const spendKey = + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + const address = + "4AeRgkWZsMJhAWKMeCZ3h4ZSPnAcW5VBtRFyLd6gBEf6GgJU2FHXDA6i1DnQTd6h8R3VU5" + "AkbGcWSNhtSwNNPgaD48gp4nn"; + + String logsWhileParsing(String uri) { + final lines = []; + runZoned( + () { + try { + WalletUriData.fromUriString(uri); + } catch (_) { + // The parse is expected to fail; the log it writes is what matters. + } + }, + zoneSpecification: ZoneSpecification( + print: (_, _, _, line) => lines.add(line), + ), + ); + return lines.join("\n"); + } + + group("a malformed wallet URI must not reach the log", () { + // Each of these makes Uri.parse itself throw, which is the only path that + // ever logged the caller's text. + final malformed = { + "label pasted in front": "Wallet URI: monero_wallet:?seed=$seed", + "invalid port": "monero_wallet://$address:1x?seed=$seed", + "unterminated bracket": "monero_wallet://[$address?seed=$seed", + "leading space": " monero_wallet:?seed=$seed", + }; + + malformed.forEach((name, uri) { + test(name, () { + final log = logsWhileParsing(uri); + expect(log, contains(""), reason: "redaction not applied"); + expect(log, isNot(contains("abandon"))); + expect(log, isNot(contains("ability"))); + expect(log, isNot(contains(uri))); + }); + }); + + test("private keys", () { + final log = logsWhileParsing( + "monero_wallet://$address:1x?view_key=$viewKey&spend_key=$spendKey", + ); + expect(log, contains("")); + expect(log, isNot(contains(viewKey))); + expect(log, isNot(contains(spendKey))); + }); + }); + + group("WalletUriData diagnostics", () { + test("does not print a seed", () { + final data = WalletUriData.fromUriString("monero_wallet:?seed=$seed"); + expect(data.seed, seed); + expect(data.toString(), isNot(contains("abandon"))); + }); + + test("does not print private keys", () { + final data = WalletUriData.fromUriString( + "monero_wallet:$address?view_key=$viewKey&spend_key=$spendKey", + ); + expect(data.viewKey, viewKey); + expect(data.spendKey, spendKey); + expect(data.toString(), isNot(contains(viewKey))); + expect(data.toString(), isNot(contains(spendKey))); + }); + }); +}