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..63fc2bcc53 --- /dev/null +++ b/lib/models/keys/cryptonote_key_restore_data.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; + +class CryptonoteKeyRestoreData { + static const int currentVersion = 1; + + 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"); + } + + 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: address, + privateViewKey: privateViewKey, + privateSpendKey: privateSpendKey, + ); + } + + String toJsonEncodedString() => jsonEncode({ + "version": currentVersion, + "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 c20c7938c1..0b12293843 100644 --- a/lib/models/keys/cw_key_data.dart +++ b/lib/models/keys/cw_key_data.dart @@ -3,19 +3,24 @@ 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), - (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 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 new file mode 100644 index 0000000000..190d2b9cec --- /dev/null +++ b/lib/models/keys/wallet_recovery_material.dart @@ -0,0 +1,60 @@ +import 'cryptonote_key_restore_data.dart'; +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, + this.cryptonoteKeyRestoreData, + }); + + final KeyDataInterface keyData; + final CryptonoteKeyRestoreData? cryptonoteKeyRestoreData; +} + +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 e650564a18..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 @@ -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,17 @@ 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/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'; +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 +39,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 +50,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 +58,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 +105,7 @@ class _RestoreOptionsViewState extends ConsumerState { bool _hasBlockHeight = false; DateTime? _restoreFromDate; bool hidePassword = true; + WalletUriData? _uriData; @override void initState() { @@ -143,26 +164,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 +281,198 @@ 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; + + final confirmed = await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) => const ConfirmRecoveryDialog(), + ); + if (confirmed == true && mounted) { + await _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.recoveryTypeIndexKey: + WalletRecoveryType.privateKeys.index, + }; + } + + final info = WalletInfo.createNew( + coin: coin, + name: walletName, + restoreHeight: restoreHeight, + otherDataJsonString: jsonEncode(otherDataJson), + ); + + 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) => const RestoringDialog(), + ), + ); + } + + late final Wallet wallet; + 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); + } + + 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 { + wallet = await Wallet.create( + walletInfo: info, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + cryptonoteKeyRestoreData: CryptonoteKeyRestoreData( + address: data.address!, + privateViewKey: data.viewKey!, + privateSpendKey: data.spendKey!, + ), + ); + } + + if (wallet is CryptonoteWallet) { + await wallet.init(isRestore: true); + } else { + await wallet.init(); + } + + await wallet.recover(isRescan: false); + + await wallet.info.setMnemonicVerified( + isar: ref.read(mainDBProvider).isar, + ); + + if (ref.read(pDuress)) { + await wallet.info.updateDuressVisibilityStatus( + isDuressVisible: true, + isar: ref.read(mainDBProvider).isar, + ); + } + } catch (e, s) { + Logging.instance.e( + "Wallet URI restore failed", + error: e, + stackTrace: s, + ); + closeRestoringDialog(); + if (mounted) { + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) => RestoreFailedDialog( + errorMessage: e.toString(), + walletId: info.walletId, + walletName: info.name, + ), + ); + } + 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(); + } + } + + // 0 = Seed, 1 = View Only, 2 = URI (Monero only) + int _restoreMode = 0; @override Widget build(BuildContext context) { @@ -306,59 +524,99 @@ 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; + if (value != 2) { + _uriData = null; + } + }); + }, + 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 +1164,242 @@ 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; + 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( + 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; + widget.blockHeightController.text = parsed!.height.toString(); + } + + 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, + 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=...", + _uriFocusNode, + 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, + ), + ), + 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, + 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), + ), + ), + ), + ], + ); + } +} 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/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/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..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) + if (keyData != null && mnemonic != null) Padding( padding: const EdgeInsets.all(10), child: CustomTextButton( @@ -92,10 +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( @@ -111,13 +113,61 @@ class WalletBackupView extends ConsumerWidget { body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), - child: - frost - ? _FrostKeys( - frostWalletData: frostWalletData, + 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"), + ), + ), + ), + ); + } +} + +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, - ) - : _Mnemonic(walletId: walletId, mnemonic: mnemonic), + cwKeyData: e, + ), + final ViewOnlyWalletData e => ViewOnlyWalletDataWidget( + data: e, + ), + _ => throw UnimplementedError( + "Don't forget to add your KeyDataInterface here! " + "${keyData.runtimeType}", + ), + }, + ), + const SizedBox(height: 16), + ], + ), ), ), ), @@ -246,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, ), ), ), @@ -322,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) @@ -349,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, + ), ), ], ), @@ -420,42 +465,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 4fd2e8f95d..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'; @@ -162,68 +160,20 @@ 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; - ({ - 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) { - if (wallet is ViewOnlyOptionInterface && - (wallet as ViewOnlyOptionInterface).isViewOnly) { - // TODO: is something needed here? - } else { - 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) { - keyData = await wallet.getKeys(); - } + 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", @@ -236,15 +186,10 @@ class _WalletSettingsViewState extends ConsumerState { } 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", @@ -417,10 +362,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_recovery_phrase_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart index 2bcf90827f..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 @@ -16,6 +16,9 @@ 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 '../../../../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'; @@ -37,27 +40,32 @@ 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({ super.key, - required this.walletId, - required this.mnemonic, - this.frostWalletData, + 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 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; @@ -68,7 +76,7 @@ class DeleteWalletRecoveryPhraseView extends ConsumerStatefulWidget { class _DeleteWalletRecoveryPhraseViewState extends ConsumerState { - late List _mnemonic; + late final List? _mnemonic; late ClipboardInterface _clipboardInterface; bool _lock = false; @@ -81,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; @@ -140,7 +146,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 == null) { + throw StateError("Wallet has no recovery data"); + } return Background( child: Scaffold( @@ -152,253 +162,293 @@ class _DeleteWalletRecoveryPhraseViewState }, ), actions: [ - 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, - ), + 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, + ), + ); + } + }, + ), ), ), - ), ], ), 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, + ), + ], ), ), - ); - }, - ) - : 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, - ), + ); + }, + ) + : keyBased + ? LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, ), - 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, - ), + 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), + ), + ), + ], ), ), ), - const SizedBox(height: 8), - Expanded( - child: SingleChildScrollView( - child: MnemonicTable( - words: _mnemonic, - isDesktop: false, - ), + ); + }, + ) + : 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: 16), - TextButton( - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - onPressed: _continuePressed, + child: Padding( + padding: const EdgeInsets.all(12), child: Text( - "Continue", - style: STextStyles.button(context), + "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: 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 d6bc5f2e2a..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,13 +12,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../app_config.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/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'; @@ -59,10 +57,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 +67,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 +84,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, ), ), ), @@ -102,62 +97,19 @@ class DeleteWalletWarningView extends ConsumerWidget { .getPrimaryEnabledButtonStyle(context), 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, - String config, - String keys, - ({String config, String keys})? prevGen, - })? - frostWalletData; - 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 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, - ), + 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 c9a0c50740..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 @@ -14,8 +14,12 @@ 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 '../../../../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'; import '../../../../providers/global/secure_store_provider.dart'; import '../../../../providers/global/wallets_provider.dart'; import '../../../../route_generator.dart'; @@ -29,19 +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, + required this.recoveryMaterial, this.clipboardInterface = const ClipboardWrapper(), }); - final String walletId; - final List words; + 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 @@ -51,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 = @@ -70,9 +87,15 @@ class _DeleteWalletKeysPopup extends ConsumerState { @override Widget build(BuildContext context) { + if (_words == null && + widget.keyData is! CWKeyData && + widget.frostData == null) { + throw StateError("Wallet has no recovery data"); + } + return DesktopDialog( maxWidth: 614, - maxHeight: double.infinity, + maxHeight: null, child: Column( children: [ Row( @@ -92,48 +115,77 @@ 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 != null) ...[ + 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, + ), + ); + } + }, + child: MnemonicTable( + words: widget.words!, + isDesktop: true, + itemBorderColor: Theme.of( + context, + ).extension()!.buttonBackSecondary, + ), + ), + ), + ] else if (widget.keyData is CWKeyData) ...[ + 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, ), - ); - } - }, - child: MnemonicTable( - words: widget.words, - isDesktop: true, - itemBorderColor: Theme.of( - context, - ).extension()!.buttonBackSecondary, + ] else ...[ + _FrostRecoveryData(data: widget.frostData!), + ], + ], ), ), ), @@ -147,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); }, @@ -169,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 17de7cd217..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 @@ -11,17 +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/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/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'; @@ -112,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( @@ -153,7 +153,7 @@ class _DesktopAttentionDeleteWallet padding: const EdgeInsets.all(32), child: DeleteViewOnlyWalletKeysView( walletId: widget.walletId, - data: data, + data: data.keyData, ), ), ], @@ -162,16 +162,11 @@ class _DesktopAttentionDeleteWallet ), ); } - } else - // TODO: [prio=med] handle other types wallet deletion - // All wallets currently are mnemonic based - if (wallet is MnemonicInterface) { - final words = await wallet.getMnemonicAsWords(); - + } else { if (context.mounted) { await Navigator.of(context).pushNamed( DeleteWalletKeysPopup.routeName, - arguments: Tuple2(widget.walletId, words), + 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 dc15771830..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; - - // 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 = [ - 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 is ViewOnlyOptionInterface && - (wallet as ViewOnlyOptionInterface).isViewOnly) { - // TODO: is something needed here? - } else { - 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) { - keyData = await wallet.getKeys(); - } + 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 6197874811..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,34 +2207,11 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case DeleteWalletRecoveryPhraseView.routeName: - 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), ); } @@ -2741,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, - })) { + if (args is WalletRecoveryMaterial) { 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, - })) { - return FadePageRoute( - WalletKeysDesktopPopup( - words: args.mnemonic, - walletId: args.walletId, - keyData: args.keyData, - ), + WalletKeysDesktopPopup(recoveryMaterial: args), RouteSettings(name: settings.name), ); } @@ -2855,20 +2720,11 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case DeleteWalletKeysPopup.routeName: - 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..a74362724f --- /dev/null +++ b/lib/services/wallet_recovery_service.dart @@ -0,0 +1,121 @@ +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'; +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}", + ); + } + final keyData = await wallet.getKeys(); + return PrivateKeyWalletRecoveryMaterial( + walletId: wallet.walletId, + keyData: keyData, + cryptonoteKeyRestoreData: await getCryptonoteKeyRestoreData( + wallet, + keyData: keyData, + ), + ); + } + + 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, + ); + } + + 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/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 cb1f5f8ad6..a9e0a835c8 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -28,6 +28,7 @@ class AddressUtils { }; static String condenseAddress(String address) { + if (address.length < 10) return address; return '${address.substring(0, 5)}...${address.substring(address.length - 5)}'; } @@ -41,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); @@ -78,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, ); @@ -86,34 +91,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; @@ -175,6 +197,39 @@ class AddressUtils { } } + /// Parses a wallet URI and returns a Map. + /// + /// Returns null on failure to parse. + static Map? _parseWalletUri(String uri) { + final Map parsedData = {}; + + final separatorIndex = uri.indexOf(":"); + if (separatorIndex <= 0) return null; + + 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 + // "_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, @@ -408,3 +463,141 @@ class PaymentUriData { "additionalParams: $additionalParams" " }"; } + +class WalletUriData { + final CryptoCurrency coin; + final String? address; + final String? seed; + final String? spendKey; + final String? viewKey; + final int? height; + + bool get isViewOnly => spendKey == null && seed == null; + + WalletUriData({ + required this.coin, + this.address, + this.seed, + this.spendKey, + this.viewKey, + this.height, + }); + + factory WalletUriData.fromUriString( + String uri, { + bool Function(String address)? addressValidator, + }) { + final map = AddressUtils._parseWalletUri(uri); + + if (map == null) { + throw const FormatException("Invalid wallet URI"); + } + + 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, { + 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 || spendKey != 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.", + ); + } + + if (spendKey != null && viewKey == null) { + throw const FormatException("Invalid: spend_key requires view_key."); + } + + if (hasKeys && address == null) { + throw const FormatException( + "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."); + } + + 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( + coin: coin, + address: address, + spendKey: spendKey, + viewKey: viewKey, + seed: seed, + height: height, + ); + } + + @override + String toString() { + return "WalletUriData { " + "coin: $coin, " + "address: $address, " + "seed: ${seed == null ? null : ''}, " + "spendKey: ${spendKey == null ? null : ''}, " + "viewKey: ${viewKey == null ? null : ''}, " + "height: $height, " + " }"; + } +} diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart index 12329f8ceb..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 @@ -144,6 +146,22 @@ class WalletInfo implements IsarId { bool get isViewOnly => otherData[WalletInfoKeys.isViewOnlyKey] as bool? ?? false; + @ignore + 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 { final index = otherData[WalletInfoKeys.viewOnlyTypeIndexKey] as int?; @@ -584,4 +602,6 @@ abstract class WalletInfoKeys { "solanaCustomTokenMintAddressesKey"; static const String firoMasternodeCollateralDismissed = "firoMasternodeCollateralDismissedKey"; + static const String isRestoredFromKeysKey = "isRestoredFromKeysKey"; + static const String recoveryTypeIndexKey = "recoveryTypeIndexKey"; } 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/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 6c0c49884e..f0f452739e 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'; @@ -163,6 +164,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); @@ -292,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( @@ -307,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; } } @@ -406,6 +410,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 +1555,82 @@ abstract class LibMoneroWallet csMonero.setRefreshFromBlockHeight(wallet!, newHeight); } + // ============== Key-based restore ========================================== + + Future _recoverFromKeys(String keysDataJson) async { + await refreshMutex.protect(() async { + final data = CryptonoteKeyRestoreData.fromJsonEncodedString(keysDataJson); + + 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: data.address, + privateViewKey: data.privateViewKey, + privateSpendKey: data.privateSpendKey, + height: height, + ); + + if (this.wallet != null) { + await exit(); + } + this.wallet = wallet; + + _setListener(); + + 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 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 1aa40ef6a7..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,6 +154,7 @@ abstract class Wallet { String? mnemonicPassphrase, String? privateKey, ViewOnlyWalletData? viewOnlyData, + CryptonoteKeyRestoreData? cryptonoteKeyRestoreData, }) async { // TODO: rework soon? if (walletInfo.isViewOnly && viewOnlyData == null) { @@ -223,6 +225,13 @@ abstract class Wallet { ); } + if (cryptonoteKeyRestoreData != null) { + await secureStorageInterface.write( + key: keysRestoreDataKey(walletId: walletInfo.walletId), + value: cryptonoteKeyRestoreData.toJsonEncodedString(), + ); + } + // Store in db after wallet creation await wallet.mainDB.isar.writeTxn(() async { await wallet.mainDB.isar.walletInfo.put(walletInfo); @@ -321,6 +330,27 @@ abstract class Wallet { static String getViewOnlyWalletDataSecStoreKey({required String walletId}) => "${walletId}_viewOnlyWalletData"; + // secure storage key + 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/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/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..6539aaa446 --- /dev/null +++ b/test/models/keys/cryptonote_key_restore_data_test.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; + +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( + 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", + 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 new file mode 100644 index 0000000000..83a90a52fc --- /dev/null +++ b/test/models/keys/cw_key_data_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/keys/cw_key_data.dart'; + +void main() { + 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.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.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, + ); + }); +} 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 new file mode 100644 index 0000000000..78f1436d9f --- /dev/null +++ b/test/pages/delete_wallet_recovery_phrase_view_test.dart @@ -0,0 +1,133 @@ +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/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'; +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 private-key recovery material", (tester) async { + Util.screenWidth = 400; + await tester.pumpWidget( + testApp( + DeleteWalletRecoveryPhraseView( + recoveryMaterial: PrivateKeyWalletRecoveryMaterial( + walletId: walletId, + keyData: keyData, + ), + ), + ), + ); + + expect(find.byType(CNWalletKeys), findsOneWidget); + expect(find.byType(MnemonicTable), findsNothing); + expect(find.text("Wallet Keys"), findsOneWidget); + }); + + testWidgets("shows keys directly in wallet backup", (tester) async { + Util.screenWidth = 400; + await tester.pumpWidget( + testApp( + WalletBackupView( + recoveryMaterial: PrivateKeyWalletRecoveryMaterial( + walletId: walletId, + 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( + recoveryMaterial: PrivateKeyWalletRecoveryMaterial( + walletId: walletId, + keyData: keyData, + ), + ), + ), + ); + + expect(find.byType(CNWalletKeys), findsOneWidget); + expect(find.byType(MnemonicTable), findsNothing); + }); + + testWidgets("keeps mnemonic desktop deletion", (tester) async { + await tester.pumpWidget( + testApp( + 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 new file mode 100644 index 0000000000..04deb6c5e7 --- /dev/null +++ b/test/wallets/isar/models/wallet_info_test.dart @@ -0,0 +1,43 @@ +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.recoveryType", () { + test("defaults to mnemonic", () { + final info = WalletInfo.createNew( + coin: AppConfig.coins.first, + name: "wallet", + ); + + 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", + otherDataJsonString: jsonEncode({ + WalletInfoKeys.isRestoredFromKeysKey: true, + }), + ); + + 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))); + }); +} 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);