From dd0fc6f369139c1e368f3ad519fec7e76af5b8c1 Mon Sep 17 00:00:00 2001 From: julian <julian@cypherstack.com> Date: Thu, 18 Jan 2024 13:33:50 -0600 Subject: [PATCH 01/57] refactor unnecessary provider watch --- .../transaction_views/tx_v2/transaction_v2_list.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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<TransactionsV2List> { late final StreamSubscription<List<TransactionV2>> _subscription; late final Query<TransactionV2> _query; + late final Coin coin; BorderRadius get _borderRadiusFirst { return BorderRadius.only( @@ -69,6 +71,7 @@ class _TransactionsV2ListState extends ConsumerState<TransactionsV2List> { @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<TransactionsV2List> { @override Widget build(BuildContext context) { - final coin = ref.watch(pWallets).getWallet(widget.walletId).info.coin; - return FutureBuilder( future: _query.findAll(), builder: (fbContext, AsyncSnapshot<List<TransactionV2>> snapshot) { From fbbd175d0f21b5c94a74ebb4725f0614de10559d Mon Sep 17 00:00:00 2001 From: julian <julian@cypherstack.com> Date: Thu, 18 Jan 2024 13:34:11 -0600 Subject: [PATCH 02/57] change wording on successful restore --- .../sub_widgets/restore_succeeded_dialog.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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<StackColors>()!.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, From 755cc049b095bd73e4543c17fa7c693065e5caba Mon Sep 17 00:00:00 2001 From: julian <julian@cypherstack.com> Date: Thu, 18 Jan 2024 14:02:41 -0600 Subject: [PATCH 03/57] add frostdart dependency --- .gitmodules | 3 +++ crypto_plugins/frostdart | 1 + linux/flutter/generated_plugins.cmake | 1 + pubspec.lock | 7 +++++++ pubspec.yaml | 3 +++ scripts/android/build_all.sh | 6 ++---- scripts/ios/build_all.sh | 5 ++--- scripts/linux/build_all.sh | 6 ++---- scripts/macos/build_all.sh | 2 ++ scripts/windows/build_all.sh | 5 ++--- windows/flutter/generated_plugins.cmake | 1 + 11 files changed, 26 insertions(+), 14 deletions(-) create mode 160000 crypto_plugins/frostdart diff --git a/.gitmodules b/.gitmodules index 7474c8a54..98bb17794 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "crypto_plugins/flutter_liblelantus"] path = crypto_plugins/flutter_liblelantus url = https://github.com/cypherstack/flutter_liblelantus.git +[submodule "crypto_plugins/frostdart"] + path = crypto_plugins/frostdart + url = https://www.github.com/cypherstack/frostdart diff --git a/crypto_plugins/frostdart b/crypto_plugins/frostdart new file mode 160000 index 000000000..2fa7e4666 --- /dev/null +++ b/crypto_plugins/frostdart @@ -0,0 +1 @@ +Subproject commit 2fa7e46669a023d270cad4552b5151b138738790 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 840efc472..fdb040a28 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -816,6 +816,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 abeb7a821..9a243905b 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 b67cd92a4..484fb7d03 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_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 ) From 85b66fd8493d963c4ee8b9b6514ae5b362df650b Mon Sep 17 00:00:00 2001 From: julian <julian@cypherstack.com> Date: Thu, 18 Jan 2024 17:47:06 -0600 Subject: [PATCH 04/57] WIP bitcoin frost wallet addition --- .../isar/models/blockchain_data/address.dart | 3 + .../models/blockchain_data/address.g.dart | 2 + .../transaction_fee_selection_sheet.dart | 5 +- .../add_edit_node_view.dart | 4 + .../manage_nodes_views/node_details_view.dart | 2 + .../wallet_view/sub_widgets/desktop_send.dart | 4 +- lib/services/notifications_service.dart | 3 +- lib/themes/color_theme.dart | 2 + lib/themes/stack_colors.dart | 2 + lib/utilities/amount/amount_unit.dart | 2 + lib/utilities/block_explorers.dart | 2 + lib/utilities/constants.dart | 14 + lib/utilities/default_nodes.dart | 2 + lib/utilities/enums/coin_enum.dart | 54 +- .../enums/derive_path_type_enum.dart | 2 + .../crypto_currency/coins/bitcoin.dart | 24 +- .../crypto_currency/coins/bitcoin_frost.dart | 65 ++ .../intermediate/private_key_currency.dart | 9 + .../isar/models/frost_wallet_info.dart | 38 + .../isar/models/frost_wallet_info.g.dart | 818 ++++++++++++++++++ lib/wallets/isar/models/wallet_info.g.dart | 2 + .../wallet/impl/bitcoin_frost_wallet.dart | 475 ++++++++++ lib/wallets/wallet/wallet.dart | 6 + .../electrumx_interface.dart | 9 +- lib/widgets/node_card.dart | 2 + lib/widgets/node_options_sheet.dart | 2 + 26 files changed, 1492 insertions(+), 61 deletions(-) create mode 100644 lib/wallets/crypto_currency/coins/bitcoin_frost.dart create mode 100644 lib/wallets/crypto_currency/intermediate/private_key_currency.dart create mode 100644 lib/wallets/isar/models/frost_wallet_info.dart create mode 100644 lib/wallets/isar/models/frost_wallet_info.g.dart create mode 100644 lib/wallets/wallet/impl/bitcoin_frost_wallet.dart 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/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 e03c3ab21..7dc743aab 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<AddEditNodeView> { case Coin.firo: case Coin.namecoin: case Coin.particl: + case Coin.bitcoinFrost: + case Coin.bitcoinFrostTestNet: case Coin.bitcoinTestNet: case Coin.litecoinTestNet: case Coin.bitcoincashTestnet: @@ -757,6 +759,8 @@ class _NodeFormState extends ConsumerState<NodeForm> { 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 6bf0092e8..3605a2815 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<NodeDetailsView> { 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_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<DesktopSend> { 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/services/notifications_service.dart b/lib/services/notifications_service.dart index 1512c12c6..c019768fc 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/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<StackColors> { 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/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..305183448 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: @@ -314,6 +300,7 @@ extension CoinExt on Coin { Coin get mainNetVersion { switch (this) { case Coin.bitcoin: + case Coin.bitcoinFrost: case Coin.litecoin: case Coin.bitcoincash: case Coin.dogecoin: @@ -337,6 +324,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 +354,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: 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..f968818e1 --- /dev/null +++ b/lib/wallets/crypto_currency/coins/bitcoin_frost.dart @@ -0,0 +1,65 @@ +import 'dart:typed_data'; + +import 'package:stackwallet/models/node_model.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 + 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..8cbf11b27 --- /dev/null +++ b/lib/wallets/crypto_currency/intermediate/private_key_currency.dart @@ -0,0 +1,9 @@ +import 'dart:typed_data'; + +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +abstract class FrostCurrency extends CryptoCurrency { + FrostCurrency(super.network); + + String pubKeyToScriptHash({required Uint8List pubKey}); +} 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..817f78f39 --- /dev/null +++ b/lib/wallets/isar/models/frost_wallet_info.dart @@ -0,0 +1,38 @@ +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<String> knownSalts; + + FrostWalletInfo({ + required this.walletId, + required this.knownSalts, + }); + + FrostWalletInfo copyWith({ + List<String>? knownSalts, + }) { + return FrostWalletInfo( + walletId: walletId, + knownSalts: knownSalts ?? this.knownSalts, + ); + } + + Future<void> updateKnownSalts( + List<String> knownSalts, { + required Isar isar, + }) async { + // await isar.writeTxn(() async { + // await isar. + // }) + } +} 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..ce5ae2aae --- /dev/null +++ b/lib/wallets/isar/models/frost_wallet_info.g.dart @@ -0,0 +1,818 @@ +// 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<FrostWalletInfo> 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'walletId': PropertySchema( + id: 1, + 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<int> offsets, + Map<Type, List<int>> 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.walletId.length * 3; + return bytesCount; +} + +void _frostWalletInfoSerialize( + FrostWalletInfo object, + IsarWriter writer, + List<int> offsets, + Map<Type, List<int>> allOffsets, +) { + writer.writeStringList(offsets[0], object.knownSalts); + writer.writeString(offsets[1], object.walletId); +} + +FrostWalletInfo _frostWalletInfoDeserialize( + Id id, + IsarReader reader, + List<int> offsets, + Map<Type, List<int>> allOffsets, +) { + final object = FrostWalletInfo( + knownSalts: reader.readStringList(offsets[0]) ?? [], + walletId: reader.readString(offsets[1]), + ); + object.id = id; + return object; +} + +P _frostWalletInfoDeserializeProp<P>( + IsarReader reader, + int propertyId, + int offset, + Map<Type, List<int>> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readStringList(offset) ?? []) as P; + case 1: + return (reader.readString(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _frostWalletInfoGetId(FrostWalletInfo object) { + return object.id; +} + +List<IsarLinkBase<dynamic>> _frostWalletInfoGetLinks(FrostWalletInfo object) { + return []; +} + +void _frostWalletInfoAttach( + IsarCollection<dynamic> col, Id id, FrostWalletInfo object) { + object.id = id; +} + +extension FrostWalletInfoByIndex on IsarCollection<FrostWalletInfo> { + Future<FrostWalletInfo?> getByWalletId(String walletId) { + return getByIndex(r'walletId', [walletId]); + } + + FrostWalletInfo? getByWalletIdSync(String walletId) { + return getByIndexSync(r'walletId', [walletId]); + } + + Future<bool> deleteByWalletId(String walletId) { + return deleteByIndex(r'walletId', [walletId]); + } + + bool deleteByWalletIdSync(String walletId) { + return deleteByIndexSync(r'walletId', [walletId]); + } + + Future<List<FrostWalletInfo?>> getAllByWalletId(List<String> walletIdValues) { + final values = walletIdValues.map((e) => [e]).toList(); + return getAllByIndex(r'walletId', values); + } + + List<FrostWalletInfo?> getAllByWalletIdSync(List<String> walletIdValues) { + final values = walletIdValues.map((e) => [e]).toList(); + return getAllByIndexSync(r'walletId', values); + } + + Future<int> deleteAllByWalletId(List<String> walletIdValues) { + final values = walletIdValues.map((e) => [e]).toList(); + return deleteAllByIndex(r'walletId', values); + } + + int deleteAllByWalletIdSync(List<String> walletIdValues) { + final values = walletIdValues.map((e) => [e]).toList(); + return deleteAllByIndexSync(r'walletId', values); + } + + Future<Id> putByWalletId(FrostWalletInfo object) { + return putByIndex(r'walletId', object); + } + + Id putByWalletIdSync(FrostWalletInfo object, {bool saveLinks = true}) { + return putByIndexSync(r'walletId', object, saveLinks: saveLinks); + } + + Future<List<Id>> putAllByWalletId(List<FrostWalletInfo> objects) { + return putAllByIndex(r'walletId', objects); + } + + List<Id> putAllByWalletIdSync(List<FrostWalletInfo> objects, + {bool saveLinks = true}) { + return putAllByIndexSync(r'walletId', objects, saveLinks: saveLinks); + } +} + +extension FrostWalletInfoQueryWhereSort + on QueryBuilder<FrostWalletInfo, FrostWalletInfo, QWhere> { + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterWhere> anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension FrostWalletInfoQueryWhere + on QueryBuilder<FrostWalletInfo, FrostWalletInfo, QWhereClause> { + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterWhereClause> idEqualTo( + Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: id, + upper: id, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterWhereClause> + 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<FrostWalletInfo, FrostWalletInfo, QAfterWhereClause> + idGreaterThan(Id id, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterWhereClause> idLessThan( + Id id, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterWhereClause> 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<FrostWalletInfo, FrostWalletInfo, QAfterWhereClause> + walletIdEqualTo(String walletId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IndexWhereClause.equalTo( + indexName: r'walletId', + value: [walletId], + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterWhereClause> + 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<FrostWalletInfo, FrostWalletInfo, QFilterCondition> { + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + idEqualTo(Id value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'id', + value: value, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + idGreaterThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + idLessThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + knownSaltsElementEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + knownSaltsElementStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + knownSaltsElementEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + knownSaltsElementContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + knownSaltsElementMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'knownSalts', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + knownSaltsElementIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'knownSalts', + value: '', + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + knownSaltsElementIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'knownSalts', + value: '', + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + knownSaltsLengthEqualTo(int length) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + length, + true, + length, + true, + ); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + knownSaltsIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + 0, + true, + 0, + true, + ); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + knownSaltsIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + 0, + false, + 999999, + true, + ); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + knownSaltsLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + 0, + true, + length, + include, + ); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + knownSaltsLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + length, + include, + 999999, + true, + ); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + walletIdEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + walletIdStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + walletIdEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + walletIdContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + walletIdMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'walletId', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + walletIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'walletId', + value: '', + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + walletIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'walletId', + value: '', + )); + }); + } +} + +extension FrostWalletInfoQueryObject + on QueryBuilder<FrostWalletInfo, FrostWalletInfo, QFilterCondition> {} + +extension FrostWalletInfoQueryLinks + on QueryBuilder<FrostWalletInfo, FrostWalletInfo, QFilterCondition> {} + +extension FrostWalletInfoQuerySortBy + on QueryBuilder<FrostWalletInfo, FrostWalletInfo, QSortBy> { + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> + sortByWalletId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.asc); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> + sortByWalletIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.desc); + }); + } +} + +extension FrostWalletInfoQuerySortThenBy + on QueryBuilder<FrostWalletInfo, FrostWalletInfo, QSortThenBy> { + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> + thenByWalletId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.asc); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> + thenByWalletIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.desc); + }); + } +} + +extension FrostWalletInfoQueryWhereDistinct + on QueryBuilder<FrostWalletInfo, FrostWalletInfo, QDistinct> { + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QDistinct> + distinctByKnownSalts() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'knownSalts'); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QDistinct> distinctByWalletId( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'walletId', caseSensitive: caseSensitive); + }); + } +} + +extension FrostWalletInfoQueryProperty + on QueryBuilder<FrostWalletInfo, FrostWalletInfo, QQueryProperty> { + QueryBuilder<FrostWalletInfo, int, QQueryOperations> idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder<FrostWalletInfo, List<String>, QQueryOperations> + knownSaltsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'knownSalts'); + }); + } + + QueryBuilder<FrostWalletInfo, String, QQueryOperations> 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/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart new file mode 100644 index 000000000..203ea5877 --- /dev/null +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -0,0 +1,475 @@ +import 'dart:async'; + +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/utxo.dart'; +import 'package:stackwallet/models/paymint/fee_object_model.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/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:stackwallet/wallets/wallet/wallet_mixin_interfaces/private_key_interface.dart'; + +class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> + with PrivateKeyInterface { + FrostWalletInfo get frostInfo => throw UnimplementedError(); + + late ElectrumXClient electrumXClient; + late CachedElectrumXClient electrumXCachedClient; + + @override + int get isarTransactionVersion => 2; + + BitcoinFrostWallet(CryptoCurrencyNetwork network) + : super(BitcoinFrost(network) as T); + + @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, + ), + ], + ); + + // Future<List<Address>> fetchAddressesForElectrumXScan() async { + // final allAddresses = await mainDB + // .getAddresses(walletId) + // .filter() + // .typeEqualTo(AddressType.frostMS) + // .and() + // .group( + // (q) => q + // .subTypeEqualTo(AddressSubType.receiving) + // .or() + // .subTypeEqualTo(AddressSubType.change), + // ) + // .findAll(); + // return allAddresses; + // } + + @override + Future<void> updateTransactions() { + // TODO: implement updateTransactions + throw UnimplementedError(); + } + + 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, + ); + } + + @override + Future<void> checkSaveInitialReceivingAddress() { + // TODO: implement checkSaveInitialReceivingAddress + throw UnimplementedError(); + } + + @override + Future<TxData> confirmSend({required TxData txData}) { + // TODO: implement confirmSend + throw UnimplementedError(); + } + + @override + Future<Amount> estimateFeeFor(Amount amount, int feeRate) { + // TODO: implement estimateFeeFor + throw UnimplementedError(); + } + + @override + // TODO: implement fees + Future<FeeObject> get fees => throw UnimplementedError(); + + @override + Future<TxData> prepareSend({required TxData txData}) { + // TODO: implement prepareSendpu + throw UnimplementedError(); + } + + @override + Future<void> recover({ + required bool isRescan, + String? serializedKeys, + String? multisigConfig, + }) async { + if (serializedKeys == null || multisigConfig == null) { + throw Exception( + "Failed to recover $runtimeType: " + "Missing serializedKeys and/or multisigConfig.", + ); + } + + try { + await refreshMutex.protect(() async { + if (!isRescan) { + final salt = frost + .multisigSalt( + multisigConfig: multisigConfig, + ) + .toHex; + final knownSalts = frostInfo.knownSalts; + if (knownSalts.contains(salt)) { + throw Exception("Known frost multisig salt found!"); + } + knownSalts.add(salt); + await frostInfo.updateKnownSalts(knownSalts, isar: mainDB.isar); + } + + 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]); + }); + + unawaited(refresh()); + } catch (e, s) { + Logging.instance.log( + "recoverFromSerializedKeys failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + @override + Future<void> 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<void> 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<bool> pingCheck() async { + try { + final result = await electrumXClient.ping(); + return result; + } catch (_) { + return false; + } + } + + @override + Future<void> updateNode() async { + await _updateElectrumX(); + } + + @override + Future<bool> 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<UTXO> 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<String?> get getSerializedKeys async => + await secureStorageInterface.read( + key: "{$walletId}_serializedFROSTKeys", + ); + Future<void> _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<String?> get getSerializedKeysPrevGen async => + await secureStorageInterface.read( + key: "{$walletId}_serializedFROSTKeysPrevGen", + ); + + Future<String?> get multisigConfig async => await secureStorageInterface.read( + key: "{$walletId}_multisigConfig", + ); + Future<String?> get multisigConfigPrevGen async => + await secureStorageInterface.read( + key: "{$walletId}_multisigConfigPrevGen", + ); + Future<void> _saveMultisigConfig(String multisigConfig) async { + final current = await this.multisigConfig; + + 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<Uint8List?> get multisigId async { + final id = await secureStorageInterface.read( + key: "{$walletId}_multisigIdFROST", + ); + if (id == null) { + return null; + } else { + return id.toUint8ListFromHex; + } + } + + Future<void> saveMultisigId(Uint8List id) async => + await secureStorageInterface.write( + key: "{$walletId}_multisigIdFROST", + value: id.toHex, + ); + + Future<String?> get recoveryString async => await secureStorageInterface.read( + key: "{$walletId}_recoveryStringFROST", + ); + Future<void> saveRecoveryString(String recoveryString) async => + await secureStorageInterface.write( + key: "{$walletId}_recoveryStringFROST", + value: recoveryString, + ); + + // =================== Private =============================================== + + Future<ElectrumXNode> _getCurrentElectrumXNode() async { + final node = getCurrentNode(); + + return ElectrumXNode( + address: node.host, + port: node.port, + name: node.name, + useSSL: node.useSSL, + id: node.id, + ); + } + + Future<void> _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(); + electrumXClient = ElectrumXClient.from( + node: newNode, + prefs: prefs, + failovers: failovers, + ); + electrumXCachedClient = CachedElectrumXClient.from( + electrumXClient: electrumXClient, + ); + } + + Future<UTXO> _parseUTXO({ + required Map<String, dynamic> 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 796760dbd..711a895a1 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -25,6 +25,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'; @@ -311,6 +312,11 @@ abstract class Wallet<T extends CryptoCurrency> { 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 1f425d498..2bb78e228 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -832,7 +832,7 @@ mixin ElectrumXInterface<T extends Bip39HDCurrency> on Bip39HDWallet<T> { } } - Future<ElectrumXNode> getCurrentElectrumXNode() async { + Future<ElectrumXNode> _getCurrentElectrumXNode() async { final node = getCurrentNode(); return ElectrumXNode( @@ -844,7 +844,7 @@ mixin ElectrumXInterface<T extends Bip39HDCurrency> on Bip39HDWallet<T> { ); } - Future<void> updateElectrumX({required ElectrumXNode newNode}) async { + Future<void> updateElectrumX() async { final failovers = nodeService .failoverNodesFor(coin: cryptoCurrency.coin) .map((e) => ElectrumXNode( @@ -856,7 +856,7 @@ mixin ElectrumXInterface<T extends Bip39HDCurrency> on Bip39HDWallet<T> { )) .toList(); - final newNode = await getCurrentElectrumXNode(); + final newNode = await _getCurrentElectrumXNode(); electrumXClient = ElectrumXClient.from( node: newNode, prefs: prefs, @@ -1160,8 +1160,7 @@ mixin ElectrumXInterface<T extends Bip39HDCurrency> on Bip39HDWallet<T> { @override Future<void> updateNode() async { - final node = await getCurrentElectrumXNode(); - await updateElectrumX(newNode: node); + await updateElectrumX(); } FeeObject? _cachedFees; diff --git a/lib/widgets/node_card.dart b/lib/widgets/node_card.dart index 7576999de..0e801490f 100644 --- a/lib/widgets/node_card.dart +++ b/lib/widgets/node_card.dart @@ -169,6 +169,8 @@ class _NodeCardState extends ConsumerState<NodeCard> { 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 c14b6a2ec..31fd13c30 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, From 8ae2faa91ff255c8860228a846beb0b4fa262cc9 Mon Sep 17 00:00:00 2001 From: julian <julian@cypherstack.com> Date: Fri, 19 Jan 2024 15:42:38 -0600 Subject: [PATCH 05/57] WIP frost wallet logic --- lib/services/frost.dart | 613 ++++++++++++++ .../crypto_currency/coins/bitcoin_frost.dart | 7 + .../intermediate/private_key_currency.dart | 3 + .../isar/models/frost_wallet_info.dart | 23 +- .../isar/models/frost_wallet_info.g.dart | 552 ++++++++++++- .../wallet/impl/bitcoin_frost_wallet.dart | 768 ++++++++++++++++-- lib/wallets/wallet/wallet.dart | 2 +- 7 files changed, 1889 insertions(+), 79 deletions(-) create mode 100644 lib/services/frost.dart 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<String> getParticipants({ + required String multisigConfig, + }) { + try { + final numberOfParticipants = multisigParticipants( + multisigConfig: multisigConfig, + ); + + final List<String> 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<Output> 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<Output> 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<String> 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<MultisigConfigWithName> multisigConfigWithNamePtr, + Pointer<SecretShareMachineWrapper> 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<SecretSharesRes> secretSharesResPtr, + }) generateSecretShares({ + required Pointer<MultisigConfigWithName> multisigConfigWithNamePtr, + required String mySeed, + required Pointer<SecretShareMachineWrapper> secretShareMachineWrapperPtr, + required List<String> 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<MultisigConfigWithName> multisigConfigWithNamePtr, + required Pointer<SecretSharesRes> secretSharesResPtr, + required List<String> shares, + }) { + try { + final keyGenResPtr = completeKeyGen( + multisigConfigWithName: multisigConfigWithNamePtr, + machineAndCommitments: secretSharesResPtr, + shares: shares, + ); + + final id = Uint8List.fromList( + List<int>.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<TransactionSignMachineWrapper> 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<TransactionSignatureMachineWrapper> machinePtr, + String share, + }) continueSigning({ + required Pointer<TransactionSignMachineWrapper> machinePtr, + required List<String> 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<TransactionSignatureMachineWrapper> machinePtr, + required List<String> 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<SignConfig> 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<int> resharers, + required List<String> 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<StartResharerRes> 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<StartResharedRes> prior, + }) beginReshared({ + required String myName, + required String resharerConfig, + required List<String> 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<String> 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<String> 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<ResharerConfig> 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<int> resharers, + List<String> newParticipants, + }) extractResharerConfigData({ + required String resharerConfig, + }) { + try { + final newThreshold = resharerNewThreshold( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + ); + + final resharersCount = resharerResharers( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + ); + final List<int> 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<String> 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/wallets/crypto_currency/coins/bitcoin_frost.dart b/lib/wallets/crypto_currency/coins/bitcoin_frost.dart index f968818e1..b82d3987c 100644 --- a/lib/wallets/crypto_currency/coins/bitcoin_frost.dart +++ b/lib/wallets/crypto_currency/coins/bitcoin_frost.dart @@ -1,6 +1,7 @@ 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'; @@ -48,6 +49,12 @@ class BitcoinFrost extends FrostCurrency { } } + @override + Amount get dustLimit => Amount( + rawValue: BigInt.from(294), + fractionDigits: fractionDigits, + ); + @override String pubKeyToScriptHash({required Uint8List pubKey}) { try { diff --git a/lib/wallets/crypto_currency/intermediate/private_key_currency.dart b/lib/wallets/crypto_currency/intermediate/private_key_currency.dart index 8cbf11b27..0c10937fa 100644 --- a/lib/wallets/crypto_currency/intermediate/private_key_currency.dart +++ b/lib/wallets/crypto_currency/intermediate/private_key_currency.dart @@ -1,9 +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 index 817f78f39..b5c7476d2 100644 --- a/lib/wallets/isar/models/frost_wallet_info.dart +++ b/lib/wallets/isar/models/frost_wallet_info.dart @@ -12,27 +12,30 @@ class FrostWalletInfo implements IsarId { final String walletId; final List<String> knownSalts; + final List<String> 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<String>? knownSalts, + List<String>? participants, + String? myName, + int? threshold, }) { return FrostWalletInfo( walletId: walletId, knownSalts: knownSalts ?? this.knownSalts, - ); - } - - Future<void> updateKnownSalts( - List<String> knownSalts, { - required Isar isar, - }) async { - // await isar.writeTxn(() async { - // await isar. - // }) + 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 index ce5ae2aae..6c80125e2 100644 --- a/lib/wallets/isar/models/frost_wallet_info.g.dart +++ b/lib/wallets/isar/models/frost_wallet_info.g.dart @@ -22,8 +22,23 @@ const FrostWalletInfoSchema = CollectionSchema( name: r'knownSalts', type: IsarType.stringList, ), - r'walletId': PropertySchema( + 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, ) @@ -69,6 +84,14 @@ int _frostWalletInfoEstimateSize( 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; } @@ -80,7 +103,10 @@ void _frostWalletInfoSerialize( Map<Type, List<int>> allOffsets, ) { writer.writeStringList(offsets[0], object.knownSalts); - writer.writeString(offsets[1], object.walletId); + 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( @@ -91,7 +117,10 @@ FrostWalletInfo _frostWalletInfoDeserialize( ) { final object = FrostWalletInfo( knownSalts: reader.readStringList(offsets[0]) ?? [], - walletId: reader.readString(offsets[1]), + 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; @@ -108,6 +137,12 @@ P _frostWalletInfoDeserializeProp<P>( 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'); } @@ -589,6 +624,423 @@ extension FrostWalletInfoQueryFilter }); } + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + myNameEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + myNameStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + myNameEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + myNameContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + myNameMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'myName', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + myNameIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'myName', + value: '', + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + myNameIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'myName', + value: '', + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + participantsElementEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + participantsElementStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + participantsElementEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + participantsElementContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + participantsElementMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'participants', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + participantsElementIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'participants', + value: '', + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + participantsElementIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'participants', + value: '', + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + participantsLengthEqualTo(int length) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + length, + true, + length, + true, + ); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + participantsIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + 0, + true, + 0, + true, + ); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + participantsIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + 0, + false, + 999999, + true, + ); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + participantsLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + 0, + true, + length, + include, + ); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + participantsLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + length, + include, + 999999, + true, + ); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + thresholdEqualTo(int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'threshold', + value: value, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + thresholdGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'threshold', + value: value, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + thresholdLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'threshold', + value: value, + )); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> + 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<FrostWalletInfo, FrostWalletInfo, QAfterFilterCondition> walletIdEqualTo( String value, { @@ -734,6 +1186,33 @@ extension FrostWalletInfoQueryLinks extension FrostWalletInfoQuerySortBy on QueryBuilder<FrostWalletInfo, FrostWalletInfo, QSortBy> { + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> sortByMyName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.asc); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> + sortByMyNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.desc); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> + sortByThreshold() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.asc); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> + sortByThresholdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.desc); + }); + } + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> sortByWalletId() { return QueryBuilder.apply(this, (query) { @@ -763,6 +1242,33 @@ extension FrostWalletInfoQuerySortThenBy }); } + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> thenByMyName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.asc); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> + thenByMyNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.desc); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> + thenByThreshold() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.asc); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> + thenByThresholdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.desc); + }); + } + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QAfterSortBy> thenByWalletId() { return QueryBuilder.apply(this, (query) { @@ -787,6 +1293,27 @@ extension FrostWalletInfoQueryWhereDistinct }); } + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QDistinct> distinctByMyName( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'myName', caseSensitive: caseSensitive); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QDistinct> + distinctByParticipants() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'participants'); + }); + } + + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QDistinct> + distinctByThreshold() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'threshold'); + }); + } + QueryBuilder<FrostWalletInfo, FrostWalletInfo, QDistinct> distinctByWalletId( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -810,6 +1337,25 @@ extension FrostWalletInfoQueryProperty }); } + QueryBuilder<FrostWalletInfo, String, QQueryOperations> myNameProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'myName'); + }); + } + + QueryBuilder<FrostWalletInfo, List<String>, QQueryOperations> + participantsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'participants'); + }); + } + + QueryBuilder<FrostWalletInfo, int, QQueryOperations> thresholdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'threshold'); + }); + } + QueryBuilder<FrostWalletInfo, String, QQueryOperations> walletIdProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'walletId'); diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index 203ea5877..102beb1e3 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ffi'; import 'package:flutter/foundation.dart'; import 'package:frostdart/frostdart.dart' as frost; @@ -8,8 +9,15 @@ 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/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/extensions/extensions.dart'; import 'package:stackwallet/utilities/logger.dart'; @@ -19,21 +27,275 @@ import 'package:stackwallet/wallets/crypto_currency/intermediate/private_key_cur 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:stackwallet/wallets/wallet/wallet_mixin_interfaces/private_key_interface.dart'; -class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> - with PrivateKeyInterface { - FrostWalletInfo get frostInfo => throw UnimplementedError(); +class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { + BitcoinFrostWallet(CryptoCurrencyNetwork network) + : super(BitcoinFrost(network) as T); + + FrostWalletInfo get frostInfo => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .findFirstSync()!; late ElectrumXClient electrumXClient; late CachedElectrumXClient electrumXCachedClient; + Future<void> initializeNewFrost({ + required String mnemonic, + required String multisigConfig, + required String recoveryString, + required String serializedKeys, + required Uint8List multisigId, + required String myName, + required List<String> 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.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<TxData> 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<UTXO> 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<TransactionSignMachineWrapper> 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<void> 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<Amount> 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 int get isarTransactionVersion => 2; - BitcoinFrostWallet(CryptoCurrencyNetwork network) - : super(BitcoinFrost(network) as T); - @override FilterOperation? get changeAddressFilterOperation => FilterGroup.and( [ @@ -62,62 +324,321 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> ], ); - // Future<List<Address>> fetchAddressesForElectrumXScan() async { - // final allAddresses = await mainDB - // .getAddresses(walletId) - // .filter() - // .typeEqualTo(AddressType.frostMS) - // .and() - // .group( - // (q) => q - // .subTypeEqualTo(AddressSubType.receiving) - // .or() - // .subTypeEqualTo(AddressSubType.change), - // ) - // .findAll(); - // return allAddresses; - // } + @override + Future<void> 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<Map<String, dynamic>> 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<TransactionV2> 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<InputV2> inputs = []; + for (final jsonInput in txData["vin"] as List) { + final map = Map<String, dynamic>.from(jsonInput as Map); + + final List<String> 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<String, dynamic>.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<OutputV2> outputs = []; + for (final outputJson in txData["vout"] as List) { + OutputV2 output = OutputV2.fromElectrumXJson( + Map<String, dynamic>.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<String>? 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<void> updateTransactions() { - // TODO: implement updateTransactions - throw UnimplementedError(); + Future<void> checkSaveInitialReceivingAddress() async { + // should not be needed for frost as we explicitly save the address + // on new init and restore } - int estimateTxFee({required int vSize, required int feeRatePerKB}) { - return vSize * (feeRatePerKB / 1000).ceil(); + @override + Future<TxData> 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; + } } - Amount roughFeeEstimate(int inputCount, int outputCount, int feeRatePerKB) { - return Amount( - rawValue: BigInt.from( - ((42 + (272 * inputCount) + (128 * outputCount)) / 4).ceil() * - (feeRatePerKB / 1000).ceil()), + @override + Future<Amount> 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<void> checkSaveInitialReceivingAddress() { - // TODO: implement checkSaveInitialReceivingAddress - throw UnimplementedError(); - } + Future<FeeObject> get fees async { + try { + // adjust numbers for different speeds? + const int f = 1, m = 5, s = 20; - @override - Future<TxData> confirmSend({required TxData txData}) { - // TODO: implement confirmSend - throw UnimplementedError(); - } + final fast = await electrumXClient.estimateFee(blocks: f); + final medium = await electrumXClient.estimateFee(blocks: m); + final slow = await electrumXClient.estimateFee(blocks: s); - @override - Future<Amount> estimateFeeFor(Amount amount, int feeRate) { - // TODO: implement estimateFeeFor - throw UnimplementedError(); - } + 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(), + ); - @override - // TODO: implement fees - Future<FeeObject> get fees => throw UnimplementedError(); + 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<TxData> prepareSend({required TxData txData}) { @@ -138,6 +659,16 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> ); } + final coin = info.coin; + + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.syncing, + walletId, + coin, + ), + ); + try { await refreshMutex.protect(() async { if (!isRescan) { @@ -146,12 +677,16 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> multisigConfig: multisigConfig, ) .toHex; - final knownSalts = frostInfo.knownSalts; + final knownSalts = _getKnownSalts(); if (knownSalts.contains(salt)) { throw Exception("Known frost multisig salt found!"); } knownSalts.add(salt); - await frostInfo.updateKnownSalts(knownSalts, isar: mainDB.isar); + await _updateKnownSalts(knownSalts); + } else { + // clear cache + await electrumXCachedClient.clearSharedTransactionCache(coin: coin); + await mainDB.deleteWalletBlockchainData(walletId); } final keys = frost.deserializeKeys(keys: serializedKeys); @@ -180,12 +715,27 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> 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; } } @@ -309,12 +859,15 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> // =================== Secure storage ======================================== - Future<String?> get getSerializedKeys async => + Future<String?> _getSerializedKeys() async => await secureStorageInterface.read( key: "{$walletId}_serializedFROSTKeys", ); - Future<void> _saveSerializedKeys(String keys) async { - final current = await getSerializedKeys; + + Future<void> _saveSerializedKeys( + String keys, + ) async { + final current = await _getSerializedKeys(); if (current == null) { // do nothing @@ -334,20 +887,24 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> ); } - Future<String?> get getSerializedKeysPrevGen async => + Future<String?> _getSerializedKeysPrevGen() async => await secureStorageInterface.read( key: "{$walletId}_serializedFROSTKeysPrevGen", ); - Future<String?> get multisigConfig async => await secureStorageInterface.read( + Future<String?> _multisigConfig() async => await secureStorageInterface.read( key: "{$walletId}_multisigConfig", ); - Future<String?> get multisigConfigPrevGen async => + + Future<String?> _multisigConfigPrevGen() async => await secureStorageInterface.read( key: "{$walletId}_multisigConfigPrevGen", ); - Future<void> _saveMultisigConfig(String multisigConfig) async { - final current = await this.multisigConfig; + + Future<void> _saveMultisigConfig( + String multisigConfig, + ) async { + final current = await _multisigConfig(); if (current == null) { // do nothing @@ -367,7 +924,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> ); } - Future<Uint8List?> get multisigId async { + Future<Uint8List?> _multisigId() async { final id = await secureStorageInterface.read( key: "{$walletId}_multisigIdFROST", ); @@ -378,21 +935,92 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> } } - Future<void> saveMultisigId(Uint8List id) async => + Future<void> _saveMultisigId( + Uint8List id, + ) async => await secureStorageInterface.write( key: "{$walletId}_multisigIdFROST", value: id.toHex, ); - Future<String?> get recoveryString async => await secureStorageInterface.read( + Future<String?> _recoveryString() async => await secureStorageInterface.read( key: "{$walletId}_recoveryStringFROST", ); - Future<void> saveRecoveryString(String recoveryString) async => + + Future<void> _saveRecoveryString( + String recoveryString, + ) async => await secureStorageInterface.write( key: "{$walletId}_recoveryStringFROST", value: recoveryString, ); + // =================== DB ==================================================== + + List<String> _getKnownSalts() => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .knownSaltsProperty() + .findFirstSync()!; + + Future<void> _updateKnownSalts(List<String> knownSalts) async { + await mainDB.isar.writeTxn(() async { + final info = frostInfo; + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(knownSalts: knownSalts), + ); + }); + } + + List<String> _getParticipants() => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .participantsProperty() + .findFirstSync()!; + + Future<void> _updateParticipants(List<String> 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<void> _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<void> _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<ElectrumXNode> _getCurrentElectrumXNode() async { @@ -430,6 +1058,16 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> ); } + bool _duplicateTxCheck( + List<Map<String, dynamic>> allTransactions, String txid) { + for (int i = 0; i < allTransactions.length; i++) { + if (allTransactions[i]["txid"] == txid) { + return true; + } + } + return false; + } + Future<UTXO> _parseUTXO({ required Map<String, dynamic> jsonUTXO, }) async { @@ -443,12 +1081,12 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> final outputs = txn["vout"] as List; - String? scriptPubKey; + // String? scriptPubKey; String? utxoOwnerAddress; // get UTXO owner address for (final output in outputs) { if (output["n"] == vout) { - scriptPubKey = output["scriptPubKey"]?["hex"] as String?; + // scriptPubKey = output["scriptPubKey"]?["hex"] as String?; utxoOwnerAddress = output["scriptPubKey"]?["addresses"]?[0] as String? ?? output["scriptPubKey"]?["address"] as String?; diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 711a895a1..959b7b635 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -289,7 +289,7 @@ abstract class Wallet<T extends CryptoCurrency> { wallet.prefs = prefs; wallet.nodeService = nodeService; - if (wallet is ElectrumXInterface) { + if (wallet is ElectrumXInterface || wallet is BitcoinFrostWallet) { // initialize electrumx instance await wallet.updateNode(); } From 6a7ec2d5d26e26ad199dcc56400794a9c04d17da Mon Sep 17 00:00:00 2001 From: julian <julian@cypherstack.com> Date: Fri, 19 Jan 2024 17:44:01 -0600 Subject: [PATCH 06/57] untested: Bitcoin frost --- crypto_plugins/frostdart | 2 +- ...irm_new_frost_ms_wallet_creation_view.dart | 343 +++++++++++++ .../new/create_new_frost_ms_wallet_view.dart | 285 +++++++++++ .../new/frost_share_commitments_view.dart | 443 ++++++++++++++++ .../frost_ms/new/frost_share_shares_view.dart | 409 +++++++++++++++ .../new/import_new_frost_ms_wallet_view.dart | 386 ++++++++++++++ .../new/share_new_multisig_config_view.dart | 162 ++++++ .../restore/restore_frost_ms_wallet_view.dart | 478 ++++++++++++++++++ .../name_your_wallet_view.dart | 216 +++++--- .../frost_ms/frost_ms_options_view.dart | 162 ++++++ .../frost_ms/frost_participants_view.dart | 119 +++++ .../resharing/finish_resharing_view.dart | 433 ++++++++++++++++ .../step_1a/begin_reshare_config_view.dart | 196 +++++++ .../step_1a/complete_reshare_config_view.dart | 335 ++++++++++++ .../step_1a/display_reshare_config_view.dart | 214 ++++++++ .../step_1b/import_reshare_config_view.dart | 338 +++++++++++++ .../involved/step_2/begin_resharing_view.dart | 439 ++++++++++++++++ .../step_2/continue_resharing_view.dart | 429 ++++++++++++++++ .../new/new_continue_sharing_view.dart | 204 ++++++++ .../new/new_import_resharer_config_view.dart | 426 ++++++++++++++++ .../new/new_start_resharing_view.dart | 379 ++++++++++++++ .../resharing/verify_updated_wallet_view.dart | 315 ++++++++++++ .../frost_wallet/frost_wallet_providers.dart | 103 ++++ lib/route_generator.dart | 333 ++++++++++++ lib/themes/coin_card_provider.dart | 14 + lib/themes/coin_icon_provider.dart | 7 + lib/themes/coin_image_provider.dart | 14 + lib/utilities/enums/coin_enum.dart | 20 + .../models/incomplete_frost_wallet.dart | 42 ++ .../wallet/impl/bitcoin_frost_wallet.dart | 8 +- lib/widgets/detail_item.dart | 90 ++++ .../dialogs/frost_interruption_dialog.dart | 70 +++ lib/widgets/stack_dialog.dart | 12 +- 33 files changed, 7349 insertions(+), 77 deletions(-) create mode 100644 lib/pages/add_wallet_views/frost_ms/new/confirm_new_frost_ms_wallet_creation_view.dart create mode 100644 lib/pages/add_wallet_views/frost_ms/new/create_new_frost_ms_wallet_view.dart create mode 100644 lib/pages/add_wallet_views/frost_ms/new/frost_share_commitments_view.dart create mode 100644 lib/pages/add_wallet_views/frost_ms/new/frost_share_shares_view.dart create mode 100644 lib/pages/add_wallet_views/frost_ms/new/import_new_frost_ms_wallet_view.dart create mode 100644 lib/pages/add_wallet_views/frost_ms/new/share_new_multisig_config_view.dart create mode 100644 lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart create mode 100644 lib/pages/settings_views/wallet_settings_view/frost_ms/frost_ms_options_view.dart create mode 100644 lib/pages/settings_views/wallet_settings_view/frost_ms/frost_participants_view.dart create mode 100644 lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/finish_resharing_view.dart create mode 100644 lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/begin_reshare_config_view.dart create mode 100644 lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/complete_reshare_config_view.dart create mode 100644 lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/display_reshare_config_view.dart create mode 100644 lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1b/import_reshare_config_view.dart create mode 100644 lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/begin_resharing_view.dart create mode 100644 lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/continue_resharing_view.dart create mode 100644 lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_continue_sharing_view.dart create mode 100644 lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_import_resharer_config_view.dart create mode 100644 lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_start_resharing_view.dart create mode 100644 lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/verify_updated_wallet_view.dart create mode 100644 lib/providers/frost_wallet/frost_wallet_providers.dart create mode 100644 lib/wallets/models/incomplete_frost_wallet.dart create mode 100644 lib/widgets/detail_item.dart create mode 100644 lib/widgets/dialogs/frost_interruption_dialog.dart diff --git a/crypto_plugins/frostdart b/crypto_plugins/frostdart index 2fa7e4666..0fbc038a2 160000 --- a/crypto_plugins/frostdart +++ b/crypto_plugins/frostdart @@ -1 +1 @@ -Subproject commit 2fa7e46669a023d270cad4552b5151b138738790 +Subproject commit 0fbc038a262e3c2d82c7c6e34e194e9a47011d91 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<ConfirmNewFrostMSWalletCreationView> createState() => + _ConfirmNewFrostMSWalletCreationViewState(); +} + +class _ConfirmNewFrostMSWalletCreationViewState + extends ConsumerState<ConfirmNewFrostMSWalletCreationView> { + 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<void>( + 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<StackColors>()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + 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<dynamic>( + 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<CreateNewFrostMsWalletView> createState() => + _NewFrostMsWalletViewState(); +} + +class _NewFrostMsWalletViewState + extends ConsumerState<CreateNewFrostMsWalletView> { + final _thresholdController = TextEditingController(); + final _participantsController = TextEditingController(); + + final List<TextEditingController> 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<StackColors>()!.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<StackColors>()!.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<void>( + 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<FrostShareCommitmentsView> createState() => + _FrostShareCommitmentsViewState(); +} + +class _FrostShareCommitmentsViewState + extends ConsumerState<FrostShareCommitmentsView> { + final List<TextEditingController> controllers = []; + final List<FocusNode> focusNodes = []; + + late final List<String> participants; + late final String myCommitment; + late final int myIndex; + + final List<bool> 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<void>( + 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<StackColors>()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()! + .background, + foregroundColor: Theme.of(context) + .extension<StackColors>()! + .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<void>.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<void>( + 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<void>( + 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<FrostShareSharesView> createState() => + _FrostShareSharesViewState(); +} + +class _FrostShareSharesViewState extends ConsumerState<FrostShareSharesView> { + final List<TextEditingController> controllers = []; + final List<FocusNode> focusNodes = []; + + late final List<String> participants; + late final String myShare; + late final int myIndex; + + final List<bool> 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<void>( + 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<StackColors>()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()! + .background, + foregroundColor: Theme.of(context) + .extension<StackColors>()! + .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<void>.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<void>( + 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<void>( + 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<ImportNewFrostMsWalletView> createState() => + _ImportNewFrostMsWalletViewState(); +} + +class _ImportNewFrostMsWalletViewState + extends ConsumerState<ImportNewFrostMsWalletView> { + 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<StackColors>()!.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<StackColors>()!.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<void>.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<void>( + context: context, + builder: (_) => StackOkDialog( + title: "Invalid config", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + if (!Frost.getParticipants(multisigConfig: config) + .contains(myNameFieldController.text)) { + return await showDialog<void>( + 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<ShareNewMultisigConfigView> createState() => + _ShareNewMultisigConfigViewState(); +} + +class _ShareNewMultisigConfigViewState + extends ConsumerState<ShareNewMultisigConfigView> { + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension<StackColors>()!.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<StackColors>()!.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<StackColors>()!.background, + foregroundColor: Theme.of(context) + .extension<StackColors>()! + .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..e99655f06 --- /dev/null +++ b/lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart @@ -0,0 +1,478 @@ +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'; +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<RestoreFrostMsWalletView> createState() => + _RestoreFrostMsWalletViewState(); +} + +class _RestoreFrostMsWalletViewState + extends ConsumerState<RestoreFrostMsWalletView> { + late final TextEditingController keysFieldController, configFieldController; + late final FocusNode keysFocusNode, configFocusNode; + + bool _keysEmpty = true, _configEmpty = true; + + bool _restoreButtonLock = false; + + Future<Wallet> _createWalletAndRecover() async { + final keys = keysFieldController.text; + final config = configFieldController.text; + + final myNameIndex = 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: multisigThreshold( + multisigConfig: config, + ), + ); + + 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<void> _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<void>( + 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<StackColors>()!.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<StackColors>()!.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<void>.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..ed91d265e 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<NameYourWalletView> { return name; } + Future<void> _nextPressed() async { + final name = textEditingController.text; + + if (mounted) { + // hide keyboard if has focus + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future<void>.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<NameYourWalletView> { 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.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<void>.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<StackColors>()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension<StackColors>()! - .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<StackColors>()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension<StackColors>()! + .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/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..169a96b64 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/frost_ms_options_view.dart @@ -0,0 +1,162 @@ +/* + * 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/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/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.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 Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension<StackColors>()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Multisig settings", + style: STextStyles.navBarTitle(context), + ), + ), + body: 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<StackColors>()!.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<StackColors>()!.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<StackColors>()!.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<FinishResharingView> createState() => + _FinishResharingViewState(); +} + +class _FinishResharingViewState extends ConsumerState<FinishResharingView> { + final List<TextEditingController> controllers = []; + final List<FocusNode> focusNodes = []; + + late final List<int> resharerIndexes; + late final String myName; + late final int? myResharerIndexIndex; + late final String? myResharerComplete; + late final bool amOutgoingParticipant; + + final List<bool> fieldIsEmptyFlags = []; + + bool _buttonLock = false; + Future<void> _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<void>( + 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<StackColors>()!.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<StackColors>()!.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<StackColors>()! + .background, + foregroundColor: Theme.of(context) + .extension<StackColors>()! + .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<void>.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<BeginReshareConfigView> createState() => + _BeginReshareConfigViewState(); +} + +class _BeginReshareConfigViewState + extends ConsumerState<BeginReshareConfigView> { + late final int currentThreshold; + late final List<String> currentParticipants; + + final Map<String, int> 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<StackColors>()!.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<StackColors>()!.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..0e2e1e111 --- /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<int> resharers; + + @override + ConsumerState<CompleteReshareConfigView> createState() => + _CompleteReshareConfigViewState(); +} + +class _CompleteReshareConfigViewState + extends ConsumerState<CompleteReshareConfigView> { + final _newThresholdController = TextEditingController(); + final _newParticipantsCountController = TextEditingController(); + + final List<TextEditingController> controllers = []; + + int _participantsCount = 0; + + bool _buttonLock = false; + + Future<void> _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<void>( + 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<void>( + context: context, + builder: (_) => StackOkDialog( + title: "Duplicate config salt", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } 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), + ); + }); + } + + 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<void>( + 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<StackColors>()!.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<StackColors>()!.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<DisplayReshareConfigView> createState() => + _DisplayReshareConfigViewState(); +} + +class _DisplayReshareConfigViewState + extends ConsumerState<DisplayReshareConfigView> { + late final bool iAmInvolved; + + bool _buttonLock = false; + + Future<void> _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<void>( + 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<StackColors>()!.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<StackColors>()!.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<StackColors>()!.background, + foregroundColor: Theme.of(context) + .extension<StackColors>()! + .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<ImportReshareConfigView> createState() => + _ImportReshareConfigViewState(); +} + +class _ImportReshareConfigViewState + extends ConsumerState<ImportReshareConfigView> { + late final TextEditingController configFieldController; + late final FocusNode configFocusNode; + + bool _configEmpty = true; + + bool _buttonLock = false; + + Future<void> _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<void>( + 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<StackColors>()!.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<StackColors>()!.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<void>.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<BeginResharingView> createState() => _BeginResharingViewState(); +} + +class _BeginResharingViewState extends ConsumerState<BeginResharingView> { + final List<TextEditingController> controllers = []; + final List<FocusNode> focusNodes = []; + + late final List<int> resharerIndexes; + late final int myResharerIndexIndex; + late final String myResharerStart; + late final bool amOutgoingParticipant; + + final List<bool> fieldIsEmptyFlags = []; + + bool _buttonLock = false; + + Future<void> _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<void>( + 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<void>( + 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<StackColors>()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()! + .background, + foregroundColor: Theme.of(context) + .extension<StackColors>()! + .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<void>.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<ContinueResharingView> createState() => + _ContinueResharingViewState(); +} + +class _ContinueResharingViewState extends ConsumerState<ContinueResharingView> { + final List<TextEditingController> controllers = []; + final List<FocusNode> focusNodes = []; + + late final List<String> newParticipants; + late final int myIndex; + late final String? myEncryptionKey; + late final bool amOutgoingParticipant; + + final List<bool> fieldIsEmptyFlags = []; + + bool _buttonLock = false; + Future<void> _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<void>( + 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<void>( + 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<StackColors>()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()! + .background, + foregroundColor: Theme.of(context) + .extension<StackColors>()! + .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<void>.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<NewContinueSharingView> createState() => + _NewContinueSharingViewState(); +} + +class _NewContinueSharingViewState + extends ConsumerState<NewContinueSharingView> { + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog<void>( + 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<StackColors>()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()! + .background, + foregroundColor: Theme.of(context) + .extension<StackColors>()! + .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<NewImportResharerConfigView> createState() => + _NewImportResharerConfigViewState(); +} + +class _NewImportResharerConfigViewState + extends ConsumerState<NewImportResharerConfigView> { + late final TextEditingController myNameFieldController, configFieldController; + late final FocusNode myNameFocusNode, configFocusNode; + + bool _nameEmpty = true, _configEmpty = true; + + bool _buttonLock = false; + + Future<IncompleteFrostWallet> _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<StackColors>()!.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<StackColors>()!.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<void>.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<void>( + 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<void>( + 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<NewStartResharingView> createState() => + _NewStartResharingViewState(); +} + +class _NewStartResharingViewState extends ConsumerState<NewStartResharingView> { + final List<TextEditingController> controllers = []; + final List<FocusNode> focusNodes = []; + + late final List<int> resharerIndexes; + + final List<bool> fieldIsEmptyFlags = []; + + bool _buttonLock = false; + Future<void> _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<void>( + 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<void>( + 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<StackColors>()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + 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<void>.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<VerifyUpdatedWalletView> createState() => + _VerifyUpdatedWalletViewState(); +} + +class _VerifyUpdatedWalletViewState + extends ConsumerState<VerifyUpdatedWalletView> { + late final String config; + late final String serializedKeys; + late final String reshareId; + + late final bool isNew; + + bool _buttonLock = false; + Future<void> _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<void>( + 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<void>( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: _popUntilPath, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension<StackColors>()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: _popUntilPath, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + 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/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<String?>((ref) => null); +final pFrostMyName = StateProvider<String?>((ref) => null); + +final pFrostStartKeyGenData = StateProvider< + ({ + String seed, + String commitments, + Pointer<MultisigConfigWithName> multisigConfigWithNamePtr, + Pointer<SecretShareMachineWrapper> secretShareMachineWrapperPtr, + })?>((_) => null); + +final pFrostSecretSharesData = StateProvider< + ({ + String share, + Pointer<SecretSharesRes> secretSharesResPtr, + })?>((ref) => null); + +final pFrostCompletedKeyGenData = StateProvider< + ({ + Uint8List multisigId, + String recoveryString, + String serializedKeys, + })?>((ref) => null); + +// ================= transaction creation ====================================== +final pFrostTxData = StateProvider<TxData?>((ref) => null); + +final pFrostAttemptSignData = StateProvider< + ({ + Pointer<TransactionSignMachineWrapper> machinePtr, + String preprocess, + })?>((ref) => null); + +final pFrostContinueSignData = StateProvider< + ({ + Pointer<TransactionSignatureMachineWrapper> machinePtr, + String share, + })?>((ref) => null); + +// ===================== shared/util =========================================== +final pFrostSelectParticipantsUnordered = + StateProvider<List<String>?>((ref) => null); + +// ========================= resharing ========================================= +final pFrostResharingData = Provider((ref) => _ResharingData()); + +class _ResharingData { + String? myName; + + IncompleteFrostWallet? incompleteWallet; + + // resharer encoded config string + String? resharerConfig; + ({ + int newThreshold, + List<int> resharers, + List<String> newParticipants, + })? get configData => resharerConfig != null + ? Frost.extractResharerConfigData(resharerConfig: resharerConfig!) + : null; + + // resharer start string (for sharing) and machine + ({ + String resharerStart, + Pointer<StartResharerRes> machine, + })? startResharerData; + + // reshared start string (for sharing) and machine + ({ + String resharedStart, + Pointer<StartResharedRes> 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..faf8967e8 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'; @@ -113,6 +120,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 +443,319 @@ 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<int> 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 MonkeyLoadedView.routeName: // if (args is Tuple2<String, ChangeNotifierProvider<Manager>>) { // return getRoute( 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<String?, Coin>((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<String?, Coin>((ref, coin) { final coinCardFavoritesProvider = Provider.family<String?, Coin>((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<String, Coin>((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<String, Coin>((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<String, Coin>((ref, coin) { final coinImageSecondaryProvider = Provider.family<String, Coin>((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/utilities/enums/coin_enum.dart b/lib/utilities/enums/coin_enum.dart index 305183448..0356c1e7c 100644 --- a/lib/utilities/enums/coin_enum.dart +++ b/lib/utilities/enums/coin_enum.dart @@ -297,6 +297,17 @@ 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: @@ -495,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/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<BitcoinFrostWallet> 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 index 102beb1e3..4f1c3febf 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -171,7 +171,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { } } - final serializedKeys = await _getSerializedKeys(); + final serializedKeys = await getSerializedKeys(); final keys = frost.deserializeKeys(keys: serializedKeys!); final int network = cryptoCurrency.network == CryptoCurrencyNetwork.main @@ -213,7 +213,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { final int network = cryptoCurrency.network == CryptoCurrencyNetwork.main ? Network.Mainnet : Network.Testnet; - final serializedKeys = await _getSerializedKeys(); + final serializedKeys = await getSerializedKeys(); return Frost.attemptSignConfig( network: network, @@ -859,7 +859,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { // =================== Secure storage ======================================== - Future<String?> _getSerializedKeys() async => + Future<String?> getSerializedKeys() async => await secureStorageInterface.read( key: "{$walletId}_serializedFROSTKeys", ); @@ -867,7 +867,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { Future<void> _saveSerializedKeys( String keys, ) async { - final current = await _getSerializedKeys(); + final current = await getSerializedKeys(); if (current == null) { // do nothing 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<StackColors>()! + .textSubtitle3, + ), + ) + : SelectableText( + "$title will appear here", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of(context) + .extension<StackColors>()! + .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/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<StackColors>()! From 9deb8a5d0c4688f434c640a5ea337e836652b79d Mon Sep 17 00:00:00 2001 From: Diego Salazar <diego@cypherstack.com> Date: Fri, 19 Jan 2024 20:16:01 -0700 Subject: [PATCH 07/57] Update version (v1.9.1, build 200) --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 9a243905b..1355dbd4e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ description: Stack Wallet # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.9.0+199 +version: 1.9.1+200 environment: sdk: ">=3.0.2 <4.0.0" From 444afb88ae90b504921d2eada0eceeb397f3813a Mon Sep 17 00:00:00 2001 From: julian <julian@cypherstack.com> Date: Tue, 23 Jan 2024 18:33:40 -0600 Subject: [PATCH 08/57] WIP frost send --- .../frost_attempt_sign_config_view.dart | 405 ++++++++++++ .../frost_ms/frost_complete_sign_view.dart | 206 ++++++ .../frost_continue_sign_config_view.dart | 445 +++++++++++++ .../frost_create_sign_config_view.dart | 180 ++++++ .../frost_import_sign_config_view.dart | 330 ++++++++++ .../send_view/frost_ms/frost_send_view.dart | 602 ++++++++++++++++++ lib/pages/send_view/frost_ms/recipient.dart | 501 +++++++++++++++ lib/pages/wallet_view/wallet_view.dart | 13 +- .../wallet_view/sub_widgets/my_wallet.dart | 49 +- lib/route_generator.dart | 18 + .../wallet/impl/bitcoin_frost_wallet.dart | 6 + lib/wallets/wallet/wallet.dart | 3 + 12 files changed, 2746 insertions(+), 12 deletions(-) create mode 100644 lib/pages/send_view/frost_ms/frost_attempt_sign_config_view.dart create mode 100644 lib/pages/send_view/frost_ms/frost_complete_sign_view.dart create mode 100644 lib/pages/send_view/frost_ms/frost_continue_sign_config_view.dart create mode 100644 lib/pages/send_view/frost_ms/frost_create_sign_config_view.dart create mode 100644 lib/pages/send_view/frost_ms/frost_import_sign_config_view.dart create mode 100644 lib/pages/send_view/frost_ms/frost_send_view.dart create mode 100644 lib/pages/send_view/frost_ms/recipient.dart 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..64b33e9f9 --- /dev/null +++ b/lib/pages/send_view/frost_ms/frost_attempt_sign_config_view.dart @@ -0,0 +1,405 @@ +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/coins/bitcoin/frost_wallet.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/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<FrostAttemptSignConfigView> createState() => + _FrostAttemptSignConfigViewState(); +} + +class _FrostAttemptSignConfigViewState + extends ConsumerState<FrostAttemptSignConfigView> { + final List<TextEditingController> controllers = []; + final List<FocusNode> focusNodes = []; + + late final String myName; + late final List<String> participantsWithoutMe; + late final String myPreprocess; + late final int myIndex; + late final int threshold; + + final List<bool> 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(walletsChangeNotifierProvider) + .getManager(widget.walletId) + .wallet as FrostWallet; + + myName = wallet.myName; + threshold = wallet.threshold; + participantsWithoutMe = wallet.participants; + myIndex = participantsWithoutMe.indexOf(wallet.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<StackColors>()!.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<StackColors>()!.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<StackColors>()!.background, + foregroundColor: Theme.of(context) + .extension<StackColors>()! + .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<void>.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<String> 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<void>( + 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..c63dca04d --- /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<FrostCompleteSignView> createState() => + _FrostCompleteSignViewState(); +} + +class _FrostCompleteSignViewState extends ConsumerState<FrostCompleteSignView> { + bool _broadcastLock = false; + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension<StackColors>()!.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<StackColors>()!.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<StackColors>()!.background, + foregroundColor: Theme.of(context) + .extension<StackColors>()! + .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(walletsChangeNotifierProvider) + .getManager(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<void>( + 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..9d6494c62 --- /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/coins/bitcoin/frost_wallet.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 FrostContinueSignView extends ConsumerStatefulWidget { + const FrostContinueSignView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/frostContinueSignView"; + + final String walletId; + + @override + ConsumerState<FrostContinueSignView> createState() => + _FrostContinueSignViewState(); +} + +class _FrostContinueSignViewState extends ConsumerState<FrostContinueSignView> { + final List<TextEditingController> controllers = []; + final List<FocusNode> focusNodes = []; + + late final String myName; + late final List<String> participantsWithoutMe; + late final List<String> participantsAll; + late final String myShare; + late final int myIndex; + + final List<bool> fieldIsEmptyFlags = []; + + @override + void initState() { + final wallet = ref + .read(walletsChangeNotifierProvider) + .getManager(widget.walletId) + .wallet as FrostWallet; + + myName = wallet.myName; + participantsAll = wallet.participants; + myIndex = wallet.participants.indexOf(wallet.myName); + myShare = ref.read(pFrostContinueSignData.state).state!.share; + + participantsWithoutMe = wallet.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<void>( + 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<StackColors>()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog<void>( + 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<StackColors>()! + .background, + foregroundColor: Theme.of(context) + .extension<StackColors>()! + .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<void>.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<void>( + context: context, + builder: (_) => StackOkDialog( + title: "Missing Shares", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + // collect Share strings + final sharesCollected = + controllers.map((e) => e.text).toList(); + + final List<String> 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<void>( + 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..104160a76 --- /dev/null +++ b/lib/pages/send_view/frost_ms/frost_create_sign_config_view.dart @@ -0,0 +1,180 @@ +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<FrostCreateSignConfigView> createState() => + _FrostCreateSignConfigViewState(); +} + +class _FrostCreateSignConfigViewState + extends ConsumerState<FrostCreateSignConfigView> { + bool _attemptSignLock = false; + + Future<void> _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) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension<StackColors>()!.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<StackColors>()!.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: MediaQuery.of(context).size.width - 32, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: ref.watch(pFrostTxData.state).state!.frostMSConfig!, + size: MediaQuery.of(context).size.width - 32, + backgroundColor: + Theme.of(context).extension<StackColors>()!.background, + foregroundColor: Theme.of(context) + .extension<StackColors>()! + .accentColorDark, + ), + ], + ), + ), + 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 ? 64 : 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..b390e0b67 --- /dev/null +++ b/lib/pages/send_view/frost_ms/frost_import_sign_config_view.dart @@ -0,0 +1,330 @@ +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<FrostImportSignConfigView> createState() => + _FrostImportSignConfigViewState(); +} + +class _FrostImportSignConfigViewState + extends ConsumerState<FrostImportSignConfigView> { + late final TextEditingController configFieldController; + late final FocusNode configFocusNode; + + bool _configEmpty = true; + + bool _attemptSignLock = false; + + Future<void> _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, + 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<void>( + 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<StackColors>()!.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<StackColors>()!.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<void>.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..cf64d8e54 --- /dev/null +++ b/lib/pages/send_view/frost_ms/frost_send_view.dart @@ -0,0 +1,602 @@ +/* + * 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<FrostSendView> createState() => _FrostSendViewState(); +} + +class _FrostSendViewState extends ConsumerState<FrostSendView> { + final List<int> recipientWidgetIndexes = [0]; + int _greatestWidgetIndex = 0; + + late final String walletId; + late final Coin coin; + + late TextEditingController noteController; + late TextEditingController onChainNoteController; + + final _noteFocusNode = FocusNode(); + + Set<UTXO> selectedUTXOs = {}; + + bool _createSignLock = false; + + Future<TxData> _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<void> _createSignConfig() async { + if (_createSignLock) { + return; + } + _createSignLock = true; + + try { + // wait for keyboard to disappear + FocusScope.of(context).unfocus(); + await Future<void>.delayed( + const Duration(milliseconds: 100), + ); + + TxData? txData; + if (mounted) { + txData = await showLoading<TxData>( + 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<dynamic>( + 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<StackColors>()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension<StackColors>()! + .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<StackColors>()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future<void>.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<StackColors>()!.background, + icon: SvgPicture.asset( + Assets.svg.circlePlus, + color: Theme.of(context) + .extension<StackColors>()! + .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: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!Util.isDesktop) + Container( + decoration: BoxDecoration( + color: Theme.of(context).extension<StackColors>()!.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<StackColors>()! + .textSubtitle1, + ), + ), + CustomTextButton( + text: selectedUTXOs.isEmpty + ? "Select coins" + : "Selected coins (${selectedUTXOs.length})", + onTap: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future<void>.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<UTXO>) { + 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<StackColors>()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension<StackColors>()! + .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..e59d3e9ad --- /dev/null +++ b/lib/pages/send_view/frost_ms/recipient.dart @@ -0,0 +1,501 @@ +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<ClipboardInterface>((ref) => const ClipboardWrapper()); +final pBarcodeScanner = + Provider<BarcodeScannerInterface>((ref) => const BarcodeScannerWrapper()); + +// final _pPrice = Provider.family<Decimal, Coin>((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<Recipient> createState() => _RecipientState(); +} + +class _RecipientState extends ConsumerState<Recipient> { + 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( + 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<void>.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<StackColors>()!.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<StackColors>()! + .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<StackColors>()!.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<StackColors>()! + // .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/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index bbb688f01..3bb5abbd8 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'; @@ -63,6 +64,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'; @@ -973,10 +975,13 @@ class _WalletViewState extends ConsumerState<WalletView> { // 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/my_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart index a330cb781..01ff6801f 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<MyWallet> { ]; 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,37 @@ class _MyWalletState extends ConsumerState<MyWallet> { titles: titles, children: [ widget.contractAddress == null - ? Padding( - padding: const EdgeInsets.all(20), - child: DesktopSend( - walletId: widget.walletId, - ), - ) + ? isFrost + ? Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + 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/route_generator.dart b/lib/route_generator.dart index faf8967e8..87e29d64c 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -756,6 +756,24 @@ class RouteGenerator { } 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 MonkeyLoadedView.routeName: // if (args is Tuple2<String, ChangeNotifierProvider<Manager>>) { // return getRoute( diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index 4f1c3febf..4058b8b59 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -29,6 +29,12 @@ import 'package:stackwallet/wallets/models/tx_data.dart'; import 'package:stackwallet/wallets/wallet/wallet.dart'; class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { + @override + int get isarTransactionVersion => 2; + + @override + bool get supportsMultiRecipient => true; + BitcoinFrostWallet(CryptoCurrencyNetwork network) : super(BitcoinFrost(network) as T); diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 959b7b635..ad18d6d4e 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -55,6 +55,9 @@ abstract class Wallet<T extends CryptoCurrency> { // 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); //============================================================================ From 1e67f3585aa7edc821afe809402108b220437fa8 Mon Sep 17 00:00:00 2001 From: julian <julian@cypherstack.com> Date: Thu, 25 Jan 2024 02:20:37 -0600 Subject: [PATCH 09/57] some frost clean up --- lib/db/isar/main_db.dart | 2 + .../restore/restore_frost_ms_wallet_view.dart | 6 +-- .../frost_attempt_sign_config_view.dart | 17 +++---- .../frost_ms/frost_complete_sign_view.dart | 4 +- .../frost_continue_sign_config_view.dart | 18 +++---- .../frost_import_sign_config_view.dart | 4 +- .../send_view/frost_ms/frost_send_view.dart | 2 +- lib/route_generator.dart | 1 + .../wallet/impl/bitcoin_frost_wallet.dart | 49 +++++++++++++++---- 9 files changed, 69 insertions(+), 34 deletions(-) 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/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 index e99655f06..e316f22c8 100644 --- 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 @@ -4,7 +4,7 @@ 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: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'; @@ -70,7 +70,7 @@ class _RestoreFrostMsWalletViewState final keys = keysFieldController.text; final config = configFieldController.text; - final myNameIndex = getParticipantIndexFromKeys(serializedKeys: keys); + final myNameIndex = frost.getParticipantIndexFromKeys(serializedKeys: keys); final participants = Frost.getParticipants(multisigConfig: config); final myName = participants[myNameIndex]; @@ -92,7 +92,7 @@ class _RestoreFrostMsWalletViewState knownSalts: [], participants: participants, myName: myName, - threshold: multisigThreshold( + threshold: frost.multisigThreshold( multisigConfig: config, ), ); 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 index 64b33e9f9..7cdf4a69b 100644 --- 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 @@ -7,13 +7,13 @@ import 'package:stackwallet/pages/send_view/frost_ms/frost_continue_sign_config_ 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/coins/bitcoin/frost_wallet.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'; @@ -72,15 +72,14 @@ class _FrostAttemptSignConfigViewState @override void initState() { - final wallet = ref - .read(walletsChangeNotifierProvider) - .getManager(widget.walletId) - .wallet as FrostWallet; + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as BitcoinFrostWallet; + final frostInfo = wallet.frostInfo; - myName = wallet.myName; - threshold = wallet.threshold; - participantsWithoutMe = wallet.participants; - myIndex = participantsWithoutMe.indexOf(wallet.myName); + myName = frostInfo.myName; + threshold = frostInfo.threshold; + participantsWithoutMe = frostInfo.participants; + myIndex = participantsWithoutMe.indexOf(frostInfo.myName); myPreprocess = ref.read(pFrostAttemptSignData.state).state!.preprocess; participantsWithoutMe.removeAt(myIndex); 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 index c63dca04d..6478495c0 100644 --- a/lib/pages/send_view/frost_ms/frost_complete_sign_view.dart +++ b/lib/pages/send_view/frost_ms/frost_complete_sign_view.dart @@ -139,8 +139,8 @@ class _FrostCompleteSignViewState extends ConsumerState<FrostCompleteSignView> { Exception? ex; final txData = await showLoading( whileFuture: ref - .read(walletsChangeNotifierProvider) - .getManager(widget.walletId) + .read(pWallets) + .getWallet(widget.walletId) .confirmSend( txData: ref.read(pFrostTxData.state).state!, ), 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 index 9d6494c62..732fb2f82 100644 --- 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 @@ -9,13 +9,13 @@ 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/coins/bitcoin/frost_wallet.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'; @@ -61,17 +61,17 @@ class _FrostContinueSignViewState extends ConsumerState<FrostContinueSignView> { @override void initState() { - final wallet = ref - .read(walletsChangeNotifierProvider) - .getManager(widget.walletId) - .wallet as FrostWallet; + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as BitcoinFrostWallet; - myName = wallet.myName; - participantsAll = wallet.participants; - myIndex = wallet.participants.indexOf(wallet.myName); + final frostInfo = wallet.frostInfo; + + myName = frostInfo.myName; + participantsAll = frostInfo.participants; + myIndex = frostInfo.participants.indexOf(frostInfo.myName); myShare = ref.read(pFrostContinueSignData.state).state!.share; - participantsWithoutMe = wallet.participants + participantsWithoutMe = frostInfo.participants .toSet() .intersection( ref.read(pFrostSelectParticipantsUnordered.state).state!.toSet()) 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 index b390e0b67..c89b6846a 100644 --- 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 @@ -92,7 +92,9 @@ class _FrostImportSignConfigViewState // TODO add more data from 'data' and display to user ? ref.read(pFrostTxData.notifier).state = TxData( frostMSConfig: config, - recipients: data.recipients, + recipients: data.recipients + .map((e) => (address: e.address, amount: e.amount, isChange: false)) + .toList(), utxos: utxos.toSet(), ); diff --git a/lib/pages/send_view/frost_ms/frost_send_view.dart b/lib/pages/send_view/frost_ms/frost_send_view.dart index cf64d8e54..1865556b7 100644 --- a/lib/pages/send_view/frost_ms/frost_send_view.dart +++ b/lib/pages/send_view/frost_ms/frost_send_view.dart @@ -84,7 +84,7 @@ class _FrostSendViewState extends ConsumerState<FrostSendView> { final recipients = recipientWidgetIndexes .map((i) => ref.read(pRecipient(i).state).state) - .map((e) => (address: e!.address, amount: e.amount!, isChange: false)) + .map((e) => (address: e!.address, amount: e!.amount!, isChange: false)) .toList(growable: false); final txData = await wallet.frostCreateSignConfig( diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 87e29d64c..26e24653c 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -83,6 +83,7 @@ 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_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'; diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index 4058b8b59..be3b7b821 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -29,12 +29,6 @@ import 'package:stackwallet/wallets/models/tx_data.dart'; import 'package:stackwallet/wallets/wallet/wallet.dart'; class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { - @override - int get isarTransactionVersion => 2; - - @override - bool get supportsMultiRecipient => true; - BitcoinFrostWallet(CryptoCurrencyNetwork network) : super(BitcoinFrost(network) as T); @@ -89,7 +83,9 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { await _saveMultisigId(multisigId); await _saveMultisigConfig(multisigConfig); - await mainDB.isar.frostWalletInfo.put(frostWalletInfo); + await mainDB.isar.writeTxn(() async { + await mainDB.isar.frostWalletInfo.put(frostWalletInfo); + }); final keys = frost.deserializeKeys(keys: serializedKeys); @@ -299,6 +295,9 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { // ==================== Overrides ============================================ + @override + bool get supportsMultiRecipient => true; + @override int get isarTransactionVersion => 2; @@ -527,8 +526,40 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { @override Future<void> checkSaveInitialReceivingAddress() async { - // should not be needed for frost as we explicitly save the address - // on new init and restore + 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 From b3fa5147340f7ae873b58ceb863c79ed6e9af4e9 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Thu, 25 Jan 2024 12:13:35 -0600 Subject: [PATCH 10/57] use github.com instead of www.github.com as frostdart submodule url caused issues for me initializing submodule using git on cli see https://stackoverflow.com/a/64991733 and related --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 98bb17794..925be21c0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -9,4 +9,4 @@ url = https://github.com/cypherstack/flutter_liblelantus.git [submodule "crypto_plugins/frostdart"] path = crypto_plugins/frostdart - url = https://www.github.com/cypherstack/frostdart + url = https://github.com/cypherstack/frostdart From 2aa3bebf78280f56650c3f0f7be5e7bf7a8964cf Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Thu, 25 Jan 2024 19:03:53 -0600 Subject: [PATCH 11/57] wrap send view content in padding will probably need to be adjusted for mobile... --- .../send_view/frost_ms/frost_send_view.dart | 388 +++++++++--------- 1 file changed, 198 insertions(+), 190 deletions(-) diff --git a/lib/pages/send_view/frost_ms/frost_send_view.dart b/lib/pages/send_view/frost_ms/frost_send_view.dart index 1865556b7..95d45cb95 100644 --- a/lib/pages/send_view/frost_ms/frost_send_view.dart +++ b/lib/pages/send_view/frost_ms/frost_send_view.dart @@ -391,207 +391,215 @@ class _FrostSendViewState extends ConsumerState<FrostSendView> { 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, + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, ), - if (showCoinControl) - RoundedWhiteContainer( - child: Row( + child: Column(children: [ + Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Coin control", - style: STextStyles.w500_14(context).copyWith( - color: Theme.of(context) - .extension<StackColors>()! - .textSubtitle1, - ), + "Recipients", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, ), CustomTextButton( - text: selectedUTXOs.isEmpty - ? "Select coins" - : "Selected coins (${selectedUTXOs.length})", - onTap: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future<void>.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<UTXO>) { - setState(() { - selectedUTXOs = result; - }); - } - } + text: "Add", + onTap: () { + // used for tracking recipient forms + _greatestWidgetIndex++; + recipientWidgetIndexes.add(_greatestWidgetIndex); + setState(() {}); }, ), ], ), - ), - 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: 8, ), - ), - ), - 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<StackColors>()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension<StackColors>()! - .getPrimaryDisabledButtonStyle(context), - child: Text( - "Create config", - style: STextStyles.button(context), - ), - ), - const SizedBox( - height: 16, + 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<StackColors>()! + .textSubtitle1, + ), + ), + CustomTextButton( + text: selectedUTXOs.isEmpty + ? "Select coins" + : "Selected coins (${selectedUTXOs.length})", + onTap: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future<void>.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<UTXO>) { + 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<StackColors>()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension<StackColors>()! + .getPrimaryDisabledButtonStyle(context), + child: Text( + "Create config", + style: STextStyles.button(context), + ), + ), + const SizedBox( + height: 16, + ), + ]), ), ], ), From 77f1f346d632598e970c44db57704536bcfc13c7 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Thu, 25 Jan 2024 19:04:07 -0600 Subject: [PATCH 12/57] override recipient input(s) padding --- lib/pages/send_view/frost_ms/recipient.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pages/send_view/frost_ms/recipient.dart b/lib/pages/send_view/frost_ms/recipient.dart index e59d3e9ad..89121d065 100644 --- a/lib/pages/send_view/frost_ms/recipient.dart +++ b/lib/pages/send_view/frost_ms/recipient.dart @@ -149,6 +149,7 @@ class _RecipientState extends ConsumerState<Recipient> { ); return RoundedWhiteContainer( + padding: const EdgeInsets.all(0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, From a100e6a15c17582e7950500b2471db368ffca4ed Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Mon, 29 Jan 2024 17:31:41 -0600 Subject: [PATCH 13/57] only show frost-related config buttons for frost coins --- .../name_your_wallet_view/name_your_wallet_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ed91d265e..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 @@ -399,7 +399,7 @@ class _NameYourWalletViewState extends ConsumerState<NameYourWalletView> { ); }, ), - if (widget.addWalletType == AddWalletType.New) + if (widget.coin.isFrost && widget.addWalletType == AddWalletType.New) Column( children: [ PrimaryButton( From cce94676a69b996a6c7a4f2219651caa03b3112a Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Mon, 29 Jan 2024 23:29:52 -0600 Subject: [PATCH 14/57] fix bitcoin frost wallet restoration --- .../frost_ms/restore/restore_frost_ms_wallet_view.dart | 4 +++- lib/wallets/wallet/impl/bitcoin_frost_wallet.dart | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) 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 index e316f22c8..c9c174ab0 100644 --- 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 @@ -97,7 +97,9 @@ class _RestoreFrostMsWalletViewState ), ); - await ref.read(mainDBProvider).isar.frostWalletInfo.put(frostInfo); + await ref.read(mainDBProvider).isar.writeTxn(() async { + await ref.read(mainDBProvider).isar.frostWalletInfo.put(frostInfo); + }); await (wallet as BitcoinFrostWallet).recover( serializedKeys: keys, diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index be3b7b821..aacad77e4 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -718,8 +718,9 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { if (knownSalts.contains(salt)) { throw Exception("Known frost multisig salt found!"); } - knownSalts.add(salt); - await _updateKnownSalts(knownSalts); + List<String> updatedKnownSalts = List<String>.from(knownSalts); + updatedKnownSalts.add(salt); + await _updateKnownSalts(updatedKnownSalts); } else { // clear cache await electrumXCachedClient.clearSharedTransactionCache(coin: coin); @@ -1001,8 +1002,9 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { .findFirstSync()!; Future<void> _updateKnownSalts(List<String> knownSalts) async { + final info = frostInfo; + await mainDB.isar.writeTxn(() async { - final info = frostInfo; await mainDB.isar.frostWalletInfo.delete(info.id); await mainDB.isar.frostWalletInfo.put( info.copyWith(knownSalts: knownSalts), From 0f73f762162f9a18627135868013377eba91a9b4 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Tue, 30 Jan 2024 11:43:09 -0600 Subject: [PATCH 15/57] refactor _multisigConfig to getMultisigConfig for SWB purposes --- lib/wallets/wallet/impl/bitcoin_frost_wallet.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index aacad77e4..851c41eed 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -930,7 +930,8 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { key: "{$walletId}_serializedFROSTKeysPrevGen", ); - Future<String?> _multisigConfig() async => await secureStorageInterface.read( + Future<String?> getMultisigConfig() async => + await secureStorageInterface.read( key: "{$walletId}_multisigConfig", ); @@ -942,7 +943,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { Future<void> _saveMultisigConfig( String multisigConfig, ) async { - final current = await _multisigConfig(); + final current = await getMultisigConfig(); if (current == null) { // do nothing From 8ba98d573c463bbbe9e0f99f128ad80272ae5ddb Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Tue, 30 Jan 2024 11:43:40 -0600 Subject: [PATCH 16/57] save frost keys and config in otherDataJsonString during SWB creation --- .../helpers/restore_create_backup.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) 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..222a2f948 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 @@ -42,6 +42,7 @@ import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/utilities/util.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 +303,16 @@ 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(); + // TODO handle case in which either keys or config is null. + + // Format keys and config as a JSON string and set otherDataJsonString. + Map<String, dynamic> otherData = {}; + otherData["keys"] = keys; + otherData["config"] = config; + backupWallet['otherDataJsonString'] = jsonEncode(otherData); } backupWallet['coinName'] = wallet.info.coin.name; backupWallet['storedChainHeight'] = wallet.info.cachedChainHeight; From 79fedf46e55dd588d4ee3c0792a91182b9033cba Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Tue, 30 Jan 2024 11:48:50 -0600 Subject: [PATCH 17/57] throw err if keys or config are null --- .../helpers/restore_create_backup.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 222a2f948..11254181a 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 @@ -306,9 +306,15 @@ abstract class SWB { } else if (wallet is BitcoinFrostWallet) { String? keys = await wallet.getSerializedKeys(); String? config = await wallet.getMultisigConfig(); - // TODO handle case in which either keys or config is null. + 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); + } + // TODO [prio=low]: solve case in which either keys or config is null. - // Format keys and config as a JSON string and set otherDataJsonString. + // Format keys & config as a JSON string and set otherDataJsonString. Map<String, dynamic> otherData = {}; otherData["keys"] = keys; otherData["config"] = config; From a17a551a2b21c76e2ef9c7020c1951c4b3228791 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Tue, 30 Jan 2024 12:25:39 -0600 Subject: [PATCH 18/57] add myName to saved frost info --- .../stack_backup_views/helpers/restore_create_backup.dart | 2 ++ 1 file changed, 2 insertions(+) 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 11254181a..01cc9f65e 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 @@ -306,6 +306,7 @@ abstract class SWB { } else if (wallet is BitcoinFrostWallet) { String? keys = await wallet.getSerializedKeys(); String? config = await wallet.getMultisigConfig(); + String myName = wallet.frostInfo.myName; if (keys == null || config == null) { String err = "${wallet.info.coin.name} wallet ${wallet.info.name} " "has null keys or config"; @@ -318,6 +319,7 @@ abstract class SWB { Map<String, dynamic> otherData = {}; otherData["keys"] = keys; otherData["config"] = config; + otherData["myName"] = myName; backupWallet['otherDataJsonString'] = jsonEncode(otherData); } backupWallet['coinName'] = wallet.info.coin.name; From 8cbca16a3a67bfd34bc5fa54b116d9565f0518e1 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Tue, 30 Jan 2024 12:41:37 -0600 Subject: [PATCH 19/57] WIP first attempt at Frost wallet restoration from backup --- .../helpers/restore_create_backup.dart | 99 +++++++++++++++++-- 1 file changed, 89 insertions(+), 10 deletions(-) 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 01cc9f65e..c47630279 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 @@ -10,9 +10,11 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:ffi'; import 'dart:io'; import 'dart:typed_data'; +import 'package:frostdart/frostdart_bindings_generated.dart'; import 'package:isar/isar.dart'; import 'package:stack_wallet_backup/stack_wallet_backup.dart'; import 'package:stackwallet/db/hive/db.dart'; @@ -26,6 +28,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'; @@ -425,16 +428,92 @@ abstract class SWB { ); try { - final wallet = await Wallet.create( - walletInfo: info, - mainDB: MainDB.instance, - secureStorageInterface: secureStorageInterface, - nodeService: nodeService, - prefs: prefs, - mnemonic: mnemonic, - mnemonicPassphrase: mnemonicPassphrase, - privateKey: privateKey, - ); + final Wallet wallet; + + if (info.coin == Coin.bitcoinFrost || + info.coin == Coin.bitcoinFrostTestNet) { + // Decode info.otherDataJsonString for Frost recovery info. + final otherData = jsonDecode(info.otherDataJsonString!); + final String serializedKeys = otherData["keys"] as String; + final String multisigConfig = otherData["config"] as String; + final String myName = otherData["myName"] as String; + + // Start Frost key generation. + final frostStartKeyGenData = Frost.startKeyGeneration( + multisigConfig: multisigConfig, + myName: myName, + ); + + // Generate shares. + final ({ + Pointer<SecretSharesRes> secretSharesResPtr, + String share + }) frostSecretSharesData; + try { + frostSecretSharesData = Frost.generateSecretShares( + multisigConfigWithNamePtr: + frostStartKeyGenData.multisigConfigWithNamePtr, + mySeed: frostStartKeyGenData.seed, + secretShareMachineWrapperPtr: + frostStartKeyGenData.secretShareMachineWrapperPtr, + commitments: [frostStartKeyGenData.commitments], + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + throw Error(); + } + + // Get shares. + final shares = [ + frostSecretSharesData.share, + ]; + + // Complete Frost key generation. + final frostCompleteKeyGenData = Frost.completeKeyGeneration( + multisigConfigWithNamePtr: + frostStartKeyGenData.multisigConfigWithNamePtr, + secretSharesResPtr: frostSecretSharesData.secretSharesResPtr, + shares: [frostSecretSharesData.share], // TODO [prio=high]: verify. + ); + + wallet = await Wallet.create( + walletInfo: info, + mainDB: MainDB.instance, + secureStorageInterface: secureStorageInterface, + nodeService: nodeService, + prefs: prefs, + ); + + await (wallet as BitcoinFrostWallet).initializeNewFrost( + mnemonic: frostStartKeyGenData.seed, + multisigConfig: multisigConfig, + recoveryString: frostCompleteKeyGenData.recoveryString, + serializedKeys: serializedKeys, + multisigId: frostCompleteKeyGenData.multisigId, + myName: myName, + participants: Frost.getParticipants( + multisigConfig: multisigConfig, + ), + threshold: Frost.getThreshold( + multisigConfig: multisigConfig, + ), + ); + } else { + wallet = await Wallet.create( + walletInfo: info, + mainDB: MainDB.instance, + secureStorageInterface: secureStorageInterface, + nodeService: nodeService, + prefs: prefs, + mnemonic: mnemonic, + mnemonicPassphrase: mnemonicPassphrase, + privateKey: privateKey, + ); + } await wallet.init(); From 2e6ac40e205724541beb479356b85d9cf7f66e1c Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Tue, 30 Jan 2024 12:45:39 -0600 Subject: [PATCH 20/57] fix 'cannot cast Null to String' --- .../stack_backup_views/helpers/restore_create_backup.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 c47630279..4dec9dac7 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 @@ -406,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<String> mnemonicList = (walletbackup['mnemonic'] as List<dynamic>) From 0d3ef1bfc4b3a76573e0afb5efbc1c64c67dfde2 Mon Sep 17 00:00:00 2001 From: julian <julian@cypherstack.com> Date: Tue, 30 Jan 2024 18:08:31 -0600 Subject: [PATCH 21/57] frost swb integration fixes --- .../helpers/restore_create_backup.dart | 137 +++++++----------- 1 file changed, 50 insertions(+), 87 deletions(-) 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 4dec9dac7..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 @@ -10,11 +10,10 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:ffi'; import 'dart:io'; import 'dart:typed_data'; -import 'package:frostdart/frostdart_bindings_generated.dart'; +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'; @@ -44,6 +43,7 @@ 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'; @@ -309,21 +309,21 @@ abstract class SWB { } else if (wallet is BitcoinFrostWallet) { String? keys = await wallet.getSerializedKeys(); String? config = await wallet.getMultisigConfig(); - String myName = wallet.frostInfo.myName; 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<String, dynamic> otherData = {}; - otherData["keys"] = keys; - otherData["config"] = config; - otherData["myName"] = myName; - backupWallet['otherDataJsonString'] = jsonEncode(otherData); + Map<String, dynamic> frostData = {}; + frostData["keys"] = keys; + frostData["config"] = config; + backupWallet['frostWalletData'] = jsonEncode(frostData); } backupWallet['coinName'] = wallet.info.coin.name; backupWallet['storedChainHeight'] = wallet.info.cachedChainHeight; @@ -430,93 +430,48 @@ abstract class SWB { ); try { - final Wallet wallet; - - if (info.coin == Coin.bitcoinFrost || - info.coin == Coin.bitcoinFrostTestNet) { + String? serializedKeys; + String? multisigConfig; + if (info.coin.isFrost) { // Decode info.otherDataJsonString for Frost recovery info. - final otherData = jsonDecode(info.otherDataJsonString!); - final String serializedKeys = otherData["keys"] as String; - final String multisigConfig = otherData["config"] as String; - final String myName = otherData["myName"] as String; + final frostData = jsonDecode(walletbackup["frostWalletData"] as String); + serializedKeys = frostData["keys"] as String; + multisigConfig = frostData["config"] as String; - // Start Frost key generation. - final frostStartKeyGenData = Frost.startKeyGeneration( - multisigConfig: multisigConfig, - myName: myName, - ); - - // Generate shares. - final ({ - Pointer<SecretSharesRes> secretSharesResPtr, - String share - }) frostSecretSharesData; - try { - frostSecretSharesData = Frost.generateSecretShares( - multisigConfigWithNamePtr: - frostStartKeyGenData.multisigConfigWithNamePtr, - mySeed: frostStartKeyGenData.seed, - secretShareMachineWrapperPtr: - frostStartKeyGenData.secretShareMachineWrapperPtr, - commitments: [frostStartKeyGenData.commitments], - ); - } catch (e, s) { - Logging.instance.log( - "$e\n$s", - level: LogLevel.Fatal, - ); - - throw Error(); - } - - // Get shares. - final shares = [ - frostSecretSharesData.share, - ]; - - // Complete Frost key generation. - final frostCompleteKeyGenData = Frost.completeKeyGeneration( - multisigConfigWithNamePtr: - frostStartKeyGenData.multisigConfigWithNamePtr, - secretSharesResPtr: frostSecretSharesData.secretSharesResPtr, - shares: [frostSecretSharesData.share], // TODO [prio=high]: verify. - ); - - wallet = await Wallet.create( - walletInfo: info, - mainDB: MainDB.instance, - secureStorageInterface: secureStorageInterface, - nodeService: nodeService, - prefs: prefs, - ); - - await (wallet as BitcoinFrostWallet).initializeNewFrost( - mnemonic: frostStartKeyGenData.seed, - multisigConfig: multisigConfig, - recoveryString: frostCompleteKeyGenData.recoveryString, + final myNameIndex = frost.getParticipantIndexFromKeys( serializedKeys: serializedKeys, - multisigId: frostCompleteKeyGenData.multisigId, + ); + final participants = Frost.getParticipants( + multisigConfig: multisigConfig, + ); + final myName = participants[myNameIndex]; + + final frostInfo = FrostWalletInfo( + walletId: info.walletId, + knownSalts: [], + participants: participants, myName: myName, - participants: Frost.getParticipants( - multisigConfig: multisigConfig, - ), - threshold: Frost.getThreshold( + threshold: frost.multisigThreshold( multisigConfig: multisigConfig, ), ); - } else { - wallet = await Wallet.create( - walletInfo: info, - mainDB: MainDB.instance, - secureStorageInterface: secureStorageInterface, - nodeService: nodeService, - prefs: prefs, - mnemonic: mnemonic, - mnemonicPassphrase: mnemonicPassphrase, - privateKey: privateKey, - ); + + await MainDB.instance.isar.writeTxn(() async { + await MainDB.instance.isar.frostWalletInfo.put(frostInfo); + }); } + final wallet = await Wallet.create( + walletInfo: info, + mainDB: MainDB.instance, + secureStorageInterface: secureStorageInterface, + nodeService: nodeService, + prefs: prefs, + mnemonic: mnemonic, + mnemonicPassphrase: mnemonicPassphrase, + privateKey: privateKey, + ); + await wallet.init(); int restoreHeight = walletbackup['restoreHeight'] as int? ?? 0; @@ -527,7 +482,15 @@ abstract class SWB { Future<void>? 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( From ccf1e3437776da374a57caad2a78605bc58587f6 Mon Sep 17 00:00:00 2001 From: julian <julian@cypherstack.com> Date: Tue, 30 Jan 2024 19:50:55 -0600 Subject: [PATCH 22/57] port of frost backup keys ui from stack frost --- .../wallet_backup_view.dart | 391 ++++++++++++------ .../wallet_settings_view.dart | 105 +++-- .../firo_rescan_recovery_error_dialog.dart | 7 +- .../unlock_wallet_keys_desktop.dart | 43 +- .../wallet_keys_desktop_popup.dart | 216 ++++++---- lib/route_generator.dart | 35 +- .../wallet/impl/bitcoin_frost_wallet.dart | 4 +- 7 files changed, 550 insertions(+), 251 deletions(-) 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..5b1548514 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 @@ -23,16 +23,23 @@ 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'; +import '../../../wallet_view/transaction_views/transaction_details_view.dart'; + class WalletBackupView extends ConsumerWidget { const WalletBackupView({ Key? key, required this.walletId, required this.mnemonic, + this.frostWalletData, this.clipboardInterface = const ClipboardWrapper(), }) : super(key: key); @@ -40,11 +47,21 @@ class WalletBackupView extends ConsumerWidget { final String walletId; final List<String> 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<StackColors>()!.background, @@ -91,139 +108,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<StackColors>()!.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<StackColors>()! - .getPrimaryEnabledButtonStyle(context), - onPressed: () { - String data = AddressUtils.encodeQRSeedData(mnemonic); - - showDialog<dynamic>( - 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<StackColors>()! - .popupBG, - foregroundColor: Theme.of(context) - .extension<StackColors>()! - .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<StackColors>()! - .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<StackColors>()! - .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<StackColors>()!.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<StackColors>()! + .getPrimaryEnabledButtonStyle(context), + onPressed: () { + String data = AddressUtils.encodeQRSeedData(mnemonic); + + showDialog<dynamic>( + 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<StackColors>()! + .popupBG, + foregroundColor: Theme.of(context) + .extension<StackColors>()! + .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<StackColors>()! + .getSecondaryEnabledButtonStyle( + context), + child: Text( + "Cancel", + style: STextStyles.button(context) + .copyWith( + color: Theme.of(context) + .extension<StackColors>()! + .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..e0b870326 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 @@ -39,6 +39,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'; @@ -235,39 +236,83 @@ class _WalletSettingsViewState extends ConsumerState<WalletSettingsView> { 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<String>? mnemonic; + ({ + String myName, + String config, + String keys, + ({ + String config, + String keys + })? prevGen, + })? frostWalletData; + if (wallet is BitcoinFrostWallet) { + List<Future<dynamic>> 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 d062b62d5..40d8e8d6c 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_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..c621a4030 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,31 @@ class _UnlockWalletKeysDesktopState Navigator.of(context, rootNavigator: true).pop(); final wallet = ref.read(pWallets).getWallet(widget.walletId); + ({String keys, String config})? frostData; + List<String>? 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.getMultisigConfig())!, + 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 { @@ -301,21 +314,35 @@ class _UnlockWalletKeysDesktopState if (verified) { Navigator.of(context, rootNavigator: true).pop(); + ({String keys, String config})? frostData; + List<String>? 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.getMultisigConfig())!, + 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..60f0d2436 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 @@ -29,10 +29,12 @@ class WalletKeysDesktopPopup extends StatelessWidget { const WalletKeysDesktopPopup({ Key? key, required this.words, + this.frostData, this.clipboardInterface = const ClipboardWrapper(), }) : super(key: key); final List<String> words; + final ({String keys, String config})? frostData; final ClipboardInterface clipboardInterface; static const String routeName = "walletKeysDesktopPopup"; @@ -66,85 +68,145 @@ 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<StackColors>()! - .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: SelectableText( + frostData!.keys, + style: + STextStyles.desktopTextExtraExtraSmall(context), + textAlign: TextAlign.center, + ), + ), + ), + const SizedBox( + height: 24, + ), + Text( + "Config", + style: STextStyles.desktopTextMedium(context), + ), + const SizedBox( + height: 8, + ), + Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + ), + child: SelectableText( + frostData!.config, + style: + STextStyles.desktopTextExtraExtraSmall(context), + textAlign: TextAlign.center, + ), + ), + ), + 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<StackColors>()! + .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/route_generator.dart b/lib/route_generator.dart index 26e24653c..3afd3e5dc 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -1403,12 +1403,33 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case WalletBackupView.routeName: - if (args is Tuple2<String, List<String>>) { + if (args is ({String walletId, List<String> 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<String> 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, @@ -2313,10 +2334,14 @@ class RouteGenerator { settings: RouteSettings(name: settings.name)); case WalletKeysDesktopPopup.routeName: - if (args is List<String>) { + if (args is ({ + List<String> 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/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index 851c41eed..2a50f20b6 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -925,7 +925,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { ); } - Future<String?> _getSerializedKeysPrevGen() async => + Future<String?> getSerializedKeysPrevGen() async => await secureStorageInterface.read( key: "{$walletId}_serializedFROSTKeysPrevGen", ); @@ -935,7 +935,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { key: "{$walletId}_multisigConfig", ); - Future<String?> _multisigConfigPrevGen() async => + Future<String?> getMultisigConfigPrevGen() async => await secureStorageInterface.read( key: "{$walletId}_multisigConfigPrevGen", ); From 73276ba676d3a6edee43db6924632b78bdc66220 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Fri, 23 Feb 2024 17:46:34 -0600 Subject: [PATCH 23/57] update frost wallet for electrum_adapter functionality pulled from electrumx_interface, might consider using those methods instead --- .../wallet/impl/bitcoin_frost_wallet.dart | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index 2a50f20b6..097769260 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -1,6 +1,8 @@ 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'; @@ -18,15 +20,19 @@ 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<T extends FrostCurrency> extends Wallet<T> { BitcoinFrostWallet(CryptoCurrencyNetwork network) @@ -38,6 +44,8 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { .findFirstSync()!; late ElectrumXClient electrumXClient; + late StreamChannel electrumAdapterChannel; + late ElectrumClient electrumAdapterClient; late CachedElectrumXClient electrumXCachedClient; Future<void> initializeNewFrost({ @@ -1075,6 +1083,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { ); } + // TODO [prio=low]: Use ElectrumXInterface method. Future<void> _updateElectrumX() async { final failovers = nodeService .failoverNodesFor(coin: cryptoCurrency.coin) @@ -1088,16 +1097,67 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { .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<ElectrumClient> updateClient() async { + Logging.instance.log( + "Updating electrum node and ElectrumAdapterClient from Frost wallet.", + level: LogLevel.Info); + await updateNode(); + return electrumAdapterClient; + } + bool _duplicateTxCheck( List<Map<String, dynamic>> allTransactions, String txid) { for (int i = 0; i < allTransactions.length; i++) { From bbfb152bd741cd7a16bb24319ad517df9ea43619 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Fri, 23 Feb 2024 17:48:45 -0600 Subject: [PATCH 24/57] add bitcoin frost cases to validation switch i'd like to do this more elegantly and just use each wallet impl's validateAddress but this will do for now --- lib/utilities/address_utils.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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: From 26b4b4b888e2544f6b99c7f49d2825b136edfdc4 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Mon, 4 Mar 2024 16:33:26 -0600 Subject: [PATCH 25/57] flutter_libepiccash: make sure to cd to build dir after building openssl --- crypto_plugins/flutter_libepiccash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto_plugins/flutter_libepiccash b/crypto_plugins/flutter_libepiccash index cef5d3aa8..eea3d7674 160000 --- a/crypto_plugins/flutter_libepiccash +++ b/crypto_plugins/flutter_libepiccash @@ -1 +1 @@ -Subproject commit cef5d3aa8c74c8dc9a466f803c964b243ad653a3 +Subproject commit eea3d76740f7b036451850c937c3c013e5724294 From 445fc832a3ab6fda088f0ed7dc69661077b2a305 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Wed, 6 Mar 2024 10:16:51 -0600 Subject: [PATCH 26/57] center "import sign config" button --- .../wallet_view/sub_widgets/my_wallet.dart | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) 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 01ff6801f..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 @@ -76,19 +76,23 @@ class _MyWalletState extends ConsumerState<MyWallet> { ? Column( children: [ Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, children: [ - SecondaryButton( - width: 200, - buttonHeight: ButtonHeight.l, - label: "Import sign config", - onPressed: () { - Navigator.of(context).pushNamed( - FrostImportSignConfigView.routeName, - arguments: widget.walletId, - ); - }, - ), + 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( From 5d1615b72ef363514f99329316cf24a1811d456b Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Wed, 6 Mar 2024 10:55:15 -0600 Subject: [PATCH 27/57] fix keys popup, add copy buttons, and add basic style and import cleanup --- .../wallet_backup_view.dart | 3 +- .../unlock_wallet_keys_desktop.dart | 6 +- .../wallet_keys_desktop_popup.dart | 62 ++++++++++++++++--- 3 files changed, 57 insertions(+), 14 deletions(-) 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 5b1548514..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,6 +17,7 @@ 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'; @@ -32,8 +33,6 @@ import 'package:stackwallet/widgets/detail_item.dart'; import 'package:stackwallet/widgets/rounded_white_container.dart'; import 'package:stackwallet/widgets/stack_dialog.dart'; -import '../../../wallet_view/transaction_views/transaction_details_view.dart'; - class WalletBackupView extends ConsumerWidget { const WalletBackupView({ Key? key, 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 c621a4030..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 @@ -89,9 +89,11 @@ class _UnlockWalletKeysDesktopState if (wallet is! MnemonicInterface) { if (wallet is BitcoinFrostWallet) { frostData = ( - keys: (await wallet.getMultisigConfig())!, + keys: (await wallet.getSerializedKeys())!, config: (await wallet.getMultisigConfig())!, ); + print(1111111); + print(frostData); } else { throw Exception("FIXME ~= see todo in code"); } @@ -325,7 +327,7 @@ class _UnlockWalletKeysDesktopState if (wallet is! MnemonicInterface) { if (wallet is BitcoinFrostWallet) { frostData = ( - keys: (await wallet.getMultisigConfig())!, + keys: (await wallet.getSerializedKeys())!, config: (await wallet.getMultisigConfig())!, ); } 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 60f0d2436..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,6 +25,7 @@ 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({ @@ -83,11 +85,31 @@ class WalletKeysDesktopPopup extends StatelessWidget { padding: const EdgeInsets.symmetric( horizontal: 32, ), - child: SelectableText( - frostData!.keys, - style: - STextStyles.desktopTextExtraExtraSmall(context), - textAlign: TextAlign.center, + child: RoundedWhiteContainer( + borderColor: Theme.of(context) + .extension<StackColors>()! + .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. + ], + ), ), ), ), @@ -106,11 +128,31 @@ class WalletKeysDesktopPopup extends StatelessWidget { padding: const EdgeInsets.symmetric( horizontal: 32, ), - child: SelectableText( - frostData!.config, - style: - STextStyles.desktopTextExtraExtraSmall(context), - textAlign: TextAlign.center, + child: RoundedWhiteContainer( + borderColor: Theme.of(context) + .extension<StackColors>()! + .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. + ], + ), ), ), ), From d59be57667067b34ce82c03ab79892229b8de55e Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Wed, 6 Mar 2024 11:17:19 -0600 Subject: [PATCH 28/57] update flutter_libepiccash build script --- crypto_plugins/flutter_libepiccash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto_plugins/flutter_libepiccash b/crypto_plugins/flutter_libepiccash index eea3d7674..72b6ce405 160000 --- a/crypto_plugins/flutter_libepiccash +++ b/crypto_plugins/flutter_libepiccash @@ -1 +1 @@ -Subproject commit eea3d76740f7b036451850c937c3c013e5724294 +Subproject commit 72b6ce405a956f163ffb25d458de28a8458223f4 From f558703253116b4b13e775210bdec3629d23d356 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Wed, 6 Mar 2024 18:04:54 -0600 Subject: [PATCH 29/57] DesktopScaffold on desktop --- .../frost_ms/frost_ms_options_view.dart | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) 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 index 169a96b64..7fc7236a4 100644 --- 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 @@ -13,14 +13,19 @@ 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 { @@ -35,21 +40,40 @@ class FrostMSWalletOptionsView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return Background( - child: Scaffold( - backgroundColor: Theme.of(context).extension<StackColors>()!.background, - appBar: AppBar( - leading: AppBarBackButton( - onPressed: () { - Navigator.of(context).pop(); - }, - ), - title: Text( - "Multisig settings", - style: STextStyles.navBarTitle(context), - ), + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension<StackColors>()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), ), - body: Padding( + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension<StackColors>()!.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, From 809cbe6195e4d944563745d2f87381c6a98f41b8 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Wed, 6 Mar 2024 18:09:38 -0600 Subject: [PATCH 30/57] FROST Multisig settings buttons mobile and desktop --- .../wallet_settings_view.dart | 16 +++++ .../sub_widgets/wallet_options_button.dart | 60 ++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) 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 e0b870326..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'; @@ -194,6 +195,21 @@ class _WalletSettingsViewState extends ConsumerState<WalletSettingsView> { 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, 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<StackColors>()! + .textFieldActiveSearchIconLeft, + ), + const SizedBox(width: 14), + Expanded( + child: Text( + _WalletOptions.frostOptions.prettyName, + style: STextStyles.desktopTextExtraExtraSmall( + context) + .copyWith( + color: Theme.of(context) + .extension<StackColors>()! + .textDark, + ), + ), + ), + ], + ), + ), + ), if (xpubEnabled) const SizedBox( height: 8, From bfdcfcec1aeb734d26f5396d22c89f764978186a Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Wed, 6 Mar 2024 18:13:39 -0600 Subject: [PATCH 31/57] resolve "can't add to fixed length list" exception --- .../involved/step_1a/complete_reshare_config_view.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 0e2e1e111..74cfaee17 100644 --- 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 @@ -93,14 +93,14 @@ class _CompleteReshareConfigViewState ), ); } else { - final salts = frostInfo.knownSalts; - salts.add(salt); + final salts = frostInfo.knownSalts; // Fixed length list. + final newSalts = List<String>.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: salts), + info.copyWith(knownSalts: newSalts), ); }); } From 88afa95b52801d05a7b568f680ee1f61928d8844 Mon Sep 17 00:00:00 2001 From: likho <likhojiba@gmail.com> Date: Mon, 11 Mar 2024 14:32:40 +0200 Subject: [PATCH 32/57] Update to latest epic --- crypto_plugins/flutter_libepiccash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto_plugins/flutter_libepiccash b/crypto_plugins/flutter_libepiccash index cef5d3aa8..aab6a4676 160000 --- a/crypto_plugins/flutter_libepiccash +++ b/crypto_plugins/flutter_libepiccash @@ -1 +1 @@ -Subproject commit cef5d3aa8c74c8dc9a466f803c964b243ad653a3 +Subproject commit aab6a4676188901fbe158d8f1feeb1fc0ea247f8 From 09bbdb536847011a7cf0ef7f041adb60808e8e31 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Mon, 11 Mar 2024 11:20:41 -0500 Subject: [PATCH 33/57] add missing frost routes --- lib/route_generator.dart | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 3afd3e5dc..6f298a7c0 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -83,6 +83,8 @@ 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_send_view.dart'; import 'package:stackwallet/pages/send_view/send_view.dart'; import 'package:stackwallet/pages/send_view/token_send_view.dart'; @@ -775,6 +777,34 @@ class RouteGenerator { } 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<String, ChangeNotifierProvider<Manager>>) { // return getRoute( From 10233550b187c7a3a879f4c09d2358551c4c8ce1 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Mon, 11 Mar 2024 11:21:19 -0500 Subject: [PATCH 34/57] fix issue with modifying fixed-length list --- .../send_view/frost_ms/frost_attempt_sign_config_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 7cdf4a69b..149eb61d3 100644 --- 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 @@ -78,7 +78,7 @@ class _FrostAttemptSignConfigViewState myName = frostInfo.myName; threshold = frostInfo.threshold; - participantsWithoutMe = frostInfo.participants; + participantsWithoutMe = List.from(frostInfo.participants); // Copy so it isn't fixed-length. myIndex = participantsWithoutMe.indexOf(frostInfo.myName); myPreprocess = ref.read(pFrostAttemptSignData.state).state!.preprocess; From 181ec5e5398d94f60b32397d53f0a899171e4799 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Mon, 11 Mar 2024 23:05:55 -0500 Subject: [PATCH 35/57] Revert "wrap send view content in padding" This reverts commit 2aa3bebf78280f56650c3f0f7be5e7bf7a8964cf. --- .../send_view/frost_ms/frost_send_view.dart | 384 +++++++++--------- 1 file changed, 188 insertions(+), 196 deletions(-) diff --git a/lib/pages/send_view/frost_ms/frost_send_view.dart b/lib/pages/send_view/frost_ms/frost_send_view.dart index 95d45cb95..1865556b7 100644 --- a/lib/pages/send_view/frost_ms/frost_send_view.dart +++ b/lib/pages/send_view/frost_ms/frost_send_view.dart @@ -391,215 +391,207 @@ class _FrostSendViewState extends ConsumerState<FrostSendView> { const SizedBox( height: 16, ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, + 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, ), - child: Column(children: [ - Row( + if (showCoinControl) + RoundedWhiteContainer( + child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Recipients", - style: STextStyles.smallMed12(context), - textAlign: TextAlign.left, + "Coin control", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of(context) + .extension<StackColors>()! + .textSubtitle1, + ), ), CustomTextButton( - text: "Add", - onTap: () { - // used for tracking recipient forms - _greatestWidgetIndex++; - recipientWidgetIndexes.add(_greatestWidgetIndex); - setState(() {}); + text: selectedUTXOs.isEmpty + ? "Select coins" + : "Selected coins (${selectedUTXOs.length})", + onTap: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future<void>.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<UTXO>) { + setState(() { + selectedUTXOs = result; + }); + } + } }, ), ], ), - 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<StackColors>()! - .textSubtitle1, - ), - ), - CustomTextButton( - text: selectedUTXOs.isEmpty - ? "Select coins" - : "Selected coins (${selectedUTXOs.length})", - onTap: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future<void>.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, + ), + 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 = ""; + }); + }, ), - ); - - if (result is Set<UTXO>) { - 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<StackColors>()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension<StackColors>()! - .getPrimaryDisabledButtonStyle(context), - child: Text( - "Create config", - style: STextStyles.button(context), - ), ), - const SizedBox( - height: 16, - ), - ]), + ), + ), + 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<StackColors>()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension<StackColors>()! + .getPrimaryDisabledButtonStyle(context), + child: Text( + "Create config", + style: STextStyles.button(context), + ), + ), + const SizedBox( + height: 16, ), ], ), From 0fe16638b04a8047f14caac94745bb565e6b5869 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Tue, 12 Mar 2024 06:45:26 -0500 Subject: [PATCH 36/57] pad desktop send view with ConditionalParent --- .../send_view/frost_ms/frost_send_view.dart | 595 +++++++++--------- 1 file changed, 303 insertions(+), 292 deletions(-) diff --git a/lib/pages/send_view/frost_ms/frost_send_view.dart b/lib/pages/send_view/frost_ms/frost_send_view.dart index 1865556b7..5f04bb346 100644 --- a/lib/pages/send_view/frost_ms/frost_send_view.dart +++ b/lib/pages/send_view/frost_ms/frost_send_view.dart @@ -287,313 +287,324 @@ class _FrostSendViewState extends ConsumerState<FrostSendView> { ), ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!Util.isDesktop) - Container( - decoration: BoxDecoration( - color: Theme.of(context).extension<StackColors>()!.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, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, ), - 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(() {}); - }, + child: child, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!Util.isDesktop) + Container( + decoration: BoxDecoration( + color: Theme.of(context).extension<StackColors>()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, ), ), - ], - ), - 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<StackColors>()! - .textSubtitle1, - ), - ), - CustomTextButton( - text: selectedUTXOs.isEmpty - ? "Select coins" - : "Selected coins (${selectedUTXOs.length})", - onTap: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future<void>.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, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + SvgPicture.file( + File( + ref.watch( + coinIconProvider(coin), ), - ); - - if (result is Set<UTXO>) { - 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( + ), + 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: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - noteController.text = ""; - }); - }, + 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, + // ) ], ), ), ) - : null, + ], + ), + ), + ), + 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<StackColors>()! + .textSubtitle1, + ), + ), + CustomTextButton( + text: selectedUTXOs.isEmpty + ? "Select coins" + : "Selected coins (${selectedUTXOs.length})", + onTap: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future<void>.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<UTXO>) { + 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, + const SizedBox( + height: 12, ), - child: FeeSlider( - coin: coin, - onSatVByteChanged: (rate) { - customFeeRate = rate; - }, + 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<StackColors>()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension<StackColors>()! - .getPrimaryDisabledButtonStyle(context), - child: Text( - "Create config", - style: STextStyles.button(context), + Util.isDesktop + ? const SizedBox( + height: 12, + ) + : const Spacer(), + const SizedBox( + height: 12, ), - ), - const SizedBox( - height: 16, - ), - ], + TextButton( + onPressed: ref.watch(previewTxButtonStateProvider.state).state + ? _createSignConfig + : null, + style: ref.watch(previewTxButtonStateProvider.state).state + ? Theme.of(context) + .extension<StackColors>()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension<StackColors>()! + .getPrimaryDisabledButtonStyle(context), + child: Text( + "Create config", + style: STextStyles.button(context), + ), + ), + const SizedBox( + height: 16, + ), + ], + ), ), ); } From 778795139a8fbd3a813fbcc1d7074f229661c004 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Tue, 12 Mar 2024 06:48:01 -0500 Subject: [PATCH 37/57] add import sign config route --- lib/route_generator.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 6f298a7c0..4eb906b8a 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -85,6 +85,7 @@ 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'; @@ -777,6 +778,20 @@ class RouteGenerator { } 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( From 64b0f23910807fe7134188028b1263fee84ff887 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Tue, 12 Mar 2024 07:05:05 -0500 Subject: [PATCH 38/57] desktop create sign tweaks making things wider and scrollable but the qr code not overflowingly wide --- .../frost_create_sign_config_view.dart | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) 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 index 104160a76..bb2c129ca 100644 --- 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 @@ -71,6 +71,8 @@ class _FrostCreateSignConfigViewState @override Widget build(BuildContext context) { + double qrImageSize = + Util.isDesktop ? 360 : MediaQuery.of(context).size.width - 32; return ConditionalParent( condition: Util.isDesktop, builder: (child) => DesktopScaffold( @@ -79,9 +81,11 @@ class _FrostCreateSignConfigViewState isCompactHeight: false, leading: AppBarBackButton(), ), - body: SizedBox( - width: 480, - child: child, + body: SingleChildScrollView( + child: SizedBox( + width: 600, // Was 480, may look better but overflows the bottom. + child: child, + ), ), ), child: ConditionalParent( @@ -126,13 +130,13 @@ class _FrostCreateSignConfigViewState children: [ if (!Util.isDesktop) const Spacer(), SizedBox( - height: MediaQuery.of(context).size.width - 32, + height: qrImageSize, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ QrImageView( data: ref.watch(pFrostTxData.state).state!.frostMSConfig!, - size: MediaQuery.of(context).size.width - 32, + size: qrImageSize, backgroundColor: Theme.of(context).extension<StackColors>()!.background, foregroundColor: Theme.of(context) @@ -142,9 +146,10 @@ class _FrostCreateSignConfigViewState ], ), ), - const SizedBox( - height: 32, - ), + if (!Util.isDesktop) + const SizedBox( + height: 32, + ), DetailItem( title: "Encoded config", detail: ref.watch(pFrostTxData.state).state!.frostMSConfig!, @@ -157,7 +162,7 @@ class _FrostCreateSignConfigViewState ), ), SizedBox( - height: Util.isDesktop ? 64 : 16, + height: Util.isDesktop ? 20 : 16, ), if (!Util.isDesktop) const Spacer( From 95bb47aaf8c3bf2553eb4421b1e8e0d907fdb236 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Tue, 12 Mar 2024 07:45:49 -0500 Subject: [PATCH 39/57] fix rescans --- .../wallet/impl/bitcoin_frost_wallet.dart | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index 097769260..fdefac4e9 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -698,10 +698,14 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { String? multisigConfig, }) async { if (serializedKeys == null || multisigConfig == null) { - throw Exception( - "Failed to recover $runtimeType: " - "Missing serializedKeys and/or multisigConfig.", - ); + 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; @@ -719,7 +723,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { if (!isRescan) { final salt = frost .multisigSalt( - multisigConfig: multisigConfig, + multisigConfig: multisigConfig!, ) .toHex; final knownSalts = _getKnownSalts(); @@ -735,9 +739,9 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> { await mainDB.deleteWalletBlockchainData(walletId); } - final keys = frost.deserializeKeys(keys: serializedKeys); - await _saveSerializedKeys(serializedKeys); - await _saveMultisigConfig(multisigConfig); + final keys = frost.deserializeKeys(keys: serializedKeys!); + await _saveSerializedKeys(serializedKeys!); + await _saveMultisigConfig(multisigConfig!); final addressString = frost.addressForKeys( network: cryptoCurrency.network == CryptoCurrencyNetwork.main From f68c0f4839f8867ba302cf0cef218bc4cf0ef3a0 Mon Sep 17 00:00:00 2001 From: Diego Salazar <diego@cypherstack.com> Date: Tue, 12 Mar 2024 16:55:01 -0600 Subject: [PATCH 40/57] Update version (v1.10.3, build 215) --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 58b5c7008..fd19a216e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ description: Stack Wallet # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.10.2+214 +version: 1.10.3+215 environment: sdk: ">=3.0.2 <4.0.0" From ec51f954496bd8ae1610cfcacc7c6415c5bfdf9c Mon Sep 17 00:00:00 2001 From: likho <likhojiba@gmail.com> Date: Thu, 14 Mar 2024 20:23:41 +0200 Subject: [PATCH 41/57] WIP: Add frost mascot --- assets/images/mascot.png | Bin 0 -> 222983 bytes lib/pages/FrostMascot.dart | 49 ++++++++++++++++++ ...irm_new_frost_ms_wallet_creation_view.dart | 14 +---- .../new/create_new_frost_ms_wallet_view.dart | 4 +- .../new/frost_share_commitments_view.dart | 14 +---- .../frost_ms/new/frost_share_shares_view.dart | 13 +---- .../new/share_new_multisig_config_view.dart | 4 +- lib/utilities/assets.dart | 1 + 8 files changed, 60 insertions(+), 39 deletions(-) create mode 100644 assets/images/mascot.png create mode 100644 lib/pages/FrostMascot.dart diff --git a/assets/images/mascot.png b/assets/images/mascot.png new file mode 100644 index 0000000000000000000000000000000000000000..9c05490a43350fd0e0e17b5b456f38b466e94ce3 GIT binary patch literal 222983 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μ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℞7d~ls!O#yat{rYK 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	WE7!}zGao8c;sF<8%{yx)AwFrR8Qj47Q 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 D-%Nhp literal 0 HcmV?d00001 diff --git a/lib/pages/FrostMascot.dart b/lib/pages/FrostMascot.dart new file mode 100644 index 000000000..92058d2b9 --- /dev/null +++ b/lib/pages/FrostMascot.dart @@ -0,0 +1,49 @@ +/* + * 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:stackwallet/pages_desktop_specific/desktop_home_view.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/assets.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; + +import 'add_wallet_views/new_wallet_recovery_phrase_warning_view/recovery_phrase_explanation_dialog.dart'; + +class FrostMascot extends StatelessWidget { + const FrostMascot({ + Key? key, + this.onPressed, + }) : super(key: key); + + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only( + right: 24, + ), + child: GestureDetector( + onTap: () async { + await showDialog<void>( + context: context, + builder: (context) => + const RecoveryPhraseExplanationDialog(), + ); + }, + child: Image( + image: AssetImage( + Assets.png.mascot, + ), + ), + ), + ); + } +} 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 index 08018b43c..c1797cd6e 100644 --- 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 @@ -4,10 +4,10 @@ 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/FrostMascot.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'; @@ -103,17 +103,7 @@ class _ConfirmNewFrostMSWalletCreationViewState ); }, ), - trailing: ExitToMyStackButton( - onPressed: () async { - await showDialog<void>( - context: context, - builder: (_) => const FrostInterruptionDialog( - type: FrostInterruptionDialogType.walletCreation, - popUntilOnYesRouteName: DesktopHomeView.routeName, - ), - ); - }, - ), + trailing: const FrostMascot(), ), body: SizedBox( width: 480, 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 index b408b61ef..d1799bc5e 100644 --- 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 @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/FrostMascot.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'; @@ -121,7 +121,7 @@ class _NewFrostMsWalletViewState appBar: const DesktopAppBar( isCompactHeight: false, leading: AppBarBackButton(), - trailing: ExitToMyStackButton(), + trailing: FrostMascot(), ), body: SizedBox( width: 480, 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 index bf3649a37..282f1716c 100644 --- 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 @@ -3,11 +3,11 @@ 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/FrostMascot.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'; @@ -121,17 +121,7 @@ class _FrostShareCommitmentsViewState ); }, ), - trailing: ExitToMyStackButton( - onPressed: () async { - await showDialog<void>( - context: context, - builder: (_) => const FrostInterruptionDialog( - type: FrostInterruptionDialogType.walletCreation, - popUntilOnYesRouteName: DesktopHomeView.routeName, - ), - ); - }, - ), + trailing: const FrostMascot(), ), body: SizedBox( width: 480, 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 index 0f5e70ee7..33fdcefcf 100644 --- 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 @@ -3,6 +3,7 @@ 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/FrostMascot.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'; @@ -120,17 +121,7 @@ class _FrostShareSharesViewState extends ConsumerState<FrostShareSharesView> { ); }, ), - trailing: ExitToMyStackButton( - onPressed: () async { - await showDialog<void>( - context: context, - builder: (_) => const FrostInterruptionDialog( - type: FrostInterruptionDialogType.walletCreation, - popUntilOnYesRouteName: DesktopHomeView.routeName, - ), - ); - }, - ), + trailing: const FrostMascot(), ), body: SizedBox( width: 480, 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 index 4afb4c0c5..aa5a3d7bf 100644 --- 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 @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/FrostMascot.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'; @@ -47,7 +47,7 @@ class _ShareNewMultisigConfigViewState appBar: const DesktopAppBar( isCompactHeight: false, leading: AppBarBackButton(), - trailing: ExitToMyStackButton(), + trailing: FrostMascot(), ), body: SizedBox( width: 480, diff --git a/lib/utilities/assets.dart b/lib/utilities/assets.dart index ecd170f6c..ff385a44d 100644 --- a/lib/utilities/assets.dart +++ b/lib/utilities/assets.dart @@ -257,6 +257,7 @@ class _PNG { String get glasses => "assets/images/glasses.png"; String get glassesHidden => "assets/images/glasses-hidden.png"; + String get mascot => "assets/images/mascot.png"; } class _ANIMATIONS { From d8e5b8e3052790300eee5c2468fd44669c0fe776 Mon Sep 17 00:00:00 2001 From: likho <likhojiba@gmail.com> Date: Fri, 15 Mar 2024 13:26:50 +0200 Subject: [PATCH 42/57] Add frost mascot and dialog --- .../frost_step_explanation_dialog.dart | 65 +++++++++++++++++++ ...irm_new_frost_ms_wallet_creation_view.dart | 9 ++- .../new/create_new_frost_ms_wallet_view.dart | 9 ++- .../new/frost_share_commitments_view.dart | 7 +- .../frost_ms/new/frost_share_shares_view.dart | 8 ++- .../new/import_new_frost_ms_wallet_view.dart | 10 ++- .../new/share_new_multisig_config_view.dart | 9 ++- .../restore/restore_frost_ms_wallet_view.dart | 9 ++- .../{FrostMascot.dart => frost_mascot.dart} | 18 +++-- .../resharing/finish_resharing_view.dart | 10 ++- .../new/new_continue_sharing_view.dart | 16 ++--- .../new/new_import_resharer_config_view.dart | 9 ++- .../new/new_start_resharing_view.dart | 16 ++--- 13 files changed, 139 insertions(+), 56 deletions(-) create mode 100644 lib/pages/add_wallet_views/frost_ms/frost_step_explanation_dialog.dart rename lib/pages/{FrostMascot.dart => frost_mascot.dart} (66%) diff --git a/lib/pages/add_wallet_views/frost_ms/frost_step_explanation_dialog.dart b/lib/pages/add_wallet_views/frost_ms/frost_step_explanation_dialog.dart new file mode 100644 index 000000000..102bab1e5 --- /dev/null +++ b/lib/pages/add_wallet_views/frost_ms/frost_step_explanation_dialog.dart @@ -0,0 +1,65 @@ +/* + * 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:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/widgets/desktop/secondary_button.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; + +class FrostStepExplanationDialog extends StatelessWidget { + final String title; + final String body; + const FrostStepExplanationDialog({super.key, required this.title, required this.body}); + + @override + Widget build(BuildContext context) { + return StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: STextStyles.titleBold12(context), + ), + const SizedBox( + height: 12, + ), + Text( + body, + style: STextStyles.baseXS(context), + ), + ], + ), + ), + ), + const SizedBox( + height: 24, + ), + Row( + children: [ + const Spacer(), + Expanded( + child: SecondaryButton( + label: "Close", + onPressed: Navigator.of(context).pop, + ), + ), + ], + ), + ], + ), + ); + } +} 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 index c1797cd6e..ffd1127b6 100644 --- 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 @@ -4,7 +4,7 @@ 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/FrostMascot.dart'; +import 'package:stackwallet/pages/frost_mascot.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'; @@ -34,7 +34,7 @@ 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'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; class ConfirmNewFrostMSWalletCreationView extends ConsumerStatefulWidget { const ConfirmNewFrostMSWalletCreationView({ @@ -103,7 +103,10 @@ class _ConfirmNewFrostMSWalletCreationViewState ); }, ), - trailing: const FrostMascot(), + trailing: FrostMascot( + title: 'Lorem ipsum', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam est justo, ', + ), ), body: SizedBox( width: 480, 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 index d1799bc5e..9f42a3fa6 100644 --- 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 @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:stackwallet/pages/FrostMascot.dart'; +import 'package:stackwallet/pages/frost_mascot.dart'; import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/share_new_multisig_config_view.dart'; import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; import 'package:stackwallet/services/frost.dart'; @@ -118,10 +118,13 @@ class _NewFrostMsWalletViewState condition: Util.isDesktop, builder: (child) => DesktopScaffold( background: Theme.of(context).extension<StackColors>()!.background, - appBar: const DesktopAppBar( + appBar: DesktopAppBar( isCompactHeight: false, leading: AppBarBackButton(), - trailing: FrostMascot(), + trailing: FrostMascot( + title: 'Lorem ipsum', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam est justo, ', + ), ), body: SizedBox( width: 480, 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 index 282f1716c..1234dbf8a 100644 --- 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 @@ -3,7 +3,7 @@ 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/FrostMascot.dart'; +import 'package:stackwallet/pages/frost_mascot.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'; @@ -121,7 +121,10 @@ class _FrostShareCommitmentsViewState ); }, ), - trailing: const FrostMascot(), + trailing: FrostMascot( + title: 'Lorem ipsum', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam est justo, ', + ), ), body: SizedBox( width: 480, 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 index 33fdcefcf..20ac39c03 100644 --- 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 @@ -3,12 +3,11 @@ 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/FrostMascot.dart'; +import 'package:stackwallet/pages/frost_mascot.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'; @@ -121,7 +120,10 @@ class _FrostShareSharesViewState extends ConsumerState<FrostShareSharesView> { ); }, ), - trailing: const FrostMascot(), + trailing: FrostMascot( + title: 'Lorem ipsum', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam est justo, ', + ), ), body: SizedBox( width: 480, 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 index 1b08b045d..4eeb3a045 100644 --- 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 @@ -3,7 +3,6 @@ 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'; @@ -25,6 +24,8 @@ 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:stackwallet/pages/frost_mascot.dart'; + class ImportNewFrostMsWalletView extends ConsumerStatefulWidget { const ImportNewFrostMsWalletView({ super.key, @@ -73,10 +74,13 @@ class _ImportNewFrostMsWalletViewState condition: Util.isDesktop, builder: (child) => DesktopScaffold( background: Theme.of(context).extension<StackColors>()!.background, - appBar: const DesktopAppBar( + appBar: DesktopAppBar( isCompactHeight: false, leading: AppBarBackButton(), - trailing: ExitToMyStackButton(), + trailing: FrostMascot( + title: 'Lorem ipsum', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam est justo, ', + ), ), body: SizedBox( width: 480, 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 index aa5a3d7bf..7d463c4ca 100644 --- 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 @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:qr_flutter/qr_flutter.dart'; -import 'package:stackwallet/pages/FrostMascot.dart'; +import 'package:stackwallet/pages/frost_mascot.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/providers/frost_wallet/frost_wallet_providers.dart'; @@ -44,10 +44,13 @@ class _ShareNewMultisigConfigViewState condition: Util.isDesktop, builder: (child) => DesktopScaffold( background: Theme.of(context).extension<StackColors>()!.background, - appBar: const DesktopAppBar( + appBar: DesktopAppBar( isCompactHeight: false, leading: AppBarBackButton(), - trailing: FrostMascot(), + trailing: FrostMascot( + title: 'Lorem ipsum', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam est justo, ', + ), ), body: SizedBox( width: 480, 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 index c9c174ab0..08f36ebde 100644 --- 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 @@ -40,6 +40,8 @@ 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:stackwallet/pages/frost_mascot.dart'; + class RestoreFrostMsWalletView extends ConsumerStatefulWidget { const RestoreFrostMsWalletView({ super.key, @@ -212,10 +214,13 @@ class _RestoreFrostMsWalletViewState condition: Util.isDesktop, builder: (child) => DesktopScaffold( background: Theme.of(context).extension<StackColors>()!.background, - appBar: const DesktopAppBar( + appBar: DesktopAppBar( isCompactHeight: false, leading: AppBarBackButton(), - trailing: ExitToMyStackButton(), + trailing: FrostMascot( + title: 'Lorem ipsum', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam est justo, ', + ) ), body: SizedBox( width: 480, diff --git a/lib/pages/FrostMascot.dart b/lib/pages/frost_mascot.dart similarity index 66% rename from lib/pages/FrostMascot.dart rename to lib/pages/frost_mascot.dart index 92058d2b9..3f6c0562d 100644 --- a/lib/pages/FrostMascot.dart +++ b/lib/pages/frost_mascot.dart @@ -9,18 +9,16 @@ */ import 'package:flutter/material.dart'; -import 'package:stackwallet/pages_desktop_specific/desktop_home_view.dart'; -import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/frost_step_explanation_dialog.dart'; import 'package:stackwallet/utilities/assets.dart'; -import 'package:stackwallet/utilities/text_styles.dart'; - -import 'add_wallet_views/new_wallet_recovery_phrase_warning_view/recovery_phrase_explanation_dialog.dart'; class FrostMascot extends StatelessWidget { - const FrostMascot({ - Key? key, - this.onPressed, - }) : super(key: key); + final String title; + final String body; + FrostMascot({ + super.key, + this.onPressed, required this.title, required this.body, + }); final VoidCallback? onPressed; @@ -35,7 +33,7 @@ class FrostMascot extends StatelessWidget { await showDialog<void>( context: context, builder: (context) => - const RecoveryPhraseExplanationDialog(), + FrostStepExplanationDialog(title: title, body: body), ); }, child: Image( 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 index 8d23e9ed4..5ff5c815d 100644 --- 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 @@ -8,7 +8,6 @@ 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'; @@ -34,6 +33,8 @@ 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:stackwallet/pages/frost_mascot.dart'; + class FinishResharingView extends ConsumerStatefulWidget { const FinishResharingView({ super.key, @@ -180,10 +181,13 @@ class _FinishResharingViewState extends ConsumerState<FinishResharingView> { condition: Util.isDesktop, builder: (child) => DesktopScaffold( background: Theme.of(context).extension<StackColors>()!.background, - appBar: const DesktopAppBar( + appBar: DesktopAppBar( isCompactHeight: false, leading: AppBarBackButton(), - trailing: ExitToMyStackButton(), + trailing: FrostMascot( + title: 'Lorem ipsum', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam est justo, ', + ), ), body: SizedBox( width: 480, 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 index 86ff2ebe0..5e4ed4762 100644 --- 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 @@ -5,7 +5,6 @@ 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'; @@ -20,6 +19,8 @@ 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/pages/frost_mascot.dart'; + class NewContinueSharingView extends ConsumerStatefulWidget { const NewContinueSharingView({ super.key, @@ -68,16 +69,9 @@ class _NewContinueSharingViewState ); }, ), - trailing: ExitToMyStackButton( - onPressed: () async { - await showDialog<void>( - context: context, - builder: (_) => const FrostInterruptionDialog( - type: FrostInterruptionDialogType.resharing, - popUntilOnYesRouteName: DesktopHomeView.routeName, - ), - ); - }, + trailing: FrostMascot( + title: 'Lorem ipsum', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam est justo, ', ), ), body: SizedBox( 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 index f3ef1ec0b..698363923 100644 --- 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 @@ -27,6 +27,8 @@ 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:stackwallet/pages/frost_mascot.dart'; + class NewImportResharerConfigView extends ConsumerStatefulWidget { const NewImportResharerConfigView({ super.key, @@ -89,10 +91,13 @@ class _NewImportResharerConfigViewState condition: Util.isDesktop, builder: (child) => DesktopScaffold( background: Theme.of(context).extension<StackColors>()!.background, - appBar: const DesktopAppBar( + appBar: DesktopAppBar( isCompactHeight: false, leading: AppBarBackButton(), - trailing: ExitToMyStackButton(), + trailing: FrostMascot( + title: 'Lorem ipsum', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam est justo, ', + ), ), body: SizedBox( width: 480, 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 index fb6107c2c..7173eff3d 100644 --- 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 @@ -5,7 +5,6 @@ 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'; @@ -27,6 +26,8 @@ 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:stackwallet/pages/frost_mascot.dart'; + class NewStartResharingView extends ConsumerStatefulWidget { const NewStartResharingView({ super.key, @@ -146,16 +147,9 @@ class _NewStartResharingViewState extends ConsumerState<NewStartResharingView> { ); }, ), - trailing: ExitToMyStackButton( - onPressed: () async { - await showDialog<void>( - context: context, - builder: (_) => const FrostInterruptionDialog( - type: FrostInterruptionDialogType.resharing, - popUntilOnYesRouteName: DesktopHomeView.routeName, - ), - ); - }, + trailing: FrostMascot( + title: 'Lorem ipsum', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam est justo, ', ), ), body: SizedBox( From f6d50756e8b4db841fd922474d1a9cea32271950 Mon Sep 17 00:00:00 2001 From: rehrar <diego@cypherstack.com> Date: Fri, 15 Mar 2024 11:55:39 -0600 Subject: [PATCH 43/57] Updated XCode podfile --- macos/Podfile.lock | 8 +++- macos/Runner.xcodeproj/project.pbxproj | 45 ++++++++++++++++++- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index f76e35a57..ba853d0f9 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -62,6 +62,8 @@ PODS: - flutter_secure_storage_macos (6.1.1): - FlutterMacOS - FlutterMacOS (1.0.0) + - frostdart (0.0.1): + - FlutterMacOS - isar_flutter_libs (1.0.0): - FlutterMacOS - lelantus (0.0.1): @@ -98,6 +100,7 @@ DEPENDENCIES: - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) + - frostdart (from `Flutter/ephemeral/.symlinks/plugins/frostdart/macos`) - isar_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/isar_flutter_libs/macos`) - lelantus (from `Flutter/ephemeral/.symlinks/plugins/lelantus/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) @@ -140,6 +143,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos FlutterMacOS: :path: Flutter/ephemeral + frostdart: + :path: Flutter/ephemeral/.symlinks/plugins/frostdart/macos isar_flutter_libs: :path: Flutter/ephemeral/.symlinks/plugins/isar_flutter_libs/macos lelantus: @@ -171,10 +176,11 @@ SPEC CHECKSUMS: device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f devicelocale: 9f0f36ac651cabae2c33f32dcff4f32b61c38225 flutter_libepiccash: be1560a04150c5cc85bcf08d236ec2b3d1f5d8da - flutter_libsparkmobile: 8ae86b0ccc7e52c9db6b53e258ee2977deb184ab + flutter_libsparkmobile: df2d36af1691379c81249e7be7b68be3c81d388b flutter_local_notifications: 3805ca215b2fb7f397d78b66db91f6a747af52e4 flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + frostdart: e6bf3119527ccfbcec1b8767da6ede5bb4c4f716 isar_flutter_libs: 43385c99864c168fadba7c9adeddc5d38838ca6a lelantus: 308e42c5a648598936a07a234471dd8cf8e687a0 package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index c2c2e62ad..f20cb25e7 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -31,6 +31,9 @@ B98151822A67402A009D013C /* mobileliblelantus.framework in Bundle Framework */ = {isa = PBXBuildFile; fileRef = B98151802A674022009D013C /* mobileliblelantus.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; B98151842A674143009D013C /* libsqlite3.0.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = B98151832A674143009D013C /* libsqlite3.0.tbd */; }; BFD0376C00E1FFD46376BB9D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9206484E84CB0AD93E3E68CA /* Pods_RunnerTests.framework */; }; + F1FA2C4E2BA4B49F00BDA1BB /* frostdart.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = F1FA2C4D2BA4B49F00BDA1BB /* frostdart.dylib */; settings = {ATTRIBUTES = (Weak, ); }; }; + F1FA2C502BA4B4CA00BDA1BB /* frostdart.dylib in Resources */ = {isa = PBXBuildFile; fileRef = F1FA2C4F2BA4B4CA00BDA1BB /* frostdart.dylib */; }; + F1FA2C512BA4B51E00BDA1BB /* frostdart.dylib in Bundle Framework */ = {isa = PBXBuildFile; fileRef = F1FA2C4D2BA4B49F00BDA1BB /* frostdart.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; F653CA022D33E8B60E11A9F3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E6036BF01BF05EA773C76D22 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -58,6 +61,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + F1FA2C512BA4B51E00BDA1BB /* frostdart.dylib in Bundle Framework */, B98151822A67402A009D013C /* mobileliblelantus.framework in Bundle Framework */, ); name = "Bundle Framework"; @@ -94,6 +98,8 @@ B98151832A674143009D013C /* libsqlite3.0.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.0.tbd; path = usr/lib/libsqlite3.0.tbd; sourceTree = SDKROOT; }; BF5E76865ACB46314AC27D8F /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; }; E6036BF01BF05EA773C76D22 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F1FA2C4D2BA4B49F00BDA1BB /* frostdart.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = frostdart.dylib; path = ../crypto_plugins/frostdart/macos/frostdart.dylib; sourceTree = "<group>"; }; + F1FA2C4F2BA4B4CA00BDA1BB /* frostdart.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = frostdart.dylib; path = ../crypto_plugins/frostdart/macos/frostdart.dylib; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -111,6 +117,7 @@ files = ( B98151842A674143009D013C /* libsqlite3.0.tbd in Frameworks */, B98151812A674022009D013C /* mobileliblelantus.framework in Frameworks */, + F1FA2C4E2BA4B49F00BDA1BB /* frostdart.dylib in Frameworks */, F653CA022D33E8B60E11A9F3 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -140,6 +147,7 @@ 33CC10E42044A3C60003C045 = { isa = PBXGroup; children = ( + F1FA2C4F2BA4B4CA00BDA1BB /* frostdart.dylib */, 33FAB671232836740065AC1E /* Runner */, 33CEB47122A05771004F2AC0 /* Flutter */, 331C80D6294CF71000263BE5 /* RunnerTests */, @@ -196,6 +204,7 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + F1FA2C4D2BA4B49F00BDA1BB /* frostdart.dylib */, B98151832A674143009D013C /* libsqlite3.0.tbd */, B98151802A674022009D013C /* mobileliblelantus.framework */, E6036BF01BF05EA773C76D22 /* Pods_Runner.framework */, @@ -268,7 +277,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 331C80D4294CF70F00263BE5 = { @@ -325,6 +334,7 @@ files = ( 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + F1FA2C502BA4B4CA00BDA1BB /* frostdart.dylib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -610,6 +620,17 @@ "$(inherited)", "@executable_path/../Frameworks", ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "\"${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cw_monero/macos/External/macos/lib\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cw_shared_external/macos/External/macos/lib\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cw_wownero/macos/External/macos/lib\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/flutter_libepiccash/macos/libs\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/isar_flutter_libs/macos\"", + /usr/lib/swift, + "$(PATH)/crypto_plugins/frostdart/macos\n", + ); PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -763,6 +784,17 @@ "$(inherited)", "@executable_path/../Frameworks", ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "\"${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cw_monero/macos/External/macos/lib\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cw_shared_external/macos/External/macos/lib\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cw_wownero/macos/External/macos/lib\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/flutter_libepiccash/macos/libs\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/isar_flutter_libs/macos\"", + /usr/lib/swift, + "$(PATH)/crypto_plugins/frostdart/macos\n", + ); PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -807,6 +839,17 @@ "$(inherited)", "@executable_path/../Frameworks", ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "\"${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cw_monero/macos/External/macos/lib\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cw_shared_external/macos/External/macos/lib\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cw_wownero/macos/External/macos/lib\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/flutter_libepiccash/macos/libs\"", + "\"${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/isar_flutter_libs/macos\"", + /usr/lib/swift, + "$(PATH)/crypto_plugins/frostdart/macos\n", + ); PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a9d38bc3b..5b6f6cbd1 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="UTF-8"?> <Scheme - LastUpgradeVersion = "1300" + LastUpgradeVersion = "1430" version = "1.3"> <BuildAction parallelizeBuildables = "YES" From 27410b8eadc7c203a09c22e126cb59c2d9b5f3aa Mon Sep 17 00:00:00 2001 From: Likho <likhojiba@gmail.com> Date: Fri, 15 Mar 2024 20:02:14 +0200 Subject: [PATCH 44/57] Change coin ordering --- lib/utilities/enums/coin_enum.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/utilities/enums/coin_enum.dart b/lib/utilities/enums/coin_enum.dart index 0356c1e7c..abb6985ea 100644 --- a/lib/utilities/enums/coin_enum.dart +++ b/lib/utilities/enums/coin_enum.dart @@ -13,10 +13,10 @@ import 'package:stackwallet/utilities/constants.dart'; enum Coin { bitcoin, - bitcoinFrost, monero, banano, bitcoincash, + bitcoinFrost, dogecoin, eCash, epicCash, @@ -36,8 +36,8 @@ enum Coin { /// bitcoinTestNet, - bitcoinFrostTestNet, bitcoincashTestnet, + bitcoinFrostTestNet, dogecoinTestNet, firoTestNet, litecoinTestNet, From 1a5e31d046986a508130bb6af1463a26b15ffc81 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Fri, 15 Mar 2024 18:57:30 -0500 Subject: [PATCH 45/57] add missing send view args option --- lib/route_generator.dart | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 4eb906b8a..e4d188cdf 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -1861,7 +1861,19 @@ class RouteGenerator { name: settings.name, ), ); + } else if (args is ({Coin coin, String walletId})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SendView( + walletId: args.walletId, + coin: args.coin, + ), + settings: RouteSettings( + name: settings.name, + ), + ); } + return _routeError("${settings.name} invalid args: ${args.toString()}"); case TokenSendView.routeName: From c1b9ba085fef6e7a8d343cdc11aee6d55b7b157f Mon Sep 17 00:00:00 2001 From: likho <likhojiba@gmail.com> Date: Mon, 18 Mar 2024 17:49:11 +0200 Subject: [PATCH 46/57] In the tx list only process txs that are not anon --- lib/wallets/wallet/impl/particl_wallet.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/wallets/wallet/impl/particl_wallet.dart b/lib/wallets/wallet/impl/particl_wallet.dart index 89faa8950..36ab9c83b 100644 --- a/lib/wallets/wallet/impl/particl_wallet.dart +++ b/lib/wallets/wallet/impl/particl_wallet.dart @@ -191,10 +191,9 @@ class ParticlWallet extends Bip39HDWallet final List<String> addresses = []; String valueStringSats = "0"; OutpointV2? outpoint; - final coinbase = map["coinbase"] as String?; - - if (coinbase == null) { + final txType = map['type'] as String?; + if (coinbase == null && txType == null) { // Not a coinbase (ie a typical input). final txid = map["txid"] as String; final vout = map["vout"] as int; From 11136d3d140d6f74c64d892dcca16a5cdd1e70bf Mon Sep 17 00:00:00 2001 From: likho <likhojiba@gmail.com> Date: Mon, 18 Mar 2024 17:50:04 +0200 Subject: [PATCH 47/57] Revert "In the tx list only process txs that are not anon" This reverts commit c1b9ba085fef6e7a8d343cdc11aee6d55b7b157f. --- lib/wallets/wallet/impl/particl_wallet.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/wallets/wallet/impl/particl_wallet.dart b/lib/wallets/wallet/impl/particl_wallet.dart index 36ab9c83b..89faa8950 100644 --- a/lib/wallets/wallet/impl/particl_wallet.dart +++ b/lib/wallets/wallet/impl/particl_wallet.dart @@ -191,9 +191,10 @@ class ParticlWallet extends Bip39HDWallet final List<String> addresses = []; String valueStringSats = "0"; OutpointV2? outpoint; + final coinbase = map["coinbase"] as String?; - final txType = map['type'] as String?; - if (coinbase == null && txType == null) { + + if (coinbase == null) { // Not a coinbase (ie a typical input). final txid = map["txid"] as String; final vout = map["vout"] as int; From ad4974e0725ad960a81bed330722d12ffa713682 Mon Sep 17 00:00:00 2001 From: likho <likhojiba@gmail.com> Date: Mon, 18 Mar 2024 17:52:40 +0200 Subject: [PATCH 48/57] Ignore anon type txs when syncing wallet --- lib/wallets/wallet/impl/particl_wallet.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/wallets/wallet/impl/particl_wallet.dart b/lib/wallets/wallet/impl/particl_wallet.dart index 89faa8950..e0d5bc9c2 100644 --- a/lib/wallets/wallet/impl/particl_wallet.dart +++ b/lib/wallets/wallet/impl/particl_wallet.dart @@ -193,8 +193,8 @@ class ParticlWallet extends Bip39HDWallet OutpointV2? outpoint; final coinbase = map["coinbase"] as String?; - - if (coinbase == null) { + final txType = map['type'] as String?; + if (coinbase == null && txType == null) { // Not a coinbase (ie a typical input). final txid = map["txid"] as String; final vout = map["vout"] as int; From 85cdb0cc096cb9c9437bc27d06227ccdd5cbafeb Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Wed, 20 Mar 2024 15:33:25 -0500 Subject: [PATCH 49/57] Default to using `double.parse` in `electrum_adapter` `catch` with the(ir custom) original `_parseDouble` --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 7b9adb7c9..56a538c7d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -528,8 +528,8 @@ packages: dependency: "direct main" description: path: "." - ref: "2897c6448e131241d4d91fe23fdab83305134225" - resolved-ref: "2897c6448e131241d4d91fe23fdab83305134225" + ref: "9e9441fc1e9ace8907256fff05fe2c607b0933b6" + resolved-ref: "9e9441fc1e9ace8907256fff05fe2c607b0933b6" url: "https://github.com/cypherstack/electrum_adapter.git" source: git version: "3.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index fd19a216e..940682650 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -179,7 +179,7 @@ dependencies: electrum_adapter: git: url: https://github.com/cypherstack/electrum_adapter.git - ref: 2897c6448e131241d4d91fe23fdab83305134225 + ref: 9e9441fc1e9ace8907256fff05fe2c607b0933b6 stream_channel: ^2.1.0 dev_dependencies: From 5e7c9ad65bd4d6b544e8a6ec5e7ba58282ba353c Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Wed, 20 Mar 2024 16:04:28 -0500 Subject: [PATCH 50/57] add FROST enabled pref a bool toggle from hidden settings menu --- .../global_settings_view/hidden_settings.dart | 32 ++++++++++++++++++- lib/utilities/prefs.dart | 22 +++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/lib/pages/settings_views/global_settings_view/hidden_settings.dart b/lib/pages/settings_views/global_settings_view/hidden_settings.dart index 6cc47a0d4..ec96afde5 100644 --- a/lib/pages/settings_views/global_settings_view/hidden_settings.dart +++ b/lib/pages/settings_views/global_settings_view/hidden_settings.dart @@ -10,6 +10,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -215,7 +216,6 @@ class HiddenSettings extends StatelessWidget { ), ); }), - const SizedBox( height: 12, ), @@ -252,6 +252,36 @@ class HiddenSettings extends StatelessWidget { } }, ), + Consumer( + builder: (_, ref, __) { + return GestureDetector( + onTap: () async { + ref + .read(prefsChangeNotifierProvider) + .frostEnabled = + !(ref + .read(prefsChangeNotifierProvider) + .frostEnabled); + if (kDebugMode) { + print( + "FROST enabled: ${ref.read(prefsChangeNotifierProvider).frostEnabled}"); + } + }, + child: RoundedWhiteContainer( + child: Text( + "Toggle FROST multisig", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension<StackColors>()! + .accentColorDark), + ), + ), + ); + }, + ), + const SizedBox( + height: 12, + ), Consumer( builder: (_, ref, __) { return GestureDetector( diff --git a/lib/utilities/prefs.dart b/lib/utilities/prefs.dart index 07726bdf1..8fbbbf069 100644 --- a/lib/utilities/prefs.dart +++ b/lib/utilities/prefs.dart @@ -68,6 +68,7 @@ class Prefs extends ChangeNotifier { await _setMaxDecimals(); _useTor = await _getUseTor(); _fusionServerInfo = await _getFusionServerInfo(); + _frostEnabled = await _getFrostEnabled(); _initialized = true; } @@ -1008,4 +1009,25 @@ class Prefs extends ChangeNotifier { return actualMap; } + + // FROST multisig + + bool _frostEnabled = false; + + bool get frostEnabled => _frostEnabled; + + set frostEnabled(bool frostEnabled) { + if (_frostEnabled != frostEnabled) { + DB.instance.put<dynamic>( + boxName: DB.boxNamePrefs, key: "frostEnabled", value: frostEnabled); + _frostEnabled = frostEnabled; + notifyListeners(); + } + } + + Future<bool> _getFrostEnabled() async { + return await DB.instance.get<dynamic>( + boxName: DB.boxNamePrefs, key: "frostEnabled") as bool? ?? + false; + } } From 0f4fb8378fbbbeeabb5ae963528792acbd31300f Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Wed, 20 Mar 2024 16:08:33 -0500 Subject: [PATCH 51/57] use pref to cull frost things if not enabled --- .../add_wallet_view/add_wallet_view.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart index 32e1b618c..3d6c4b8df 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart @@ -134,9 +134,18 @@ class _AddWalletViewState extends ConsumerState<AddWalletView> { _coins.remove(Coin.wownero); } + // Remove FROST from the list of coins based on our frostEnabled preference. + if (!ref.read(prefsChangeNotifierProvider).frostEnabled) { + _coins.remove(Coin.bitcoinFrost); + } + coinEntities.addAll(_coins.map((e) => CoinEntity(e))); if (ref.read(prefsChangeNotifierProvider).showTestNetCoins) { + if (!ref.read(prefsChangeNotifierProvider).frostEnabled) { + _coinsTestnet.remove(Coin.bitcoinFrostTestNet); + } + coinEntities.addAll(_coinsTestnet.map((e) => CoinEntity(e))); } From 96c1976d7874aea35e48a06110b9b412afced255 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Wed, 20 Mar 2024 16:16:44 -0500 Subject: [PATCH 52/57] use same back button widget on mobile as on desktop --- .../global_settings_view/hidden_settings.dart | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/lib/pages/settings_views/global_settings_view/hidden_settings.dart b/lib/pages/settings_views/global_settings_view/hidden_settings.dart index ec96afde5..b8b9a9982 100644 --- a/lib/pages/settings_views/global_settings_view/hidden_settings.dart +++ b/lib/pages/settings_views/global_settings_view/hidden_settings.dart @@ -23,7 +23,6 @@ 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/utilities/util.dart'; import 'package:stackwallet/widgets/background.dart'; import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; import 'package:stackwallet/widgets/onetime_popups/tor_has_been_add_dialog.dart'; @@ -40,27 +39,25 @@ class HiddenSettings extends StatelessWidget { child: Scaffold( backgroundColor: Theme.of(context).extension<StackColors>()!.background, appBar: AppBar( - leading: Util.isDesktop - ? Padding( - padding: const EdgeInsets.all(8.0), - child: AppBarIconButton( - size: 32, - color: Theme.of(context) - .extension<StackColors>()! - .textFieldDefaultBG, - shadows: const [], - icon: SvgPicture.asset( - Assets.svg.arrowLeft, - width: 18, - height: 18, - color: Theme.of(context) - .extension<StackColors>()! - .topNavIconPrimary, - ), - onPressed: Navigator.of(context).pop, - ), - ) - : Container(), + leading: Padding( + padding: const EdgeInsets.all(8.0), + child: AppBarIconButton( + size: 32, + color: Theme.of(context) + .extension<StackColors>()! + .textFieldDefaultBG, + shadows: const [], + icon: SvgPicture.asset( + Assets.svg.arrowLeft, + width: 18, + height: 18, + color: Theme.of(context) + .extension<StackColors>()! + .topNavIconPrimary, + ), + onPressed: Navigator.of(context).pop, + ), + ), title: Text( "Dev options", style: STextStyles.navBarTitle(context), From d9163a2bbd72d1638386737a713b586d49e3f050 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Wed, 20 Mar 2024 16:48:55 -0500 Subject: [PATCH 53/57] hide tor stacy buttons --- .../global_settings_view/hidden_settings.dart | 87 +++++++++---------- 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/lib/pages/settings_views/global_settings_view/hidden_settings.dart b/lib/pages/settings_views/global_settings_view/hidden_settings.dart index b8b9a9982..915d48552 100644 --- a/lib/pages/settings_views/global_settings_view/hidden_settings.dart +++ b/lib/pages/settings_views/global_settings_view/hidden_settings.dart @@ -14,8 +14,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:hive_flutter/hive_flutter.dart'; -import 'package:stackwallet/db/hive/db.dart'; import 'package:stackwallet/notifications/show_flush_bar.dart'; import 'package:stackwallet/providers/global/debug_service_provider.dart'; import 'package:stackwallet/providers/providers.dart'; @@ -25,7 +23,6 @@ import 'package:stackwallet/utilities/constants.dart'; import 'package:stackwallet/utilities/text_styles.dart'; import 'package:stackwallet/widgets/background.dart'; import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; -import 'package:stackwallet/widgets/onetime_popups/tor_has_been_add_dialog.dart'; import 'package:stackwallet/widgets/rounded_white_container.dart'; class HiddenSettings extends StatelessWidget { @@ -174,48 +171,48 @@ class HiddenSettings extends StatelessWidget { const SizedBox( height: 12, ), - Consumer(builder: (_, ref, __) { - return GestureDetector( - onTap: () async { - await showOneTimeTorHasBeenAddedDialogIfRequired( - context, - ); - }, - child: RoundedWhiteContainer( - child: Text( - "Test tor stacy popup", - style: STextStyles.button(context).copyWith( - color: Theme.of(context) - .extension<StackColors>()! - .accentColorDark), - ), - ), - ); - }), - const SizedBox( - height: 12, - ), - Consumer(builder: (_, ref, __) { - return GestureDetector( - onTap: () async { - final box = await Hive.openBox<bool>( - DB.boxNameOneTimeDialogsShown); - await box.clear(); - }, - child: RoundedWhiteContainer( - child: Text( - "Reset tor stacy popup", - style: STextStyles.button(context).copyWith( - color: Theme.of(context) - .extension<StackColors>()! - .accentColorDark), - ), - ), - ); - }), - const SizedBox( - height: 12, - ), + // Consumer(builder: (_, ref, __) { + // return GestureDetector( + // onTap: () async { + // await showOneTimeTorHasBeenAddedDialogIfRequired( + // context, + // ); + // }, + // child: RoundedWhiteContainer( + // child: Text( + // "Test tor stacy popup", + // style: STextStyles.button(context).copyWith( + // color: Theme.of(context) + // .extension<StackColors>()! + // .accentColorDark), + // ), + // ), + // ); + // }), + // const SizedBox( + // height: 12, + // ), + // Consumer(builder: (_, ref, __) { + // return GestureDetector( + // onTap: () async { + // final box = await Hive.openBox<bool>( + // DB.boxNameOneTimeDialogsShown); + // await box.clear(); + // }, + // child: RoundedWhiteContainer( + // child: Text( + // "Reset tor stacy popup", + // style: STextStyles.button(context).copyWith( + // color: Theme.of(context) + // .extension<StackColors>()! + // .accentColorDark), + // ), + // ), + // ); + // }), + // const SizedBox( + // height: 12, + // ), Consumer( builder: (_, ref, __) { if (ref.watch(prefsChangeNotifierProvider From e597045db49626f96f233cb7eff27ce8344e803a Mon Sep 17 00:00:00 2001 From: Diego Salazar <diego@cypherstack.com> Date: Mon, 25 Mar 2024 17:33:53 -0600 Subject: [PATCH 54/57] Update version (v1.10.4, build 216) --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 940682650..0ddcae808 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ description: Stack Wallet # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.10.3+215 +version: 1.10.4+216 environment: sdk: ">=3.0.2 <4.0.0" From 22504c42a9acb58dd4327e8bec1b6759c68aea62 Mon Sep 17 00:00:00 2001 From: sneurlax <sneurlax@gmail.com> Date: Thu, 28 Mar 2024 15:54:46 -0500 Subject: [PATCH 55/57] do not change rust version in parallel --- scripts/android/build_all.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/android/build_all.sh b/scripts/android/build_all.sh index f42a6c5d3..28e56acd4 100755 --- a/scripts/android/build_all.sh +++ b/scripts/android/build_all.sh @@ -12,7 +12,7 @@ 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 ) & +(cd ../../crypto_plugins/flutter_libmonero/scripts/android/ && ./build_all.sh ) && set_rust_to_1720 & (cd ../../crypto_plugins/frostdart/scripts/android && ./build_all.sh ) & From 98c8b4dc6ace8141e670c5ce54b706de41d412e1 Mon Sep 17 00:00:00 2001 From: Kronk <49569383+Kronkmeister@users.noreply.github.com> Date: Mon, 1 Apr 2024 17:29:22 +0200 Subject: [PATCH 56/57] Update block_explorers.dart (change eCash default explorer) Changed the eCash block explorer from bitcoinabc.org to the default explorer.e.cash. --- lib/utilities/block_explorers.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utilities/block_explorers.dart b/lib/utilities/block_explorers.dart index 9f3e92c5d..941f7c599 100644 --- a/lib/utilities/block_explorers.dart +++ b/lib/utilities/block_explorers.dart @@ -31,7 +31,7 @@ Uri getDefaultBlockExplorerUrlFor({ case Coin.dogecoin: return Uri.parse("https://chain.so/tx/DOGE/$txid"); case Coin.eCash: - return Uri.parse("https://explorer.bitcoinabc.org/tx/$txid"); + return Uri.parse("https://explorer.e.cash/tx/$txid"); case Coin.dogecoinTestNet: return Uri.parse("https://chain.so/tx/DOGETEST/$txid"); case Coin.epicCash: From 9eb6aef23621d37797f68e02b2bc85c055ed70d2 Mon Sep 17 00:00:00 2001 From: Diego Salazar <diego@cypherstack.com> Date: Tue, 2 Apr 2024 17:47:54 -0600 Subject: [PATCH 57/57] Update version (v1.10.4, build 217) --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 0ddcae808..0daa6d065 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ description: Stack Wallet # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.10.4+216 +version: 1.10.4+217 environment: sdk: ">=3.0.2 <4.0.0"