diff --git a/lib/models/keys/view_only_wallet_data.dart b/lib/models/keys/view_only_wallet_data.dart index 92c23b082d..384985ac16 100644 --- a/lib/models/keys/view_only_wallet_data.dart +++ b/lib/models/keys/view_only_wallet_data.dart @@ -7,7 +7,8 @@ import 'key_data_interface.dart'; enum ViewOnlyWalletType { cryptonote, addressOnly, - xPub; + xPub, + spark; } sealed class ViewOnlyWalletData with KeyDataInterface { @@ -46,6 +47,12 @@ sealed class ViewOnlyWalletData with KeyDataInterface { jsonEncodedString, walletId: walletId, ); + + case ViewOnlyWalletType.spark: + return SparkViewOnlyWalletData.fromJsonEncodedString( + jsonEncodedString, + walletId: walletId, + ); } } @@ -162,3 +169,34 @@ class ExtendedKeysViewOnlyWalletData extends ViewOnlyWalletData { ], }); } + +class SparkViewOnlyWalletData extends ViewOnlyWalletData { + @override + final type = ViewOnlyWalletType.spark; + + final String viewKey; + + SparkViewOnlyWalletData({ + required super.walletId, + required this.viewKey, + }); + + static SparkViewOnlyWalletData fromJsonEncodedString( + String jsonEncodedString, { + required String walletId, + }) { + final map = jsonDecode(jsonEncodedString) as Map; + final json = Map.from(map); + + return SparkViewOnlyWalletData( + walletId: walletId, + viewKey: json["viewKey"] as String, + ); + } + + @override + String toJsonEncodedString() => jsonEncode({ + "type": type.index, + "viewKey": viewKey, + }); +} 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 e0d3871c42..e4709aada9 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 @@ -36,12 +36,13 @@ 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 '../../../widgets/options.dart'; import '../../home_view/home_view.dart'; import 'confirm_recovery_dialog.dart'; import 'sub_widgets/restore_failed_dialog.dart'; import 'sub_widgets/restore_succeeded_dialog.dart'; import 'sub_widgets/restoring_dialog.dart'; +import '../../../wallets/wallet/impl/firo_wallet.dart'; class RestoreViewOnlyWalletView extends ConsumerStatefulWidget { const RestoreViewOnlyWalletView({ @@ -68,13 +69,13 @@ class _RestoreViewOnlyWalletViewState extends ConsumerState { late final TextEditingController addressController; late final TextEditingController viewKeyController; + late final TextEditingController sparkViewKeyController; - late String _currentDropDownValue; + late ViewOnlyWalletType _walletType; bool _enableRestoreButton = false; - bool _addressOnly = false; - bool _buttonLock = false; + late String _currentDropDownValue; Future _requestRestore() async { if (_buttonLock) return; @@ -107,12 +108,8 @@ class _RestoreViewOnlyWalletViewState WalletInfoKeys.isViewOnlyKey: true, }; - final ViewOnlyWalletType viewOnlyWalletType; + ViewOnlyWalletType viewOnlyWalletType = _walletType; if (widget.coin is Bip39HDCurrency) { - viewOnlyWalletType = - _addressOnly - ? ViewOnlyWalletType.addressOnly - : ViewOnlyWalletType.xPub; } else if (widget.coin is CryptonoteCurrency) { viewOnlyWalletType = ViewOnlyWalletType.cryptonote; } else { @@ -120,8 +117,7 @@ class _RestoreViewOnlyWalletViewState "Unsupported view only wallet currency type found: ${widget.coin.runtimeType}", ); } - otherDataJson[WalletInfoKeys.viewOnlyTypeIndexKey] = - viewOnlyWalletType.index; + otherDataJson[WalletInfoKeys.viewOnlyTypeIndexKey] = _walletType.index; if (!Platform.isLinux && !Util.isDesktop) await WakelockPlus.enable(); @@ -192,6 +188,16 @@ class _RestoreViewOnlyWalletViewState ], ); break; + + case ViewOnlyWalletType.spark: + if (sparkViewKeyController.text.isEmpty) { + throw Exception("Spark View Key is empty"); + } + viewOnlyData = SparkViewOnlyWalletData( + walletId: info.walletId, + viewKey: sparkViewKeyController.text, + ); + break; } var node = ref @@ -237,6 +243,10 @@ class _RestoreViewOnlyWalletViewState await (wallet as XelisWallet).init(isRestore: true); break; + case const (FiroWallet): + await (wallet as FiroWallet).init(); + break; + default: await wallet.init(); } @@ -314,12 +324,15 @@ class _RestoreViewOnlyWalletViewState super.initState(); addressController = TextEditingController(); viewKeyController = TextEditingController(); + sparkViewKeyController = TextEditingController(); if (widget.coin is Bip39HDCurrency) { - _currentDropDownValue = - (widget.coin as Bip39HDCurrency) - .supportedHardenedDerivationPaths - .last; + _currentDropDownValue = (widget.coin as Bip39HDCurrency) + .supportedHardenedDerivationPaths + .last; + _walletType = ViewOnlyWalletType.xPub; + } else if (widget.coin is CryptonoteCurrency) { + _walletType = ViewOnlyWalletType.cryptonote; } } @@ -327,6 +340,7 @@ class _RestoreViewOnlyWalletViewState void dispose() { addressController.dispose(); viewKeyController.dispose(); + sparkViewKeyController.dispose(); super.dispose(); } @@ -393,23 +407,25 @@ class _RestoreViewOnlyWalletViewState if (isElectrumX) SizedBox( height: isDesktop ? 56 : 48, - width: isDesktop ? 490 : null, - child: Toggle( + width: isDesktop ? 490 : double.infinity, + child: Options( key: UniqueKey(), - onText: "Extended pub key", - offText: "Single address", - onColor: - Theme.of( - context, - ).extension()!.popupBG, - offColor: - Theme.of(context) - .extension()! - .textFieldDefaultBG, - isOn: _addressOnly, + texts: [ + "Single address", + "Extended pub key", + if (widget.coin is Firo) + isDesktop ? "Spark View Key" : "View Key" + ], + onColor: Theme.of(context) + .extension()! + .popupBG, + offColor: Theme.of(context) + .extension()! + .textFieldDefaultBG, + selectedIndex: _walletType.index-1, onValueChanged: (value) { setState(() { - _addressOnly = value; + _walletType = ViewOnlyWalletType.values[value+1]; }); }, decoration: BoxDecoration( @@ -420,8 +436,10 @@ class _RestoreViewOnlyWalletViewState ), ), ), - SizedBox(height: isDesktop ? 24 : 16), - if (!isElectrumX || _addressOnly) + SizedBox( + height: isDesktop ? 24 : 16, + ), + if (!isElectrumX || _walletType == ViewOnlyWalletType.addressOnly) FullTextField( key: const Key("viewOnlyAddressRestoreFieldKey"), label: "Address", @@ -441,8 +459,11 @@ class _RestoreViewOnlyWalletViewState } }, ), - if (!isElectrumX) SizedBox(height: isDesktop ? 16 : 12), - if (isElectrumX && !_addressOnly) + if (!isElectrumX) + SizedBox( + height: isDesktop ? 16 : 12, + ), + if (isElectrumX && _walletType == ViewOnlyWalletType.xPub) DropdownButtonHideUnderline( child: DropdownButton2( value: _currentDropDownValue, @@ -513,9 +534,11 @@ class _RestoreViewOnlyWalletViewState ), ), ), - if (isElectrumX && !_addressOnly) - SizedBox(height: isDesktop ? 16 : 12), - if (!isElectrumX || !_addressOnly) + if (isElectrumX && _walletType == ViewOnlyWalletType.xPub) + SizedBox( + height: isDesktop ? 16 : 12, + ), + if (!isElectrumX || _walletType == ViewOnlyWalletType.xPub) FullTextField( key: const Key("viewOnlyKeyRestoreFieldKey"), label: @@ -536,6 +559,21 @@ class _RestoreViewOnlyWalletViewState } }, ), + if (_walletType == ViewOnlyWalletType.spark) + SizedBox( + height: isDesktop ? 16 : 12, + ), + if (_walletType == ViewOnlyWalletType.spark) + FullTextField( + key: const Key("viewOnlySparkViewKeyRestoreFieldKey"), + label: "Spark View Key", + controller: sparkViewKeyController, + onChanged: (value) { + setState(() { + _enableRestoreButton = value.isNotEmpty; + }); + }, + ), if (!isDesktop) const Spacer(), SizedBox(height: isDesktop ? 24 : 16), PrimaryButton( diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index 5fa78b359a..66b8c88a7c 100644 --- a/lib/pages/receive_view/receive_view.dart +++ b/lib/pages/receive_view/receive_view.dart @@ -183,16 +183,16 @@ class _ReceiveViewState extends ConsumerState { if (wallet is Bip39HDWallet && wallet is! BCashInterface) { DerivePathType? type; if (wallet.isViewOnly && wallet is ExtendedKeysInterface) { - final voData = - await wallet.getViewOnlyWalletData() - as ExtendedKeysViewOnlyWalletData; + final voData = await wallet.getViewOnlyWalletData(); for (final t in wallet.cryptoCurrency.supportedDerivationPathTypes) { final testPath = wallet.cryptoCurrency.constructDerivePath( derivePathType: t, chain: 0, index: 0, ); - if (testPath.startsWith(voData.xPubs.first.path)) { + if (voData is SparkViewOnlyWalletData) { + type = t; + } else if (testPath.startsWith((voData as ExtendedKeysViewOnlyWalletData).xPubs.first.path)) { type = t; break; } 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 index 2659860879..ef68684fe6 100644 --- 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 @@ -81,6 +81,22 @@ class ViewOnlyWalletDataWidget extends StatelessWidget { ), ], ), + final SparkViewOnlyWalletData e => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DetailItem( + title: "View Key", + detail: e.viewKey, + button: Util.isDesktop + ? IconCopyButton( + data: e.viewKey, + ) + : SimpleCopyButton( + data: e.viewKey, + ), + ), + ], + ), }; } } diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart new file mode 100644 index 0000000000..13c24d57ae --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart @@ -0,0 +1,179 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/assets.dart'; +import '../../../../utilities/clipboard_interface.dart'; +import '../../../../utilities/constants.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../utilities/util.dart'; +import '../../../../widgets/background.dart'; +import '../../../../widgets/conditional_parent.dart'; +import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../../widgets/desktop/desktop_dialog.dart'; +import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/detail_item.dart'; +import '../../../../widgets/qr.dart'; +import '../../../../widgets/rounded_white_container.dart'; +import '../../../../notifications/show_flush_bar.dart'; + +class SparkViewKeyView extends ConsumerStatefulWidget { + const SparkViewKeyView({ + super.key, + required this.walletId, + required this.sparkViewKeyHex, + this.clipboardInterface = const ClipboardWrapper(), + }); + + final String walletId; + final String sparkViewKeyHex; + final ClipboardInterface clipboardInterface; + + static const String routeName = "/spark_view_key"; + + @override + ConsumerState createState() => _SparkViewKeyViewState(); +} + +class _SparkViewKeyViewState extends ConsumerState { + Future _copy() async { + await widget.clipboardInterface.setData( + ClipboardData(text: widget.sparkViewKeyHex), + ); + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final bool isDesktop = Util.isDesktop; + + return ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Spark View Key", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 12, left: 16, right: 16), + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + children: [ + Expanded(child: child), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + child: ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopDialog( + maxWidth: 600, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Spark View Key", + style: STextStyles.desktopH2(context), + ), + ), + DesktopDialogCloseButton( + onPressedOverride: Navigator.of(context, rootNavigator: true).pop, + ), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.fromLTRB(32, 0, 32, 32), + child: SingleChildScrollView(child: child), + ), + ), + ], + ), + ), + child: Column( + mainAxisSize: Util.isDesktop ? MainAxisSize.min : MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(height: Util.isDesktop ? 12 : 16), + QR( + data: widget.sparkViewKeyHex, + size: Util.isDesktop ? 256 : MediaQuery.of(context).size.width / 1.5, + ), + SizedBox(height: Util.isDesktop ? 12 : 16), + RoundedWhiteContainer( + borderColor: Util.isDesktop + ? Theme.of(context).extension()!.textFieldDefaultBG + : null, + child: SelectableText( + widget.sparkViewKeyHex, + style: STextStyles.w500_14(context), + ), + ), + SizedBox(height: Util.isDesktop ? 12 : 16), + if (!Util.isDesktop) const Spacer(), + Row( + children: [ + if (Util.isDesktop) const Spacer(), + if (Util.isDesktop) const SizedBox(width: 16), + Expanded(child: PrimaryButton(label: "Copy", onPressed: _copy)), + ], + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index b7929f1329..d2ce19bb6c 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -103,6 +103,7 @@ import '../send_view/frost_ms/frost_send_view.dart'; import '../send_view/send_view.dart'; import '../settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart'; import '../settings_views/wallet_settings_view/wallet_settings_view.dart'; +import '../settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart'; import '../spark_names/spark_names_home_view.dart'; import '../token_view/my_tokens_view.dart'; import 'sub_widgets/transactions_list.dart'; @@ -420,6 +421,27 @@ class _WalletViewState extends ConsumerState { } } + Future _onShowSparkViewKeyPressed(BuildContext context) async { + unawaited( + showDialog( + context: context, + builder: (_) => const LoadingIndicator(width: 100), + ), + ); + + final wallet = ref.read(pWallets).getWallet(walletId) as SparkInterface; + await wallet.init(); + final sparkViewKeyHex = wallet.viewKeyHex; + + if (context.mounted) { + Navigator.of(context).pop(); // Close loading dialog + await Navigator.of(context).pushNamed( + SparkViewKeyView.routeName, + arguments: (walletId, sparkViewKeyHex), + ); + } + } + Future attemptAnonymize() async { bool shouldPop = false; unawaited( @@ -621,7 +643,7 @@ class _WalletViewState extends ConsumerState { context, ).extension()!.background, icon: _buildNetworkIcon(_currentSyncStatus), - onPressed: () { + onPressed: () { Navigator.of(context).pushNamed( WalletNetworkSettingsView.routeName, arguments: Tuple3( @@ -996,7 +1018,7 @@ class _WalletViewState extends ConsumerState { ), SafeArea( child: WalletNavigationBar( - items: [ + items: [ WalletNavigationBarItemData( label: "Receive", icon: const ReceiveNavIcon(), @@ -1080,6 +1102,8 @@ class _WalletViewState extends ConsumerState { icon: const BuyNavIcon(), onTap: () => _onBuyPressed(context), ), + ], + moreItems: [ if (wallet is SparkInterface) WalletNavigationBarItemData( label: "Names", @@ -1091,8 +1115,22 @@ class _WalletViewState extends ConsumerState { ); }, ), - ], - moreItems: [ + if (wallet is SparkInterface) + WalletNavigationBarItemData( + label: "Show Spark View Key", + icon: SvgPicture.asset( + Assets.svg.eye, + height: 20, + width: 20, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.bottomNavIconIcon, + BlendMode.srcIn, + ), + ), + onTap: () => _onShowSparkViewKeyPressed(context), + ), if (ref.watch( pWallets.select( (value) => value 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 607bf3f9c5..713521b14c 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 @@ -197,16 +197,16 @@ class _DesktopReceiveState extends ConsumerState { if (wallet is Bip39HDWallet && wallet is! BCashInterface) { DerivePathType? type; if (wallet.isViewOnly && wallet is ExtendedKeysInterface) { - final voData = - await wallet.getViewOnlyWalletData() - as ExtendedKeysViewOnlyWalletData; + final voData = await wallet.getViewOnlyWalletData(); for (final t in wallet.cryptoCurrency.supportedDerivationPathTypes) { final testPath = wallet.cryptoCurrency.constructDerivePath( derivePathType: t, chain: 0, index: 0, ); - if (testPath.startsWith(voData.xPubs.first.path)) { + if (voData is SparkViewOnlyWalletData) { + type = t; + } else if (testPath.startsWith((voData as ExtendedKeysViewOnlyWalletData).xPubs.first.path)) { type = t; break; } @@ -327,8 +327,10 @@ class _DesktopReceiveState extends ConsumerState { isMimblewimblecoin = wallet is MimblewimblecoinWallet; - if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { + if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly && wallet.viewOnlyType == ViewOnlyWalletType.spark) { showMultiType = false; + } else if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { + showMultiType = supportsSpark; } else { showMultiType = supportsSpark || @@ -338,7 +340,11 @@ class _DesktopReceiveState extends ConsumerState { wallet.supportedAddressTypes.length > 1); } - _walletAddressTypes.add(wallet.info.mainAddressType); + if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly && wallet.viewOnlyType == ViewOnlyWalletType.spark) { + _walletAddressTypes.add(AddressType.spark); + } else { + _walletAddressTypes.add(wallet.info.mainAddressType); + } if (showMultiType) { if (supportsSpark) { 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 9a933e5116..a3c281f3c3 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 @@ -18,6 +18,7 @@ import '../../../../pages/settings_views/wallet_settings_view/frost_ms/frost_ms_ import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/change_representative_view.dart'; import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart'; import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/xpub_view.dart'; +import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart'; import '../../../../providers/global/wallets_provider.dart'; import '../../../../route_generator.dart'; import '../../../../themes/stack_colors.dart'; @@ -31,6 +32,7 @@ import '../../../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../addresses/desktop_wallet_addresses_view.dart'; import 'desktop_delete_wallet_dialog.dart'; @@ -41,7 +43,8 @@ enum _WalletOptions { changeRepresentative, showXpub, frostOptions, - refreshFromHeight; + refreshFromHeight, + showSparkKey; String get prettyName { switch (this) { @@ -57,6 +60,8 @@ enum _WalletOptions { return "FROST settings"; case _WalletOptions.refreshFromHeight: return "Refresh height"; + case _WalletOptions.showSparkKey: + return "Show Spark View Key"; } } } @@ -136,6 +141,38 @@ class WalletOptionsButton extends ConsumerWidget { } } break; + case _WalletOptions.showSparkKey: + final wallet = ref.read(pWallets).getWallet(walletId) as SparkInterface; + await wallet.init(); + final sparkViewKeyHex = wallet.viewKeyHex; + + if (context.mounted) { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => Navigator( + initialRoute: SparkViewKeyView.routeName, + onGenerateRoute: RouteGenerator.generateRoute, + onGenerateInitialRoutes: (_, __) { + return [ + RouteGenerator.generateRoute( + RouteSettings( + name: SparkViewKeyView.routeName, + arguments: (walletId, sparkViewKeyHex), + ), + ), + ]; + }, + ), + ); + + if (result == true) { + if (context.mounted) { + Navigator.of(context).pop(); + } + } + } + break; case _WalletOptions.showXpub: final xpubData = await showLoading( delay: const Duration(milliseconds: 800), @@ -286,6 +323,7 @@ class WalletOptionsPopupMenu extends ConsumerWidget { final bool isFrost = coin is FrostCurrency; final bool isCN = wallet is CryptonoteWallet; + final bool isSpark = wallet is SparkInterface; return Stack( children: [ @@ -481,6 +519,45 @@ class WalletOptionsPopupMenu extends ConsumerWidget { ), ), ), + if (isSpark) + const SizedBox( + height: 8, + ), + if (isSpark) + TransparentButton( + onPressed: () { + Navigator.of(context).pop(_WalletOptions.showSparkKey); + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SvgPicture.asset( + Assets.svg.eye, + width: 20, + height: 20, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconLeft, + ), + const SizedBox(width: 14), + Expanded( + child: Text( + _WalletOptions.showSparkKey.prettyName, + style: STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + ), + ], + ), + ), + ), const SizedBox(height: 8), TransparentButton( onPressed: onDeletePressed, diff --git a/lib/route_generator.dart b/lib/route_generator.dart index b44550bafc..693940b58f 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -224,6 +224,7 @@ import 'wallets/wallet/wallet.dart'; import 'wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; import 'widgets/choose_coin_view.dart'; import 'widgets/frost_scaffold.dart'; +import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart'; /* * This file contains all the routes for the app. @@ -2508,6 +2509,19 @@ class RouteGenerator { // == End of desktop specific routes ===================================== + case SparkViewKeyView.routeName: + if (args is (String, String)) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SparkViewKeyView( + walletId: args.$1, + sparkViewKeyHex: args.$2, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + default: return _routeError(""); } diff --git a/lib/utilities/util.dart b/lib/utilities/util.dart index 5480e08048..9fddfcf847 100644 --- a/lib/utilities/util.dart +++ b/lib/utilities/util.dart @@ -38,11 +38,10 @@ abstract class Util { return false; } - // special check for running under ipad mode in macos - if (Platform.isIOS && - libraryPath != null && - !libraryPath!.path.contains("/var/mobile/")) { - return true; + // iOS devices and simulators should use mobile UI + // Only desktop platforms should use desktop UI + if (Platform.isIOS) { + return false; } return Platform.isLinux || Platform.isMacOS || Platform.isWindows; diff --git a/lib/wallets/crypto_currency/coins/firo.dart b/lib/wallets/crypto_currency/coins/firo.dart index f432bd77bc..75bd15c07c 100644 --- a/lib/wallets/crypto_currency/coins/firo.dart +++ b/lib/wallets/crypto_currency/coins/firo.dart @@ -197,10 +197,7 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { } bool validateSparkAddress(String address) { - return SparkInterface.validateSparkAddress( - address: address, - isTestNet: network.isTestNet, - ); + return SparkInterface.validateSparkAddress(address: address, isTestNet: network.isTestNet); } bool isExchangeAddress(String address) { diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart index e55052e903..5cfc895831 100644 --- a/lib/wallets/wallet/impl/firo_wallet.dart +++ b/lib/wallets/wallet/impl/firo_wallet.dart @@ -24,6 +24,7 @@ import '../wallet_mixin_interfaces/coin_control_interface.dart'; import '../wallet_mixin_interfaces/electrumx_interface.dart'; import '../wallet_mixin_interfaces/extended_keys_interface.dart'; import '../wallet_mixin_interfaces/spark_interface.dart'; +import '../../../models/keys/view_only_wallet_data.dart'; const sparkStartBlock = 819300; // (approx 18 Jan 2024) @@ -50,6 +51,17 @@ class FiroWallet extends Bip39HDWallet final Set _unconfirmedTxids = {}; + @override + Set get supportedAddressTypes { + if (isViewOnly && viewOnlyType == ViewOnlyWalletType.spark) { + return {AddressType.spark}; + } else { + final supportedAddressTypes = super.supportedAddressTypes; + supportedAddressTypes.add(AddressType.spark); + return supportedAddressTypes; + } + } + // =========================================================================== @override @@ -345,7 +357,7 @@ class FiroWallet extends Bip39HDWallet output = output.copyWith(walletOwns: true); } else if (isSparkMint && isMySpark) { wasReceivedInThisWallet = true; - if (output.addresses.contains(sparkChangeAddress)) { + if (output.addresses.contains(sparkChangeAddress.value)) { changeAmountReceivedInThisWallet += output.value; } else { amountReceivedInThisWallet += output.value; @@ -668,9 +680,26 @@ class FiroWallet extends Bip39HDWallet return (blockedReason: blockedReason, blocked: blocked, utxoLabel: label); } + @override + Future> fetchAddressesForElectrumXScan() async { + return await mainDB + .getAddresses(walletId) + .filter() + .not() + .group( + (q) => q + .typeEqualTo(AddressType.spark) + .or() + .typeEqualTo(AddressType.nonWallet) + .or() + .subTypeEqualTo(AddressSubType.nonWallet), + ) + .findAll(); + } + @override Future recover({required bool isRescan}) async { - if (isViewOnly) { + if (isViewOnly && viewOnlyType != ViewOnlyWalletType.spark) { await recoverViewOnly(isRescan: isRescan); return; } @@ -684,7 +713,6 @@ class FiroWallet extends Bip39HDWallet ); final start = DateTime.now(); - final root = await getRootHDNode(); final List addresses})>> receiveFutures = []; @@ -731,22 +759,26 @@ class FiroWallet extends Bip39HDWallet final canBatch = await serverCanBatch; - for (final type in cryptoCurrency.supportedDerivationPathTypes) { - receiveFutures.add( - canBatch - ? checkGapsBatched(txCountBatchSize, root, type, receiveChain) - : checkGapsLinearly(root, type, receiveChain), - ); - } + if (!isViewOnly || viewOnlyType != ViewOnlyWalletType.spark) { + final root = await getRootHDNode(); - // change addresses - Logging.instance.d("checking change addresses..."); - for (final type in cryptoCurrency.supportedDerivationPathTypes) { - changeFutures.add( - canBatch - ? checkGapsBatched(txCountBatchSize, root, type, changeChain) - : checkGapsLinearly(root, type, changeChain), - ); + for (final type in cryptoCurrency.supportedDerivationPathTypes) { + receiveFutures.add( + canBatch + ? checkGapsBatched(txCountBatchSize, root, type, receiveChain) + : checkGapsLinearly(root, type, receiveChain), + ); + } + + // change addresses + Logging.instance.d("checking change addresses..."); + for (final type in cryptoCurrency.supportedDerivationPathTypes) { + changeFutures.add( + canBatch + ? checkGapsBatched(txCountBatchSize, root, type, changeChain) + : checkGapsLinearly(root, type, changeChain), + ); + } } // io limitations may require running these linearly instead diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 26106e6778..c325584ba4 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -1478,7 +1478,9 @@ mixin ElectrumXInterface @override Future checkReceivingAddressForTransactions() async { - if (isViewOnly && viewOnlyType == ViewOnlyWalletType.addressOnly) { + if (isViewOnly && + (viewOnlyType == ViewOnlyWalletType.addressOnly || + viewOnlyType == ViewOnlyWalletType.spark)) { return; } @@ -1533,7 +1535,9 @@ mixin ElectrumXInterface @override Future checkChangeAddressForTransactions() async { - if (isViewOnly && viewOnlyType == ViewOnlyWalletType.addressOnly) { + if (isViewOnly && + (viewOnlyType == ViewOnlyWalletType.addressOnly || + viewOnlyType == ViewOnlyWalletType.spark)) { return; } @@ -2120,7 +2124,7 @@ mixin ElectrumXInterface final data = await getViewOnlyWalletData(); final coinlib.HDKey? root; - if (data is AddressViewOnlyWalletData) { + if (data is AddressViewOnlyWalletData || data is SparkViewOnlyWalletData) { root = null; } else { if ((data as ExtendedKeysViewOnlyWalletData).xPubs.length != 1) { diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index d9059a5bb1..03443fcd0e 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -11,6 +11,7 @@ import 'package:logger/logger.dart'; import '../../../db/drift/database.dart' show Drift; import '../../../db/sqlite/firo_cache.dart'; import '../../../models/balance.dart'; +import '../../../models/electrumx_response/spark_models.dart'; import '../../../models/input.dart'; import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; @@ -33,6 +34,7 @@ import '../../models/tx_data.dart'; import '../intermediate/bip39_hd_wallet.dart'; import 'cpfp_interface.dart'; import 'electrumx_interface.dart'; +import '../../../models/keys/view_only_wallet_data.dart'; const kDefaultSparkIndex = 1; @@ -103,22 +105,148 @@ Future computeWithLibSparkLogging( return _SparkIsolate.run(callback, message); } -mixin SparkInterface - on Bip39HDWallet, ElectrumXInterface { - String? _sparkChangeAddressCached; - /// Spark change address. Should generally not be exposed to end users. - String get sparkChangeAddress { - if (_sparkChangeAddressCached == null) { - throw Exception("_sparkChangeAddressCached was not initialized"); +mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { + late Address _currentSparkAddress; + + late String viewKeyHex; + + // Really we should just send change back to the same address. + late Address sparkChangeAddress; + + bool get isTestNet { + return cryptoCurrency.network.isTestNet; + } + + // This is the BIP44 derivation path for the spark private key; spark public keys will have their own derivation path. + String get sparkDerivationPath { + // NOTE: This is reusing the sparkIndex for backwards compatibility, but these are actually distinct things which do + // not have to be the same. sparkIndex has nothing at all to do with the derivation path. + if (isTestNet) { + return "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex"; + } else { + return "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex"; } - return _sparkChangeAddressCached!; } - static bool validateSparkAddress({ - required String address, - required bool isTestNet, - }) => libSpark.validateAddress(address: address, isTestNet: isTestNet); + // This is the index for the spark key, which is NOT the diversifier or the BIP44 derivation path (which generates the + // private key data). + int get sparkIndex => kDefaultSparkIndex; + + Future
generateSparkAddress(int diversifier) async { + final sparkAddress = await libSpark.getAddressFromFullViewKey( + fullViewKeyHex: viewKeyHex, + index: sparkIndex, + diversifier: diversifier, + isTestNet: isTestNet, + ); + + return Address( + walletId: walletId, + value: sparkAddress, + publicKey: [], + derivationIndex: diversifier, + derivationPath: DerivationPath()..value = sparkDerivationPath, + type: AddressType.spark, + subType: AddressSubType.receiving, + ); + } + + static bool validateSparkAddress({required String address, required bool isTestNet}) { + return libSpark.validateAddress(address: address, isTestNet: isTestNet); + } + + Future> identifyCoins({ + required List anonymitySetCoins, + required int groupId, + }) async { + return await computeWithLibSparkLogging( + identifyCoinsStatic, + ( + walletId_: walletId, + viewKeyHex_: viewKeyHex, + isTestNet_: isTestNet, + anonymitySetCoins: anonymitySetCoins, + groupId: groupId, + ), + ); + } + + static Future> identifyCoinsStatic(({ + List anonymitySetCoins, + int groupId, + bool isTestNet_, + String viewKeyHex_, + String walletId_, + }) args) async { + final List myCoins = []; + + for (final dynData in args.anonymitySetCoins) { + final data = List.from(dynData as List); + + if (data.length != 3) { + Logging.instance.e("Unexpected serialized coin info found", error: data); + continue; + } + + final serializedCoinB64 = data[0]; + final txHash = data[1].toHexReversedFromBase64; + final contextB64 = data[2]; + + final WrappedLibSparkCoin? coin; + try { + coin = libSpark.identifyAndRecoverCoinByFullViewKey( + serializedCoinB64, + fullViewKeyHex: args.viewKeyHex_, + context: base64Decode(contextB64), + isTestNet: args.isTestNet_, + ); + } catch (e) { + Logging.instance.e("Failed to identify coin", error: e); + continue; + } + + // its ours + if (coin != null) { + final SparkCoinType coinType; + switch (coin.type.value) { + case 0: + coinType = SparkCoinType.mint; + case 1: + coinType = SparkCoinType.spend; + default: + Logging.instance.e("Unknown spark coin type detected", error: coin.type.value); + continue; + } + + myCoins.add( + SparkCoin( + walletId: args.walletId_, + type: coinType, + // isUsed is a placeholder value here; its value is incorrect. + isUsed: false, + groupId: args.groupId, + nonce: coin.nonceHex?.toUint8ListFromHex, + address: coin.address!, + txHash: txHash, + valueIntString: coin.value!.toString(), + memo: coin.memo, + serialContext: coin.serialContext, + diversifierIntString: coin.diversifier!.toString(), + encryptedDiversifier: coin.encryptedDiversifier, + serial: coin.serial, + tag: coin.tag, + lTagHash: coin.lTagHash!, + height: coin.height, + serializedCoinB64: serializedCoinB64, + contextB64: contextB64, + ), + ); + } + } + + return myCoins; + } Future hashTag(String tag) async { try { @@ -130,128 +258,90 @@ mixin SparkInterface @override Future init() async { - try { - final sparkUsedTagsResetVersion = - info.otherData[WalletInfoKeys.firoSparkUsedTagsCacheResetVersion] - as int? ?? - 0; - if (sparkUsedTagsResetVersion == 0) { - await info.updateOtherData( - newEntries: {WalletInfoKeys.firoSparkUsedTagsCacheResetVersion: 1}, - isar: mainDB.isar, - ); - await FiroCacheCoordinator.clearSharedCache( - cryptoCurrency.network, - clearOnlyUsedTagsCache: true, - ); - } + final sparkUsedTagsResetVersion = + info.otherData[WalletInfoKeys.firoSparkUsedTagsCacheResetVersion] + as int? ?? + 0; - Address? address = await getCurrentReceivingSparkAddress(); - if (address == null) { - address = await generateNextSparkAddress(); - await mainDB.putAddress(address); - } // TODO add other address types to wallet info? - - if (_sparkChangeAddressCached == null) { - final root = await getRootHDNode(); - final String derivationPath; - if (cryptoCurrency.network.isTestNet) { - derivationPath = - "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex"; - } else { - derivationPath = - "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex"; - } - final keys = root.derivePath(derivationPath); + if (sparkUsedTagsResetVersion == 0) { + await info.updateOtherData( + newEntries: {WalletInfoKeys.firoSparkUsedTagsCacheResetVersion: 1}, + isar: mainDB.isar, + ); + await FiroCacheCoordinator.clearSharedCache( + cryptoCurrency.network, + clearOnlyUsedTagsCache: true, + ); + } - _sparkChangeAddressCached = await libSpark.getAddress( - privateKey: keys.privateKey.data, - index: kDefaultSparkIndex, - diversifier: libSpark.sparkChange, - isTestNet: cryptoCurrency.network.isTestNet, - ); - } - } catch (e, s) { - // do nothing, still allow user into wallet - Logging.instance.e("$runtimeType init() failed", error: e, stackTrace: s); + if (isViewOnly) { + final walletData = await getViewOnlyWalletData() as SparkViewOnlyWalletData; + viewKeyHex = walletData.viewKey; + } else { + final root = await getRootHDNode(); + final privateKey = root.derivePath(sparkDerivationPath).privateKey.data; + viewKeyHex = libSpark.getFullViewKeyHexFromPrivateKeyData(privateKeyHex: privateKey.toHex, index: sparkIndex); } - // await info.updateReceivingAddress( - // newAddress: address.value, - // isar: mainDB.isar, - // ); + Address? address = await getCurrentReceivingSparkAddress(); + if (address == null) { + address = await generateSparkAddress(1); + await mainDB.putAddress(address); + } + + if (address.derivationIndex == -1) { + throw Exception("Error finding spark receiving address"); + } + + _currentSparkAddress = address; + sparkChangeAddress = await generateSparkAddress(libSpark.sparkChange); await super.init(); } @override Future> fetchAddressesForElectrumXScan() async { - final allAddresses = await mainDB - .getAddresses(walletId) - .filter() - .not() - .group( - (q) => q - .typeEqualTo(AddressType.spark) - .or() - .typeEqualTo(AddressType.nonWallet) - .or() - .subTypeEqualTo(AddressSubType.nonWallet), - ) - .findAll(); - return allAddresses; + return await mainDB + .getAddresses(walletId) + .filter() + .not() + .group( + (q) => q + .typeEqualTo(AddressType.spark) + .or() + .typeEqualTo(AddressType.nonWallet) + .or() + .subTypeEqualTo(AddressSubType.nonWallet), + ) + .findAll(); } Future getCurrentReceivingSparkAddress() async { - return await mainDB.isar.addresses + try { + // if _currentSparkAddress is not initialized, this will throw. + return _currentSparkAddress; + } catch (e) { + return await mainDB.isar.addresses .where() .walletIdEqualTo(walletId) .filter() .typeEqualTo(AddressType.spark) .sortByDerivationIndexDesc() .findFirst(); + } } Future
generateNextSparkAddress() async { - final highestStoredDiversifier = - (await getCurrentReceivingSparkAddress())?.derivationIndex; - - // default to starting at 1 if none found - int diversifier = (highestStoredDiversifier ?? 0) + 1; - // change address check - if (diversifier == libSpark.sparkChange) { - diversifier++; - } - - final root = await getRootHDNode(); - final String derivationPath; - if (cryptoCurrency.network.isTestNet) { - derivationPath = - "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex"; - } else { - derivationPath = "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex"; - } - final keys = root.derivePath(derivationPath); - - final String addressString = await libSpark.getAddress( - privateKey: keys.privateKey.data, - index: kDefaultSparkIndex, - diversifier: diversifier, - isTestNet: cryptoCurrency.network.isTestNet, - ); - - return Address( - walletId: walletId, - value: addressString, - publicKey: keys.publicKey.data, - derivationIndex: diversifier, - derivationPath: DerivationPath()..value = derivationPath, - type: AddressType.spark, - subType: AddressSubType.receiving, - ); + final newAddress = await generateSparkAddress(_currentSparkAddress.derivationIndex + 1); + _currentSparkAddress = newAddress; + return newAddress; } Future estimateFeeForSpark(Amount amount) async { + if (isViewOnly) { + throw Exception("Fee estimation is not supported for view only wallets"); + } + final spendAmount = amount.raw.toInt(); if (spendAmount == 0) { return Amount( @@ -296,18 +386,10 @@ mixin SparkInterface .toList(); final root = await getRootHDNode(); - final String derivationPath; - if (cryptoCurrency.network.isTestNet) { - derivationPath = - "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex"; - } else { - derivationPath = - "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex"; - } - final privateKey = root.derivePath(derivationPath).privateKey.data; + final privateKey = root.derivePath(sparkDerivationPath).privateKey.data; int estimate = await _asyncSparkFeesWrapper( privateKeyHex: privateKey.toHex, - index: kDefaultSparkIndex, + index: sparkIndex, sendAmount: spendAmount, subtractFeeFromAmount: true, serializedCoins: serializedCoins, @@ -330,6 +412,10 @@ mixin SparkInterface /// Spark to Spark/Transparent (spend) creation Future prepareSendSpark({required TxData txData}) async { + if (isViewOnly) { + throw Exception("Spending is not supported for view only wallets"); + } + // There should be at least one output. if (!(txData.recipients?.isNotEmpty == true || txData.sparkRecipients?.isNotEmpty == true)) { @@ -462,14 +548,7 @@ mixin SparkInterface .toList(); final root = await getRootHDNode(); - final String derivationPath; - if (cryptoCurrency.network.isTestNet) { - derivationPath = - "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex"; - } else { - derivationPath = "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex"; - } - final privateKey = root.derivePath(derivationPath).privateKey.data; + final privateKey = root.derivePath(sparkDerivationPath).privateKey.data; final txb = btc.TransactionBuilder(network: _bitcoinDartNetwork); txb.setLockTime(await chainHeight); @@ -487,7 +566,7 @@ mixin SparkInterface if (isSendAll) { final estFee = await _asyncSparkFeesWrapper( privateKeyHex: privateKey.toHex, - index: kDefaultSparkIndex, + index: sparkIndex, sendAmount: txAmount.raw.toInt(), subtractFeeFromAmount: true, serializedCoins: serializedCoins, @@ -517,7 +596,7 @@ mixin SparkInterface fractionDigits: cryptoCurrency.fractionDigits, ), memo: txData.sparkRecipients![i].memo, - isChange: sparkChangeAddress == txData.sparkRecipients![i].address, + isChange: sparkChangeAddress.value == txData.sparkRecipients![i].address, )); } @@ -606,7 +685,7 @@ mixin SparkInterface additionalInfo: txData.sparkNameInfo!.additionalInfo, scalarHex: extractedTx.getId(), privateKeyHex: privateKey.toHex, - spendKeyIndex: kDefaultSparkIndex, + spendKeyIndex: sparkIndex, diversifier: txData.sparkNameInfo!.sparkAddress.derivationIndex, isTestNet: cryptoCurrency.network != CryptoCurrencyNetwork.main, ignoreProof: true, @@ -616,7 +695,7 @@ mixin SparkInterface final spend = await computeWithLibSparkLogging(_createSparkSend, ( privateKeyHex: privateKey.toHex, - index: kDefaultSparkIndex, + index: sparkIndex, recipients: txData.recipients ?.map( @@ -679,7 +758,7 @@ mixin SparkInterface additionalInfo: txData.sparkNameInfo!.additionalInfo, scalarHex: hash, privateKeyHex: privateKey.toHex, - spendKeyIndex: kDefaultSparkIndex, + spendKeyIndex: sparkIndex, diversifier: txData.sparkNameInfo!.sparkAddress.derivationIndex, isTestNet: cryptoCurrency.network != CryptoCurrencyNetwork.main, ignoreProof: false, @@ -783,6 +862,10 @@ mixin SparkInterface // this may not be needed for either mints or spends or both Future confirmSendSpark({required TxData txData}) async { + if (isViewOnly) { + throw Exception("Spending is not supported for view only wallets"); + } + try { Logging.instance.d("confirmSend txData: $txData"); @@ -822,77 +905,64 @@ mixin SparkInterface Set _mempoolTxidsChecked = {}; Future> _refreshSparkCoinsMempoolCheck({ - required Set privateKeyHexSet, required int groupId, }) async { final start = DateTime.now(); - try { - // update cache - _mempoolTxids = await electrumXClient.getMempoolTxids(); - // remove any checked txids that are not in the mempool anymore - _mempoolTxidsChecked = _mempoolTxidsChecked.intersection(_mempoolTxids); + // update cache + _mempoolTxids = await electrumXClient.getMempoolTxids(); - // get all unchecked txids currently in mempool - final txidsToCheck = _mempoolTxids.difference(_mempoolTxidsChecked); - if (txidsToCheck.isEmpty) { - return []; - } + // remove any checked txids that are not in the mempool anymore + _mempoolTxidsChecked = _mempoolTxidsChecked.intersection(_mempoolTxids); - // fetch spark data to scan if we own any unconfirmed spark coins - final sparkDataToCheck = await electrumXClient.getMempoolSparkData( + // get all unchecked txids currently in mempool + final txidsToCheck = _mempoolTxids.difference(_mempoolTxidsChecked); + if (txidsToCheck.isEmpty) { + return []; + } + + // fetch spark data to scan if we own any unconfirmed spark coins + List sparkDataToCheck = []; + try { + sparkDataToCheck = await electrumXClient.getMempoolSparkData( txids: txidsToCheck.toList(), ); + } catch (e, s) { + Logging.instance.e( + "Exception rethrown from _refreshSparkCoinsMempoolCheck(): ", + error: e, + stackTrace: s, + ); + return []; + } - final Set checkedTxids = {}; - final List> rawCoins = []; - - for (final data in sparkDataToCheck) { - for (int i = 0; i < data.coins.length; i++) { - rawCoins.add([data.coins[i], data.txid, data.serialContext.first]); - } + final Set checkedTxids = {}; + final List> rawCoins = []; - checkedTxids.add(data.txid); + for (final data in sparkDataToCheck) { + for (int i = 0; i < data.coins.length; i++) { + rawCoins.add([data.coins[i], data.txid, data.serialContext.first]); } - final result = []; + checkedTxids.add(data.txid); + } - // if there is new data we try and identify the coins - if (rawCoins.isNotEmpty) { - // run identify off main isolate - final myCoins = await computeWithLibSparkLogging(_identifyCoins, ( - anonymitySetCoins: rawCoins, - groupId: groupId, - privateKeyHexSet: privateKeyHexSet, - walletId: walletId, - isTestNet: cryptoCurrency.network.isTestNet, - )); + // if there is new data we try and identify the coins + final List myCoins = await identifyCoins( + anonymitySetCoins: rawCoins, + groupId: groupId, + ); - // add checked txids after identification - _mempoolTxidsChecked.addAll(checkedTxids); - for (final coin in myCoins) { - final match = sparkDataToCheck.firstWhere( - (e) => e.serialContext.contains(coin.contextB64!), - ); - result.add(coin.copyWith(isLocked: match.isLocked)); - } - } + // add checked txids after identification + _mempoolTxidsChecked.addAll(checkedTxids); - return result; - } catch (e, s) { - Logging.instance.e( - "_refreshSparkCoinsMempoolCheck() failed", - error: e, - stackTrace: s, - ); - return []; - } finally { - Logging.instance.d( - "$walletId ${info.name} _refreshSparkCoinsMempoolCheck() run " - "duration: ${DateTime.now().difference(start)}", - ); - } + Logging.instance.d( + "Finished _refreshSparkCoinsMempoolCheck(). " + "Duration=${DateTime.now().difference(start)}", + ); + + return myCoins; } // returns next percent @@ -907,10 +977,12 @@ mixin SparkInterface (double startingPercent, double endingPercent)? refreshProgressRange, ) async { final start = DateTime.now(); + try { // start by checking if any previous sets are missing from db and add the // missing groupIds to the list if sets to check and update final latestGroupId = await electrumXClient.getSparkLatestCoinId(); + final List groupIds = []; if (latestGroupId > 1) { for (int id = 1; id < latestGroupId; id++) { @@ -1016,33 +1088,15 @@ mixin SparkInterface currentPercent = _triggerEventHelper(currentPercent, percentIncrement); } - // get address(es) to get the private key hex strings required for - // identifying spark coins - final sparkAddresses = await mainDB.isar.addresses - .where() - .walletIdEqualTo(walletId) - .filter() - .typeEqualTo(AddressType.spark) - .findAll(); - final root = await getRootHDNode(); - final Set privateKeyHexSet = sparkAddresses - .map( - (e) => - root.derivePath(e.derivationPath!.value).privateKey.data.toHex, - ) - .toSet(); - // try to identify any coins in the unchecked set data final List newlyIdCoins = []; for (final groupId in rawCoinsBySetId.keys) { - final myCoins = await computeWithLibSparkLogging(_identifyCoins, ( - anonymitySetCoins: rawCoinsBySetId[groupId]!, - groupId: groupId, - privateKeyHexSet: privateKeyHexSet, - walletId: walletId, - isTestNet: cryptoCurrency.network.isTestNet, - )); - newlyIdCoins.addAll(myCoins); + newlyIdCoins.addAll( + await identifyCoins( + anonymitySetCoins: rawCoinsBySetId[groupId]!, + groupId: groupId, + ), + ); } // if any were found, add to database if (newlyIdCoins.isNotEmpty) { @@ -1064,10 +1118,10 @@ mixin SparkInterface } // check for spark coins in mempool - final mempoolMyCoins = await _refreshSparkCoinsMempoolCheck( - privateKeyHexSet: privateKeyHexSet, + final List mempoolMyCoins = await _refreshSparkCoinsMempoolCheck( groupId: latestGroupId, ); + // if any were found, add to database if (mempoolMyCoins.isNotEmpty) { await mainDB.isar.writeTxn(() async { @@ -1213,12 +1267,6 @@ mixin SparkInterface /// Should only be called within the standard wallet [recover] function due to /// mutex locking. Otherwise behaviour MAY be undefined. Future recoverSparkWallet({required int latestSparkCoinId}) async { - // generate spark addresses if non existing - if (await getCurrentReceivingSparkAddress() == null) { - final address = await generateNextSparkAddress(); - await mainDB.putAddress(address); - } - try { await refreshSparkData(null); } catch (e, s) { @@ -1231,6 +1279,10 @@ mixin SparkInterface } } + Future recoverViewOnlyWallet() async { + await recoverSparkWallet(latestSparkCoinId: 0); + } + Future refreshSparkNames() async { try { Logging.instance.i("Refreshing spark names for $walletId ${info.name}"); @@ -1263,38 +1315,13 @@ mixin SparkInterface // TODO revisit this and clean up (track pre gen'd addresses instead of generating every time) // arbitrary number of addresses const lookAheadCount = 100; - final highestStoredDiversifier = - (await getCurrentReceivingSparkAddress())?.derivationIndex; - - final root = await getRootHDNode(); - final String derivationPath; - if (cryptoCurrency.network.isTestNet) { - derivationPath = - "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex"; - } else { - derivationPath = - "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex"; - } - final keys = root.derivePath(derivationPath); - - // default to starting at 1 if none found - int diversifier = (highestStoredDiversifier ?? 0) + 1; + int diversifier = _currentSparkAddress.derivationIndex; final maxDiversifier = diversifier + lookAheadCount; while (diversifier < maxDiversifier) { - // change address check - if (diversifier == libSpark.sparkChange) { - diversifier++; - } - final addressString = await libSpark.getAddress( - privateKey: keys.privateKey.data, - index: kDefaultSparkIndex, - diversifier: diversifier, - isTestNet: cryptoCurrency.network.isTestNet, - ); - - myAddresses.add(addressString); + final addressString = await generateSparkAddress(diversifier); + myAddresses.add(addressString.value); diversifier++; } @@ -1350,6 +1377,10 @@ mixin SparkInterface required bool subtractFeeFromAmount, required bool autoMintAll, }) async { + if (isViewOnly) { + throw Exception("Minting is not supported for view only wallets"); + } + // pre checks if (outputs.isEmpty) { throw Exception("Cannot mint without some recipients"); @@ -1963,6 +1994,10 @@ mixin SparkInterface } Future anonymizeAllSpark() async { + if (isViewOnly) { + throw Exception("Anonymizing is not supported for view only wallets"); + } + try { const subtractFeeFromAmount = true; // must be true for mint all final currentHeight = await chainHeight; @@ -2020,6 +2055,10 @@ mixin SparkInterface /// /// See https://docs.google.com/document/d/1RG52GoYTZDvKlZz_3G4sQu-PpT6JWSZGHLNswWcrE3o Future prepareSparkMintTransaction({required TxData txData}) async { + if (isViewOnly) { + throw Exception("Minting is not supported for view only wallets"); + } + try { if (txData.sparkRecipients?.isNotEmpty != true) { throw Exception("Missing spark recipients."); @@ -2123,6 +2162,10 @@ mixin SparkInterface } Future confirmSparkMintTransactions({required TxData txData}) async { + if (isViewOnly) { + throw Exception("Minting is not supported for view only wallets"); + } + final futures = txData.sparkMints!.map((e) => confirmSend(txData: e)); return txData.copyWith(sparkMints: await Future.wait(futures)); } @@ -2308,79 +2351,6 @@ _createSparkSend( return spend; } -/// Top level function which should be called wrapped in [compute] -Future> _identifyCoins( - ({ - List anonymitySetCoins, - int groupId, - Set privateKeyHexSet, - String walletId, - bool isTestNet, - }) - args, -) async { - final List myCoins = []; - - for (final privateKeyHex in args.privateKeyHexSet) { - for (final dynData in args.anonymitySetCoins) { - final data = List.from(dynData as List); - - if (data.length != 3) { - throw Exception("Unexpected serialized coin info found"); - } - - final serializedCoinB64 = data[0]; - final txHash = data[1].toHexReversedFromBase64; - final contextB64 = data[2]; - - final coin = libSpark.identifyAndRecoverCoin( - serializedCoinB64, - privateKeyHex: privateKeyHex, - index: kDefaultSparkIndex, - context: base64Decode(contextB64), - isTestNet: args.isTestNet, - ); - - // its ours - if (coin != null) { - final SparkCoinType coinType; - switch (coin.type.value) { - case 0: - coinType = SparkCoinType.mint; - case 1: - coinType = SparkCoinType.spend; - default: - throw Exception("Unknown spark coin type detected"); - } - myCoins.add( - SparkCoin( - walletId: args.walletId, - type: coinType, - isUsed: false, - groupId: args.groupId, - nonce: coin.nonceHex?.toUint8ListFromHex, - address: coin.address!, - txHash: txHash, - valueIntString: coin.value!.toString(), - memo: coin.memo, - serialContext: coin.serialContext, - diversifierIntString: coin.diversifier!.toString(), - encryptedDiversifier: coin.encryptedDiversifier, - serial: coin.serial, - tag: coin.tag, - lTagHash: coin.lTagHash!, - height: coin.height, - serializedCoinB64: serializedCoinB64, - contextB64: contextB64, - ), - ); - } - } - } - - return myCoins; -} - BigInt _min(BigInt a, BigInt b) { if (a <= b) { return a; diff --git a/lib/widgets/options.dart b/lib/widgets/options.dart new file mode 100644 index 0000000000..36bccd0c66 --- /dev/null +++ b/lib/widgets/options.dart @@ -0,0 +1,249 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../themes/stack_colors.dart'; +import '../utilities/text_styles.dart'; +import '../utilities/util.dart'; + +class Options extends StatefulWidget { + const Options({ + super.key, + this.icons, + this.texts, + this.onValueChanged, + required this.selectedIndex, + this.controller, + required this.onColor, + required this.offColor, + this.decoration, + }); + + final List? icons; + final List? texts; + final void Function(int)? onValueChanged; + final int selectedIndex; + final DSBController? controller; + final Color onColor; + final Color offColor; + final BoxDecoration? decoration; + + @override + OptionsState createState() => OptionsState(); +} + +class OptionsState extends State { + late final BoxDecoration? decoration; + late final Color onColor; + late final Color offColor; + late final DSBController? controller; + + final bool isDesktop = Util.isDesktop; + + late int _selectedIndex; + int get selectedIndex => _selectedIndex; + + late ValueNotifier valueListener; + + final tapAnimationDuration = const Duration(milliseconds: 150); + bool _isDragging = false; + + @override + initState() { + onColor = widget.onColor; + offColor = widget.offColor; + decoration = widget.decoration; + controller = widget.controller; + _selectedIndex = widget.selectedIndex; + valueListener = ValueNotifier(_selectedIndex.toDouble()); + + widget.controller?.activate = () { + _selectedIndex = (_selectedIndex + 1) % (widget.texts?.length ?? 1); + valueListener.value = _selectedIndex.toDouble(); + }; + super.initState(); + } + + @override + void dispose() { + valueListener.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + final optionsCount = widget.texts?.length ?? 1; + + return GestureDetector( + onTapDown: (details) { + final RenderBox box = context.findRenderObject() as RenderBox; + final localPosition = box.globalToLocal(details.globalPosition); + final optionsCount = widget.texts?.length ?? 1; + final optionWidth = box.size.width / optionsCount; + final tappedIndex = (localPosition.dx / optionWidth).floor().clamp(0, optionsCount - 1); + if (_selectedIndex != tappedIndex) { + _selectedIndex = tappedIndex; + widget.onValueChanged?.call(_selectedIndex); + valueListener.value = _selectedIndex.toDouble(); + setState(() {}); + } + }, + child: LayoutBuilder( + builder: (context, constraint) { + return Stack( + children: [ + AnimatedBuilder( + animation: valueListener, + builder: (context, child) { + return AnimatedContainer( + duration: tapAnimationDuration, + height: constraint.maxHeight, + width: constraint.maxWidth, + decoration: decoration?.copyWith( + color: offColor, + ), + ); + }, + ), + Builder( + builder: (context) { + final handle = GestureDetector( + key: const Key("draggableSwitchButtonSwitch"), + onHorizontalDragStart: (_) => _isDragging = true, + onHorizontalDragUpdate: (details) { + valueListener.value = (valueListener.value + + details.delta.dx / (constraint.maxWidth / optionsCount)) + .clamp(0.0, optionsCount - 1.0); + }, + onHorizontalDragEnd: (details) { + final int oldValue = _selectedIndex; + _selectedIndex = valueListener.value.round(); + if (_selectedIndex != oldValue) { + widget.onValueChanged?.call(_selectedIndex); + setState(() {}); + } + _isDragging = false; + }, + child: AnimatedBuilder( + animation: valueListener, + builder: (context, child) { + return AnimatedContainer( + duration: tapAnimationDuration, + height: constraint.maxHeight, + width: constraint.maxWidth / optionsCount, + decoration: decoration?.copyWith( + color: onColor, + ), + ); + }, + ), + ); + return AnimatedBuilder( + animation: valueListener, + builder: (context, child) { + return AnimatedAlign( + duration: _isDragging ? Duration.zero : tapAnimationDuration, + alignment: Alignment( + (valueListener.value * 2 / (optionsCount - 1)) - 1, + 0.5, + ), + child: child, + ); + }, + child: handle, + ); + }, + ), + IgnorePointer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(optionsCount, (index) { + return SizedBox( + width: constraint.maxWidth / optionsCount, + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.icons != null && widget.icons!.length > index) + SvgPicture.asset( + widget.icons![index], + width: 12, + height: 14, + color: isDesktop + ? _selectedIndex != index + ? Theme.of(context) + .extension()! + .accentColorBlue + : Theme.of(context) + .extension()! + .buttonTextSecondary + : _selectedIndex != index + ? Theme.of(context) + .extension()! + .textDark + : Theme.of(context) + .extension()! + .textSubtitle1, + ), + if (widget.icons != null && widget.icons!.length > index) + const SizedBox( + width: 5, + ), + Flexible( + child: Text( + widget.texts?[index] ?? "", + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: _selectedIndex != index + ? Theme.of(context) + .extension()! + .accentColorBlue + : Theme.of(context) + .extension()! + .buttonTextSecondary, + ) + : STextStyles.smallMed12(context).copyWith( + color: _selectedIndex != index + ? Theme.of(context) + .extension()! + .textDark + : Theme.of(context) + .extension()! + .textSubtitle1, + ), + ), + ), + ], + ), + ), + ); + }), + ), + ), + ], + ); + }, + ), + ); + } +} + +class DSBController { + VoidCallback? activate; + bool? isOn; +} diff --git a/lib/wl_gen/interfaces/lib_spark_interface.dart b/lib/wl_gen/interfaces/lib_spark_interface.dart index 5f24277857..6f0df295bc 100644 --- a/lib/wl_gen/interfaces/lib_spark_interface.dart +++ b/lib/wl_gen/interfaces/lib_spark_interface.dart @@ -61,6 +61,25 @@ abstract class LibSparkInterface { final bool isTestNet = false, }); + WrappedLibSparkCoin? identifyAndRecoverCoinByFullViewKey( + final String serializedCoin, { + required final String fullViewKeyHex, + required final Uint8List context, + final bool isTestNet = false, + }); + + Future getAddressFromFullViewKey({ + required String fullViewKeyHex, + required int index, + required int diversifier, + bool isTestNet = false, + }); + + String getFullViewKeyHexFromPrivateKeyData({ + required String privateKeyHex, + required int index, + }); + ({ Uint8List serializedSpendPayload, List outputScripts, diff --git a/scripts/app_config/templates/ios/Runner.xcodeproj/project.pbxproj b/scripts/app_config/templates/ios/Runner.xcodeproj/project.pbxproj index febbbb8af7..d03d3a71c0 100644 --- a/scripts/app_config/templates/ios/Runner.xcodeproj/project.pbxproj +++ b/scripts/app_config/templates/ios/Runner.xcodeproj/project.pbxproj @@ -456,6 +456,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 4DQKUWSG6C; ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "x86_64"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -632,6 +633,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 4DQKUWSG6C; ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "x86_64"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -700,6 +702,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 4DQKUWSG6C; ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "x86_64"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 1f443effac..c97bdf3bc4 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -39,8 +39,8 @@ dependencies: # %%ENABLE_FIRO%% # flutter_libsparkmobile: # git: -# url: https://github.com/cypherstack/flutter_libsparkmobile.git -# ref: 84a139a25ab1691762002fafcae351e3d444a5c7 +# url: https://github.com/cassandras-lies/flutter_libsparkmobile.git +# ref: 9e13dfa07eb55d29431e072336a5211428fd7384 # %%END_ENABLE_FIRO%% # %%ENABLE_EPIC%% diff --git a/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart b/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart index 0842a48e7c..f4120a8f6c 100644 --- a/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart +++ b/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart @@ -178,6 +178,67 @@ class _LibSparkInterfaceImpl extends LibSparkInterface { ); } + @override + WrappedLibSparkCoin? identifyAndRecoverCoinByFullViewKey( + String serializedCoin, { + required String fullViewKeyHex, + required Uint8List context, + bool isTestNet = false, + }) { + final coin = LibSpark.identifyAndRecoverCoinByFullViewKey( + serializedCoin: serializedCoin, + fullViewKeyHex: fullViewKeyHex, + context: context, + isTestNet: isTestNet, + ); + + if (coin == null) return null; + + return WrappedLibSparkCoin( + type: WrappedLibSparkCoinType.values.firstWhere( + (e) => e.value == coin.type.value, + ), + + id: coin.id, + height: coin.height, + isUsed: coin.isUsed, + nonceHex: coin.nonceHex, + address: coin.address, + value: coin.value, + serial: coin.serial, + memo: coin.memo, + txHash: coin.txHash, + serialContext: coin.serialContext, + diversifier: coin.diversifier, + encryptedDiversifier: coin.encryptedDiversifier, + tag: coin.tag, + lTagHash: coin.lTagHash, + serializedCoin: coin.serializedCoin, + ); + } + + @override + Future getAddressFromFullViewKey({ + required String fullViewKeyHex, + required int index, + required int diversifier, + bool isTestNet = false, + }) => LibSpark.getAddressFromFullViewKey( + fullViewKeyHex: fullViewKeyHex, + index: index, + diversifier: diversifier, + isTestNet: isTestNet, + ); + + @override + String getFullViewKeyHexFromPrivateKeyData({ + required String privateKeyHex, + required int index, + }) => LibSpark.getFullViewKeyHexFromPrivateKeyData( + privateKeyHex: privateKeyHex, + index: index, + ); + @override ({ int fee,