From 93e6aefbe1fa1c2ae34db8187d7f9145a0452cb6 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 21 Feb 2026 11:02:39 -0600 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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