From dd0fc6f369139c1e368f3ad519fec7e76af5b8c1 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 18 Jan 2024 13:33:50 -0600 Subject: [PATCH 01/38] 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 { late final StreamSubscription> _subscription; late final Query _query; + late final Coin coin; BorderRadius get _borderRadiusFirst { return BorderRadius.only( @@ -69,6 +71,7 @@ class _TransactionsV2ListState extends ConsumerState { @override void initState() { + coin = ref.read(pWallets).getWallet(widget.walletId).info.coin; _query = ref .read(mainDBProvider) .isar @@ -110,8 +113,6 @@ class _TransactionsV2ListState extends ConsumerState { @override Widget build(BuildContext context) { - final coin = ref.watch(pWallets).getWallet(widget.walletId).info.coin; - return FutureBuilder( future: _query.findAll(), builder: (fbContext, AsyncSnapshot> snapshot) { From fbbd175d0f21b5c94a74ebb4725f0614de10559d Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 18 Jan 2024 13:34:11 -0600 Subject: [PATCH 02/38] 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()!.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 Date: Thu, 18 Jan 2024 14:02:41 -0600 Subject: [PATCH 03/38] 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 Date: Thu, 18 Jan 2024 17:47:06 -0600 Subject: [PATCH 04/38] 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 { 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 { 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 { 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 { 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 { 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 knownSalts; + + FrostWalletInfo({ + required this.walletId, + required this.knownSalts, + }); + + FrostWalletInfo copyWith({ + List? knownSalts, + }) { + return FrostWalletInfo( + walletId: walletId, + knownSalts: knownSalts ?? this.knownSalts, + ); + } + + Future updateKnownSalts( + List 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 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 offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + bytesCount += 3 + object.knownSalts.length * 3; + { + for (var i = 0; i < object.knownSalts.length; i++) { + final value = object.knownSalts[i]; + bytesCount += value.length * 3; + } + } + bytesCount += 3 + object.walletId.length * 3; + return bytesCount; +} + +void _frostWalletInfoSerialize( + FrostWalletInfo object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeStringList(offsets[0], object.knownSalts); + writer.writeString(offsets[1], object.walletId); +} + +FrostWalletInfo _frostWalletInfoDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = FrostWalletInfo( + knownSalts: reader.readStringList(offsets[0]) ?? [], + walletId: reader.readString(offsets[1]), + ); + object.id = id; + return object; +} + +P _frostWalletInfoDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readStringList(offset) ?? []) as P; + case 1: + return (reader.readString(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _frostWalletInfoGetId(FrostWalletInfo object) { + return object.id; +} + +List> _frostWalletInfoGetLinks(FrostWalletInfo object) { + return []; +} + +void _frostWalletInfoAttach( + IsarCollection col, Id id, FrostWalletInfo object) { + object.id = id; +} + +extension FrostWalletInfoByIndex on IsarCollection { + Future getByWalletId(String walletId) { + return getByIndex(r'walletId', [walletId]); + } + + FrostWalletInfo? getByWalletIdSync(String walletId) { + return getByIndexSync(r'walletId', [walletId]); + } + + Future deleteByWalletId(String walletId) { + return deleteByIndex(r'walletId', [walletId]); + } + + bool deleteByWalletIdSync(String walletId) { + return deleteByIndexSync(r'walletId', [walletId]); + } + + Future> getAllByWalletId(List walletIdValues) { + final values = walletIdValues.map((e) => [e]).toList(); + return getAllByIndex(r'walletId', values); + } + + List getAllByWalletIdSync(List walletIdValues) { + final values = walletIdValues.map((e) => [e]).toList(); + return getAllByIndexSync(r'walletId', values); + } + + Future deleteAllByWalletId(List walletIdValues) { + final values = walletIdValues.map((e) => [e]).toList(); + return deleteAllByIndex(r'walletId', values); + } + + int deleteAllByWalletIdSync(List walletIdValues) { + final values = walletIdValues.map((e) => [e]).toList(); + return deleteAllByIndexSync(r'walletId', values); + } + + Future putByWalletId(FrostWalletInfo object) { + return putByIndex(r'walletId', object); + } + + Id putByWalletIdSync(FrostWalletInfo object, {bool saveLinks = true}) { + return putByIndexSync(r'walletId', object, saveLinks: saveLinks); + } + + Future> putAllByWalletId(List objects) { + return putAllByIndex(r'walletId', objects); + } + + List putAllByWalletIdSync(List objects, + {bool saveLinks = true}) { + return putAllByIndexSync(r'walletId', objects, saveLinks: saveLinks); + } +} + +extension FrostWalletInfoQueryWhereSort + on QueryBuilder { + QueryBuilder anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension FrostWalletInfoQueryWhere + on QueryBuilder { + QueryBuilder idEqualTo( + Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: id, + upper: id, + )); + }); + } + + QueryBuilder + idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder + idGreaterThan(Id id, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder idLessThan( + Id id, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + walletIdEqualTo(String walletId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IndexWhereClause.equalTo( + indexName: r'walletId', + value: [walletId], + )); + }); + } + + QueryBuilder + walletIdNotEqualTo(String walletId) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'walletId', + lower: [], + upper: [walletId], + includeUpper: false, + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'walletId', + lower: [walletId], + includeLower: false, + upper: [], + )); + } else { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'walletId', + lower: [walletId], + includeLower: false, + upper: [], + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'walletId', + lower: [], + upper: [walletId], + includeUpper: false, + )); + } + }); + } +} + +extension FrostWalletInfoQueryFilter + on QueryBuilder { + QueryBuilder + idEqualTo(Id value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'id', + value: value, + )); + }); + } + + QueryBuilder + idGreaterThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder + idLessThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder + idBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + knownSaltsElementEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'knownSalts', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'knownSalts', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'knownSalts', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + knownSaltsElementIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'knownSalts', + value: '', + )); + }); + } + + QueryBuilder + knownSaltsElementIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'knownSalts', + value: '', + )); + }); + } + + QueryBuilder + knownSaltsLengthEqualTo(int length) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + length, + true, + length, + true, + ); + }); + } + + QueryBuilder + knownSaltsIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + 0, + true, + 0, + true, + ); + }); + } + + QueryBuilder + knownSaltsIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + 0, + false, + 999999, + true, + ); + }); + } + + QueryBuilder + knownSaltsLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + 0, + true, + length, + include, + ); + }); + } + + QueryBuilder + knownSaltsLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + length, + include, + 999999, + true, + ); + }); + } + + QueryBuilder + knownSaltsLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'knownSalts', + lower, + includeLower, + upper, + includeUpper, + ); + }); + } + + QueryBuilder + walletIdEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'walletId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'walletId', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + walletIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'walletId', + value: '', + )); + }); + } + + QueryBuilder + walletIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'walletId', + value: '', + )); + }); + } +} + +extension FrostWalletInfoQueryObject + on QueryBuilder {} + +extension FrostWalletInfoQueryLinks + on QueryBuilder {} + +extension FrostWalletInfoQuerySortBy + on QueryBuilder { + QueryBuilder + sortByWalletId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.asc); + }); + } + + QueryBuilder + sortByWalletIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.desc); + }); + } +} + +extension FrostWalletInfoQuerySortThenBy + on QueryBuilder { + QueryBuilder thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder + thenByWalletId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.asc); + }); + } + + QueryBuilder + thenByWalletIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.desc); + }); + } +} + +extension FrostWalletInfoQueryWhereDistinct + on QueryBuilder { + QueryBuilder + distinctByKnownSalts() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'knownSalts'); + }); + } + + QueryBuilder distinctByWalletId( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'walletId', caseSensitive: caseSensitive); + }); + } +} + +extension FrostWalletInfoQueryProperty + on QueryBuilder { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder, QQueryOperations> + knownSaltsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'knownSalts'); + }); + } + + QueryBuilder 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 extends Wallet + 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> 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 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 checkSaveInitialReceivingAddress() { + // TODO: implement checkSaveInitialReceivingAddress + throw UnimplementedError(); + } + + @override + Future confirmSend({required TxData txData}) { + // TODO: implement confirmSend + throw UnimplementedError(); + } + + @override + Future estimateFeeFor(Amount amount, int feeRate) { + // TODO: implement estimateFeeFor + throw UnimplementedError(); + } + + @override + // TODO: implement fees + Future get fees => throw UnimplementedError(); + + @override + Future prepareSend({required TxData txData}) { + // TODO: implement prepareSendpu + throw UnimplementedError(); + } + + @override + Future recover({ + required bool isRescan, + String? serializedKeys, + String? multisigConfig, + }) async { + if (serializedKeys == null || multisigConfig == null) { + 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 updateBalance() async { + final utxos = await mainDB.getUTXOs(walletId).findAll(); + + final currentChainHeight = await chainHeight; + + Amount satoshiBalanceTotal = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + Amount satoshiBalancePending = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + Amount satoshiBalanceSpendable = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + Amount satoshiBalanceBlocked = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + + for (final utxo in utxos) { + final utxoAmount = Amount( + rawValue: BigInt.from(utxo.value), + fractionDigits: cryptoCurrency.fractionDigits, + ); + + satoshiBalanceTotal += utxoAmount; + + if (utxo.isBlocked) { + satoshiBalanceBlocked += utxoAmount; + } else { + if (utxo.isConfirmed( + currentChainHeight, + cryptoCurrency.minConfirms, + )) { + satoshiBalanceSpendable += utxoAmount; + } else { + satoshiBalancePending += utxoAmount; + } + } + } + + final balance = Balance( + total: satoshiBalanceTotal, + spendable: satoshiBalanceSpendable, + blockedTotal: satoshiBalanceBlocked, + pendingSpendable: satoshiBalancePending, + ); + + await info.updateBalance(newBalance: balance, isar: mainDB.isar); + } + + @override + Future updateChainHeight() async { + final int height; + try { + final result = await electrumXClient.getBlockHeadTip(); + height = result["height"] as int; + } catch (e) { + rethrow; + } + + await info.updateCachedChainHeight( + newHeight: height, + isar: mainDB.isar, + ); + } + + @override + Future pingCheck() async { + try { + final result = await electrumXClient.ping(); + return result; + } catch (_) { + return false; + } + } + + @override + Future updateNode() async { + await _updateElectrumX(); + } + + @override + Future updateUTXOs() async { + final address = await getCurrentReceivingAddress(); + + try { + final scriptHash = cryptoCurrency.pubKeyToScriptHash( + pubKey: Uint8List.fromList(address!.publicKey), + ); + + final utxos = await electrumXClient.getUTXOs(scripthash: scriptHash); + + final List outputArray = []; + + for (int i = 0; i < utxos.length; i++) { + final utxo = await _parseUTXO( + jsonUTXO: utxos[i], + ); + + outputArray.add(utxo); + } + + return await mainDB.updateUTXOs(walletId, outputArray); + } catch (e, s) { + Logging.instance.log( + "Output fetch unsuccessful: $e\n$s", + level: LogLevel.Error, + ); + return false; + } + } + + // =================== Secure storage ======================================== + + Future get getSerializedKeys async => + await secureStorageInterface.read( + key: "{$walletId}_serializedFROSTKeys", + ); + Future _saveSerializedKeys(String keys) async { + final current = await getSerializedKeys; + + if (current == null) { + // do nothing + } else if (current == keys) { + // should never occur + } else { + // save current as prev gen before updating current + await secureStorageInterface.write( + key: "{$walletId}_serializedFROSTKeysPrevGen", + value: current, + ); + } + + await secureStorageInterface.write( + key: "{$walletId}_serializedFROSTKeys", + value: keys, + ); + } + + Future get getSerializedKeysPrevGen async => + await secureStorageInterface.read( + key: "{$walletId}_serializedFROSTKeysPrevGen", + ); + + Future get multisigConfig async => await secureStorageInterface.read( + key: "{$walletId}_multisigConfig", + ); + Future get multisigConfigPrevGen async => + await secureStorageInterface.read( + key: "{$walletId}_multisigConfigPrevGen", + ); + Future _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 get multisigId async { + final id = await secureStorageInterface.read( + key: "{$walletId}_multisigIdFROST", + ); + if (id == null) { + return null; + } else { + return id.toUint8ListFromHex; + } + } + + Future saveMultisigId(Uint8List id) async => + await secureStorageInterface.write( + key: "{$walletId}_multisigIdFROST", + value: id.toHex, + ); + + Future get recoveryString async => await secureStorageInterface.read( + key: "{$walletId}_recoveryStringFROST", + ); + Future saveRecoveryString(String recoveryString) async => + await secureStorageInterface.write( + key: "{$walletId}_recoveryStringFROST", + value: recoveryString, + ); + + // =================== Private =============================================== + + Future _getCurrentElectrumXNode() async { + final node = getCurrentNode(); + + return ElectrumXNode( + address: node.host, + port: node.port, + name: node.name, + useSSL: node.useSSL, + id: node.id, + ); + } + + Future _updateElectrumX() async { + final failovers = nodeService + .failoverNodesFor(coin: cryptoCurrency.coin) + .map((e) => ElectrumXNode( + address: e.host, + port: e.port, + name: e.name, + id: e.id, + useSSL: e.useSSL, + )) + .toList(); + + final newNode = await _getCurrentElectrumXNode(); + electrumXClient = ElectrumXClient.from( + node: newNode, + prefs: prefs, + failovers: failovers, + ); + electrumXCachedClient = CachedElectrumXClient.from( + electrumXClient: electrumXClient, + ); + } + + Future _parseUTXO({ + required Map jsonUTXO, + }) async { + final txn = await electrumXCachedClient.getTransaction( + txHash: jsonUTXO["tx_hash"] as String, + verbose: true, + coin: cryptoCurrency.coin, + ); + + final vout = jsonUTXO["tx_pos"] as int; + + final outputs = txn["vout"] as List; + + String? scriptPubKey; + String? utxoOwnerAddress; + // get UTXO owner address + for (final output in outputs) { + if (output["n"] == vout) { + scriptPubKey = output["scriptPubKey"]?["hex"] as String?; + utxoOwnerAddress = + output["scriptPubKey"]?["addresses"]?[0] as String? ?? + output["scriptPubKey"]?["address"] as String?; + } + } + + final utxo = UTXO( + walletId: walletId, + txid: txn["txid"] as String, + vout: vout, + value: jsonUTXO["value"] as int, + name: "", + isBlocked: false, + blockedReason: null, + isCoinbase: txn["is_coinbase"] as bool? ?? false, + blockHash: txn["blockhash"] as String?, + blockHeight: jsonUTXO["height"] as int?, + blockTime: txn["blocktime"] as int?, + address: utxoOwnerAddress, + ); + + return utxo; + } +} diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 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 { 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 on Bip39HDWallet { } } - Future getCurrentElectrumXNode() async { + Future _getCurrentElectrumXNode() async { final node = getCurrentNode(); return ElectrumXNode( @@ -844,7 +844,7 @@ mixin ElectrumXInterface on Bip39HDWallet { ); } - Future updateElectrumX({required ElectrumXNode newNode}) async { + Future updateElectrumX() async { final failovers = nodeService .failoverNodesFor(coin: cryptoCurrency.coin) .map((e) => ElectrumXNode( @@ -856,7 +856,7 @@ mixin ElectrumXInterface on Bip39HDWallet { )) .toList(); - final newNode = await getCurrentElectrumXNode(); + final newNode = await _getCurrentElectrumXNode(); electrumXClient = ElectrumXClient.from( node: newNode, prefs: prefs, @@ -1160,8 +1160,7 @@ mixin ElectrumXInterface on Bip39HDWallet { @override Future 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 { 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 Date: Fri, 19 Jan 2024 15:42:38 -0600 Subject: [PATCH 05/38] 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 getParticipants({ + required String multisigConfig, + }) { + try { + final numberOfParticipants = multisigParticipants( + multisigConfig: multisigConfig, + ); + + final List participants = []; + for (int i = 0; i < numberOfParticipants; i++) { + participants.add( + multisigParticipant( + multisigConfig: multisigConfig, + index: i, + ), + ); + } + + return participants; + } catch (e, s) { + Logging.instance.log( + "getParticipants failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static bool validateEncodedMultisigConfig({required String encodedConfig}) { + try { + decodeMultisigConfig(multisigConfig: encodedConfig); + return true; + } catch (e, s) { + Logging.instance.log( + "validateEncodedMultisigConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + return false; + } + } + + static int getThreshold({ + required String multisigConfig, + }) { + try { + final threshold = multisigThreshold( + multisigConfig: multisigConfig, + ); + + return threshold; + } catch (e, s) { + Logging.instance.log( + "getThreshold failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + List<({String address, Amount amount})> recipients, + String changeAddress, + int feePerWeight, + List inputs, + }) extractDataFromSignConfig({ + required String signConfig, + required CryptoCurrency coin, + }) { + try { + final network = coin.network == CryptoCurrencyNetwork.test + ? Network.Testnet + : Network.Mainnet; + final signConfigPointer = decodedSignConfig( + encodedConfig: signConfig, + network: network, + ); + + // get various data from config + final feePerWeight = + signFeePerWeight(signConfigPointer: signConfigPointer); + final changeAddress = signChange(signConfigPointer: signConfigPointer); + final recipientsCount = signPayments( + signConfigPointer: signConfigPointer, + ); + + // get tx recipient info + final List<({String address, Amount amount})> recipients = []; + for (int i = 0; i < recipientsCount; i++) { + final String address = signPaymentAddress( + signConfigPointer: signConfigPointer, + index: i, + ); + final int amount = signPaymentAmount( + signConfigPointer: signConfigPointer, + index: i, + ); + recipients.add( + ( + address: address, + amount: Amount( + rawValue: BigInt.from(amount), + fractionDigits: coin.fractionDigits, + ), + ), + ); + } + + // get utxos + final count = signInputs(signConfigPointer: signConfigPointer); + final List outputs = []; + for (int i = 0; i < count; i++) { + final output = signInput( + signConfig: signConfig, + index: i, + network: network, + ); + + outputs.add(output); + } + + return ( + recipients: recipients, + changeAddress: changeAddress, + feePerWeight: feePerWeight, + inputs: outputs, + ); + } catch (e, s) { + Logging.instance.log( + "extractDataFromSignConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + //==================== wallet creation ======================================= + + static String createMultisigConfig({ + required String name, + required int threshold, + required List participants, + }) { + try { + final config = newMultisigConfig( + name: name, + threshold: threshold, + participants: participants, + ); + + return config; + } catch (e, s) { + Logging.instance.log( + "createMultisigConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + String seed, + String commitments, + Pointer multisigConfigWithNamePtr, + Pointer secretShareMachineWrapperPtr, + }) startKeyGeneration({ + required String multisigConfig, + required String myName, + }) { + try { + final startKeyGenResPtr = startKeyGen( + multisigConfig: multisigConfig, + myName: myName, + language: Language.english, + ); + + final seed = startKeyGenResPtr.ref.seed.toDartString(); + final commitments = startKeyGenResPtr.ref.commitments.toDartString(); + final configWithNamePtr = startKeyGenResPtr.ref.config; + final machinePtr = startKeyGenResPtr.ref.machine; + + return ( + seed: seed, + commitments: commitments, + multisigConfigWithNamePtr: configWithNamePtr, + secretShareMachineWrapperPtr: machinePtr, + ); + } catch (e, s) { + Logging.instance.log( + "startKeyGeneration failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + String share, + Pointer secretSharesResPtr, + }) generateSecretShares({ + required Pointer multisigConfigWithNamePtr, + required String mySeed, + required Pointer secretShareMachineWrapperPtr, + required List commitments, + }) { + try { + final secretSharesResPtr = getSecretShares( + multisigConfigWithName: multisigConfigWithNamePtr, + seed: mySeed, + language: Language.english, + machine: secretShareMachineWrapperPtr, + commitments: commitments, + ); + + final share = secretSharesResPtr.ref.shares.toDartString(); + + return (share: share, secretSharesResPtr: secretSharesResPtr); + } catch (e, s) { + Logging.instance.log( + "generateSecretShares failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + Uint8List multisigId, + String recoveryString, + String serializedKeys, + }) completeKeyGeneration({ + required Pointer multisigConfigWithNamePtr, + required Pointer secretSharesResPtr, + required List shares, + }) { + try { + final keyGenResPtr = completeKeyGen( + multisigConfigWithName: multisigConfigWithNamePtr, + machineAndCommitments: secretSharesResPtr, + shares: shares, + ); + + final id = Uint8List.fromList( + List.generate( + MULTISIG_ID_LENGTH, + (index) => keyGenResPtr.ref.multisig_id[index], + ), + ); + + final recoveryString = keyGenResPtr.ref.recovery.toDartString(); + + final serializedKeys = serializeKeys(keys: keyGenResPtr.ref.keys); + + return ( + multisigId: id, + recoveryString: recoveryString, + serializedKeys: serializedKeys, + ); + } catch (e, s) { + Logging.instance.log( + "completeKeyGeneration failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + //=================== transaction creation =================================== + + static String createSignConfig({ + required int network, + required List<({UTXO utxo, Uint8List scriptPubKey})> inputs, + required List<({String address, Amount amount, bool isChange})> outputs, + required String changeAddress, + required int feePerWeight, + }) { + try { + final signConfig = newSignConfig( + network: network, + outputs: inputs + .map( + (e) => Output( + hash: e.utxo.txid.toUint8ListFromHex, + vout: e.utxo.vout, + value: e.utxo.value, + scriptPubKey: e.scriptPubKey, + ), + ) + .toList(), + paymentAddresses: outputs.map((e) => e.address).toList(), + paymentAmounts: outputs.map((e) => e.amount.raw.toInt()).toList(), + change: changeAddress, + feePerWeight: feePerWeight, + ); + + return signConfig; + } catch (e, s) { + Logging.instance.log( + "createSignConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + Pointer machinePtr, + String preprocess, + }) attemptSignConfig({ + required int network, + required String config, + required String serializedKeys, + }) { + try { + final keys = deserializeKeys(keys: serializedKeys); + + final attemptSignRes = attemptSign( + thresholdKeysWrapperPointer: keys, + network: network, + signConfig: config, + ); + + return ( + preprocess: attemptSignRes.ref.preprocess.toDartString(), + machinePtr: attemptSignRes.ref.machine, + ); + } catch (e, s) { + Logging.instance.log( + "attemptSignConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + Pointer machinePtr, + String share, + }) continueSigning({ + required Pointer machinePtr, + required List preprocesses, + }) { + try { + final continueSignRes = continueSign( + machine: machinePtr, + preprocesses: preprocesses, + ); + + return ( + share: continueSignRes.ref.preprocess.toDartString(), + machinePtr: continueSignRes.ref.machine, + ); + } catch (e, s) { + Logging.instance.log( + "continueSigning failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static String completeSigning({ + required Pointer machinePtr, + required List shares, + }) { + try { + final rawTransaction = completeSign( + machine: machinePtr, + shares: shares, + ); + + return rawTransaction; + } catch (e, s) { + Logging.instance.log( + "completeSigning failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static Pointer decodedSignConfig({ + required String encodedConfig, + required int network, + }) { + try { + final configPtr = + decodeSignConfig(encodedSignConfig: encodedConfig, network: network); + return configPtr; + } catch (e, s) { + Logging.instance.log( + "decodedSignConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + //========================== resharing ======================================= + + static String createResharerConfig({ + required int newThreshold, + required List resharers, + required List newParticipants, + }) { + try { + final config = newResharerConfig( + newThreshold: newThreshold, + newParticipants: newParticipants, + resharers: resharers, + ); + + return config; + } catch (e, s) { + Logging.instance.log( + "createResharerConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + String resharerStart, + Pointer machine, + }) beginResharer({ + required String serializedKeys, + required String config, + }) { + try { + final result = startResharer( + serializedKeys: serializedKeys, + config: config, + ); + + return ( + resharerStart: result.encoded, + machine: result.machine, + ); + } catch (e, s) { + Logging.instance.log( + "beginResharer failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + /// expects [resharerStarts] of length equal to resharers. + static ({ + String resharedStart, + Pointer prior, + }) beginReshared({ + required String myName, + required String resharerConfig, + required List resharerStarts, + }) { + try { + final result = startReshared( + newMultisigName: 'unused_property', + myName: myName, + resharerConfig: resharerConfig, + resharerStarts: resharerStarts, + ); + return ( + resharedStart: result.encoded, + prior: result.machine, + ); + } catch (e, s) { + Logging.instance.log( + "beginReshared failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + /// expects [encryptionKeysOfResharedTo] of length equal to new participants + static String finishResharer({ + required StartResharerRes machine, + required List encryptionKeysOfResharedTo, + }) { + try { + final result = completeResharer( + machine: machine, + encryptionKeysOfResharedTo: encryptionKeysOfResharedTo, + ); + return result; + } catch (e, s) { + Logging.instance.log( + "finishResharer failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + /// expects [resharerCompletes] of length equal to resharers + static ({ + String multisigConfig, + String serializedKeys, + String resharedId, + }) finishReshared({ + required StartResharedRes prior, + required List resharerCompletes, + }) { + try { + final result = completeReshared( + prior: prior, + resharerCompletes: resharerCompletes, + ); + return result; + } catch (e, s) { + Logging.instance.log( + "finishReshared failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static Pointer decodedResharerConfig({ + required String resharerConfig, + }) { + try { + final config = decodeResharerConfig(resharerConfig: resharerConfig); + + return config; + } catch (e, s) { + Logging.instance.log( + "decodedResharerConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + int newThreshold, + List resharers, + List newParticipants, + }) extractResharerConfigData({ + required String resharerConfig, + }) { + try { + final newThreshold = resharerNewThreshold( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + ); + + final resharersCount = resharerResharers( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + ); + final List resharers = []; + for (int i = 0; i < resharersCount; i++) { + resharers.add( + resharerResharer( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + index: i, + ), + ); + } + + final newParticipantsCount = resharerNewParticipants( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + ); + final List newParticipants = []; + for (int i = 0; i < newParticipantsCount; i++) { + newParticipants.add( + resharerNewParticipant( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + index: i, + ), + ); + } + + return ( + newThreshold: newThreshold, + resharers: resharers, + newParticipants: newParticipants, + ); + } catch (e, s) { + Logging.instance.log( + "extractResharerConfigData failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } +} diff --git a/lib/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 knownSalts; + final List participants; + final String myName; + final int threshold; FrostWalletInfo({ required this.walletId, required this.knownSalts, + required this.participants, + required this.myName, + required this.threshold, }); FrostWalletInfo copyWith({ List? knownSalts, + List? participants, + String? myName, + int? threshold, }) { return FrostWalletInfo( walletId: walletId, knownSalts: knownSalts ?? this.knownSalts, - ); - } - - Future updateKnownSalts( - List 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> 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

