diff --git a/.gitmodules b/.gitmodules index 95b02e580..925be21c0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,4 +6,7 @@ url = https://github.com/cypherstack/flutter_libmonero.git [submodule "crypto_plugins/flutter_liblelantus"] path = crypto_plugins/flutter_liblelantus - url = https://github.com/cypherstack/flutter_liblelantus.git \ No newline at end of file + url = https://github.com/cypherstack/flutter_liblelantus.git +[submodule "crypto_plugins/frostdart"] + path = crypto_plugins/frostdart + url = https://github.com/cypherstack/frostdart diff --git a/crypto_plugins/frostdart b/crypto_plugins/frostdart new file mode 160000 index 000000000..0fbc038a2 --- /dev/null +++ b/crypto_plugins/frostdart @@ -0,0 +1 @@ +Subproject commit 0fbc038a262e3c2d82c7c6e34e194e9a47011d91 diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index 528c99f98..ac5a544f4 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -21,6 +21,7 @@ import 'package:stackwallet/models/isar/stack_theme.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/stack_file_system.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; import 'package:stackwallet/wallets/isar/models/spark_coin.dart'; import 'package:stackwallet/wallets/isar/models/token_wallet_info.dart'; import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; @@ -67,6 +68,7 @@ class MainDB { SparkCoinSchema, WalletInfoMetaSchema, TokenWalletInfoSchema, + FrostWalletInfoSchema, ], directory: (await StackFileSystem.applicationIsarDirectory()).path, // inspector: kDebugMode, diff --git a/lib/models/isar/models/blockchain_data/address.dart b/lib/models/isar/models/blockchain_data/address.dart index e3368a119..8adaa4ce5 100644 --- a/lib/models/isar/models/blockchain_data/address.dart +++ b/lib/models/isar/models/blockchain_data/address.dart @@ -163,6 +163,7 @@ enum AddressType { spark, stellar, tezos, + frostMS, ; String get readableName { @@ -193,6 +194,8 @@ enum AddressType { return "Stellar"; case AddressType.tezos: return "Tezos"; + case AddressType.frostMS: + return "FrostMS"; } } } diff --git a/lib/models/isar/models/blockchain_data/address.g.dart b/lib/models/isar/models/blockchain_data/address.g.dart index 796c29f29..7d3aff776 100644 --- a/lib/models/isar/models/blockchain_data/address.g.dart +++ b/lib/models/isar/models/blockchain_data/address.g.dart @@ -266,6 +266,7 @@ const _AddresstypeEnumValueMap = { 'spark': 10, 'stellar': 11, 'tezos': 12, + 'frostMS': 13, }; const _AddresstypeValueEnumMap = { 0: AddressType.p2pkh, @@ -281,6 +282,7 @@ const _AddresstypeValueEnumMap = { 10: AddressType.spark, 11: AddressType.stellar, 12: AddressType.tezos, + 13: AddressType.frostMS, }; Id _addressGetId(Address object) { diff --git a/lib/pages/add_wallet_views/frost_ms/new/confirm_new_frost_ms_wallet_creation_view.dart b/lib/pages/add_wallet_views/frost_ms/new/confirm_new_frost_ms_wallet_creation_view.dart new file mode 100644 index 000000000..08018b43c --- /dev/null +++ b/lib/pages/add_wallet_views/frost_ms/new/confirm_new_frost_ms_wallet_creation_view.dart @@ -0,0 +1,343 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/notifications/show_flush_bar.dart'; +import 'package:stackwallet/pages/home_view/home_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_home_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/providers/global/node_service_provider.dart'; +import 'package:stackwallet/providers/global/prefs_provider.dart'; +import 'package:stackwallet/providers/global/secure_store_provider.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/assets.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; +import 'package:stackwallet/widgets/loading_indicator.dart'; + +import '../../../../wallets/isar/models/wallet_info.dart'; + +class ConfirmNewFrostMSWalletCreationView extends ConsumerStatefulWidget { + const ConfirmNewFrostMSWalletCreationView({ + super.key, + required this.walletName, + required this.coin, + }); + + static const String routeName = "/confirmNewFrostMSWalletCreationView"; + + final String walletName; + final Coin coin; + + @override + ConsumerState createState() => + _ConfirmNewFrostMSWalletCreationViewState(); +} + +class _ConfirmNewFrostMSWalletCreationViewState + extends ConsumerState { + late final String seed, recoveryString, serializedKeys, multisigConfig; + late final Uint8List multisigId; + + @override + void initState() { + seed = ref.read(pFrostStartKeyGenData.state).state!.seed; + serializedKeys = + ref.read(pFrostCompletedKeyGenData.state).state!.serializedKeys; + recoveryString = + ref.read(pFrostCompletedKeyGenData.state).state!.recoveryString; + multisigId = ref.read(pFrostCompletedKeyGenData.state).state!.multisigId; + multisigConfig = ref.read(pFrostMultisigConfig.state).state!; + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: + Util.isDesktop ? DesktopHomeView.routeName : HomeView.routeName, + ), + ); + + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: HomeView.routeName, + ), + ); + }, + ), + title: Text( + "Finalize FROST multisig wallet", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Ensure your multisig ID matches that of each other participant", + style: STextStyles.pageTitleH2(context), + ), + const _Div(), + DetailItem( + title: "ID", + detail: multisigId.toString(), + button: Util.isDesktop + ? IconCopyButton( + data: multisigId.toString(), + ) + : SimpleCopyButton( + data: multisigId.toString(), + ), + ), + const _Div(), + const _Div(), + Text( + "Back up your keys and config", + style: STextStyles.pageTitleH2(context), + ), + const _Div(), + DetailItem( + title: "Multisig Config", + detail: multisigConfig, + button: Util.isDesktop + ? IconCopyButton( + data: multisigConfig, + ) + : SimpleCopyButton( + data: multisigConfig, + ), + ), + const _Div(), + DetailItem( + title: "Keys", + detail: serializedKeys, + button: Util.isDesktop + ? IconCopyButton( + data: serializedKeys, + ) + : SimpleCopyButton( + data: serializedKeys, + ), + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Confirm", + onPressed: () async { + bool progressPopped = false; + try { + unawaited( + showDialog( + context: context, + barrierDismissible: false, + useSafeArea: true, + builder: (ctx) { + return const Center( + child: LoadingIndicator( + width: 50, + height: 50, + ), + ); + }, + ), + ); + + final info = WalletInfo.createNew( + coin: widget.coin, + name: widget.walletName, + ); + + final wallet = await Wallet.create( + walletInfo: info, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + ); + + await (wallet as BitcoinFrostWallet).initializeNewFrost( + mnemonic: seed, + multisigConfig: multisigConfig, + recoveryString: recoveryString, + serializedKeys: serializedKeys, + multisigId: multisigId, + myName: ref.read(pFrostMyName.state).state!, + participants: Frost.getParticipants( + multisigConfig: + ref.read(pFrostMultisigConfig.state).state!, + ), + threshold: Frost.getThreshold( + multisigConfig: + ref.read(pFrostMultisigConfig.state).state!, + ), + ); + + await info.setMnemonicVerified( + isar: ref.read(mainDBProvider).isar, + ); + + ref.read(pWallets).addWallet(wallet); + + // pop progress dialog + if (mounted) { + Navigator.pop(context); + progressPopped = true; + } + + if (mounted) { + if (Util.isDesktop) { + Navigator.of(context).popUntil( + ModalRoute.withName( + DesktopHomeView.routeName, + ), + ); + } else { + unawaited( + Navigator.of(context).pushNamedAndRemoveUntil( + HomeView.routeName, + (route) => false, + ), + ); + } + + ref.read(pFrostMultisigConfig.state).state = null; + ref.read(pFrostStartKeyGenData.state).state = null; + ref.read(pFrostSecretSharesData.state).state = null; + + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Your wallet is set up.", + iconAsset: Assets.svg.check, + context: context, + ), + ); + } + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + // pop progress dialog + if (mounted && !progressPopped) { + Navigator.pop(context); + progressPopped = true; + } + // TODO: handle gracefully + rethrow; + } + }, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/add_wallet_views/frost_ms/new/create_new_frost_ms_wallet_view.dart b/lib/pages/add_wallet_views/frost_ms/new/create_new_frost_ms_wallet_view.dart new file mode 100644 index 000000000..b408b61ef --- /dev/null +++ b/lib/pages/add_wallet_views/frost_ms/new/create_new_frost_ms_wallet_view.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/share_new_multisig_config_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; + +class CreateNewFrostMsWalletView extends ConsumerStatefulWidget { + const CreateNewFrostMsWalletView({ + super.key, + required this.walletName, + required this.coin, + }); + + static const String routeName = "/createNewFrostMsWalletView"; + + final String walletName; + final Coin coin; + + @override + ConsumerState createState() => + _NewFrostMsWalletViewState(); +} + +class _NewFrostMsWalletViewState + extends ConsumerState { + final _thresholdController = TextEditingController(); + final _participantsController = TextEditingController(); + + final List controllers = []; + + int _participantsCount = 0; + + String _validateInputData() { + final threshold = int.tryParse(_thresholdController.text); + if (threshold == null) { + return "Choose a threshold"; + } + + final partsCount = int.tryParse(_participantsController.text); + if (partsCount == null) { + return "Choose total number of participants"; + } + + if (threshold > partsCount) { + return "Threshold cannot be greater than the number of participants"; + } + + if (partsCount < 2) { + return "At least two participants required"; + } + + if (controllers.length != partsCount) { + return "Participants count error"; + } + + final hasEmptyParticipants = controllers + .map((e) => e.text.isEmpty) + .reduce((value, element) => value |= element); + if (hasEmptyParticipants) { + return "Participants must not be empty"; + } + + if (controllers.length != controllers.map((e) => e.text).toSet().length) { + return "Duplicate participant name found"; + } + + return "valid"; + } + + void _participantsCountChanged(String newValue) { + final count = int.tryParse(newValue); + if (count != null) { + if (count > _participantsCount) { + for (int i = _participantsCount; i < count; i++) { + controllers.add(TextEditingController()); + } + + _participantsCount = count; + setState(() {}); + } else if (count < _participantsCount) { + for (int i = _participantsCount; i > count; i--) { + final last = controllers.removeLast(); + last.dispose(); + } + + _participantsCount = count; + setState(() {}); + } + } + } + + @override + void dispose() { + _thresholdController.dispose(); + _participantsController.dispose(); + for (final e in controllers) { + e.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "New FROST multisig config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Threshold", + style: STextStyles.label(context), + ), + const SizedBox( + height: 10, + ), + TextField( + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + controller: _thresholdController, + ), + const SizedBox( + height: 16, + ), + Text( + "Number of participants", + style: STextStyles.label(context), + ), + const SizedBox( + height: 10, + ), + TextField( + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + controller: _participantsController, + onChanged: _participantsCountChanged, + ), + const SizedBox( + height: 16, + ), + if (controllers.isNotEmpty) + Text( + "My name", + style: STextStyles.label(context), + ), + if (controllers.isNotEmpty) + const SizedBox( + height: 10, + ), + if (controllers.isNotEmpty) + TextField( + controller: controllers.first, + ), + if (controllers.length > 1) + const SizedBox( + height: 16, + ), + if (controllers.length > 1) + Text( + "Remaining participants", + style: STextStyles.label(context), + ), + if (controllers.length > 1) + Column( + children: [ + for (int i = 1; i < controllers.length; i++) + Padding( + padding: const EdgeInsets.only( + top: 10, + ), + child: TextField( + controller: controllers[i], + ), + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Generate", + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + } + + final validationMessage = _validateInputData(); + + if (validationMessage != "valid") { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: validationMessage, + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + final config = Frost.createMultisigConfig( + name: controllers.first.text, + threshold: int.parse(_thresholdController.text), + participants: controllers.map((e) => e.text).toList(), + ); + + ref.read(pFrostMyName.notifier).state = controllers.first.text; + ref.read(pFrostMultisigConfig.notifier).state = config; + + await Navigator.of(context).pushNamed( + ShareNewMultisigConfigView.routeName, + arguments: ( + walletName: widget.walletName, + coin: widget.coin, + ), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/add_wallet_views/frost_ms/new/frost_share_commitments_view.dart b/lib/pages/add_wallet_views/frost_ms/new/frost_share_commitments_view.dart new file mode 100644 index 000000000..bf3649a37 --- /dev/null +++ b/lib/pages/add_wallet_views/frost_ms/new/frost_share_commitments_view.dart @@ -0,0 +1,443 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/frost_share_shares_view.dart'; +import 'package:stackwallet/pages/home_view/home_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_home_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class FrostShareCommitmentsView extends ConsumerStatefulWidget { + const FrostShareCommitmentsView({ + super.key, + required this.walletName, + required this.coin, + }); + + static const String routeName = "/frostShareCommitmentsView"; + + final String walletName; + final Coin coin; + + @override + ConsumerState createState() => + _FrostShareCommitmentsViewState(); +} + +class _FrostShareCommitmentsViewState + extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final List participants; + late final String myCommitment; + late final int myIndex; + + final List fieldIsEmptyFlags = []; + + @override + void initState() { + participants = Frost.getParticipants( + multisigConfig: ref.read(pFrostMultisigConfig.state).state!, + ); + myIndex = participants.indexOf(ref.read(pFrostMyName.state).state!); + myCommitment = ref.read(pFrostStartKeyGenData.state).state!.commitments; + + // temporarily remove my name + participants.removeAt(myIndex); + + for (int i = 0; i < participants.length; i++) { + controllers.add(TextEditingController()); + focusNodes.add(FocusNode()); + fieldIsEmptyFlags.add(true); + } + super.initState(); + } + + @override + void dispose() { + for (int i = 0; i < controllers.length; i++) { + controllers[i].dispose(); + } + for (int i = 0; i < focusNodes.length; i++) { + focusNodes[i].dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: + Util.isDesktop ? DesktopHomeView.routeName : HomeView.routeName, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: HomeView.routeName, + ), + ); + }, + ), + title: Text( + "Commitments", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: myCommitment, + size: 220, + backgroundColor: Theme.of(context) + .extension()! + .background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const _Div(), + DetailItem( + title: "My name", + detail: ref.watch(pFrostMyName.state).state!, + ), + const _Div(), + DetailItem( + title: "My commitment", + detail: myCommitment, + button: Util.isDesktop + ? IconCopyButton( + data: myCommitment, + ) + : SimpleCopyButton( + data: myCommitment, + ), + ), + const _Div(), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < participants.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frostCommitmentsTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (_) { + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + }, + decoration: standardInputDecoration( + "Enter ${participants[i]}'s commitment", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Commitment Field Input.", + key: Key( + "frostCommitmentsClearButtonKey_$i"), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Commitment Field Input.", + key: Key( + "frostCommitmentsPasteButtonKey_$i"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: Key( + "frostCommitmentsScanQrButtonKey_$i"), + onTap: () async { + try { + if (FocusScope.of(context) + .hasFocus) { + FocusScope.of(context) + .unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Generate shares", + enabled: !fieldIsEmptyFlags.reduce((v, e) => v |= e), + onPressed: () async { + // check for empty commitments + if (controllers + .map((e) => e.text.isEmpty) + .reduce((value, element) => value |= element)) { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Missing commitments", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + // collect commitment strings and insert my own at the correct index + final commitments = controllers.map((e) => e.text).toList(); + commitments.insert(myIndex, myCommitment); + + try { + ref.read(pFrostSecretSharesData.notifier).state = + Frost.generateSecretShares( + multisigConfigWithNamePtr: ref + .read(pFrostStartKeyGenData.state) + .state! + .multisigConfigWithNamePtr, + mySeed: ref.read(pFrostStartKeyGenData.state).state!.seed, + secretShareMachineWrapperPtr: ref + .read(pFrostStartKeyGenData.state) + .state! + .secretShareMachineWrapperPtr, + commitments: commitments, + ); + + await Navigator.of(context).pushNamed( + FrostShareSharesView.routeName, + arguments: ( + walletName: widget.walletName, + coin: widget.coin, + ), + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Failed to generate shares", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + }, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/add_wallet_views/frost_ms/new/frost_share_shares_view.dart b/lib/pages/add_wallet_views/frost_ms/new/frost_share_shares_view.dart new file mode 100644 index 000000000..0f5e70ee7 --- /dev/null +++ b/lib/pages/add_wallet_views/frost_ms/new/frost_share_shares_view.dart @@ -0,0 +1,409 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/confirm_new_frost_ms_wallet_creation_view.dart'; +import 'package:stackwallet/pages/home_view/home_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_home_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class FrostShareSharesView extends ConsumerStatefulWidget { + const FrostShareSharesView({ + super.key, + required this.walletName, + required this.coin, + }); + + static const String routeName = "/frostShareSharesView"; + + final String walletName; + final Coin coin; + + @override + ConsumerState createState() => + _FrostShareSharesViewState(); +} + +class _FrostShareSharesViewState extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final List participants; + late final String myShare; + late final int myIndex; + + final List fieldIsEmptyFlags = []; + + @override + void initState() { + participants = Frost.getParticipants( + multisigConfig: ref.read(pFrostMultisigConfig.state).state!, + ); + myIndex = participants.indexOf(ref.read(pFrostMyName.state).state!); + myShare = ref.read(pFrostSecretSharesData.state).state!.share; + + // temporarily remove my name. Added back later + participants.removeAt(myIndex); + + for (int i = 0; i < participants.length; i++) { + controllers.add(TextEditingController()); + focusNodes.add(FocusNode()); + fieldIsEmptyFlags.add(true); + } + super.initState(); + } + + @override + void dispose() { + for (int i = 0; i < controllers.length; i++) { + controllers[i].dispose(); + } + for (int i = 0; i < focusNodes.length; i++) { + focusNodes[i].dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: + Util.isDesktop ? DesktopHomeView.routeName : HomeView.routeName, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: HomeView.routeName, + ), + ); + }, + ), + title: Text( + "Generate shares", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: myShare, + size: 220, + backgroundColor: Theme.of(context) + .extension()! + .background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const _Div(), + DetailItem( + title: "My name", + detail: ref.watch(pFrostMyName.state).state!, + ), + const _Div(), + DetailItem( + title: "My share", + detail: myShare, + button: Util.isDesktop + ? IconCopyButton( + data: myShare, + ) + : SimpleCopyButton( + data: myShare, + ), + ), + const _Div(), + for (int i = 0; i < participants.length; i++) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frSharesTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter ${participants[i]}'s share", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Share Field Input.", + key: Key("frSharesClearButtonKey_$i"), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Share Field Input.", + key: Key("frSharesPasteButtonKey_$i"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: Key("frSharesScanQrButtonKey_$i"), + onTap: () async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Generate", + onPressed: () async { + // check for empty commitments + if (controllers + .map((e) => e.text.isEmpty) + .reduce((value, element) => value |= element)) { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Missing shares", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + // collect commitment strings and insert my own at the correct index + final shares = controllers.map((e) => e.text).toList(); + shares.insert(myIndex, myShare); + + try { + ref.read(pFrostCompletedKeyGenData.notifier).state = + Frost.completeKeyGeneration( + multisigConfigWithNamePtr: ref + .read(pFrostStartKeyGenData.state) + .state! + .multisigConfigWithNamePtr, + secretSharesResPtr: ref + .read(pFrostSecretSharesData.state) + .state! + .secretSharesResPtr, + shares: shares, + ); + await Navigator.of(context).pushNamed( + ConfirmNewFrostMSWalletCreationView.routeName, + arguments: ( + walletName: widget.walletName, + coin: widget.coin, + ), + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Failed to complete key generation", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + }, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/add_wallet_views/frost_ms/new/import_new_frost_ms_wallet_view.dart b/lib/pages/add_wallet_views/frost_ms/new/import_new_frost_ms_wallet_view.dart new file mode 100644 index 000000000..1b08b045d --- /dev/null +++ b/lib/pages/add_wallet_views/frost_ms/new/import_new_frost_ms_wallet_view.dart @@ -0,0 +1,386 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/frost_share_commitments_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class ImportNewFrostMsWalletView extends ConsumerStatefulWidget { + const ImportNewFrostMsWalletView({ + super.key, + required this.walletName, + required this.coin, + }); + + static const String routeName = "/importNewFrostMsWalletView"; + + final String walletName; + final Coin coin; + + @override + ConsumerState createState() => + _ImportNewFrostMsWalletViewState(); +} + +class _ImportNewFrostMsWalletViewState + extends ConsumerState { + late final TextEditingController myNameFieldController, configFieldController; + late final FocusNode myNameFocusNode, configFocusNode; + + bool _nameEmpty = true, _configEmpty = true; + + @override + void initState() { + myNameFieldController = TextEditingController(); + configFieldController = TextEditingController(); + myNameFocusNode = FocusNode(); + configFocusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + myNameFieldController.dispose(); + configFieldController.dispose(); + myNameFocusNode.dispose(); + configFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Import FROST multisig config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frMyNameTextFieldKey"), + controller: myNameFieldController, + onChanged: (_) { + setState(() { + _nameEmpty = myNameFieldController.text.isEmpty; + }); + }, + focusNode: myNameFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "My name", + myNameFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _nameEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_nameEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Config Field.", + key: const Key("frMyNameClearButtonKey"), + onTap: () { + myNameFieldController.text = ""; + + setState(() { + _nameEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Name Field.", + key: const Key("frMyNamePasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + myNameFieldController.text = + data.text!.trim(); + } + + setState(() { + _nameEmpty = + myNameFieldController.text.isEmpty; + }); + }, + child: _nameEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frConfigTextFieldKey"), + controller: configFieldController, + onChanged: (_) { + setState(() { + _configEmpty = configFieldController.text.isEmpty; + }); + }, + focusNode: configFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter config", + configFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _configEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_configEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Config Field.", + key: const Key("frConfigClearButtonKey"), + onTap: () { + configFieldController.text = ""; + + setState(() { + _configEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Config Field Input.", + key: const Key("frConfigPasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + configFieldController.text = + data.text!.trim(); + } + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + }, + child: _configEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_configEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("frConfigScanQrButtonKey"), + onTap: () async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75)); + } + + final qrResult = await BarcodeScanner.scan(); + + configFieldController.text = + qrResult.rawContent; + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Start key generation", + enabled: !_nameEmpty && !_configEmpty, + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + } + + final config = configFieldController.text; + + if (!Frost.validateEncodedMultisigConfig( + encodedConfig: config)) { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Invalid config", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + if (!Frost.getParticipants(multisigConfig: config) + .contains(myNameFieldController.text)) { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "My name not found in config participants", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + ref.read(pFrostMyName.state).state = myNameFieldController.text; + ref.read(pFrostMultisigConfig.notifier).state = config; + + ref.read(pFrostStartKeyGenData.state).state = + Frost.startKeyGeneration( + multisigConfig: ref.read(pFrostMultisigConfig.state).state!, + myName: ref.read(pFrostMyName.state).state!, + ); + + await Navigator.of(context).pushNamed( + FrostShareCommitmentsView.routeName, + arguments: ( + walletName: widget.walletName, + coin: widget.coin, + ), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/add_wallet_views/frost_ms/new/share_new_multisig_config_view.dart b/lib/pages/add_wallet_views/frost_ms/new/share_new_multisig_config_view.dart new file mode 100644 index 000000000..4afb4c0c5 --- /dev/null +++ b/lib/pages/add_wallet_views/frost_ms/new/share_new_multisig_config_view.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/frost_share_commitments_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; + +class ShareNewMultisigConfigView extends ConsumerStatefulWidget { + const ShareNewMultisigConfigView({ + super.key, + required this.walletName, + required this.coin, + }); + + static const String routeName = "/shareNewMultisigConfigView"; + + final String walletName; + final Coin coin; + + @override + ConsumerState createState() => + _ShareNewMultisigConfigViewState(); +} + +class _ShareNewMultisigConfigViewState + extends ConsumerState { + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Multisig config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + if (!Util.isDesktop) const Spacer(), + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: + ref.watch(pFrostMultisigConfig.state).state ?? "Error", + size: 220, + backgroundColor: + Theme.of(context).extension()!.background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const SizedBox( + height: 32, + ), + DetailItem( + title: "Encoded config", + detail: ref.watch(pFrostMultisigConfig.state).state ?? "Error", + button: Util.isDesktop + ? IconCopyButton( + data: ref.watch(pFrostMultisigConfig.state).state ?? + "Error", + ) + : SimpleCopyButton( + data: ref.watch(pFrostMultisigConfig.state).state ?? + "Error", + ), + ), + SizedBox( + height: Util.isDesktop ? 64 : 16, + ), + if (!Util.isDesktop) + const Spacer( + flex: 2, + ), + PrimaryButton( + label: "Start key generation", + onPressed: () async { + ref.read(pFrostStartKeyGenData.notifier).state = + Frost.startKeyGeneration( + multisigConfig: ref.watch(pFrostMultisigConfig.state).state!, + myName: ref.read(pFrostMyName.state).state!, + ); + + await Navigator.of(context).pushNamed( + FrostShareCommitmentsView.routeName, + arguments: ( + walletName: widget.walletName, + coin: widget.coin, + ), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart b/lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart new file mode 100644 index 000000000..c9c174ab0 --- /dev/null +++ b/lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart @@ -0,0 +1,480 @@ +import 'dart:async'; + +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frostdart/frostdart.dart' as frost; +import 'package:stackwallet/notifications/show_flush_bar.dart'; +import 'package:stackwallet/pages/home_view/home_view.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_home_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/global/node_service_provider.dart'; +import 'package:stackwallet/providers/global/prefs_provider.dart'; +import 'package:stackwallet/providers/global/secure_store_provider.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/assets.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/show_loading.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class RestoreFrostMsWalletView extends ConsumerStatefulWidget { + const RestoreFrostMsWalletView({ + super.key, + required this.walletName, + required this.coin, + }); + + static const String routeName = "/restoreFrostMsWalletView"; + + final String walletName; + final Coin coin; + + @override + ConsumerState createState() => + _RestoreFrostMsWalletViewState(); +} + +class _RestoreFrostMsWalletViewState + extends ConsumerState { + late final TextEditingController keysFieldController, configFieldController; + late final FocusNode keysFocusNode, configFocusNode; + + bool _keysEmpty = true, _configEmpty = true; + + bool _restoreButtonLock = false; + + Future _createWalletAndRecover() async { + final keys = keysFieldController.text; + final config = configFieldController.text; + + final myNameIndex = frost.getParticipantIndexFromKeys(serializedKeys: keys); + final participants = Frost.getParticipants(multisigConfig: config); + final myName = participants[myNameIndex]; + + final info = WalletInfo.createNew( + coin: widget.coin, + name: widget.walletName, + ); + + final wallet = await Wallet.create( + walletInfo: info, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + ); + + final frostInfo = FrostWalletInfo( + walletId: info.walletId, + knownSalts: [], + participants: participants, + myName: myName, + threshold: frost.multisigThreshold( + multisigConfig: config, + ), + ); + + await ref.read(mainDBProvider).isar.writeTxn(() async { + await ref.read(mainDBProvider).isar.frostWalletInfo.put(frostInfo); + }); + + await (wallet as BitcoinFrostWallet).recover( + serializedKeys: keys, + multisigConfig: config, + isRescan: false, + ); + + await info.setMnemonicVerified( + isar: ref.read(mainDBProvider).isar, + ); + + return wallet; + } + + Future _restore() async { + if (_restoreButtonLock) { + return; + } + _restoreButtonLock = true; + + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + } + + Exception? ex; + final wallet = await showLoading( + whileFuture: _createWalletAndRecover(), + context: context, + message: "Restoring wallet...", + isDesktop: Util.isDesktop, + onException: (e) { + ex = e; + }, + ); + + if (ex != null) { + throw ex!; + } + + ref.read(pWallets).addWallet(wallet!); + + if (mounted) { + if (Util.isDesktop) { + Navigator.of(context).popUntil( + ModalRoute.withName( + DesktopHomeView.routeName, + ), + ); + } else { + unawaited( + Navigator.of(context).pushNamedAndRemoveUntil( + HomeView.routeName, + (route) => false, + ), + ); + } + + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Your wallet is set up.", + iconAsset: Assets.svg.check, + context: context, + ), + ); + } + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + if (mounted) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Failed to restore", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + _restoreButtonLock = false; + } + } + + @override + void initState() { + keysFieldController = TextEditingController(); + configFieldController = TextEditingController(); + keysFocusNode = FocusNode(); + configFocusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + keysFieldController.dispose(); + configFieldController.dispose(); + keysFocusNode.dispose(); + configFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Restore FROST multisig wallet", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frMyNameTextFieldKey"), + controller: keysFieldController, + onChanged: (_) { + setState(() { + _keysEmpty = keysFieldController.text.isEmpty; + }); + }, + focusNode: keysFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Keys", + keysFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _keysEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_keysEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Keys Field.", + key: const Key("frMyNameClearButtonKey"), + onTap: () { + keysFieldController.text = ""; + + setState(() { + _keysEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Keys Field.", + key: const Key("frKeysPasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + keysFieldController.text = + data.text!.trim(); + } + + setState(() { + _keysEmpty = + keysFieldController.text.isEmpty; + }); + }, + child: _keysEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frConfigTextFieldKey"), + controller: configFieldController, + onChanged: (_) { + setState(() { + _configEmpty = configFieldController.text.isEmpty; + }); + }, + focusNode: configFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter config", + configFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _configEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_configEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Config Field.", + key: const Key("frConfigClearButtonKey"), + onTap: () { + configFieldController.text = ""; + + setState(() { + _configEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Config Field Input.", + key: const Key("frConfigPasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + configFieldController.text = + data.text!.trim(); + } + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + }, + child: _configEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_configEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("frConfigScanQrButtonKey"), + onTap: () async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75)); + } + + final qrResult = await BarcodeScanner.scan(); + + configFieldController.text = + qrResult.rawContent; + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Restore", + enabled: !_keysEmpty && !_configEmpty, + onPressed: _restore, + ), + ], + ), + ), + ); + } +} 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 350c839f8..7ddaaba3a 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 @@ -14,9 +14,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:isar/isar.dart'; import 'package:stackwallet/pages/add_wallet_views/create_or_restore_wallet_view/sub_widgets/coin_image.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/create_new_frost_ms_wallet_view.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/import_new_frost_ms_wallet_view.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart'; import 'package:stackwallet/pages/add_wallet_views/new_wallet_options/new_wallet_options_view.dart'; import 'package:stackwallet/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart'; import 'package:stackwallet/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_import_resharer_config_view.dart'; import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; import 'package:stackwallet/providers/db/main_db_provider.dart'; import 'package:stackwallet/providers/ui/verify_recovery_phrase/mnemonic_word_count_state_provider.dart'; @@ -32,6 +36,8 @@ import 'package:stackwallet/widgets/background.dart'; import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/desktop/secondary_button.dart'; import 'package:stackwallet/widgets/icon_widgets/dice_icon.dart'; import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; import 'package:stackwallet/widgets/rounded_white_container.dart'; @@ -77,6 +83,52 @@ class _NameYourWalletViewState extends ConsumerState { return name; } + Future _nextPressed() async { + final name = textEditingController.text; + + if (mounted) { + // hide keyboard if has focus + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + } + + if (mounted) { + ref.read(mnemonicWordCountStateProvider.state).state = + Constants.possibleLengthsForCoin(coin).last; + ref.read(pNewWalletOptions.notifier).state = null; + + switch (widget.addWalletType) { + case AddWalletType.New: + unawaited( + Navigator.of(context).pushNamed( + coin.hasMnemonicPassphraseSupport + ? NewWalletOptionsView.routeName + : NewWalletRecoveryPhraseWarningView.routeName, + arguments: Tuple2( + name, + coin, + ), + ), + ); + break; + + case AddWalletType.Restore: + unawaited( + Navigator.of(context).pushNamed( + RestoreOptionsView.routeName, + arguments: Tuple2( + name, + coin, + ), + ), + ); + break; + } + } + } + } + @override void initState() { isDesktop = Util.isDesktop; @@ -330,78 +382,104 @@ class _NameYourWalletViewState extends ConsumerState { const SizedBox( height: 32, ), - ConstrainedBox( - constraints: BoxConstraints( - minWidth: isDesktop ? 480 : 0, - minHeight: isDesktop ? 70 : 0, + if (widget.coin.isFrost) + if (widget.addWalletType == AddWalletType.Restore) + PrimaryButton( + label: "Next", + enabled: _nextEnabled, + onPressed: () async { + final name = textEditingController.text; + + await Navigator.of(context).pushNamed( + RestoreFrostMsWalletView.routeName, + arguments: ( + walletName: name, + coin: coin, + ), + ); + }, + ), + if (widget.coin.isFrost && widget.addWalletType == AddWalletType.New) + Column( + children: [ + PrimaryButton( + label: "Create config", + enabled: _nextEnabled, + onPressed: () async { + final name = textEditingController.text; + + await Navigator.of(context).pushNamed( + CreateNewFrostMsWalletView.routeName, + arguments: ( + walletName: name, + coin: coin, + ), + ); + }, + ), + const SizedBox( + height: 12, + ), + SecondaryButton( + label: "Import multisig config", + enabled: _nextEnabled, + onPressed: () async { + final name = textEditingController.text; + + await Navigator.of(context).pushNamed( + ImportNewFrostMsWalletView.routeName, + arguments: ( + walletName: name, + coin: coin, + ), + ); + }, + ), + const SizedBox( + height: 12, + ), + SecondaryButton( + label: "Import resharer config", + enabled: _nextEnabled, + onPressed: () async { + final name = textEditingController.text; + + await Navigator.of(context).pushNamed( + NewImportResharerConfigView.routeName, + arguments: ( + walletName: name, + coin: coin, + ), + ); + }, + ), + ], ), - child: TextButton( - onPressed: _nextEnabled - ? () async { - final name = textEditingController.text; - - if (mounted) { - // hide keyboard if has focus - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 50)); - } - - if (mounted) { - ref.read(mnemonicWordCountStateProvider.state).state = - Constants.possibleLengthsForCoin(coin).last; - ref.read(pNewWalletOptions.notifier).state = null; - - switch (widget.addWalletType) { - case AddWalletType.New: - unawaited( - Navigator.of(context).pushNamed( - coin.hasMnemonicPassphraseSupport - ? NewWalletOptionsView.routeName - : NewWalletRecoveryPhraseWarningView - .routeName, - arguments: Tuple2( - name, - coin, - ), - ), - ); - break; - - case AddWalletType.Restore: - unawaited( - Navigator.of(context).pushNamed( - RestoreOptionsView.routeName, - arguments: Tuple2( - name, - coin, - ), - ), - ); - break; - } - } - } - } - : null, - style: _nextEnabled - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle(context), - child: Text( - "Next", - style: isDesktop - ? _nextEnabled - ? STextStyles.desktopButtonEnabled(context) - : STextStyles.desktopButtonDisabled(context) - : STextStyles.button(context), + if (!widget.coin.isFrost) + ConstrainedBox( + constraints: BoxConstraints( + minWidth: isDesktop ? 480 : 0, + minHeight: isDesktop ? 70 : 0, + ), + child: TextButton( + onPressed: _nextEnabled ? _nextPressed : null, + style: _nextEnabled + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), + child: Text( + "Next", + style: isDesktop + ? _nextEnabled + ? STextStyles.desktopButtonEnabled(context) + : STextStyles.desktopButtonDisabled(context) + : STextStyles.button(context), + ), ), ), - ), if (isDesktop) const Spacer( flex: 15, diff --git a/lib/pages/add_wallet_views/restore_wallet_view/sub_widgets/restore_succeeded_dialog.dart b/lib/pages/add_wallet_views/restore_wallet_view/sub_widgets/restore_succeeded_dialog.dart index 3963fc139..0b816cbe9 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/sub_widgets/restore_succeeded_dialog.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/sub_widgets/restore_succeeded_dialog.dart @@ -51,7 +51,7 @@ class RestoreSucceededDialog extends StatelessWidget { height: 16, ), Text( - "You can use your wallet now.", + "You may access your wallet now.", style: STextStyles.desktopTextMedium(context).copyWith( color: Theme.of(context).extension()!.textDark3, ), @@ -80,7 +80,7 @@ class RestoreSucceededDialog extends StatelessWidget { } else { return StackDialog( title: "Wallet restored", - message: "You can use your wallet now.", + message: "You may access your wallet now.", icon: SvgPicture.asset( Assets.svg.checkCircle, width: 24, diff --git a/lib/pages/send_view/frost_ms/frost_attempt_sign_config_view.dart b/lib/pages/send_view/frost_ms/frost_attempt_sign_config_view.dart new file mode 100644 index 000000000..149eb61d3 --- /dev/null +++ b/lib/pages/send_view/frost_ms/frost_attempt_sign_config_view.dart @@ -0,0 +1,404 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/frost_continue_sign_config_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class FrostAttemptSignConfigView extends ConsumerStatefulWidget { + const FrostAttemptSignConfigView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/frostAttemptSignConfigView"; + + final String walletId; + + @override + ConsumerState createState() => + _FrostAttemptSignConfigViewState(); +} + +class _FrostAttemptSignConfigViewState + extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final String myName; + late final List participantsWithoutMe; + late final String myPreprocess; + late final int myIndex; + late final int threshold; + + final List fieldIsEmptyFlags = []; + + bool hasEnoughPreprocesses() { + // own preprocess is not included in controllers and must be set here + int count = 1; + + for (final controller in controllers) { + if (controller.text.isNotEmpty) { + count++; + } + } + + return count >= threshold; + } + + @override + void initState() { + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as BitcoinFrostWallet; + final frostInfo = wallet.frostInfo; + + myName = frostInfo.myName; + threshold = frostInfo.threshold; + participantsWithoutMe = List.from(frostInfo.participants); // Copy so it isn't fixed-length. + myIndex = participantsWithoutMe.indexOf(frostInfo.myName); + myPreprocess = ref.read(pFrostAttemptSignData.state).state!.preprocess; + + participantsWithoutMe.removeAt(myIndex); + + for (int i = 0; i < participantsWithoutMe.length; i++) { + controllers.add(TextEditingController()); + focusNodes.add(FocusNode()); + fieldIsEmptyFlags.add(true); + } + super.initState(); + } + + @override + void dispose() { + for (int i = 0; i < controllers.length; i++) { + controllers[i].dispose(); + } + for (int i = 0; i < focusNodes.length; i++) { + focusNodes[i].dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Preprocesses", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: myPreprocess, + size: 220, + backgroundColor: + Theme.of(context).extension()!.background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const _Div(), + DetailItem( + title: "My name", + detail: myName, + ), + const _Div(), + DetailItem( + title: "My preprocess", + detail: myPreprocess, + button: Util.isDesktop + ? IconCopyButton( + data: myPreprocess, + ) + : SimpleCopyButton( + data: myPreprocess, + ), + ), + const _Div(), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < participantsWithoutMe.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frostPreprocessesTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (_) { + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + }, + decoration: standardInputDecoration( + "Enter ${participantsWithoutMe[i]}'s preprocess", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Preprocess Field Input.", + key: Key( + "frostPreprocessesClearButtonKey_$i", + ), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Preprocess Field Input.", + key: Key( + "frostPreprocessesPasteButtonKey_$i", + ), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: Key( + "frostPreprocessesScanQrButtonKey_$i", + ), + onTap: () async { + try { + if (FocusScope.of(context) + .hasFocus) { + FocusScope.of(context) + .unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Continue signing", + enabled: hasEnoughPreprocesses(), + onPressed: () async { + // collect Preprocess strings (not including my own) + final preprocesses = controllers.map((e) => e.text).toList(); + + // collect participants who are involved in this transaction + final List requiredParticipantsUnordered = []; + for (int i = 0; i < participantsWithoutMe.length; i++) { + if (preprocesses[i].isNotEmpty) { + requiredParticipantsUnordered.add(participantsWithoutMe[i]); + } + } + ref.read(pFrostSelectParticipantsUnordered.notifier).state = + requiredParticipantsUnordered; + + // insert an empty string at my index + preprocesses.insert(myIndex, ""); + + try { + ref.read(pFrostContinueSignData.notifier).state = + Frost.continueSigning( + machinePtr: + ref.read(pFrostAttemptSignData.state).state!.machinePtr, + preprocesses: preprocesses, + ); + + await Navigator.of(context).pushNamed( + FrostContinueSignView.routeName, + arguments: widget.walletId, + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Failed to continue signing", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + }, + ), + ], + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/send_view/frost_ms/frost_complete_sign_view.dart b/lib/pages/send_view/frost_ms/frost_complete_sign_view.dart new file mode 100644 index 000000000..6478495c0 --- /dev/null +++ b/lib/pages/send_view/frost_ms/frost_complete_sign_view.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages/wallet_view/wallet_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/my_stack_view.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/show_loading.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; + +class FrostCompleteSignView extends ConsumerStatefulWidget { + const FrostCompleteSignView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/frostCompleteSignView"; + + final String walletId; + + @override + ConsumerState createState() => + _FrostCompleteSignViewState(); +} + +class _FrostCompleteSignViewState extends ConsumerState { + bool _broadcastLock = false; + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Preview transaction", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: ref.watch(pFrostTxData.state).state!.raw!, + size: 220, + backgroundColor: + Theme.of(context).extension()!.background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const _Div(), + DetailItem( + title: "Raw transaction hex", + detail: ref.watch(pFrostTxData.state).state!.raw!, + button: Util.isDesktop + ? IconCopyButton( + data: ref.watch(pFrostTxData.state).state!.raw!, + ) + : SimpleCopyButton( + data: ref.watch(pFrostTxData.state).state!.raw!, + ), + ), + const _Div(), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Broadcast Transaction", + onPressed: () async { + if (_broadcastLock) { + return; + } + _broadcastLock = true; + + try { + Exception? ex; + final txData = await showLoading( + whileFuture: ref + .read(pWallets) + .getWallet(widget.walletId) + .confirmSend( + txData: ref.read(pFrostTxData.state).state!, + ), + context: context, + message: "Broadcasting transaction to network", + isDesktop: Util.isDesktop, + onException: (e) { + ex = e; + }, + ); + + if (ex != null) { + throw ex!; + } + + if (mounted) { + if (txData != null) { + ref.read(pFrostTxData.state).state = txData; + Navigator.of(context).popUntil( + ModalRoute.withName( + Util.isDesktop + ? MyStackView.routeName + : WalletView.routeName, + ), + ); + } + } + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Broadcast error", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } finally { + _broadcastLock = false; + } + }, + ), + ], + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/send_view/frost_ms/frost_continue_sign_config_view.dart b/lib/pages/send_view/frost_ms/frost_continue_sign_config_view.dart new file mode 100644 index 000000000..732fb2f82 --- /dev/null +++ b/lib/pages/send_view/frost_ms/frost_continue_sign_config_view.dart @@ -0,0 +1,445 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/frost_complete_sign_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages/wallet_view/wallet_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class FrostContinueSignView extends ConsumerStatefulWidget { + const FrostContinueSignView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/frostContinueSignView"; + + final String walletId; + + @override + ConsumerState createState() => + _FrostContinueSignViewState(); +} + +class _FrostContinueSignViewState extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final String myName; + late final List participantsWithoutMe; + late final List participantsAll; + late final String myShare; + late final int myIndex; + + final List fieldIsEmptyFlags = []; + + @override + void initState() { + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as BitcoinFrostWallet; + + final frostInfo = wallet.frostInfo; + + myName = frostInfo.myName; + participantsAll = frostInfo.participants; + myIndex = frostInfo.participants.indexOf(frostInfo.myName); + myShare = ref.read(pFrostContinueSignData.state).state!.share; + + participantsWithoutMe = frostInfo.participants + .toSet() + .intersection( + ref.read(pFrostSelectParticipantsUnordered.state).state!.toSet()) + .toList(); + + participantsWithoutMe.remove(myName); + + for (int i = 0; i < participantsWithoutMe.length; i++) { + controllers.add(TextEditingController()); + focusNodes.add(FocusNode()); + fieldIsEmptyFlags.add(true); + } + super.initState(); + } + + @override + void dispose() { + for (int i = 0; i < controllers.length; i++) { + controllers[i].dispose(); + } + for (int i = 0; i < focusNodes.length; i++) { + focusNodes[i].dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.transactionCreation, + popUntilOnYesRouteName: Util.isDesktop + ? DesktopWalletView.routeName + : WalletView.routeName, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.transactionCreation, + popUntilOnYesRouteName: DesktopWalletView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.transactionCreation, + popUntilOnYesRouteName: WalletView.routeName, + ), + ); + }, + ), + title: Text( + "Shares", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: myShare, + size: 220, + backgroundColor: Theme.of(context) + .extension()! + .background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const _Div(), + DetailItem( + title: "My name", + detail: myName, + ), + const _Div(), + DetailItem( + title: "My shares", + detail: myShare, + button: Util.isDesktop + ? IconCopyButton( + data: myShare, + ) + : SimpleCopyButton( + data: myShare, + ), + ), + const _Div(), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < participantsWithoutMe.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frostSharesTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter ${participantsWithoutMe[i]}'s share", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears " + "The Share Field Input.", + key: Key( + "frostSharesClearButtonKey_$i", + ), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From " + "Clipboard To Share Field Input.", + key: Key( + "frostSharesPasteButtonKey_$i"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera " + "For Scanning QR Code.", + key: Key( + "frostSharesScanQrButtonKey_$i", + ), + onTap: () async { + try { + if (FocusScope.of(context) + .hasFocus) { + FocusScope.of(context) + .unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions " + "while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Complete signing", + onPressed: () async { + // check for empty shares + if (controllers + .map((e) => e.text.isEmpty) + .reduce((value, element) => value |= element)) { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Missing Shares", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + // collect Share strings + final sharesCollected = + controllers.map((e) => e.text).toList(); + + final List shares = []; + for (final participant in participantsAll) { + if (participantsWithoutMe.contains(participant)) { + shares.add(sharesCollected[ + participantsWithoutMe.indexOf(participant)]); + } else { + shares.add(""); + } + } + + try { + final rawTx = Frost.completeSigning( + machinePtr: ref + .read(pFrostContinueSignData.state) + .state! + .machinePtr, + shares: shares, + ); + + ref.read(pFrostTxData.state).state = + ref.read(pFrostTxData.state).state!.copyWith( + raw: rawTx, + ); + + await Navigator.of(context).pushNamed( + FrostCompleteSignView.routeName, + arguments: widget.walletId, + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Failed to complete signing process", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + }, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/send_view/frost_ms/frost_create_sign_config_view.dart b/lib/pages/send_view/frost_ms/frost_create_sign_config_view.dart new file mode 100644 index 000000000..bb2c129ca --- /dev/null +++ b/lib/pages/send_view/frost_ms/frost_create_sign_config_view.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/frost_attempt_sign_config_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; + +class FrostCreateSignConfigView extends ConsumerStatefulWidget { + const FrostCreateSignConfigView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/frostCreateSignConfigView"; + + final String walletId; + + @override + ConsumerState createState() => + _FrostCreateSignConfigViewState(); +} + +class _FrostCreateSignConfigViewState + extends ConsumerState { + bool _attemptSignLock = false; + + Future _attemptSign() async { + if (_attemptSignLock) { + return; + } + + _attemptSignLock = true; + + try { + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as BitcoinFrostWallet; + + final attemptSignRes = await wallet.frostAttemptSignConfig( + config: ref.read(pFrostTxData.state).state!.frostMSConfig!, + ); + + ref.read(pFrostAttemptSignData.notifier).state = attemptSignRes; + + await Navigator.of(context).pushNamed( + FrostAttemptSignConfigView.routeName, + arguments: widget.walletId, + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Error, + ); + } finally { + _attemptSignLock = false; + } + } + + @override + Widget build(BuildContext context) { + double qrImageSize = + Util.isDesktop ? 360 : MediaQuery.of(context).size.width - 32; + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SingleChildScrollView( + child: SizedBox( + width: 600, // Was 480, may look better but overflows the bottom. + child: child, + ), + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Sign config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + if (!Util.isDesktop) const Spacer(), + SizedBox( + height: qrImageSize, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: ref.watch(pFrostTxData.state).state!.frostMSConfig!, + size: qrImageSize, + backgroundColor: + Theme.of(context).extension()!.background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + if (!Util.isDesktop) + const SizedBox( + height: 32, + ), + DetailItem( + title: "Encoded config", + detail: ref.watch(pFrostTxData.state).state!.frostMSConfig!, + button: Util.isDesktop + ? IconCopyButton( + data: ref.watch(pFrostTxData.state).state!.frostMSConfig!, + ) + : SimpleCopyButton( + data: ref.watch(pFrostTxData.state).state!.frostMSConfig!, + ), + ), + SizedBox( + height: Util.isDesktop ? 20 : 16, + ), + if (!Util.isDesktop) + const Spacer( + flex: 2, + ), + PrimaryButton( + label: "Attempt sign", + onPressed: () { + _attemptSign(); + }, + ), + const SizedBox( + height: 16, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/send_view/frost_ms/frost_import_sign_config_view.dart b/lib/pages/send_view/frost_ms/frost_import_sign_config_view.dart new file mode 100644 index 000000000..c89b6846a --- /dev/null +++ b/lib/pages/send_view/frost_ms/frost_import_sign_config_view.dart @@ -0,0 +1,332 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar/isar.dart'; +import 'package:stackwallet/models/isar/models/isar_models.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/frost_attempt_sign_config_view.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/format.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class FrostImportSignConfigView extends ConsumerStatefulWidget { + const FrostImportSignConfigView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/frostImportSignConfigView"; + + final String walletId; + + @override + ConsumerState createState() => + _FrostImportSignConfigViewState(); +} + +class _FrostImportSignConfigViewState + extends ConsumerState { + late final TextEditingController configFieldController; + late final FocusNode configFocusNode; + + bool _configEmpty = true; + + bool _attemptSignLock = false; + + Future _attemptSign() async { + if (_attemptSignLock) { + return; + } + + _attemptSignLock = true; + + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + } + + final config = configFieldController.text; + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as BitcoinFrostWallet; + + final data = Frost.extractDataFromSignConfig( + signConfig: config, + coin: wallet.cryptoCurrency, + ); + + final utxos = await ref + .read(mainDBProvider) + .getUTXOs(wallet.walletId) + .filter() + .anyOf( + data.inputs, + (q, e) => q + .txidEqualTo(Format.uint8listToString(e.hash)) + .and() + .valueEqualTo(e.value) + .and() + .voutEqualTo(e.vout)) + .findAll(); + + // TODO add more data from 'data' and display to user ? + ref.read(pFrostTxData.notifier).state = TxData( + frostMSConfig: config, + recipients: data.recipients + .map((e) => (address: e.address, amount: e.amount, isChange: false)) + .toList(), + utxos: utxos.toSet(), + ); + + final attemptSignRes = await wallet.frostAttemptSignConfig( + config: ref.read(pFrostTxData.state).state!.frostMSConfig!, + ); + + ref.read(pFrostAttemptSignData.notifier).state = attemptSignRes; + + await Navigator.of(context).pushNamed( + FrostAttemptSignConfigView.routeName, + arguments: widget.walletId, + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Error, + ); + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Import and attempt sign config failed", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } finally { + _attemptSignLock = false; + } + } + + @override + void initState() { + configFieldController = TextEditingController(); + configFocusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + configFieldController.dispose(); + configFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Import FROST sign config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frConfigTextFieldKey"), + controller: configFieldController, + onChanged: (_) { + setState(() { + _configEmpty = configFieldController.text.isEmpty; + }); + }, + focusNode: configFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter config", + configFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _configEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_configEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Config Field.", + key: const Key("frConfigClearButtonKey"), + onTap: () { + configFieldController.text = ""; + + setState(() { + _configEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Config Field Input.", + key: const Key("frConfigPasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + configFieldController.text = + data.text!.trim(); + } + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + }, + child: _configEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_configEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("frConfigScanQrButtonKey"), + onTap: () async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75)); + } + + final qrResult = await BarcodeScanner.scan(); + + configFieldController.text = + qrResult.rawContent; + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Start signing", + enabled: !_configEmpty, + onPressed: () { + _attemptSign(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/send_view/frost_ms/frost_send_view.dart b/lib/pages/send_view/frost_ms/frost_send_view.dart new file mode 100644 index 000000000..5f04bb346 --- /dev/null +++ b/lib/pages/send_view/frost_ms/frost_send_view.dart @@ -0,0 +1,613 @@ +/* + * 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 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:stackwallet/models/isar/models/isar_models.dart'; +import 'package:stackwallet/pages/coin_control/coin_control_view.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/frost_create_sign_config_view.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/frost_import_sign_config_view.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/recipient.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/providers/providers.dart'; +import 'package:stackwallet/themes/coin_icon_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/assets.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/show_loading.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; +import 'package:stackwallet/widgets/fee_slider.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/rounded_white_container.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; +import 'package:tuple/tuple.dart'; + +class FrostSendView extends ConsumerStatefulWidget { + const FrostSendView({ + Key? key, + required this.walletId, + required this.coin, + }) : super(key: key); + + static const String routeName = "/frostSendView"; + + final String walletId; + final Coin coin; + + @override + ConsumerState createState() => _FrostSendViewState(); +} + +class _FrostSendViewState extends ConsumerState { + final List recipientWidgetIndexes = [0]; + int _greatestWidgetIndex = 0; + + late final String walletId; + late final Coin coin; + + late TextEditingController noteController; + late TextEditingController onChainNoteController; + + final _noteFocusNode = FocusNode(); + + Set selectedUTXOs = {}; + + bool _createSignLock = false; + + Future _loadingFuture() async { + final wallet = ref.read(pWallets).getWallet(walletId) as BitcoinFrostWallet; + + final recipients = recipientWidgetIndexes + .map((i) => ref.read(pRecipient(i).state).state) + .map((e) => (address: e!.address, amount: e!.amount!, isChange: false)) + .toList(growable: false); + + final txData = await wallet.frostCreateSignConfig( + txData: TxData(recipients: recipients), + changeAddress: (await wallet.getCurrentReceivingAddress())!.value, + feePerWeight: customFeeRate, + ); + + return txData; + } + + Future _createSignConfig() async { + if (_createSignLock) { + return; + } + _createSignLock = true; + + try { + // wait for keyboard to disappear + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 100), + ); + + TxData? txData; + if (mounted) { + txData = await showLoading( + whileFuture: _loadingFuture(), + context: context, + message: "Generating sign config", + isDesktop: Util.isDesktop, + onException: (e) { + throw e; + }, + ); + } + + if (mounted && txData != null) { + ref.read(pFrostTxData.notifier).state = txData; + + await Navigator.of(context).pushNamed( + FrostCreateSignConfigView.routeName, + arguments: widget.walletId, + ); + } + } catch (e) { + if (mounted) { + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Create sign config failed", + message: e.toString(), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + ), + ); + } + } finally { + _createSignLock = false; + } + } + + int customFeeRate = 1; + + void _validateRecipientFormStates() { + for (final i in recipientWidgetIndexes) { + final state = ref.read(pRecipient(i).state).state; + if (state?.amount == null || state?.address == null) { + ref.read(previewTxButtonStateProvider.notifier).state = false; + return; + } + } + ref.read(previewTxButtonStateProvider.notifier).state = true; + return; + } + + @override + void initState() { + coin = widget.coin; + walletId = widget.walletId; + + noteController = TextEditingController(); + + super.initState(); + } + + @override + void dispose() { + noteController.dispose(); + + _noteFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + final wallet = ref.watch(pWallets).getWallet(walletId); + + final showCoinControl = wallet is CoinControlInterface && + ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.enableCoinControl, + ), + ); + + return ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + } + if (mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Send ${coin.ticker}", + style: STextStyles.navBarTitle(context), + ), + actions: [ + Padding( + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + right: 10, + ), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + semanticsLabel: "Import sign config Button.", + key: const Key("importSignConfigButtonKey"), + size: 36, + shadows: const [], + color: + Theme.of(context).extension()!.background, + icon: SvgPicture.asset( + Assets.svg.circlePlus, + color: Theme.of(context) + .extension()! + .accentColorDark, + width: 20, + height: 20, + ), + onPressed: () { + Navigator.of(context).pushNamed( + FrostImportSignConfigView.routeName, + arguments: walletId, + ); + }, + ), + ), + ), + ], + ), + body: LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + // subtract top and bottom padding set in parent + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), + child: child, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!Util.isDesktop) + Container( + decoration: BoxDecoration( + color: Theme.of(context).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + SvgPicture.file( + File( + ref.watch( + coinIconProvider(coin), + ), + ), + width: 22, + height: 22, + ), + const SizedBox( + width: 6, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.titleBold12(context) + .copyWith(fontSize: 14), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + // const SizedBox( + // height: 2, + // ), + Text( + "Available balance", + style: STextStyles.label(context) + .copyWith(fontSize: 10), + ), + ], + ), + Util.isDesktop + ? const SizedBox( + height: 24, + ) + : const Spacer(), + GestureDetector( + onTap: () { + // cryptoAmountController.text = ref + // .read(pAmountFormatter(coin)) + // .format( + // _cachedBalance!, + // withUnitName: false, + // ); + }, + child: Container( + color: Colors.transparent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + ref.watch(pAmountFormatter(coin)).format(ref + .watch(pWalletBalance(walletId)) + .spendable), + style: + STextStyles.titleBold12(context).copyWith( + fontSize: 10, + ), + textAlign: TextAlign.right, + ), + // Text( + // "${(manager.balance.spendable.decimal * ref.watch( + // priceAnd24hChangeNotifierProvider.select( + // (value) => value.getPrice(coin).item1, + // ), + // )).toAmount( + // fractionDigits: 2, + // ).fiatString( + // locale: locale, + // )} ${ref.watch( + // prefsChangeNotifierProvider + // .select((value) => value.currency), + // )}", + // style: STextStyles.subtitle(context).copyWith( + // fontSize: 8, + // ), + // textAlign: TextAlign.right, + // ) + ], + ), + ), + ) + ], + ), + ), + ), + const SizedBox( + height: 16, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Recipients", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + CustomTextButton( + text: "Add", + onTap: () { + // used for tracking recipient forms + _greatestWidgetIndex++; + recipientWidgetIndexes.add(_greatestWidgetIndex); + setState(() {}); + }, + ), + ], + ), + const SizedBox( + height: 8, + ), + Column( + children: [ + for (int i = 0; i < recipientWidgetIndexes.length; i++) + ConditionalParent( + condition: recipientWidgetIndexes.length > 1, + builder: (child) => Padding( + padding: const EdgeInsets.only(top: 8), + child: child, + ), + child: Recipient( + key: Key( + "recipientKey_${recipientWidgetIndexes[i]}", + ), + index: recipientWidgetIndexes[i], + coin: coin, + onChanged: () { + _validateRecipientFormStates(); + }, + remove: i == 0 && recipientWidgetIndexes.length == 1 + ? null + : () { + recipientWidgetIndexes.removeAt(i); + setState(() {}); + }, + ), + ), + ], + ), + if (showCoinControl) + const SizedBox( + height: 8, + ), + if (showCoinControl) + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Coin control", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), + ), + CustomTextButton( + text: selectedUTXOs.isEmpty + ? "Select coins" + : "Selected coins (${selectedUTXOs.length})", + onTap: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 100), + ); + } + + if (mounted) { + // finally spendable = ref + // .read(walletsChangeNotifierProvider) + // .getManager(widget.walletId) + // .balance + // .spendable; + + // TODO: [prio=high] make sure this coincontrol works correctly + + Amount? amount; + + final result = await Navigator.of(context).pushNamed( + CoinControlView.routeName, + arguments: Tuple4( + walletId, + CoinControlViewType.use, + amount, + selectedUTXOs, + ), + ); + + if (result is Set) { + setState(() { + selectedUTXOs = result; + }); + } + } + }, + ), + ], + ), + ), + const SizedBox( + height: 12, + ), + Text( + "Note (optional)", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox( + height: 8, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + controller: noteController, + focusNode: _noteFocusNode, + style: STextStyles.field(context), + onChanged: (_) => setState(() {}), + decoration: standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + ).copyWith( + suffixIcon: noteController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + noteController.text = ""; + }); + }, + ), + ], + ), + ), + ) + : null, + ), + ), + ), + const SizedBox( + height: 12, + ), + Padding( + padding: const EdgeInsets.only( + bottom: 12, + top: 16, + ), + child: FeeSlider( + coin: coin, + onSatVByteChanged: (rate) { + customFeeRate = rate; + }, + ), + ), + Util.isDesktop + ? const SizedBox( + height: 12, + ) + : const Spacer(), + const SizedBox( + height: 12, + ), + TextButton( + onPressed: ref.watch(previewTxButtonStateProvider.state).state + ? _createSignConfig + : null, + style: ref.watch(previewTxButtonStateProvider.state).state + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), + child: Text( + "Create config", + style: STextStyles.button(context), + ), + ), + const SizedBox( + height: 16, + ), + ], + ), + ), + ); + } +} + +final previewTxButtonStateProvider = StateProvider((_) => false); diff --git a/lib/pages/send_view/frost_ms/recipient.dart b/lib/pages/send_view/frost_ms/recipient.dart new file mode 100644 index 000000000..89121d065 --- /dev/null +++ b/lib/pages/send_view/frost_ms/recipient.dart @@ -0,0 +1,502 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/providers/global/locale_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/address_utils.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_input_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_unit.dart'; +import 'package:stackwallet/utilities/barcode_scanner_interface.dart'; +import 'package:stackwallet/utilities/clipboard_interface.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/rounded_white_container.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +//TODO: move the following two providers elsewhere +final pClipboard = + Provider((ref) => const ClipboardWrapper()); +final pBarcodeScanner = + Provider((ref) => const BarcodeScannerWrapper()); + +// final _pPrice = Provider.family((ref, coin) { +// return ref.watch( +// priceAnd24hChangeNotifierProvider +// .select((value) => value.getPrice(coin).item1), +// ); +// }); + +final pRecipient = + StateProvider.family<({String address, Amount? amount})?, int>( + (ref, index) => null); + +class Recipient extends ConsumerStatefulWidget { + const Recipient({ + super.key, + required this.index, + required this.coin, + this.remove, + this.onChanged, + }); + + final int index; + final Coin coin; + + final VoidCallback? remove; + final VoidCallback? onChanged; + + @override + ConsumerState createState() => _RecipientState(); +} + +class _RecipientState extends ConsumerState { + late final TextEditingController addressController, amountController; + late final FocusNode addressFocusNode, amountFocusNode; + + bool _addressIsEmpty = true; + bool _cryptoAmountChangeLock = false; + + void _updateRecipientData() { + final address = addressController.text; + final amount = + ref.read(pAmountFormatter(widget.coin)).tryParse(amountController.text); + + ref.read(pRecipient(widget.index).notifier).state = ( + address: address, + amount: amount, + ); + widget.onChanged?.call(); + } + + void _cryptoAmountChanged() async { + if (!_cryptoAmountChangeLock) { + Amount? cryptoAmount = ref.read(pAmountFormatter(widget.coin)).tryParse( + amountController.text, + ); + if (cryptoAmount != null) { + if (ref.read(pRecipient(widget.index))?.amount != null && + ref.read(pRecipient(widget.index))?.amount == cryptoAmount) { + return; + } + + // final price = ref.read(_pPrice(widget.coin)); + // + // if (price > Decimal.zero) { + // baseController.text = (cryptoAmount.decimal * price) + // .toAmount( + // fractionDigits: 2, + // ) + // .fiatString( + // locale: ref.read(localeServiceChangeNotifierProvider).locale, + // ); + // } + } else { + cryptoAmount = null; + // baseController.text = ""; + } + + _updateRecipientData(); + } + } + + @override + void initState() { + addressController = TextEditingController(); + amountController = TextEditingController(); + // baseController = TextEditingController(); + + addressFocusNode = FocusNode(); + amountFocusNode = FocusNode(); + // baseFocusNode = FocusNode(); + + amountController.addListener(_cryptoAmountChanged); + + super.initState(); + } + + @override + void dispose() { + amountController.removeListener(_cryptoAmountChanged); + + addressController.dispose(); + amountController.dispose(); + // baseController.dispose(); + + addressFocusNode.dispose(); + amountFocusNode.dispose(); + // baseFocusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final String locale = ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), + ); + + return RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("sendViewAddressFieldKey"), + controller: addressController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + focusNode: addressFocusNode, + style: STextStyles.field(context), + onChanged: (_) { + setState(() { + _addressIsEmpty = addressController.text.isEmpty; + }); + }, + decoration: standardInputDecoration( + "Enter ${widget.coin.ticker} address", + addressFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _addressIsEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_addressIsEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Address Field Input.", + key: const Key( + "sendViewClearAddressFieldButtonKey"), + onTap: () { + addressController.text = ""; + + setState(() { + _addressIsEmpty = true; + }); + + _updateRecipientData(); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Address Field Input.", + key: const Key( + "sendViewPasteAddressFieldButtonKey"), + onTap: () async { + final ClipboardData? data = await ref + .read(pClipboard) + .getData(Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + String content = data.text!.trim(); + if (content.contains("\n")) { + content = content.substring( + 0, content.indexOf("\n")); + } + + addressController.text = content.trim(); + + setState(() { + _addressIsEmpty = + addressController.text.isEmpty; + }); + + _updateRecipientData(); + } + }, + child: _addressIsEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_addressIsEmpty) + TextFieldIconButton( + semanticsLabel: "Scan QR Button. " + "Opens Camera For Scanning QR Code.", + key: const Key( + "sendViewScanQrButtonKey", + ), + onTap: () async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75, + ), + ); + } + + final qrResult = + await ref.read(pBarcodeScanner).scan(); + + Logging.instance.log( + "qrResult content: ${qrResult.rawContent}", + level: LogLevel.Info, + ); + + /// TODO: deal with address utils + final results = + AddressUtils.parseUri(qrResult.rawContent); + + Logging.instance.log( + "qrResult parsed: $results", + level: LogLevel.Info, + ); + + if (results.isNotEmpty && + results["scheme"] == + widget.coin.uriScheme) { + // auto fill address + + addressController.text = + (results["address"] ?? "").trim(); + + // autofill amount field + if (results["amount"] != null) { + final Amount amount = + Decimal.parse(results["amount"]!) + .toAmount( + fractionDigits: widget.coin.decimals, + ); + amountController.text = ref + .read(pAmountFormatter(widget.coin)) + .format( + amount, + withUnitName: false, + ); + } + } else { + addressController.text = + qrResult.rawContent.trim(); + } + + setState(() { + _addressIsEmpty = + addressController.text.isEmpty; + }); + + _updateRecipientData(); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while " + "trying to scan qr code in SendView: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 12, + ), + TextField( + autocorrect: false, + enableSuggestions: false, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ), + key: const Key("amountInputFieldCryptoTextFieldKey"), + controller: amountController, + focusNode: amountFocusNode, + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + AmountInputFormatter( + decimals: widget.coin.decimals, + unit: ref.watch(pAmountUnit(widget.coin)), + locale: locale, + ), + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 12, + right: 12, + ), + hintText: "0", + hintStyle: STextStyles.fieldLabel(context).copyWith( + fontSize: 14, + ), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + ref + .watch(pAmountUnit(widget.coin)) + .unitForCoin(widget.coin), + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark), + ), + ), + ), + ), + ), + // if (ref.watch(prefsChangeNotifierProvider + // .select((value) => value.externalCalls))) + // const SizedBox( + // height: 8, + // ), + // if (ref.watch(prefsChangeNotifierProvider + // .select((value) => value.externalCalls))) + // TextField( + // autocorrect: Util.isDesktop ? false : true, + // enableSuggestions: Util.isDesktop ? false : true, + // style: STextStyles.smallMed14(context).copyWith( + // color: Theme.of(context).extension()!.textDark, + // ), + // key: const Key("amountInputFieldFiatTextFieldKey"), + // controller: baseController, + // focusNode: baseFocusNode, + // keyboardType: Util.isDesktop + // ? null + // : const TextInputType.numberWithOptions( + // signed: false, + // decimal: true, + // ), + // textAlign: TextAlign.right, + // inputFormatters: [ + // AmountInputFormatter( + // decimals: 2, + // locale: locale, + // ), + // ], + // onChanged: (baseAmountString) { + // final baseAmount = Amount.tryParseFiatString( + // baseAmountString, + // locale: locale, + // ); + // Amount? cryptoAmount; + // final int decimals = widget.coin.decimals; + // if (baseAmount != null) { + // final _price = ref.read(_pPrice(widget.coin)); + // + // if (_price == Decimal.zero) { + // cryptoAmount = 0.toAmountAsRaw( + // fractionDigits: decimals, + // ); + // } else { + // cryptoAmount = baseAmount <= Amount.zero + // ? 0.toAmountAsRaw(fractionDigits: decimals) + // : (baseAmount.decimal / _price) + // .toDecimal( + // scaleOnInfinitePrecision: decimals, + // ) + // .toAmount(fractionDigits: decimals); + // } + // if (ref.read(pRecipient(widget.index))?.amount != null && + // ref.read(pRecipient(widget.index))?.amount == + // cryptoAmount) { + // return; + // } + // + // final amountString = + // ref.read(pAmountFormatter(widget.coin)).format( + // cryptoAmount, + // withUnitName: false, + // ); + // + // _cryptoAmountChangeLock = true; + // amountController.text = amountString; + // _cryptoAmountChangeLock = false; + // } else { + // cryptoAmount = 0.toAmountAsRaw( + // fractionDigits: decimals, + // ); + // _cryptoAmountChangeLock = true; + // amountController.text = ""; + // _cryptoAmountChangeLock = false; + // } + // + // _updateRecipientData(); + // }, + // decoration: InputDecoration( + // contentPadding: const EdgeInsets.only( + // top: 12, + // right: 12, + // ), + // hintText: "0", + // hintStyle: STextStyles.fieldLabel(context).copyWith( + // fontSize: 14, + // ), + // prefixIcon: FittedBox( + // fit: BoxFit.scaleDown, + // child: Padding( + // padding: const EdgeInsets.all(12), + // child: Text( + // ref.watch(prefsChangeNotifierProvider + // .select((value) => value.currency)), + // style: STextStyles.smallMed14(context).copyWith( + // color: Theme.of(context) + // .extension()! + // .accentColorDark), + // ), + // ), + // ), + // ), + // ), + if (widget.remove != null) + const SizedBox( + height: 6, + ), + if (widget.remove != null) + Row( + children: [ + const Spacer(), + CustomTextButton( + text: "Remove", + onTap: () { + ref.read(pRecipient(widget.index).notifier).state = null; + widget.remove?.call(); + }, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart index f2178a450..8572d5037 100644 --- a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart +++ b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart @@ -26,6 +26,7 @@ import 'package:stackwallet/utilities/text_styles.dart'; import 'package:stackwallet/wallets/isar/providers/eth/current_token_wallet_provider.dart'; import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; import 'package:stackwallet/widgets/animated_text.dart'; final feeSheetSessionCacheProvider = @@ -697,7 +698,7 @@ class _TransactionFeeSelectionSheetState const SizedBox( height: 24, ), - if (coin.isElectrumXCoin) + if (wallet is ElectrumXInterface) GestureDetector( onTap: () { final state = @@ -766,7 +767,7 @@ class _TransactionFeeSelectionSheetState ), ), ), - if (coin.isElectrumXCoin) + if (wallet is ElectrumXInterface) const SizedBox( height: 24, ), diff --git a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart index ddac06aca..3c56cde4d 100644 --- a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart +++ b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart @@ -166,6 +166,8 @@ class _AddEditNodeViewState extends ConsumerState { case Coin.firo: case Coin.namecoin: case Coin.particl: + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: case Coin.bitcoinTestNet: case Coin.litecoinTestNet: case Coin.bitcoincashTestnet: @@ -758,6 +760,8 @@ class _NodeFormState extends ConsumerState { case Coin.eCash: case Coin.stellar: case Coin.stellarTestnet: + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: return false; case Coin.ethereum: diff --git a/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart b/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart index bd974b6ec..83e6bbab9 100644 --- a/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart +++ b/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart @@ -148,6 +148,8 @@ class _NodeDetailsViewState extends ConsumerState { case Coin.litecoinTestNet: case Coin.bitcoincashTestnet: case Coin.eCash: + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: final client = ElectrumXClient( host: node!.host, port: node.port, 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 9891148d7..f6491cf1d 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 @@ -13,6 +13,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:frostdart/frostdart.dart' as frost; import 'package:isar/isar.dart'; import 'package:stack_wallet_backup/stack_wallet_backup.dart'; import 'package:stackwallet/db/hive/db.dart'; @@ -26,6 +27,7 @@ import 'package:stackwallet/models/stack_restoring_ui_state.dart'; import 'package:stackwallet/models/trade_wallet_lookup.dart'; import 'package:stackwallet/models/wallet_restore_state.dart'; import 'package:stackwallet/services/address_book_service.dart'; +import 'package:stackwallet/services/frost.dart'; import 'package:stackwallet/services/node_service.dart'; import 'package:stackwallet/services/trade_notes_service.dart'; import 'package:stackwallet/services/trade_sent_from_stack_service.dart'; @@ -41,7 +43,9 @@ import 'package:stackwallet/utilities/format.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; import 'package:stackwallet/wallets/wallet/wallet.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/cw_based_interface.dart'; @@ -302,6 +306,24 @@ abstract class SWB { await wallet.getMnemonicPassphrase(); } else if (wallet is PrivateKeyInterface) { backupWallet['privateKey'] = await wallet.getPrivateKey(); + } else if (wallet is BitcoinFrostWallet) { + String? keys = await wallet.getSerializedKeys(); + String? config = await wallet.getMultisigConfig(); + if (keys == null || config == null) { + String err = "${wallet.info.coin.name} wallet ${wallet.info.name} " + "has null keys or config"; + Logging.instance.log(err, level: LogLevel.Fatal); + throw Exception(err); + } + //This case should never actually happen in practice unless the whole + // wallet is somehow corrupt + // TODO [prio=low]: solve case in which either keys or config is null. + + // Format keys & config as a JSON string and set otherDataJsonString. + Map frostData = {}; + frostData["keys"] = keys; + frostData["config"] = config; + backupWallet['frostWalletData'] = jsonEncode(frostData); } backupWallet['coinName'] = wallet.info.coin.name; backupWallet['storedChainHeight'] = wallet.info.cachedChainHeight; @@ -384,7 +406,9 @@ abstract class SWB { if (walletbackup['mnemonic'] == null) { // probably private key based - privateKey = walletbackup['privateKey'] as String; + if (walletbackup['privateKey'] != null) { + privateKey = walletbackup['privateKey'] as String; + } } else { if (walletbackup['mnemonic'] is List) { List mnemonicList = (walletbackup['mnemonic'] as List) @@ -406,6 +430,37 @@ abstract class SWB { ); try { + String? serializedKeys; + String? multisigConfig; + if (info.coin.isFrost) { + // Decode info.otherDataJsonString for Frost recovery info. + final frostData = jsonDecode(walletbackup["frostWalletData"] as String); + serializedKeys = frostData["keys"] as String; + multisigConfig = frostData["config"] as String; + + final myNameIndex = frost.getParticipantIndexFromKeys( + serializedKeys: serializedKeys, + ); + final participants = Frost.getParticipants( + multisigConfig: multisigConfig, + ); + final myName = participants[myNameIndex]; + + final frostInfo = FrostWalletInfo( + walletId: info.walletId, + knownSalts: [], + participants: participants, + myName: myName, + threshold: frost.multisigThreshold( + multisigConfig: multisigConfig, + ), + ); + + await MainDB.instance.isar.writeTxn(() async { + await MainDB.instance.isar.frostWalletInfo.put(frostInfo); + }); + } + final wallet = await Wallet.create( walletInfo: info, mainDB: MainDB.instance, @@ -427,7 +482,15 @@ abstract class SWB { Future? restoringFuture; if (!(wallet is CwBasedInterface || wallet is EpiccashWallet)) { - restoringFuture = wallet.recover(isRescan: false); + if (wallet is BitcoinFrostWallet) { + restoringFuture = wallet.recover( + isRescan: false, + multisigConfig: multisigConfig!, + serializedKeys: serializedKeys!, + ); + } else { + restoringFuture = wallet.recover(isRescan: false); + } } uiState?.update( diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/frost_ms_options_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/frost_ms_options_view.dart new file mode 100644 index 000000000..7fc7236a4 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/frost_ms_options_view.dart @@ -0,0 +1,186 @@ +/* + * 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_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/frost_participants_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/begin_reshare_config_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1b/import_reshare_config_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/rounded_white_container.dart'; + +class FrostMSWalletOptionsView extends ConsumerWidget { + const FrostMSWalletOptionsView({ + Key? key, + required this.walletId, + }) : super(key: key); + + static const String routeName = "/frostMSWalletOptionsView"; + + final String walletId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "FROST Multisig options", + style: STextStyles.navBarTitle(context), + ), + ), + body: child), + ), + child: Padding( + padding: const EdgeInsets.only( + top: 12, + left: 16, + right: 16, + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _OptionButton( + label: "Show participants", + onPressed: () { + Navigator.of(context).pushNamed( + FrostParticipantsView.routeName, + arguments: walletId, + ); + }, + ), + const SizedBox( + height: 8, + ), + _OptionButton( + label: "Initiate resharing", + onPressed: () { + // TODO: optimize this by creating watcher providers (similar to normal WalletInfo) + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(walletId)!; + + ref.read(pFrostMyName.state).state = frostInfo.myName; + + Navigator.of(context).pushNamed( + BeginReshareConfigView.routeName, + arguments: walletId, + ); + }, + ), + const SizedBox( + height: 8, + ), + _OptionButton( + label: "Import reshare config", + onPressed: () { + // TODO: optimize this by creating watcher providers (similar to normal WalletInfo) + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(walletId)!; + + ref.read(pFrostMyName.state).state = frostInfo.myName; + + Navigator.of(context).pushNamed( + ImportReshareConfigView.routeName, + arguments: walletId, + ); + }, + ), + ], + ), + ), + ), + ), + ); + } +} + +class _OptionButton extends StatelessWidget { + const _OptionButton({ + super.key, + required this.label, + required this.onPressed, + }); + + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + child: RawMaterialButton( + // splashColor: Theme.of(context).extension()!.highlight, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: onPressed, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 20, + ), + child: Row( + children: [ + Text( + label, + style: STextStyles.titleBold12(context), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/frost_participants_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/frost_participants_view.dart new file mode 100644 index 000000000..b4710bfe7 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/frost_participants_view.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; + +class FrostParticipantsView extends ConsumerWidget { + const FrostParticipantsView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/frostParticipantsView"; + + final String walletId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // TODO: optimize this by creating watcher providers (similar to normal WalletInfo) + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(walletId)!; + + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Participants", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < frostInfo.participants.length; i++) + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Index $i", + style: STextStyles.label(context), + ), + const SizedBox( + height: 6, + ), + SelectableText( + frostInfo.participants[i] == frostInfo.myName + ? "${frostInfo.participants[i]} (me)" + : frostInfo.participants[i], + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/finish_resharing_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/finish_resharing_view.dart new file mode 100644 index 000000000..8d23e9ed4 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/finish_resharing_view.dart @@ -0,0 +1,433 @@ +import 'dart:ffi'; + +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/verify_updated_wallet_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages/wallet_view/wallet_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class FinishResharingView extends ConsumerStatefulWidget { + const FinishResharingView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/finishResharingView"; + + final String walletId; + + @override + ConsumerState createState() => + _FinishResharingViewState(); +} + +class _FinishResharingViewState extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final List resharerIndexes; + late final String myName; + late final int? myResharerIndexIndex; + late final String? myResharerComplete; + late final bool amOutgoingParticipant; + + final List fieldIsEmptyFlags = []; + + bool _buttonLock = false; + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + if (amOutgoingParticipant) { + ref.read(pFrostResharingData).reset(); + Navigator.of(context).popUntil( + ModalRoute.withName( + Util.isDesktop ? DesktopWalletView.routeName : WalletView.routeName, + ), + ); + } else { + // collect resharer completes strings and insert my own at the correct index + final resharerCompletes = controllers.map((e) => e.text).toList(); + if (myResharerIndexIndex != null && myResharerComplete != null) { + resharerCompletes.insert(myResharerIndexIndex!, myResharerComplete!); + } + + final data = Frost.finishReshared( + prior: ref.read(pFrostResharingData).startResharedData!.prior.ref, + resharerCompletes: resharerCompletes, + ); + + ref.read(pFrostResharingData).newWalletData = data; + + await Navigator.of(context).pushNamed( + VerifyUpdatedWalletView.routeName, + arguments: widget.walletId, + ); + } + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + if (mounted) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Error", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + _buttonLock = false; + } + } + + @override + void initState() { + final amNewParticipant = + ref.read(pFrostResharingData).startResharerData == null && + ref.read(pFrostResharingData).incompleteWallet != null && + ref.read(pFrostResharingData).incompleteWallet?.walletId == + widget.walletId; + + myName = ref.read(pFrostResharingData).myName!; + + resharerIndexes = ref.read(pFrostResharingData).configData!.resharers; + + if (amNewParticipant) { + myResharerComplete = null; + myResharerIndexIndex = null; + amOutgoingParticipant = false; + } else { + myResharerComplete = ref.read(pFrostResharingData).resharerComplete!; + + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(widget.walletId)!; + final myOldIndex = + frostInfo.participants.indexOf(ref.read(pFrostResharingData).myName!); + + myResharerIndexIndex = resharerIndexes.indexOf(myOldIndex); + if (myResharerIndexIndex! >= 0) { + // remove my name for now as we don't need a text field for it + resharerIndexes.removeAt(myResharerIndexIndex!); + } + + amOutgoingParticipant = !ref + .read(pFrostResharingData) + .configData! + .newParticipants + .contains(ref.read(pFrostResharingData).myName!); + } + + for (int i = 0; i < resharerIndexes.length; i++) { + controllers.add(TextEditingController()); + focusNodes.add(FocusNode()); + fieldIsEmptyFlags.add(true); + } + super.initState(); + } + + @override + void dispose() { + for (int i = 0; i < controllers.length; i++) { + controllers[i].dispose(); + } + for (int i = 0; i < focusNodes.length; i++) { + focusNodes[i].dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Resharer completes", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + if (myResharerComplete != null) + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: myResharerComplete!, + size: 220, + backgroundColor: Theme.of(context) + .extension()! + .background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + if (myResharerComplete != null) const _Div(), + if (myResharerComplete != null) + DetailItem( + title: "My resharer complete", + detail: myResharerComplete!, + button: Util.isDesktop + ? IconCopyButton( + data: myResharerComplete!, + ) + : SimpleCopyButton( + data: myResharerComplete!, + ), + ), + if (!amOutgoingParticipant) const _Div(), + if (!amOutgoingParticipant) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < resharerIndexes.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frostEncryptionKeyTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (_) { + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + }, + decoration: standardInputDecoration( + "Enter index " + "${resharerIndexes[i]}" + "'s resharer complete", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Encryption Key Field Input.", + key: Key( + "frostEncryptionKeyClearButtonKey_$i"), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Encryption Key Field Input.", + key: Key( + "frostEncryptionKeyPasteButtonKey_$i"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: "Scan QR Button. " + "Opens Camera For Scanning QR Code.", + key: Key("frostScanQrButtonKey_$i"), + onTap: () async { + try { + if (FocusScope.of(context) + .hasFocus) { + FocusScope.of(context) + .unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions " + "while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: amOutgoingParticipant ? "Exit" : "Complete", + enabled: amOutgoingParticipant || + !fieldIsEmptyFlags.reduce((v, e) => v |= e), + onPressed: _onPressed, + ), + ], + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/begin_reshare_config_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/begin_reshare_config_view.dart new file mode 100644 index 000000000..94d2de0b2 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/begin_reshare_config_view.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/complete_reshare_config_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; + +final class BeginReshareConfigView extends ConsumerStatefulWidget { + const BeginReshareConfigView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/beginReshareConfigView"; + + final String walletId; + + @override + ConsumerState createState() => + _BeginReshareConfigViewState(); +} + +class _BeginReshareConfigViewState + extends ConsumerState { + late final int currentThreshold; + late final List currentParticipants; + + final Map pFrostResharersMap = {}; + + @override + void initState() { + ref.read(pFrostResharingData).reset(); + + // TODO: optimize this by creating watcher providers (similar to normal WalletInfo) + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(widget.walletId)!; + + currentThreshold = frostInfo.threshold; + currentParticipants = frostInfo.participants; + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + // title: Text( + // "Modify Participants", + // style: STextStyles.navBarTitle(context), + // ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Select participants for resharing", + style: STextStyles.label(context), + ), + const SizedBox( + height: 16, + ), + Column( + children: [ + for (int i = 0; i < currentParticipants.length; i++) + Padding( + padding: const EdgeInsets.only( + top: 10, + ), + child: RawMaterialButton( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: () { + if (pFrostResharersMap[currentParticipants[i]] == + null) { + pFrostResharersMap[currentParticipants[i]] = i; + } else { + pFrostResharersMap.remove(currentParticipants[i]); + } + + setState(() {}); + }, + child: Container( + color: Colors.transparent, + child: IgnorePointer( + child: Row( + children: [ + Checkbox( + value: pFrostResharersMap[ + currentParticipants[i]] == + i, + onChanged: (bool? value) {}, + ), + const SizedBox( + width: 10, + ), + Text( + currentParticipants[i], + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + ), + ), + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Continue", + enabled: pFrostResharersMap.length >= currentThreshold, + onPressed: () async { + await Navigator.of(context).pushNamed( + CompleteReshareConfigView.routeName, + arguments: ( + walletId: widget.walletId, + resharers: + pFrostResharersMap.values.toList(growable: false), + ), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/complete_reshare_config_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/complete_reshare_config_view.dart new file mode 100644 index 000000000..74cfaee17 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/complete_reshare_config_view.dart @@ -0,0 +1,335 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frostdart/frostdart.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/display_reshare_config_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/format.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; + +final class CompleteReshareConfigView extends ConsumerStatefulWidget { + const CompleteReshareConfigView({ + super.key, + required this.walletId, + required this.resharers, + }); + + static const String routeName = "/completeReshareConfigView"; + + final String walletId; + final List resharers; + + @override + ConsumerState createState() => + _CompleteReshareConfigViewState(); +} + +class _CompleteReshareConfigViewState + extends ConsumerState { + final _newThresholdController = TextEditingController(); + final _newParticipantsCountController = TextEditingController(); + + final List controllers = []; + + int _participantsCount = 0; + + bool _buttonLock = false; + + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + // TODO: optimize this by creating watcher providers (similar to normal WalletInfo) + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(widget.walletId)!; + final validationMessage = _validateInputData(); + + if (validationMessage != "valid") { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: validationMessage, + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + final config = Frost.createResharerConfig( + newThreshold: int.parse(_newThresholdController.text), + resharers: widget.resharers, + newParticipants: controllers.map((e) => e.text).toList(), + ); + + final salt = Format.uint8listToString( + resharerSalt(resharerConfig: config), + ); + + if (frostInfo.knownSalts.contains(salt)) { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Duplicate config salt", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } else { + final salts = frostInfo.knownSalts; // Fixed length list. + final newSalts = List.from(salts)..add(salt); + final mainDB = ref.read(mainDBProvider); + await mainDB.isar.writeTxn(() async { + final info = frostInfo; + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(knownSalts: newSalts), + ); + }); + } + + ref.read(pFrostResharingData).myName = frostInfo.myName; + ref.read(pFrostResharingData).resharerConfig = config; + + if (mounted) { + await Navigator.of(context).pushNamed( + DisplayReshareConfigView.routeName, + arguments: widget.walletId, + ); + } + } 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, + ), + ); + } + } finally { + _buttonLock = false; + } + } + + String _validateInputData() { + final threshold = int.tryParse(_newThresholdController.text); + if (threshold == null) { + return "Choose a threshold"; + } + + final partsCount = int.tryParse(_newParticipantsCountController.text); + if (partsCount == null) { + return "Choose total number of participants"; + } + + if (threshold > partsCount) { + return "Threshold cannot be greater than the number of participants"; + } + + if (partsCount < 2) { + return "At least two participants required"; + } + + if (controllers.length != partsCount) { + return "Participants count error"; + } + + final hasEmptyParticipants = controllers + .map((e) => e.text.isEmpty) + .reduce((value, element) => value |= element); + if (hasEmptyParticipants) { + return "Participants must not be empty"; + } + + if (controllers.length != controllers.map((e) => e.text).toSet().length) { + return "Duplicate participant name found"; + } + + return "valid"; + } + + void _participantsCountChanged(String newValue) { + final count = int.tryParse(newValue); + if (count != null) { + if (count > _participantsCount) { + for (int i = _participantsCount; i < count; i++) { + controllers.add(TextEditingController()); + } + + _participantsCount = count; + setState(() {}); + } else if (count < _participantsCount) { + for (int i = _participantsCount; i > count; i--) { + final last = controllers.removeLast(); + last.dispose(); + } + + _participantsCount = count; + setState(() {}); + } + } + } + + @override + void dispose() { + _newThresholdController.dispose(); + _newParticipantsCountController.dispose(); + for (final e in controllers) { + e.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Modify Participants", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "New threshold", + style: STextStyles.label(context), + ), + const SizedBox( + height: 10, + ), + TextField( + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + controller: _newThresholdController, + ), + const SizedBox( + height: 16, + ), + Text( + "Number of participants", + style: STextStyles.label(context), + ), + const SizedBox( + height: 10, + ), + TextField( + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + controller: _newParticipantsCountController, + onChanged: _participantsCountChanged, + ), + const SizedBox( + height: 16, + ), + if (controllers.isNotEmpty) + Text( + "Participants", + style: STextStyles.label(context), + ), + if (controllers.isNotEmpty) + const SizedBox( + height: 10, + ), + if (controllers.isNotEmpty) + Column( + children: [ + for (int i = 0; i < controllers.length; i++) + Padding( + padding: const EdgeInsets.only( + top: 10, + ), + child: TextField( + controller: controllers[i], + ), + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Generate config", + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + } + await _onPressed(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/display_reshare_config_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/display_reshare_config_view.dart new file mode 100644 index 000000000..2b7f1f899 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/display_reshare_config_view.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/begin_resharing_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; + +class DisplayReshareConfigView extends ConsumerStatefulWidget { + const DisplayReshareConfigView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/displayReshareConfigView"; + + final String walletId; + + @override + ConsumerState createState() => + _DisplayReshareConfigViewState(); +} + +class _DisplayReshareConfigViewState + extends ConsumerState { + late final bool iAmInvolved; + + bool _buttonLock = false; + + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as BitcoinFrostWallet; + + final serializedKeys = await wallet.getSerializedKeys(); + if (mounted) { + final result = Frost.beginResharer( + serializedKeys: serializedKeys!, + config: ref.read(pFrostResharingData).resharerConfig!, + ); + + ref.read(pFrostResharingData).startResharerData = result; + + await Navigator.of(context).pushNamed( + BeginResharingView.routeName, + arguments: widget.walletId, + ); + } + } 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, + ), + ); + } + } finally { + _buttonLock = false; + } + } + + @override + void initState() { + // TODO: optimize this by creating watcher providers (similar to normal WalletInfo) + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(widget.walletId)!; + + final myOldIndex = frostInfo.participants.indexOf(frostInfo.myName); + + iAmInvolved = ref + .read(pFrostResharingData) + .configData! + .resharers + .contains(myOldIndex); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Resharer config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + if (!Util.isDesktop) const Spacer(), + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: ref.watch(pFrostResharingData).resharerConfig!, + size: 220, + backgroundColor: + Theme.of(context).extension()!.background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const SizedBox( + height: 32, + ), + DetailItem( + title: "Config", + detail: ref.watch(pFrostResharingData).resharerConfig!, + button: Util.isDesktop + ? IconCopyButton( + data: ref.watch(pFrostResharingData).resharerConfig!, + ) + : SimpleCopyButton( + data: ref.watch(pFrostResharingData).resharerConfig!, + ), + ), + SizedBox( + height: Util.isDesktop ? 64 : 16, + ), + if (!Util.isDesktop) + const Spacer( + flex: 2, + ), + if (iAmInvolved) + PrimaryButton( + label: "Start resharing", + onPressed: _onPressed, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1b/import_reshare_config_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1b/import_reshare_config_view.dart new file mode 100644 index 000000000..966a24710 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1b/import_reshare_config_view.dart @@ -0,0 +1,338 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frostdart/frostdart.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/begin_resharing_view.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/providers/global/secure_store_provider.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/format.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class ImportReshareConfigView extends ConsumerStatefulWidget { + const ImportReshareConfigView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/importReshareConfigView"; + + final String walletId; + + @override + ConsumerState createState() => + _ImportReshareConfigViewState(); +} + +class _ImportReshareConfigViewState + extends ConsumerState { + late final TextEditingController configFieldController; + late final FocusNode configFocusNode; + + bool _configEmpty = true; + + bool _buttonLock = false; + + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + // TODO: optimize this by creating watcher providers (similar to normal WalletInfo) + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(widget.walletId)!; + + ref.read(pFrostResharingData).reset(); + ref.read(pFrostResharingData).myName = frostInfo.myName; + ref.read(pFrostResharingData).resharerConfig = configFieldController.text; + + String? salt; + try { + salt = Format.uint8listToString( + resharerSalt( + resharerConfig: ref.read(pFrostResharingData).resharerConfig!, + ), + ); + } catch (_) { + throw Exception("Bad resharer config"); + } + + if (frostInfo.knownSalts.contains(salt)) { + throw Exception("Duplicate config salt"); + } else { + final salts = frostInfo.knownSalts; + salts.add(salt); + final mainDB = ref.read(mainDBProvider); + await mainDB.isar.writeTxn(() async { + final info = frostInfo; + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(knownSalts: salts), + ); + }); + } + + final serializedKeys = await ref.read(secureStoreProvider).read( + key: "{${widget.walletId}}_serializedFROSTKeys", + ); + if (mounted) { + final result = Frost.beginResharer( + serializedKeys: serializedKeys!, + config: ref.read(pFrostResharingData).resharerConfig!, + ); + + ref.read(pFrostResharingData).startResharerData = result; + + await Navigator.of(context).pushNamed( + BeginResharingView.routeName, + arguments: widget.walletId, + ); + } + } 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, + ), + ); + } + } finally { + _buttonLock = false; + } + } + + @override + void initState() { + configFieldController = TextEditingController(); + configFocusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + configFieldController.dispose(); + configFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: const AppBarBackButton(), + title: Text( + "Import FROST reshare config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frConfigTextFieldKey"), + controller: configFieldController, + onChanged: (_) { + setState(() { + _configEmpty = configFieldController.text.isEmpty; + }); + }, + focusNode: configFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter config", + configFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _configEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_configEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Config Field.", + key: const Key("frConfigClearButtonKey"), + onTap: () { + configFieldController.text = ""; + + setState(() { + _configEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Config Field Input.", + key: const Key("frConfigPasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + configFieldController.text = + data.text!.trim(); + } + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + }, + child: _configEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_configEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("frConfigScanQrButtonKey"), + onTap: () async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75)); + } + + final qrResult = await BarcodeScanner.scan(); + + configFieldController.text = + qrResult.rawContent; + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Start resharing", + enabled: !_configEmpty, + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + } + + await _onPressed(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/begin_resharing_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/begin_resharing_view.dart new file mode 100644 index 000000000..90218529f --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/begin_resharing_view.dart @@ -0,0 +1,439 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/continue_resharing_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages/wallet_view/wallet_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class BeginResharingView extends ConsumerStatefulWidget { + const BeginResharingView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/beginResharingView"; + + final String walletId; + + @override + ConsumerState createState() => _BeginResharingViewState(); +} + +class _BeginResharingViewState extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final List resharerIndexes; + late final int myResharerIndexIndex; + late final String myResharerStart; + late final bool amOutgoingParticipant; + + final List fieldIsEmptyFlags = []; + + bool _buttonLock = false; + + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + if (!amOutgoingParticipant) { + // collect resharer strings + final resharerStarts = controllers.map((e) => e.text).toList(); + if (myResharerIndexIndex >= 0) { + // only insert my own at the correct index if I am a resharer + resharerStarts.insert(myResharerIndexIndex, myResharerStart); + } + + final result = Frost.beginReshared( + myName: ref.read(pFrostResharingData).myName!, + resharerConfig: ref.read(pFrostResharingData).resharerConfig!, + resharerStarts: resharerStarts, + ); + + ref.read(pFrostResharingData).startResharedData = result; + } + await Navigator.of(context).pushNamed( + ContinueResharingView.routeName, + arguments: widget.walletId, + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + if (mounted) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Error", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + _buttonLock = false; + } + } + + @override + void initState() { + // TODO: optimize this by creating watcher providers (similar to normal WalletInfo) + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(widget.walletId)!; + final myOldIndex = + frostInfo.participants.indexOf(ref.read(pFrostResharingData).myName!); + + myResharerStart = + ref.read(pFrostResharingData).startResharerData!.resharerStart; + + resharerIndexes = ref.read(pFrostResharingData).configData!.resharers; + myResharerIndexIndex = resharerIndexes.indexOf(myOldIndex); + if (myResharerIndexIndex >= 0) { + // remove my name for now as we don't need a text field for it + resharerIndexes.removeAt(myResharerIndexIndex); + } + + amOutgoingParticipant = !ref + .read(pFrostResharingData) + .configData! + .newParticipants + .contains(ref.read(pFrostResharingData).myName!); + + for (int i = 0; i < resharerIndexes.length; i++) { + controllers.add(TextEditingController()); + focusNodes.add(FocusNode()); + fieldIsEmptyFlags.add(true); + } + super.initState(); + } + + @override + void dispose() { + for (int i = 0; i < controllers.length; i++) { + controllers[i].dispose(); + } + for (int i = 0; i < focusNodes.length; i++) { + focusNodes[i].dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: Util.isDesktop + ? DesktopWalletView.routeName + : WalletView.routeName, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: DesktopWalletView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: WalletView.routeName, + ), + ); + }, + ), + title: Text( + "Resharers", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: myResharerStart, + size: 220, + backgroundColor: Theme.of(context) + .extension()! + .background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const _Div(), + DetailItem( + title: "My resharer", + detail: myResharerStart, + button: Util.isDesktop + ? IconCopyButton( + data: myResharerStart, + ) + : SimpleCopyButton( + data: myResharerStart, + ), + ), + const _Div(), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < resharerIndexes.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frostResharerTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (_) { + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + }, + decoration: standardInputDecoration( + "Enter index " + "${resharerIndexes[i]}" + "'s resharer", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Resharer Field Input.", + key: Key( + "frostResharerClearButtonKey_$i"), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Resharer Field Input.", + key: Key( + "frostResharerPasteButtonKey_$i"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: "Scan QR Button. " + "Opens Camera For Scanning QR Code.", + key: Key( + "frostCommitmentsScanQrButtonKey_$i"), + onTap: () async { + try { + if (FocusScope.of(context) + .hasFocus) { + FocusScope.of(context) + .unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions " + "while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Continue", + enabled: amOutgoingParticipant || + !fieldIsEmptyFlags.reduce((v, e) => v |= e), + onPressed: _onPressed, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/continue_resharing_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/continue_resharing_view.dart new file mode 100644 index 000000000..75359d266 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/continue_resharing_view.dart @@ -0,0 +1,429 @@ +import 'dart:ffi'; + +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/finish_resharing_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages/wallet_view/wallet_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class ContinueResharingView extends ConsumerStatefulWidget { + const ContinueResharingView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/continueResharingView"; + + final String walletId; + + @override + ConsumerState createState() => + _ContinueResharingViewState(); +} + +class _ContinueResharingViewState extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final List newParticipants; + late final int myIndex; + late final String? myEncryptionKey; + late final bool amOutgoingParticipant; + + final List fieldIsEmptyFlags = []; + + bool _buttonLock = false; + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + // collect encryptionKeys strings and insert my own at the correct index + final encryptionKeys = controllers.map((e) => e.text).toList(); + if (!amOutgoingParticipant) { + encryptionKeys.insert(myIndex, myEncryptionKey!); + } + + final result = Frost.finishResharer( + machine: ref.read(pFrostResharingData).startResharerData!.machine.ref, + encryptionKeysOfResharedTo: encryptionKeys, + ); + + ref.read(pFrostResharingData).resharerComplete = result; + + await Navigator.of(context).pushNamed( + FinishResharingView.routeName, + arguments: widget.walletId, + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Error", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } finally { + _buttonLock = false; + } + } + + @override + void initState() { + myEncryptionKey = + ref.read(pFrostResharingData).startResharedData?.resharedStart; + + newParticipants = ref.read(pFrostResharingData).configData!.newParticipants; + myIndex = newParticipants.indexOf(ref.read(pFrostResharingData).myName!); + + if (myIndex >= 0) { + // remove my name for now as we don't need a text field for it + newParticipants.removeAt(myIndex); + } + + if (myEncryptionKey == null && myIndex == -1) { + amOutgoingParticipant = true; + } else if (myEncryptionKey != null && myIndex >= 0) { + amOutgoingParticipant = false; + } else { + throw Exception("Invalid resharing state"); + } + + for (int i = 0; i < newParticipants.length; i++) { + controllers.add(TextEditingController()); + focusNodes.add(FocusNode()); + fieldIsEmptyFlags.add(true); + } + super.initState(); + } + + @override + void dispose() { + for (int i = 0; i < controllers.length; i++) { + controllers[i].dispose(); + } + for (int i = 0; i < focusNodes.length; i++) { + focusNodes[i].dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: Util.isDesktop + ? DesktopWalletView.routeName + : WalletView.routeName, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: DesktopWalletView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: WalletView.routeName, + ), + ); + }, + ), + title: Text( + "Encryption keys", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + if (!amOutgoingParticipant) + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: myEncryptionKey!, + size: 220, + backgroundColor: Theme.of(context) + .extension()! + .background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + if (!amOutgoingParticipant) const _Div(), + if (!amOutgoingParticipant) + DetailItem( + title: "My encryption key", + detail: myEncryptionKey!, + button: Util.isDesktop + ? IconCopyButton( + data: myEncryptionKey!, + ) + : SimpleCopyButton( + data: myEncryptionKey!, + ), + ), + if (!amOutgoingParticipant) const _Div(), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < newParticipants.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frostEncryptionKeyTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (_) { + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + }, + decoration: standardInputDecoration( + "Enter " + "${newParticipants[i]}" + "'s encryption key", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Encryption Key Field Input.", + key: Key( + "frostEncryptionKeyClearButtonKey_$i"), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Encryption Key Field Input.", + key: Key( + "frostEncryptionKeyPasteButtonKey_$i"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: "Scan QR Button. " + "Opens Camera For Scanning QR Code.", + key: Key( + "frostCommitmentsScanQrButtonKey_$i"), + onTap: () async { + try { + if (FocusScope.of(context) + .hasFocus) { + FocusScope.of(context) + .unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions " + "while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Continue", + enabled: !fieldIsEmptyFlags.reduce((v, e) => v |= e), + onPressed: _onPressed, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_continue_sharing_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_continue_sharing_view.dart new file mode 100644 index 000000000..86ff2ebe0 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_continue_sharing_view.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/home_view/home_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/finish_resharing_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_home_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; + +class NewContinueSharingView extends ConsumerStatefulWidget { + const NewContinueSharingView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/NewContinueSharingView"; + + final String walletId; + + @override + ConsumerState createState() => + _NewContinueSharingViewState(); +} + +class _NewContinueSharingViewState + extends ConsumerState { + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: + Util.isDesktop ? DesktopHomeView.routeName : HomeView.routeName, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: HomeView.routeName, + ), + ); + }, + ), + title: Text( + "Encryption keys", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: ref + .watch(pFrostResharingData) + .startResharedData! + .resharedStart, + size: 220, + backgroundColor: Theme.of(context) + .extension()! + .background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const _Div(), + DetailItem( + title: "My encryption key", + detail: ref + .watch(pFrostResharingData) + .startResharedData! + .resharedStart, + button: Util.isDesktop + ? IconCopyButton( + data: ref + .watch(pFrostResharingData) + .startResharedData! + .resharedStart, + ) + : SimpleCopyButton( + data: ref + .watch(pFrostResharingData) + .startResharedData! + .resharedStart, + ), + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Continue", + onPressed: () { + Navigator.of(context).pushNamed( + FinishResharingView.routeName, + arguments: widget.walletId, + ); + }, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_import_resharer_config_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_import_resharer_config_view.dart new file mode 100644 index 000000000..f3ef1ec0b --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_import_resharer_config_view.dart @@ -0,0 +1,426 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_start_resharing_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/show_loading.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/models/incomplete_frost_wallet.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class NewImportResharerConfigView extends ConsumerStatefulWidget { + const NewImportResharerConfigView({ + super.key, + required this.walletName, + required this.coin, + }); + + static const String routeName = "/newImportResharerConfigView"; + + final String walletName; + final Coin coin; + + @override + ConsumerState createState() => + _NewImportResharerConfigViewState(); +} + +class _NewImportResharerConfigViewState + extends ConsumerState { + late final TextEditingController myNameFieldController, configFieldController; + late final FocusNode myNameFocusNode, configFocusNode; + + bool _nameEmpty = true, _configEmpty = true; + + bool _buttonLock = false; + + Future _createWallet() async { + final info = WalletInfo.createNew( + name: widget.walletName, + coin: widget.coin, + ); + + final wallet = IncompleteFrostWallet(); + wallet.info = info; + + return wallet; + } + + @override + void initState() { + myNameFieldController = TextEditingController(); + configFieldController = TextEditingController(); + myNameFocusNode = FocusNode(); + configFocusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + myNameFieldController.dispose(); + configFieldController.dispose(); + myNameFocusNode.dispose(); + configFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Import FROST reshare config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frMyNameTextFieldKey"), + controller: myNameFieldController, + onChanged: (_) { + setState(() { + _nameEmpty = myNameFieldController.text.isEmpty; + }); + }, + focusNode: myNameFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "My name", + myNameFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _nameEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_nameEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Config Field.", + key: const Key("frMyNameClearButtonKey"), + onTap: () { + myNameFieldController.text = ""; + + setState(() { + _nameEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Name Field.", + key: const Key("frMyNamePasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + myNameFieldController.text = + data.text!.trim(); + } + + setState(() { + _nameEmpty = + myNameFieldController.text.isEmpty; + }); + }, + child: _nameEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frConfigTextFieldKey"), + controller: configFieldController, + onChanged: (_) { + setState(() { + _configEmpty = configFieldController.text.isEmpty; + }); + }, + focusNode: configFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter config", + configFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _configEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_configEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Config Field.", + key: const Key("frConfigClearButtonKey"), + onTap: () { + configFieldController.text = ""; + + setState(() { + _configEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Config Field Input.", + key: const Key("frConfigPasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + configFieldController.text = + data.text!.trim(); + } + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + }, + child: _configEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_configEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("frConfigScanQrButtonKey"), + onTap: () async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75)); + } + + final qrResult = await BarcodeScanner.scan(); + + configFieldController.text = + qrResult.rawContent; + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Start", + enabled: !_nameEmpty && !_configEmpty, + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + } + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + ref.read(pFrostResharingData).reset(); + ref.read(pFrostResharingData).myName = + myNameFieldController.text; + ref.read(pFrostResharingData).resharerConfig = + configFieldController.text; + + if (!ref + .read(pFrostResharingData) + .configData! + .newParticipants + .contains(ref.read(pFrostResharingData).myName!)) { + ref.read(pFrostResharingData).reset(); + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "My name not found in config participants", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + Exception? ex; + final wallet = await showLoading( + whileFuture: _createWallet(), + context: context, + message: "Setting up wallet", + isDesktop: Util.isDesktop, + onException: (e) => ex = e, + ); + + if (ex != null) { + throw ex!; + } + + if (mounted) { + ref.read(pFrostResharingData).incompleteWallet = wallet!; + await Navigator.of(context).pushNamed( + NewStartResharingView.routeName, + arguments: wallet.walletId, + ); + } + } 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, + ), + ); + } + } finally { + _buttonLock = false; + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_start_resharing_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_start_resharing_view.dart new file mode 100644 index 000000000..fb6107c2c --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_start_resharing_view.dart @@ -0,0 +1,379 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/home_view/home_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_continue_sharing_view.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_home_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class NewStartResharingView extends ConsumerStatefulWidget { + const NewStartResharingView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/newStartResharingView"; + + final String walletId; + + @override + ConsumerState createState() => + _NewStartResharingViewState(); +} + +class _NewStartResharingViewState extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final List resharerIndexes; + + final List fieldIsEmptyFlags = []; + + bool _buttonLock = false; + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + // collect resharer strings + final resharerStarts = controllers.map((e) => e.text).toList(); + + final result = Frost.beginReshared( + myName: ref.read(pFrostResharingData).myName!, + resharerConfig: ref.read(pFrostResharingData).resharerConfig!, + resharerStarts: resharerStarts, + ); + + ref.read(pFrostResharingData).startResharedData = result; + + await Navigator.of(context).pushNamed( + NewContinueSharingView.routeName, + arguments: widget.walletId, + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Error", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } finally { + _buttonLock = false; + } + } + + @override + void initState() { + resharerIndexes = ref.read(pFrostResharingData).configData!.resharers; + + for (int i = 0; i < resharerIndexes.length; i++) { + controllers.add(TextEditingController()); + focusNodes.add(FocusNode()); + fieldIsEmptyFlags.add(true); + } + super.initState(); + } + + @override + void dispose() { + for (int i = 0; i < controllers.length; i++) { + controllers[i].dispose(); + } + for (int i = 0; i < focusNodes.length; i++) { + focusNodes[i].dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: + Util.isDesktop ? DesktopHomeView.routeName : HomeView.routeName, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: HomeView.routeName, + ), + ); + }, + ), + title: Text( + "Resharers", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < resharerIndexes.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frostResharerTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (_) { + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + }, + decoration: standardInputDecoration( + "Enter index " + "${resharerIndexes[i]}" + "'s resharer", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Resharer Field Input.", + key: Key( + "frostResharerClearButtonKey_$i"), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Resharer Field Input.", + key: Key( + "frostResharerPasteButtonKey_$i"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: "Scan QR Button. " + "Opens Camera For Scanning QR Code.", + key: Key( + "frostCommitmentsScanQrButtonKey_$i"), + onTap: () async { + try { + if (FocusScope.of(context) + .hasFocus) { + FocusScope.of(context) + .unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions " + "while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Continue", + enabled: !fieldIsEmptyFlags.reduce((v, e) => v |= e), + onPressed: _onPressed, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/verify_updated_wallet_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/verify_updated_wallet_view.dart new file mode 100644 index 000000000..85d02c0ff --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/verify_updated_wallet_view.dart @@ -0,0 +1,315 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/home_view/home_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages/wallet_view/wallet_view.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_home_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/providers/global/node_service_provider.dart'; +import 'package:stackwallet/providers/global/prefs_provider.dart'; +import 'package:stackwallet/providers/global/secure_store_provider.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/show_loading.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; + +class VerifyUpdatedWalletView extends ConsumerStatefulWidget { + const VerifyUpdatedWalletView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/verifyUpdatedWalletView"; + + final String walletId; + + @override + ConsumerState createState() => + _VerifyUpdatedWalletViewState(); +} + +class _VerifyUpdatedWalletViewState + extends ConsumerState { + late final String config; + late final String serializedKeys; + late final String reshareId; + + late final bool isNew; + + bool _buttonLock = false; + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + Exception? ex; + + final BitcoinFrostWallet wallet; + + if (isNew) { + wallet = await ref + .read(pFrostResharingData) + .incompleteWallet! + .toBitcoinFrostWallet( + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + ); + + await wallet.info.setMnemonicVerified( + isar: ref.read(mainDBProvider).isar, + ); + + ref.read(pWallets).addWallet(wallet); + } else { + wallet = + ref.read(pWallets).getWallet(widget.walletId) as BitcoinFrostWallet; + } + + if (mounted) { + await showLoading( + whileFuture: wallet.updateWithResharedData( + serializedKeys: serializedKeys, + multisigConfig: config, + isNewWallet: isNew, + ), + context: context, + message: isNew ? "Creating wallet" : "Updating wallet data", + isDesktop: Util.isDesktop, + onException: (e) => ex = e, + ); + + if (ex != null) { + throw ex!; + } + + if (mounted) { + ref.read(pFrostResharingData).reset(); + + Navigator.of(context).popUntil( + ModalRoute.withName( + _popUntilPath, + ), + ); + } + } + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + if (mounted) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Error", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + _buttonLock = false; + } + } + + String get _popUntilPath => isNew + ? Util.isDesktop + ? DesktopHomeView.routeName + : HomeView.routeName + : Util.isDesktop + ? DesktopWalletView.routeName + : WalletView.routeName; + + @override + void initState() { + config = ref.read(pFrostResharingData).newWalletData!.multisigConfig; + serializedKeys = + ref.read(pFrostResharingData).newWalletData!.serializedKeys; + reshareId = ref.read(pFrostResharingData).newWalletData!.resharedId; + + isNew = ref.read(pFrostResharingData).incompleteWallet != null && + ref.read(pFrostResharingData).incompleteWallet!.walletId == + widget.walletId; + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: _popUntilPath, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: _popUntilPath, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: _popUntilPath, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: _popUntilPath, + ), + ); + }, + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + Text( + "Ensure your reshare ID matches that of each other participant", + style: STextStyles.pageTitleH2(context), + ), + const _Div(), + DetailItem( + title: "ID", + detail: reshareId, + button: Util.isDesktop + ? IconCopyButton( + data: reshareId, + ) + : SimpleCopyButton( + data: reshareId, + ), + ), + const _Div(), + const _Div(), + Text( + "Back up your keys and config", + style: STextStyles.pageTitleH2(context), + ), + const _Div(), + DetailItem( + title: "Config", + detail: config, + button: Util.isDesktop + ? IconCopyButton( + data: config, + ) + : SimpleCopyButton( + data: config, + ), + ), + const _Div(), + DetailItem( + title: "Keys", + detail: serializedKeys, + button: Util.isDesktop + ? IconCopyButton( + data: serializedKeys, + ) + : SimpleCopyButton( + data: serializedKeys, + ), + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Confirm", + onPressed: _onPressed, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} 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 8c2873d0d..fee8781ff 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,15 +17,20 @@ import 'package:flutter_svg/svg.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:stackwallet/notifications/show_flush_bar.dart'; import 'package:stackwallet/pages/add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; import 'package:stackwallet/themes/stack_colors.dart'; import 'package:stackwallet/utilities/address_utils.dart'; import 'package:stackwallet/utilities/assets.dart'; import 'package:stackwallet/utilities/clipboard_interface.dart'; import 'package:stackwallet/utilities/constants.dart'; import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; import 'package:stackwallet/widgets/background.dart'; import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/rounded_white_container.dart'; import 'package:stackwallet/widgets/stack_dialog.dart'; class WalletBackupView extends ConsumerWidget { @@ -33,6 +38,7 @@ class WalletBackupView extends ConsumerWidget { Key? key, required this.walletId, required this.mnemonic, + this.frostWalletData, this.clipboardInterface = const ClipboardWrapper(), }) : super(key: key); @@ -40,11 +46,21 @@ class WalletBackupView extends ConsumerWidget { final String walletId; final List mnemonic; + final ({ + String myName, + String config, + String keys, + ({String config, String keys})? prevGen, + })? frostWalletData; final ClipboardInterface clipboardInterface; @override Widget build(BuildContext context, WidgetRef ref) { debugPrint("BUILD: $runtimeType"); + + final bool frost = frostWalletData != null; + final prevGen = frostWalletData?.prevGen != null; + return Background( child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, @@ -91,139 +107,261 @@ class WalletBackupView extends ConsumerWidget { ), body: Padding( padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox( - height: 4, - ), - Text( - ref.watch(pWalletName(walletId)), - textAlign: TextAlign.center, - style: STextStyles.label(context).copyWith( - fontSize: 12, - ), - ), - const SizedBox( - height: 4, - ), - Text( - "Recovery Phrase", - textAlign: TextAlign.center, - style: STextStyles.pageTitleH1(context), - ), - const SizedBox( - height: 16, - ), - Container( - decoration: BoxDecoration( - color: Theme.of(context).extension()!.popupBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Text( - "Please write down your backup key. Keep it safe and never share it with anyone. Your backup key is the only way you can access your funds if you forget your PIN, lose your phone, etc.\n\nStack Wallet does not keep nor is able to restore your backup key. Only you have access to your wallet.", - style: STextStyles.label(context), - ), - ), - ), - const SizedBox( - height: 8, - ), - Expanded( - child: SingleChildScrollView( - child: MnemonicTable( - words: mnemonic, - isDesktop: false, - ), - ), - ), - const SizedBox( - height: 12, - ), - TextButton( - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - onPressed: () { - String data = AddressUtils.encodeQRSeedData(mnemonic); - - showDialog( - context: context, - useSafeArea: false, - barrierDismissible: true, - builder: (_) { - final width = MediaQuery.of(context).size.width / 2; - return StackDialogBase( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Text( - "Recovery phrase QR code", - style: STextStyles.pageTitleH2(context), - ), - ), - const SizedBox( - height: 12, - ), - Center( - child: RepaintBoundary( - // key: _qrKey, - child: SizedBox( - width: width + 20, - height: width + 20, - child: QrImageView( - data: data, - size: width, - backgroundColor: Theme.of(context) - .extension()! - .popupBG, - foregroundColor: Theme.of(context) - .extension()! - .accentColorDark), + child: frost + ? LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, + ), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RoundedWhiteContainer( + child: Text( + "Please write down your backup data. Keep it safe and " + "never share it with anyone. " + "Your backup data is the only way you can access your " + "funds if you forget your PIN, lose your phone, etc." + "\n\n" + "Stack Wallet does not keep nor is able to restore " + "your backup data. " + "Only you have access to your wallet.", + style: STextStyles.label(context), ), ), - ), - const SizedBox( - height: 12, - ), - Center( - child: SizedBox( - width: width, - child: TextButton( - onPressed: () async { - // await _capturePng(true); - Navigator.of(context).pop(); - }, - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), + const SizedBox( + height: 24, + ), + // DetailItem( + // title: "My name", + // detail: frostWalletData!.myName, + // button: Util.isDesktop + // ? IconCopyButton( + // data: frostWalletData!.myName, + // ) + // : SimpleCopyButton( + // data: frostWalletData!.myName, + // ), + // ), + // const SizedBox( + // height: 16, + // ), + DetailItem( + title: "Multisig config", + detail: frostWalletData!.config, + button: Util.isDesktop + ? IconCopyButton( + data: frostWalletData!.config, + ) + : SimpleCopyButton( + data: frostWalletData!.config, + ), + ), + const SizedBox( + height: 16, + ), + DetailItem( + title: "Keys", + detail: frostWalletData!.keys, + button: Util.isDesktop + ? IconCopyButton( + data: frostWalletData!.keys, + ) + : SimpleCopyButton( + data: frostWalletData!.keys, + ), + ), + if (prevGen) + const SizedBox( + height: 24, + ), + if (prevGen) + RoundedWhiteContainer( child: Text( - "Cancel", - style: STextStyles.button(context).copyWith( - color: Theme.of(context) - .extension()! - .accentColorDark), + "Previous generation info", + style: STextStyles.label(context), ), ), - ), - ), - ], + if (prevGen) + const SizedBox( + height: 12, + ), + if (prevGen) + DetailItem( + title: "Previous multisig config", + detail: frostWalletData!.prevGen!.config, + button: Util.isDesktop + ? IconCopyButton( + data: + frostWalletData!.prevGen!.config, + ) + : SimpleCopyButton( + data: + frostWalletData!.prevGen!.config, + ), + ), + if (prevGen) + const SizedBox( + height: 16, + ), + if (prevGen) + DetailItem( + title: "Previous keys", + detail: frostWalletData!.prevGen!.keys, + button: Util.isDesktop + ? IconCopyButton( + data: frostWalletData!.prevGen!.keys, + ) + : SimpleCopyButton( + data: frostWalletData!.prevGen!.keys, + ), + ), + ], + ), ), - ); - }, - ); - }, - child: Text( - "Show QR Code", - style: STextStyles.button(context), + ), + ); + }, + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox( + height: 4, + ), + Text( + ref.watch(pWalletName(walletId)), + textAlign: TextAlign.center, + style: STextStyles.label(context).copyWith( + fontSize: 12, + ), + ), + const SizedBox( + height: 4, + ), + Text( + "Recovery Phrase", + textAlign: TextAlign.center, + style: STextStyles.pageTitleH1(context), + ), + const SizedBox( + height: 16, + ), + Container( + decoration: BoxDecoration( + color: + Theme.of(context).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + "Please write down your backup key. Keep it safe and never share it with anyone. Your backup key is the only way you can access your funds if you forget your PIN, lose your phone, etc.\n\nStack Wallet does not keep nor is able to restore your backup key. Only you have access to your wallet.", + style: STextStyles.label(context), + ), + ), + ), + const SizedBox( + height: 8, + ), + Expanded( + child: SingleChildScrollView( + child: MnemonicTable( + words: mnemonic, + isDesktop: false, + ), + ), + ), + const SizedBox( + height: 12, + ), + TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: () { + String data = AddressUtils.encodeQRSeedData(mnemonic); + + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (_) { + final width = MediaQuery.of(context).size.width / 2; + return StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Text( + "Recovery phrase QR code", + style: STextStyles.pageTitleH2(context), + ), + ), + const SizedBox( + height: 12, + ), + Center( + child: RepaintBoundary( + // key: _qrKey, + child: SizedBox( + width: width + 20, + height: width + 20, + child: QrImageView( + data: data, + size: width, + backgroundColor: Theme.of(context) + .extension()! + .popupBG, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark), + ), + ), + ), + const SizedBox( + height: 12, + ), + Center( + child: SizedBox( + width: width, + child: TextButton( + onPressed: () async { + // await _capturePng(true); + Navigator.of(context).pop(); + }, + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle( + context), + child: Text( + "Cancel", + style: STextStyles.button(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark), + ), + ), + ), + ), + ], + ), + ); + }, + ); + }, + child: Text( + "Show QR Code", + style: STextStyles.button(context), + ), + ), + ], ), - ), - ], - ), ), ), ); 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 0714528e0..04de48cb4 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 @@ -22,6 +22,7 @@ import 'package:stackwallet/pages/pinpad_views/lock_screen_view.dart'; import 'package:stackwallet/pages/settings_views/global_settings_view/advanced_views/debug_view.dart'; import 'package:stackwallet/pages/settings_views/global_settings_view/syncing_preferences_views/syncing_preferences_view.dart'; import 'package:stackwallet/pages/settings_views/sub_widgets/settings_list_button.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/frost_ms_options_view.dart'; import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_backup_view.dart'; import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart'; import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/change_representative_view.dart'; @@ -39,6 +40,7 @@ import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/show_loading.dart'; import 'package:stackwallet/utilities/text_styles.dart'; import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; import 'package:stackwallet/widgets/background.dart'; @@ -193,6 +195,21 @@ class _WalletSettingsViewState extends ConsumerState { padding: const EdgeInsets.all(4), child: Column( children: [ + if (coin == Coin.bitcoinFrost || + coin == Coin.bitcoinFrostTestNet) + if (coin == Coin.bitcoinFrost || + coin == Coin.bitcoinFrostTestNet) + SettingsListButton( + iconAssetName: Assets.svg.addressBook2, + iconSize: 16, + title: "FROST Multisig settings", + onPressed: () { + Navigator.of(context).pushNamed( + FrostMSWalletOptionsView.routeName, + arguments: walletId, + ); + }, + ), SettingsListButton( iconAssetName: Assets.svg.addressBook, iconSize: 16, @@ -235,39 +252,83 @@ class _WalletSettingsViewState extends ConsumerState { final wallet = ref .read(pWallets) .getWallet(widget.walletId); - // TODO: [prio=frost] take wallets that don't have a mnemonic into account - if (wallet is MnemonicInterface) { - final mnemonic = - await wallet.getMnemonicAsWords(); - if (mounted) { - await Navigator.push( - context, - RouteGenerator.getRoute( - shouldUseMaterialRoute: - RouteGenerator - .useMaterialPageRoute, - builder: (_) => LockscreenView( - routeOnSuccessArguments: - Tuple2( - walletId, mnemonic), - showBackButton: true, - routeOnSuccess: - WalletBackupView - .routeName, - biometricsCancelButtonString: - "CANCEL", - biometricsLocalizedReason: - "Authenticate to view recovery phrase", - biometricsAuthenticationTitle: - "View recovery phrase", - ), - settings: const RouteSettings( - name: - "/viewRecoverPhraseLockscreen"), - ), + // TODO: [prio=med] take wallets that don't have a mnemonic into account + + List? mnemonic; + ({ + String myName, + String config, + String keys, + ({ + String config, + String keys + })? prevGen, + })? frostWalletData; + if (wallet is BitcoinFrostWallet) { + List> futures = []; + + futures.addAll( + [ + wallet.getSerializedKeys(), + wallet.getMultisigConfig(), + wallet.getSerializedKeysPrevGen(), + wallet.getMultisigConfigPrevGen(), + ], + ); + + final results = + await Future.wait(futures); + + if (results.length == 5) { + 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) { + mnemonic = + await wallet.getMnemonicAsWords(); + } + + if (mounted) { + await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: + RouteGenerator + .useMaterialPageRoute, + builder: (_) => LockscreenView( + routeOnSuccessArguments: ( + walletId: walletId, + mnemonic: mnemonic ?? [], + frostWalletData: + frostWalletData, + ), + showBackButton: true, + routeOnSuccess: + WalletBackupView.routeName, + biometricsCancelButtonString: + "CANCEL", + biometricsLocalizedReason: + "Authenticate to view recovery phrase", + biometricsAuthenticationTitle: + "View recovery phrase", + ), + settings: const RouteSettings( + name: + "/viewRecoverPhraseLockscreen"), + ), + ); } }, ); diff --git a/lib/pages/special/firo_rescan_recovery_error_dialog.dart b/lib/pages/special/firo_rescan_recovery_error_dialog.dart index a812c377d..6f23c7a87 100644 --- a/lib/pages/special/firo_rescan_recovery_error_dialog.dart +++ b/lib/pages/special/firo_rescan_recovery_error_dialog.dart @@ -23,7 +23,6 @@ import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; import 'package:stackwallet/widgets/desktop/primary_button.dart'; import 'package:stackwallet/widgets/desktop/secondary_button.dart'; import 'package:stackwallet/widgets/stack_dialog.dart'; -import 'package:tuple/tuple.dart'; enum FiroRescanRecoveryErrorViewOption { retry, @@ -269,8 +268,10 @@ class _FiroRescanRecoveryErrorViewState shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, builder: (_) => LockscreenView( - routeOnSuccessArguments: - Tuple2(widget.walletId, mnemonic), + routeOnSuccessArguments: ( + walletId: widget.walletId, + mnemonic: mnemonic, + ), showBackButton: true, routeOnSuccess: WalletBackupView.routeName, biometricsCancelButtonString: "CANCEL", diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart index f47417d99..ac868aee9 100644 --- a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart +++ b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart @@ -23,6 +23,7 @@ import 'package:stackwallet/providers/db/main_db_provider.dart'; import 'package:stackwallet/providers/global/wallets_provider.dart'; import 'package:stackwallet/themes/stack_colors.dart'; import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/util.dart'; import 'package:stackwallet/widgets/loading_indicator.dart'; @@ -44,6 +45,7 @@ class _TransactionsV2ListState extends ConsumerState { late final StreamSubscription> _subscription; late final Query _query; + late final Coin coin; BorderRadius get _borderRadiusFirst { return BorderRadius.only( @@ -69,6 +71,7 @@ class _TransactionsV2ListState extends ConsumerState { @override void initState() { + coin = ref.read(pWallets).getWallet(widget.walletId).info.coin; _query = ref .read(mainDBProvider) .isar @@ -110,8 +113,6 @@ class _TransactionsV2ListState extends ConsumerState { @override Widget build(BuildContext context) { - final coin = ref.watch(pWallets).getWallet(widget.walletId).info.coin; - return FutureBuilder( future: _query.findAll(), builder: (fbContext, AsyncSnapshot> snapshot) { diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 07d592b33..cfa690f11 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -29,6 +29,7 @@ import 'package:stackwallet/pages/ordinals/ordinals_view.dart'; import 'package:stackwallet/pages/paynym/paynym_claim_view.dart'; import 'package:stackwallet/pages/paynym/paynym_home_view.dart'; import 'package:stackwallet/pages/receive_view/receive_view.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/frost_send_view.dart'; import 'package:stackwallet/pages/send_view/send_view.dart'; import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart'; import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_settings_view.dart'; @@ -64,6 +65,7 @@ import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/show_loading.dart'; import 'package:stackwallet/utilities/text_styles.dart'; import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; @@ -994,10 +996,13 @@ class _WalletViewState extends ConsumerState { // break; // } Navigator.of(context).pushNamed( - SendView.routeName, - arguments: Tuple2( - walletId, - coin, + ref.read(pWallets).getWallet(walletId) + is BitcoinFrostWallet + ? FrostSendView.routeName + : SendView.routeName, + arguments: ( + walletId: walletId, + coin: coin, ), ); }, diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index 90c5ae041..160de0367 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -52,6 +52,7 @@ import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; import 'package:stackwallet/wallets/models/tx_data.dart'; import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import 'package:stackwallet/widgets/animated_text.dart'; @@ -1566,7 +1567,8 @@ class _DesktopSendState extends ConsumerState { if (!([Coin.nano, Coin.banano, Coin.epicCash, Coin.tezos] .contains(coin))) ConditionalParent( - condition: coin.isElectrumXCoin && + condition: ref.watch(pWallets).getWallet(walletId) + is ElectrumXInterface && !(((coin == Coin.firo || coin == Coin.firoTestNet) && (ref.watch(publicPrivateBalanceStateProvider.state).state == FiroType.lelantus || diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart index a330cb781..5b0cac5f7 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart @@ -10,13 +10,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/frost_import_sign_config_view.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/frost_send_view.dart'; import 'package:stackwallet/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart'; import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart'; import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart'; import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart'; import 'package:stackwallet/providers/global/wallets_provider.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; import 'package:stackwallet/widgets/custom_tab_view.dart'; +import 'package:stackwallet/widgets/desktop/secondary_button.dart'; import 'package:stackwallet/widgets/rounded_white_container.dart'; class MyWallet extends ConsumerStatefulWidget { @@ -40,11 +44,15 @@ class _MyWalletState extends ConsumerState { ]; late final bool isEth; + late final Coin coin; + late final bool isFrost; @override void initState() { - isEth = ref.read(pWallets).getWallet(widget.walletId).info.coin == - Coin.ethereum; + final wallet = ref.read(pWallets).getWallet(widget.walletId); + coin = wallet.info.coin; + isFrost = wallet is BitcoinFrostWallet; + isEth = coin == Coin.ethereum; if (isEth && widget.contractAddress == null) { titles.add("Transactions"); @@ -64,12 +72,41 @@ class _MyWalletState extends ConsumerState { titles: titles, children: [ widget.contractAddress == null - ? Padding( - padding: const EdgeInsets.all(20), - child: DesktopSend( - walletId: widget.walletId, - ), - ) + ? isFrost + ? Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: + const EdgeInsets.fromLTRB(0, 20, 0, 0), + child: SecondaryButton( + width: 200, + buttonHeight: ButtonHeight.l, + label: "Import sign config", + onPressed: () { + Navigator.of(context).pushNamed( + FrostImportSignConfigView.routeName, + arguments: widget.walletId, + ); + }, + ), + ) + ], + ), + FrostSendView( + walletId: widget.walletId, + coin: coin, + ), + ], + ) + : Padding( + padding: const EdgeInsets.all(20), + child: DesktopSend( + walletId: widget.walletId, + ), + ) : Padding( padding: const EdgeInsets.all(20), child: DesktopTokenSend( 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 0a9a5a29e..163052ec0 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 @@ -21,6 +21,7 @@ import 'package:stackwallet/themes/stack_colors.dart'; import 'package:stackwallet/utilities/assets.dart'; import 'package:stackwallet/utilities/constants.dart'; import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; import 'package:stackwallet/widgets/desktop/desktop_dialog.dart'; import 'package:stackwallet/widgets/desktop/desktop_dialog_close_button.dart'; @@ -80,19 +81,33 @@ class _UnlockWalletKeysDesktopState Navigator.of(context, rootNavigator: true).pop(); final wallet = ref.read(pWallets).getWallet(widget.walletId); + ({String keys, String config})? frostData; + List? words; - // TODO: [prio=med] handle wallets that don't have a mnemonic + // TODO: [prio=low] handle wallets that don't have a mnemonic // All wallets currently are mnemonic based if (wallet is! MnemonicInterface) { - throw Exception("FIXME ~= see todo in code"); + if (wallet is BitcoinFrostWallet) { + frostData = ( + keys: (await wallet.getSerializedKeys())!, + config: (await wallet.getMultisigConfig())!, + ); + print(1111111); + print(frostData); + } else { + throw Exception("FIXME ~= see todo in code"); + } + } else { + words = await wallet.getMnemonicAsWords(); } - final words = await wallet.getMnemonicAsWords(); - if (mounted) { await Navigator.of(context).pushReplacementNamed( WalletKeysDesktopPopup.routeName, - arguments: words, + arguments: ( + mnemonic: words ?? [], + frostData: frostData, + ), ); } } else { @@ -301,21 +316,35 @@ class _UnlockWalletKeysDesktopState if (verified) { Navigator.of(context, rootNavigator: true).pop(); + ({String keys, String config})? frostData; + List? words; + final wallet = ref.read(pWallets).getWallet(widget.walletId); // TODO: [prio=low] handle wallets that don't have a mnemonic // All wallets currently are mnemonic based if (wallet is! MnemonicInterface) { - throw Exception("FIXME ~= see todo in code"); + if (wallet is BitcoinFrostWallet) { + frostData = ( + keys: (await wallet.getSerializedKeys())!, + config: (await wallet.getMultisigConfig())!, + ); + } else { + throw Exception("FIXME ~= see todo in code"); + } + } else { + words = await wallet.getMnemonicAsWords(); } - final words = await wallet.getMnemonicAsWords(); if (mounted) { await Navigator.of(context) .pushReplacementNamed( WalletKeysDesktopPopup.routeName, - arguments: words, + arguments: ( + mnemonic: words ?? [], + frostData: frostData, + ), ); } } else { diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart index 14574a083..606ae21f4 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 @@ -14,6 +14,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:stackwallet/notifications/show_flush_bar.dart'; import 'package:stackwallet/pages/add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/qr_code_desktop_popup_content.dart'; import 'package:stackwallet/themes/stack_colors.dart'; import 'package:stackwallet/utilities/address_utils.dart'; @@ -24,15 +25,18 @@ import 'package:stackwallet/widgets/desktop/desktop_dialog.dart'; import 'package:stackwallet/widgets/desktop/desktop_dialog_close_button.dart'; import 'package:stackwallet/widgets/desktop/primary_button.dart'; import 'package:stackwallet/widgets/desktop/secondary_button.dart'; +import 'package:stackwallet/widgets/rounded_white_container.dart'; class WalletKeysDesktopPopup extends StatelessWidget { const WalletKeysDesktopPopup({ Key? key, required this.words, + this.frostData, this.clipboardInterface = const ClipboardWrapper(), }) : super(key: key); final List words; + final ({String keys, String config})? frostData; final ClipboardInterface clipboardInterface; static const String routeName = "walletKeysDesktopPopup"; @@ -66,85 +70,185 @@ class WalletKeysDesktopPopup extends StatelessWidget { const SizedBox( height: 28, ), - Text( - "Recovery phrase", - style: STextStyles.desktopTextMedium(context), - ), - const SizedBox( - height: 8, - ), - Center( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - ), - child: Text( - "Please write down your recovery phrase in the correct order and save it to keep your funds secure. You will also be asked to verify the words on the next screen.", - style: STextStyles.desktopTextExtraExtraSmall(context), - textAlign: TextAlign.center, - ), - ), - ), - const SizedBox( - height: 24, - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - ), - child: MnemonicTable( - words: words, - isDesktop: true, - itemBorderColor: Theme.of(context) - .extension()! - .buttonBackSecondary, - ), - ), - const SizedBox( - height: 24, - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - ), - child: Row( - children: [ - Expanded( - child: SecondaryButton( - label: "Show QR code", - onPressed: () { - final String value = AddressUtils.encodeQRSeedData(words); - Navigator.of(context).pushNamed( - QRCodeDesktopPopupContent.routeName, - arguments: value, - ); - }, - ), - ), - const SizedBox( - width: 16, - ), - Expanded( - child: PrimaryButton( - label: "Copy", - onPressed: () async { - await clipboardInterface.setData( - ClipboardData(text: words.join(" ")), - ); - unawaited( - showFloatingFlushBar( - type: FlushBarType.info, - message: "Copied to clipboard", - iconAsset: Assets.svg.copy, - context: context, + frostData != null + ? Column( + children: [ + Text( + "Keys", + style: STextStyles.desktopTextMedium(context), + ), + const SizedBox( + height: 8, + ), + Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, ), - ); - }, - ), + child: RoundedWhiteContainer( + borderColor: Theme.of(context) + .extension()! + .textFieldDefaultBG, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 9), + child: Row( + children: [ + Flexible( + child: SelectableText( + frostData!.keys, + style: STextStyles.desktopTextExtraExtraSmall( + context), + textAlign: TextAlign.center, + ), + ), + const SizedBox( + width: 10, + ), + IconCopyButton( + data: frostData!.keys, + ) + // TODO [prio=low: Add QR code button and dialog. + ], + ), + ), + ), + ), + const SizedBox( + height: 24, + ), + Text( + "Config", + style: STextStyles.desktopTextMedium(context), + ), + const SizedBox( + height: 8, + ), + Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + ), + child: RoundedWhiteContainer( + borderColor: Theme.of(context) + .extension()! + .textFieldDefaultBG, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 9), + child: Row( + children: [ + Flexible( + child: SelectableText( + frostData!.config, + style: STextStyles.desktopTextExtraExtraSmall( + context), + textAlign: TextAlign.center, + ), + ), + const SizedBox( + width: 10, + ), + IconCopyButton( + data: frostData!.config, + ) + // TODO [prio=low: Add QR code button and dialog. + ], + ), + ), + ), + ), + const SizedBox( + height: 24, + ), + ], + ) + : Column( + children: [ + Text( + "Recovery phrase", + style: STextStyles.desktopTextMedium(context), + ), + const SizedBox( + height: 8, + ), + Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + ), + child: Text( + "Please write down your recovery phrase in the correct order and save it to keep your funds secure. You will also be asked to verify the words on the next screen.", + style: + STextStyles.desktopTextExtraExtraSmall(context), + textAlign: TextAlign.center, + ), + ), + ), + const SizedBox( + height: 24, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + ), + child: MnemonicTable( + words: words, + isDesktop: true, + itemBorderColor: Theme.of(context) + .extension()! + .buttonBackSecondary, + ), + ), + const SizedBox( + height: 24, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + ), + child: Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Show QR code", + onPressed: () { + // TODO: address utils + final String value = + AddressUtils.encodeQRSeedData(words); + Navigator.of(context).pushNamed( + QRCodeDesktopPopupContent.routeName, + arguments: value, + ); + }, + ), + ), + const SizedBox( + width: 16, + ), + Expanded( + child: PrimaryButton( + label: "Copy", + onPressed: () async { + await clipboardInterface.setData( + ClipboardData(text: words.join(" ")), + ); + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ), + ); + } + }, + ), + ), + ], + ), + ), + ], ), - ], - ), - ), const SizedBox( height: 32, ), 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 f495475db..c27b7855d 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 @@ -14,6 +14,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/frost_ms_options_view.dart'; import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/change_representative_view.dart'; import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/xpub_view.dart'; import 'package:stackwallet/pages_desktop_specific/addresses/desktop_wallet_addresses_view.dart'; @@ -34,7 +35,8 @@ enum _WalletOptions { changeRepresentative, showXpub, lelantusCoins, - sparkCoins; + sparkCoins, + frostOptions; String get prettyName { switch (this) { @@ -50,6 +52,8 @@ enum _WalletOptions { return "Lelantus Coins"; case _WalletOptions.sparkCoins: return "Spark Coins"; + case _WalletOptions.frostOptions: + return "FROST settings"; } } } @@ -96,6 +100,9 @@ class WalletOptionsButton extends StatelessWidget { onFiroShowSparkCoins: () async { Navigator.of(context).pop(_WalletOptions.sparkCoins); }, + onFrostMSWalletOptionsPressed: () async { + Navigator.of(context).pop(_WalletOptions.frostOptions); + }, walletId: walletId, ); }, @@ -207,6 +214,15 @@ class WalletOptionsButton extends StatelessWidget { ), ); break; + + case _WalletOptions.frostOptions: + unawaited( + Navigator.of(context).pushNamed( + FrostMSWalletOptionsView.routeName, + arguments: walletId, + ), + ); + break; } } }, @@ -241,6 +257,7 @@ class WalletOptionsPopupMenu extends ConsumerWidget { required this.onChangeRepPressed, required this.onFiroShowLelantusCoins, required this.onFiroShowSparkCoins, + required this.onFrostMSWalletOptionsPressed, required this.walletId, }) : super(key: key); @@ -250,6 +267,7 @@ class WalletOptionsPopupMenu extends ConsumerWidget { final VoidCallback onChangeRepPressed; final VoidCallback onFiroShowLelantusCoins; final VoidCallback onFiroShowSparkCoins; + final VoidCallback onFrostMSWalletOptionsPressed; final String walletId; @override @@ -265,6 +283,9 @@ class WalletOptionsPopupMenu extends ConsumerWidget { final bool canChangeRep = coin == Coin.nano || coin == Coin.banano; + final bool isFrost = + coin == Coin.bitcoinFrost || coin == Coin.bitcoinFrostTestNet; + return Stack( children: [ Positioned( @@ -429,6 +450,43 @@ class WalletOptionsPopupMenu extends ConsumerWidget { ), ), ), + if (isFrost) + const SizedBox( + height: 8, + ), + if (isFrost) + TransparentButton( + onPressed: onFrostMSWalletOptionsPressed, + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SvgPicture.asset( + Assets.svg.addressBookDesktop, + width: 20, + height: 20, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconLeft, + ), + const SizedBox(width: 14), + Expanded( + child: Text( + _WalletOptions.frostOptions.prettyName, + style: STextStyles.desktopTextExtraExtraSmall( + context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + ), + ], + ), + ), + ), if (xpubEnabled) const SizedBox( height: 8, diff --git a/lib/providers/frost_wallet/frost_wallet_providers.dart b/lib/providers/frost_wallet/frost_wallet_providers.dart new file mode 100644 index 000000000..3b181b7b8 --- /dev/null +++ b/lib/providers/frost_wallet/frost_wallet_providers.dart @@ -0,0 +1,103 @@ +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frostdart/frostdart_bindings_generated.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/wallets/models/incomplete_frost_wallet.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; + +// =================== wallet creation ========================================= +final pFrostMultisigConfig = StateProvider((ref) => null); +final pFrostMyName = StateProvider((ref) => null); + +final pFrostStartKeyGenData = StateProvider< + ({ + String seed, + String commitments, + Pointer multisigConfigWithNamePtr, + Pointer secretShareMachineWrapperPtr, + })?>((_) => null); + +final pFrostSecretSharesData = StateProvider< + ({ + String share, + Pointer secretSharesResPtr, + })?>((ref) => null); + +final pFrostCompletedKeyGenData = StateProvider< + ({ + Uint8List multisigId, + String recoveryString, + String serializedKeys, + })?>((ref) => null); + +// ================= transaction creation ====================================== +final pFrostTxData = StateProvider((ref) => null); + +final pFrostAttemptSignData = StateProvider< + ({ + Pointer machinePtr, + String preprocess, + })?>((ref) => null); + +final pFrostContinueSignData = StateProvider< + ({ + Pointer machinePtr, + String share, + })?>((ref) => null); + +// ===================== shared/util =========================================== +final pFrostSelectParticipantsUnordered = + StateProvider?>((ref) => null); + +// ========================= resharing ========================================= +final pFrostResharingData = Provider((ref) => _ResharingData()); + +class _ResharingData { + String? myName; + + IncompleteFrostWallet? incompleteWallet; + + // resharer encoded config string + String? resharerConfig; + ({ + int newThreshold, + List resharers, + List newParticipants, + })? get configData => resharerConfig != null + ? Frost.extractResharerConfigData(resharerConfig: resharerConfig!) + : null; + + // resharer start string (for sharing) and machine + ({ + String resharerStart, + Pointer machine, + })? startResharerData; + + // reshared start string (for sharing) and machine + ({ + String resharedStart, + Pointer prior, + })? startResharedData; + + // resharer complete string (for sharing) + String? resharerComplete; + + // new keys and config with an ID + ({ + String multisigConfig, + String serializedKeys, + String resharedId, + })? newWalletData; + + // reset/clear all data + void reset() { + resharerConfig = null; + startResharerData = null; + startResharedData = null; + resharerComplete = null; + newWalletData = null; + incompleteWallet = null; + } +} diff --git a/lib/route_generator.dart b/lib/route_generator.dart index a046cc01d..4eb906b8a 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -26,6 +26,13 @@ import 'package:stackwallet/pages/add_wallet_views/add_token_view/add_custom_tok import 'package:stackwallet/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart'; import 'package:stackwallet/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart'; import 'package:stackwallet/pages/add_wallet_views/create_or_restore_wallet_view/create_or_restore_wallet_view.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/confirm_new_frost_ms_wallet_creation_view.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/create_new_frost_ms_wallet_view.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/frost_share_commitments_view.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/frost_share_shares_view.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/import_new_frost_ms_wallet_view.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/share_new_multisig_config_view.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart'; import 'package:stackwallet/pages/add_wallet_views/name_your_wallet_view/name_your_wallet_view.dart'; import 'package:stackwallet/pages/add_wallet_views/new_wallet_options/new_wallet_options_view.dart'; import 'package:stackwallet/pages/add_wallet_views/new_wallet_recovery_phrase_view/new_wallet_recovery_phrase_view.dart'; @@ -76,6 +83,10 @@ import 'package:stackwallet/pages/receive_view/addresses/wallet_addresses_view.d import 'package:stackwallet/pages/receive_view/generate_receiving_uri_qr_code_view.dart'; import 'package:stackwallet/pages/receive_view/receive_view.dart'; import 'package:stackwallet/pages/send_view/confirm_transaction_view.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/frost_attempt_sign_config_view.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/frost_create_sign_config_view.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/frost_import_sign_config_view.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/frost_send_view.dart'; import 'package:stackwallet/pages/send_view/send_view.dart'; import 'package:stackwallet/pages/send_view/token_send_view.dart'; import 'package:stackwallet/pages/settings_views/global_settings_view/about_view.dart'; @@ -113,6 +124,19 @@ import 'package:stackwallet/pages/settings_views/global_settings_view/syncing_pr import 'package:stackwallet/pages/settings_views/global_settings_view/syncing_preferences_views/syncing_preferences_view.dart'; import 'package:stackwallet/pages/settings_views/global_settings_view/syncing_preferences_views/wallet_syncing_options_view.dart'; import 'package:stackwallet/pages/settings_views/global_settings_view/tor_settings/tor_settings_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/frost_ms_options_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/frost_participants_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/finish_resharing_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/begin_reshare_config_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/complete_reshare_config_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/display_reshare_config_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1b/import_reshare_config_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/begin_resharing_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/continue_resharing_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_continue_sharing_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_import_resharer_config_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_start_resharing_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/verify_updated_wallet_view.dart'; import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_backup_view.dart'; import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart'; import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_settings_view.dart'; @@ -423,6 +447,379 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case CreateNewFrostMsWalletView.routeName: + if (args is ({ + String walletName, + Coin coin, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => CreateNewFrostMsWalletView( + walletName: args.walletName, + coin: args.coin, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case RestoreFrostMsWalletView.routeName: + if (args is ({ + String walletName, + Coin coin, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => RestoreFrostMsWalletView( + walletName: args.walletName, + coin: args.coin, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShareNewMultisigConfigView.routeName: + if (args is ({ + String walletName, + Coin coin, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShareNewMultisigConfigView( + walletName: args.walletName, + coin: args.coin, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ImportNewFrostMsWalletView.routeName: + if (args is ({ + String walletName, + Coin coin, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ImportNewFrostMsWalletView( + walletName: args.walletName, + coin: args.coin, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case NewImportResharerConfigView.routeName: + if (args is ({ + String walletName, + Coin coin, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => NewImportResharerConfigView( + walletName: args.walletName, + coin: args.coin, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case NewStartResharingView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => NewStartResharingView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case NewContinueSharingView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => NewContinueSharingView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case FrostShareCommitmentsView.routeName: + if (args is ({ + String walletName, + Coin coin, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => FrostShareCommitmentsView( + walletName: args.walletName, + coin: args.coin, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case FrostShareSharesView.routeName: + if (args is ({ + String walletName, + Coin coin, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => FrostShareSharesView( + walletName: args.walletName, + coin: args.coin, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ConfirmNewFrostMSWalletCreationView.routeName: + if (args is ({ + String walletName, + Coin coin, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ConfirmNewFrostMSWalletCreationView( + walletName: args.walletName, + coin: args.coin, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case FrostMSWalletOptionsView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => FrostMSWalletOptionsView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case FrostParticipantsView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => FrostParticipantsView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ImportReshareConfigView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ImportReshareConfigView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case BeginReshareConfigView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => BeginReshareConfigView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case CompleteReshareConfigView.routeName: + if (args is ({String walletId, List resharers})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => CompleteReshareConfigView( + walletId: args.walletId, + resharers: args.resharers, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case DisplayReshareConfigView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => DisplayReshareConfigView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case BeginResharingView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => BeginResharingView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ContinueResharingView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ContinueResharingView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case FinishResharingView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => FinishResharingView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case VerifyUpdatedWalletView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => VerifyUpdatedWalletView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case FrostSendView.routeName: + if (args is ({ + String walletId, + Coin coin, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => FrostSendView( + walletId: args.walletId, + coin: args.coin, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case FrostImportSignConfigView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => FrostImportSignConfigView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case FrostCreateSignConfigView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => FrostCreateSignConfigView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case FrostAttemptSignConfigView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => FrostAttemptSignConfigView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + // case MonkeyLoadedView.routeName: // if (args is Tuple2>) { // return getRoute( @@ -1051,12 +1448,33 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case WalletBackupView.routeName: - if (args is Tuple2>) { + if (args is ({String walletId, List mnemonic})) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, builder: (_) => WalletBackupView( - walletId: args.item1, - mnemonic: args.item2, + walletId: args.walletId, + mnemonic: args.mnemonic, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } else if (args is ({ + String walletId, + List mnemonic, + ({ + String myName, + String config, + String keys, + ({String config, String keys})? prevGen, + })? frostWalletData, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => WalletBackupView( + walletId: args.walletId, + mnemonic: args.mnemonic, + frostWalletData: args.frostWalletData, ), settings: RouteSettings( name: settings.name, @@ -1961,10 +2379,14 @@ class RouteGenerator { settings: RouteSettings(name: settings.name)); case WalletKeysDesktopPopup.routeName: - if (args is List) { + if (args is ({ + List mnemonic, + ({String keys, String config})? frostData + })) { return FadePageRoute( WalletKeysDesktopPopup( - words: args, + words: args.mnemonic, + frostData: args.frostData, ), RouteSettings( name: settings.name, diff --git a/lib/services/frost.dart b/lib/services/frost.dart new file mode 100644 index 000000000..a420a5b16 --- /dev/null +++ b/lib/services/frost.dart @@ -0,0 +1,613 @@ +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:frostdart/frostdart.dart'; +import 'package:frostdart/frostdart_bindings_generated.dart'; +import 'package:frostdart/output.dart'; +import 'package:frostdart/util.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/extensions/extensions.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +abstract class Frost { + //==================== utility =============================================== + static List getParticipants({ + required String multisigConfig, + }) { + try { + final numberOfParticipants = multisigParticipants( + multisigConfig: multisigConfig, + ); + + final List participants = []; + for (int i = 0; i < numberOfParticipants; i++) { + participants.add( + multisigParticipant( + multisigConfig: multisigConfig, + index: i, + ), + ); + } + + return participants; + } catch (e, s) { + Logging.instance.log( + "getParticipants failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static bool validateEncodedMultisigConfig({required String encodedConfig}) { + try { + decodeMultisigConfig(multisigConfig: encodedConfig); + return true; + } catch (e, s) { + Logging.instance.log( + "validateEncodedMultisigConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + return false; + } + } + + static int getThreshold({ + required String multisigConfig, + }) { + try { + final threshold = multisigThreshold( + multisigConfig: multisigConfig, + ); + + return threshold; + } catch (e, s) { + Logging.instance.log( + "getThreshold failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + List<({String address, Amount amount})> recipients, + String changeAddress, + int feePerWeight, + List inputs, + }) extractDataFromSignConfig({ + required String signConfig, + required CryptoCurrency coin, + }) { + try { + final network = coin.network == CryptoCurrencyNetwork.test + ? Network.Testnet + : Network.Mainnet; + final signConfigPointer = decodedSignConfig( + encodedConfig: signConfig, + network: network, + ); + + // get various data from config + final feePerWeight = + signFeePerWeight(signConfigPointer: signConfigPointer); + final changeAddress = signChange(signConfigPointer: signConfigPointer); + final recipientsCount = signPayments( + signConfigPointer: signConfigPointer, + ); + + // get tx recipient info + final List<({String address, Amount amount})> recipients = []; + for (int i = 0; i < recipientsCount; i++) { + final String address = signPaymentAddress( + signConfigPointer: signConfigPointer, + index: i, + ); + final int amount = signPaymentAmount( + signConfigPointer: signConfigPointer, + index: i, + ); + recipients.add( + ( + address: address, + amount: Amount( + rawValue: BigInt.from(amount), + fractionDigits: coin.fractionDigits, + ), + ), + ); + } + + // get utxos + final count = signInputs(signConfigPointer: signConfigPointer); + final List outputs = []; + for (int i = 0; i < count; i++) { + final output = signInput( + signConfig: signConfig, + index: i, + network: network, + ); + + outputs.add(output); + } + + return ( + recipients: recipients, + changeAddress: changeAddress, + feePerWeight: feePerWeight, + inputs: outputs, + ); + } catch (e, s) { + Logging.instance.log( + "extractDataFromSignConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + //==================== wallet creation ======================================= + + static String createMultisigConfig({ + required String name, + required int threshold, + required List participants, + }) { + try { + final config = newMultisigConfig( + name: name, + threshold: threshold, + participants: participants, + ); + + return config; + } catch (e, s) { + Logging.instance.log( + "createMultisigConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + String seed, + String commitments, + Pointer multisigConfigWithNamePtr, + Pointer secretShareMachineWrapperPtr, + }) startKeyGeneration({ + required String multisigConfig, + required String myName, + }) { + try { + final startKeyGenResPtr = startKeyGen( + multisigConfig: multisigConfig, + myName: myName, + language: Language.english, + ); + + final seed = startKeyGenResPtr.ref.seed.toDartString(); + final commitments = startKeyGenResPtr.ref.commitments.toDartString(); + final configWithNamePtr = startKeyGenResPtr.ref.config; + final machinePtr = startKeyGenResPtr.ref.machine; + + return ( + seed: seed, + commitments: commitments, + multisigConfigWithNamePtr: configWithNamePtr, + secretShareMachineWrapperPtr: machinePtr, + ); + } catch (e, s) { + Logging.instance.log( + "startKeyGeneration failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + String share, + Pointer secretSharesResPtr, + }) generateSecretShares({ + required Pointer multisigConfigWithNamePtr, + required String mySeed, + required Pointer secretShareMachineWrapperPtr, + required List commitments, + }) { + try { + final secretSharesResPtr = getSecretShares( + multisigConfigWithName: multisigConfigWithNamePtr, + seed: mySeed, + language: Language.english, + machine: secretShareMachineWrapperPtr, + commitments: commitments, + ); + + final share = secretSharesResPtr.ref.shares.toDartString(); + + return (share: share, secretSharesResPtr: secretSharesResPtr); + } catch (e, s) { + Logging.instance.log( + "generateSecretShares failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + Uint8List multisigId, + String recoveryString, + String serializedKeys, + }) completeKeyGeneration({ + required Pointer multisigConfigWithNamePtr, + required Pointer secretSharesResPtr, + required List shares, + }) { + try { + final keyGenResPtr = completeKeyGen( + multisigConfigWithName: multisigConfigWithNamePtr, + machineAndCommitments: secretSharesResPtr, + shares: shares, + ); + + final id = Uint8List.fromList( + List.generate( + MULTISIG_ID_LENGTH, + (index) => keyGenResPtr.ref.multisig_id[index], + ), + ); + + final recoveryString = keyGenResPtr.ref.recovery.toDartString(); + + final serializedKeys = serializeKeys(keys: keyGenResPtr.ref.keys); + + return ( + multisigId: id, + recoveryString: recoveryString, + serializedKeys: serializedKeys, + ); + } catch (e, s) { + Logging.instance.log( + "completeKeyGeneration failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + //=================== transaction creation =================================== + + static String createSignConfig({ + required int network, + required List<({UTXO utxo, Uint8List scriptPubKey})> inputs, + required List<({String address, Amount amount, bool isChange})> outputs, + required String changeAddress, + required int feePerWeight, + }) { + try { + final signConfig = newSignConfig( + network: network, + outputs: inputs + .map( + (e) => Output( + hash: e.utxo.txid.toUint8ListFromHex, + vout: e.utxo.vout, + value: e.utxo.value, + scriptPubKey: e.scriptPubKey, + ), + ) + .toList(), + paymentAddresses: outputs.map((e) => e.address).toList(), + paymentAmounts: outputs.map((e) => e.amount.raw.toInt()).toList(), + change: changeAddress, + feePerWeight: feePerWeight, + ); + + return signConfig; + } catch (e, s) { + Logging.instance.log( + "createSignConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + Pointer machinePtr, + String preprocess, + }) attemptSignConfig({ + required int network, + required String config, + required String serializedKeys, + }) { + try { + final keys = deserializeKeys(keys: serializedKeys); + + final attemptSignRes = attemptSign( + thresholdKeysWrapperPointer: keys, + network: network, + signConfig: config, + ); + + return ( + preprocess: attemptSignRes.ref.preprocess.toDartString(), + machinePtr: attemptSignRes.ref.machine, + ); + } catch (e, s) { + Logging.instance.log( + "attemptSignConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + Pointer machinePtr, + String share, + }) continueSigning({ + required Pointer machinePtr, + required List preprocesses, + }) { + try { + final continueSignRes = continueSign( + machine: machinePtr, + preprocesses: preprocesses, + ); + + return ( + share: continueSignRes.ref.preprocess.toDartString(), + machinePtr: continueSignRes.ref.machine, + ); + } catch (e, s) { + Logging.instance.log( + "continueSigning failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static String completeSigning({ + required Pointer machinePtr, + required List shares, + }) { + try { + final rawTransaction = completeSign( + machine: machinePtr, + shares: shares, + ); + + return rawTransaction; + } catch (e, s) { + Logging.instance.log( + "completeSigning failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static Pointer decodedSignConfig({ + required String encodedConfig, + required int network, + }) { + try { + final configPtr = + decodeSignConfig(encodedSignConfig: encodedConfig, network: network); + return configPtr; + } catch (e, s) { + Logging.instance.log( + "decodedSignConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + //========================== resharing ======================================= + + static String createResharerConfig({ + required int newThreshold, + required List resharers, + required List newParticipants, + }) { + try { + final config = newResharerConfig( + newThreshold: newThreshold, + newParticipants: newParticipants, + resharers: resharers, + ); + + return config; + } catch (e, s) { + Logging.instance.log( + "createResharerConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + String resharerStart, + Pointer machine, + }) beginResharer({ + required String serializedKeys, + required String config, + }) { + try { + final result = startResharer( + serializedKeys: serializedKeys, + config: config, + ); + + return ( + resharerStart: result.encoded, + machine: result.machine, + ); + } catch (e, s) { + Logging.instance.log( + "beginResharer failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + /// expects [resharerStarts] of length equal to resharers. + static ({ + String resharedStart, + Pointer prior, + }) beginReshared({ + required String myName, + required String resharerConfig, + required List resharerStarts, + }) { + try { + final result = startReshared( + newMultisigName: 'unused_property', + myName: myName, + resharerConfig: resharerConfig, + resharerStarts: resharerStarts, + ); + return ( + resharedStart: result.encoded, + prior: result.machine, + ); + } catch (e, s) { + Logging.instance.log( + "beginReshared failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + /// expects [encryptionKeysOfResharedTo] of length equal to new participants + static String finishResharer({ + required StartResharerRes machine, + required List encryptionKeysOfResharedTo, + }) { + try { + final result = completeResharer( + machine: machine, + encryptionKeysOfResharedTo: encryptionKeysOfResharedTo, + ); + return result; + } catch (e, s) { + Logging.instance.log( + "finishResharer failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + /// expects [resharerCompletes] of length equal to resharers + static ({ + String multisigConfig, + String serializedKeys, + String resharedId, + }) finishReshared({ + required StartResharedRes prior, + required List resharerCompletes, + }) { + try { + final result = completeReshared( + prior: prior, + resharerCompletes: resharerCompletes, + ); + return result; + } catch (e, s) { + Logging.instance.log( + "finishReshared failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static Pointer decodedResharerConfig({ + required String resharerConfig, + }) { + try { + final config = decodeResharerConfig(resharerConfig: resharerConfig); + + return config; + } catch (e, s) { + Logging.instance.log( + "decodedResharerConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + int newThreshold, + List resharers, + List newParticipants, + }) extractResharerConfigData({ + required String resharerConfig, + }) { + try { + final newThreshold = resharerNewThreshold( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + ); + + final resharersCount = resharerResharers( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + ); + final List resharers = []; + for (int i = 0; i < resharersCount; i++) { + resharers.add( + resharerResharer( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + index: i, + ), + ); + } + + final newParticipantsCount = resharerNewParticipants( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + ); + final List newParticipants = []; + for (int i = 0; i < newParticipantsCount; i++) { + newParticipants.add( + resharerNewParticipant( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + index: i, + ), + ); + } + + return ( + newThreshold: newThreshold, + resharers: resharers, + newParticipants: newParticipants, + ); + } catch (e, s) { + Logging.instance.log( + "extractResharerConfigData failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } +} diff --git a/lib/services/notifications_service.dart b/lib/services/notifications_service.dart index e6c49f47f..5a71c4668 100644 --- a/lib/services/notifications_service.dart +++ b/lib/services/notifications_service.dart @@ -24,6 +24,7 @@ import 'package:stackwallet/services/wallets.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; import 'exchange/exchange.dart'; @@ -123,7 +124,7 @@ class NotificationsService extends ChangeNotifier { final node = nodeService.getPrimaryNodeFor(coin: coin); if (node != null) { - if (coin.isElectrumXCoin) { + if (wallet is ElectrumXInterface) { final eNode = ElectrumXNode( address: node.host, port: node.port, diff --git a/lib/themes/coin_card_provider.dart b/lib/themes/coin_card_provider.dart index b34e9e6f1..ce5e71038 100644 --- a/lib/themes/coin_card_provider.dart +++ b/lib/themes/coin_card_provider.dart @@ -16,6 +16,13 @@ import 'package:stackwallet/utilities/enums/coin_enum.dart'; final coinCardProvider = Provider.family((ref, coin) { final assets = ref.watch(themeAssetsProvider); + // TODO: handle this differently by adding proper frost assets to themes + if (coin == Coin.bitcoinFrost) { + coin = Coin.bitcoin; + } else if (coin == Coin.bitcoinFrostTestNet) { + coin = Coin.bitcoinTestNet; + } + if (assets is ThemeAssetsV3) { return assets.coinCardImages?[coin.mainNetVersion]; } else { @@ -26,6 +33,13 @@ final coinCardProvider = Provider.family((ref, coin) { final coinCardFavoritesProvider = Provider.family((ref, coin) { final assets = ref.watch(themeAssetsProvider); + // TODO: handle this differently by adding proper frost assets to themes + if (coin == Coin.bitcoinFrost) { + coin = Coin.bitcoin; + } else if (coin == Coin.bitcoinFrostTestNet) { + coin = Coin.bitcoinTestNet; + } + if (assets is ThemeAssetsV3) { return assets.coinCardFavoritesImages?[coin.mainNetVersion] ?? assets.coinCardImages?[coin.mainNetVersion]; diff --git a/lib/themes/coin_icon_provider.dart b/lib/themes/coin_icon_provider.dart index 9bd3990bb..f0c0df842 100644 --- a/lib/themes/coin_icon_provider.dart +++ b/lib/themes/coin_icon_provider.dart @@ -16,6 +16,13 @@ import 'package:stackwallet/utilities/enums/coin_enum.dart'; final coinIconProvider = Provider.family((ref, coin) { final assets = ref.watch(themeAssetsProvider); + // TODO: handle this differently by adding proper frost assets to themes + if (coin == Coin.bitcoinFrost) { + coin = Coin.bitcoin; + } else if (coin == Coin.bitcoinFrostTestNet) { + coin = Coin.bitcoinTestNet; + } + if (assets is ThemeAssets) { switch (coin) { case Coin.bitcoin: diff --git a/lib/themes/coin_image_provider.dart b/lib/themes/coin_image_provider.dart index 6ca839fb9..fa1fdde4c 100644 --- a/lib/themes/coin_image_provider.dart +++ b/lib/themes/coin_image_provider.dart @@ -16,6 +16,13 @@ import 'package:stackwallet/utilities/enums/coin_enum.dart'; final coinImageProvider = Provider.family((ref, coin) { final assets = ref.watch(themeAssetsProvider); + // TODO: handle this differently by adding proper frost assets to themes + if (coin == Coin.bitcoinFrost) { + coin = Coin.bitcoin; + } else if (coin == Coin.bitcoinFrostTestNet) { + coin = Coin.bitcoinTestNet; + } + if (assets is ThemeAssets) { switch (coin) { case Coin.bitcoin: @@ -64,6 +71,13 @@ final coinImageProvider = Provider.family((ref, coin) { final coinImageSecondaryProvider = Provider.family((ref, coin) { final assets = ref.watch(themeAssetsProvider); + // TODO: handle this differently by adding proper frost assets to themes + if (coin == Coin.bitcoinFrost) { + coin = Coin.bitcoin; + } else if (coin == Coin.bitcoinFrostTestNet) { + coin = Coin.bitcoinTestNet; + } + if (assets is ThemeAssets) { switch (coin) { case Coin.bitcoin: diff --git a/lib/themes/color_theme.dart b/lib/themes/color_theme.dart index abec28d4e..38de6c636 100644 --- a/lib/themes/color_theme.dart +++ b/lib/themes/color_theme.dart @@ -37,6 +37,8 @@ class CoinThemeColorDefault { switch (coin) { case Coin.bitcoin: case Coin.bitcoinTestNet: + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: return bitcoin; case Coin.litecoin: case Coin.litecoinTestNet: diff --git a/lib/themes/stack_colors.dart b/lib/themes/stack_colors.dart index cbec0077a..0cc83b04f 100644 --- a/lib/themes/stack_colors.dart +++ b/lib/themes/stack_colors.dart @@ -1680,6 +1680,8 @@ class StackColors extends ThemeExtension { switch (coin) { case Coin.bitcoin: case Coin.bitcoinTestNet: + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: return _coin.bitcoin; case Coin.litecoin: case Coin.litecoinTestNet: diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index 0e766d28e..563ca69fd 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -14,10 +14,9 @@ import 'package:bitcoindart/bitcoindart.dart'; import 'package:crypto/crypto.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/logger.dart'; -import 'package:stackwallet/wallets/crypto_currency/coins/bitcoin.dart'; -import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; - import 'package:stackwallet/wallets/crypto_currency/coins/banano.dart'; +import 'package:stackwallet/wallets/crypto_currency/coins/bitcoin.dart'; +import 'package:stackwallet/wallets/crypto_currency/coins/bitcoin_frost.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/bitcoincash.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/dogecoin.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/ecash.dart'; @@ -32,6 +31,7 @@ import 'package:stackwallet/wallets/crypto_currency/coins/particl.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/stellar.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/tezos.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/wownero.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; class AddressUtils { static String condenseAddress(String address) { @@ -72,6 +72,9 @@ class AddressUtils { switch (coin) { case Coin.bitcoin: return Bitcoin(CryptoCurrencyNetwork.main).validateAddress(address); + case Coin.bitcoinFrost: + return BitcoinFrost(CryptoCurrencyNetwork.main) + .validateAddress(address); case Coin.litecoin: return Litecoin(CryptoCurrencyNetwork.main).validateAddress(address); case Coin.bitcoincash: @@ -104,6 +107,9 @@ class AddressUtils { return Tezos(CryptoCurrencyNetwork.main).validateAddress(address); case Coin.bitcoinTestNet: return Bitcoin(CryptoCurrencyNetwork.test).validateAddress(address); + case Coin.bitcoinFrostTestNet: + return BitcoinFrost(CryptoCurrencyNetwork.test) + .validateAddress(address); case Coin.litecoinTestNet: return Litecoin(CryptoCurrencyNetwork.test).validateAddress(address); case Coin.bitcoincashTestnet: diff --git a/lib/utilities/amount/amount_unit.dart b/lib/utilities/amount/amount_unit.dart index 6a646fd11..87efcc4cd 100644 --- a/lib/utilities/amount/amount_unit.dart +++ b/lib/utilities/amount/amount_unit.dart @@ -40,6 +40,8 @@ enum AmountUnit { case Coin.litecoin: case Coin.particl: case Coin.namecoin: + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: case Coin.bitcoinTestNet: case Coin.litecoinTestNet: case Coin.bitcoincashTestnet: diff --git a/lib/utilities/block_explorers.dart b/lib/utilities/block_explorers.dart index bb4ac06fb..9f3e92c5d 100644 --- a/lib/utilities/block_explorers.dart +++ b/lib/utilities/block_explorers.dart @@ -18,6 +18,7 @@ Uri getDefaultBlockExplorerUrlFor({ required String txid, }) { switch (coin) { + case Coin.bitcoinFrost: case Coin.bitcoin: return Uri.parse("https://mempool.space/tx/$txid"); case Coin.litecoin: @@ -25,6 +26,7 @@ Uri getDefaultBlockExplorerUrlFor({ case Coin.litecoinTestNet: return Uri.parse("https://chain.so/tx/LTCTEST/$txid"); case Coin.bitcoinTestNet: + case Coin.bitcoinFrostTestNet: return Uri.parse("https://mempool.space/testnet/tx/$txid"); case Coin.dogecoin: return Uri.parse("https://chain.so/tx/DOGE/$txid"); diff --git a/lib/utilities/constants.dart b/lib/utilities/constants.dart index db0543044..f7a6faeb2 100644 --- a/lib/utilities/constants.dart +++ b/lib/utilities/constants.dart @@ -69,6 +69,7 @@ abstract class Constants { static BigInt satsPerCoin(Coin coin) { switch (coin) { case Coin.bitcoin: + case Coin.bitcoinFrost: case Coin.litecoin: case Coin.litecoinTestNet: case Coin.bitcoincash: @@ -76,6 +77,7 @@ abstract class Constants { case Coin.dogecoin: case Coin.firo: case Coin.bitcoinTestNet: + case Coin.bitcoinFrostTestNet: case Coin.dogecoinTestNet: case Coin.firoTestNet: case Coin.epicCash: @@ -113,6 +115,7 @@ abstract class Constants { static int decimalPlacesForCoin(Coin coin) { switch (coin) { case Coin.bitcoin: + case Coin.bitcoinFrost: case Coin.litecoin: case Coin.litecoinTestNet: case Coin.bitcoincash: @@ -120,6 +123,7 @@ abstract class Constants { case Coin.dogecoin: case Coin.firo: case Coin.bitcoinTestNet: + case Coin.bitcoinFrostTestNet: case Coin.dogecoinTestNet: case Coin.firoTestNet: case Coin.epicCash: @@ -189,6 +193,10 @@ abstract class Constants { case Coin.wownero: values.addAll([14, 25]); break; + + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: + throw ArgumentError("Frost mnemonic lengths unsupported"); } return values; } @@ -198,6 +206,8 @@ abstract class Constants { switch (coin) { case Coin.bitcoin: case Coin.bitcoinTestNet: + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: case Coin.bitcoincash: case Coin.bitcoincashTestnet: case Coin.eCash: @@ -277,6 +287,10 @@ abstract class Constants { case Coin.monero: return 25; + + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: + throw ArgumentError("Frost mnemonic length unsupported"); // // default: // -1; diff --git a/lib/utilities/default_nodes.dart b/lib/utilities/default_nodes.dart index 5d80784a3..b8f296b68 100644 --- a/lib/utilities/default_nodes.dart +++ b/lib/utilities/default_nodes.dart @@ -312,6 +312,7 @@ abstract class DefaultNodes { static NodeModel getNodeFor(Coin coin) { switch (coin) { case Coin.bitcoin: + case Coin.bitcoinFrost: return bitcoin; case Coin.litecoin: @@ -360,6 +361,7 @@ abstract class DefaultNodes { return tezos; case Coin.bitcoinTestNet: + case Coin.bitcoinFrostTestNet: return bitcoinTestnet; case Coin.litecoinTestNet: diff --git a/lib/utilities/enums/coin_enum.dart b/lib/utilities/enums/coin_enum.dart index c71d39ba4..0356c1e7c 100644 --- a/lib/utilities/enums/coin_enum.dart +++ b/lib/utilities/enums/coin_enum.dart @@ -13,6 +13,7 @@ import 'package:stackwallet/utilities/constants.dart'; enum Coin { bitcoin, + bitcoinFrost, monero, banano, bitcoincash, @@ -35,6 +36,7 @@ enum Coin { /// bitcoinTestNet, + bitcoinFrostTestNet, bitcoincashTestnet, dogecoinTestNet, firoTestNet, @@ -47,6 +49,8 @@ extension CoinExt on Coin { switch (this) { case Coin.bitcoin: return "Bitcoin"; + case Coin.bitcoinFrost: + return "Bitcoin Frost"; case Coin.litecoin: return "Litecoin"; case Coin.bitcoincash: @@ -79,6 +83,8 @@ extension CoinExt on Coin { return "Banano"; case Coin.bitcoinTestNet: return "tBitcoin"; + case Coin.bitcoinFrostTestNet: + return "tBitcoin Frost"; case Coin.litecoinTestNet: return "tLitecoin"; case Coin.bitcoincashTestnet: @@ -95,6 +101,7 @@ extension CoinExt on Coin { String get ticker { switch (this) { case Coin.bitcoin: + case Coin.bitcoinFrost: return "BTC"; case Coin.litecoin: return "LTC"; @@ -127,6 +134,7 @@ extension CoinExt on Coin { case Coin.banano: return "BAN"; case Coin.bitcoinTestNet: + case Coin.bitcoinFrostTestNet: return "tBTC"; case Coin.litecoinTestNet: return "tLTC"; @@ -144,6 +152,7 @@ extension CoinExt on Coin { String get uriScheme { switch (this) { case Coin.bitcoin: + case Coin.bitcoinFrost: return "bitcoin"; case Coin.litecoin: return "litecoin"; @@ -177,6 +186,7 @@ extension CoinExt on Coin { case Coin.banano: return "ban"; case Coin.bitcoinTestNet: + case Coin.bitcoinFrostTestNet: return "bitcoin"; case Coin.litecoinTestNet: return "litecoin"; @@ -191,36 +201,6 @@ extension CoinExt on Coin { } } - bool get isElectrumXCoin { - switch (this) { - case Coin.bitcoin: - case Coin.litecoin: - case Coin.bitcoincash: - case Coin.dogecoin: - case Coin.firo: - case Coin.namecoin: - case Coin.particl: - case Coin.bitcoinTestNet: - case Coin.litecoinTestNet: - case Coin.bitcoincashTestnet: - case Coin.firoTestNet: - case Coin.dogecoinTestNet: - case Coin.eCash: - return true; - - case Coin.epicCash: - case Coin.ethereum: - case Coin.monero: - case Coin.tezos: - case Coin.wownero: - case Coin.nano: - case Coin.banano: - case Coin.stellar: - case Coin.stellarTestnet: - return false; - } - } - bool get hasMnemonicPassphraseSupport { switch (this) { case Coin.bitcoin: @@ -241,6 +221,8 @@ extension CoinExt on Coin { case Coin.stellarTestnet: return true; + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: case Coin.epicCash: case Coin.monero: case Coin.wownero: @@ -260,6 +242,8 @@ extension CoinExt on Coin { case Coin.ethereum: return true; + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: case Coin.firo: case Coin.namecoin: case Coin.particl: @@ -284,6 +268,7 @@ extension CoinExt on Coin { bool get isTestNet { switch (this) { case Coin.bitcoin: + case Coin.bitcoinFrost: case Coin.litecoin: case Coin.bitcoincash: case Coin.dogecoin: @@ -303,6 +288,7 @@ extension CoinExt on Coin { case Coin.dogecoinTestNet: case Coin.bitcoinTestNet: + case Coin.bitcoinFrostTestNet: case Coin.litecoinTestNet: case Coin.bitcoincashTestnet: case Coin.firoTestNet: @@ -311,9 +297,21 @@ extension CoinExt on Coin { } } + bool get isFrost { + switch (this) { + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: + return true; + + default: + return false; + } + } + Coin get mainNetVersion { switch (this) { case Coin.bitcoin: + case Coin.bitcoinFrost: case Coin.litecoin: case Coin.bitcoincash: case Coin.dogecoin: @@ -337,6 +335,9 @@ extension CoinExt on Coin { case Coin.bitcoinTestNet: return Coin.bitcoin; + case Coin.bitcoinFrostTestNet: + return Coin.bitcoinFrost; + case Coin.litecoinTestNet: return Coin.litecoin; @@ -364,6 +365,10 @@ extension CoinExt on Coin { case Coin.particl: return AddressType.p2wpkh; + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: + return AddressType.frostMS; + case Coin.eCash: case Coin.bitcoincash: case Coin.bitcoincashTestnet: @@ -501,6 +506,15 @@ Coin coinFromPrettyName(String name) { case "tStellar": return Coin.stellarTestnet; + case "Bitcoin Frost": + case "bitcoinFrost": + return Coin.bitcoinFrost; + + case "Bitcoin Frost Testnet": + case "tBitcoin Frost": + case "bitcoinFrostTestNet": + return Coin.bitcoinFrostTestNet; + default: throw ArgumentError.value( name, diff --git a/lib/utilities/enums/derive_path_type_enum.dart b/lib/utilities/enums/derive_path_type_enum.dart index 5b94f41f6..6d2371735 100644 --- a/lib/utilities/enums/derive_path_type_enum.dart +++ b/lib/utilities/enums/derive_path_type_enum.dart @@ -44,6 +44,8 @@ extension DerivePathTypeExt on DerivePathType { case Coin.ethereum: // TODO: do we need something here? return DerivePathType.eth; + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: case Coin.epicCash: case Coin.monero: case Coin.wownero: diff --git a/lib/wallets/crypto_currency/coins/bitcoin.dart b/lib/wallets/crypto_currency/coins/bitcoin.dart index 2402a977f..d441961a7 100644 --- a/lib/wallets/crypto_currency/coins/bitcoin.dart +++ b/lib/wallets/crypto_currency/coins/bitcoin.dart @@ -170,30 +170,10 @@ class Bitcoin extends Bip39HDCurrency with PaynymCurrencyInterface { NodeModel get defaultNode { switch (network) { case CryptoCurrencyNetwork.main: - return NodeModel( - host: "bitcoin.stackwallet.com", - port: 50002, - name: DefaultNodes.defaultName, - id: DefaultNodes.buildId(Coin.bitcoin), - useSSL: true, - enabled: true, - coinName: Coin.bitcoin.name, - isFailover: true, - isDown: false, - ); + return DefaultNodes.bitcoin; case CryptoCurrencyNetwork.test: - return NodeModel( - host: "bitcoin-testnet.stackwallet.com", - port: 51002, - name: DefaultNodes.defaultName, - id: DefaultNodes.buildId(Coin.bitcoinTestNet), - useSSL: true, - enabled: true, - coinName: Coin.bitcoinTestNet.name, - isFailover: true, - isDown: false, - ); + return DefaultNodes.bitcoinTestnet; default: throw UnimplementedError(); diff --git a/lib/wallets/crypto_currency/coins/bitcoin_frost.dart b/lib/wallets/crypto_currency/coins/bitcoin_frost.dart new file mode 100644 index 000000000..b82d3987c --- /dev/null +++ b/lib/wallets/crypto_currency/coins/bitcoin_frost.dart @@ -0,0 +1,72 @@ +import 'dart:typed_data'; + +import 'package:stackwallet/models/node_model.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/default_nodes.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/crypto_currency/intermediate/bip39_hd_currency.dart'; +import 'package:stackwallet/wallets/crypto_currency/intermediate/private_key_currency.dart'; + +class BitcoinFrost extends FrostCurrency { + BitcoinFrost(super.network) { + switch (network) { + case CryptoCurrencyNetwork.main: + coin = Coin.bitcoin; + case CryptoCurrencyNetwork.test: + coin = Coin.bitcoinTestNet; + default: + throw Exception("Unsupported network: $network"); + } + } + + @override + int get minConfirms => 1; + + @override + NodeModel get defaultNode { + switch (network) { + case CryptoCurrencyNetwork.main: + return DefaultNodes.bitcoin; + + case CryptoCurrencyNetwork.test: + return DefaultNodes.bitcoinTestnet; + + default: + throw UnimplementedError(); + } + } + + @override + String get genesisHash { + switch (network) { + case CryptoCurrencyNetwork.main: + return "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + case CryptoCurrencyNetwork.test: + return "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943"; + default: + throw Exception("Unsupported network: $network"); + } + } + + @override + Amount get dustLimit => Amount( + rawValue: BigInt.from(294), + fractionDigits: fractionDigits, + ); + + @override + String pubKeyToScriptHash({required Uint8List pubKey}) { + try { + return Bip39HDCurrency.convertBytesToScriptHash(pubKey); + } catch (e) { + rethrow; + } + } + + @override + bool validateAddress(String address) { + // TODO: implement validateAddress for frost addresses + return true; + } +} diff --git a/lib/wallets/crypto_currency/intermediate/private_key_currency.dart b/lib/wallets/crypto_currency/intermediate/private_key_currency.dart new file mode 100644 index 000000000..0c10937fa --- /dev/null +++ b/lib/wallets/crypto_currency/intermediate/private_key_currency.dart @@ -0,0 +1,12 @@ +import 'dart:typed_data'; + +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +abstract class FrostCurrency extends CryptoCurrency { + FrostCurrency(super.network); + + String pubKeyToScriptHash({required Uint8List pubKey}); + + Amount get dustLimit; +} diff --git a/lib/wallets/isar/models/frost_wallet_info.dart b/lib/wallets/isar/models/frost_wallet_info.dart new file mode 100644 index 000000000..b5c7476d2 --- /dev/null +++ b/lib/wallets/isar/models/frost_wallet_info.dart @@ -0,0 +1,41 @@ +import 'package:isar/isar.dart'; +import 'package:stackwallet/wallets/isar/isar_id_interface.dart'; + +part 'frost_wallet_info.g.dart'; + +@Collection(accessor: "frostWalletInfo", inheritance: false) +class FrostWalletInfo implements IsarId { + @override + Id id = Isar.autoIncrement; + + @Index(unique: true, replace: false) + final String walletId; + + final List knownSalts; + final List participants; + final String myName; + final int threshold; + + FrostWalletInfo({ + required this.walletId, + required this.knownSalts, + required this.participants, + required this.myName, + required this.threshold, + }); + + FrostWalletInfo copyWith({ + List? knownSalts, + List? participants, + String? myName, + int? threshold, + }) { + return FrostWalletInfo( + walletId: walletId, + knownSalts: knownSalts ?? this.knownSalts, + participants: participants ?? this.participants, + myName: myName ?? this.myName, + threshold: threshold ?? this.threshold, + )..id = id; + } +} diff --git a/lib/wallets/isar/models/frost_wallet_info.g.dart b/lib/wallets/isar/models/frost_wallet_info.g.dart new file mode 100644 index 000000000..6c80125e2 --- /dev/null +++ b/lib/wallets/isar/models/frost_wallet_info.g.dart @@ -0,0 +1,1364 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'frost_wallet_info.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters + +extension GetFrostWalletInfoCollection on Isar { + IsarCollection get frostWalletInfo => this.collection(); +} + +const FrostWalletInfoSchema = CollectionSchema( + name: r'FrostWalletInfo', + id: -4182879703273806681, + properties: { + r'knownSalts': PropertySchema( + id: 0, + name: r'knownSalts', + type: IsarType.stringList, + ), + r'myName': PropertySchema( + id: 1, + name: r'myName', + type: IsarType.string, + ), + r'participants': PropertySchema( + id: 2, + name: r'participants', + type: IsarType.stringList, + ), + r'threshold': PropertySchema( + id: 3, + name: r'threshold', + type: IsarType.long, + ), + r'walletId': PropertySchema( + id: 4, + name: r'walletId', + type: IsarType.string, + ) + }, + estimateSize: _frostWalletInfoEstimateSize, + serialize: _frostWalletInfoSerialize, + deserialize: _frostWalletInfoDeserialize, + deserializeProp: _frostWalletInfoDeserializeProp, + idName: r'id', + indexes: { + r'walletId': IndexSchema( + id: -1783113319798776304, + name: r'walletId', + unique: true, + replace: false, + properties: [ + IndexPropertySchema( + name: r'walletId', + type: IndexType.hash, + caseSensitive: true, + ) + ], + ) + }, + links: {}, + embeddedSchemas: {}, + getId: _frostWalletInfoGetId, + getLinks: _frostWalletInfoGetLinks, + attach: _frostWalletInfoAttach, + version: '3.0.5', +); + +int _frostWalletInfoEstimateSize( + FrostWalletInfo object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + bytesCount += 3 + object.knownSalts.length * 3; + { + for (var i = 0; i < object.knownSalts.length; i++) { + final value = object.knownSalts[i]; + bytesCount += value.length * 3; + } + } + bytesCount += 3 + object.myName.length * 3; + bytesCount += 3 + object.participants.length * 3; + { + for (var i = 0; i < object.participants.length; i++) { + final value = object.participants[i]; + bytesCount += value.length * 3; + } + } + bytesCount += 3 + object.walletId.length * 3; + return bytesCount; +} + +void _frostWalletInfoSerialize( + FrostWalletInfo object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeStringList(offsets[0], object.knownSalts); + writer.writeString(offsets[1], object.myName); + writer.writeStringList(offsets[2], object.participants); + writer.writeLong(offsets[3], object.threshold); + writer.writeString(offsets[4], object.walletId); +} + +FrostWalletInfo _frostWalletInfoDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = FrostWalletInfo( + knownSalts: reader.readStringList(offsets[0]) ?? [], + myName: reader.readString(offsets[1]), + participants: reader.readStringList(offsets[2]) ?? [], + threshold: reader.readLong(offsets[3]), + walletId: reader.readString(offsets[4]), + ); + object.id = id; + return object; +} + +P _frostWalletInfoDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readStringList(offset) ?? []) as P; + case 1: + return (reader.readString(offset)) as P; + case 2: + return (reader.readStringList(offset) ?? []) as P; + case 3: + return (reader.readLong(offset)) as P; + case 4: + return (reader.readString(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _frostWalletInfoGetId(FrostWalletInfo object) { + return object.id; +} + +List> _frostWalletInfoGetLinks(FrostWalletInfo object) { + return []; +} + +void _frostWalletInfoAttach( + IsarCollection col, Id id, FrostWalletInfo object) { + object.id = id; +} + +extension FrostWalletInfoByIndex on IsarCollection { + Future getByWalletId(String walletId) { + return getByIndex(r'walletId', [walletId]); + } + + FrostWalletInfo? getByWalletIdSync(String walletId) { + return getByIndexSync(r'walletId', [walletId]); + } + + Future deleteByWalletId(String walletId) { + return deleteByIndex(r'walletId', [walletId]); + } + + bool deleteByWalletIdSync(String walletId) { + return deleteByIndexSync(r'walletId', [walletId]); + } + + Future> getAllByWalletId(List walletIdValues) { + final values = walletIdValues.map((e) => [e]).toList(); + return getAllByIndex(r'walletId', values); + } + + List getAllByWalletIdSync(List walletIdValues) { + final values = walletIdValues.map((e) => [e]).toList(); + return getAllByIndexSync(r'walletId', values); + } + + Future deleteAllByWalletId(List walletIdValues) { + final values = walletIdValues.map((e) => [e]).toList(); + return deleteAllByIndex(r'walletId', values); + } + + int deleteAllByWalletIdSync(List walletIdValues) { + final values = walletIdValues.map((e) => [e]).toList(); + return deleteAllByIndexSync(r'walletId', values); + } + + Future putByWalletId(FrostWalletInfo object) { + return putByIndex(r'walletId', object); + } + + Id putByWalletIdSync(FrostWalletInfo object, {bool saveLinks = true}) { + return putByIndexSync(r'walletId', object, saveLinks: saveLinks); + } + + Future> putAllByWalletId(List objects) { + return putAllByIndex(r'walletId', objects); + } + + List putAllByWalletIdSync(List objects, + {bool saveLinks = true}) { + return putAllByIndexSync(r'walletId', objects, saveLinks: saveLinks); + } +} + +extension FrostWalletInfoQueryWhereSort + on QueryBuilder { + QueryBuilder anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension FrostWalletInfoQueryWhere + on QueryBuilder { + QueryBuilder idEqualTo( + Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: id, + upper: id, + )); + }); + } + + QueryBuilder + idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder + idGreaterThan(Id id, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder idLessThan( + Id id, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + walletIdEqualTo(String walletId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IndexWhereClause.equalTo( + indexName: r'walletId', + value: [walletId], + )); + }); + } + + QueryBuilder + walletIdNotEqualTo(String walletId) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'walletId', + lower: [], + upper: [walletId], + includeUpper: false, + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'walletId', + lower: [walletId], + includeLower: false, + upper: [], + )); + } else { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'walletId', + lower: [walletId], + includeLower: false, + upper: [], + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'walletId', + lower: [], + upper: [walletId], + includeUpper: false, + )); + } + }); + } +} + +extension FrostWalletInfoQueryFilter + on QueryBuilder { + QueryBuilder + idEqualTo(Id value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'id', + value: value, + )); + }); + } + + QueryBuilder + idGreaterThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder + idLessThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder + idBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + knownSaltsElementEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'knownSalts', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'knownSalts', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'knownSalts', + value: '', + )); + }); + } + + QueryBuilder + knownSaltsElementIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'knownSalts', + value: '', + )); + }); + } + + QueryBuilder + knownSaltsLengthEqualTo(int length) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + length, + true, + length, + true, + ); + }); + } + + QueryBuilder + knownSaltsIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + 0, + true, + 0, + true, + ); + }); + } + + QueryBuilder + knownSaltsIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + 0, + false, + 999999, + true, + ); + }); + } + + QueryBuilder + knownSaltsLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + 0, + true, + length, + include, + ); + }); + } + + QueryBuilder + knownSaltsLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + length, + include, + 999999, + true, + ); + }); + } + + QueryBuilder + knownSaltsLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + lower, + includeLower, + upper, + includeUpper, + ); + }); + } + + QueryBuilder + myNameEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'myName', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'myName', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'myName', + value: '', + )); + }); + } + + QueryBuilder + myNameIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'myName', + value: '', + )); + }); + } + + QueryBuilder + participantsElementEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'participants', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'participants', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'participants', + value: '', + )); + }); + } + + QueryBuilder + participantsElementIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'participants', + value: '', + )); + }); + } + + QueryBuilder + participantsLengthEqualTo(int length) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + length, + true, + length, + true, + ); + }); + } + + QueryBuilder + participantsIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + 0, + true, + 0, + true, + ); + }); + } + + QueryBuilder + participantsIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + 0, + false, + 999999, + true, + ); + }); + } + + QueryBuilder + participantsLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + 0, + true, + length, + include, + ); + }); + } + + QueryBuilder + participantsLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + length, + include, + 999999, + true, + ); + }); + } + + QueryBuilder + participantsLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + lower, + includeLower, + upper, + includeUpper, + ); + }); + } + + QueryBuilder + thresholdEqualTo(int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'threshold', + value: value, + )); + }); + } + + QueryBuilder + thresholdGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'threshold', + value: value, + )); + }); + } + + QueryBuilder + thresholdLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'threshold', + value: value, + )); + }); + } + + QueryBuilder + thresholdBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'threshold', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + walletIdEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'walletId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'walletId', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'walletId', + value: '', + )); + }); + } + + QueryBuilder + walletIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'walletId', + value: '', + )); + }); + } +} + +extension FrostWalletInfoQueryObject + on QueryBuilder {} + +extension FrostWalletInfoQueryLinks + on QueryBuilder {} + +extension FrostWalletInfoQuerySortBy + on QueryBuilder { + QueryBuilder sortByMyName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.asc); + }); + } + + QueryBuilder + sortByMyNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.desc); + }); + } + + QueryBuilder + sortByThreshold() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.asc); + }); + } + + QueryBuilder + sortByThresholdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.desc); + }); + } + + QueryBuilder + sortByWalletId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.asc); + }); + } + + QueryBuilder + sortByWalletIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.desc); + }); + } +} + +extension FrostWalletInfoQuerySortThenBy + on QueryBuilder { + QueryBuilder thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder thenByMyName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.asc); + }); + } + + QueryBuilder + thenByMyNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.desc); + }); + } + + QueryBuilder + thenByThreshold() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.asc); + }); + } + + QueryBuilder + thenByThresholdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.desc); + }); + } + + QueryBuilder + thenByWalletId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.asc); + }); + } + + QueryBuilder + thenByWalletIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.desc); + }); + } +} + +extension FrostWalletInfoQueryWhereDistinct + on QueryBuilder { + QueryBuilder + distinctByKnownSalts() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'knownSalts'); + }); + } + + QueryBuilder distinctByMyName( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'myName', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByParticipants() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'participants'); + }); + } + + QueryBuilder + distinctByThreshold() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'threshold'); + }); + } + + QueryBuilder distinctByWalletId( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'walletId', caseSensitive: caseSensitive); + }); + } +} + +extension FrostWalletInfoQueryProperty + on QueryBuilder { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder, QQueryOperations> + knownSaltsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'knownSalts'); + }); + } + + QueryBuilder myNameProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'myName'); + }); + } + + QueryBuilder, QQueryOperations> + participantsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'participants'); + }); + } + + QueryBuilder thresholdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'threshold'); + }); + } + + QueryBuilder walletIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'walletId'); + }); + } +} diff --git a/lib/wallets/isar/models/wallet_info.g.dart b/lib/wallets/isar/models/wallet_info.g.dart index db50a581a..0b809ddcb 100644 --- a/lib/wallets/isar/models/wallet_info.g.dart +++ b/lib/wallets/isar/models/wallet_info.g.dart @@ -265,6 +265,7 @@ const _WalletInfomainAddressTypeEnumValueMap = { 'spark': 10, 'stellar': 11, 'tezos': 12, + 'frostMS': 13, }; const _WalletInfomainAddressTypeValueEnumMap = { 0: AddressType.p2pkh, @@ -280,6 +281,7 @@ const _WalletInfomainAddressTypeValueEnumMap = { 10: AddressType.spark, 11: AddressType.stellar, 12: AddressType.tezos, + 13: AddressType.frostMS, }; Id _walletInfoGetId(WalletInfo object) { diff --git a/lib/wallets/models/incomplete_frost_wallet.dart b/lib/wallets/models/incomplete_frost_wallet.dart new file mode 100644 index 000000000..e5075da63 --- /dev/null +++ b/lib/wallets/models/incomplete_frost_wallet.dart @@ -0,0 +1,42 @@ +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/services/node_service.dart'; +import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; + +class IncompleteFrostWallet { + WalletInfo? info; + + String? get walletId => info?.walletId; + + Future toBitcoinFrostWallet({ + required MainDB mainDB, + required SecureStorageInterface secureStorageInterface, + required NodeService nodeService, + required Prefs prefs, + }) async { + final wallet = await Wallet.create( + walletInfo: info!, + mainDB: mainDB, + secureStorageInterface: secureStorageInterface, + nodeService: nodeService, + prefs: prefs, + ); + + // dummy entry so updaters work when `wallet.updateWithResharedData` is called + final frostInfo = FrostWalletInfo( + walletId: info!.walletId, + knownSalts: [], + participants: [], + myName: "", + threshold: -1, + ); + + await mainDB.isar.frostWalletInfo.put(frostInfo); + + return wallet as BitcoinFrostWallet; + } +} diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart new file mode 100644 index 000000000..fdefac4e9 --- /dev/null +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -0,0 +1,1217 @@ +import 'dart:async'; +import 'dart:ffi'; + +import 'package:electrum_adapter/electrum_adapter.dart' as electrum_adapter; +import 'package:electrum_adapter/electrum_adapter.dart'; +import 'package:flutter/foundation.dart'; +import 'package:frostdart/frostdart.dart' as frost; +import 'package:frostdart/frostdart_bindings_generated.dart'; +import 'package:isar/isar.dart'; +import 'package:stackwallet/electrumx_rpc/cached_electrumx_client.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/models/balance.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/transaction.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/input_v2.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/output_v2.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import 'package:stackwallet/models/paymint/fee_object_model.dart'; +import 'package:stackwallet/services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import 'package:stackwallet/services/event_bus/global_event_bus.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/extensions/extensions.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/wallets/crypto_currency/coins/bitcoin_frost.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/crypto_currency/intermediate/private_key_currency.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; +import 'package:stream_channel/stream_channel.dart'; + +class BitcoinFrostWallet extends Wallet { + BitcoinFrostWallet(CryptoCurrencyNetwork network) + : super(BitcoinFrost(network) as T); + + FrostWalletInfo get frostInfo => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .findFirstSync()!; + + late ElectrumXClient electrumXClient; + late StreamChannel electrumAdapterChannel; + late ElectrumClient electrumAdapterClient; + late CachedElectrumXClient electrumXCachedClient; + + Future initializeNewFrost({ + required String mnemonic, + required String multisigConfig, + required String recoveryString, + required String serializedKeys, + required Uint8List multisigId, + required String myName, + required List participants, + required int threshold, + }) async { + Logging.instance.log( + "Generating new FROST wallet.", + level: LogLevel.Info, + ); + + try { + final salt = frost + .multisigSalt( + multisigConfig: multisigConfig, + ) + .toHex; + + final FrostWalletInfo frostWalletInfo = FrostWalletInfo( + walletId: info.walletId, + knownSalts: [salt], + participants: participants, + myName: myName, + threshold: threshold, + ); + + await secureStorageInterface.write( + key: Wallet.mnemonicKey(walletId: info.walletId), + value: mnemonic, + ); + await secureStorageInterface.write( + key: Wallet.mnemonicPassphraseKey(walletId: info.walletId), + value: "", + ); + await _saveSerializedKeys(serializedKeys); + await _saveRecoveryString(recoveryString); + await _saveMultisigId(multisigId); + await _saveMultisigConfig(multisigConfig); + + await mainDB.isar.writeTxn(() async { + await mainDB.isar.frostWalletInfo.put(frostWalletInfo); + }); + + final keys = frost.deserializeKeys(keys: serializedKeys); + + final addressString = frost.addressForKeys( + network: cryptoCurrency.network == CryptoCurrencyNetwork.main + ? Network.Mainnet + : Network.Testnet, + keys: keys, + ); + + final publicKey = frost.scriptPubKeyForKeys(keys: keys); + + final address = Address( + walletId: info.walletId, + value: addressString, + publicKey: publicKey.toUint8ListFromHex, + derivationIndex: 0, + derivationPath: null, + subType: AddressSubType.receiving, + type: AddressType.unknown, + ); + + await mainDB.putAddresses([address]); + } catch (e, s) { + Logging.instance.log( + "Exception rethrown from initializeNewFrost(): $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + Future frostCreateSignConfig({ + required TxData txData, + required String changeAddress, + required int feePerWeight, + }) async { + try { + if (txData.recipients == null || txData.recipients!.isEmpty) { + throw Exception("No recipients found!"); + } + + final total = txData.recipients! + .map((e) => e.amount) + .reduce((value, e) => value += e); + + final utxos = await mainDB + .getUTXOs(walletId) + .filter() + .isBlockedEqualTo(false) + .findAll(); + + if (utxos.isEmpty) { + throw Exception("No UTXOs found"); + } else { + final currentHeight = await chainHeight; + utxos.removeWhere( + (e) => !e.isConfirmed( + currentHeight, + cryptoCurrency.minConfirms, + ), + ); + if (utxos.isEmpty) { + throw Exception("No confirmed UTXOs found"); + } + } + + if (total.raw > + utxos.map((e) => BigInt.from(e.value)).reduce((v, e) => v += e)) { + throw Exception("Insufficient available funds"); + } + + Amount sum = Amount.zeroWith( + fractionDigits: cryptoCurrency.fractionDigits, + ); + final Set utxosToUse = {}; + for (final utxo in utxos) { + sum += Amount( + rawValue: BigInt.from(utxo.value), + fractionDigits: cryptoCurrency.fractionDigits, + ); + utxosToUse.add(utxo); + if (sum > total) { + break; + } + } + + final serializedKeys = await getSerializedKeys(); + final keys = frost.deserializeKeys(keys: serializedKeys!); + + final int network = cryptoCurrency.network == CryptoCurrencyNetwork.main + ? Network.Mainnet + : Network.Testnet; + + final publicKey = frost + .scriptPubKeyForKeys( + keys: keys, + ) + .toUint8ListFromHex; + + final config = Frost.createSignConfig( + network: network, + inputs: utxosToUse + .map((e) => ( + utxo: e, + scriptPubKey: publicKey, + )) + .toList(), + outputs: txData.recipients!, + changeAddress: (await getCurrentReceivingAddress())!.value, + feePerWeight: feePerWeight, + ); + + return txData.copyWith(frostMSConfig: config, utxos: utxosToUse); + } catch (_) { + rethrow; + } + } + + Future< + ({ + Pointer machinePtr, + String preprocess, + })> frostAttemptSignConfig({ + required String config, + }) async { + final int network = cryptoCurrency.network == CryptoCurrencyNetwork.main + ? Network.Mainnet + : Network.Testnet; + final serializedKeys = await getSerializedKeys(); + + return Frost.attemptSignConfig( + network: network, + config: config, + serializedKeys: serializedKeys!, + ); + } + + Future updateWithResharedData({ + required String serializedKeys, + required String multisigConfig, + required bool isNewWallet, + }) async { + await _saveSerializedKeys(serializedKeys); + await _saveMultisigConfig(multisigConfig); + + await _updateThreshold( + frost.getThresholdFromKeys( + serializedKeys: serializedKeys, + ), + ); + + final myNameIndex = frost.getParticipantIndexFromKeys( + serializedKeys: serializedKeys, + ); + final participants = Frost.getParticipants( + multisigConfig: multisigConfig, + ); + final myName = participants[myNameIndex]; + + await _updateParticipants(participants); + await _updateMyName(myName); + + if (isNewWallet) { + await recover( + serializedKeys: serializedKeys, + multisigConfig: multisigConfig, + isRescan: false, + ); + } + } + + Future sweepAllEstimate(int feeRate) async { + int available = 0; + int inputCount = 0; + final height = await chainHeight; + for (final output in (await mainDB.getUTXOs(walletId).findAll())) { + if (!output.isBlocked && + output.isConfirmed(height, cryptoCurrency.minConfirms)) { + available += output.value; + inputCount++; + } + } + + // transaction will only have 1 output minus the fee + final estimatedFee = _roughFeeEstimate(inputCount, 1, feeRate); + + return Amount( + rawValue: BigInt.from(available), + fractionDigits: cryptoCurrency.fractionDigits, + ) - + estimatedFee; + } + + // int _estimateTxFee({required int vSize, required int feeRatePerKB}) { + // return vSize * (feeRatePerKB / 1000).ceil(); + // } + + Amount _roughFeeEstimate(int inputCount, int outputCount, int feeRatePerKB) { + return Amount( + rawValue: BigInt.from( + ((42 + (272 * inputCount) + (128 * outputCount)) / 4).ceil() * + (feeRatePerKB / 1000).ceil()), + fractionDigits: cryptoCurrency.fractionDigits, + ); + } + + // ==================== Overrides ============================================ + + @override + bool get supportsMultiRecipient => true; + + @override + int get isarTransactionVersion => 2; + + @override + FilterOperation? get changeAddressFilterOperation => FilterGroup.and( + [ + FilterCondition.equalTo( + property: r"type", + value: info.mainAddressType, + ), + const FilterCondition.equalTo( + property: r"subType", + value: AddressSubType.change, + ), + ], + ); + + @override + FilterOperation? get receivingAddressFilterOperation => FilterGroup.and( + [ + FilterCondition.equalTo( + property: r"type", + value: info.mainAddressType, + ), + const FilterCondition.equalTo( + property: r"subType", + value: AddressSubType.receiving, + ), + ], + ); + + @override + Future updateTransactions() async { + final myAddress = (await getCurrentReceivingAddress())!; + + final scriptHash = cryptoCurrency.pubKeyToScriptHash( + pubKey: Uint8List.fromList(myAddress.publicKey), + ); + final allTxHashes = + (await electrumXClient.getHistory(scripthash: scriptHash)).toSet(); + + final currentHeight = await chainHeight; + final coin = info.coin; + + List> allTransactions = []; + + for (final txHash in allTxHashes) { + final storedTx = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .txidEqualTo(txHash["tx_hash"] as String) + .findFirst(); + + if (storedTx == null || + !storedTx.isConfirmed(currentHeight, cryptoCurrency.minConfirms)) { + final tx = await electrumXCachedClient.getTransaction( + txHash: txHash["tx_hash"] as String, + verbose: true, + coin: coin, + ); + + if (!_duplicateTxCheck(allTransactions, tx["txid"] as String)) { + tx["height"] = txHash["height"]; + allTransactions.add(tx); + } + } + } + + // Parse all new txs. + final List txns = []; + for (final txData in allTransactions) { + bool wasSentFromThisWallet = false; + // Set to true if any inputs were detected as owned by this wallet. + + bool wasReceivedInThisWallet = false; + // Set to true if any outputs were detected as owned by this wallet. + + // Parse inputs. + BigInt amountReceivedInThisWallet = BigInt.zero; + final List inputs = []; + for (final jsonInput in txData["vin"] as List) { + final map = Map.from(jsonInput as Map); + + final List addresses = []; + String valueStringSats = "0"; + OutpointV2? outpoint; + + final coinbase = map["coinbase"] as String?; + + if (coinbase == null) { + // Not a coinbase (ie a typical input). + final txid = map["txid"] as String; + final vout = map["vout"] as int; + + final inputTx = await electrumXCachedClient.getTransaction( + txHash: txid, + coin: cryptoCurrency.coin, + ); + + final prevOutJson = Map.from( + (inputTx["vout"] as List).firstWhere((e) => e["n"] == vout) + as Map); + + final prevOut = OutputV2.fromElectrumXJson( + prevOutJson, + decimalPlaces: cryptoCurrency.fractionDigits, + isFullAmountNotSats: true, + walletOwns: false, // Doesn't matter here as this is not saved. + ); + + outpoint = OutpointV2.isarCantDoRequiredInDefaultConstructor( + txid: txid, + vout: vout, + ); + valueStringSats = prevOut.valueStringSats; + addresses.addAll(prevOut.addresses); + } + + InputV2 input = InputV2.fromElectrumxJson( + json: map, + outpoint: outpoint, + valueStringSats: valueStringSats, + addresses: addresses, + coinbase: coinbase, + // Need addresses before we can know if the wallet owns this input. + walletOwns: false, + ); + + // Check if input was from this wallet. + if (input.addresses.contains(myAddress.value)) { + wasSentFromThisWallet = true; + input = input.copyWith(walletOwns: true); + } + + inputs.add(input); + } + + // Parse outputs. + final List outputs = []; + for (final outputJson in txData["vout"] as List) { + OutputV2 output = OutputV2.fromElectrumXJson( + Map.from(outputJson as Map), + decimalPlaces: cryptoCurrency.fractionDigits, + isFullAmountNotSats: true, + // Need addresses before we can know if the wallet owns this input. + walletOwns: false, + ); + + // If output was to my wallet, add value to amount received. + if (output.addresses.contains(myAddress.value)) { + wasReceivedInThisWallet = true; + amountReceivedInThisWallet += output.value; + output = output.copyWith(walletOwns: true); + } + + outputs.add(output); + } + + final totalOut = outputs + .map((e) => e.value) + .fold(BigInt.zero, (value, element) => value + element); + + TransactionType type; + TransactionSubType subType = TransactionSubType.none; + if (outputs.length > 1 && inputs.isNotEmpty) { + for (int i = 0; i < outputs.length; i++) { + List? scriptChunks = outputs[i].scriptPubKeyAsm?.split(" "); + if (scriptChunks?.length == 2 && scriptChunks?[0] == "OP_RETURN") { + final blindedPaymentCode = scriptChunks![1]; + final bytes = blindedPaymentCode.toUint8ListFromHex; + + // https://en.bitcoin.it/wiki/BIP_0047#Sending + if (bytes.length == 80 && bytes.first == 1) { + subType = TransactionSubType.bip47Notification; + break; + } + } + } + } + + // At least one input was owned by this wallet. + if (wasSentFromThisWallet) { + type = TransactionType.outgoing; + + if (wasReceivedInThisWallet) { + if (amountReceivedInThisWallet == totalOut) { + // Definitely sent all to self. + type = TransactionType.sentToSelf; + } else if (amountReceivedInThisWallet == BigInt.zero) { + // Most likely just a typical send, do nothing here yet. + } + } + } else if (wasReceivedInThisWallet) { + // Only found outputs owned by this wallet. + type = TransactionType.incoming; + } else { + Logging.instance.log( + "Unexpected tx found (ignoring it): $txData", + level: LogLevel.Error, + ); + continue; + } + + final tx = TransactionV2( + walletId: walletId, + blockHash: txData["blockhash"] as String?, + hash: txData["hash"] as String, + txid: txData["txid"] as String, + height: txData["height"] as int?, + version: txData["version"] as int, + timestamp: txData["blocktime"] as int? ?? + DateTime.timestamp().millisecondsSinceEpoch ~/ 1000, + inputs: List.unmodifiable(inputs), + outputs: List.unmodifiable(outputs), + type: type, + subType: subType, + otherData: null, + ); + + txns.add(tx); + } + + await mainDB.updateOrPutTransactionV2s(txns); + } + + @override + Future checkSaveInitialReceivingAddress() async { + final address = await getCurrentReceivingAddress(); + if (address == null) { + final serializedKeys = await getSerializedKeys(); + if (serializedKeys != null) { + final keys = frost.deserializeKeys(keys: serializedKeys); + + final addressString = frost.addressForKeys( + network: cryptoCurrency.network == CryptoCurrencyNetwork.main + ? Network.Mainnet + : Network.Testnet, + keys: keys, + ); + + final publicKey = frost.scriptPubKeyForKeys(keys: keys); + + final address = Address( + walletId: walletId, + value: addressString, + publicKey: publicKey.toUint8ListFromHex, + derivationIndex: 0, + derivationPath: null, + subType: AddressSubType.receiving, + type: AddressType.frostMS, + ); + + await mainDB.updateOrPutAddresses([address]); + } else { + Logging.instance.log( + "$runtimeType.checkSaveInitialReceivingAddress() failed due" + " to missing serialized keys", + level: LogLevel.Fatal, + ); + } + } + } + + @override + Future confirmSend({required TxData txData}) async { + try { + Logging.instance.log("confirmSend txData: $txData", level: LogLevel.Info); + + final hex = txData.raw!; + + final txHash = await electrumXClient.broadcastTransaction(rawTx: hex); + Logging.instance.log("Sent txHash: $txHash", level: LogLevel.Info); + + // mark utxos as used + final usedUTXOs = txData.utxos!.map((e) => e.copyWith(used: true)); + await mainDB.putUTXOs(usedUTXOs.toList()); + + txData = txData.copyWith( + utxos: usedUTXOs.toSet(), + txHash: txHash, + txid: txHash, + ); + + return txData; + } catch (e, s) { + Logging.instance.log("Exception rethrown from confirmSend(): $e\n$s", + level: LogLevel.Error); + rethrow; + } + } + + @override + Future estimateFeeFor(Amount amount, int feeRate) async { + final available = info.cachedBalance.spendable; + + if (available == amount) { + return amount - (await sweepAllEstimate(feeRate)); + } else if (amount <= Amount.zero || amount > available) { + return _roughFeeEstimate(1, 2, feeRate); + } + + Amount runningBalance = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + int inputCount = 0; + for (final output in (await mainDB.getUTXOs(walletId).findAll())) { + if (!output.isBlocked) { + runningBalance += Amount( + rawValue: BigInt.from(output.value), + fractionDigits: cryptoCurrency.fractionDigits, + ); + inputCount++; + if (runningBalance > amount) { + break; + } + } + } + + final oneOutPutFee = _roughFeeEstimate(inputCount, 1, feeRate); + final twoOutPutFee = _roughFeeEstimate(inputCount, 2, feeRate); + + if (runningBalance - amount > oneOutPutFee) { + if (runningBalance - amount > oneOutPutFee + cryptoCurrency.dustLimit) { + final change = runningBalance - amount - twoOutPutFee; + if (change > cryptoCurrency.dustLimit && + runningBalance - amount - change == twoOutPutFee) { + return runningBalance - amount - change; + } else { + return runningBalance - amount; + } + } else { + return runningBalance - amount; + } + } else if (runningBalance - amount == oneOutPutFee) { + return oneOutPutFee; + } else { + return twoOutPutFee; + } + } + + @override + Future get fees async { + try { + // adjust numbers for different speeds? + const int f = 1, m = 5, s = 20; + + final fast = await electrumXClient.estimateFee(blocks: f); + final medium = await electrumXClient.estimateFee(blocks: m); + final slow = await electrumXClient.estimateFee(blocks: s); + + final feeObject = FeeObject( + numberOfBlocksFast: f, + numberOfBlocksAverage: m, + numberOfBlocksSlow: s, + fast: Amount.fromDecimal( + fast, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(), + medium: Amount.fromDecimal( + medium, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(), + slow: Amount.fromDecimal( + slow, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(), + ); + + Logging.instance.log("fetched fees: $feeObject", level: LogLevel.Info); + return feeObject; + } catch (e) { + Logging.instance + .log("Exception rethrown from _getFees(): $e", level: LogLevel.Error); + rethrow; + } + } + + @override + Future prepareSend({required TxData txData}) { + // TODO: implement prepareSendpu + throw UnimplementedError(); + } + + @override + Future recover({ + required bool isRescan, + String? serializedKeys, + String? multisigConfig, + }) async { + if (serializedKeys == null || multisigConfig == null) { + serializedKeys = await getSerializedKeys(); + multisigConfig = await getMultisigConfig(); + } + if (serializedKeys == null || multisigConfig == null) { + String err = "${info.coinName} wallet ${info.walletId} had null keys/cfg"; + Logging.instance.log(err, level: LogLevel.Fatal); + throw Exception(err); + // TODO [prio=low]: handle null keys or config. This should not happen. + } + + final coin = info.coin; + + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.syncing, + walletId, + coin, + ), + ); + + try { + await refreshMutex.protect(() async { + if (!isRescan) { + final salt = frost + .multisigSalt( + multisigConfig: multisigConfig!, + ) + .toHex; + final knownSalts = _getKnownSalts(); + if (knownSalts.contains(salt)) { + throw Exception("Known frost multisig salt found!"); + } + List updatedKnownSalts = List.from(knownSalts); + updatedKnownSalts.add(salt); + await _updateKnownSalts(updatedKnownSalts); + } else { + // clear cache + await electrumXCachedClient.clearSharedTransactionCache(coin: coin); + await mainDB.deleteWalletBlockchainData(walletId); + } + + final keys = frost.deserializeKeys(keys: serializedKeys!); + await _saveSerializedKeys(serializedKeys!); + await _saveMultisigConfig(multisigConfig!); + + final addressString = frost.addressForKeys( + network: cryptoCurrency.network == CryptoCurrencyNetwork.main + ? Network.Mainnet + : Network.Testnet, + keys: keys, + ); + + final publicKey = frost.scriptPubKeyForKeys(keys: keys); + + final address = Address( + walletId: walletId, + value: addressString, + publicKey: publicKey.toUint8ListFromHex, + derivationIndex: 0, + derivationPath: null, + subType: AddressSubType.receiving, + type: AddressType.frostMS, + ); + + await mainDB.updateOrPutAddresses([address]); + }); + + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.synced, + walletId, + coin, + ), + ); + + unawaited(refresh()); + } catch (e, s) { + Logging.instance.log( + "recoverFromSerializedKeys failed: $e\n$s", + level: LogLevel.Fatal, + ); + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.unableToSync, + walletId, + coin, + ), + ); + rethrow; + } + } + + @override + Future updateBalance() async { + final utxos = await mainDB.getUTXOs(walletId).findAll(); + + final currentChainHeight = await chainHeight; + + Amount satoshiBalanceTotal = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + Amount satoshiBalancePending = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + Amount satoshiBalanceSpendable = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + Amount satoshiBalanceBlocked = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + + for (final utxo in utxos) { + final utxoAmount = Amount( + rawValue: BigInt.from(utxo.value), + fractionDigits: cryptoCurrency.fractionDigits, + ); + + satoshiBalanceTotal += utxoAmount; + + if (utxo.isBlocked) { + satoshiBalanceBlocked += utxoAmount; + } else { + if (utxo.isConfirmed( + currentChainHeight, + cryptoCurrency.minConfirms, + )) { + satoshiBalanceSpendable += utxoAmount; + } else { + satoshiBalancePending += utxoAmount; + } + } + } + + final balance = Balance( + total: satoshiBalanceTotal, + spendable: satoshiBalanceSpendable, + blockedTotal: satoshiBalanceBlocked, + pendingSpendable: satoshiBalancePending, + ); + + await info.updateBalance(newBalance: balance, isar: mainDB.isar); + } + + @override + Future updateChainHeight() async { + final int height; + try { + final result = await electrumXClient.getBlockHeadTip(); + height = result["height"] as int; + } catch (e) { + rethrow; + } + + await info.updateCachedChainHeight( + newHeight: height, + isar: mainDB.isar, + ); + } + + @override + Future pingCheck() async { + try { + final result = await electrumXClient.ping(); + return result; + } catch (_) { + return false; + } + } + + @override + Future updateNode() async { + await _updateElectrumX(); + } + + @override + Future updateUTXOs() async { + final address = await getCurrentReceivingAddress(); + + try { + final scriptHash = cryptoCurrency.pubKeyToScriptHash( + pubKey: Uint8List.fromList(address!.publicKey), + ); + + final utxos = await electrumXClient.getUTXOs(scripthash: scriptHash); + + final List outputArray = []; + + for (int i = 0; i < utxos.length; i++) { + final utxo = await _parseUTXO( + jsonUTXO: utxos[i], + ); + + outputArray.add(utxo); + } + + return await mainDB.updateUTXOs(walletId, outputArray); + } catch (e, s) { + Logging.instance.log( + "Output fetch unsuccessful: $e\n$s", + level: LogLevel.Error, + ); + return false; + } + } + + // =================== Secure storage ======================================== + + Future getSerializedKeys() async => + await secureStorageInterface.read( + key: "{$walletId}_serializedFROSTKeys", + ); + + Future _saveSerializedKeys( + String keys, + ) async { + final current = await getSerializedKeys(); + + if (current == null) { + // do nothing + } else if (current == keys) { + // should never occur + } else { + // save current as prev gen before updating current + await secureStorageInterface.write( + key: "{$walletId}_serializedFROSTKeysPrevGen", + value: current, + ); + } + + await secureStorageInterface.write( + key: "{$walletId}_serializedFROSTKeys", + value: keys, + ); + } + + Future getSerializedKeysPrevGen() async => + await secureStorageInterface.read( + key: "{$walletId}_serializedFROSTKeysPrevGen", + ); + + Future getMultisigConfig() async => + await secureStorageInterface.read( + key: "{$walletId}_multisigConfig", + ); + + Future getMultisigConfigPrevGen() async => + await secureStorageInterface.read( + key: "{$walletId}_multisigConfigPrevGen", + ); + + Future _saveMultisigConfig( + String multisigConfig, + ) async { + final current = await getMultisigConfig(); + + if (current == null) { + // do nothing + } else if (current == multisigConfig) { + // should never occur + } else { + // save current as prev gen before updating current + await secureStorageInterface.write( + key: "{$walletId}_multisigConfigPrevGen", + value: current, + ); + } + + await secureStorageInterface.write( + key: "{$walletId}_multisigConfig", + value: multisigConfig, + ); + } + + Future _multisigId() async { + final id = await secureStorageInterface.read( + key: "{$walletId}_multisigIdFROST", + ); + if (id == null) { + return null; + } else { + return id.toUint8ListFromHex; + } + } + + Future _saveMultisigId( + Uint8List id, + ) async => + await secureStorageInterface.write( + key: "{$walletId}_multisigIdFROST", + value: id.toHex, + ); + + Future _recoveryString() async => await secureStorageInterface.read( + key: "{$walletId}_recoveryStringFROST", + ); + + Future _saveRecoveryString( + String recoveryString, + ) async => + await secureStorageInterface.write( + key: "{$walletId}_recoveryStringFROST", + value: recoveryString, + ); + + // =================== DB ==================================================== + + List _getKnownSalts() => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .knownSaltsProperty() + .findFirstSync()!; + + Future _updateKnownSalts(List knownSalts) async { + final info = frostInfo; + + await mainDB.isar.writeTxn(() async { + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(knownSalts: knownSalts), + ); + }); + } + + List _getParticipants() => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .participantsProperty() + .findFirstSync()!; + + Future _updateParticipants(List participants) async { + await mainDB.isar.writeTxn(() async { + final info = frostInfo; + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(participants: participants), + ); + }); + } + + int _getThreshold() => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .thresholdProperty() + .findFirstSync()!; + + Future _updateThreshold(int threshold) async { + await mainDB.isar.writeTxn(() async { + final info = frostInfo; + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(threshold: threshold), + ); + }); + } + + String _getMyName() => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .myNameProperty() + .findFirstSync()!; + + Future _updateMyName(String myName) async { + await mainDB.isar.writeTxn(() async { + final info = frostInfo; + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(myName: myName), + ); + }); + } + + // =================== Private =============================================== + + Future _getCurrentElectrumXNode() async { + final node = getCurrentNode(); + + return ElectrumXNode( + address: node.host, + port: node.port, + name: node.name, + useSSL: node.useSSL, + id: node.id, + ); + } + + // TODO [prio=low]: Use ElectrumXInterface method. + Future _updateElectrumX() async { + final failovers = nodeService + .failoverNodesFor(coin: cryptoCurrency.coin) + .map((e) => ElectrumXNode( + address: e.host, + port: e.port, + name: e.name, + id: e.id, + useSSL: e.useSSL, + )) + .toList(); + + final newNode = await _getCurrentElectrumXNode(); + try { + await electrumXClient.electrumAdapterClient?.close(); + } catch (e, s) { + if (e.toString().contains("initialized")) { + // Ignore. This should happen every first time the wallet is opened. + } else { + Logging.instance + .log("Error closing electrumXClient: $e", level: LogLevel.Error); + } + } + electrumXClient = ElectrumXClient.from( + node: newNode, + prefs: prefs, + failovers: failovers, + coin: cryptoCurrency.coin, + ); + electrumAdapterChannel = await electrum_adapter.connect( + newNode.address, + port: newNode.port, + acceptUnverified: true, + useSSL: newNode.useSSL, + proxyInfo: Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + if (electrumXClient.coin == Coin.firo || + electrumXClient.coin == Coin.firoTestNet) { + electrumAdapterClient = FiroElectrumClient( + electrumAdapterChannel, + newNode.address, + newNode.port, + newNode.useSSL, + Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null); + } else { + electrumAdapterClient = ElectrumClient( + electrumAdapterChannel, + newNode.address, + newNode.port, + newNode.useSSL, + Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null); + } + electrumXCachedClient = CachedElectrumXClient.from( + electrumXClient: electrumXClient, + electrumAdapterClient: electrumAdapterClient, + electrumAdapterUpdateCallback: updateClient, + ); + } + + // TODO [prio=low]: Use ElectrumXInterface method. + Future updateClient() async { + Logging.instance.log( + "Updating electrum node and ElectrumAdapterClient from Frost wallet.", + level: LogLevel.Info); + await updateNode(); + return electrumAdapterClient; + } + + bool _duplicateTxCheck( + List> allTransactions, String txid) { + for (int i = 0; i < allTransactions.length; i++) { + if (allTransactions[i]["txid"] == txid) { + return true; + } + } + return false; + } + + Future _parseUTXO({ + required Map jsonUTXO, + }) async { + final txn = await electrumXCachedClient.getTransaction( + txHash: jsonUTXO["tx_hash"] as String, + verbose: true, + coin: cryptoCurrency.coin, + ); + + final vout = jsonUTXO["tx_pos"] as int; + + final outputs = txn["vout"] as List; + + // String? scriptPubKey; + String? utxoOwnerAddress; + // get UTXO owner address + for (final output in outputs) { + if (output["n"] == vout) { + // scriptPubKey = output["scriptPubKey"]?["hex"] as String?; + utxoOwnerAddress = + output["scriptPubKey"]?["addresses"]?[0] as String? ?? + output["scriptPubKey"]?["address"] as String?; + } + } + + final utxo = UTXO( + walletId: walletId, + txid: txn["txid"] as String, + vout: vout, + value: jsonUTXO["value"] as int, + name: "", + isBlocked: false, + blockedReason: null, + isCoinbase: txn["is_coinbase"] as bool? ?? false, + blockHash: txn["blockhash"] as String?, + blockHeight: jsonUTXO["height"] as int?, + blockTime: txn["blocktime"] as int?, + address: utxoOwnerAddress, + ); + + return utxo; + } +} diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 2f1691b0a..00131b88f 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -26,6 +26,7 @@ import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; import 'package:stackwallet/wallets/models/tx_data.dart'; import 'package:stackwallet/wallets/wallet/impl/banano_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/bitcoin_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/bitcoincash_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/dogecoin_wallet.dart'; @@ -55,6 +56,9 @@ abstract class Wallet { // default to Transaction class. For TransactionV2 set to 2 int get isarTransactionVersion => 1; + // whether the wallet currently supports multiple recipients per tx + bool get supportsMultiRecipient => false; + Wallet(this.cryptoCurrency); //============================================================================ @@ -289,7 +293,7 @@ abstract class Wallet { wallet.prefs = prefs; wallet.nodeService = nodeService; - if (wallet is ElectrumXInterface) { + if (wallet is ElectrumXInterface || wallet is BitcoinFrostWallet) { // initialize electrumx instance await wallet.updateNode(); } @@ -312,6 +316,11 @@ abstract class Wallet { case Coin.bitcoinTestNet: return BitcoinWallet(CryptoCurrencyNetwork.test); + case Coin.bitcoinFrost: + return BitcoinFrostWallet(CryptoCurrencyNetwork.main); + case Coin.bitcoinFrostTestNet: + return BitcoinFrostWallet(CryptoCurrencyNetwork.test); + case Coin.bitcoincash: return BitcoincashWallet(CryptoCurrencyNetwork.main); case Coin.bitcoincashTestnet: diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index a7bb2a1af..aa738c6ec 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -865,7 +865,7 @@ mixin ElectrumXInterface on Bip39HDWallet { } } - Future getCurrentElectrumXNode() async { + Future _getCurrentElectrumXNode() async { final node = getCurrentNode(); return ElectrumXNode( @@ -877,7 +877,7 @@ mixin ElectrumXInterface on Bip39HDWallet { ); } - Future updateElectrumX({required ElectrumXNode newNode}) async { + Future updateElectrumX() async { final failovers = nodeService .failoverNodesFor(coin: cryptoCurrency.coin) .map((e) => ElectrumXNode( @@ -889,7 +889,7 @@ mixin ElectrumXInterface on Bip39HDWallet { )) .toList(); - final newNode = await getCurrentElectrumXNode(); + final newNode = await _getCurrentElectrumXNode(); try { await electrumXClient.electrumAdapterClient?.close(); } catch (e, s) { @@ -1241,8 +1241,7 @@ mixin ElectrumXInterface on Bip39HDWallet { @override Future updateNode() async { - final node = await getCurrentElectrumXNode(); - await updateElectrumX(newNode: node); + await updateElectrumX(); } Future updateClient() async { diff --git a/lib/widgets/detail_item.dart b/lib/widgets/detail_item.dart new file mode 100644 index 000000000..75e0e6a1e --- /dev/null +++ b/lib/widgets/detail_item.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/rounded_white_container.dart'; + +class DetailItem extends StatelessWidget { + const DetailItem({ + Key? key, + required this.title, + required this.detail, + this.button, + this.showEmptyDetail = true, + this.disableSelectableText = false, + }) : super(key: key); + + final String title; + final String detail; + final Widget? button; + final bool showEmptyDetail; + final bool disableSelectableText; + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => RoundedWhiteContainer( + child: child, + ), + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + disableSelectableText + ? Text( + title, + style: STextStyles.itemSubtitle(context), + ) + : SelectableText( + title, + style: STextStyles.itemSubtitle(context), + ), + button ?? Container(), + ], + ), + const SizedBox( + height: 5, + ), + detail.isEmpty && showEmptyDetail + ? disableSelectableText + ? Text( + "$title will appear here", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle3, + ), + ) + : SelectableText( + "$title will appear here", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle3, + ), + ) + : disableSelectableText + ? Text( + detail, + style: STextStyles.w500_14(context), + ) + : SelectableText( + detail, + style: STextStyles.w500_14(context), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/dialogs/frost_interruption_dialog.dart b/lib/widgets/dialogs/frost_interruption_dialog.dart new file mode 100644 index 000000000..4d36456bb --- /dev/null +++ b/lib/widgets/dialogs/frost_interruption_dialog.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/desktop/secondary_button.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; + +enum FrostInterruptionDialogType { + walletCreation, + resharing, + transactionCreation; +} + +class FrostInterruptionDialog extends StatelessWidget { + const FrostInterruptionDialog({ + super.key, + required this.type, + required this.popUntilOnYesRouteName, + this.onNoPressedOverride, + this.onYesPressedOverride, + }); + + final FrostInterruptionDialogType type; + final String popUntilOnYesRouteName; + final VoidCallback? onNoPressedOverride; + final VoidCallback? onYesPressedOverride; + + String get message { + switch (type) { + case FrostInterruptionDialogType.walletCreation: + return "wallet creation"; + case FrostInterruptionDialogType.resharing: + return "resharing"; + case FrostInterruptionDialogType.transactionCreation: + return "transaction signing"; + } + } + + @override + Widget build(BuildContext context) { + return StackDialog( + title: "Cancel $message process", + message: "Are you sure you want to cancel the $message process?", + leftButton: SecondaryButton( + label: "No", + onPressed: onNoPressedOverride ?? + Navigator.of( + context, + rootNavigator: Util.isDesktop, + ).pop, + ), + rightButton: PrimaryButton( + label: "Yes", + onPressed: onYesPressedOverride ?? + () { + // pop dialog + Navigator.of( + context, + rootNavigator: Util.isDesktop, + ).pop(); + + Navigator.of(context).popUntil( + ModalRoute.withName( + popUntilOnYesRouteName, + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/node_card.dart b/lib/widgets/node_card.dart index bc7233065..e0d15a851 100644 --- a/lib/widgets/node_card.dart +++ b/lib/widgets/node_card.dart @@ -169,6 +169,8 @@ class _NodeCardState extends ConsumerState { case Coin.namecoin: case Coin.bitcoincashTestnet: case Coin.eCash: + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: final client = ElectrumXClient( host: node.host, port: node.port, diff --git a/lib/widgets/node_options_sheet.dart b/lib/widgets/node_options_sheet.dart index 0a2e5ac4c..eda89e1ae 100644 --- a/lib/widgets/node_options_sheet.dart +++ b/lib/widgets/node_options_sheet.dart @@ -151,6 +151,8 @@ class NodeOptionsSheet extends ConsumerWidget { case Coin.namecoin: case Coin.bitcoincashTestnet: case Coin.eCash: + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: final client = ElectrumXClient( host: node.host, port: node.port, diff --git a/lib/widgets/stack_dialog.dart b/lib/widgets/stack_dialog.dart index be7f22ed9..bc247c2f2 100644 --- a/lib/widgets/stack_dialog.dart +++ b/lib/widgets/stack_dialog.dart @@ -147,8 +147,10 @@ class StackOkDialog extends StatelessWidget { this.icon, required this.title, this.message, + this.desktopPopRootNavigator = false, }) : super(key: key); + final bool desktopPopRootNavigator; final Widget? leftButton; final void Function(String)? onOkPressed; @@ -208,9 +210,13 @@ class StackOkDialog extends StatelessWidget { onOkPressed?.call("OK"); } : () { - int count = 0; - Navigator.of(context).popUntil((_) => count++ >= 2); - // onOkPressed?.call("OK"); + if (desktopPopRootNavigator) { + Navigator.of(context, rootNavigator: true).pop(); + } else { + int count = 0; + Navigator.of(context).popUntil((_) => count++ >= 2); + // onOkPressed?.call("OK"); + } }, style: Theme.of(context) .extension()! diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index bb9965d23..e1af526f4 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -17,6 +17,7 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST coinlib_flutter flutter_libsparkmobile + frostdart tor_ffi_plugin ) diff --git a/pubspec.lock b/pubspec.lock index 423323325..7b9adb7c9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -825,6 +825,13 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" + frostdart: + dependency: "direct main" + description: + path: "crypto_plugins/frostdart" + relative: true + source: path + version: "0.0.1" fuchsia_remote_debug_protocol: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index b9b3a004e..58b5c7008 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,9 @@ dependencies: lelantus: path: ./crypto_plugins/flutter_liblelantus + frostdart: + path: ./crypto_plugins/frostdart + flutter_libsparkmobile: git: url: https://github.com/cypherstack/flutter_libsparkmobile.git diff --git a/scripts/android/build_all.sh b/scripts/android/build_all.sh index 3f499e3f9..f42a6c5d3 100755 --- a/scripts/android/build_all.sh +++ b/scripts/android/build_all.sh @@ -13,10 +13,8 @@ mkdir -p build (cd ../../crypto_plugins/flutter_liblelantus/scripts/android && ./build_all.sh ) & (cd ../../crypto_plugins/flutter_libepiccash/scripts/android && ./install_ndk.sh && ./build_opensll.sh && ./build_all.sh ) & (cd ../../crypto_plugins/flutter_libmonero/scripts/android/ && ./build_all.sh ) & +set_rust_to_1720 & +(cd ../../crypto_plugins/frostdart/scripts/android && ./build_all.sh ) & wait echo "Done building" - -# set rust (back) to a more recent stable release to allow stack wallet to build tor -set_rust_to_1720 - diff --git a/scripts/ios/build_all.sh b/scripts/ios/build_all.sh index dd6ad38ff..db806c3bb 100755 --- a/scripts/ios/build_all.sh +++ b/scripts/ios/build_all.sh @@ -17,13 +17,12 @@ rustup target add x86_64-apple-ios (cd ../../crypto_plugins/flutter_liblelantus/scripts/ios && ./build_all.sh ) & (cd ../../crypto_plugins/flutter_libepiccash/scripts/ios && ./build_all.sh ) & (cd ../../crypto_plugins/flutter_libmonero/scripts/ios/ && ./build_all.sh ) & +set_rust_to_1720 & +(cd ../../crypto_plugins/frostdart/scripts/ios && ./build_all.sh ) & wait echo "Done building" -# set rust (back) to a more recent stable release to allow stack wallet to build tor -set_rust_to_1720 - # ensure ios rust triples are there rustup target add aarch64-apple-ios rustup target add x86_64-apple-ios diff --git a/scripts/linux/build_all.sh b/scripts/linux/build_all.sh index 672668c13..2b6bd1ffd 100755 --- a/scripts/linux/build_all.sh +++ b/scripts/linux/build_all.sh @@ -15,10 +15,8 @@ mkdir -p build (cd ../../crypto_plugins/flutter_liblelantus/scripts/linux && ./build_all.sh ) & (cd ../../crypto_plugins/flutter_libepiccash/scripts/linux && ./build_all.sh ) & (cd ../../crypto_plugins/flutter_libmonero/scripts/linux && ./build_monero_all.sh && ./build_sharedfile.sh ) & +set_rust_to_1720 & +(cd ../../crypto_plugins/frostdart/scripts/linux && ./build_all.sh ) & wait echo "Done building" - -# set rust (back) to a more recent stable release to allow stack wallet to build tor -set_rust_to_1720 - diff --git a/scripts/macos/build_all.sh b/scripts/macos/build_all.sh index 0e086fc71..53d6f9bac 100755 --- a/scripts/macos/build_all.sh +++ b/scripts/macos/build_all.sh @@ -9,6 +9,8 @@ set_rust_to_1671 (cd ../../crypto_plugins/flutter_liblelantus/scripts/macos && ./build_all.sh ) & (cd ../../crypto_plugins/flutter_libepiccash/scripts/macos && ./build_all.sh ) & (cd ../../crypto_plugins/flutter_libmonero/scripts/macos/ && ./build_all.sh ) & +set_rust_to_1720 & +(cd ../../crypto_plugins/frostdart/scripts/macos && ./build_all.sh ) & wait echo "Done building" diff --git a/scripts/windows/build_all.sh b/scripts/windows/build_all.sh index ee3c1b558..1a585e276 100755 --- a/scripts/windows/build_all.sh +++ b/scripts/windows/build_all.sh @@ -10,9 +10,8 @@ mkdir -p build (cd ../../crypto_plugins/flutter_libepiccash/scripts/windows && ./build_all.sh ) & (cd ../../crypto_plugins/flutter_liblelantus/scripts/windows && ./build_all.sh ) & (cd ../../crypto_plugins/flutter_libmonero/scripts/windows && ./build_all.sh) & +set_rust_to_1720 & +(cd ../../crypto_plugins/frostdart/scripts/windows && ./build_all.sh ) & wait echo "Done building" - -# set rust (back) to a more recent stable release to allow stack wallet to build tor -set_rust_to_1720 diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index a774c684a..02d70698f 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -18,6 +18,7 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST coinlib_flutter flutter_libsparkmobile + frostdart tor_ffi_plugin )