diff --git a/lib/models/keys/view_only_wallet_data.dart b/lib/models/keys/view_only_wallet_data.dart new file mode 100644 index 000000000..92c23b082 --- /dev/null +++ b/lib/models/keys/view_only_wallet_data.dart @@ -0,0 +1,164 @@ +import 'dart:convert'; + +import '../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; +import 'key_data_interface.dart'; + +// do not remove or change the order of these enum values +enum ViewOnlyWalletType { + cryptonote, + addressOnly, + xPub; +} + +sealed class ViewOnlyWalletData with KeyDataInterface { + @override + final String walletId; + + ViewOnlyWalletType get type; + + ViewOnlyWalletData({ + required this.walletId, + }); + + static ViewOnlyWalletData fromJsonEncodedString( + String jsonEncodedString, { + required String walletId, + }) { + final map = jsonDecode(jsonEncodedString) as Map; + final json = Map.from(map); + final type = ViewOnlyWalletType.values[json["type"] as int]; + + switch (type) { + case ViewOnlyWalletType.cryptonote: + return CryptonoteViewOnlyWalletData.fromJsonEncodedString( + jsonEncodedString, + walletId: walletId, + ); + + case ViewOnlyWalletType.addressOnly: + return AddressViewOnlyWalletData.fromJsonEncodedString( + jsonEncodedString, + walletId: walletId, + ); + + case ViewOnlyWalletType.xPub: + return ExtendedKeysViewOnlyWalletData.fromJsonEncodedString( + jsonEncodedString, + walletId: walletId, + ); + } + } + + String toJsonEncodedString(); +} + +class CryptonoteViewOnlyWalletData extends ViewOnlyWalletData { + @override + final type = ViewOnlyWalletType.cryptonote; + + final String address; + final String privateViewKey; + + CryptonoteViewOnlyWalletData({ + required super.walletId, + required this.address, + required this.privateViewKey, + }); + + static CryptonoteViewOnlyWalletData fromJsonEncodedString( + String jsonEncodedString, { + required String walletId, + }) { + final map = jsonDecode(jsonEncodedString) as Map; + final json = Map.from(map); + + return CryptonoteViewOnlyWalletData( + walletId: walletId, + address: json["address"] as String, + privateViewKey: json["privateViewKey"] as String, + ); + } + + @override + String toJsonEncodedString() => jsonEncode({ + "type": type.index, + "address": address, + "privateViewKey": privateViewKey, + }); +} + +class AddressViewOnlyWalletData extends ViewOnlyWalletData { + @override + final type = ViewOnlyWalletType.addressOnly; + + final String address; + + AddressViewOnlyWalletData({ + required super.walletId, + required this.address, + }); + + static AddressViewOnlyWalletData fromJsonEncodedString( + String jsonEncodedString, { + required String walletId, + }) { + final map = jsonDecode(jsonEncodedString) as Map; + final json = Map.from(map); + + return AddressViewOnlyWalletData( + walletId: walletId, + address: json["address"] as String, + ); + } + + @override + String toJsonEncodedString() => jsonEncode({ + "type": type.index, + "address": address, + }); +} + +class ExtendedKeysViewOnlyWalletData extends ViewOnlyWalletData { + @override + final type = ViewOnlyWalletType.xPub; + + final List xPubs; + + ExtendedKeysViewOnlyWalletData({ + required super.walletId, + required List xPubs, + }) : xPubs = List.unmodifiable(xPubs); + + static ExtendedKeysViewOnlyWalletData fromJsonEncodedString( + String jsonEncodedString, { + required String walletId, + }) { + final map = jsonDecode(jsonEncodedString) as Map; + final json = Map.from(map); + + return ExtendedKeysViewOnlyWalletData( + walletId: walletId, + xPubs: List>.from((json["xPubs"] as List)) + .map( + (e) => XPub( + path: e["path"] as String, + encoded: e["encoded"] as String, + ), + ) + .toList(growable: false), + ); + } + + @override + String toJsonEncodedString() => jsonEncode({ + "type": type.index, + "xPubs": [ + ...xPubs.map( + (e) => { + "path": e.path, + "encoded": e.encoded, + }, + ), + ], + }); +} diff --git a/lib/pages/add_wallet_views/name_your_wallet_view/name_your_wallet_view.dart b/lib/pages/add_wallet_views/name_your_wallet_view/name_your_wallet_view.dart index 29742f251..f17ed06ff 100644 --- a/lib/pages/add_wallet_views/name_your_wallet_view/name_your_wallet_view.dart +++ b/lib/pages/add_wallet_views/name_your_wallet_view/name_your_wallet_view.dart @@ -25,6 +25,7 @@ import '../../../utilities/name_generator.dart'; import '../../../utilities/text_styles.dart'; 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/frost_currency.dart'; import '../../../wallets/isar/models/wallet_info.dart'; import '../../../widgets/background.dart'; @@ -104,7 +105,9 @@ class _NameYourWalletViewState extends ConsumerState { case AddWalletType.New: unawaited( Navigator.of(context).pushNamed( - coin.hasMnemonicPassphraseSupport + coin.possibleMnemonicLengths.length > 1 || + coin.hasMnemonicPassphraseSupport || + coin is ViewOnlyOptionCurrencyInterface ? NewWalletOptionsView.routeName : NewWalletRecoveryPhraseWarningView.routeName, arguments: Tuple2( diff --git a/lib/pages/add_wallet_views/new_wallet_options/new_wallet_options_view.dart b/lib/pages/add_wallet_views/new_wallet_options/new_wallet_options_view.dart index 1008fd5df..bfe41c3fe 100644 --- a/lib/pages/add_wallet_views/new_wallet_options/new_wallet_options_view.dart +++ b/lib/pages/add_wallet_views/new_wallet_options/new_wallet_options_view.dart @@ -12,9 +12,11 @@ import '../../../utilities/constants.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/crypto_currency/interfaces/view_only_option_currency_interface.dart'; import '../../../widgets/background.dart'; import '../../../widgets/conditional_parent.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../widgets/custom_buttons/checkbox_text_button.dart'; import '../../../widgets/desktop/desktop_app_bar.dart'; import '../../../widgets/desktop/desktop_scaffold.dart'; import '../../../widgets/desktop/primary_button.dart'; @@ -25,8 +27,12 @@ import '../new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_wa import '../restore_wallet_view/restore_options_view/sub_widgets/mobile_mnemonic_length_selector.dart'; import '../restore_wallet_view/sub_widgets/mnemonic_word_count_select_sheet.dart'; -final pNewWalletOptions = - StateProvider<({String mnemonicPassphrase, int mnemonicWordsCount})?>( +final pNewWalletOptions = StateProvider< + ({ + String mnemonicPassphrase, + int mnemonicWordsCount, + bool convertToViewOnly, + })?>( (ref) => null, ); @@ -59,6 +65,8 @@ class _NewWalletOptionsViewState extends ConsumerState { bool hidePassword = true; NewWalletOptions _selectedOptions = NewWalletOptions.Default; + bool _convertToViewOnly = false; + @override void initState() { passwordController = TextEditingController(); @@ -210,7 +218,7 @@ class _NewWalletOptionsViewState extends ConsumerState { if (_selectedOptions == NewWalletOptions.Advanced) Column( children: [ - if (Util.isDesktop) + if (Util.isDesktop && lengths.length > 1) DropdownButtonHideUnderline( child: DropdownButton2( value: ref @@ -265,7 +273,7 @@ class _NewWalletOptionsViewState extends ConsumerState { ), ), ), - if (!Util.isDesktop) + if (!Util.isDesktop && lengths.length > 1) MobileMnemonicLengthSelector( chooseMnemonicLength: () { showModalBottomSheet( @@ -284,91 +292,109 @@ class _NewWalletOptionsViewState extends ConsumerState { ); }, ), - const SizedBox( - height: 24, - ), - RoundedWhiteContainer( - child: Center( - child: Text( - "You may add a BIP39 passphrase. This is optional. " - "You will need BOTH your seed and your passphrase to recover the wallet.", - style: Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, - ) - : STextStyles.itemSubtitle(context), + if (widget.coin.hasMnemonicPassphraseSupport) + const SizedBox( + height: 24, + ), + if (widget.coin.hasMnemonicPassphraseSupport) + RoundedWhiteContainer( + child: Center( + child: Text( + "You may add a BIP39 passphrase. This is optional. " + "You will need BOTH your seed and your passphrase to recover the wallet.", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ) + : STextStyles.itemSubtitle(context), + ), ), ), - ), - const SizedBox( - height: 8, - ), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, + if (widget.coin.hasMnemonicPassphraseSupport) + const SizedBox( + height: 8, ), - child: TextField( - key: const Key("mnemonicPassphraseFieldKey1"), - focusNode: passwordFocusNode, - controller: passwordController, - style: Util.isDesktop - ? STextStyles.desktopTextMedium(context).copyWith( - height: 2, - ) - : STextStyles.field(context), - obscureText: hidePassword, - enableSuggestions: false, - autocorrect: false, - decoration: standardInputDecoration( - "BIP39 passphrase", - passwordFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: ConditionalParent( - condition: Util.isDesktop, - builder: (child) => SizedBox( - height: 70, - child: child, - ), - child: Row( - children: [ - SizedBox( - width: Util.isDesktop ? 24 : 16, - ), - GestureDetector( - key: const Key( - "mnemonicPassphraseFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword - ? Assets.svg.eye - : Assets.svg.eyeSlash, - color: Theme.of(context) - .extension()! - .textDark3, + if (widget.coin.hasMnemonicPassphraseSupport) + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("mnemonicPassphraseFieldKey1"), + focusNode: passwordFocusNode, + controller: passwordController, + style: Util.isDesktop + ? STextStyles.desktopTextMedium(context).copyWith( + height: 2, + ) + : STextStyles.field(context), + obscureText: hidePassword, + enableSuggestions: false, + autocorrect: false, + decoration: standardInputDecoration( + "BIP39 passphrase", + passwordFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => SizedBox( + height: 70, + child: child, + ), + child: Row( + children: [ + SizedBox( width: Util.isDesktop ? 24 : 16, - height: Util.isDesktop ? 24 : 16, ), - ), - const SizedBox( - width: 12, - ), - ], + GestureDetector( + key: const Key( + "mnemonicPassphraseFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of(context) + .extension()! + .textDark3, + width: Util.isDesktop ? 24 : 16, + height: Util.isDesktop ? 24 : 16, + ), + ), + const SizedBox( + width: 12, + ), + ], + ), ), ), ), ), ), - ), + if (widget.coin is ViewOnlyOptionCurrencyInterface) + const SizedBox( + height: 24, + ), + if (widget.coin is ViewOnlyOptionCurrencyInterface) + CheckboxTextButton( + label: "Convert to view only wallet. " + "You will only be shown the seed phrase once. " + "Save it somewhere. " + "If you lose it you will lose access to any funds in this wallet.", + onChanged: (value) { + _convertToViewOnly = value; + }, + ), ], ), if (!Util.isDesktop) const Spacer(), @@ -383,6 +409,7 @@ class _NewWalletOptionsViewState extends ConsumerState { mnemonicWordsCount: ref.read(mnemonicWordCountStateProvider.state).state, mnemonicPassphrase: passwordController.text, + convertToViewOnly: _convertToViewOnly, ); } else { ref.read(pNewWalletOptions.notifier).state = null; diff --git a/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart b/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart index 2b9d787e3..73acccb9d 100644 --- a/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart +++ b/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart @@ -582,7 +582,12 @@ class _NewWalletRecoveryPhraseWarningViewState ) .state! .mnemonicPassphrase; - } else {} + } else { + // this may not be epiccash specific? + if (coin is Epiccash) { + mnemonicPassphrase = ""; + } + } wordCount = ref .read( 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 9d2ac8c1d..384cacb0c 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 @@ -24,6 +24,7 @@ import '../../../../utilities/text_styles.dart'; 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 '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../../widgets/custom_buttons/checkbox_text_button.dart'; @@ -656,7 +657,7 @@ class ViewOnlyRestoreOption extends StatefulWidget { class _ViewOnlyRestoreOptionState extends State { @override Widget build(BuildContext context) { - final showDateOption = widget.coin is ViewOnlyOptionCurrencyInterface; + final showDateOption = widget.coin is CryptonoteCurrency; return Column( children: [ if (showDateOption) 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 5f2bd08b0..3a7131762 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 @@ -4,32 +4,41 @@ import 'dart:io'; import 'package:cs_monero/src/deprecated/get_height_by_date.dart' as cs_monero_deprecated; +import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; +import '../../../models/keys/view_only_wallet_data.dart'; import '../../../pages_desktop_specific/desktop_home_view.dart'; import '../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; import '../../../providers/db/main_db_provider.dart'; import '../../../providers/global/secure_store_provider.dart'; import '../../../providers/providers.dart'; import '../../../themes/stack_colors.dart'; +import '../../../utilities/assets.dart'; import '../../../utilities/barcode_scanner_interface.dart'; import '../../../utilities/clipboard_interface.dart'; +import '../../../utilities/constants.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/crypto_currency/interfaces/electrumx_currency_interface.dart'; +import '../../../wallets/crypto_currency/intermediate/bip39_hd_currency.dart'; +import '../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; import '../../../wallets/isar/models/wallet_info.dart'; import '../../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../../wallets/wallet/impl/monero_wallet.dart'; import '../../../wallets/wallet/impl/wownero_wallet.dart'; import '../../../wallets/wallet/wallet.dart'; -import '../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../widgets/desktop/desktop_app_bar.dart'; import '../../../widgets/desktop/desktop_scaffold.dart'; import '../../../widgets/desktop/primary_button.dart'; import '../../../widgets/stack_text_field.dart'; +import '../../../widgets/toggle.dart'; import '../../home_view/home_view.dart'; import 'confirm_recovery_dialog.dart'; import 'sub_widgets/restore_failed_dialog.dart'; @@ -66,7 +75,10 @@ class _RestoreViewOnlyWalletViewState late final TextEditingController addressController; late final TextEditingController viewKeyController; + late String _currentDropDownValue; + bool _enableRestoreButton = false; + bool _addressOnly = false; bool _buttonLock = false; @@ -106,30 +118,43 @@ class _RestoreViewOnlyWalletViewState WalletInfoKeys.isViewOnlyKey: true, }; - if (widget.restoreFromDate != null) { - if (widget.coin is Monero) { - height = cs_monero_deprecated.getMoneroHeightByDate( - date: widget.restoreFromDate!, + final ViewOnlyWalletType viewOnlyWalletType; + if (widget.coin is Bip39HDCurrency) { + if (widget.coin is Firo) { + otherDataJson.addAll( + { + WalletInfoKeys.lelantusCoinIsarRescanRequired: false, + WalletInfoKeys.enableLelantusScanning: + widget.enableLelantusScanning, + }, ); } - if (widget.coin is Wownero) { - height = cs_monero_deprecated.getWowneroHeightByDate( - date: widget.restoreFromDate!, - ); + viewOnlyWalletType = _addressOnly + ? ViewOnlyWalletType.addressOnly + : ViewOnlyWalletType.xPub; + } else if (widget.coin is CryptonoteCurrency) { + if (widget.restoreFromDate != null) { + if (widget.coin is Monero) { + height = cs_monero_deprecated.getMoneroHeightByDate( + date: widget.restoreFromDate!, + ); + } + if (widget.coin is Wownero) { + height = cs_monero_deprecated.getWowneroHeightByDate( + date: widget.restoreFromDate!, + ); + } + if (height < 0) height = 0; } - if (height < 0) { - height = 0; - } - } - if (widget.coin is Firo) { - otherDataJson.addAll( - { - WalletInfoKeys.lelantusCoinIsarRescanRequired: false, - WalletInfoKeys.enableLelantusScanning: widget.enableLelantusScanning, - }, + viewOnlyWalletType = ViewOnlyWalletType.cryptonote; + } else { + throw Exception( + "Unsupported view only wallet currency type found: ${widget.coin.runtimeType}", ); } + otherDataJson[WalletInfoKeys.viewOnlyTypeIndexKey] = + viewOnlyWalletType.index; if (!Platform.isLinux && !Util.isDesktop) await WakelockPlus.enable(); @@ -166,6 +191,43 @@ class _RestoreViewOnlyWalletViewState ); } + final ViewOnlyWalletData viewOnlyData; + switch (viewOnlyWalletType) { + case ViewOnlyWalletType.cryptonote: + if (addressController.text.isEmpty || + viewKeyController.text.isEmpty) { + throw Exception("Missing address and/or private view key fields"); + } + viewOnlyData = CryptonoteViewOnlyWalletData( + walletId: info.walletId, + address: addressController.text, + privateViewKey: viewKeyController.text, + ); + break; + + case ViewOnlyWalletType.addressOnly: + if (addressController.text.isEmpty) { + throw Exception("Address is empty"); + } + viewOnlyData = AddressViewOnlyWalletData( + walletId: info.walletId, + address: addressController.text, + ); + break; + + case ViewOnlyWalletType.xPub: + viewOnlyData = ExtendedKeysViewOnlyWalletData( + walletId: info.walletId, + xPubs: [ + XPub( + path: _currentDropDownValue, + encoded: viewKeyController.text, + ), + ], + ); + break; + } + var node = ref .read(nodeServiceChangeNotifierProvider) .getPrimaryNodeFor(currency: widget.coin); @@ -185,10 +247,7 @@ class _RestoreViewOnlyWalletViewState secureStorageInterface: ref.read(secureStoreProvider), nodeService: ref.read(nodeServiceChangeNotifierProvider), prefs: ref.read(prefsChangeNotifierProvider), - viewOnlyData: ViewOnlyWalletData( - address: addressController.text, - privateViewKey: viewKeyController.text, - ), + viewOnlyData: viewOnlyData, ); // TODO: extract interface with isRestore param @@ -278,11 +337,27 @@ class _RestoreViewOnlyWalletViewState super.initState(); addressController = TextEditingController(); viewKeyController = TextEditingController(); + + if (widget.coin is Bip39HDCurrency) { + _currentDropDownValue = (widget.coin as Bip39HDCurrency) + .supportedHardenedDerivationPaths + .last; + } + } + + @override + void dispose() { + addressController.dispose(); + viewKeyController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; + + final isElectrumX = widget.coin is ElectrumXCurrencyInterface; + return MasterScaffold( isDesktop: isDesktop, appBar: isDesktop @@ -339,32 +414,156 @@ class _RestoreViewOnlyWalletViewState ? STextStyles.desktopH2(context) : STextStyles.pageTitleH1(context), ), + if (isElectrumX) + SizedBox( + height: isDesktop ? 24 : 16, + ), + if (isElectrumX) + SizedBox( + height: isDesktop ? 56 : 48, + width: isDesktop ? 490 : null, + child: Toggle( + key: UniqueKey(), + onText: "Extended pub key", + offText: "Single address", + onColor: Theme.of(context) + .extension()! + .popupBG, + offColor: Theme.of(context) + .extension()! + .textFieldDefaultBG, + isOn: _addressOnly, + onValueChanged: (value) { + setState(() { + _addressOnly = value; + }); + }, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + ), SizedBox( height: isDesktop ? 24 : 16, ), - FullTextField( - label: "Address", - controller: addressController, - onChanged: (newValue) { - setState(() { - _enableRestoreButton = newValue.isNotEmpty && - viewKeyController.text.isNotEmpty; - }); - }, - ), - SizedBox( - height: isDesktop ? 16 : 12, - ), - FullTextField( - label: "View Key", - controller: viewKeyController, - onChanged: (value) { - setState(() { - _enableRestoreButton = value.isNotEmpty && - addressController.text.isNotEmpty; - }); - }, - ), + if (!isElectrumX || _addressOnly) + FullTextField( + key: const Key("viewOnlyAddressRestoreFieldKey"), + label: "Address", + controller: addressController, + onChanged: (newValue) { + if (isElectrumX) { + viewKeyController.text = ""; + setState(() { + _enableRestoreButton = newValue.isNotEmpty; + }); + } else { + setState(() { + _enableRestoreButton = newValue.isNotEmpty && + viewKeyController.text.isNotEmpty; + }); + } + }, + ), + if (!isElectrumX) + SizedBox( + height: isDesktop ? 16 : 12, + ), + if (isElectrumX && !_addressOnly) + DropdownButtonHideUnderline( + child: DropdownButton2( + value: _currentDropDownValue, + items: [ + ...(widget.coin as Bip39HDCurrency) + .supportedHardenedDerivationPaths + .map( + (e) => DropdownMenuItem( + value: e, + child: Text( + e, + style: STextStyles.w500_14(context), + ), + ), + ), + ], + onChanged: (value) { + if (value is String) { + setState(() { + _currentDropDownValue = value; + }); + } + }, + isExpanded: true, + buttonStyleData: ButtonStyleData( + decoration: BoxDecoration( + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + iconStyleData: IconStyleData( + icon: Padding( + padding: const EdgeInsets.only(right: 10), + child: SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + height: 6, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + ), + ), + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, -10), + elevation: 0, + decoration: BoxDecoration( + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + ), + ), + if (isElectrumX && !_addressOnly) + SizedBox( + height: isDesktop ? 16 : 12, + ), + if (!isElectrumX || !_addressOnly) + FullTextField( + key: const Key("viewOnlyKeyRestoreFieldKey"), + label: + "${isElectrumX ? "Extended" : "Private View"} Key", + controller: viewKeyController, + onChanged: (value) { + if (isElectrumX) { + addressController.text = ""; + setState(() { + _enableRestoreButton = value.isNotEmpty; + }); + } else { + setState(() { + _enableRestoreButton = value.isNotEmpty && + addressController.text.isNotEmpty; + }); + } + }, + ), if (!isDesktop) const Spacer(), SizedBox( height: isDesktop ? 24 : 16, diff --git a/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart b/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart index 2a7a9fcae..f0e5cab74 100644 --- a/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart +++ b/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart @@ -9,13 +9,17 @@ */ import 'dart:async'; +import 'dart:convert'; import 'dart:math'; +import 'package:cs_monero/src/deprecated/get_height_by_date.dart' + as cs_monero_deprecated; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tuple/tuple.dart'; +import '../../../models/keys/view_only_wallet_data.dart'; import '../../../notifications/show_flush_bar.dart'; import '../../../pages_desktop_specific/desktop_home_view.dart'; import '../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; @@ -25,14 +29,25 @@ import '../../../providers/providers.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/constants.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/show_loading.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; -import '../../../wallets/crypto_currency/coins/ethereum.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/crypto_currency/intermediate/bip39_hd_currency.dart'; +import '../../../wallets/isar/models/wallet_info.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../wallets/wallet/impl/epiccash_wallet.dart'; +import '../../../wallets/wallet/impl/monero_wallet.dart'; +import '../../../wallets/wallet/impl/wownero_wallet.dart'; +import '../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; import '../../../wallets/wallet/wallet.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../widgets/desktop/desktop_app_bar.dart'; import '../../../widgets/desktop/desktop_scaffold.dart'; +import '../../../widgets/stack_dialog.dart'; import '../../home_view/home_view.dart'; import '../add_token_view/edit_wallet_tokens_view.dart'; import '../new_wallet_options/new_wallet_options_view.dart'; @@ -64,46 +79,25 @@ class _VerifyRecoveryPhraseViewState extends ConsumerState // with WidgetsBindingObserver { - late Wallet _wallet; + late String _walletId; + late CryptoCurrency _coin; late List _mnemonic; late final bool isDesktop; @override void initState() { - _wallet = widget.wallet; + _walletId = widget.wallet.walletId; + _coin = widget.wallet.cryptoCurrency; _mnemonic = widget.mnemonic; isDesktop = Util.isDesktop; - // WidgetsBinding.instance?.addObserver(this); super.initState(); } @override dispose() { - // WidgetsBinding.instance?.removeObserver(this); super.dispose(); } - // @override - // void didChangeAppLifecycleState(AppLifecycleState state) { - // switch (state) { - // case AppLifecycleState.inactive: - // debugPrint( - // "VerifyRecoveryPhraseView ========================= Inactive"); - // break; - // case AppLifecycleState.paused: - // debugPrint("VerifyRecoveryPhraseView ========================= Paused"); - // break; - // case AppLifecycleState.resumed: - // debugPrint( - // "VerifyRecoveryPhraseView ========================= Resumed"); - // break; - // case AppLifecycleState.detached: - // debugPrint( - // "VerifyRecoveryPhraseView ========================= Detached"); - // break; - // } - // } - Future _verifyMnemonicPassphrase() async { final result = await showDialog( context: context, @@ -113,6 +107,153 @@ class _VerifyRecoveryPhraseViewState return result == "verified"; } + Future _convertToViewOnly() async { + int height = 0; + final Map otherDataJson = { + WalletInfoKeys.isViewOnlyKey: true, + }; + + final ViewOnlyWalletType viewOnlyWalletType; + if (widget.wallet is ExtendedKeysInterface) { + if (widget.wallet.cryptoCurrency is Firo) { + otherDataJson.addAll( + { + WalletInfoKeys.lelantusCoinIsarRescanRequired: false, + WalletInfoKeys.enableLelantusScanning: false, + }, + ); + } + viewOnlyWalletType = ViewOnlyWalletType.xPub; + } else if (widget.wallet is LibMoneroWallet) { + if (widget.wallet.cryptoCurrency is Monero) { + height = cs_monero_deprecated.getMoneroHeightByDate( + date: DateTime.now().subtract(const Duration(days: 7)), + ); + } + if (widget.wallet.cryptoCurrency is Wownero) { + height = cs_monero_deprecated.getWowneroHeightByDate( + date: DateTime.now().subtract(const Duration(days: 7)), + ); + } + if (height < 0) height = 0; + + viewOnlyWalletType = ViewOnlyWalletType.cryptonote; + } else { + throw Exception( + "Unsupported view only wallet type found: ${widget.wallet.runtimeType}", + ); + } + + otherDataJson[WalletInfoKeys.viewOnlyTypeIndexKey] = + viewOnlyWalletType.index; + + final voInfo = WalletInfo.createNew( + coin: _coin, + name: widget.wallet.info.name, + restoreHeight: height, + otherDataJsonString: jsonEncode(otherDataJson), + ); + + final ViewOnlyWalletData viewOnlyData; + if (widget.wallet is ExtendedKeysInterface) { + final extendedKeyInfo = + await (widget.wallet as ExtendedKeysInterface).getXPubs(); + final testPath = (_coin as Bip39HDCurrency).constructDerivePath( + derivePathType: (_coin as Bip39HDCurrency).defaultDerivePathType, + chain: 0, + index: 0, + ); + + XPub? xPub; + for (final pub in extendedKeyInfo.xpubs) { + if (testPath.startsWith(pub.path)) { + xPub = pub; + break; + } + } + + if (xPub == null) { + throw Exception("Default derivation path not matched in xPubs"); + } + + viewOnlyData = ExtendedKeysViewOnlyWalletData( + walletId: voInfo.walletId, + xPubs: [xPub], + ); + } else if (widget.wallet is LibMoneroWallet) { + final w = widget.wallet as LibMoneroWallet; + + final info = await w + .hackToCreateNewViewOnlyWalletDataFromNewlyCreatedWalletThisFunctionShouldNotBeCalledUnlessYouKnowWhatYouAreDoing(); + final address = info.$1; + final privateViewKey = info.$2; + + viewOnlyData = CryptonoteViewOnlyWalletData( + walletId: voInfo.walletId, + address: address, + privateViewKey: privateViewKey, + ); + } else { + throw Exception( + "Unsupported view only wallet type found: ${widget.wallet.runtimeType}", + ); + } + + final voWallet = await Wallet.create( + walletInfo: voInfo, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + viewOnlyData: viewOnlyData, + ); + + try { + // TODO: extract interface with isRestore param + switch (voWallet.runtimeType) { + case const (EpiccashWallet): + await (voWallet as EpiccashWallet).init(isRestore: true); + break; + + case const (MoneroWallet): + await (voWallet as MoneroWallet).init(isRestore: true); + break; + + case const (WowneroWallet): + await (voWallet as WowneroWallet).init(isRestore: true); + break; + + default: + await voWallet.init(); + } + + await voWallet.recover(isRescan: false); + + // don't remove this setMnemonicVerified thing + await voWallet.info.setMnemonicVerified( + isar: ref.read(mainDBProvider).isar, + ); + + ref.read(pWallets).addWallet(voWallet); + + await ref.read(pWallets).deleteWallet( + widget.wallet.info, + ref.read(secureStoreProvider), + ); + } catch (e) { + await ref.read(pWallets).deleteWallet( + widget.wallet.info, + ref.read(secureStoreProvider), + ); + await ref.read(pWallets).deleteWallet( + voWallet.info, + ref.read(secureStoreProvider), + ); + + rethrow; + } + } + Future _continue(bool isMatch) async { if (isMatch) { if (ref.read(pNewWalletOptions) != null && @@ -124,11 +265,11 @@ class _VerifyRecoveryPhraseViewState } } - await ref.read(pWalletInfo(_wallet.walletId)).setMnemonicVerified( + await ref.read(pWalletInfo(widget.wallet.walletId)).setMnemonicVerified( isar: ref.read(mainDBProvider).isar, ); - ref.read(pWallets).addWallet(_wallet); + ref.read(pWallets).addWallet(widget.wallet); final isCreateSpecialEthWallet = ref.read(createSpecialEthWalletRoutingFlag); @@ -142,6 +283,51 @@ class _VerifyRecoveryPhraseViewState .state; } + if (mounted && + ref.read(pNewWalletOptions)?.convertToViewOnly == true && + widget.wallet is ViewOnlyOptionInterface) { + try { + Exception? ex; + await showLoading( + whileFuture: _convertToViewOnly(), + context: context, + message: "Converting to view only wallet", + rootNavigator: Util.isDesktop, + onException: (e) { + ex = e; + }, + ); + + if (ex != null) { + throw ex!; + } + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + if (mounted) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + if (mounted) { + Navigator.of(context).popUntil( + ModalRoute.withName( + NewWalletRecoveryPhraseView.routeName, + ), + ); + } + return; + } + } + if (mounted) { if (isDesktop) { if (isCreateSpecialEthWallet) { @@ -156,7 +342,7 @@ class _VerifyRecoveryPhraseViewState DesktopHomeView.routeName, ), ); - if (widget.wallet.info.coin is Ethereum) { + if (_coin is Ethereum) { unawaited( Navigator.of(context).pushNamed( EditWalletTokensView.routeName, @@ -179,7 +365,7 @@ class _VerifyRecoveryPhraseViewState (route) => false, ), ); - if (widget.wallet.info.coin is Ethereum) { + if (_coin is Ethereum) { unawaited( Navigator.of(context).pushNamed( EditWalletTokensView.routeName, @@ -269,7 +455,7 @@ class _VerifyRecoveryPhraseViewState Future delete() async { await ref.read(pWallets).deleteWallet( - _wallet.info, + widget.wallet.info, ref.read(secureStoreProvider), ); } @@ -299,7 +485,7 @@ class _VerifyRecoveryPhraseViewState trailing: ExitToMyStackButton( onPressed: () async { await delete(); - if (mounted) { + if (context.mounted) { Navigator.of(context).popUntil( ModalRoute.withName(DesktopHomeView.routeName), ); diff --git a/lib/pages/receive_view/addresses/address_details_view.dart b/lib/pages/receive_view/addresses/address_details_view.dart index bac8f2700..25e18becb 100644 --- a/lib/pages/receive_view/addresses/address_details_view.dart +++ b/lib/pages/receive_view/addresses/address_details_view.dart @@ -148,6 +148,7 @@ class _AddressDetailsViewState extends ConsumerState { @override Widget build(BuildContext context) { final coin = ref.watch(pWalletCoin(widget.walletId)); + final wallet = ref.watch(pWallets).getWallet(widget.walletId); return ConditionalParent( condition: !isDesktop, builder: (child) => Background( @@ -383,13 +384,11 @@ class _AddressDetailsViewState extends ConsumerState { detail: address.zSafeFrost.toString(), button: Container(), ), - if (ref.watch(pWallets).getWallet(widget.walletId) - is Bip39HDWallet) + if (wallet is Bip39HDWallet && !wallet.isViewOnly) const _Div( height: 12, ), - if (ref.watch(pWallets).getWallet(widget.walletId) - is Bip39HDWallet) + if (wallet is Bip39HDWallet && !wallet.isViewOnly) AddressPrivateKey( walletId: widget.walletId, address: address, diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index cbfd1763e..8b7e35373 100644 --- a/lib/pages/receive_view/receive_view.dart +++ b/lib/pages/receive_view/receive_view.dart @@ -18,6 +18,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:isar/isar.dart'; import '../../models/isar/models/isar_models.dart'; +import '../../models/keys/view_only_wallet_data.dart'; import '../../notifications/show_flush_bar.dart'; import '../../providers/db/main_db_provider.dart'; import '../../providers/providers.dart'; @@ -36,6 +37,7 @@ import '../../wallets/wallet/intermediate/bip39_hd_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/bcash_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/multi_address_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'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -189,6 +191,16 @@ class _ReceiveViewState extends ConsumerState { clipboard = widget.clipboard; final wallet = ref.read(pWallets).getWallet(walletId); _supportsSpark = wallet is SparkInterface; + + if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { + _showMultiType = false; + } else { + _showMultiType = _supportsSpark || + (wallet is! BCashInterface && + wallet is Bip39HDWallet && + wallet.supportedAddressTypes.length > 1); + } + _showMultiType = _supportsSpark || (wallet is! BCashInterface && wallet is Bip39HDWallet && @@ -265,6 +277,18 @@ class _ReceiveViewState extends ConsumerState { address = ref.watch(pWalletReceivingAddress(walletId)); } + final wallet = + ref.watch(pWallets.select((value) => value.getWallet(walletId))); + + final bool canGen; + if (wallet is ViewOnlyOptionInterface && + wallet.isViewOnly && + wallet.viewOnlyType == ViewOnlyWalletType.addressOnly) { + canGen = false; + } else { + canGen = (wallet is MultiAddressInterface || _supportsSpark); + } + return Background( child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, @@ -553,17 +577,11 @@ class _ReceiveViewState extends ConsumerState { ); }, ), - if (ref.watch( - pWallets.select((value) => value.getWallet(walletId)), - ) is MultiAddressInterface || - _supportsSpark) + if (canGen) const SizedBox( height: 12, ), - if (ref.watch( - pWallets.select((value) => value.getWallet(walletId)), - ) is MultiAddressInterface || - _supportsSpark) + if (canGen) SecondaryButton( label: "Generate new address", onPressed: _supportsSpark && 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 32430d228..6d99938b3 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 @@ -27,6 +27,7 @@ import '../../../../../models/exchange/change_now/exchange_transaction.dart'; 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/node_model.dart'; import '../../../../../models/stack_restoring_ui_state.dart'; import '../../../../../models/trade_wallet_lookup.dart'; @@ -58,6 +59,7 @@ import '../../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; import '../../../../../wallets/wallet/wallet.dart'; import '../../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; import '../../../../../wallets/wallet/wallet_mixin_interfaces/private_key_interface.dart'; +import '../../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; class PreRestoreState { final Set walletIds; @@ -312,7 +314,10 @@ abstract class SWB { backupWallet['isFavorite'] = wallet.info.isFavourite; backupWallet['otherDataJsonString'] = wallet.info.otherDataJsonString; - if (wallet is MnemonicInterface) { + if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { + backupWallet['viewOnlyWalletDataKey'] = + (await wallet.getViewOnlyWalletData()).toJsonEncodedString(); + } else if (wallet is MnemonicInterface) { backupWallet['mnemonic'] = await wallet.getMnemonic(); backupWallet['mnemonicPassphrase'] = await wallet.getMnemonicPassphrase(); @@ -419,7 +424,16 @@ abstract class SWB { String? mnemonic, mnemonicPassphrase, privateKey; - if (walletbackup['mnemonic'] == null) { + ViewOnlyWalletData? viewOnlyData; + if (info.isViewOnly) { + final viewOnlyDataEncoded = + walletbackup['viewOnlyWalletDataKey'] as String; + + viewOnlyData = ViewOnlyWalletData.fromJsonEncodedString( + viewOnlyDataEncoded, + walletId: info.walletId, + ); + } else if (walletbackup['mnemonic'] == null) { // probably private key based if (walletbackup['privateKey'] != null) { privateKey = walletbackup['privateKey'] as String; @@ -486,6 +500,7 @@ abstract class SWB { mnemonic: mnemonic, mnemonicPassphrase: mnemonicPassphrase, privateKey: privateKey, + viewOnlyData: viewOnlyData, ); if (wallet is MoneroWallet /*|| wallet is WowneroWallet doesn't work.*/) { diff --git a/lib/pages/settings_views/sub_widgets/view_only_wallet_data_widget.dart b/lib/pages/settings_views/sub_widgets/view_only_wallet_data_widget.dart new file mode 100644 index 000000000..265986087 --- /dev/null +++ b/lib/pages/settings_views/sub_widgets/view_only_wallet_data_widget.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import '../../../models/keys/view_only_wallet_data.dart'; +import '../../../utilities/util.dart'; +import '../../../widgets/custom_buttons/simple_copy_button.dart'; +import '../../../widgets/detail_item.dart'; +import '../../wallet_view/transaction_views/transaction_details_view.dart'; + +class ViewOnlyWalletDataWidget extends StatelessWidget { + const ViewOnlyWalletDataWidget({ + super.key, + required this.data, + }); + + final ViewOnlyWalletData data; + + @override + Widget build(BuildContext context) { + return switch (data) { + final CryptonoteViewOnlyWalletData e => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DetailItem( + title: "Address", + detail: e.address, + button: Util.isDesktop + ? IconCopyButton( + data: e.address, + ) + : SimpleCopyButton( + data: e.address, + ), + ), + const SizedBox( + height: 16, + ), + DetailItem( + title: "Private view key", + detail: e.privateViewKey, + button: Util.isDesktop + ? IconCopyButton( + data: e.privateViewKey, + ) + : SimpleCopyButton( + data: e.privateViewKey, + ), + ), + ], + ), + final AddressViewOnlyWalletData e => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DetailItem( + title: "Address", + detail: e.address, + button: Util.isDesktop + ? IconCopyButton( + data: e.address, + ) + : SimpleCopyButton( + data: e.address, + ), + ), + ], + ), + final ExtendedKeysViewOnlyWalletData e => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...e.xPubs.map( + (xPub) => DetailItem( + title: xPub.path, + detail: xPub.encoded, + button: Util.isDesktop + ? IconCopyButton( + data: xPub.encoded, + ) + : SimpleCopyButton( + data: xPub.encoded, + ), + ), + ), + ], + ), + }; + } +} 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 95538ed10..aa7ea1e23 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 @@ -17,6 +17,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; 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/xpriv_data.dart'; import '../../../../notifications/show_flush_bar.dart'; import '../../../../themes/stack_colors.dart'; @@ -39,6 +40,7 @@ import '../../../../widgets/rounded_white_container.dart'; import '../../../../widgets/stack_dialog.dart'; import '../../../add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart'; import '../../../wallet_view/transaction_views/transaction_details_view.dart'; +import '../../sub_widgets/view_only_wallet_data_widget.dart'; import 'cn_wallet_keys.dart'; import 'wallet_xprivs.dart'; @@ -426,7 +428,7 @@ class _FrostKeys extends StatelessWidget { } } -class MobileKeyDataView extends StatelessWidget { +class MobileKeyDataView extends ConsumerWidget { const MobileKeyDataView({ super.key, required this.walletId, @@ -441,7 +443,7 @@ class MobileKeyDataView extends StatelessWidget { final KeyDataInterface keyData; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return Background( child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, @@ -455,6 +457,7 @@ class MobileKeyDataView extends StatelessWidget { "Wallet ${switch (keyData.runtimeType) { const (XPrivData) => "xpriv(s)", const (CWKeyData) => "keys", + const (ViewOnlyWalletData) => "keys", _ => throw UnimplementedError( "Don't forget to add your KeyDataInterface here!", ), @@ -483,6 +486,10 @@ class MobileKeyDataView extends StatelessWidget { walletId: walletId, cwKeyData: keyData as CWKeyData, ), + const (ViewOnlyWalletData) => + ViewOnlyWalletDataWidget( + data: keyData as ViewOnlyWalletData, + ), _ => throw UnimplementedError( "Don't forget to add your KeyDataInterface here!", ), diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_xprivs.dart b/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_xprivs.dart index 594db33a7..264032961 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_xprivs.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_xprivs.dart @@ -49,7 +49,7 @@ class WalletXPrivsState extends ConsumerState { late String _currentDropDownValue; String _current(String key) => - widget.xprivData.xprivs.firstWhere((e) => e.path == key).xpriv; + widget.xprivData.xprivs.firstWhere((e) => e.path == key).encoded; Future _copy() async { await widget.clipboardInterface.setData( 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 494834b02..c095d4df1 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 @@ -19,6 +19,7 @@ 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 '../../../notifications/show_flush_bar.dart'; import '../../../providers/global/wallets_provider.dart'; import '../../../providers/ui/transaction_filter_provider.dart'; @@ -99,8 +100,13 @@ class _WalletSettingsViewState extends ConsumerState { void initState() { walletId = widget.walletId; coin = widget.coin; - xPubEnabled = - ref.read(pWallets).getWallet(walletId) is ExtendedKeysInterface; + + final wallet = ref.read(pWallets).getWallet(walletId); + if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { + xPubEnabled = false; + } else { + xPubEnabled = wallet is ExtendedKeysInterface; + } xpub = ""; @@ -166,6 +172,15 @@ class _WalletSettingsViewState extends ConsumerState { @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + final wallet = ref.read(pWallets).getWallet(widget.walletId); + + bool canBackup = true; + if (wallet is ViewOnlyOptionInterface && + wallet.isViewOnly && + wallet.viewOnlyType == ViewOnlyWalletType.addressOnly) { + canBackup = false; + } + return Background( child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, @@ -248,146 +263,154 @@ class _WalletSettingsViewState extends ConsumerState { ); }, ), - const SizedBox( - height: 8, - ), - Consumer( - builder: (_, ref, __) { - return SettingsListButton( - iconAssetName: Assets.svg.lock, - iconSize: 16, - title: "Wallet backup", - onPressed: () async { - final wallet = ref - .read(pWallets) - .getWallet(widget.walletId); + if (canBackup) + const SizedBox( + height: 8, + ), + if (canBackup) + Consumer( + builder: (_, ref, __) { + return SettingsListButton( + iconAssetName: Assets.svg.lock, + iconSize: 16, + title: "Wallet backup", + onPressed: () async { + // TODO: [prio=med] take wallets that don't have a mnemonic into account - // TODO: [prio=med] take wallets that don't have a mnemonic into account - - List? mnemonic; - ({ - String myName, - String config, - String keys, + List? mnemonic; ({ + String myName, String config, - String keys - })? prevGen, - })? frostWalletData; - ViewOnlyWalletData? voData; - if (wallet is BitcoinFrostWallet) { - final futures = [ - wallet.getSerializedKeys(), - wallet.getMultisigConfig(), - wallet.getSerializedKeysPrevGen(), - wallet.getMultisigConfigPrevGen(), - ]; + 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); + 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]!, - ), - ); + 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) { + mnemonic = await wallet + .getMnemonicAsWords(); + } + } } - } else { + + KeyDataInterface? keyData; if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { - voData = await wallet + keyData = await wallet .getViewOnlyWalletData(); } else if (wallet - is MnemonicInterface) { - mnemonic = await wallet - .getMnemonicAsWords(); + is ExtendedKeysInterface) { + keyData = await wallet.getXPrivs(); + } else if (wallet + is LibMoneroWallet) { + keyData = await wallet.getKeys(); } - } - KeyDataInterface? keyData; - if (wallet is ExtendedKeysInterface) { - keyData = await wallet.getXPrivs(); - } else if (wallet is LibMoneroWallet) { - keyData = await wallet.getKeys(); - } - - if (context.mounted) { - if (voData != null) { - await Navigator.push( - context, - RouteGenerator.getRoute( - shouldUseMaterialRoute: - RouteGenerator - .useMaterialPageRoute, - builder: (_) => LockscreenView( - routeOnSuccessArguments: ( - walletId: walletId, - keyData: keyData, + if (context.mounted) { + if (keyData != null && + wallet + is ViewOnlyOptionInterface) { + await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: + RouteGenerator + .useMaterialPageRoute, + builder: (_) => + LockscreenView( + routeOnSuccessArguments: ( + walletId: walletId, + keyData: keyData, + ), + showBackButton: true, + routeOnSuccess: + MobileKeyDataView + .routeName, + biometricsCancelButtonString: + "CANCEL", + biometricsLocalizedReason: + "Authenticate to view recovery data", + biometricsAuthenticationTitle: + "View recovery data", ), - showBackButton: true, - routeOnSuccess: - MobileKeyDataView - .routeName, - biometricsCancelButtonString: - "CANCEL", - biometricsLocalizedReason: - "Authenticate to view recovery data", - biometricsAuthenticationTitle: - "View recovery data", - ), - settings: const RouteSettings( - name: - "/viewRecoveryDataLockscreen", - ), - ), - ); - } else { - await Navigator.push( - context, - RouteGenerator.getRoute( - shouldUseMaterialRoute: - RouteGenerator - .useMaterialPageRoute, - builder: (_) => LockscreenView( - routeOnSuccessArguments: ( - walletId: walletId, - mnemonic: mnemonic ?? [], - frostWalletData: - frostWalletData, - keyData: keyData, + settings: const RouteSettings( + name: + "/viewRecoveryDataLockscreen", ), - showBackButton: true, - routeOnSuccess: - WalletBackupView - .routeName, - biometricsCancelButtonString: - "CANCEL", - biometricsLocalizedReason: - "Authenticate to view recovery phrase", - biometricsAuthenticationTitle: - "View recovery phrase", ), - settings: const RouteSettings( - name: - "/viewRecoverPhraseLockscreen", + ); + } else { + await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: + RouteGenerator + .useMaterialPageRoute, + builder: (_) => + LockscreenView( + routeOnSuccessArguments: ( + walletId: walletId, + mnemonic: mnemonic ?? [], + frostWalletData: + frostWalletData, + keyData: keyData, + ), + showBackButton: true, + routeOnSuccess: + WalletBackupView + .routeName, + biometricsCancelButtonString: + "CANCEL", + biometricsLocalizedReason: + "Authenticate to view recovery phrase", + biometricsAuthenticationTitle: + "View recovery phrase", + ), + settings: const RouteSettings( + name: + "/viewRecoverPhraseLockscreen", + ), ), - ), - ); + ); + } } - } - }, - ); - }, - ), + }, + ); + }, + ), const SizedBox( height: 8, ), diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart index cfa60e76c..d2d2bef2a 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart @@ -2,6 +2,7 @@ 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 '../../../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart'; import '../../../../providers/global/secure_store_provider.dart'; import '../../../../providers/global/wallets_provider.dart'; @@ -10,16 +11,13 @@ import '../../../../themes/stack_colors.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; -import '../../../../widgets/custom_buttons/simple_copy_button.dart'; import '../../../../widgets/desktop/primary_button.dart'; -import '../../../../widgets/detail_item.dart'; import '../../../../widgets/rounded_white_container.dart'; import '../../../../widgets/stack_dialog.dart'; import '../../../home_view/home_view.dart'; -import '../../../wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart'; +import '../../sub_widgets/view_only_wallet_data_widget.dart'; class DeleteViewOnlyWalletKeysView extends ConsumerStatefulWidget { const DeleteViewOnlyWalletKeysView({ @@ -161,34 +159,9 @@ class _DeleteViewOnlyWalletKeysViewState const SizedBox( height: 24, ), - if (widget.data.address != null) - DetailItem( - title: "Address", - detail: widget.data.address!, - button: Util.isDesktop - ? IconCopyButton( - data: widget.data.address!, - ) - : SimpleCopyButton( - data: widget.data.address!, - ), - ), - if (widget.data.address != null) - const SizedBox( - height: 16, - ), - if (widget.data.privateViewKey != null) - DetailItem( - title: "Private view key", - detail: widget.data.privateViewKey!, - button: Util.isDesktop - ? IconCopyButton( - data: widget.data.privateViewKey!, - ) - : SimpleCopyButton( - data: widget.data.privateViewKey!, - ), - ), + ViewOnlyWalletDataWidget( + data: widget.data, + ), if (!Util.isDesktop) const Spacer(), const SizedBox( height: 16, 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 9421b251d..39a723d66 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,6 +12,7 @@ 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 '../../../../providers/providers.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/text_styles.dart'; diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart index e8dc5138e..db6aa1a5d 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart @@ -11,6 +11,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../models/keys/view_only_wallet_data.dart'; import '../../../../providers/db/main_db_provider.dart'; import '../../../../providers/providers.dart'; import '../../../../route_generator.dart'; @@ -23,6 +24,7 @@ import '../../../../wallets/wallet/wallet_mixin_interfaces/lelantus_interface.da import '../../../../wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/rbf_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'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../../widgets/custom_buttons/draggable_switch_button.dart'; @@ -133,6 +135,12 @@ class _WalletSettingsWalletSettingsViewState @override Widget build(BuildContext context) { + final wallet = ref.watch(pWallets).getWallet(widget.walletId); + + final isViewOnlyNoAddressGen = wallet is ViewOnlyOptionInterface && + wallet.isViewOnly && + wallet.viewOnlyType == ViewOnlyWalletType.addressOnly; + return Background( child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, @@ -189,13 +197,11 @@ class _WalletSettingsWalletSettingsViewState ), ), ), - if (ref.watch(pWallets).getWallet(widget.walletId) - is RbfInterface) + if (wallet is RbfInterface) const SizedBox( height: 8, ), - if (ref.watch(pWallets).getWallet(widget.walletId) - is RbfInterface) + if (wallet is RbfInterface) RoundedWhiteContainer( padding: const EdgeInsets.all(0), child: RawMaterialButton( @@ -227,13 +233,11 @@ class _WalletSettingsWalletSettingsViewState ), ), ), - if (ref.watch(pWallets).getWallet(widget.walletId) - is MultiAddressInterface) + if (wallet is MultiAddressInterface && !isViewOnlyNoAddressGen) const SizedBox( height: 8, ), - if (ref.watch(pWallets).getWallet(widget.walletId) - is MultiAddressInterface) + if (wallet is MultiAddressInterface && !isViewOnlyNoAddressGen) RoundedWhiteContainer( padding: const EdgeInsets.all(0), child: RawMaterialButton( @@ -278,13 +282,11 @@ class _WalletSettingsWalletSettingsViewState ), ), ), - if (ref.watch(pWallets).getWallet(widget.walletId) - is LelantusInterface) + if (wallet is LelantusInterface) const SizedBox( height: 8, ), - if (ref.watch(pWallets).getWallet(widget.walletId) - is LelantusInterface) + if (wallet is LelantusInterface) RoundedWhiteContainer( padding: const EdgeInsets.all(0), child: RawMaterialButton( @@ -316,13 +318,11 @@ class _WalletSettingsWalletSettingsViewState ), ), ), - if (ref.watch(pWallets).getWallet(widget.walletId) - is SparkInterface) + if (wallet is SparkInterface) const SizedBox( height: 8, ), - if (ref.watch(pWallets).getWallet(widget.walletId) - is SparkInterface) + if (wallet is SparkInterface) RoundedWhiteContainer( padding: const EdgeInsets.all(0), child: RawMaterialButton( diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/xpub_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/xpub_view.dart index 09200fb1b..2eddd3078 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/xpub_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/xpub_view.dart @@ -57,7 +57,7 @@ class XPubViewState extends ConsumerState { late String _currentDropDownValue; String _current(String key) => - widget.xpubData.xpubs.firstWhere((e) => e.path == key).xpub; + widget.xpubData.xpubs.firstWhere((e) => e.path == key).encoded; Future _copy() async { await widget.clipboardInterface.setData( diff --git a/lib/pages/wallet_view/sub_widgets/wallet_summary_info.dart b/lib/pages/wallet_view/sub_widgets/wallet_summary_info.dart index c9b973f5d..08d9152b8 100644 --- a/lib/pages/wallet_view/sub_widgets/wallet_summary_info.dart +++ b/lib/pages/wallet_view/sub_widgets/wallet_summary_info.dart @@ -186,6 +186,21 @@ class WalletSummaryInfo extends ConsumerWidget { ), ), const Spacer(), + if (ref.watch(pWalletInfo(walletId)).isViewOnly) + FittedBox( + fit: BoxFit.scaleDown, + child: SelectableText( + "(View only)", + style: STextStyles.pageTitleH1(context).copyWith( + fontSize: 18, + color: Theme.of(context) + .extension()! + .textFavoriteCard + .withOpacity(0.7), + ), + ), + ), + const Spacer(), FittedBox( fit: BoxFit.scaleDown, child: SelectableText( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart index 0105c0006..9893a6cae 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart @@ -21,6 +21,7 @@ import 'package:isar/isar.dart'; import '../../../db/sqlite/firo_cache.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/isar/models/isar_models.dart'; +import '../../../models/keys/view_only_wallet_data.dart'; import '../../../pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart'; import '../../../pages/token_view/my_tokens_view.dart'; import '../../../pages/wallet_view/sub_widgets/transactions_list.dart'; @@ -44,6 +45,7 @@ import '../../../utilities/wallet_tools.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/wallet/impl/banano_wallet.dart'; import '../../../wallets/wallet/impl/firo_wallet.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../widgets/custom_buttons/blue_text_button.dart'; import '../../../widgets/desktop/desktop_app_bar.dart'; @@ -168,6 +170,11 @@ class _DesktopWalletViewState extends ConsumerState { final monke = wallet is BananoWallet ? wallet.getMonkeyImageBytes() : null; + // if the view only wallet watches a single address there are no keys of any kind + final showKeysButton = !(wallet is ViewOnlyOptionInterface && + wallet.isViewOnly && + wallet.viewOnlyType == ViewOnlyWalletType.addressOnly); + return DesktopScaffold( appBar: DesktopAppBar( background: Theme.of(context).extension()!.popupBG, @@ -216,6 +223,19 @@ class _DesktopWalletViewState extends ConsumerState { ), ), ), + if (ref.watch(pWalletInfo(widget.walletId)).isViewOnly) + const SizedBox( + width: 20, + ), + if (ref.watch(pWalletInfo(widget.walletId)).isViewOnly) + Text( + "(View only)", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconLeft, + ), + ), if (kDebugMode) const Spacer(), if (kDebugMode) Column( @@ -312,12 +332,14 @@ class _DesktopWalletViewState extends ConsumerState { walletId: widget.walletId, eventBus: eventBus, ), - const SizedBox( - width: 2, - ), - WalletKeysButton( - walletId: widget.walletId, - ), + if (showKeysButton) + const SizedBox( + width: 2, + ), + if (showKeysButton) + WalletKeysButton( + walletId: widget.walletId, + ), const SizedBox( width: 2, ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart index 7c003e7f2..87f62a6f4 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart @@ -19,6 +19,7 @@ import 'package:isar/isar.dart'; import 'package:tuple/tuple.dart'; import '../../../../models/isar/models/isar_models.dart'; +import '../../../../models/keys/view_only_wallet_data.dart'; import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/receive_view/generate_receiving_uri_qr_code_view.dart'; import '../../../../providers/db/main_db_provider.dart'; @@ -40,6 +41,7 @@ import '../../../../wallets/wallet/intermediate/bip39_hd_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/bcash_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../../widgets/custom_loading_overlay.dart'; @@ -185,10 +187,15 @@ class _DesktopReceiveState extends ConsumerState { clipboard = widget.clipboard; final wallet = ref.read(pWallets).getWallet(walletId); supportsSpark = ref.read(pWallets).getWallet(walletId) is SparkInterface; - showMultiType = supportsSpark || - (wallet is! BCashInterface && - wallet is Bip39HDWallet && - wallet.supportedAddressTypes.length > 1); + + if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { + showMultiType = false; + } else { + showMultiType = supportsSpark || + (wallet is! BCashInterface && + wallet is Bip39HDWallet && + wallet.supportedAddressTypes.length > 1); + } _walletAddressTypes.add(wallet.info.mainAddressType); @@ -259,6 +266,18 @@ class _DesktopReceiveState extends ConsumerState { address = ref.watch(pWalletReceivingAddress(walletId)); } + final wallet = + ref.watch(pWallets.select((value) => value.getWallet(walletId))); + + final bool canGen; + if (wallet is ViewOnlyOptionInterface && + wallet.isViewOnly && + wallet.viewOnlyType == ViewOnlyWalletType.addressOnly) { + canGen = false; + } else { + canGen = (wallet is MultiAddressInterface || supportsSpark); + } + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -430,16 +449,12 @@ class _DesktopReceiveState extends ConsumerState { ), ), - if (ref.watch(pWallets.select((value) => value.getWallet(walletId))) - is MultiAddressInterface || - supportsSpark) + if (canGen) const SizedBox( height: 20, ), - if (ref.watch(pWallets.select((value) => value.getWallet(walletId))) - is MultiAddressInterface || - supportsSpark) + if (canGen) SecondaryButton( buttonHeight: ButtonHeight.l, onPressed: supportsSpark && diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart index 95147a002..97b77ec2e 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart @@ -14,6 +14,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../../../../../app_config.dart'; import '../../../../../db/sqlite/firo_cache.dart'; +import '../../../../../models/keys/view_only_wallet_data.dart'; import '../../../../../providers/db/main_db_provider.dart'; import '../../../../../providers/global/prefs_provider.dart'; import '../../../../../providers/global/wallets_provider.dart'; @@ -240,6 +241,9 @@ class _MoreFeaturesDialogState extends ConsumerState { ); final isViewOnly = wallet is ViewOnlyOptionInterface && wallet.isViewOnly; + final isViewOnlyNoAddressGen = wallet is ViewOnlyOptionInterface && + wallet.isViewOnly && + wallet.viewOnlyType == ViewOnlyWalletType.addressOnly; return DesktopDialog( child: Column( @@ -386,40 +390,41 @@ class _MoreFeaturesDialogState extends ConsumerState { ), ), // reuseAddress preference. - _MoreFeaturesItemBase( - onPressed: _switchReuseAddressToggled, - child: Row( - children: [ - const SizedBox(width: 3), - SizedBox( - height: 20, - width: 40, - child: IgnorePointer( - child: DraggableSwitchButton( - isOn: ref.watch( - pWalletInfo(widget.walletId) - .select((value) => value.otherData), - )[WalletInfoKeys.reuseAddress] as bool? ?? - false, - controller: _switchController, + if (!isViewOnlyNoAddressGen) + _MoreFeaturesItemBase( + onPressed: _switchReuseAddressToggled, + child: Row( + children: [ + const SizedBox(width: 3), + SizedBox( + height: 20, + width: 40, + child: IgnorePointer( + child: DraggableSwitchButton( + isOn: ref.watch( + pWalletInfo(widget.walletId) + .select((value) => value.otherData), + )[WalletInfoKeys.reuseAddress] as bool? ?? + false, + controller: _switchController, + ), ), ), - ), - const SizedBox( - width: 16, - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Reuse receiving address", - style: STextStyles.w600_20(context), - ), - ], - ), - ], + const SizedBox( + width: 16, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Reuse receiving address", + style: STextStyles.w600_20(context), + ), + ], + ), + ], + ), ), - ), const SizedBox( height: 28, ), 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 3db6866a8..f2357448c 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 @@ -83,7 +83,9 @@ class _UnlockWalletKeysDesktopState .verifyPassphrase(passwordController.text); if (verified) { - Navigator.of(context, rootNavigator: true).pop(); + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } final wallet = ref.read(pWallets).getWallet(widget.walletId); ({String keys, String config})? frostData; @@ -101,7 +103,8 @@ class _UnlockWalletKeysDesktopState throw Exception("FIXME ~= see todo in code"); } } else { - if (wallet is ViewOnlyOptionInterface) { + if (wallet is ViewOnlyOptionInterface && + (wallet as ViewOnlyOptionInterface).isViewOnly) { // TODO: is something needed here? } else { words = await wallet.getMnemonicAsWords(); @@ -109,7 +112,9 @@ class _UnlockWalletKeysDesktopState } KeyDataInterface? keyData; - if (wallet is ExtendedKeysInterface) { + if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { + keyData = await wallet.getViewOnlyWalletData(); + } else if (wallet is ExtendedKeysInterface) { keyData = await wallet.getXPrivs(); } else if (wallet is LibMoneroWallet) { keyData = await wallet.getKeys(); @@ -127,17 +132,20 @@ class _UnlockWalletKeysDesktopState ); } } else { - Navigator.of(context, rootNavigator: true).pop(); + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } await Future.delayed(const Duration(milliseconds: 300)); - - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Invalid passphrase!", - context: context, - ), - ); + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid passphrase!", + context: context, + ), + ); + } } } @@ -332,7 +340,10 @@ class _UnlockWalletKeysDesktopState .verifyPassphrase(passwordController.text); if (verified) { - Navigator.of(context, rootNavigator: true).pop(); + if (context.mounted) { + Navigator.of(context, rootNavigator: true) + .pop(); + } ({String keys, String config})? frostData; List? words; @@ -352,7 +363,9 @@ class _UnlockWalletKeysDesktopState throw Exception("FIXME ~= see todo in code"); } } else { - if (wallet is ViewOnlyOptionInterface) { + if (wallet is ViewOnlyOptionInterface && + (wallet as ViewOnlyOptionInterface) + .isViewOnly) { // TODO: is something needed here? } else { words = await wallet.getMnemonicAsWords(); @@ -360,7 +373,10 @@ class _UnlockWalletKeysDesktopState } KeyDataInterface? keyData; - if (wallet is ExtendedKeysInterface) { + if (wallet is ViewOnlyOptionInterface && + wallet.isViewOnly) { + keyData = await wallet.getViewOnlyWalletData(); + } else if (wallet is ExtendedKeysInterface) { keyData = await wallet.getXPrivs(); } else if (wallet is LibMoneroWallet) { keyData = await wallet.getKeys(); @@ -379,19 +395,23 @@ class _UnlockWalletKeysDesktopState ); } } else { - Navigator.of(context, rootNavigator: true).pop(); + if (context.mounted) { + Navigator.of(context, rootNavigator: true) + .pop(); + } await Future.delayed( const Duration(milliseconds: 300), ); - - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Invalid passphrase!", - context: context, - ), - ); + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid passphrase!", + context: context, + ), + ); + } } } : null, 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 e80e42158..bb8ba8089 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 @@ -16,9 +16,11 @@ 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/xpriv_data.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/sub_widgets/view_only_wallet_data_widget.dart'; import '../../../../pages/settings_views/wallet_settings_view/wallet_backup_views/cn_wallet_keys.dart'; import '../../../../pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_xprivs.dart'; import '../../../../pages/wallet_view/transaction_views/transaction_details_view.dart'; @@ -180,32 +182,39 @@ class WalletKeysDesktopPopup extends ConsumerWidget { ], ) : keyData != null - ? 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, - ), + ? keyData is ViewOnlyWalletData + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ViewOnlyWalletDataWidget( + data: keyData as ViewOnlyWalletData, ), - if (keyData is XPrivData) - WalletXPrivs( - xprivData: keyData as XPrivData, - walletId: walletId, - ), - if (keyData is CWKeyData) - CNWalletKeys( - cwKeyData: keyData as CWKeyData, - walletId: walletId, - ), - ], - ) + ) + : 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 (keyData is XPrivData) + WalletXPrivs( + xprivData: keyData as XPrivData, + walletId: walletId, + ), + if (keyData is CWKeyData) + CNWalletKeys( + cwKeyData: keyData as CWKeyData, + walletId: walletId, + ), + ], + ) : _Mnemonic( words: words, ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart index 4f955c00f..e0ece604c 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart @@ -31,6 +31,7 @@ import '../../../../wallets/crypto_currency/intermediate/frost_currency.dart'; import '../../../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../addresses/desktop_wallet_addresses_view.dart'; import '../../../lelantus_coins/lelantus_coins_view.dart'; import '../../../spark_coins/spark_coins_view.dart'; @@ -295,8 +296,12 @@ class WalletOptionsPopupMenu extends ConsumerWidget { final firoDebug = kDebugMode && (coin is Firo); - final bool xpubEnabled = - ref.watch(pWallets).getWallet(walletId) is ExtendedKeysInterface; + final wallet = ref.watch(pWallets).getWallet(walletId); + bool xpubEnabled = wallet is ExtendedKeysInterface; + + if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { + xpubEnabled = false; + } final bool canChangeRep = coin is NanoCurrency; diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 41aec3834..52454fb29 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -23,6 +23,7 @@ import 'models/isar/models/contact_entry.dart'; 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/paynym/paynym_account_lite.dart'; import 'models/send_view_auto_fill_data.dart'; import 'pages/add_wallet_views/add_token_view/add_custom_token_view.dart'; @@ -205,7 +206,6 @@ import 'wallets/crypto_currency/intermediate/frost_currency.dart'; import 'wallets/models/tx_data.dart'; import 'wallets/wallet/wallet.dart'; import 'wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; -import 'wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import 'widgets/choose_coin_view.dart'; import 'widgets/frost_scaffold.dart'; diff --git a/lib/services/wallets.dart b/lib/services/wallets.dart index d7b93cf33..c606a75cf 100644 --- a/lib/services/wallets.dart +++ b/lib/services/wallets.dart @@ -84,6 +84,9 @@ class Wallets { key: Wallet.mnemonicPassphraseKey(walletId: walletId), ); await secureStorage.delete(key: Wallet.privateKeyKey(walletId: walletId)); + await secureStorage.delete( + key: Wallet.getViewOnlyWalletDataSecStoreKey(walletId: walletId), + ); if (info.coin is Wownero) { await lib_monero_compat.deleteWalletFiles( diff --git a/lib/wallets/crypto_currency/coins/wownero.dart b/lib/wallets/crypto_currency/coins/wownero.dart index ba68d5874..ed3f18c3d 100644 --- a/lib/wallets/crypto_currency/coins/wownero.dart +++ b/lib/wallets/crypto_currency/coins/wownero.dart @@ -92,7 +92,7 @@ class Wownero extends CryptonoteCurrency { bool get hasMnemonicPassphraseSupport => false; @override - List get possibleMnemonicLengths => [defaultSeedPhraseLength, 25]; + List get possibleMnemonicLengths => [defaultSeedPhraseLength, 16, 25]; @override BigInt get satsPerCoin => BigInt.from(100000000000); diff --git a/lib/wallets/crypto_currency/intermediate/bip39_hd_currency.dart b/lib/wallets/crypto_currency/intermediate/bip39_hd_currency.dart index bb97b6754..44b2f79e2 100644 --- a/lib/wallets/crypto_currency/intermediate/bip39_hd_currency.dart +++ b/lib/wallets/crypto_currency/intermediate/bip39_hd_currency.dart @@ -5,9 +5,11 @@ import 'package:flutter/foundation.dart'; import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/enums/derive_path_type_enum.dart'; +import '../interfaces/view_only_option_currency_interface.dart'; import 'bip39_currency.dart'; -abstract class Bip39HDCurrency extends Bip39Currency { +abstract class Bip39HDCurrency extends Bip39Currency + implements ViewOnlyOptionCurrencyInterface { Bip39HDCurrency(super.network); coinlib.Network get networkParams; @@ -39,6 +41,25 @@ abstract class Bip39HDCurrency extends Bip39Currency { } } + List get supportedHardenedDerivationPaths { + final paths = supportedDerivationPathTypes.map( + (e) => ( + path: e, + addressType: e.getAddressType(), + ), + ); + + return paths.map((e) { + final path = constructDerivePath( + derivePathType: e.path, + chain: 0, + index: 0, + ); + // trim unhardened + return path.substring(0, path.lastIndexOf("'") + 1); + }).toList(); + } + static String convertBytesToScriptHash(Uint8List bytes) { final hash = sha256.convert(bytes.toList(growable: false)).toString(); diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart index 3e296a3a0..eb6a4e9ea 100644 --- a/lib/wallets/isar/models/wallet_info.dart +++ b/lib/wallets/isar/models/wallet_info.dart @@ -6,6 +6,7 @@ import 'package:uuid/uuid.dart'; import '../../../app_config.dart'; import '../../../models/balance.dart'; import '../../../models/isar/models/blockchain_data/address.dart'; +import '../../../models/keys/view_only_wallet_data.dart'; import '../../crypto_currency/crypto_currency.dart'; import '../isar_id_interface.dart'; import 'wallet_info_meta.dart'; @@ -121,6 +122,13 @@ class WalletInfo implements IsarId { bool get isViewOnly => otherData[WalletInfoKeys.isViewOnlyKey] as bool? ?? false; + @ignore + ViewOnlyWalletType? get viewOnlyWalletType { + final index = otherData[WalletInfoKeys.viewOnlyTypeIndexKey] as int?; + if (index == null) return null; + return ViewOnlyWalletType.values[index]; + } + Future isMnemonicVerified(Isar isar) async => (await isar.walletInfoMeta.where().walletIdEqualTo(walletId).findFirst()) ?.isMnemonicVerified == @@ -517,4 +525,5 @@ abstract class WalletInfoKeys { static const String enableOptInRbf = "enableOptInRbfKey"; static const String reuseAddress = "reuseAddressKey"; static const String isViewOnlyKey = "isViewOnlyKey"; + static const String viewOnlyTypeIndexKey = "viewOnlyTypeIndexKey"; } diff --git a/lib/wallets/isar/models/wallet_info_meta.dart b/lib/wallets/isar/models/wallet_info_meta.dart index 62d37b7d4..ce0820a1a 100644 --- a/lib/wallets/isar/models/wallet_info_meta.dart +++ b/lib/wallets/isar/models/wallet_info_meta.dart @@ -1,4 +1,5 @@ import 'package:isar/isar.dart'; + import '../isar_id_interface.dart'; part 'wallet_info_meta.g.dart'; diff --git a/lib/wallets/wallet/intermediate/bip39_hd_wallet.dart b/lib/wallets/wallet/intermediate/bip39_hd_wallet.dart index 3d603fc03..268693d60 100644 --- a/lib/wallets/wallet/intermediate/bip39_hd_wallet.dart +++ b/lib/wallets/wallet/intermediate/bip39_hd_wallet.dart @@ -6,15 +6,17 @@ import 'package:isar/isar.dart'; import '../../../models/balance.dart'; import '../../../models/isar/models/blockchain_data/address.dart'; +import '../../../models/keys/view_only_wallet_data.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/enums/derive_path_type_enum.dart'; import '../../../utilities/extensions/extensions.dart'; import '../../crypto_currency/intermediate/bip39_hd_currency.dart'; import '../wallet_mixin_interfaces/multi_address_interface.dart'; +import '../wallet_mixin_interfaces/view_only_option_interface.dart'; import 'bip39_wallet.dart'; abstract class Bip39HDWallet extends Bip39Wallet - with MultiAddressInterface { + with MultiAddressInterface, ViewOnlyOptionInterface { Bip39HDWallet(super.cryptoCurrency); Set get supportedAddressTypes => @@ -116,7 +118,9 @@ abstract class Bip39HDWallet extends Bip39Wallet @override Future checkSaveInitialReceivingAddress() async { - final current = await getCurrentChangeAddress(); + if (isViewOnly && viewOnlyType == ViewOnlyWalletType.addressOnly) return; + + final current = await getCurrentReceivingAddress(); if (current == null) { final address = await _generateAddress( chain: 0, // receiving @@ -174,15 +178,29 @@ abstract class Bip39HDWallet extends Bip39Wallet required int index, required DerivePathType derivePathType, }) async { - final root = await getRootHDNode(); - final derivationPath = cryptoCurrency.constructDerivePath( derivePathType: derivePathType, chain: chain, index: index, ); - final keys = root.derivePath(derivationPath); + final coinlib.HDKey keys; + if (isViewOnly) { + final idx = derivationPath.lastIndexOf("'/"); + final path = derivationPath.substring(idx + 2); + final data = + await getViewOnlyWalletData() as ExtendedKeysViewOnlyWalletData; + + final xPub = data.xPubs.firstWhere( + (e) => derivationPath.startsWith(e.path), + ); + + final node = coinlib.HDPublicKey.decode(xPub.encoded); + keys = node.derivePath(path); + } else { + final root = await getRootHDNode(); + keys = root.derivePath(derivationPath); + } final data = cryptoCurrency.getAddressForPublicKey( publicKey: keys.publicKey, @@ -205,7 +223,8 @@ abstract class Bip39HDWallet extends Bip39Wallet value: convertAddressString(data.address.toString()), publicKey: keys.publicKey.data, derivationIndex: index, - derivationPath: DerivationPath()..value = derivationPath, + derivationPath: + isViewOnly ? null : (DerivationPath()..value = derivationPath), type: data.addressType, subType: subType, ); diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index 5e7d2a8dd..f1ac98c80 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -18,6 +18,7 @@ import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/keys/cw_key_data.dart'; +import '../../../models/keys/view_only_wallet_data.dart'; import '../../../models/paymint/fee_object_model.dart'; import '../../../services/event_bus/events/global/blocks_remaining_event.dart'; import '../../../services/event_bus/events/global/refresh_percent_changed_event.dart'; @@ -41,7 +42,8 @@ import 'cryptonote_wallet.dart'; abstract class LibMoneroWallet extends CryptonoteWallet - implements MultiAddressInterface, ViewOnlyOptionInterface { + with ViewOnlyOptionInterface + implements MultiAddressInterface { @override int get isarTransactionVersion => 2; @@ -91,8 +93,16 @@ abstract class LibMoneroWallet .walletIdEqualTo(walletId) .watch(fireImmediately: true) .listen((utxos) async { - await onUTXOsChanged(utxos); - await updateBalance(shouldUpdateUtxos: false); + try { + await onUTXOsChanged(utxos); + await updateBalance(shouldUpdateUtxos: false); + } catch (e, s) { + lib_monero.Logging.log?.i( + "_startInit", + error: e, + stackTrace: s, + ); + } }); }); } @@ -286,6 +296,22 @@ abstract class LibMoneroWallet ); } + Future<(String, String)> + hackToCreateNewViewOnlyWalletDataFromNewlyCreatedWalletThisFunctionShouldNotBeCalledUnlessYouKnowWhatYouAreDoing() async { + final path = await pathForWallet(name: walletId, type: compatType); + final String password; + try { + password = (await secureStorageInterface.read( + key: lib_monero_compat.libMoneroWalletPasswordKey(walletId), + ))!; + } catch (e, s) { + throw Exception("Password not found $e, $s"); + } + loadWallet(path: path, password: password); + final wallet = libMoneroWallet!; + return (wallet.getAddress().value, wallet.getPrivateViewKey()); + } + @override Future init({bool? isRestore}) async { final path = await pathForWallet( @@ -1301,19 +1327,11 @@ abstract class LibMoneroWallet // ============== View only ================================================== - @override - bool get isViewOnly => info.isViewOnly; - @override Future recoverViewOnly() async { await refreshMutex.protect(() async { - final jsonEncodedString = await secureStorageInterface.read( - key: Wallet.getViewOnlyWalletDataSecStoreKey( - walletId: walletId, - ), - ); - - final data = ViewOnlyWalletData.fromJsonEncodedString(jsonEncodedString!); + final data = + await getViewOnlyWalletData() as CryptonoteViewOnlyWalletData; try { final height = max(info.restoreHeight, 0); @@ -1338,8 +1356,8 @@ abstract class LibMoneroWallet final wallet = await getRestoredFromViewKeyWallet( path: path, password: password, - address: data.address!, - privateViewKey: data.privateViewKey!, + address: data.address, + privateViewKey: data.privateViewKey, height: height, ); @@ -1387,14 +1405,6 @@ abstract class LibMoneroWallet }); } - @override - Future getViewOnlyWalletData() async { - return ViewOnlyWalletData( - address: libMoneroWallet!.getAddress().value, - privateViewKey: libMoneroWallet!.getPrivateViewKey(), - ); - } - // ============== Private ==================================================== StreamSubscription? _torStatusListener; diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 4f69b6056..6eea07a92 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -7,6 +7,7 @@ import 'package:mutex/mutex.dart'; import '../../db/isar/main_db.dart'; import '../../models/isar/models/blockchain_data/address.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/keys/view_only_wallet_data.dart'; import '../../models/node_model.dart'; import '../../models/paymint/fee_object_model.dart'; import '../../services/event_bus/events/global/node_connection_status_changed_event.dart'; @@ -161,7 +162,7 @@ abstract class Wallet { prefs: prefs, ); - if (wallet is ViewOnlyOptionInterface) { + if (wallet is ViewOnlyOptionInterface && walletInfo.isViewOnly) { await secureStorageInterface.write( key: getViewOnlyWalletDataSecStoreKey(walletId: walletInfo.walletId), value: viewOnlyData!.toJsonEncodedString(), @@ -583,6 +584,9 @@ abstract class Wallet { if (!tAlive) throw Exception("refresh alive ping failure"); } + final viewOnly = this is ViewOnlyOptionInterface && + (this as ViewOnlyOptionInterface).isViewOnly; + try { // this acquire should be almost instant due to above check. // Slight possibility of race but should be irrelevant @@ -607,7 +611,7 @@ abstract class Wallet { // TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided. final Set codesToCheck = {}; _checkAlive(); - if (this is PaynymInterface) { + if (this is PaynymInterface && !viewOnly) { // isSegwit does not matter here at all final myCode = await (this as PaynymInterface).getPaymentCode(isSegwit: false); @@ -654,8 +658,10 @@ abstract class Wallet { // TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided. if (this is MultiAddressInterface) { - await (this as MultiAddressInterface) - .checkChangeAddressForTransactions(); + if (info.otherData[WalletInfoKeys.reuseAddress] != true) { + await (this as MultiAddressInterface) + .checkChangeAddressForTransactions(); + } } _checkAlive(); GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.3, walletId)); @@ -681,7 +687,7 @@ abstract class Wallet { await fetchFuture; // TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided. - if (this is PaynymInterface && codesToCheck.isNotEmpty) { + if (!viewOnly && this is PaynymInterface && codesToCheck.isNotEmpty) { _checkAlive(); await (this as PaynymInterface) .checkForNotificationTransactionsTo(codesToCheck); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 4122b257a..b2d4ce8cc 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -13,6 +13,7 @@ import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/isar/models/isar_models.dart'; +import '../../../models/keys/view_only_wallet_data.dart'; import '../../../models/paymint/fee_object_model.dart'; import '../../../models/signing_data.dart'; import '../../../utilities/amount/amount.dart'; @@ -32,9 +33,10 @@ import '../intermediate/bip39_hd_wallet.dart'; import 'cpfp_interface.dart'; import 'paynym_interface.dart'; import 'rbf_interface.dart'; +import 'view_only_option_interface.dart'; mixin ElectrumXInterface - on Bip39HDWallet { + on Bip39HDWallet implements ViewOnlyOptionInterface { late ElectrumXClient electrumXClient; late CachedElectrumXClient electrumXCachedClient; @@ -137,7 +139,9 @@ mixin ElectrumXInterface (e.used != true) && (canCPFP || e.isConfirmed( - currentChainHeight, cryptoCurrency.minConfirms)), + currentChainHeight, + cryptoCurrency.minConfirms, + )), ) .toList(); final spendableSatoshiValue = @@ -944,7 +948,7 @@ mixin ElectrumXInterface Future<({List
addresses, int index})> checkGapsBatched( int txCountBatchSize, - coinlib.HDPrivateKey root, + coinlib.HDKey node, DerivePathType type, int chain, ) async { @@ -969,7 +973,14 @@ mixin ElectrumXInterface index: index + j, ); - final keys = root.derivePath(derivePath); + final coinlib.HDKey keys; + if (isViewOnly) { + final idx = derivePath.lastIndexOf("'/"); + final path = derivePath.substring(idx + 2); + keys = node.derivePath(path); + } else { + keys = node.derivePath(derivePath); + } final addressData = cryptoCurrency.getAddressForPublicKey( publicKey: keys.publicKey, @@ -986,7 +997,8 @@ mixin ElectrumXInterface publicKey: keys.publicKey.data, type: addressData.addressType, derivationIndex: index + j, - derivationPath: DerivationPath()..value = derivePath, + derivationPath: + isViewOnly ? null : (DerivationPath()..value = derivePath), subType: chain == 0 ? AddressSubType.receiving : AddressSubType.change, ); @@ -1025,13 +1037,14 @@ mixin ElectrumXInterface } Future<({List
addresses, int index})> checkGapsLinearly( - coinlib.HDPrivateKey root, + coinlib.HDKey node, DerivePathType type, int chain, ) async { final List
addressArray = []; int gapCounter = 0; int index = 0; + for (; gapCounter < cryptoCurrency.maxUnusedAddressGap; index++) { Logging.instance.log( "index: $index, \t GapCounter chain=$chain ${type.name}: $gapCounter", @@ -1043,7 +1056,16 @@ mixin ElectrumXInterface chain: chain, index: index, ); - final keys = root.derivePath(derivePath); + + final coinlib.HDKey keys; + if (isViewOnly) { + final idx = derivePath.lastIndexOf("'/"); + final path = derivePath.substring(idx + 2); + keys = node.derivePath(path); + } else { + keys = node.derivePath(derivePath); + } + final addressData = cryptoCurrency.getAddressForPublicKey( publicKey: keys.publicKey, derivePathType: type, @@ -1059,7 +1081,8 @@ mixin ElectrumXInterface publicKey: keys.publicKey.data, type: addressData.addressType, derivationIndex: index, - derivationPath: DerivationPath()..value = derivePath, + derivationPath: + isViewOnly ? null : (DerivationPath()..value = derivePath), subType: chain == 0 ? AddressSubType.receiving : AddressSubType.change, ); @@ -1331,6 +1354,10 @@ mixin ElectrumXInterface @override Future checkReceivingAddressForTransactions() async { + if (isViewOnly && viewOnlyType == ViewOnlyWalletType.addressOnly) { + return; + } + if (info.otherData[WalletInfoKeys.reuseAddress] == true) { try { throw Exception(); @@ -1382,6 +1409,21 @@ mixin ElectrumXInterface @override Future checkChangeAddressForTransactions() async { + if (isViewOnly && viewOnlyType == ViewOnlyWalletType.addressOnly) { + return; + } + + if (info.otherData[WalletInfoKeys.reuseAddress] == true) { + try { + throw Exception(); + } catch (_, s) { + Logging.instance.log( + "checkChangeAddressForTransactions called but reuse address flag set: $s", + level: LogLevel.Error, + ); + } + } + try { final currentChange = await getCurrentChangeAddress(); @@ -1419,6 +1461,11 @@ mixin ElectrumXInterface @override Future recover({required bool isRescan}) async { + if (isViewOnly) { + await recoverViewOnly(isRescan: isRescan); + return; + } + final root = await getRootHDNode(); final List addresses})>> receiveFutures = @@ -1918,5 +1965,219 @@ mixin ElectrumXInterface return address; } + // ============== View only ================================================== + + @override + Future recoverViewOnly({bool isRescan = false}) async { + final data = await getViewOnlyWalletData(); + + final coinlib.HDKey? root; + if (data is AddressViewOnlyWalletData) { + root = null; + } else { + if ((data as ExtendedKeysViewOnlyWalletData).xPubs.length != 1) { + throw Exception( + "Only single xpub view only wallets are currently supported", + ); + } + + root = coinlib.HDPublicKey.decode(data.xPubs.first.encoded); + } + + final List addresses})>> receiveFutures = + []; + final List addresses})>> changeFutures = + []; + + const receiveChain = 0; + const changeChain = 1; + + const txCountBatchSize = 12; + + try { + await refreshMutex.protect(() async { + if (isRescan) { + // clear cache + await electrumXCachedClient.clearSharedTransactionCache( + cryptoCurrency: info.coin, + ); + // clear blockchain info + await mainDB.deleteWalletBlockchainData(walletId); + } + + final List
addressesToStore = []; + + if (root != null) { + // receiving addresses + Logging.instance.log( + "checking receiving addresses...", + level: LogLevel.Info, + ); + + final canBatch = await serverCanBatch; + + for (final type in cryptoCurrency.supportedDerivationPathTypes) { + final path = cryptoCurrency.constructDerivePath( + derivePathType: type, + chain: 0, + index: 0, + ); + if (path.startsWith( + (data as ExtendedKeysViewOnlyWalletData).xPubs.first.path, + )) { + receiveFutures.add( + canBatch + ? checkGapsBatched( + txCountBatchSize, + root, + type, + receiveChain, + ) + : checkGapsLinearly( + root, + type, + receiveChain, + ), + ); + } + } + + // change addresses + Logging.instance.log( + "checking change addresses...", + level: LogLevel.Info, + ); + for (final type in cryptoCurrency.supportedDerivationPathTypes) { + final path = cryptoCurrency.constructDerivePath( + derivePathType: type, + chain: 0, + index: 0, + ); + if (path.startsWith( + (data as ExtendedKeysViewOnlyWalletData).xPubs.first.path, + )) { + changeFutures.add( + canBatch + ? checkGapsBatched( + txCountBatchSize, + root, + type, + changeChain, + ) + : checkGapsLinearly( + root, + type, + changeChain, + ), + ); + } + } + + // io limitations may require running these linearly instead + final futuresResult = await Future.wait([ + Future.wait(receiveFutures), + Future.wait(changeFutures), + ]); + + final receiveResults = futuresResult[0]; + final changeResults = futuresResult[1]; + + int highestReceivingIndexWithHistory = 0; + + for (final tuple in receiveResults) { + if (tuple.addresses.isEmpty) { + await checkReceivingAddressForTransactions(); + } else { + highestReceivingIndexWithHistory = max( + tuple.index, + highestReceivingIndexWithHistory, + ); + addressesToStore.addAll(tuple.addresses); + } + } + + int highestChangeIndexWithHistory = 0; + // If restoring a wallet that never sent any funds with change, then set changeArray + // manually. If we didn't do this, it'd store an empty array. + for (final tuple in changeResults) { + if (tuple.addresses.isEmpty) { + await checkChangeAddressForTransactions(); + } else { + highestChangeIndexWithHistory = max( + tuple.index, + highestChangeIndexWithHistory, + ); + addressesToStore.addAll(tuple.addresses); + } + } + + // remove extra addresses to help minimize risk of creating a large gap + addressesToStore.removeWhere( + (e) => + e.subType == AddressSubType.change && + e.derivationIndex > highestChangeIndexWithHistory, + ); + addressesToStore.removeWhere( + (e) => + e.subType == AddressSubType.receiving && + e.derivationIndex > highestReceivingIndexWithHistory, + ); + } else { + final clAddress = coinlib.Address.fromString( + (data as AddressViewOnlyWalletData).address, + cryptoCurrency.networkParams, + ); + + final AddressType addressType; + switch (clAddress.runtimeType) { + case const (coinlib.P2PKHAddress): + addressType = AddressType.p2pkh; + break; + + case const (coinlib.P2SHAddress): + addressType = AddressType.p2sh; + break; + + case const (coinlib.P2WPKHAddress): + addressType = AddressType.p2wpkh; + break; + + case const (coinlib.P2TRAddress): + addressType = AddressType.p2tr; + break; + + default: + throw Exception( + "Unsupported address type: ${clAddress.runtimeType}", + ); + } + + addressesToStore.add( + Address( + walletId: walletId, + value: clAddress.toString(), + publicKey: [], + derivationIndex: -1, + derivationPath: null, + type: addressType, + subType: AddressSubType.receiving, + ), + ); + } + + await mainDB.updateOrPutAddresses(addressesToStore); + }); + + unawaited(refresh()); + } catch (e, s) { + Logging.instance.log( + "Exception rethrown from electrumx_mixin recoverViewOnly(): $e\n$s", + level: LogLevel.Info, + ); + + rethrow; + } + } + // =========================================================================== } diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart index 1606b1295..8b41dc122 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart @@ -2,35 +2,35 @@ import '../../../models/keys/xpriv_data.dart'; import '../../crypto_currency/interfaces/electrumx_currency_interface.dart'; import 'electrumx_interface.dart'; -typedef XPub = ({String path, String xpub}); -typedef XPriv = ({String path, String xpriv}); +abstract class XKey { + XKey({required this.path}); + final String path; +} + +class XPub extends XKey { + XPub({required super.path, required this.encoded}); + final String encoded; +} + +class XPriv extends XKey { + XPriv({required super.path, required this.encoded}); + final String encoded; +} mixin ExtendedKeysInterface on ElectrumXInterface { Future<({List xpubs, String fingerprint})> getXPubs() async { - final paths = cryptoCurrency.supportedDerivationPathTypes.map( - (e) => ( - path: e, - addressType: e.getAddressType(), - ), - ); + final paths = cryptoCurrency.supportedHardenedDerivationPaths; final master = await getRootHDNode(); final fingerprint = master.fingerprint.toRadixString(16); - final futures = paths.map((e) async { - String path = cryptoCurrency.constructDerivePath( - derivePathType: e.path, - chain: 0, - index: 0, - ); - // trim chain and address index - path = path.substring(0, path.lastIndexOf("'") + 1); + final futures = paths.map((path) async { final node = master.derivePath(path); - return ( + return XPub( path: path, - xpub: node.hdPublicKey.encode( + encoded: node.hdPublicKey.encode( cryptoCurrency.networkParams.pubHDPrefix, // 0x04b24746, ), @@ -44,29 +44,17 @@ mixin ExtendedKeysInterface } Future getXPrivs() async { - final paths = cryptoCurrency.supportedDerivationPathTypes.map( - (e) => ( - path: e, - addressType: e.getAddressType(), - ), - ); + final paths = cryptoCurrency.supportedHardenedDerivationPaths; final master = await getRootHDNode(); final fingerprint = master.fingerprint.toRadixString(16); - final futures = paths.map((e) async { - String path = cryptoCurrency.constructDerivePath( - derivePathType: e.path, - chain: 0, - index: 0, - ); - // trim chain and address index - path = path.substring(0, path.lastIndexOf("'") + 1); + final futures = paths.map((path) async { final node = master.derivePath(path); - return ( + return XPriv( path: path, - xpriv: node.encode( + encoded: node.encode( cryptoCurrency.networkParams.privHDPrefix, ), ); @@ -76,9 +64,9 @@ mixin ExtendedKeysInterface walletId: walletId, fingerprint: fingerprint, xprivs: [ - ( + XPriv( path: "Master", - xpriv: master.encode( + encoded: master.encode( cryptoCurrency.networkParams.privHDPrefix, ), ), diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart index 6e12f02cc..4989ce0d0 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart @@ -1,34 +1,27 @@ -import 'dart:convert'; - +import '../../../models/keys/view_only_wallet_data.dart'; import '../../crypto_currency/interfaces/view_only_option_currency_interface.dart'; import '../wallet.dart'; -class ViewOnlyWalletData { - final String? address; - final String? privateViewKey; - - ViewOnlyWalletData({required this.address, required this.privateViewKey}); - - factory ViewOnlyWalletData.fromJsonEncodedString(String jsonEncodedString) { - final map = jsonDecode(jsonEncodedString) as Map; - final json = Map.from(map); - return ViewOnlyWalletData( - address: json["address"] as String?, - privateViewKey: json["privateViewKey"] as String?, - ); - } - - String toJsonEncodedString() => jsonEncode({ - "address": address, - "privateViewKey": privateViewKey, - }); -} - mixin ViewOnlyOptionInterface on Wallet { - bool get isViewOnly; + ViewOnlyWalletType get viewOnlyType => info.viewOnlyWalletType!; + + bool get isViewOnly => info.isViewOnly; Future recoverViewOnly(); - Future getViewOnlyWalletData(); + Future getViewOnlyWalletData() async { + if (!isViewOnly) { + throw Exception("This is not a view only wallet"); + } + + final encoded = await secureStorageInterface.read( + key: Wallet.getViewOnlyWalletDataSecStoreKey(walletId: walletId), + ); + + return ViewOnlyWalletData.fromJsonEncodedString( + encoded!, + walletId: walletId, + ); + } } diff --git a/lib/widgets/stack_dialog.dart b/lib/widgets/stack_dialog.dart index 1541256a0..ff921d24c 100644 --- a/lib/widgets/stack_dialog.dart +++ b/lib/widgets/stack_dialog.dart @@ -94,7 +94,7 @@ class StackDialog extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( - child: Text( + child: SelectableText( title, style: STextStyles.pageTitleH2(context), ), @@ -110,7 +110,7 @@ class StackDialog extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( + SelectableText( message!, style: STextStyles.smallMed14(context), ),