( 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 + myNameEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'myName', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'myName', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'myName', + value: '', + )); + }); + } + + QueryBuilder + myNameIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'myName', + value: '', + )); + }); + } + + QueryBuilder + participantsElementEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'participants', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'participants', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'participants', + value: '', + )); + }); + } + + QueryBuilder + participantsElementIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'participants', + value: '', + )); + }); + } + + QueryBuilder + participantsLengthEqualTo(int length) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + length, + true, + length, + true, + ); + }); + } + + QueryBuilder + participantsIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + 0, + true, + 0, + true, + ); + }); + } + + QueryBuilder + participantsIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + 0, + false, + 999999, + true, + ); + }); + } + + QueryBuilder + participantsLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + 0, + true, + length, + include, + ); + }); + } + + QueryBuilder + participantsLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + length, + include, + 999999, + true, + ); + }); + } + + QueryBuilder + participantsLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + lower, + includeLower, + upper, + includeUpper, + ); + }); + } + + QueryBuilder + thresholdEqualTo(int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'threshold', + value: value, + )); + }); + } + + QueryBuilder + thresholdGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'threshold', + value: value, + )); + }); + } + + QueryBuilder + thresholdLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'threshold', + value: value, + )); + }); + } + + QueryBuilder + thresholdBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'threshold', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + QueryBuilder walletIdEqualTo( String value, { @@ -734,6 +1186,33 @@ extension FrostWalletInfoQueryLinks extension FrostWalletInfoQuerySortBy on QueryBuilder { + QueryBuilder sortByMyName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.asc); + }); + } + + QueryBuilder + sortByMyNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.desc); + }); + } + + QueryBuilder + sortByThreshold() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.asc); + }); + } + + QueryBuilder + sortByThresholdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.desc); + }); + } + QueryBuilder sortByWalletId() { return QueryBuilder.apply(this, (query) { @@ -763,6 +1242,33 @@ extension FrostWalletInfoQuerySortThenBy }); } + QueryBuilder thenByMyName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.asc); + }); + } + + QueryBuilder + thenByMyNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.desc); + }); + } + + QueryBuilder + thenByThreshold() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.asc); + }); + } + + QueryBuilder + thenByThresholdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.desc); + }); + } + QueryBuilder thenByWalletId() { return QueryBuilder.apply(this, (query) { @@ -787,6 +1293,27 @@ extension FrostWalletInfoQueryWhereDistinct }); } + QueryBuilder distinctByMyName( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'myName', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByParticipants() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'participants'); + }); + } + + QueryBuilder + distinctByThreshold() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'threshold'); + }); + } + QueryBuilder distinctByWalletId( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -810,6 +1337,25 @@ extension FrostWalletInfoQueryProperty }); } + QueryBuilder myNameProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'myName'); + }); + } + + QueryBuilder, QQueryOperations> + participantsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'participants'); + }); + } + + QueryBuilder thresholdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'threshold'); + }); + } + QueryBuilder walletIdProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'walletId'); diff --git a/lib/wallets/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 extends Wallet - with PrivateKeyInterface { - FrostWalletInfo get frostInfo => throw UnimplementedError(); +class BitcoinFrostWallet extends Wallet { + BitcoinFrostWallet(CryptoCurrencyNetwork network) + : super(BitcoinFrost(network) as T); + + FrostWalletInfo get frostInfo => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .findFirstSync()!; late ElectrumXClient electrumXClient; late CachedElectrumXClient electrumXCachedClient; + Future initializeNewFrost({ + required String mnemonic, + required String multisigConfig, + required String recoveryString, + required String serializedKeys, + required Uint8List multisigId, + required String myName, + required List participants, + required int threshold, + }) async { + Logging.instance.log( + "Generating new FROST wallet.", + level: LogLevel.Info, + ); + + try { + final salt = frost + .multisigSalt( + multisigConfig: multisigConfig, + ) + .toHex; + + final FrostWalletInfo frostWalletInfo = FrostWalletInfo( + walletId: info.walletId, + knownSalts: [salt], + participants: participants, + myName: myName, + threshold: threshold, + ); + + await secureStorageInterface.write( + key: Wallet.mnemonicKey(walletId: info.walletId), + value: mnemonic, + ); + await secureStorageInterface.write( + key: Wallet.mnemonicPassphraseKey(walletId: info.walletId), + value: "", + ); + await _saveSerializedKeys(serializedKeys); + await _saveRecoveryString(recoveryString); + await _saveMultisigId(multisigId); + await _saveMultisigConfig(multisigConfig); + + await mainDB.isar.frostWalletInfo.put(frostWalletInfo); + + final keys = frost.deserializeKeys(keys: serializedKeys); + + final addressString = frost.addressForKeys( + network: cryptoCurrency.network == CryptoCurrencyNetwork.main + ? Network.Mainnet + : Network.Testnet, + keys: keys, + ); + + final publicKey = frost.scriptPubKeyForKeys(keys: keys); + + final address = Address( + walletId: info.walletId, + value: addressString, + publicKey: publicKey.toUint8ListFromHex, + derivationIndex: 0, + derivationPath: null, + subType: AddressSubType.receiving, + type: AddressType.unknown, + ); + + await mainDB.putAddresses([address]); + } catch (e, s) { + Logging.instance.log( + "Exception rethrown from initializeNewFrost(): $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + Future frostCreateSignConfig({ + required TxData txData, + required String changeAddress, + required int feePerWeight, + }) async { + try { + if (txData.recipients == null || txData.recipients!.isEmpty) { + throw Exception("No recipients found!"); + } + + final total = txData.recipients! + .map((e) => e.amount) + .reduce((value, e) => value += e); + + final utxos = await mainDB + .getUTXOs(walletId) + .filter() + .isBlockedEqualTo(false) + .findAll(); + + if (utxos.isEmpty) { + throw Exception("No UTXOs found"); + } else { + final currentHeight = await chainHeight; + utxos.removeWhere( + (e) => !e.isConfirmed( + currentHeight, + cryptoCurrency.minConfirms, + ), + ); + if (utxos.isEmpty) { + throw Exception("No confirmed UTXOs found"); + } + } + + if (total.raw > + utxos.map((e) => BigInt.from(e.value)).reduce((v, e) => v += e)) { + throw Exception("Insufficient available funds"); + } + + Amount sum = Amount.zeroWith( + fractionDigits: cryptoCurrency.fractionDigits, + ); + final Set utxosToUse = {}; + for (final utxo in utxos) { + sum += Amount( + rawValue: BigInt.from(utxo.value), + fractionDigits: cryptoCurrency.fractionDigits, + ); + utxosToUse.add(utxo); + if (sum > total) { + break; + } + } + + final serializedKeys = await _getSerializedKeys(); + final keys = frost.deserializeKeys(keys: serializedKeys!); + + final int network = cryptoCurrency.network == CryptoCurrencyNetwork.main + ? Network.Mainnet + : Network.Testnet; + + final publicKey = frost + .scriptPubKeyForKeys( + keys: keys, + ) + .toUint8ListFromHex; + + final config = Frost.createSignConfig( + network: network, + inputs: utxosToUse + .map((e) => ( + utxo: e, + scriptPubKey: publicKey, + )) + .toList(), + outputs: txData.recipients!, + changeAddress: (await getCurrentReceivingAddress())!.value, + feePerWeight: feePerWeight, + ); + + return txData.copyWith(frostMSConfig: config, utxos: utxosToUse); + } catch (_) { + rethrow; + } + } + + Future< + ({ + Pointer machinePtr, + String preprocess, + })> frostAttemptSignConfig({ + required String config, + }) async { + final int network = cryptoCurrency.network == CryptoCurrencyNetwork.main + ? Network.Mainnet + : Network.Testnet; + final serializedKeys = await _getSerializedKeys(); + + return Frost.attemptSignConfig( + network: network, + config: config, + serializedKeys: serializedKeys!, + ); + } + + Future updateWithResharedData({ + required String serializedKeys, + required String multisigConfig, + required bool isNewWallet, + }) async { + await _saveSerializedKeys(serializedKeys); + await _saveMultisigConfig(multisigConfig); + + await _updateThreshold( + frost.getThresholdFromKeys( + serializedKeys: serializedKeys, + ), + ); + + final myNameIndex = frost.getParticipantIndexFromKeys( + serializedKeys: serializedKeys, + ); + final participants = Frost.getParticipants( + multisigConfig: multisigConfig, + ); + final myName = participants[myNameIndex]; + + await _updateParticipants(participants); + await _updateMyName(myName); + + if (isNewWallet) { + await recover( + serializedKeys: serializedKeys, + multisigConfig: multisigConfig, + isRescan: false, + ); + } + } + + Future sweepAllEstimate(int feeRate) async { + int available = 0; + int inputCount = 0; + final height = await chainHeight; + for (final output in (await mainDB.getUTXOs(walletId).findAll())) { + if (!output.isBlocked && + output.isConfirmed(height, cryptoCurrency.minConfirms)) { + available += output.value; + inputCount++; + } + } + + // transaction will only have 1 output minus the fee + final estimatedFee = _roughFeeEstimate(inputCount, 1, feeRate); + + return Amount( + rawValue: BigInt.from(available), + fractionDigits: cryptoCurrency.fractionDigits, + ) - + estimatedFee; + } + + // int _estimateTxFee({required int vSize, required int feeRatePerKB}) { + // return vSize * (feeRatePerKB / 1000).ceil(); + // } + + Amount _roughFeeEstimate(int inputCount, int outputCount, int feeRatePerKB) { + return Amount( + rawValue: BigInt.from( + ((42 + (272 * inputCount) + (128 * outputCount)) / 4).ceil() * + (feeRatePerKB / 1000).ceil()), + fractionDigits: cryptoCurrency.fractionDigits, + ); + } + + // ==================== Overrides ============================================ + @override int get isarTransactionVersion => 2; - BitcoinFrostWallet(CryptoCurrencyNetwork network) - : super(BitcoinFrost(network) as T); - @override FilterOperation? get changeAddressFilterOperation => FilterGroup.and( [ @@ -62,62 +324,321 @@ class BitcoinFrostWallet extends Wallet ], ); - // Future> 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 updateTransactions() async { + final myAddress = (await getCurrentReceivingAddress())!; + + final scriptHash = cryptoCurrency.pubKeyToScriptHash( + pubKey: Uint8List.fromList(myAddress.publicKey), + ); + final allTxHashes = + (await electrumXClient.getHistory(scripthash: scriptHash)).toSet(); + + final currentHeight = await chainHeight; + final coin = info.coin; + + List> allTransactions = []; + + for (final txHash in allTxHashes) { + final storedTx = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .txidEqualTo(txHash["tx_hash"] as String) + .findFirst(); + + if (storedTx == null || + !storedTx.isConfirmed(currentHeight, cryptoCurrency.minConfirms)) { + final tx = await electrumXCachedClient.getTransaction( + txHash: txHash["tx_hash"] as String, + verbose: true, + coin: coin, + ); + + if (!_duplicateTxCheck(allTransactions, tx["txid"] as String)) { + tx["height"] = txHash["height"]; + allTransactions.add(tx); + } + } + } + + // Parse all new txs. + final List txns = []; + for (final txData in allTransactions) { + bool wasSentFromThisWallet = false; + // Set to true if any inputs were detected as owned by this wallet. + + bool wasReceivedInThisWallet = false; + // Set to true if any outputs were detected as owned by this wallet. + + // Parse inputs. + BigInt amountReceivedInThisWallet = BigInt.zero; + final List inputs = []; + for (final jsonInput in txData["vin"] as List) { + final map = Map.from(jsonInput as Map); + + final List addresses = []; + String valueStringSats = "0"; + OutpointV2? outpoint; + + final coinbase = map["coinbase"] as String?; + + if (coinbase == null) { + // Not a coinbase (ie a typical input). + final txid = map["txid"] as String; + final vout = map["vout"] as int; + + final inputTx = await electrumXCachedClient.getTransaction( + txHash: txid, + coin: cryptoCurrency.coin, + ); + + final prevOutJson = Map.from( + (inputTx["vout"] as List).firstWhere((e) => e["n"] == vout) + as Map); + + final prevOut = OutputV2.fromElectrumXJson( + prevOutJson, + decimalPlaces: cryptoCurrency.fractionDigits, + isFullAmountNotSats: true, + walletOwns: false, // Doesn't matter here as this is not saved. + ); + + outpoint = OutpointV2.isarCantDoRequiredInDefaultConstructor( + txid: txid, + vout: vout, + ); + valueStringSats = prevOut.valueStringSats; + addresses.addAll(prevOut.addresses); + } + + InputV2 input = InputV2.fromElectrumxJson( + json: map, + outpoint: outpoint, + valueStringSats: valueStringSats, + addresses: addresses, + coinbase: coinbase, + // Need addresses before we can know if the wallet owns this input. + walletOwns: false, + ); + + // Check if input was from this wallet. + if (input.addresses.contains(myAddress.value)) { + wasSentFromThisWallet = true; + input = input.copyWith(walletOwns: true); + } + + inputs.add(input); + } + + // Parse outputs. + final List outputs = []; + for (final outputJson in txData["vout"] as List) { + OutputV2 output = OutputV2.fromElectrumXJson( + Map.from(outputJson as Map), + decimalPlaces: cryptoCurrency.fractionDigits, + isFullAmountNotSats: true, + // Need addresses before we can know if the wallet owns this input. + walletOwns: false, + ); + + // If output was to my wallet, add value to amount received. + if (output.addresses.contains(myAddress.value)) { + wasReceivedInThisWallet = true; + amountReceivedInThisWallet += output.value; + output = output.copyWith(walletOwns: true); + } + + outputs.add(output); + } + + final totalOut = outputs + .map((e) => e.value) + .fold(BigInt.zero, (value, element) => value + element); + + TransactionType type; + TransactionSubType subType = TransactionSubType.none; + if (outputs.length > 1 && inputs.isNotEmpty) { + for (int i = 0; i < outputs.length; i++) { + List? scriptChunks = outputs[i].scriptPubKeyAsm?.split(" "); + if (scriptChunks?.length == 2 && scriptChunks?[0] == "OP_RETURN") { + final blindedPaymentCode = scriptChunks![1]; + final bytes = blindedPaymentCode.toUint8ListFromHex; + + // https://en.bitcoin.it/wiki/BIP_0047#Sending + if (bytes.length == 80 && bytes.first == 1) { + subType = TransactionSubType.bip47Notification; + break; + } + } + } + } + + // At least one input was owned by this wallet. + if (wasSentFromThisWallet) { + type = TransactionType.outgoing; + + if (wasReceivedInThisWallet) { + if (amountReceivedInThisWallet == totalOut) { + // Definitely sent all to self. + type = TransactionType.sentToSelf; + } else if (amountReceivedInThisWallet == BigInt.zero) { + // Most likely just a typical send, do nothing here yet. + } + } + } else if (wasReceivedInThisWallet) { + // Only found outputs owned by this wallet. + type = TransactionType.incoming; + } else { + Logging.instance.log( + "Unexpected tx found (ignoring it): $txData", + level: LogLevel.Error, + ); + continue; + } + + final tx = TransactionV2( + walletId: walletId, + blockHash: txData["blockhash"] as String?, + hash: txData["hash"] as String, + txid: txData["txid"] as String, + height: txData["height"] as int?, + version: txData["version"] as int, + timestamp: txData["blocktime"] as int? ?? + DateTime.timestamp().millisecondsSinceEpoch ~/ 1000, + inputs: List.unmodifiable(inputs), + outputs: List.unmodifiable(outputs), + type: type, + subType: subType, + otherData: null, + ); + + txns.add(tx); + } + + await mainDB.updateOrPutTransactionV2s(txns); + } @override - Future updateTransactions() { - // TODO: implement updateTransactions - throw UnimplementedError(); + Future 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 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 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 checkSaveInitialReceivingAddress() { - // TODO: implement checkSaveInitialReceivingAddress - throw UnimplementedError(); - } + Future get fees async { + try { + // adjust numbers for different speeds? + const int f = 1, m = 5, s = 20; - @override - Future 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 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 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 prepareSend({required TxData txData}) { @@ -138,6 +659,16 @@ class BitcoinFrostWallet extends Wallet ); } + 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 extends Wallet 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 extends Wallet 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 extends Wallet // =================== Secure storage ======================================== - Future get getSerializedKeys async => + Future _getSerializedKeys() async => await secureStorageInterface.read( key: "{$walletId}_serializedFROSTKeys", ); - Future _saveSerializedKeys(String keys) async { - final current = await getSerializedKeys; + + Future _saveSerializedKeys( + String keys, + ) async { + final current = await _getSerializedKeys(); if (current == null) { // do nothing @@ -334,20 +887,24 @@ class BitcoinFrostWallet extends Wallet ); } - Future get getSerializedKeysPrevGen async => + Future _getSerializedKeysPrevGen() async => await secureStorageInterface.read( key: "{$walletId}_serializedFROSTKeysPrevGen", ); - Future get multisigConfig async => await secureStorageInterface.read( + Future _multisigConfig() async => await secureStorageInterface.read( key: "{$walletId}_multisigConfig", ); - Future get multisigConfigPrevGen async => + + Future _multisigConfigPrevGen() async => await secureStorageInterface.read( key: "{$walletId}_multisigConfigPrevGen", ); - Future _saveMultisigConfig(String multisigConfig) async { - final current = await this.multisigConfig; + + Future _saveMultisigConfig( + String multisigConfig, + ) async { + final current = await _multisigConfig(); if (current == null) { // do nothing @@ -367,7 +924,7 @@ class BitcoinFrostWallet extends Wallet ); } - Future get multisigId async { + Future _multisigId() async { final id = await secureStorageInterface.read( key: "{$walletId}_multisigIdFROST", ); @@ -378,21 +935,92 @@ class BitcoinFrostWallet extends Wallet } } - Future saveMultisigId(Uint8List id) async => + Future _saveMultisigId( + Uint8List id, + ) async => await secureStorageInterface.write( key: "{$walletId}_multisigIdFROST", value: id.toHex, ); - Future get recoveryString async => await secureStorageInterface.read( + Future _recoveryString() async => await secureStorageInterface.read( key: "{$walletId}_recoveryStringFROST", ); - Future saveRecoveryString(String recoveryString) async => + + Future _saveRecoveryString( + String recoveryString, + ) async => await secureStorageInterface.write( key: "{$walletId}_recoveryStringFROST", value: recoveryString, ); + // =================== DB ==================================================== + + List _getKnownSalts() => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .knownSaltsProperty() + .findFirstSync()!; + + Future _updateKnownSalts(List knownSalts) async { + 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 _getParticipants() => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .participantsProperty() + .findFirstSync()!; + + Future _updateParticipants(List participants) async { + await mainDB.isar.writeTxn(() async { + final info = frostInfo; + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(participants: participants), + ); + }); + } + + int _getThreshold() => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .thresholdProperty() + .findFirstSync()!; + + Future _updateThreshold(int threshold) async { + await mainDB.isar.writeTxn(() async { + final info = frostInfo; + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(threshold: threshold), + ); + }); + } + + String _getMyName() => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .myNameProperty() + .findFirstSync()!; + + Future _updateMyName(String myName) async { + await mainDB.isar.writeTxn(() async { + final info = frostInfo; + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(myName: myName), + ); + }); + } + // =================== Private =============================================== Future _getCurrentElectrumXNode() async { @@ -430,6 +1058,16 @@ class BitcoinFrostWallet extends Wallet ); } + bool _duplicateTxCheck( + List> allTransactions, String txid) { + for (int i = 0; i < allTransactions.length; i++) { + if (allTransactions[i]["txid"] == txid) { + return true; + } + } + return false; + } + Future _parseUTXO({ required Map jsonUTXO, }) async { @@ -443,12 +1081,12 @@ class BitcoinFrostWallet extends Wallet 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 { 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 Date: Fri, 19 Jan 2024 17:44:01 -0600 Subject: [PATCH 06/38] 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 createState() => + _ConfirmNewFrostMSWalletCreationViewState(); +} + +class _ConfirmNewFrostMSWalletCreationViewState + extends ConsumerState { + late final String seed, recoveryString, serializedKeys, multisigConfig; + late final Uint8List multisigId; + + @override + void initState() { + seed = ref.read(pFrostStartKeyGenData.state).state!.seed; + serializedKeys = + ref.read(pFrostCompletedKeyGenData.state).state!.serializedKeys; + recoveryString = + ref.read(pFrostCompletedKeyGenData.state).state!.recoveryString; + multisigId = ref.read(pFrostCompletedKeyGenData.state).state!.multisigId; + multisigConfig = ref.read(pFrostMultisigConfig.state).state!; + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: + Util.isDesktop ? DesktopHomeView.routeName : HomeView.routeName, + ), + ); + + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: HomeView.routeName, + ), + ); + }, + ), + title: Text( + "Finalize FROST multisig wallet", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Ensure your multisig ID matches that of each other participant", + style: STextStyles.pageTitleH2(context), + ), + const _Div(), + DetailItem( + title: "ID", + detail: multisigId.toString(), + button: Util.isDesktop + ? IconCopyButton( + data: multisigId.toString(), + ) + : SimpleCopyButton( + data: multisigId.toString(), + ), + ), + const _Div(), + const _Div(), + Text( + "Back up your keys and config", + style: STextStyles.pageTitleH2(context), + ), + const _Div(), + DetailItem( + title: "Multisig Config", + detail: multisigConfig, + button: Util.isDesktop + ? IconCopyButton( + data: multisigConfig, + ) + : SimpleCopyButton( + data: multisigConfig, + ), + ), + const _Div(), + DetailItem( + title: "Keys", + detail: serializedKeys, + button: Util.isDesktop + ? IconCopyButton( + data: serializedKeys, + ) + : SimpleCopyButton( + data: serializedKeys, + ), + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Confirm", + onPressed: () async { + bool progressPopped = false; + try { + unawaited( + showDialog( + context: context, + barrierDismissible: false, + useSafeArea: true, + builder: (ctx) { + return const Center( + child: LoadingIndicator( + width: 50, + height: 50, + ), + ); + }, + ), + ); + + final info = WalletInfo.createNew( + coin: widget.coin, + name: widget.walletName, + ); + + final wallet = await Wallet.create( + walletInfo: info, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + ); + + await (wallet as BitcoinFrostWallet).initializeNewFrost( + mnemonic: seed, + multisigConfig: multisigConfig, + recoveryString: recoveryString, + serializedKeys: serializedKeys, + multisigId: multisigId, + myName: ref.read(pFrostMyName.state).state!, + participants: Frost.getParticipants( + multisigConfig: + ref.read(pFrostMultisigConfig.state).state!, + ), + threshold: Frost.getThreshold( + multisigConfig: + ref.read(pFrostMultisigConfig.state).state!, + ), + ); + + await info.setMnemonicVerified( + isar: ref.read(mainDBProvider).isar, + ); + + ref.read(pWallets).addWallet(wallet); + + // pop progress dialog + if (mounted) { + Navigator.pop(context); + progressPopped = true; + } + + if (mounted) { + if (Util.isDesktop) { + Navigator.of(context).popUntil( + ModalRoute.withName( + DesktopHomeView.routeName, + ), + ); + } else { + unawaited( + Navigator.of(context).pushNamedAndRemoveUntil( + HomeView.routeName, + (route) => false, + ), + ); + } + + ref.read(pFrostMultisigConfig.state).state = null; + ref.read(pFrostStartKeyGenData.state).state = null; + ref.read(pFrostSecretSharesData.state).state = null; + + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Your wallet is set up.", + iconAsset: Assets.svg.check, + context: context, + ), + ); + } + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + // pop progress dialog + if (mounted && !progressPopped) { + Navigator.pop(context); + progressPopped = true; + } + // TODO: handle gracefully + rethrow; + } + }, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/add_wallet_views/frost_ms/new/create_new_frost_ms_wallet_view.dart b/lib/pages/add_wallet_views/frost_ms/new/create_new_frost_ms_wallet_view.dart new file mode 100644 index 000000000..b408b61ef --- /dev/null +++ b/lib/pages/add_wallet_views/frost_ms/new/create_new_frost_ms_wallet_view.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/share_new_multisig_config_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; + +class CreateNewFrostMsWalletView extends ConsumerStatefulWidget { + const CreateNewFrostMsWalletView({ + super.key, + required this.walletName, + required this.coin, + }); + + static const String routeName = "/createNewFrostMsWalletView"; + + final String walletName; + final Coin coin; + + @override + ConsumerState createState() => + _NewFrostMsWalletViewState(); +} + +class _NewFrostMsWalletViewState + extends ConsumerState { + final _thresholdController = TextEditingController(); + final _participantsController = TextEditingController(); + + final List controllers = []; + + int _participantsCount = 0; + + String _validateInputData() { + final threshold = int.tryParse(_thresholdController.text); + if (threshold == null) { + return "Choose a threshold"; + } + + final partsCount = int.tryParse(_participantsController.text); + if (partsCount == null) { + return "Choose total number of participants"; + } + + if (threshold > partsCount) { + return "Threshold cannot be greater than the number of participants"; + } + + if (partsCount < 2) { + return "At least two participants required"; + } + + if (controllers.length != partsCount) { + return "Participants count error"; + } + + final hasEmptyParticipants = controllers + .map((e) => e.text.isEmpty) + .reduce((value, element) => value |= element); + if (hasEmptyParticipants) { + return "Participants must not be empty"; + } + + if (controllers.length != controllers.map((e) => e.text).toSet().length) { + return "Duplicate participant name found"; + } + + return "valid"; + } + + void _participantsCountChanged(String newValue) { + final count = int.tryParse(newValue); + if (count != null) { + if (count > _participantsCount) { + for (int i = _participantsCount; i < count; i++) { + controllers.add(TextEditingController()); + } + + _participantsCount = count; + setState(() {}); + } else if (count < _participantsCount) { + for (int i = _participantsCount; i > count; i--) { + final last = controllers.removeLast(); + last.dispose(); + } + + _participantsCount = count; + setState(() {}); + } + } + } + + @override + void dispose() { + _thresholdController.dispose(); + _participantsController.dispose(); + for (final e in controllers) { + e.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "New FROST multisig config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Threshold", + style: STextStyles.label(context), + ), + const SizedBox( + height: 10, + ), + TextField( + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + controller: _thresholdController, + ), + const SizedBox( + height: 16, + ), + Text( + "Number of participants", + style: STextStyles.label(context), + ), + const SizedBox( + height: 10, + ), + TextField( + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + controller: _participantsController, + onChanged: _participantsCountChanged, + ), + const SizedBox( + height: 16, + ), + if (controllers.isNotEmpty) + Text( + "My name", + style: STextStyles.label(context), + ), + if (controllers.isNotEmpty) + const SizedBox( + height: 10, + ), + if (controllers.isNotEmpty) + TextField( + controller: controllers.first, + ), + if (controllers.length > 1) + const SizedBox( + height: 16, + ), + if (controllers.length > 1) + Text( + "Remaining participants", + style: STextStyles.label(context), + ), + if (controllers.length > 1) + Column( + children: [ + for (int i = 1; i < controllers.length; i++) + Padding( + padding: const EdgeInsets.only( + top: 10, + ), + child: TextField( + controller: controllers[i], + ), + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Generate", + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + } + + final validationMessage = _validateInputData(); + + if (validationMessage != "valid") { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: validationMessage, + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + final config = Frost.createMultisigConfig( + name: controllers.first.text, + threshold: int.parse(_thresholdController.text), + participants: controllers.map((e) => e.text).toList(), + ); + + ref.read(pFrostMyName.notifier).state = controllers.first.text; + ref.read(pFrostMultisigConfig.notifier).state = config; + + await Navigator.of(context).pushNamed( + ShareNewMultisigConfigView.routeName, + arguments: ( + walletName: widget.walletName, + coin: widget.coin, + ), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/add_wallet_views/frost_ms/new/frost_share_commitments_view.dart b/lib/pages/add_wallet_views/frost_ms/new/frost_share_commitments_view.dart new file mode 100644 index 000000000..bf3649a37 --- /dev/null +++ b/lib/pages/add_wallet_views/frost_ms/new/frost_share_commitments_view.dart @@ -0,0 +1,443 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/frost_share_shares_view.dart'; +import 'package:stackwallet/pages/home_view/home_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_home_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class FrostShareCommitmentsView extends ConsumerStatefulWidget { + const FrostShareCommitmentsView({ + super.key, + required this.walletName, + required this.coin, + }); + + static const String routeName = "/frostShareCommitmentsView"; + + final String walletName; + final Coin coin; + + @override + ConsumerState createState() => + _FrostShareCommitmentsViewState(); +} + +class _FrostShareCommitmentsViewState + extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final List participants; + late final String myCommitment; + late final int myIndex; + + final List fieldIsEmptyFlags = []; + + @override + void initState() { + participants = Frost.getParticipants( + multisigConfig: ref.read(pFrostMultisigConfig.state).state!, + ); + myIndex = participants.indexOf(ref.read(pFrostMyName.state).state!); + myCommitment = ref.read(pFrostStartKeyGenData.state).state!.commitments; + + // temporarily remove my name + participants.removeAt(myIndex); + + for (int i = 0; i < participants.length; i++) { + controllers.add(TextEditingController()); + focusNodes.add(FocusNode()); + fieldIsEmptyFlags.add(true); + } + super.initState(); + } + + @override + void dispose() { + for (int i = 0; i < controllers.length; i++) { + controllers[i].dispose(); + } + for (int i = 0; i < focusNodes.length; i++) { + focusNodes[i].dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: + Util.isDesktop ? DesktopHomeView.routeName : HomeView.routeName, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: HomeView.routeName, + ), + ); + }, + ), + title: Text( + "Commitments", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: myCommitment, + size: 220, + backgroundColor: Theme.of(context) + .extension()! + .background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const _Div(), + DetailItem( + title: "My name", + detail: ref.watch(pFrostMyName.state).state!, + ), + const _Div(), + DetailItem( + title: "My commitment", + detail: myCommitment, + button: Util.isDesktop + ? IconCopyButton( + data: myCommitment, + ) + : SimpleCopyButton( + data: myCommitment, + ), + ), + const _Div(), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < participants.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frostCommitmentsTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (_) { + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + }, + decoration: standardInputDecoration( + "Enter ${participants[i]}'s commitment", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Commitment Field Input.", + key: Key( + "frostCommitmentsClearButtonKey_$i"), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Commitment Field Input.", + key: Key( + "frostCommitmentsPasteButtonKey_$i"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: Key( + "frostCommitmentsScanQrButtonKey_$i"), + onTap: () async { + try { + if (FocusScope.of(context) + .hasFocus) { + FocusScope.of(context) + .unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Generate shares", + enabled: !fieldIsEmptyFlags.reduce((v, e) => v |= e), + onPressed: () async { + // check for empty commitments + if (controllers + .map((e) => e.text.isEmpty) + .reduce((value, element) => value |= element)) { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Missing commitments", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + // collect commitment strings and insert my own at the correct index + final commitments = controllers.map((e) => e.text).toList(); + commitments.insert(myIndex, myCommitment); + + try { + ref.read(pFrostSecretSharesData.notifier).state = + Frost.generateSecretShares( + multisigConfigWithNamePtr: ref + .read(pFrostStartKeyGenData.state) + .state! + .multisigConfigWithNamePtr, + mySeed: ref.read(pFrostStartKeyGenData.state).state!.seed, + secretShareMachineWrapperPtr: ref + .read(pFrostStartKeyGenData.state) + .state! + .secretShareMachineWrapperPtr, + commitments: commitments, + ); + + await Navigator.of(context).pushNamed( + FrostShareSharesView.routeName, + arguments: ( + walletName: widget.walletName, + coin: widget.coin, + ), + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Failed to generate shares", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + }, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/add_wallet_views/frost_ms/new/frost_share_shares_view.dart b/lib/pages/add_wallet_views/frost_ms/new/frost_share_shares_view.dart new file mode 100644 index 000000000..0f5e70ee7 --- /dev/null +++ b/lib/pages/add_wallet_views/frost_ms/new/frost_share_shares_view.dart @@ -0,0 +1,409 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/confirm_new_frost_ms_wallet_creation_view.dart'; +import 'package:stackwallet/pages/home_view/home_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_home_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class FrostShareSharesView extends ConsumerStatefulWidget { + const FrostShareSharesView({ + super.key, + required this.walletName, + required this.coin, + }); + + static const String routeName = "/frostShareSharesView"; + + final String walletName; + final Coin coin; + + @override + ConsumerState createState() => + _FrostShareSharesViewState(); +} + +class _FrostShareSharesViewState extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final List participants; + late final String myShare; + late final int myIndex; + + final List fieldIsEmptyFlags = []; + + @override + void initState() { + participants = Frost.getParticipants( + multisigConfig: ref.read(pFrostMultisigConfig.state).state!, + ); + myIndex = participants.indexOf(ref.read(pFrostMyName.state).state!); + myShare = ref.read(pFrostSecretSharesData.state).state!.share; + + // temporarily remove my name. Added back later + participants.removeAt(myIndex); + + for (int i = 0; i < participants.length; i++) { + controllers.add(TextEditingController()); + focusNodes.add(FocusNode()); + fieldIsEmptyFlags.add(true); + } + super.initState(); + } + + @override + void dispose() { + for (int i = 0; i < controllers.length; i++) { + controllers[i].dispose(); + } + for (int i = 0; i < focusNodes.length; i++) { + focusNodes[i].dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: + Util.isDesktop ? DesktopHomeView.routeName : HomeView.routeName, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.walletCreation, + popUntilOnYesRouteName: HomeView.routeName, + ), + ); + }, + ), + title: Text( + "Generate shares", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: myShare, + size: 220, + backgroundColor: Theme.of(context) + .extension()! + .background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const _Div(), + DetailItem( + title: "My name", + detail: ref.watch(pFrostMyName.state).state!, + ), + const _Div(), + DetailItem( + title: "My share", + detail: myShare, + button: Util.isDesktop + ? IconCopyButton( + data: myShare, + ) + : SimpleCopyButton( + data: myShare, + ), + ), + const _Div(), + for (int i = 0; i < participants.length; i++) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frSharesTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter ${participants[i]}'s share", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Share Field Input.", + key: Key("frSharesClearButtonKey_$i"), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Share Field Input.", + key: Key("frSharesPasteButtonKey_$i"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: Key("frSharesScanQrButtonKey_$i"), + onTap: () async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Generate", + onPressed: () async { + // check for empty commitments + if (controllers + .map((e) => e.text.isEmpty) + .reduce((value, element) => value |= element)) { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Missing shares", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + // collect commitment strings and insert my own at the correct index + final shares = controllers.map((e) => e.text).toList(); + shares.insert(myIndex, myShare); + + try { + ref.read(pFrostCompletedKeyGenData.notifier).state = + Frost.completeKeyGeneration( + multisigConfigWithNamePtr: ref + .read(pFrostStartKeyGenData.state) + .state! + .multisigConfigWithNamePtr, + secretSharesResPtr: ref + .read(pFrostSecretSharesData.state) + .state! + .secretSharesResPtr, + shares: shares, + ); + await Navigator.of(context).pushNamed( + ConfirmNewFrostMSWalletCreationView.routeName, + arguments: ( + walletName: widget.walletName, + coin: widget.coin, + ), + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Failed to complete key generation", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + }, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/add_wallet_views/frost_ms/new/import_new_frost_ms_wallet_view.dart b/lib/pages/add_wallet_views/frost_ms/new/import_new_frost_ms_wallet_view.dart new file mode 100644 index 000000000..1b08b045d --- /dev/null +++ b/lib/pages/add_wallet_views/frost_ms/new/import_new_frost_ms_wallet_view.dart @@ -0,0 +1,386 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/frost_share_commitments_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class ImportNewFrostMsWalletView extends ConsumerStatefulWidget { + const ImportNewFrostMsWalletView({ + super.key, + required this.walletName, + required this.coin, + }); + + static const String routeName = "/importNewFrostMsWalletView"; + + final String walletName; + final Coin coin; + + @override + ConsumerState createState() => + _ImportNewFrostMsWalletViewState(); +} + +class _ImportNewFrostMsWalletViewState + extends ConsumerState { + late final TextEditingController myNameFieldController, configFieldController; + late final FocusNode myNameFocusNode, configFocusNode; + + bool _nameEmpty = true, _configEmpty = true; + + @override + void initState() { + myNameFieldController = TextEditingController(); + configFieldController = TextEditingController(); + myNameFocusNode = FocusNode(); + configFocusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + myNameFieldController.dispose(); + configFieldController.dispose(); + myNameFocusNode.dispose(); + configFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Import FROST multisig config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frMyNameTextFieldKey"), + controller: myNameFieldController, + onChanged: (_) { + setState(() { + _nameEmpty = myNameFieldController.text.isEmpty; + }); + }, + focusNode: myNameFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "My name", + myNameFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _nameEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_nameEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Config Field.", + key: const Key("frMyNameClearButtonKey"), + onTap: () { + myNameFieldController.text = ""; + + setState(() { + _nameEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Name Field.", + key: const Key("frMyNamePasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + myNameFieldController.text = + data.text!.trim(); + } + + setState(() { + _nameEmpty = + myNameFieldController.text.isEmpty; + }); + }, + child: _nameEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frConfigTextFieldKey"), + controller: configFieldController, + onChanged: (_) { + setState(() { + _configEmpty = configFieldController.text.isEmpty; + }); + }, + focusNode: configFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter config", + configFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _configEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_configEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Config Field.", + key: const Key("frConfigClearButtonKey"), + onTap: () { + configFieldController.text = ""; + + setState(() { + _configEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Config Field Input.", + key: const Key("frConfigPasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + configFieldController.text = + data.text!.trim(); + } + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + }, + child: _configEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_configEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("frConfigScanQrButtonKey"), + onTap: () async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75)); + } + + final qrResult = await BarcodeScanner.scan(); + + configFieldController.text = + qrResult.rawContent; + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Start key generation", + enabled: !_nameEmpty && !_configEmpty, + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + } + + final config = configFieldController.text; + + if (!Frost.validateEncodedMultisigConfig( + encodedConfig: config)) { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Invalid config", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + if (!Frost.getParticipants(multisigConfig: config) + .contains(myNameFieldController.text)) { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "My name not found in config participants", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + ref.read(pFrostMyName.state).state = myNameFieldController.text; + ref.read(pFrostMultisigConfig.notifier).state = config; + + ref.read(pFrostStartKeyGenData.state).state = + Frost.startKeyGeneration( + multisigConfig: ref.read(pFrostMultisigConfig.state).state!, + myName: ref.read(pFrostMyName.state).state!, + ); + + await Navigator.of(context).pushNamed( + FrostShareCommitmentsView.routeName, + arguments: ( + walletName: widget.walletName, + coin: widget.coin, + ), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/add_wallet_views/frost_ms/new/share_new_multisig_config_view.dart b/lib/pages/add_wallet_views/frost_ms/new/share_new_multisig_config_view.dart new file mode 100644 index 000000000..4afb4c0c5 --- /dev/null +++ b/lib/pages/add_wallet_views/frost_ms/new/share_new_multisig_config_view.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/add_wallet_views/frost_ms/new/frost_share_commitments_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; + +class ShareNewMultisigConfigView extends ConsumerStatefulWidget { + const ShareNewMultisigConfigView({ + super.key, + required this.walletName, + required this.coin, + }); + + static const String routeName = "/shareNewMultisigConfigView"; + + final String walletName; + final Coin coin; + + @override + ConsumerState createState() => + _ShareNewMultisigConfigViewState(); +} + +class _ShareNewMultisigConfigViewState + extends ConsumerState { + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Multisig config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + if (!Util.isDesktop) const Spacer(), + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: + ref.watch(pFrostMultisigConfig.state).state ?? "Error", + size: 220, + backgroundColor: + Theme.of(context).extension()!.background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const SizedBox( + height: 32, + ), + DetailItem( + title: "Encoded config", + detail: ref.watch(pFrostMultisigConfig.state).state ?? "Error", + button: Util.isDesktop + ? IconCopyButton( + data: ref.watch(pFrostMultisigConfig.state).state ?? + "Error", + ) + : SimpleCopyButton( + data: ref.watch(pFrostMultisigConfig.state).state ?? + "Error", + ), + ), + SizedBox( + height: Util.isDesktop ? 64 : 16, + ), + if (!Util.isDesktop) + const Spacer( + flex: 2, + ), + PrimaryButton( + label: "Start key generation", + onPressed: () async { + ref.read(pFrostStartKeyGenData.notifier).state = + Frost.startKeyGeneration( + multisigConfig: ref.watch(pFrostMultisigConfig.state).state!, + myName: ref.read(pFrostMyName.state).state!, + ); + + await Navigator.of(context).pushNamed( + FrostShareCommitmentsView.routeName, + arguments: ( + walletName: widget.walletName, + coin: widget.coin, + ), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart b/lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart new file mode 100644 index 000000000..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 createState() => + _RestoreFrostMsWalletViewState(); +} + +class _RestoreFrostMsWalletViewState + extends ConsumerState { + late final TextEditingController keysFieldController, configFieldController; + late final FocusNode keysFocusNode, configFocusNode; + + bool _keysEmpty = true, _configEmpty = true; + + bool _restoreButtonLock = false; + + Future _createWalletAndRecover() async { + final keys = keysFieldController.text; + final config = configFieldController.text; + + final myNameIndex = 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 _restore() async { + if (_restoreButtonLock) { + return; + } + _restoreButtonLock = true; + + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + } + + Exception? ex; + final wallet = await showLoading( + whileFuture: _createWalletAndRecover(), + context: context, + message: "Restoring wallet...", + isDesktop: Util.isDesktop, + onException: (e) { + ex = e; + }, + ); + + if (ex != null) { + throw ex!; + } + + ref.read(pWallets).addWallet(wallet!); + + if (mounted) { + if (Util.isDesktop) { + Navigator.of(context).popUntil( + ModalRoute.withName( + DesktopHomeView.routeName, + ), + ); + } else { + unawaited( + Navigator.of(context).pushNamedAndRemoveUntil( + HomeView.routeName, + (route) => false, + ), + ); + } + + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Your wallet is set up.", + iconAsset: Assets.svg.check, + context: context, + ), + ); + } + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + if (mounted) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Failed to restore", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + _restoreButtonLock = false; + } + } + + @override + void initState() { + keysFieldController = TextEditingController(); + configFieldController = TextEditingController(); + keysFocusNode = FocusNode(); + configFocusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + keysFieldController.dispose(); + configFieldController.dispose(); + keysFocusNode.dispose(); + configFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Restore FROST multisig wallet", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frMyNameTextFieldKey"), + controller: keysFieldController, + onChanged: (_) { + setState(() { + _keysEmpty = keysFieldController.text.isEmpty; + }); + }, + focusNode: keysFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Keys", + keysFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _keysEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_keysEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Keys Field.", + key: const Key("frMyNameClearButtonKey"), + onTap: () { + keysFieldController.text = ""; + + setState(() { + _keysEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Keys Field.", + key: const Key("frKeysPasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + keysFieldController.text = + data.text!.trim(); + } + + setState(() { + _keysEmpty = + keysFieldController.text.isEmpty; + }); + }, + child: _keysEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frConfigTextFieldKey"), + controller: configFieldController, + onChanged: (_) { + setState(() { + _configEmpty = configFieldController.text.isEmpty; + }); + }, + focusNode: configFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter config", + configFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _configEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_configEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Config Field.", + key: const Key("frConfigClearButtonKey"), + onTap: () { + configFieldController.text = ""; + + setState(() { + _configEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Config Field Input.", + key: const Key("frConfigPasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + configFieldController.text = + data.text!.trim(); + } + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + }, + child: _configEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_configEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("frConfigScanQrButtonKey"), + onTap: () async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75)); + } + + final qrResult = await BarcodeScanner.scan(); + + configFieldController.text = + qrResult.rawContent; + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Restore", + enabled: !_keysEmpty && !_configEmpty, + onPressed: _restore, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/add_wallet_views/name_your_wallet_view/name_your_wallet_view.dart b/lib/pages/add_wallet_views/name_your_wallet_view/name_your_wallet_view.dart index 350c839f8..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 { return name; } + Future _nextPressed() async { + final name = textEditingController.text; + + if (mounted) { + // hide keyboard if has focus + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + } + + if (mounted) { + ref.read(mnemonicWordCountStateProvider.state).state = + Constants.possibleLengthsForCoin(coin).last; + ref.read(pNewWalletOptions.notifier).state = null; + + switch (widget.addWalletType) { + case AddWalletType.New: + unawaited( + Navigator.of(context).pushNamed( + coin.hasMnemonicPassphraseSupport + ? NewWalletOptionsView.routeName + : NewWalletRecoveryPhraseWarningView.routeName, + arguments: Tuple2( + name, + coin, + ), + ), + ); + break; + + case AddWalletType.Restore: + unawaited( + Navigator.of(context).pushNamed( + RestoreOptionsView.routeName, + arguments: Tuple2( + name, + coin, + ), + ), + ); + break; + } + } + } + } + @override void initState() { isDesktop = Util.isDesktop; @@ -330,78 +382,104 @@ class _NameYourWalletViewState extends ConsumerState { const SizedBox( height: 32, ), - ConstrainedBox( - constraints: BoxConstraints( - minWidth: isDesktop ? 480 : 0, - minHeight: isDesktop ? 70 : 0, + if (widget.coin.isFrost) + if (widget.addWalletType == AddWalletType.Restore) + PrimaryButton( + label: "Next", + enabled: _nextEnabled, + onPressed: () async { + final name = textEditingController.text; + + await Navigator.of(context).pushNamed( + RestoreFrostMsWalletView.routeName, + arguments: ( + walletName: name, + coin: coin, + ), + ); + }, + ), + if (widget.addWalletType == AddWalletType.New) + Column( + children: [ + PrimaryButton( + label: "Create config", + enabled: _nextEnabled, + onPressed: () async { + final name = textEditingController.text; + + await Navigator.of(context).pushNamed( + CreateNewFrostMsWalletView.routeName, + arguments: ( + walletName: name, + coin: coin, + ), + ); + }, + ), + const SizedBox( + height: 12, + ), + SecondaryButton( + label: "Import multisig config", + enabled: _nextEnabled, + onPressed: () async { + final name = textEditingController.text; + + await Navigator.of(context).pushNamed( + ImportNewFrostMsWalletView.routeName, + arguments: ( + walletName: name, + coin: coin, + ), + ); + }, + ), + const SizedBox( + height: 12, + ), + SecondaryButton( + label: "Import resharer config", + enabled: _nextEnabled, + onPressed: () async { + final name = textEditingController.text; + + await Navigator.of(context).pushNamed( + NewImportResharerConfigView.routeName, + arguments: ( + walletName: name, + coin: coin, + ), + ); + }, + ), + ], ), - child: TextButton( - onPressed: _nextEnabled - ? () async { - final name = textEditingController.text; - - if (mounted) { - // hide keyboard if has focus - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 50)); - } - - if (mounted) { - ref.read(mnemonicWordCountStateProvider.state).state = - Constants.possibleLengthsForCoin(coin).last; - ref.read(pNewWalletOptions.notifier).state = null; - - switch (widget.addWalletType) { - case AddWalletType.New: - unawaited( - Navigator.of(context).pushNamed( - coin.hasMnemonicPassphraseSupport - ? NewWalletOptionsView.routeName - : NewWalletRecoveryPhraseWarningView - .routeName, - arguments: Tuple2( - name, - coin, - ), - ), - ); - break; - - case AddWalletType.Restore: - unawaited( - Navigator.of(context).pushNamed( - RestoreOptionsView.routeName, - arguments: Tuple2( - name, - coin, - ), - ), - ); - break; - } - } - } - } - : null, - style: _nextEnabled - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle(context), - child: Text( - "Next", - style: isDesktop - ? _nextEnabled - ? STextStyles.desktopButtonEnabled(context) - : STextStyles.desktopButtonDisabled(context) - : STextStyles.button(context), + if (!widget.coin.isFrost) + ConstrainedBox( + constraints: BoxConstraints( + minWidth: isDesktop ? 480 : 0, + minHeight: isDesktop ? 70 : 0, + ), + child: TextButton( + onPressed: _nextEnabled ? _nextPressed : null, + style: _nextEnabled + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), + child: Text( + "Next", + style: isDesktop + ? _nextEnabled + ? STextStyles.desktopButtonEnabled(context) + : STextStyles.desktopButtonDisabled(context) + : STextStyles.button(context), + ), ), ), - ), if (isDesktop) const Spacer( flex: 15, diff --git a/lib/pages/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()!.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()!.highlight, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: onPressed, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 20, + ), + child: Row( + children: [ + Text( + label, + style: STextStyles.titleBold12(context), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/frost_participants_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/frost_participants_view.dart new file mode 100644 index 000000000..b4710bfe7 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/frost_participants_view.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; + +class FrostParticipantsView extends ConsumerWidget { + const FrostParticipantsView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/frostParticipantsView"; + + final String walletId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // TODO: optimize this by creating watcher providers (similar to normal WalletInfo) + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(walletId)!; + + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Participants", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < frostInfo.participants.length; i++) + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Index $i", + style: STextStyles.label(context), + ), + const SizedBox( + height: 6, + ), + SelectableText( + frostInfo.participants[i] == frostInfo.myName + ? "${frostInfo.participants[i]} (me)" + : frostInfo.participants[i], + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/finish_resharing_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/finish_resharing_view.dart new file mode 100644 index 000000000..8d23e9ed4 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/finish_resharing_view.dart @@ -0,0 +1,433 @@ +import 'dart:ffi'; + +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/verify_updated_wallet_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages/wallet_view/wallet_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class FinishResharingView extends ConsumerStatefulWidget { + const FinishResharingView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/finishResharingView"; + + final String walletId; + + @override + ConsumerState createState() => + _FinishResharingViewState(); +} + +class _FinishResharingViewState extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final List resharerIndexes; + late final String myName; + late final int? myResharerIndexIndex; + late final String? myResharerComplete; + late final bool amOutgoingParticipant; + + final List fieldIsEmptyFlags = []; + + bool _buttonLock = false; + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + if (amOutgoingParticipant) { + ref.read(pFrostResharingData).reset(); + Navigator.of(context).popUntil( + ModalRoute.withName( + Util.isDesktop ? DesktopWalletView.routeName : WalletView.routeName, + ), + ); + } else { + // collect resharer completes strings and insert my own at the correct index + final resharerCompletes = controllers.map((e) => e.text).toList(); + if (myResharerIndexIndex != null && myResharerComplete != null) { + resharerCompletes.insert(myResharerIndexIndex!, myResharerComplete!); + } + + final data = Frost.finishReshared( + prior: ref.read(pFrostResharingData).startResharedData!.prior.ref, + resharerCompletes: resharerCompletes, + ); + + ref.read(pFrostResharingData).newWalletData = data; + + await Navigator.of(context).pushNamed( + VerifyUpdatedWalletView.routeName, + arguments: widget.walletId, + ); + } + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + if (mounted) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Error", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + _buttonLock = false; + } + } + + @override + void initState() { + final amNewParticipant = + ref.read(pFrostResharingData).startResharerData == null && + ref.read(pFrostResharingData).incompleteWallet != null && + ref.read(pFrostResharingData).incompleteWallet?.walletId == + widget.walletId; + + myName = ref.read(pFrostResharingData).myName!; + + resharerIndexes = ref.read(pFrostResharingData).configData!.resharers; + + if (amNewParticipant) { + myResharerComplete = null; + myResharerIndexIndex = null; + amOutgoingParticipant = false; + } else { + myResharerComplete = ref.read(pFrostResharingData).resharerComplete!; + + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(widget.walletId)!; + final myOldIndex = + frostInfo.participants.indexOf(ref.read(pFrostResharingData).myName!); + + myResharerIndexIndex = resharerIndexes.indexOf(myOldIndex); + if (myResharerIndexIndex! >= 0) { + // remove my name for now as we don't need a text field for it + resharerIndexes.removeAt(myResharerIndexIndex!); + } + + amOutgoingParticipant = !ref + .read(pFrostResharingData) + .configData! + .newParticipants + .contains(ref.read(pFrostResharingData).myName!); + } + + for (int i = 0; i < resharerIndexes.length; i++) { + controllers.add(TextEditingController()); + focusNodes.add(FocusNode()); + fieldIsEmptyFlags.add(true); + } + super.initState(); + } + + @override + void dispose() { + for (int i = 0; i < controllers.length; i++) { + controllers[i].dispose(); + } + for (int i = 0; i < focusNodes.length; i++) { + focusNodes[i].dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Resharer completes", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + if (myResharerComplete != null) + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: myResharerComplete!, + size: 220, + backgroundColor: Theme.of(context) + .extension()! + .background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + if (myResharerComplete != null) const _Div(), + if (myResharerComplete != null) + DetailItem( + title: "My resharer complete", + detail: myResharerComplete!, + button: Util.isDesktop + ? IconCopyButton( + data: myResharerComplete!, + ) + : SimpleCopyButton( + data: myResharerComplete!, + ), + ), + if (!amOutgoingParticipant) const _Div(), + if (!amOutgoingParticipant) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < resharerIndexes.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frostEncryptionKeyTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (_) { + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + }, + decoration: standardInputDecoration( + "Enter index " + "${resharerIndexes[i]}" + "'s resharer complete", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Encryption Key Field Input.", + key: Key( + "frostEncryptionKeyClearButtonKey_$i"), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Encryption Key Field Input.", + key: Key( + "frostEncryptionKeyPasteButtonKey_$i"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: "Scan QR Button. " + "Opens Camera For Scanning QR Code.", + key: Key("frostScanQrButtonKey_$i"), + onTap: () async { + try { + if (FocusScope.of(context) + .hasFocus) { + FocusScope.of(context) + .unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions " + "while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: amOutgoingParticipant ? "Exit" : "Complete", + enabled: amOutgoingParticipant || + !fieldIsEmptyFlags.reduce((v, e) => v |= e), + onPressed: _onPressed, + ), + ], + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/begin_reshare_config_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/begin_reshare_config_view.dart new file mode 100644 index 000000000..94d2de0b2 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/begin_reshare_config_view.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/complete_reshare_config_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; + +final class BeginReshareConfigView extends ConsumerStatefulWidget { + const BeginReshareConfigView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/beginReshareConfigView"; + + final String walletId; + + @override + ConsumerState createState() => + _BeginReshareConfigViewState(); +} + +class _BeginReshareConfigViewState + extends ConsumerState { + late final int currentThreshold; + late final List currentParticipants; + + final Map pFrostResharersMap = {}; + + @override + void initState() { + ref.read(pFrostResharingData).reset(); + + // TODO: optimize this by creating watcher providers (similar to normal WalletInfo) + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(widget.walletId)!; + + currentThreshold = frostInfo.threshold; + currentParticipants = frostInfo.participants; + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + // title: Text( + // "Modify Participants", + // style: STextStyles.navBarTitle(context), + // ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Select participants for resharing", + style: STextStyles.label(context), + ), + const SizedBox( + height: 16, + ), + Column( + children: [ + for (int i = 0; i < currentParticipants.length; i++) + Padding( + padding: const EdgeInsets.only( + top: 10, + ), + child: RawMaterialButton( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: () { + if (pFrostResharersMap[currentParticipants[i]] == + null) { + pFrostResharersMap[currentParticipants[i]] = i; + } else { + pFrostResharersMap.remove(currentParticipants[i]); + } + + setState(() {}); + }, + child: Container( + color: Colors.transparent, + child: IgnorePointer( + child: Row( + children: [ + Checkbox( + value: pFrostResharersMap[ + currentParticipants[i]] == + i, + onChanged: (bool? value) {}, + ), + const SizedBox( + width: 10, + ), + Text( + currentParticipants[i], + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + ), + ), + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Continue", + enabled: pFrostResharersMap.length >= currentThreshold, + onPressed: () async { + await Navigator.of(context).pushNamed( + CompleteReshareConfigView.routeName, + arguments: ( + walletId: widget.walletId, + resharers: + pFrostResharersMap.values.toList(growable: false), + ), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/complete_reshare_config_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/complete_reshare_config_view.dart new file mode 100644 index 000000000..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 resharers; + + @override + ConsumerState createState() => + _CompleteReshareConfigViewState(); +} + +class _CompleteReshareConfigViewState + extends ConsumerState { + final _newThresholdController = TextEditingController(); + final _newParticipantsCountController = TextEditingController(); + + final List controllers = []; + + int _participantsCount = 0; + + bool _buttonLock = false; + + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + // TODO: optimize this by creating watcher providers (similar to normal WalletInfo) + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(widget.walletId)!; + final validationMessage = _validateInputData(); + + if (validationMessage != "valid") { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: validationMessage, + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + final config = Frost.createResharerConfig( + newThreshold: int.parse(_newThresholdController.text), + resharers: widget.resharers, + newParticipants: controllers.map((e) => e.text).toList(), + ); + + final salt = Format.uint8listToString( + resharerSalt(resharerConfig: config), + ); + + if (frostInfo.knownSalts.contains(salt)) { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Duplicate config salt", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } else { + final salts = frostInfo.knownSalts; + 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( + context: context, + builder: (_) => StackOkDialog( + title: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + _buttonLock = false; + } + } + + String _validateInputData() { + final threshold = int.tryParse(_newThresholdController.text); + if (threshold == null) { + return "Choose a threshold"; + } + + final partsCount = int.tryParse(_newParticipantsCountController.text); + if (partsCount == null) { + return "Choose total number of participants"; + } + + if (threshold > partsCount) { + return "Threshold cannot be greater than the number of participants"; + } + + if (partsCount < 2) { + return "At least two participants required"; + } + + if (controllers.length != partsCount) { + return "Participants count error"; + } + + final hasEmptyParticipants = controllers + .map((e) => e.text.isEmpty) + .reduce((value, element) => value |= element); + if (hasEmptyParticipants) { + return "Participants must not be empty"; + } + + if (controllers.length != controllers.map((e) => e.text).toSet().length) { + return "Duplicate participant name found"; + } + + return "valid"; + } + + void _participantsCountChanged(String newValue) { + final count = int.tryParse(newValue); + if (count != null) { + if (count > _participantsCount) { + for (int i = _participantsCount; i < count; i++) { + controllers.add(TextEditingController()); + } + + _participantsCount = count; + setState(() {}); + } else if (count < _participantsCount) { + for (int i = _participantsCount; i > count; i--) { + final last = controllers.removeLast(); + last.dispose(); + } + + _participantsCount = count; + setState(() {}); + } + } + } + + @override + void dispose() { + _newThresholdController.dispose(); + _newParticipantsCountController.dispose(); + for (final e in controllers) { + e.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Modify Participants", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "New threshold", + style: STextStyles.label(context), + ), + const SizedBox( + height: 10, + ), + TextField( + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + controller: _newThresholdController, + ), + const SizedBox( + height: 16, + ), + Text( + "Number of participants", + style: STextStyles.label(context), + ), + const SizedBox( + height: 10, + ), + TextField( + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + controller: _newParticipantsCountController, + onChanged: _participantsCountChanged, + ), + const SizedBox( + height: 16, + ), + if (controllers.isNotEmpty) + Text( + "Participants", + style: STextStyles.label(context), + ), + if (controllers.isNotEmpty) + const SizedBox( + height: 10, + ), + if (controllers.isNotEmpty) + Column( + children: [ + for (int i = 0; i < controllers.length; i++) + Padding( + padding: const EdgeInsets.only( + top: 10, + ), + child: TextField( + controller: controllers[i], + ), + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Generate config", + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + } + await _onPressed(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/display_reshare_config_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/display_reshare_config_view.dart new file mode 100644 index 000000000..2b7f1f899 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1a/display_reshare_config_view.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/begin_resharing_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; + +class DisplayReshareConfigView extends ConsumerStatefulWidget { + const DisplayReshareConfigView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/displayReshareConfigView"; + + final String walletId; + + @override + ConsumerState createState() => + _DisplayReshareConfigViewState(); +} + +class _DisplayReshareConfigViewState + extends ConsumerState { + late final bool iAmInvolved; + + bool _buttonLock = false; + + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as BitcoinFrostWallet; + + final serializedKeys = await wallet.getSerializedKeys(); + if (mounted) { + final result = Frost.beginResharer( + serializedKeys: serializedKeys!, + config: ref.read(pFrostResharingData).resharerConfig!, + ); + + ref.read(pFrostResharingData).startResharerData = result; + + await Navigator.of(context).pushNamed( + BeginResharingView.routeName, + arguments: widget.walletId, + ); + } + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + if (mounted) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + _buttonLock = false; + } + } + + @override + void initState() { + // TODO: optimize this by creating watcher providers (similar to normal WalletInfo) + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(widget.walletId)!; + + final myOldIndex = frostInfo.participants.indexOf(frostInfo.myName); + + iAmInvolved = ref + .read(pFrostResharingData) + .configData! + .resharers + .contains(myOldIndex); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Resharer config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + if (!Util.isDesktop) const Spacer(), + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: ref.watch(pFrostResharingData).resharerConfig!, + size: 220, + backgroundColor: + Theme.of(context).extension()!.background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const SizedBox( + height: 32, + ), + DetailItem( + title: "Config", + detail: ref.watch(pFrostResharingData).resharerConfig!, + button: Util.isDesktop + ? IconCopyButton( + data: ref.watch(pFrostResharingData).resharerConfig!, + ) + : SimpleCopyButton( + data: ref.watch(pFrostResharingData).resharerConfig!, + ), + ), + SizedBox( + height: Util.isDesktop ? 64 : 16, + ), + if (!Util.isDesktop) + const Spacer( + flex: 2, + ), + if (iAmInvolved) + PrimaryButton( + label: "Start resharing", + onPressed: _onPressed, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1b/import_reshare_config_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1b/import_reshare_config_view.dart new file mode 100644 index 000000000..966a24710 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_1b/import_reshare_config_view.dart @@ -0,0 +1,338 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frostdart/frostdart.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/begin_resharing_view.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/providers/global/secure_store_provider.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/format.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class ImportReshareConfigView extends ConsumerStatefulWidget { + const ImportReshareConfigView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/importReshareConfigView"; + + final String walletId; + + @override + ConsumerState createState() => + _ImportReshareConfigViewState(); +} + +class _ImportReshareConfigViewState + extends ConsumerState { + late final TextEditingController configFieldController; + late final FocusNode configFocusNode; + + bool _configEmpty = true; + + bool _buttonLock = false; + + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + // TODO: optimize this by creating watcher providers (similar to normal WalletInfo) + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(widget.walletId)!; + + ref.read(pFrostResharingData).reset(); + ref.read(pFrostResharingData).myName = frostInfo.myName; + ref.read(pFrostResharingData).resharerConfig = configFieldController.text; + + String? salt; + try { + salt = Format.uint8listToString( + resharerSalt( + resharerConfig: ref.read(pFrostResharingData).resharerConfig!, + ), + ); + } catch (_) { + throw Exception("Bad resharer config"); + } + + if (frostInfo.knownSalts.contains(salt)) { + throw Exception("Duplicate config salt"); + } else { + final salts = frostInfo.knownSalts; + salts.add(salt); + final mainDB = ref.read(mainDBProvider); + await mainDB.isar.writeTxn(() async { + final info = frostInfo; + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(knownSalts: salts), + ); + }); + } + + final serializedKeys = await ref.read(secureStoreProvider).read( + key: "{${widget.walletId}}_serializedFROSTKeys", + ); + if (mounted) { + final result = Frost.beginResharer( + serializedKeys: serializedKeys!, + config: ref.read(pFrostResharingData).resharerConfig!, + ); + + ref.read(pFrostResharingData).startResharerData = result; + + await Navigator.of(context).pushNamed( + BeginResharingView.routeName, + arguments: widget.walletId, + ); + } + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + if (mounted) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + _buttonLock = false; + } + } + + @override + void initState() { + configFieldController = TextEditingController(); + configFocusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + configFieldController.dispose(); + configFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: const AppBarBackButton(), + title: Text( + "Import FROST reshare config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frConfigTextFieldKey"), + controller: configFieldController, + onChanged: (_) { + setState(() { + _configEmpty = configFieldController.text.isEmpty; + }); + }, + focusNode: configFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter config", + configFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _configEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_configEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Config Field.", + key: const Key("frConfigClearButtonKey"), + onTap: () { + configFieldController.text = ""; + + setState(() { + _configEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Config Field Input.", + key: const Key("frConfigPasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + configFieldController.text = + data.text!.trim(); + } + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + }, + child: _configEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_configEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("frConfigScanQrButtonKey"), + onTap: () async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75)); + } + + final qrResult = await BarcodeScanner.scan(); + + configFieldController.text = + qrResult.rawContent; + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Start resharing", + enabled: !_configEmpty, + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + } + + await _onPressed(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/begin_resharing_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/begin_resharing_view.dart new file mode 100644 index 000000000..90218529f --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/begin_resharing_view.dart @@ -0,0 +1,439 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/continue_resharing_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages/wallet_view/wallet_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class BeginResharingView extends ConsumerStatefulWidget { + const BeginResharingView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/beginResharingView"; + + final String walletId; + + @override + ConsumerState createState() => _BeginResharingViewState(); +} + +class _BeginResharingViewState extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final List resharerIndexes; + late final int myResharerIndexIndex; + late final String myResharerStart; + late final bool amOutgoingParticipant; + + final List fieldIsEmptyFlags = []; + + bool _buttonLock = false; + + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + if (!amOutgoingParticipant) { + // collect resharer strings + final resharerStarts = controllers.map((e) => e.text).toList(); + if (myResharerIndexIndex >= 0) { + // only insert my own at the correct index if I am a resharer + resharerStarts.insert(myResharerIndexIndex, myResharerStart); + } + + final result = Frost.beginReshared( + myName: ref.read(pFrostResharingData).myName!, + resharerConfig: ref.read(pFrostResharingData).resharerConfig!, + resharerStarts: resharerStarts, + ); + + ref.read(pFrostResharingData).startResharedData = result; + } + await Navigator.of(context).pushNamed( + ContinueResharingView.routeName, + arguments: widget.walletId, + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + if (mounted) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Error", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + _buttonLock = false; + } + } + + @override + void initState() { + // TODO: optimize this by creating watcher providers (similar to normal WalletInfo) + final frostInfo = ref + .read(mainDBProvider) + .isar + .frostWalletInfo + .getByWalletIdSync(widget.walletId)!; + final myOldIndex = + frostInfo.participants.indexOf(ref.read(pFrostResharingData).myName!); + + myResharerStart = + ref.read(pFrostResharingData).startResharerData!.resharerStart; + + resharerIndexes = ref.read(pFrostResharingData).configData!.resharers; + myResharerIndexIndex = resharerIndexes.indexOf(myOldIndex); + if (myResharerIndexIndex >= 0) { + // remove my name for now as we don't need a text field for it + resharerIndexes.removeAt(myResharerIndexIndex); + } + + amOutgoingParticipant = !ref + .read(pFrostResharingData) + .configData! + .newParticipants + .contains(ref.read(pFrostResharingData).myName!); + + for (int i = 0; i < resharerIndexes.length; i++) { + controllers.add(TextEditingController()); + focusNodes.add(FocusNode()); + fieldIsEmptyFlags.add(true); + } + super.initState(); + } + + @override + void dispose() { + for (int i = 0; i < controllers.length; i++) { + controllers[i].dispose(); + } + for (int i = 0; i < focusNodes.length; i++) { + focusNodes[i].dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: Util.isDesktop + ? DesktopWalletView.routeName + : WalletView.routeName, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: DesktopWalletView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: WalletView.routeName, + ), + ); + }, + ), + title: Text( + "Resharers", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: myResharerStart, + size: 220, + backgroundColor: Theme.of(context) + .extension()! + .background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const _Div(), + DetailItem( + title: "My resharer", + detail: myResharerStart, + button: Util.isDesktop + ? IconCopyButton( + data: myResharerStart, + ) + : SimpleCopyButton( + data: myResharerStart, + ), + ), + const _Div(), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < resharerIndexes.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frostResharerTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (_) { + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + }, + decoration: standardInputDecoration( + "Enter index " + "${resharerIndexes[i]}" + "'s resharer", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Resharer Field Input.", + key: Key( + "frostResharerClearButtonKey_$i"), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Resharer Field Input.", + key: Key( + "frostResharerPasteButtonKey_$i"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: "Scan QR Button. " + "Opens Camera For Scanning QR Code.", + key: Key( + "frostCommitmentsScanQrButtonKey_$i"), + onTap: () async { + try { + if (FocusScope.of(context) + .hasFocus) { + FocusScope.of(context) + .unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions " + "while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Continue", + enabled: amOutgoingParticipant || + !fieldIsEmptyFlags.reduce((v, e) => v |= e), + onPressed: _onPressed, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/continue_resharing_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/continue_resharing_view.dart new file mode 100644 index 000000000..75359d266 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/involved/step_2/continue_resharing_view.dart @@ -0,0 +1,429 @@ +import 'dart:ffi'; + +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/finish_resharing_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages/wallet_view/wallet_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class ContinueResharingView extends ConsumerStatefulWidget { + const ContinueResharingView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/continueResharingView"; + + final String walletId; + + @override + ConsumerState createState() => + _ContinueResharingViewState(); +} + +class _ContinueResharingViewState extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final List newParticipants; + late final int myIndex; + late final String? myEncryptionKey; + late final bool amOutgoingParticipant; + + final List fieldIsEmptyFlags = []; + + bool _buttonLock = false; + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + // collect encryptionKeys strings and insert my own at the correct index + final encryptionKeys = controllers.map((e) => e.text).toList(); + if (!amOutgoingParticipant) { + encryptionKeys.insert(myIndex, myEncryptionKey!); + } + + final result = Frost.finishResharer( + machine: ref.read(pFrostResharingData).startResharerData!.machine.ref, + encryptionKeysOfResharedTo: encryptionKeys, + ); + + ref.read(pFrostResharingData).resharerComplete = result; + + await Navigator.of(context).pushNamed( + FinishResharingView.routeName, + arguments: widget.walletId, + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Error", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } finally { + _buttonLock = false; + } + } + + @override + void initState() { + myEncryptionKey = + ref.read(pFrostResharingData).startResharedData?.resharedStart; + + newParticipants = ref.read(pFrostResharingData).configData!.newParticipants; + myIndex = newParticipants.indexOf(ref.read(pFrostResharingData).myName!); + + if (myIndex >= 0) { + // remove my name for now as we don't need a text field for it + newParticipants.removeAt(myIndex); + } + + if (myEncryptionKey == null && myIndex == -1) { + amOutgoingParticipant = true; + } else if (myEncryptionKey != null && myIndex >= 0) { + amOutgoingParticipant = false; + } else { + throw Exception("Invalid resharing state"); + } + + for (int i = 0; i < newParticipants.length; i++) { + controllers.add(TextEditingController()); + focusNodes.add(FocusNode()); + fieldIsEmptyFlags.add(true); + } + super.initState(); + } + + @override + void dispose() { + for (int i = 0; i < controllers.length; i++) { + controllers[i].dispose(); + } + for (int i = 0; i < focusNodes.length; i++) { + focusNodes[i].dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: Util.isDesktop + ? DesktopWalletView.routeName + : WalletView.routeName, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: DesktopWalletView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: WalletView.routeName, + ), + ); + }, + ), + title: Text( + "Encryption keys", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + if (!amOutgoingParticipant) + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: myEncryptionKey!, + size: 220, + backgroundColor: Theme.of(context) + .extension()! + .background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + if (!amOutgoingParticipant) const _Div(), + if (!amOutgoingParticipant) + DetailItem( + title: "My encryption key", + detail: myEncryptionKey!, + button: Util.isDesktop + ? IconCopyButton( + data: myEncryptionKey!, + ) + : SimpleCopyButton( + data: myEncryptionKey!, + ), + ), + if (!amOutgoingParticipant) const _Div(), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < newParticipants.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frostEncryptionKeyTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (_) { + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + }, + decoration: standardInputDecoration( + "Enter " + "${newParticipants[i]}" + "'s encryption key", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Encryption Key Field Input.", + key: Key( + "frostEncryptionKeyClearButtonKey_$i"), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Encryption Key Field Input.", + key: Key( + "frostEncryptionKeyPasteButtonKey_$i"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: "Scan QR Button. " + "Opens Camera For Scanning QR Code.", + key: Key( + "frostCommitmentsScanQrButtonKey_$i"), + onTap: () async { + try { + if (FocusScope.of(context) + .hasFocus) { + FocusScope.of(context) + .unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions " + "while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Continue", + enabled: !fieldIsEmptyFlags.reduce((v, e) => v |= e), + onPressed: _onPressed, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_continue_sharing_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_continue_sharing_view.dart new file mode 100644 index 000000000..86ff2ebe0 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_continue_sharing_view.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:stackwallet/pages/home_view/home_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/finish_resharing_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_home_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; + +class NewContinueSharingView extends ConsumerStatefulWidget { + const NewContinueSharingView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/NewContinueSharingView"; + + final String walletId; + + @override + ConsumerState createState() => + _NewContinueSharingViewState(); +} + +class _NewContinueSharingViewState + extends ConsumerState { + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: + Util.isDesktop ? DesktopHomeView.routeName : HomeView.routeName, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: HomeView.routeName, + ), + ); + }, + ), + title: Text( + "Encryption keys", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: ref + .watch(pFrostResharingData) + .startResharedData! + .resharedStart, + size: 220, + backgroundColor: Theme.of(context) + .extension()! + .background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const _Div(), + DetailItem( + title: "My encryption key", + detail: ref + .watch(pFrostResharingData) + .startResharedData! + .resharedStart, + button: Util.isDesktop + ? IconCopyButton( + data: ref + .watch(pFrostResharingData) + .startResharedData! + .resharedStart, + ) + : SimpleCopyButton( + data: ref + .watch(pFrostResharingData) + .startResharedData! + .resharedStart, + ), + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Continue", + onPressed: () { + Navigator.of(context).pushNamed( + FinishResharingView.routeName, + arguments: widget.walletId, + ); + }, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_import_resharer_config_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_import_resharer_config_view.dart new file mode 100644 index 000000000..f3ef1ec0b --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_import_resharer_config_view.dart @@ -0,0 +1,426 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_start_resharing_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/show_loading.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/models/incomplete_frost_wallet.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class NewImportResharerConfigView extends ConsumerStatefulWidget { + const NewImportResharerConfigView({ + super.key, + required this.walletName, + required this.coin, + }); + + static const String routeName = "/newImportResharerConfigView"; + + final String walletName; + final Coin coin; + + @override + ConsumerState createState() => + _NewImportResharerConfigViewState(); +} + +class _NewImportResharerConfigViewState + extends ConsumerState { + late final TextEditingController myNameFieldController, configFieldController; + late final FocusNode myNameFocusNode, configFocusNode; + + bool _nameEmpty = true, _configEmpty = true; + + bool _buttonLock = false; + + Future _createWallet() async { + final info = WalletInfo.createNew( + name: widget.walletName, + coin: widget.coin, + ); + + final wallet = IncompleteFrostWallet(); + wallet.info = info; + + return wallet; + } + + @override + void initState() { + myNameFieldController = TextEditingController(); + configFieldController = TextEditingController(); + myNameFocusNode = FocusNode(); + configFocusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + myNameFieldController.dispose(); + configFieldController.dispose(); + myNameFocusNode.dispose(); + configFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Import FROST reshare config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frMyNameTextFieldKey"), + controller: myNameFieldController, + onChanged: (_) { + setState(() { + _nameEmpty = myNameFieldController.text.isEmpty; + }); + }, + focusNode: myNameFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "My name", + myNameFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _nameEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_nameEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Config Field.", + key: const Key("frMyNameClearButtonKey"), + onTap: () { + myNameFieldController.text = ""; + + setState(() { + _nameEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Name Field.", + key: const Key("frMyNamePasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + myNameFieldController.text = + data.text!.trim(); + } + + setState(() { + _nameEmpty = + myNameFieldController.text.isEmpty; + }); + }, + child: _nameEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frConfigTextFieldKey"), + controller: configFieldController, + onChanged: (_) { + setState(() { + _configEmpty = configFieldController.text.isEmpty; + }); + }, + focusNode: configFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter config", + configFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _configEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_configEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Config Field.", + key: const Key("frConfigClearButtonKey"), + onTap: () { + configFieldController.text = ""; + + setState(() { + _configEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Config Field Input.", + key: const Key("frConfigPasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + configFieldController.text = + data.text!.trim(); + } + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + }, + child: _configEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_configEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("frConfigScanQrButtonKey"), + onTap: () async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75)); + } + + final qrResult = await BarcodeScanner.scan(); + + configFieldController.text = + qrResult.rawContent; + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Start", + enabled: !_nameEmpty && !_configEmpty, + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + } + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + ref.read(pFrostResharingData).reset(); + ref.read(pFrostResharingData).myName = + myNameFieldController.text; + ref.read(pFrostResharingData).resharerConfig = + configFieldController.text; + + if (!ref + .read(pFrostResharingData) + .configData! + .newParticipants + .contains(ref.read(pFrostResharingData).myName!)) { + ref.read(pFrostResharingData).reset(); + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "My name not found in config participants", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + Exception? ex; + final wallet = await showLoading( + whileFuture: _createWallet(), + context: context, + message: "Setting up wallet", + isDesktop: Util.isDesktop, + onException: (e) => ex = e, + ); + + if (ex != null) { + throw ex!; + } + + if (mounted) { + ref.read(pFrostResharingData).incompleteWallet = wallet!; + await Navigator.of(context).pushNamed( + NewStartResharingView.routeName, + arguments: wallet.walletId, + ); + } + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + if (mounted) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + _buttonLock = false; + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_start_resharing_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_start_resharing_view.dart new file mode 100644 index 000000000..fb6107c2c --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_start_resharing_view.dart @@ -0,0 +1,379 @@ +import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/home_view/home_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/frost_ms/resharing/new/new_continue_sharing_view.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_home_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +class NewStartResharingView extends ConsumerStatefulWidget { + const NewStartResharingView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/newStartResharingView"; + + final String walletId; + + @override + ConsumerState createState() => + _NewStartResharingViewState(); +} + +class _NewStartResharingViewState extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final List resharerIndexes; + + final List fieldIsEmptyFlags = []; + + bool _buttonLock = false; + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + // collect resharer strings + final resharerStarts = controllers.map((e) => e.text).toList(); + + final result = Frost.beginReshared( + myName: ref.read(pFrostResharingData).myName!, + resharerConfig: ref.read(pFrostResharingData).resharerConfig!, + resharerStarts: resharerStarts, + ); + + ref.read(pFrostResharingData).startResharedData = result; + + await Navigator.of(context).pushNamed( + NewContinueSharingView.routeName, + arguments: widget.walletId, + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Error", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } finally { + _buttonLock = false; + } + } + + @override + void initState() { + resharerIndexes = ref.read(pFrostResharingData).configData!.resharers; + + for (int i = 0; i < resharerIndexes.length; i++) { + controllers.add(TextEditingController()); + focusNodes.add(FocusNode()); + fieldIsEmptyFlags.add(true); + } + super.initState(); + } + + @override + void dispose() { + for (int i = 0; i < controllers.length; i++) { + controllers[i].dispose(); + } + for (int i = 0; i < focusNodes.length; i++) { + focusNodes[i].dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: + Util.isDesktop ? DesktopHomeView.routeName : HomeView.routeName, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: DesktopHomeView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: HomeView.routeName, + ), + ); + }, + ), + title: Text( + "Resharers", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < resharerIndexes.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frostResharerTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (_) { + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + }, + decoration: standardInputDecoration( + "Enter index " + "${resharerIndexes[i]}" + "'s resharer", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Resharer Field Input.", + key: Key( + "frostResharerClearButtonKey_$i"), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Resharer Field Input.", + key: Key( + "frostResharerPasteButtonKey_$i"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: "Scan QR Button. " + "Opens Camera For Scanning QR Code.", + key: Key( + "frostCommitmentsScanQrButtonKey_$i"), + onTap: () async { + try { + if (FocusScope.of(context) + .hasFocus) { + FocusScope.of(context) + .unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions " + "while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Continue", + enabled: !fieldIsEmptyFlags.reduce((v, e) => v |= e), + onPressed: _onPressed, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/verify_updated_wallet_view.dart b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/verify_updated_wallet_view.dart new file mode 100644 index 000000000..85d02c0ff --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/frost_ms/resharing/verify_updated_wallet_view.dart @@ -0,0 +1,315 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/home_view/home_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/pages/wallet_view/wallet_view.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_home_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart'; +import 'package:stackwallet/providers/global/node_service_provider.dart'; +import 'package:stackwallet/providers/global/prefs_provider.dart'; +import 'package:stackwallet/providers/global/secure_store_provider.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/show_loading.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; +import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/detail_item.dart'; +import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; + +class VerifyUpdatedWalletView extends ConsumerStatefulWidget { + const VerifyUpdatedWalletView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/verifyUpdatedWalletView"; + + final String walletId; + + @override + ConsumerState createState() => + _VerifyUpdatedWalletViewState(); +} + +class _VerifyUpdatedWalletViewState + extends ConsumerState { + late final String config; + late final String serializedKeys; + late final String reshareId; + + late final bool isNew; + + bool _buttonLock = false; + Future _onPressed() async { + if (_buttonLock) { + return; + } + _buttonLock = true; + + try { + Exception? ex; + + final BitcoinFrostWallet wallet; + + if (isNew) { + wallet = await ref + .read(pFrostResharingData) + .incompleteWallet! + .toBitcoinFrostWallet( + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + ); + + await wallet.info.setMnemonicVerified( + isar: ref.read(mainDBProvider).isar, + ); + + ref.read(pWallets).addWallet(wallet); + } else { + wallet = + ref.read(pWallets).getWallet(widget.walletId) as BitcoinFrostWallet; + } + + if (mounted) { + await showLoading( + whileFuture: wallet.updateWithResharedData( + serializedKeys: serializedKeys, + multisigConfig: config, + isNewWallet: isNew, + ), + context: context, + message: isNew ? "Creating wallet" : "Updating wallet data", + isDesktop: Util.isDesktop, + onException: (e) => ex = e, + ); + + if (ex != null) { + throw ex!; + } + + if (mounted) { + ref.read(pFrostResharingData).reset(); + + Navigator.of(context).popUntil( + ModalRoute.withName( + _popUntilPath, + ), + ); + } + } + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + if (mounted) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Error", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + _buttonLock = false; + } + } + + String get _popUntilPath => isNew + ? Util.isDesktop + ? DesktopHomeView.routeName + : HomeView.routeName + : Util.isDesktop + ? DesktopWalletView.routeName + : WalletView.routeName; + + @override + void initState() { + config = ref.read(pFrostResharingData).newWalletData!.multisigConfig; + serializedKeys = + ref.read(pFrostResharingData).newWalletData!.serializedKeys; + reshareId = ref.read(pFrostResharingData).newWalletData!.resharedId; + + isNew = ref.read(pFrostResharingData).incompleteWallet != null && + ref.read(pFrostResharingData).incompleteWallet!.walletId == + widget.walletId; + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: _popUntilPath, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: _popUntilPath, + ), + ); + }, + ), + trailing: ExitToMyStackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: _popUntilPath, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.resharing, + popUntilOnYesRouteName: _popUntilPath, + ), + ); + }, + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + children: [ + Text( + "Ensure your reshare ID matches that of each other participant", + style: STextStyles.pageTitleH2(context), + ), + const _Div(), + DetailItem( + title: "ID", + detail: reshareId, + button: Util.isDesktop + ? IconCopyButton( + data: reshareId, + ) + : SimpleCopyButton( + data: reshareId, + ), + ), + const _Div(), + const _Div(), + Text( + "Back up your keys and config", + style: STextStyles.pageTitleH2(context), + ), + const _Div(), + DetailItem( + title: "Config", + detail: config, + button: Util.isDesktop + ? IconCopyButton( + data: config, + ) + : SimpleCopyButton( + data: config, + ), + ), + const _Div(), + DetailItem( + title: "Keys", + detail: serializedKeys, + button: Util.isDesktop + ? IconCopyButton( + data: serializedKeys, + ) + : SimpleCopyButton( + data: serializedKeys, + ), + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Confirm", + onPressed: _onPressed, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/providers/frost_wallet/frost_wallet_providers.dart b/lib/providers/frost_wallet/frost_wallet_providers.dart new file mode 100644 index 000000000..3b181b7b8 --- /dev/null +++ b/lib/providers/frost_wallet/frost_wallet_providers.dart @@ -0,0 +1,103 @@ +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frostdart/frostdart_bindings_generated.dart'; +import 'package:stackwallet/services/frost.dart'; +import 'package:stackwallet/wallets/models/incomplete_frost_wallet.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; + +// =================== wallet creation ========================================= +final pFrostMultisigConfig = StateProvider((ref) => null); +final pFrostMyName = StateProvider((ref) => null); + +final pFrostStartKeyGenData = StateProvider< + ({ + String seed, + String commitments, + Pointer multisigConfigWithNamePtr, + Pointer secretShareMachineWrapperPtr, + })?>((_) => null); + +final pFrostSecretSharesData = StateProvider< + ({ + String share, + Pointer secretSharesResPtr, + })?>((ref) => null); + +final pFrostCompletedKeyGenData = StateProvider< + ({ + Uint8List multisigId, + String recoveryString, + String serializedKeys, + })?>((ref) => null); + +// ================= transaction creation ====================================== +final pFrostTxData = StateProvider((ref) => null); + +final pFrostAttemptSignData = StateProvider< + ({ + Pointer machinePtr, + String preprocess, + })?>((ref) => null); + +final pFrostContinueSignData = StateProvider< + ({ + Pointer machinePtr, + String share, + })?>((ref) => null); + +// ===================== shared/util =========================================== +final pFrostSelectParticipantsUnordered = + StateProvider?>((ref) => null); + +// ========================= resharing ========================================= +final pFrostResharingData = Provider((ref) => _ResharingData()); + +class _ResharingData { + String? myName; + + IncompleteFrostWallet? incompleteWallet; + + // resharer encoded config string + String? resharerConfig; + ({ + int newThreshold, + List resharers, + List newParticipants, + })? get configData => resharerConfig != null + ? Frost.extractResharerConfigData(resharerConfig: resharerConfig!) + : null; + + // resharer start string (for sharing) and machine + ({ + String resharerStart, + Pointer machine, + })? startResharerData; + + // reshared start string (for sharing) and machine + ({ + String resharedStart, + Pointer prior, + })? startResharedData; + + // resharer complete string (for sharing) + String? resharerComplete; + + // new keys and config with an ID + ({ + String multisigConfig, + String serializedKeys, + String resharedId, + })? newWalletData; + + // reset/clear all data + void reset() { + resharerConfig = null; + startResharerData = null; + startResharedData = null; + resharerComplete = null; + newWalletData = null; + incompleteWallet = null; + } +} diff --git a/lib/route_generator.dart b/lib/route_generator.dart index a046cc01d..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 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>) { // 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((ref, coin) { final assets = ref.watch(themeAssetsProvider); + // TODO: handle this differently by adding proper frost assets to themes + if (coin == Coin.bitcoinFrost) { + coin = Coin.bitcoin; + } else if (coin == Coin.bitcoinFrostTestNet) { + coin = Coin.bitcoinTestNet; + } + if (assets is ThemeAssetsV3) { return assets.coinCardImages?[coin.mainNetVersion]; } else { @@ -26,6 +33,13 @@ final coinCardProvider = Provider.family((ref, coin) { final coinCardFavoritesProvider = Provider.family((ref, coin) { final assets = ref.watch(themeAssetsProvider); + // TODO: handle this differently by adding proper frost assets to themes + if (coin == Coin.bitcoinFrost) { + coin = Coin.bitcoin; + } else if (coin == Coin.bitcoinFrostTestNet) { + coin = Coin.bitcoinTestNet; + } + if (assets is ThemeAssetsV3) { return assets.coinCardFavoritesImages?[coin.mainNetVersion] ?? assets.coinCardImages?[coin.mainNetVersion]; diff --git a/lib/themes/coin_icon_provider.dart b/lib/themes/coin_icon_provider.dart index 9bd3990bb..f0c0df842 100644 --- a/lib/themes/coin_icon_provider.dart +++ b/lib/themes/coin_icon_provider.dart @@ -16,6 +16,13 @@ import 'package:stackwallet/utilities/enums/coin_enum.dart'; final coinIconProvider = Provider.family((ref, coin) { final assets = ref.watch(themeAssetsProvider); + // TODO: handle this differently by adding proper frost assets to themes + if (coin == Coin.bitcoinFrost) { + coin = Coin.bitcoin; + } else if (coin == Coin.bitcoinFrostTestNet) { + coin = Coin.bitcoinTestNet; + } + if (assets is ThemeAssets) { switch (coin) { case Coin.bitcoin: diff --git a/lib/themes/coin_image_provider.dart b/lib/themes/coin_image_provider.dart index 6ca839fb9..fa1fdde4c 100644 --- a/lib/themes/coin_image_provider.dart +++ b/lib/themes/coin_image_provider.dart @@ -16,6 +16,13 @@ import 'package:stackwallet/utilities/enums/coin_enum.dart'; final coinImageProvider = Provider.family((ref, coin) { final assets = ref.watch(themeAssetsProvider); + // TODO: handle this differently by adding proper frost assets to themes + if (coin == Coin.bitcoinFrost) { + coin = Coin.bitcoin; + } else if (coin == Coin.bitcoinFrostTestNet) { + coin = Coin.bitcoinTestNet; + } + if (assets is ThemeAssets) { switch (coin) { case Coin.bitcoin: @@ -64,6 +71,13 @@ final coinImageProvider = Provider.family((ref, coin) { final coinImageSecondaryProvider = Provider.family((ref, coin) { final assets = ref.watch(themeAssetsProvider); + // TODO: handle this differently by adding proper frost assets to themes + if (coin == Coin.bitcoinFrost) { + coin = Coin.bitcoin; + } else if (coin == Coin.bitcoinFrostTestNet) { + coin = Coin.bitcoinTestNet; + } + if (assets is ThemeAssets) { switch (coin) { case Coin.bitcoin: diff --git a/lib/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 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 extends Wallet { } } - 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 extends Wallet { 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 extends Wallet { // =================== Secure storage ======================================== - Future _getSerializedKeys() async => + Future getSerializedKeys() async => await secureStorageInterface.read( key: "{$walletId}_serializedFROSTKeys", ); @@ -867,7 +867,7 @@ class BitcoinFrostWallet extends Wallet { Future _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()! + .textSubtitle3, + ), + ) + : SelectableText( + "$title will appear here", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle3, + ), + ) + : disableSelectableText + ? Text( + detail, + style: STextStyles.w500_14(context), + ) + : SelectableText( + detail, + style: STextStyles.w500_14(context), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/dialogs/frost_interruption_dialog.dart b/lib/widgets/dialogs/frost_interruption_dialog.dart new file mode 100644 index 000000000..4d36456bb --- /dev/null +++ b/lib/widgets/dialogs/frost_interruption_dialog.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/desktop/secondary_button.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; + +enum FrostInterruptionDialogType { + walletCreation, + resharing, + transactionCreation; +} + +class FrostInterruptionDialog extends StatelessWidget { + const FrostInterruptionDialog({ + super.key, + required this.type, + required this.popUntilOnYesRouteName, + this.onNoPressedOverride, + this.onYesPressedOverride, + }); + + final FrostInterruptionDialogType type; + final String popUntilOnYesRouteName; + final VoidCallback? onNoPressedOverride; + final VoidCallback? onYesPressedOverride; + + String get message { + switch (type) { + case FrostInterruptionDialogType.walletCreation: + return "wallet creation"; + case FrostInterruptionDialogType.resharing: + return "resharing"; + case FrostInterruptionDialogType.transactionCreation: + return "transaction signing"; + } + } + + @override + Widget build(BuildContext context) { + return StackDialog( + title: "Cancel $message process", + message: "Are you sure you want to cancel the $message process?", + leftButton: SecondaryButton( + label: "No", + onPressed: onNoPressedOverride ?? + Navigator.of( + context, + rootNavigator: Util.isDesktop, + ).pop, + ), + rightButton: PrimaryButton( + label: "Yes", + onPressed: onYesPressedOverride ?? + () { + // pop dialog + Navigator.of( + context, + rootNavigator: Util.isDesktop, + ).pop(); + + Navigator.of(context).popUntil( + ModalRoute.withName( + popUntilOnYesRouteName, + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/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()! From 9deb8a5d0c4688f434c640a5ea337e836652b79d Mon Sep 17 00:00:00 2001 From: Diego Salazar Date: Fri, 19 Jan 2024 20:16:01 -0700 Subject: [PATCH 07/38] 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 Date: Tue, 23 Jan 2024 18:33:40 -0600 Subject: [PATCH 08/38] 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 createState() => + _FrostAttemptSignConfigViewState(); +} + +class _FrostAttemptSignConfigViewState + extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final String myName; + late final List participantsWithoutMe; + late final String myPreprocess; + late final int myIndex; + late final int threshold; + + final List fieldIsEmptyFlags = []; + + bool hasEnoughPreprocesses() { + // own preprocess is not included in controllers and must be set here + int count = 1; + + for (final controller in controllers) { + if (controller.text.isNotEmpty) { + count++; + } + } + + return count >= threshold; + } + + @override + void initState() { + final wallet = ref + .read(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()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Preprocesses", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: myPreprocess, + size: 220, + backgroundColor: + Theme.of(context).extension()!.background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const _Div(), + DetailItem( + title: "My name", + detail: myName, + ), + const _Div(), + DetailItem( + title: "My preprocess", + detail: myPreprocess, + button: Util.isDesktop + ? IconCopyButton( + data: myPreprocess, + ) + : SimpleCopyButton( + data: myPreprocess, + ), + ), + const _Div(), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < participantsWithoutMe.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frostPreprocessesTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (_) { + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + }, + decoration: standardInputDecoration( + "Enter ${participantsWithoutMe[i]}'s preprocess", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Preprocess Field Input.", + key: Key( + "frostPreprocessesClearButtonKey_$i", + ), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Preprocess Field Input.", + key: Key( + "frostPreprocessesPasteButtonKey_$i", + ), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: Key( + "frostPreprocessesScanQrButtonKey_$i", + ), + onTap: () async { + try { + if (FocusScope.of(context) + .hasFocus) { + FocusScope.of(context) + .unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i].text.isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Continue signing", + enabled: hasEnoughPreprocesses(), + onPressed: () async { + // collect Preprocess strings (not including my own) + final preprocesses = controllers.map((e) => e.text).toList(); + + // collect participants who are involved in this transaction + final List requiredParticipantsUnordered = []; + for (int i = 0; i < participantsWithoutMe.length; i++) { + if (preprocesses[i].isNotEmpty) { + requiredParticipantsUnordered.add(participantsWithoutMe[i]); + } + } + ref.read(pFrostSelectParticipantsUnordered.notifier).state = + requiredParticipantsUnordered; + + // insert an empty string at my index + preprocesses.insert(myIndex, ""); + + try { + ref.read(pFrostContinueSignData.notifier).state = + Frost.continueSigning( + machinePtr: + ref.read(pFrostAttemptSignData.state).state!.machinePtr, + preprocesses: preprocesses, + ); + + await Navigator.of(context).pushNamed( + FrostContinueSignView.routeName, + arguments: widget.walletId, + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Failed to continue signing", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + }, + ), + ], + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/send_view/frost_ms/frost_complete_sign_view.dart b/lib/pages/send_view/frost_ms/frost_complete_sign_view.dart new file mode 100644 index 000000000..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 createState() => + _FrostCompleteSignViewState(); +} + +class _FrostCompleteSignViewState extends ConsumerState { + bool _broadcastLock = false; + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Preview transaction", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: ref.watch(pFrostTxData.state).state!.raw!, + size: 220, + backgroundColor: + Theme.of(context).extension()!.background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const _Div(), + DetailItem( + title: "Raw transaction hex", + detail: ref.watch(pFrostTxData.state).state!.raw!, + button: Util.isDesktop + ? IconCopyButton( + data: ref.watch(pFrostTxData.state).state!.raw!, + ) + : SimpleCopyButton( + data: ref.watch(pFrostTxData.state).state!.raw!, + ), + ), + const _Div(), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Broadcast Transaction", + onPressed: () async { + if (_broadcastLock) { + return; + } + _broadcastLock = true; + + try { + Exception? ex; + final txData = await showLoading( + whileFuture: ref + .read(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( + 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 createState() => + _FrostContinueSignViewState(); +} + +class _FrostContinueSignViewState extends ConsumerState { + final List controllers = []; + final List focusNodes = []; + + late final String myName; + late final List participantsWithoutMe; + late final List participantsAll; + late final String myShare; + late final int myIndex; + + final List fieldIsEmptyFlags = []; + + @override + void initState() { + final wallet = ref + .read(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( + context: context, + builder: (_) => FrostInterruptionDialog( + type: FrostInterruptionDialogType.transactionCreation, + popUntilOnYesRouteName: Util.isDesktop + ? DesktopWalletView.routeName + : WalletView.routeName, + ), + ); + return false; + }, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.transactionCreation, + popUntilOnYesRouteName: DesktopWalletView.routeName, + ), + ); + }, + ), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const FrostInterruptionDialog( + type: FrostInterruptionDialogType.transactionCreation, + popUntilOnYesRouteName: WalletView.routeName, + ), + ); + }, + ), + title: Text( + "Shares", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + QrImageView( + data: myShare, + size: 220, + backgroundColor: Theme.of(context) + .extension()! + .background, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark, + ), + ], + ), + ), + const _Div(), + DetailItem( + title: "My name", + detail: myName, + ), + const _Div(), + DetailItem( + title: "My shares", + detail: myShare, + button: Util.isDesktop + ? IconCopyButton( + data: myShare, + ) + : SimpleCopyButton( + data: myShare, + ), + ), + const _Div(), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < participantsWithoutMe.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: Key("frostSharesTextFieldKey_$i"), + controller: controllers[i], + focusNode: focusNodes[i], + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter ${participantsWithoutMe[i]}'s share", + focusNodes[i], + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: fieldIsEmptyFlags[i] + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + !fieldIsEmptyFlags[i] + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears " + "The Share Field Input.", + key: Key( + "frostSharesClearButtonKey_$i", + ), + onTap: () { + controllers[i].text = ""; + + setState(() { + fieldIsEmptyFlags[i] = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From " + "Clipboard To Share Field Input.", + key: Key( + "frostSharesPasteButtonKey_$i"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + controllers[i].text = + data.text!.trim(); + } + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + }, + child: fieldIsEmptyFlags[i] + ? const ClipboardIcon() + : const XIcon(), + ), + if (fieldIsEmptyFlags[i]) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera " + "For Scanning QR Code.", + key: Key( + "frostSharesScanQrButtonKey_$i", + ), + onTap: () async { + try { + if (FocusScope.of(context) + .hasFocus) { + FocusScope.of(context) + .unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75)); + } + + final qrResult = + await BarcodeScanner.scan(); + + controllers[i].text = + qrResult.rawContent; + + setState(() { + fieldIsEmptyFlags[i] = + controllers[i] + .text + .isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions " + "while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + if (!Util.isDesktop) const Spacer(), + const _Div(), + PrimaryButton( + label: "Complete signing", + onPressed: () async { + // check for empty shares + if (controllers + .map((e) => e.text.isEmpty) + .reduce((value, element) => value |= element)) { + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Missing Shares", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + // collect Share strings + final sharesCollected = + controllers.map((e) => e.text).toList(); + + final List shares = []; + for (final participant in participantsAll) { + if (participantsWithoutMe.contains(participant)) { + shares.add(sharesCollected[ + participantsWithoutMe.indexOf(participant)]); + } else { + shares.add(""); + } + } + + try { + final rawTx = Frost.completeSigning( + machinePtr: ref + .read(pFrostContinueSignData.state) + .state! + .machinePtr, + shares: shares, + ); + + ref.read(pFrostTxData.state).state = + ref.read(pFrostTxData.state).state!.copyWith( + raw: rawTx, + ); + + await Navigator.of(context).pushNamed( + FrostCompleteSignView.routeName, + arguments: widget.walletId, + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Fatal, + ); + + return await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Failed to complete signing process", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + }, + ), + ], + ), + ), + ), + ); + } +} + +class _Div extends StatelessWidget { + const _Div({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 12, + ); + } +} diff --git a/lib/pages/send_view/frost_ms/frost_create_sign_config_view.dart b/lib/pages/send_view/frost_ms/frost_create_sign_config_view.dart new file mode 100644 index 000000000..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 createState() => + _FrostCreateSignConfigViewState(); +} + +class _FrostCreateSignConfigViewState + extends ConsumerState { + bool _attemptSignLock = false; + + Future _attemptSign() async { + if (_attemptSignLock) { + return; + } + + _attemptSignLock = true; + + try { + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as BitcoinFrostWallet; + + final attemptSignRes = await wallet.frostAttemptSignConfig( + config: ref.read(pFrostTxData.state).state!.frostMSConfig!, + ); + + ref.read(pFrostAttemptSignData.notifier).state = attemptSignRes; + + await Navigator.of(context).pushNamed( + FrostAttemptSignConfigView.routeName, + arguments: widget.walletId, + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Error, + ); + } finally { + _attemptSignLock = false; + } + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "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()!.background, + foregroundColor: Theme.of(context) + .extension()! + .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 createState() => + _FrostImportSignConfigViewState(); +} + +class _FrostImportSignConfigViewState + extends ConsumerState { + late final TextEditingController configFieldController; + late final FocusNode configFocusNode; + + bool _configEmpty = true; + + bool _attemptSignLock = false; + + Future _attemptSign() async { + if (_attemptSignLock) { + return; + } + + _attemptSignLock = true; + + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + } + + final config = configFieldController.text; + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as BitcoinFrostWallet; + + final data = Frost.extractDataFromSignConfig( + signConfig: config, + coin: wallet.cryptoCurrency, + ); + + final utxos = await ref + .read(mainDBProvider) + .getUTXOs(wallet.walletId) + .filter() + .anyOf( + data.inputs, + (q, e) => q + .txidEqualTo(Format.uint8listToString(e.hash)) + .and() + .valueEqualTo(e.value) + .and() + .voutEqualTo(e.vout)) + .findAll(); + + // TODO add more data from 'data' and display to user ? + ref.read(pFrostTxData.notifier).state = TxData( + frostMSConfig: config, + recipients: data.recipients, + utxos: utxos.toSet(), + ); + + final attemptSignRes = await wallet.frostAttemptSignConfig( + config: ref.read(pFrostTxData.state).state!.frostMSConfig!, + ); + + ref.read(pFrostAttemptSignData.notifier).state = attemptSignRes; + + await Navigator.of(context).pushNamed( + FrostAttemptSignConfigView.routeName, + arguments: widget.walletId, + ); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Error, + ); + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Import and attempt sign config failed", + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } finally { + _attemptSignLock = false; + } + } + + @override + void initState() { + configFieldController = TextEditingController(); + configFocusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + configFieldController.dispose(); + configFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Import FROST sign config", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 16, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("frConfigTextFieldKey"), + controller: configFieldController, + onChanged: (_) { + setState(() { + _configEmpty = configFieldController.text.isEmpty; + }); + }, + focusNode: configFocusNode, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter config", + configFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _configEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_configEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Config Field.", + key: const Key("frConfigClearButtonKey"), + onTap: () { + configFieldController.text = ""; + + setState(() { + _configEmpty = true; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Config Field Input.", + key: const Key("frConfigPasteButtonKey"), + onTap: () async { + final ClipboardData? data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + configFieldController.text = + data.text!.trim(); + } + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + }, + child: _configEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_configEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("frConfigScanQrButtonKey"), + onTap: () async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75)); + } + + final qrResult = await BarcodeScanner.scan(); + + configFieldController.text = + qrResult.rawContent; + + setState(() { + _configEmpty = + configFieldController.text.isEmpty; + }); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ) + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 16, + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Start signing", + enabled: !_configEmpty, + onPressed: () { + _attemptSign(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/send_view/frost_ms/frost_send_view.dart b/lib/pages/send_view/frost_ms/frost_send_view.dart new file mode 100644 index 000000000..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 createState() => _FrostSendViewState(); +} + +class _FrostSendViewState extends ConsumerState { + final List recipientWidgetIndexes = [0]; + int _greatestWidgetIndex = 0; + + late final String walletId; + late final Coin coin; + + late TextEditingController noteController; + late TextEditingController onChainNoteController; + + final _noteFocusNode = FocusNode(); + + Set selectedUTXOs = {}; + + bool _createSignLock = false; + + Future _loadingFuture() async { + final wallet = ref.read(pWallets).getWallet(walletId) as BitcoinFrostWallet; + + final recipients = recipientWidgetIndexes + .map((i) => ref.read(pRecipient(i).state).state) + .map((e) => (address: e!.address, amount: e.amount!, isChange: false)) + .toList(growable: false); + + final txData = await wallet.frostCreateSignConfig( + txData: TxData(recipients: recipients), + changeAddress: (await wallet.getCurrentReceivingAddress())!.value, + feePerWeight: customFeeRate, + ); + + return txData; + } + + Future _createSignConfig() async { + if (_createSignLock) { + return; + } + _createSignLock = true; + + try { + // wait for keyboard to disappear + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 100), + ); + + TxData? txData; + if (mounted) { + txData = await showLoading( + whileFuture: _loadingFuture(), + context: context, + message: "Generating sign config", + isDesktop: Util.isDesktop, + onException: (e) { + throw e; + }, + ); + } + + if (mounted && txData != null) { + ref.read(pFrostTxData.notifier).state = txData; + + await Navigator.of(context).pushNamed( + FrostCreateSignConfigView.routeName, + arguments: widget.walletId, + ); + } + } catch (e) { + if (mounted) { + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Create sign config failed", + message: e.toString(), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + ), + ); + } + } finally { + _createSignLock = false; + } + } + + int customFeeRate = 1; + + void _validateRecipientFormStates() { + for (final i in recipientWidgetIndexes) { + final state = ref.read(pRecipient(i).state).state; + if (state?.amount == null || state?.address == null) { + ref.read(previewTxButtonStateProvider.notifier).state = false; + return; + } + } + ref.read(previewTxButtonStateProvider.notifier).state = true; + return; + } + + @override + void initState() { + coin = widget.coin; + walletId = widget.walletId; + + noteController = TextEditingController(); + + super.initState(); + } + + @override + void dispose() { + noteController.dispose(); + + _noteFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + final wallet = ref.watch(pWallets).getWallet(walletId); + + final showCoinControl = wallet is CoinControlInterface && + ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.enableCoinControl, + ), + ); + + return ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + } + if (mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Send ${coin.ticker}", + style: STextStyles.navBarTitle(context), + ), + actions: [ + Padding( + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + right: 10, + ), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + semanticsLabel: "Import sign config Button.", + key: const Key("importSignConfigButtonKey"), + size: 36, + shadows: const [], + color: + Theme.of(context).extension()!.background, + icon: SvgPicture.asset( + Assets.svg.circlePlus, + color: Theme.of(context) + .extension()! + .accentColorDark, + width: 20, + height: 20, + ), + onPressed: () { + Navigator.of(context).pushNamed( + FrostImportSignConfigView.routeName, + arguments: walletId, + ); + }, + ), + ), + ), + ], + ), + body: LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + // subtract top and bottom padding set in parent + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!Util.isDesktop) + Container( + decoration: BoxDecoration( + color: Theme.of(context).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + SvgPicture.file( + File( + ref.watch( + coinIconProvider(coin), + ), + ), + width: 22, + height: 22, + ), + const SizedBox( + width: 6, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.titleBold12(context) + .copyWith(fontSize: 14), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + // const SizedBox( + // height: 2, + // ), + Text( + "Available balance", + style: + STextStyles.label(context).copyWith(fontSize: 10), + ), + ], + ), + Util.isDesktop + ? const SizedBox( + height: 24, + ) + : const Spacer(), + GestureDetector( + onTap: () { + // cryptoAmountController.text = ref + // .read(pAmountFormatter(coin)) + // .format( + // _cachedBalance!, + // withUnitName: false, + // ); + }, + child: Container( + color: Colors.transparent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + ref.watch(pAmountFormatter(coin)).format(ref + .watch(pWalletBalance(walletId)) + .spendable), + style: STextStyles.titleBold12(context).copyWith( + fontSize: 10, + ), + textAlign: TextAlign.right, + ), + // Text( + // "${(manager.balance.spendable.decimal * ref.watch( + // priceAnd24hChangeNotifierProvider.select( + // (value) => value.getPrice(coin).item1, + // ), + // )).toAmount( + // fractionDigits: 2, + // ).fiatString( + // locale: locale, + // )} ${ref.watch( + // prefsChangeNotifierProvider + // .select((value) => value.currency), + // )}", + // style: STextStyles.subtitle(context).copyWith( + // fontSize: 8, + // ), + // textAlign: TextAlign.right, + // ) + ], + ), + ), + ) + ], + ), + ), + ), + const SizedBox( + height: 16, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Recipients", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + CustomTextButton( + text: "Add", + onTap: () { + // used for tracking recipient forms + _greatestWidgetIndex++; + recipientWidgetIndexes.add(_greatestWidgetIndex); + setState(() {}); + }, + ), + ], + ), + const SizedBox( + height: 8, + ), + Column( + children: [ + for (int i = 0; i < recipientWidgetIndexes.length; i++) + ConditionalParent( + condition: recipientWidgetIndexes.length > 1, + builder: (child) => Padding( + padding: const EdgeInsets.only(top: 8), + child: child, + ), + child: Recipient( + key: Key( + "recipientKey_${recipientWidgetIndexes[i]}", + ), + index: recipientWidgetIndexes[i], + coin: coin, + onChanged: () { + _validateRecipientFormStates(); + }, + remove: i == 0 && recipientWidgetIndexes.length == 1 + ? null + : () { + recipientWidgetIndexes.removeAt(i); + setState(() {}); + }, + ), + ), + ], + ), + if (showCoinControl) + const SizedBox( + height: 8, + ), + if (showCoinControl) + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Coin control", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), + ), + CustomTextButton( + text: selectedUTXOs.isEmpty + ? "Select coins" + : "Selected coins (${selectedUTXOs.length})", + onTap: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 100), + ); + } + + if (mounted) { + // finally spendable = ref + // .read(walletsChangeNotifierProvider) + // .getManager(widget.walletId) + // .balance + // .spendable; + + // TODO: [prio=high] make sure this coincontrol works correctly + + Amount? amount; + + final result = await Navigator.of(context).pushNamed( + CoinControlView.routeName, + arguments: Tuple4( + walletId, + CoinControlViewType.use, + amount, + selectedUTXOs, + ), + ); + + if (result is Set) { + setState(() { + selectedUTXOs = result; + }); + } + } + }, + ), + ], + ), + ), + const SizedBox( + height: 12, + ), + Text( + "Note (optional)", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox( + height: 8, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + controller: noteController, + focusNode: _noteFocusNode, + style: STextStyles.field(context), + onChanged: (_) => setState(() {}), + decoration: standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + ).copyWith( + suffixIcon: noteController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + noteController.text = ""; + }); + }, + ), + ], + ), + ), + ) + : null, + ), + ), + ), + const SizedBox( + height: 12, + ), + Padding( + padding: const EdgeInsets.only( + bottom: 12, + top: 16, + ), + child: FeeSlider( + coin: coin, + onSatVByteChanged: (rate) { + customFeeRate = rate; + }, + ), + ), + Util.isDesktop + ? const SizedBox( + height: 12, + ) + : const Spacer(), + const SizedBox( + height: 12, + ), + TextButton( + onPressed: ref.watch(previewTxButtonStateProvider.state).state + ? _createSignConfig + : null, + style: ref.watch(previewTxButtonStateProvider.state).state + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), + child: Text( + "Create config", + style: STextStyles.button(context), + ), + ), + const SizedBox( + height: 16, + ), + ], + ), + ); + } +} + +final previewTxButtonStateProvider = StateProvider((_) => false); diff --git a/lib/pages/send_view/frost_ms/recipient.dart b/lib/pages/send_view/frost_ms/recipient.dart new file mode 100644 index 000000000..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((ref) => const ClipboardWrapper()); +final pBarcodeScanner = + Provider((ref) => const BarcodeScannerWrapper()); + +// final _pPrice = Provider.family((ref, coin) { +// return ref.watch( +// priceAnd24hChangeNotifierProvider +// .select((value) => value.getPrice(coin).item1), +// ); +// }); + +final pRecipient = + StateProvider.family<({String address, Amount? amount})?, int>( + (ref, index) => null); + +class Recipient extends ConsumerStatefulWidget { + const Recipient({ + super.key, + required this.index, + required this.coin, + this.remove, + this.onChanged, + }); + + final int index; + final Coin coin; + + final VoidCallback? remove; + final VoidCallback? onChanged; + + @override + ConsumerState createState() => _RecipientState(); +} + +class _RecipientState extends ConsumerState { + late final TextEditingController addressController, amountController; + late final FocusNode addressFocusNode, amountFocusNode; + + bool _addressIsEmpty = true; + bool _cryptoAmountChangeLock = false; + + void _updateRecipientData() { + final address = addressController.text; + final amount = + ref.read(pAmountFormatter(widget.coin)).tryParse(amountController.text); + + ref.read(pRecipient(widget.index).notifier).state = ( + address: address, + amount: amount, + ); + widget.onChanged?.call(); + } + + void _cryptoAmountChanged() async { + if (!_cryptoAmountChangeLock) { + Amount? cryptoAmount = ref.read(pAmountFormatter(widget.coin)).tryParse( + amountController.text, + ); + if (cryptoAmount != null) { + if (ref.read(pRecipient(widget.index))?.amount != null && + ref.read(pRecipient(widget.index))?.amount == cryptoAmount) { + return; + } + + // final price = ref.read(_pPrice(widget.coin)); + // + // if (price > Decimal.zero) { + // baseController.text = (cryptoAmount.decimal * price) + // .toAmount( + // fractionDigits: 2, + // ) + // .fiatString( + // locale: ref.read(localeServiceChangeNotifierProvider).locale, + // ); + // } + } else { + cryptoAmount = null; + // baseController.text = ""; + } + + _updateRecipientData(); + } + } + + @override + void initState() { + addressController = TextEditingController(); + amountController = TextEditingController(); + // baseController = TextEditingController(); + + addressFocusNode = FocusNode(); + amountFocusNode = FocusNode(); + // baseFocusNode = FocusNode(); + + amountController.addListener(_cryptoAmountChanged); + + super.initState(); + } + + @override + void dispose() { + amountController.removeListener(_cryptoAmountChanged); + + addressController.dispose(); + amountController.dispose(); + // baseController.dispose(); + + addressFocusNode.dispose(); + amountFocusNode.dispose(); + // baseFocusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final String locale = ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), + ); + + return RoundedWhiteContainer( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("sendViewAddressFieldKey"), + controller: addressController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + focusNode: addressFocusNode, + style: STextStyles.field(context), + onChanged: (_) { + setState(() { + _addressIsEmpty = addressController.text.isEmpty; + }); + }, + decoration: standardInputDecoration( + "Enter ${widget.coin.ticker} address", + addressFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _addressIsEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_addressIsEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Address Field Input.", + key: const Key( + "sendViewClearAddressFieldButtonKey"), + onTap: () { + addressController.text = ""; + + setState(() { + _addressIsEmpty = true; + }); + + _updateRecipientData(); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Address Field Input.", + key: const Key( + "sendViewPasteAddressFieldButtonKey"), + onTap: () async { + final ClipboardData? data = await ref + .read(pClipboard) + .getData(Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + String content = data.text!.trim(); + if (content.contains("\n")) { + content = content.substring( + 0, content.indexOf("\n")); + } + + addressController.text = content.trim(); + + setState(() { + _addressIsEmpty = + addressController.text.isEmpty; + }); + + _updateRecipientData(); + } + }, + child: _addressIsEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_addressIsEmpty) + TextFieldIconButton( + semanticsLabel: "Scan QR Button. " + "Opens Camera For Scanning QR Code.", + key: const Key( + "sendViewScanQrButtonKey", + ), + onTap: () async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration( + milliseconds: 75, + ), + ); + } + + final qrResult = + await ref.read(pBarcodeScanner).scan(); + + Logging.instance.log( + "qrResult content: ${qrResult.rawContent}", + level: LogLevel.Info, + ); + + /// TODO: deal with address utils + final results = + AddressUtils.parseUri(qrResult.rawContent); + + Logging.instance.log( + "qrResult parsed: $results", + level: LogLevel.Info, + ); + + if (results.isNotEmpty && + results["scheme"] == + widget.coin.uriScheme) { + // auto fill address + + addressController.text = + (results["address"] ?? "").trim(); + + // autofill amount field + if (results["amount"] != null) { + final Amount amount = + Decimal.parse(results["amount"]!) + .toAmount( + fractionDigits: widget.coin.decimals, + ); + amountController.text = ref + .read(pAmountFormatter(widget.coin)) + .format( + amount, + withUnitName: false, + ); + } + } else { + addressController.text = + qrResult.rawContent.trim(); + } + + setState(() { + _addressIsEmpty = + addressController.text.isEmpty; + }); + + _updateRecipientData(); + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while " + "trying to scan qr code in SendView: $e\n$s", + level: LogLevel.Warning, + ); + } + }, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 12, + ), + TextField( + autocorrect: false, + enableSuggestions: false, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ), + key: const Key("amountInputFieldCryptoTextFieldKey"), + controller: amountController, + focusNode: amountFocusNode, + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + AmountInputFormatter( + decimals: widget.coin.decimals, + unit: ref.watch(pAmountUnit(widget.coin)), + locale: locale, + ), + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 12, + right: 12, + ), + hintText: "0", + hintStyle: STextStyles.fieldLabel(context).copyWith( + fontSize: 14, + ), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + ref + .watch(pAmountUnit(widget.coin)) + .unitForCoin(widget.coin), + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark), + ), + ), + ), + ), + ), + // if (ref.watch(prefsChangeNotifierProvider + // .select((value) => value.externalCalls))) + // const SizedBox( + // height: 8, + // ), + // if (ref.watch(prefsChangeNotifierProvider + // .select((value) => value.externalCalls))) + // TextField( + // autocorrect: Util.isDesktop ? false : true, + // enableSuggestions: Util.isDesktop ? false : true, + // style: STextStyles.smallMed14(context).copyWith( + // color: Theme.of(context).extension()!.textDark, + // ), + // key: const Key("amountInputFieldFiatTextFieldKey"), + // controller: baseController, + // focusNode: baseFocusNode, + // keyboardType: Util.isDesktop + // ? null + // : const TextInputType.numberWithOptions( + // signed: false, + // decimal: true, + // ), + // textAlign: TextAlign.right, + // inputFormatters: [ + // AmountInputFormatter( + // decimals: 2, + // locale: locale, + // ), + // ], + // onChanged: (baseAmountString) { + // final baseAmount = Amount.tryParseFiatString( + // baseAmountString, + // locale: locale, + // ); + // Amount? cryptoAmount; + // final int decimals = widget.coin.decimals; + // if (baseAmount != null) { + // final _price = ref.read(_pPrice(widget.coin)); + // + // if (_price == Decimal.zero) { + // cryptoAmount = 0.toAmountAsRaw( + // fractionDigits: decimals, + // ); + // } else { + // cryptoAmount = baseAmount <= Amount.zero + // ? 0.toAmountAsRaw(fractionDigits: decimals) + // : (baseAmount.decimal / _price) + // .toDecimal( + // scaleOnInfinitePrecision: decimals, + // ) + // .toAmount(fractionDigits: decimals); + // } + // if (ref.read(pRecipient(widget.index))?.amount != null && + // ref.read(pRecipient(widget.index))?.amount == + // cryptoAmount) { + // return; + // } + // + // final amountString = + // ref.read(pAmountFormatter(widget.coin)).format( + // cryptoAmount, + // withUnitName: false, + // ); + // + // _cryptoAmountChangeLock = true; + // amountController.text = amountString; + // _cryptoAmountChangeLock = false; + // } else { + // cryptoAmount = 0.toAmountAsRaw( + // fractionDigits: decimals, + // ); + // _cryptoAmountChangeLock = true; + // amountController.text = ""; + // _cryptoAmountChangeLock = false; + // } + // + // _updateRecipientData(); + // }, + // decoration: InputDecoration( + // contentPadding: const EdgeInsets.only( + // top: 12, + // right: 12, + // ), + // hintText: "0", + // hintStyle: STextStyles.fieldLabel(context).copyWith( + // fontSize: 14, + // ), + // prefixIcon: FittedBox( + // fit: BoxFit.scaleDown, + // child: Padding( + // padding: const EdgeInsets.all(12), + // child: Text( + // ref.watch(prefsChangeNotifierProvider + // .select((value) => value.currency)), + // style: STextStyles.smallMed14(context).copyWith( + // color: Theme.of(context) + // .extension()! + // .accentColorDark), + // ), + // ), + // ), + // ), + // ), + if (widget.remove != null) + const SizedBox( + height: 6, + ), + if (widget.remove != null) + Row( + children: [ + const Spacer(), + CustomTextButton( + text: "Remove", + onTap: () { + ref.read(pRecipient(widget.index).notifier).state = null; + widget.remove?.call(); + }, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/pages/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 { // 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 { ]; 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 { 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>) { // 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 extends Wallet { + @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 { // 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 Date: Thu, 25 Jan 2024 02:20:37 -0600 Subject: [PATCH 09/38] 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 { 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 { @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 { 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 extends Wallet { - @override - int get isarTransactionVersion => 2; - - @override - bool get supportsMultiRecipient => true; - BitcoinFrostWallet(CryptoCurrencyNetwork network) : super(BitcoinFrost(network) as T); @@ -89,7 +83,9 @@ class BitcoinFrostWallet extends Wallet { 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 extends Wallet { // ==================== Overrides ============================================ + @override + bool get supportsMultiRecipient => true; + @override int get isarTransactionVersion => 2; @@ -527,8 +526,40 @@ class BitcoinFrostWallet extends Wallet { @override Future 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 Date: Thu, 25 Jan 2024 12:13:35 -0600 Subject: [PATCH 10/38] 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 Date: Thu, 25 Jan 2024 19:03:53 -0600 Subject: [PATCH 11/38] 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 { 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()! - .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.delayed( - const Duration(milliseconds: 100), - ); - } - - if (mounted) { - // finally spendable = ref - // .read(walletsChangeNotifierProvider) - // .getManager(widget.walletId) - // .balance - // .spendable; - - // TODO: [prio=high] make sure this coincontrol works correctly - - Amount? amount; - - final result = await Navigator.of(context).pushNamed( - CoinControlView.routeName, - arguments: Tuple4( - walletId, - CoinControlViewType.use, - amount, - selectedUTXOs, - ), - ); - - if (result is Set) { - setState(() { - selectedUTXOs = result; - }); - } - } + 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()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension()! - .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()! + .textSubtitle1, + ), + ), + CustomTextButton( + text: selectedUTXOs.isEmpty + ? "Select coins" + : "Selected coins (${selectedUTXOs.length})", + onTap: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 100), + ); + } + + if (mounted) { + // finally spendable = ref + // .read(walletsChangeNotifierProvider) + // .getManager(widget.walletId) + // .balance + // .spendable; + + // TODO: [prio=high] make sure this coincontrol works correctly + + Amount? amount; + + final result = + await Navigator.of(context).pushNamed( + CoinControlView.routeName, + arguments: Tuple4( + walletId, + CoinControlViewType.use, + amount, + selectedUTXOs, + ), + ); + + if (result is Set) { + setState(() { + selectedUTXOs = result; + }); + } + } + }, + ), + ], + ), + ), + const SizedBox( + height: 12, + ), + Text( + "Note (optional)", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox( + height: 8, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + controller: noteController, + focusNode: _noteFocusNode, + style: STextStyles.field(context), + onChanged: (_) => setState(() {}), + decoration: standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + ).copyWith( + suffixIcon: noteController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + noteController.text = ""; + }); + }, + ), + ], + ), + ), + ) + : null, + ), + ), + ), + const SizedBox( + height: 12, + ), + Padding( + padding: const EdgeInsets.only( + bottom: 12, + top: 16, + ), + child: FeeSlider( + coin: coin, + onSatVByteChanged: (rate) { + customFeeRate = rate; + }, + ), + ), + Util.isDesktop + ? const SizedBox( + height: 12, + ) + : const Spacer(), + const SizedBox( + height: 12, + ), + TextButton( + onPressed: ref.watch(previewTxButtonStateProvider.state).state + ? _createSignConfig + : null, + style: ref.watch(previewTxButtonStateProvider.state).state + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), + child: Text( + "Create config", + style: STextStyles.button(context), + ), + ), + const SizedBox( + height: 16, + ), + ]), ), ], ), From 77f1f346d632598e970c44db57704536bcfc13c7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 25 Jan 2024 19:04:07 -0600 Subject: [PATCH 12/38] 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 { ); 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 Date: Mon, 29 Jan 2024 17:31:41 -0600 Subject: [PATCH 13/38] 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 { ); }, ), - 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 Date: Mon, 29 Jan 2024 23:29:52 -0600 Subject: [PATCH 14/38] 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 extends Wallet { if (knownSalts.contains(salt)) { throw Exception("Known frost multisig salt found!"); } - knownSalts.add(salt); - await _updateKnownSalts(knownSalts); + List updatedKnownSalts = List.from(knownSalts); + updatedKnownSalts.add(salt); + await _updateKnownSalts(updatedKnownSalts); } else { // clear cache await electrumXCachedClient.clearSharedTransactionCache(coin: coin); @@ -1001,8 +1002,9 @@ class BitcoinFrostWallet extends Wallet { .findFirstSync()!; Future _updateKnownSalts(List 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 Date: Tue, 30 Jan 2024 11:43:09 -0600 Subject: [PATCH 15/38] 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 extends Wallet { key: "{$walletId}_serializedFROSTKeysPrevGen", ); - Future _multisigConfig() async => await secureStorageInterface.read( + Future getMultisigConfig() async => + await secureStorageInterface.read( key: "{$walletId}_multisigConfig", ); @@ -942,7 +943,7 @@ class BitcoinFrostWallet extends Wallet { Future _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 Date: Tue, 30 Jan 2024 11:43:40 -0600 Subject: [PATCH 16/38] 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 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 Date: Tue, 30 Jan 2024 11:48:50 -0600 Subject: [PATCH 17/38] 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 otherData = {}; otherData["keys"] = keys; otherData["config"] = config; From a17a551a2b21c76e2ef9c7020c1951c4b3228791 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 30 Jan 2024 12:25:39 -0600 Subject: [PATCH 18/38] 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 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 Date: Tue, 30 Jan 2024 12:41:37 -0600 Subject: [PATCH 19/38] 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 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 Date: Tue, 30 Jan 2024 12:45:39 -0600 Subject: [PATCH 20/38] 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 mnemonicList = (walletbackup['mnemonic'] as List) From 0d3ef1bfc4b3a76573e0afb5efbc1c64c67dfde2 Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 30 Jan 2024 18:08:31 -0600 Subject: [PATCH 21/38] 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 otherData = {}; - otherData["keys"] = keys; - otherData["config"] = config; - otherData["myName"] = myName; - backupWallet['otherDataJsonString'] = jsonEncode(otherData); + Map 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 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? 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 Date: Tue, 30 Jan 2024 19:50:55 -0600 Subject: [PATCH 22/38] 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 mnemonic; + final ({ + String myName, + String config, + String keys, + ({String config, String keys})? prevGen, + })? frostWalletData; final ClipboardInterface clipboardInterface; @override Widget build(BuildContext context, WidgetRef ref) { debugPrint("BUILD: $runtimeType"); + + final bool frost = frostWalletData != null; + final prevGen = frostWalletData?.prevGen != null; + return Background( child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, @@ -91,139 +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()!.popupBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Text( - "Please write down your backup key. Keep it safe and never share it with anyone. Your backup key is the only way you can access your funds if you forget your PIN, lose your phone, etc.\n\nStack Wallet does not keep nor is able to restore your backup key. Only you have access to your wallet.", - style: STextStyles.label(context), - ), - ), - ), - const SizedBox( - height: 8, - ), - Expanded( - child: SingleChildScrollView( - child: MnemonicTable( - words: mnemonic, - isDesktop: false, - ), - ), - ), - const SizedBox( - height: 12, - ), - TextButton( - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - onPressed: () { - String data = AddressUtils.encodeQRSeedData(mnemonic); - - showDialog( - context: context, - useSafeArea: false, - barrierDismissible: true, - builder: (_) { - final width = MediaQuery.of(context).size.width / 2; - return StackDialogBase( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Text( - "Recovery phrase QR code", - style: STextStyles.pageTitleH2(context), - ), - ), - const SizedBox( - height: 12, - ), - Center( - child: RepaintBoundary( - // key: _qrKey, - child: SizedBox( - width: width + 20, - height: width + 20, - child: QrImageView( - data: data, - size: width, - backgroundColor: Theme.of(context) - .extension()! - .popupBG, - foregroundColor: Theme.of(context) - .extension()! - .accentColorDark), + child: frost + ? LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, + ), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RoundedWhiteContainer( + child: Text( + "Please write down your backup data. Keep it safe and " + "never share it with anyone. " + "Your backup data is the only way you can access your " + "funds if you forget your PIN, lose your phone, etc." + "\n\n" + "Stack Wallet does not keep nor is able to restore " + "your backup data. " + "Only you have access to your wallet.", + style: STextStyles.label(context), ), ), - ), - const SizedBox( - height: 12, - ), - Center( - child: SizedBox( - width: width, - child: TextButton( - onPressed: () async { - // await _capturePng(true); - Navigator.of(context).pop(); - }, - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), + const SizedBox( + height: 24, + ), + // DetailItem( + // title: "My name", + // detail: frostWalletData!.myName, + // button: Util.isDesktop + // ? IconCopyButton( + // data: frostWalletData!.myName, + // ) + // : SimpleCopyButton( + // data: frostWalletData!.myName, + // ), + // ), + // const SizedBox( + // height: 16, + // ), + DetailItem( + title: "Multisig config", + detail: frostWalletData!.config, + button: Util.isDesktop + ? IconCopyButton( + data: frostWalletData!.config, + ) + : SimpleCopyButton( + data: frostWalletData!.config, + ), + ), + const SizedBox( + height: 16, + ), + DetailItem( + title: "Keys", + detail: frostWalletData!.keys, + button: Util.isDesktop + ? IconCopyButton( + data: frostWalletData!.keys, + ) + : SimpleCopyButton( + data: frostWalletData!.keys, + ), + ), + if (prevGen) + const SizedBox( + height: 24, + ), + if (prevGen) + RoundedWhiteContainer( child: Text( - "Cancel", - style: STextStyles.button(context).copyWith( - color: Theme.of(context) - .extension()! - .accentColorDark), + "Previous generation info", + style: STextStyles.label(context), ), ), - ), - ), - ], + if (prevGen) + const SizedBox( + height: 12, + ), + if (prevGen) + DetailItem( + title: "Previous multisig config", + detail: frostWalletData!.prevGen!.config, + button: Util.isDesktop + ? IconCopyButton( + data: + frostWalletData!.prevGen!.config, + ) + : SimpleCopyButton( + data: + frostWalletData!.prevGen!.config, + ), + ), + if (prevGen) + const SizedBox( + height: 16, + ), + if (prevGen) + DetailItem( + title: "Previous keys", + detail: frostWalletData!.prevGen!.keys, + button: Util.isDesktop + ? IconCopyButton( + data: frostWalletData!.prevGen!.keys, + ) + : SimpleCopyButton( + data: frostWalletData!.prevGen!.keys, + ), + ), + ], + ), ), - ); - }, - ); - }, - child: Text( - "Show QR Code", - style: STextStyles.button(context), + ), + ); + }, + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox( + height: 4, + ), + Text( + ref.watch(pWalletName(walletId)), + textAlign: TextAlign.center, + style: STextStyles.label(context).copyWith( + fontSize: 12, + ), + ), + const SizedBox( + height: 4, + ), + Text( + "Recovery Phrase", + textAlign: TextAlign.center, + style: STextStyles.pageTitleH1(context), + ), + const SizedBox( + height: 16, + ), + Container( + decoration: BoxDecoration( + color: + Theme.of(context).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + "Please write down your backup key. Keep it safe and never share it with anyone. Your backup key is the only way you can access your funds if you forget your PIN, lose your phone, etc.\n\nStack Wallet does not keep nor is able to restore your backup key. Only you have access to your wallet.", + style: STextStyles.label(context), + ), + ), + ), + const SizedBox( + height: 8, + ), + Expanded( + child: SingleChildScrollView( + child: MnemonicTable( + words: mnemonic, + isDesktop: false, + ), + ), + ), + const SizedBox( + height: 12, + ), + TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: () { + String data = AddressUtils.encodeQRSeedData(mnemonic); + + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (_) { + final width = MediaQuery.of(context).size.width / 2; + return StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Text( + "Recovery phrase QR code", + style: STextStyles.pageTitleH2(context), + ), + ), + const SizedBox( + height: 12, + ), + Center( + child: RepaintBoundary( + // key: _qrKey, + child: SizedBox( + width: width + 20, + height: width + 20, + child: QrImageView( + data: data, + size: width, + backgroundColor: Theme.of(context) + .extension()! + .popupBG, + foregroundColor: Theme.of(context) + .extension()! + .accentColorDark), + ), + ), + ), + const SizedBox( + height: 12, + ), + Center( + child: SizedBox( + width: width, + child: TextButton( + onPressed: () async { + // await _capturePng(true); + Navigator.of(context).pop(); + }, + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle( + context), + child: Text( + "Cancel", + style: STextStyles.button(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark), + ), + ), + ), + ), + ], + ), + ); + }, + ); + }, + child: Text( + "Show QR Code", + style: STextStyles.button(context), + ), + ), + ], ), - ), - ], - ), ), ), ); diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart index 0714528e0..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 { final wallet = ref .read(pWallets) .getWallet(widget.walletId); - // TODO: [prio=frost] take wallets that don't have a mnemonic into account - if (wallet is MnemonicInterface) { - final mnemonic = - await wallet.getMnemonicAsWords(); - if (mounted) { - await Navigator.push( - context, - RouteGenerator.getRoute( - shouldUseMaterialRoute: - RouteGenerator - .useMaterialPageRoute, - builder: (_) => LockscreenView( - routeOnSuccessArguments: - Tuple2( - walletId, mnemonic), - showBackButton: true, - routeOnSuccess: - WalletBackupView - .routeName, - biometricsCancelButtonString: - "CANCEL", - biometricsLocalizedReason: - "Authenticate to view recovery phrase", - biometricsAuthenticationTitle: - "View recovery phrase", - ), - settings: const RouteSettings( - name: - "/viewRecoverPhraseLockscreen"), - ), + // TODO: [prio=med] take wallets that don't have a mnemonic into account + + List? mnemonic; + ({ + String myName, + String config, + String keys, + ({ + String config, + String keys + })? prevGen, + })? frostWalletData; + if (wallet is BitcoinFrostWallet) { + List> futures = []; + + futures.addAll( + [ + wallet.getSerializedKeys(), + wallet.getMultisigConfig(), + wallet.getSerializedKeysPrevGen(), + wallet.getMultisigConfigPrevGen(), + ], + ); + + final results = + await Future.wait(futures); + + if (results.length == 5) { + frostWalletData = ( + myName: wallet.frostInfo.myName, + config: results[1], + keys: results[0], + prevGen: results[2] == null || + results[3] == null + ? null + : ( + config: results[3], + keys: results[2], + ), ); } + } else if (wallet + is MnemonicInterface) { + mnemonic = + await wallet.getMnemonicAsWords(); + } + + if (mounted) { + await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: + RouteGenerator + .useMaterialPageRoute, + builder: (_) => LockscreenView( + routeOnSuccessArguments: ( + walletId: walletId, + mnemonic: mnemonic ?? [], + frostWalletData: + frostWalletData, + ), + showBackButton: true, + routeOnSuccess: + WalletBackupView.routeName, + biometricsCancelButtonString: + "CANCEL", + biometricsLocalizedReason: + "Authenticate to view recovery phrase", + biometricsAuthenticationTitle: + "View recovery phrase", + ), + settings: const RouteSettings( + name: + "/viewRecoverPhraseLockscreen"), + ), + ); } }, ); diff --git a/lib/pages/special/firo_rescan_recovery_error_dialog.dart b/lib/pages/special/firo_rescan_recovery_error_dialog.dart index 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? 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? 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 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()! - .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()! + .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>) { + if (args is ({String walletId, List mnemonic})) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, builder: (_) => WalletBackupView( - walletId: args.item1, - mnemonic: args.item2, + walletId: args.walletId, + mnemonic: args.mnemonic, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } else if (args is ({ + String walletId, + List mnemonic, + ({ + String myName, + String config, + String keys, + ({String config, String keys})? prevGen, + })? frostWalletData, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => WalletBackupView( + walletId: args.walletId, + mnemonic: args.mnemonic, + frostWalletData: args.frostWalletData, ), settings: RouteSettings( name: settings.name, @@ -2313,10 +2334,14 @@ class RouteGenerator { settings: RouteSettings(name: settings.name)); case WalletKeysDesktopPopup.routeName: - if (args is List) { + if (args is ({ + List mnemonic, + ({String keys, String config})? frostData + })) { return FadePageRoute( WalletKeysDesktopPopup( - words: args, + words: args.mnemonic, + frostData: args.frostData, ), RouteSettings( name: settings.name, diff --git a/lib/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 extends Wallet { ); } - Future _getSerializedKeysPrevGen() async => + Future getSerializedKeysPrevGen() async => await secureStorageInterface.read( key: "{$walletId}_serializedFROSTKeysPrevGen", ); @@ -935,7 +935,7 @@ class BitcoinFrostWallet extends Wallet { key: "{$walletId}_multisigConfig", ); - Future _multisigConfigPrevGen() async => + Future getMultisigConfigPrevGen() async => await secureStorageInterface.read( key: "{$walletId}_multisigConfigPrevGen", ); From 73276ba676d3a6edee43db6924632b78bdc66220 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 23 Feb 2024 17:46:34 -0600 Subject: [PATCH 23/38] 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 extends Wallet { BitcoinFrostWallet(CryptoCurrencyNetwork network) @@ -38,6 +44,8 @@ class BitcoinFrostWallet extends Wallet { .findFirstSync()!; late ElectrumXClient electrumXClient; + late StreamChannel electrumAdapterChannel; + late ElectrumClient electrumAdapterClient; late CachedElectrumXClient electrumXCachedClient; Future initializeNewFrost({ @@ -1075,6 +1083,7 @@ class BitcoinFrostWallet extends Wallet { ); } + // TODO [prio=low]: Use ElectrumXInterface method. Future _updateElectrumX() async { final failovers = nodeService .failoverNodesFor(coin: cryptoCurrency.coin) @@ -1088,16 +1097,67 @@ class BitcoinFrostWallet extends Wallet { .toList(); final newNode = await _getCurrentElectrumXNode(); + try { + await electrumXClient.electrumAdapterClient?.close(); + } catch (e, s) { + if (e.toString().contains("initialized")) { + // Ignore. This should happen every first time the wallet is opened. + } else { + Logging.instance + .log("Error closing electrumXClient: $e", level: LogLevel.Error); + } + } electrumXClient = ElectrumXClient.from( node: newNode, prefs: prefs, failovers: failovers, + coin: cryptoCurrency.coin, ); + electrumAdapterChannel = await electrum_adapter.connect( + newNode.address, + port: newNode.port, + acceptUnverified: true, + useSSL: newNode.useSSL, + proxyInfo: Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + if (electrumXClient.coin == Coin.firo || + electrumXClient.coin == Coin.firoTestNet) { + electrumAdapterClient = FiroElectrumClient( + electrumAdapterChannel, + newNode.address, + newNode.port, + newNode.useSSL, + Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null); + } else { + electrumAdapterClient = ElectrumClient( + electrumAdapterChannel, + newNode.address, + newNode.port, + newNode.useSSL, + Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null); + } electrumXCachedClient = CachedElectrumXClient.from( electrumXClient: electrumXClient, + electrumAdapterClient: electrumAdapterClient, + electrumAdapterUpdateCallback: updateClient, ); } + // TODO [prio=low]: Use ElectrumXInterface method. + Future updateClient() async { + Logging.instance.log( + "Updating electrum node and ElectrumAdapterClient from Frost wallet.", + level: LogLevel.Info); + await updateNode(); + return electrumAdapterClient; + } + bool _duplicateTxCheck( List> allTransactions, String txid) { for (int i = 0; i < allTransactions.length; i++) { From bbfb152bd741cd7a16bb24319ad517df9ea43619 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 23 Feb 2024 17:48:45 -0600 Subject: [PATCH 24/38] 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 Date: Mon, 4 Mar 2024 16:33:26 -0600 Subject: [PATCH 25/38] 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 Date: Wed, 6 Mar 2024 10:16:51 -0600 Subject: [PATCH 26/38] 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 { ? 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 Date: Wed, 6 Mar 2024 10:55:15 -0600 Subject: [PATCH 27/38] 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()! + .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()! + .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 Date: Wed, 6 Mar 2024 11:17:19 -0600 Subject: [PATCH 28/38] 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 Date: Wed, 6 Mar 2024 18:04:54 -0600 Subject: [PATCH 29/38] 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()!.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()!.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()!.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 Date: Wed, 6 Mar 2024 18:09:38 -0600 Subject: [PATCH 30/38] 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 { 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()! + .textFieldActiveSearchIconLeft, + ), + const SizedBox(width: 14), + Expanded( + child: Text( + _WalletOptions.frostOptions.prettyName, + style: STextStyles.desktopTextExtraExtraSmall( + context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + ), + ], + ), + ), + ), if (xpubEnabled) const SizedBox( height: 8, From bfdcfcec1aeb734d26f5396d22c89f764978186a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 6 Mar 2024 18:13:39 -0600 Subject: [PATCH 31/38] 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.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 09bbdb536847011a7cf0ef7f041adb60808e8e31 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 11 Mar 2024 11:20:41 -0500 Subject: [PATCH 32/38] 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>) { // return getRoute( From 10233550b187c7a3a879f4c09d2358551c4c8ce1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 11 Mar 2024 11:21:19 -0500 Subject: [PATCH 33/38] 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 Date: Mon, 11 Mar 2024 23:05:55 -0500 Subject: [PATCH 34/38] 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 { 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()! + .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.delayed( + const Duration(milliseconds: 100), + ); + } + + if (mounted) { + // finally spendable = ref + // .read(walletsChangeNotifierProvider) + // .getManager(widget.walletId) + // .balance + // .spendable; + + // TODO: [prio=high] make sure this coincontrol works correctly + + Amount? amount; + + final result = await Navigator.of(context).pushNamed( + CoinControlView.routeName, + arguments: Tuple4( + walletId, + CoinControlViewType.use, + amount, + selectedUTXOs, + ), + ); + + if (result is Set) { + setState(() { + selectedUTXOs = result; + }); + } + } }, ), ], ), - const SizedBox( - height: 8, - ), - Column( - children: [ - for (int i = 0; i < recipientWidgetIndexes.length; i++) - ConditionalParent( - condition: recipientWidgetIndexes.length > 1, - builder: (child) => Padding( - padding: const EdgeInsets.only(top: 8), - child: child, - ), - child: Recipient( - key: Key( - "recipientKey_${recipientWidgetIndexes[i]}", - ), - index: recipientWidgetIndexes[i], - coin: coin, - onChanged: () { - _validateRecipientFormStates(); - }, - remove: i == 0 && recipientWidgetIndexes.length == 1 - ? null - : () { - recipientWidgetIndexes.removeAt(i); - setState(() {}); - }, - ), - ), - ], - ), - if (showCoinControl) - const SizedBox( - height: 8, - ), - if (showCoinControl) - RoundedWhiteContainer( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Coin control", - style: STextStyles.w500_14(context).copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, - ), - ), - CustomTextButton( - text: selectedUTXOs.isEmpty - ? "Select coins" - : "Selected coins (${selectedUTXOs.length})", - onTap: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 100), - ); - } - - if (mounted) { - // finally spendable = ref - // .read(walletsChangeNotifierProvider) - // .getManager(widget.walletId) - // .balance - // .spendable; - - // TODO: [prio=high] make sure this coincontrol works correctly - - Amount? amount; - - final result = - await Navigator.of(context).pushNamed( - CoinControlView.routeName, - arguments: Tuple4( - walletId, - CoinControlViewType.use, - amount, - selectedUTXOs, + ), + 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) { - setState(() { - selectedUTXOs = result; - }); - } - } - }, - ), - ], - ), - ), - const SizedBox( - height: 12, - ), - Text( - "Note (optional)", - style: STextStyles.smallMed12(context), - textAlign: TextAlign.left, - ), - const SizedBox( - height: 8, - ), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - autocorrect: Util.isDesktop ? false : true, - enableSuggestions: Util.isDesktop ? false : true, - controller: noteController, - focusNode: _noteFocusNode, - style: STextStyles.field(context), - onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Type something...", - _noteFocusNode, - context, - ).copyWith( - suffixIcon: noteController.text.isNotEmpty - ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - noteController.text = ""; - }); - }, - ), - ], - ), - ), - ) - : null, - ), - ), - ), - const SizedBox( - height: 12, - ), - Padding( - padding: const EdgeInsets.only( - bottom: 12, - top: 16, - ), - child: FeeSlider( - coin: coin, - onSatVByteChanged: (rate) { - customFeeRate = rate; - }, - ), - ), - Util.isDesktop - ? const SizedBox( - height: 12, - ) - : const Spacer(), - const SizedBox( - height: 12, - ), - TextButton( - onPressed: ref.watch(previewTxButtonStateProvider.state).state - ? _createSignConfig + ], + ), + ), + ) : null, - style: ref.watch(previewTxButtonStateProvider.state).state - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle(context), - child: Text( - "Create config", - style: STextStyles.button(context), - ), ), - const SizedBox( - height: 16, - ), - ]), + ), + ), + const SizedBox( + height: 12, + ), + Padding( + padding: const EdgeInsets.only( + bottom: 12, + top: 16, + ), + child: FeeSlider( + coin: coin, + onSatVByteChanged: (rate) { + customFeeRate = rate; + }, + ), + ), + Util.isDesktop + ? const SizedBox( + height: 12, + ) + : const Spacer(), + const SizedBox( + height: 12, + ), + TextButton( + onPressed: ref.watch(previewTxButtonStateProvider.state).state + ? _createSignConfig + : null, + style: ref.watch(previewTxButtonStateProvider.state).state + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), + child: Text( + "Create config", + style: STextStyles.button(context), + ), + ), + const SizedBox( + height: 16, ), ], ), From 0fe16638b04a8047f14caac94745bb565e6b5869 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 12 Mar 2024 06:45:26 -0500 Subject: [PATCH 35/38] 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 { ), ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!Util.isDesktop) - Container( - decoration: BoxDecoration( - color: Theme.of(context).extension()!.popupBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Row( - children: [ - SvgPicture.file( - File( - ref.watch( - coinIconProvider(coin), - ), - ), - width: 22, - height: 22, - ), - const SizedBox( - width: 6, - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - ref.watch(pWalletName(walletId)), - style: STextStyles.titleBold12(context) - .copyWith(fontSize: 14), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - // const SizedBox( - // height: 2, - // ), - Text( - "Available balance", - style: - STextStyles.label(context).copyWith(fontSize: 10), - ), - ], - ), - Util.isDesktop - ? const SizedBox( - height: 24, - ) - : const Spacer(), - GestureDetector( - onTap: () { - // cryptoAmountController.text = ref - // .read(pAmountFormatter(coin)) - // .format( - // _cachedBalance!, - // withUnitName: false, - // ); - }, - child: Container( - color: Colors.transparent, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - ref.watch(pAmountFormatter(coin)).format(ref - .watch(pWalletBalance(walletId)) - .spendable), - style: STextStyles.titleBold12(context).copyWith( - fontSize: 10, - ), - textAlign: TextAlign.right, - ), - // Text( - // "${(manager.balance.spendable.decimal * ref.watch( - // priceAnd24hChangeNotifierProvider.select( - // (value) => value.getPrice(coin).item1, - // ), - // )).toAmount( - // fractionDigits: 2, - // ).fiatString( - // locale: locale, - // )} ${ref.watch( - // prefsChangeNotifierProvider - // .select((value) => value.currency), - // )}", - // style: STextStyles.subtitle(context).copyWith( - // fontSize: 8, - // ), - // textAlign: TextAlign.right, - // ) - ], - ), - ), - ) - ], - ), - ), - ), - const SizedBox( - height: 16, + 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()!.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()! - .textSubtitle1, - ), - ), - CustomTextButton( - text: selectedUTXOs.isEmpty - ? "Select coins" - : "Selected coins (${selectedUTXOs.length})", - onTap: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 100), - ); - } - - if (mounted) { - // finally spendable = ref - // .read(walletsChangeNotifierProvider) - // .getManager(widget.walletId) - // .balance - // .spendable; - - // TODO: [prio=high] make sure this coincontrol works correctly - - Amount? amount; - - final result = await Navigator.of(context).pushNamed( - CoinControlView.routeName, - arguments: Tuple4( - walletId, - CoinControlViewType.use, - amount, - selectedUTXOs, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + SvgPicture.file( + File( + ref.watch( + coinIconProvider(coin), ), - ); - - if (result is Set) { - setState(() { - selectedUTXOs = result; - }); - } - } - }, - ), - ], - ), - ), - const SizedBox( - height: 12, - ), - Text( - "Note (optional)", - style: STextStyles.smallMed12(context), - textAlign: TextAlign.left, - ), - const SizedBox( - height: 8, - ), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - autocorrect: Util.isDesktop ? false : true, - enableSuggestions: Util.isDesktop ? false : true, - controller: noteController, - focusNode: _noteFocusNode, - style: STextStyles.field(context), - onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Type something...", - _noteFocusNode, - context, - ).copyWith( - suffixIcon: noteController.text.isNotEmpty - ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( + ), + 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()! + .textSubtitle1, + ), + ), + CustomTextButton( + text: selectedUTXOs.isEmpty + ? "Select coins" + : "Selected coins (${selectedUTXOs.length})", + onTap: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 100), + ); + } + + if (mounted) { + // finally spendable = ref + // .read(walletsChangeNotifierProvider) + // .getManager(widget.walletId) + // .balance + // .spendable; + + // TODO: [prio=high] make sure this coincontrol works correctly + + Amount? amount; + + final result = await Navigator.of(context).pushNamed( + CoinControlView.routeName, + arguments: Tuple4( + walletId, + CoinControlViewType.use, + amount, + selectedUTXOs, + ), + ); + + if (result is Set) { + setState(() { + selectedUTXOs = result; + }); + } + } + }, + ), + ], + ), + ), + const SizedBox( + height: 12, + ), + Text( + "Note (optional)", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox( + height: 8, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + controller: noteController, + focusNode: _noteFocusNode, + style: STextStyles.field(context), + onChanged: (_) => setState(() {}), + decoration: standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + ).copyWith( + suffixIcon: noteController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + noteController.text = ""; + }); + }, + ), + ], + ), + ), + ) + : null, + ), ), ), - ), - const SizedBox( - height: 12, - ), - Padding( - padding: const EdgeInsets.only( - bottom: 12, - top: 16, + 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()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension()! - .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()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .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 Date: Tue, 12 Mar 2024 06:48:01 -0500 Subject: [PATCH 36/38] 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 Date: Tue, 12 Mar 2024 07:05:05 -0500 Subject: [PATCH 37/38] 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()!.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 Date: Tue, 12 Mar 2024 07:45:49 -0500 Subject: [PATCH 38/38] 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 extends Wallet { 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 extends Wallet { if (!isRescan) { final salt = frost .multisigSalt( - multisigConfig: multisigConfig, + multisigConfig: multisigConfig!, ) .toHex; final knownSalts = _getKnownSalts(); @@ -735,9 +739,9 @@ class BitcoinFrostWallet extends Wallet { 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