From 7b629e81b40d168b2564161bfb54a23b35b0a0cd Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 27 Dec 2024 20:11:09 -0600 Subject: [PATCH 1/3] feat: (WIP) add nfc_manager plugin and non-working UI elements added Android permissions, but TODO add iOS entitlements etc. ... see https://github.com/okadan/flutter-nfc-manager?tab=readme-ov-file#setup --- .../multisig_coordinator.dart | 216 ++++++++ .../multisig_setup_view.dart | 494 ++++++++++++++++++ lib/pages/wallet_view/wallet_view.dart | 14 + .../more_features/more_features_dialog.dart | 8 + lib/route_generator.dart | 11 + .../icons/multisig_setup_nav_icon.dart | 30 ++ pubspec.lock | 16 + scripts/app_config/templates/pubspec.template | 2 + 8 files changed, 791 insertions(+) create mode 100644 lib/pages/wallet_view/multisig_setup_view/multisig_coordinator.dart create mode 100644 lib/pages/wallet_view/multisig_setup_view/multisig_setup_view.dart create mode 100644 lib/widgets/wallet_navigation_bar/components/icons/multisig_setup_nav_icon.dart diff --git a/lib/pages/wallet_view/multisig_setup_view/multisig_coordinator.dart b/lib/pages/wallet_view/multisig_setup_view/multisig_coordinator.dart new file mode 100644 index 000000000..4684e7beb --- /dev/null +++ b/lib/pages/wallet_view/multisig_setup_view/multisig_coordinator.dart @@ -0,0 +1,216 @@ +import 'package:bip48/bip48.dart'; +import 'package:coinlib/coinlib.dart'; + +/// Represents the parameters needed to create a shared multisig account. +class MultisigParams { + /// Number of required signatures (M in M-of-N). + final int threshold; + + /// Total number of participants (N in M-of-N). + final int totalCosigners; + + /// BIP44 coin type (e.g., 0 for Bitcoin mainnet). + final int coinType; + + /// BIP44/48 account index. + final int account; + + /// BIP48 script type (e.g., p2sh, p2wsh). + final Bip48ScriptType scriptType; + + /// Creates a new set of multisig parameters. + const MultisigParams({ + required this.threshold, + required this.totalCosigners, + required this.coinType, + required this.account, + required this.scriptType, + }); + + /// Validates the parameters for consistency. + /// + /// Returns true if all parameters are valid: + /// - threshold > 0 + /// - threshold <= totalCosigners + /// - account >= 0 + /// - coinType >= 0 + bool isValid() { + return threshold > 0 && + threshold <= totalCosigners && + account >= 0 && + coinType >= 0; + } +} + +/// Represents a participant in the multisig setup process. +class CosignerInfo { + /// The cosigner's BIP48 account-level extended public key. + final String accountXpub; + + /// Position in the sorted set of cosigners (0-based). + final int index; + + /// Creates info about a cosigner participant. + const CosignerInfo({ + required this.accountXpub, + required this.index, + }); +} + +/// Coordinates the creation of a shared multisig account between multiple users. +class MultisigCoordinator { + /// Local master key if available (otherwise uses accountXpub). + final HDPrivateKey? localMasterKey; + + /// Parameters for the shared multisig wallet. + final MultisigParams params; + + /// Collected cosigner information. + final List _cosigners = []; + + /// Local account xpub when not using master key. + String? _accountXpub; + + /// Creates a coordinator using the local HD master private key. + /// + /// Uses the provided [localMasterKey] to derive the account xpub that will + /// be shared with other cosigners. + MultisigCoordinator({ + required this.localMasterKey, + required this.params, + }) { + if (!params.isValid()) { + throw ArgumentError('Invalid multisig parameters'); + } + } + + /// Creates a coordinator using a pre-derived account xpub. + /// + /// This constructor should be used when you only want to verify addresses + /// or don't have access to the master private key. + MultisigCoordinator.fromXpub({ + required String accountXpub, + required this.params, + }) : localMasterKey = null { + if (!params.isValid()) { + throw ArgumentError('Invalid multisig parameters'); + } + _accountXpub = accountXpub; + } + + /// Gets this user's account xpub that needs to be shared with other cosigners. + /// + /// If created with a master key, derives the account xpub at the BIP48 path. + /// If created with fromXpub, returns the provided account xpub. + String getLocalAccountXpub() { + if (_accountXpub != null) { + return _accountXpub!; + } + + if (localMasterKey == null) { + throw StateError('No master key or account xpub available'); + } + + final path = bip48DerivationPath( + coinType: params.coinType, + account: params.account, + scriptType: params.scriptType, + ); + final accountKey = localMasterKey!.derivePath(path); + return accountKey.hdPublicKey.encode(bitcoinNetwork.mainnet.pubHDPrefix); + } + + /// Adds a cosigner's account xpub to the set. + /// + /// Throws [StateError] if all cosigners have already been added. + void addCosigner(String accountXpub) { + if (_cosigners.length >= params.totalCosigners - 1) { + throw StateError('All cosigners have been added'); + } + + // Assign index based on current position + _cosigners.add(CosignerInfo( + accountXpub: accountXpub, + index: _cosigners.length + 1, // Local user is always index 0. + )); + } + + /// Checks if all required cosigner information has been collected. + bool isComplete() { + return _cosigners.length == params.totalCosigners - 1; + } + + /// Creates the local wallet instance once all cosigners are added. + /// + /// Throws [StateError] if not all cosigners have been added yet. + Bip48Wallet createWallet() { + if (!isComplete()) { + throw StateError('Not all cosigners have been added'); + } + + // Create wallet with either our master key or xpub + final wallet = localMasterKey != null + ? Bip48Wallet( + masterKey: localMasterKey, + coinType: params.coinType, + account: params.account, + scriptType: params.scriptType, + threshold: params.threshold, + totalKeys: params.totalCosigners, + ) + : Bip48Wallet( + accountXpub: _accountXpub, + coinType: params.coinType, + account: params.account, + scriptType: params.scriptType, + threshold: params.threshold, + totalKeys: params.totalCosigners, + ); + + // Add all cosigner xpubs. + for (final cosigner in _cosigners) { + wallet.addCosignerXpub(cosigner.accountXpub); + } + + return wallet; + } + + /// Verifies that derived addresses match between all participants. + /// + /// Takes a list of [sharedAddresses] that other participants derived, along + /// with the [indices] used to derive them and whether they are [isChange] + /// addresses. + /// + /// Returns true if all provided addresses match our local derivation. + bool verifyAddresses(List sharedAddresses, + {required List indices, required bool isChange}) { + if (!isComplete()) return false; + + final wallet = createWallet(); + for (final idx in indices) { + final derivedAddress = + wallet.deriveMultisigAddress(idx, isChange: isChange); + final sharedAddress = sharedAddresses[indices.indexOf(idx)]; + if (derivedAddress != sharedAddress) return false; + } + return true; + } + + /// Gets a list of derived addresses for verification. + /// + /// Derives addresses at the specified [indices] on either the external + /// or change chain based on [isChange]. + /// + /// Throws [StateError] if not all cosigners have been added yet. + List getVerificationAddresses( + {required List indices, required bool isChange}) { + if (!isComplete()) { + throw StateError('Not all cosigners have been added'); + } + + final wallet = createWallet(); + return indices + .map((idx) => wallet.deriveMultisigAddress(idx, isChange: isChange)) + .toList(); + } +} diff --git a/lib/pages/wallet_view/multisig_setup_view/multisig_setup_view.dart b/lib/pages/wallet_view/multisig_setup_view/multisig_setup_view.dart new file mode 100644 index 000000000..80b63b863 --- /dev/null +++ b/lib/pages/wallet_view/multisig_setup_view/multisig_setup_view.dart @@ -0,0 +1,494 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data' show Uint8List; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:nfc_manager/nfc_manager.dart'; + +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/assets.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../utilities/util.dart'; +import '../../../../widgets/background.dart'; +import '../../../../widgets/custom_buttons/app_bar_icondart'; +import '../../../widgets/stack_dialog.dart'; + +final multisigSetupStateProvider = + StateNotifierProvider((ref) { + return MultisigSetupState(); +}); + +class MultisigSetupData { + const MultisigSetupData({ + this.threshold = 2, + this.totalCosigners = 3, + this.coinType = 0, // Bitcoin mainnet. + this.accountIndex = 0, + this.scriptType = MultisigScriptType.nativeSegwit, + this.cosignerXpubs = const [], + }); + + final int threshold; + final int totalCosigners; + final int coinType; + final int accountIndex; + final MultisigScriptType scriptType; + final List cosignerXpubs; + + MultisigSetupData copyWith({ + int? threshold, + int? totalCosigners, + int? coinType, + int? accountIndex, + MultisigScriptType? scriptType, + List? cosignerXpubs, + }) { + return MultisigSetupData( + threshold: threshold ?? this.threshold, + totalCosigners: totalCosigners ?? this.totalCosigners, + coinType: coinType ?? this.coinType, + accountIndex: accountIndex ?? this.accountIndex, + scriptType: scriptType ?? this.scriptType, + cosignerXpubs: cosignerXpubs ?? this.cosignerXpubs, + ); + } + + Map toJson() => { + 'threshold': threshold, + 'totalCosigners': totalCosigners, + 'coinType': coinType, + 'accountIndex': accountIndex, + 'scriptType': scriptType.index, + 'cosignerXpubs': cosignerXpubs, + }; + + factory MultisigSetupData.fromJson(Map json) { + return MultisigSetupData( + threshold: json['threshold'] as int, + totalCosigners: json['totalCosigners'] as int, + coinType: json['coinType'] as int, + accountIndex: json['accountIndex'] as int, + scriptType: MultisigScriptType.values[json['scriptType'] as int], + cosignerXpubs: (json['cosignerXpubs'] as List).cast(), + ); + } +} + +enum MultisigScriptType { + legacy, // P2SH. + segwit, // P2SH-P2WSH. + nativeSegwit, // P2WSH. +} + +class MultisigSetupState extends StateNotifier { + MultisigSetupState() : super(const MultisigSetupData()); + + void updateThreshold(int threshold) { + state = state.copyWith(threshold: threshold); + } + + void updateTotalCosigners(int total) { + state = state.copyWith(totalCosigners: total); + } + + void updateScriptType(MultisigScriptType type) { + state = state.copyWith(scriptType: type); + } + + void addCosignerXpub(String xpub) { + if (state.cosignerXpubs.length < state.totalCosigners) { + state = state.copyWith( + cosignerXpubs: [...state.cosignerXpubs, xpub], + ); + } + } +} + +class MultisigSetupView extends ConsumerStatefulWidget { + const MultisigSetupView({ + super.key, + }); + + static const String routeName = "/multisigSetup"; + + @override + ConsumerState createState() => _MultisigSetupViewState(); +} + +class _MultisigSetupViewState extends ConsumerState { + bool _isNfcAvailable = false; + String _nfcStatus = 'Checking NFC availability...'; + + @override + void initState() { + super.initState(); + _checkNfcAvailability(); + } + + Future _checkNfcAvailability() async { + try { + final availability = await NfcManager.instance.isAvailable(); + setState(() { + _isNfcAvailable = availability; + _nfcStatus = _isNfcAvailable + ? 'NFC is available' + : 'NFC is not available on this device'; + }); + } catch (e) { + setState(() { + _nfcStatus = 'Error checking NFC: $e'; + _isNfcAvailable = false; + }); + } + } + + Future _startNfcSession() async { + if (!_isNfcAvailable) return; + + setState(() => _nfcStatus = 'Ready to exchange information...'); + + try { + await NfcManager.instance.startSession( + onDiscovered: (tag) async { + try { + final ndef = Ndef.from(tag); + + if (ndef == null) { + setState(() => _nfcStatus = 'Tag is not NDEF compatible'); + return; + } + + final setupData = ref.watch(multisigSetupStateProvider); + + if (ndef.isWritable) { + final message = NdefMessage([ + NdefRecord.createMime( + 'application/x-multisig-setup', + Uint8List.fromList( + utf8.encode(jsonEncode(setupData.toJson()))), + ), + ]); + + try { + await ndef.write(message); + setState( + () => _nfcStatus = 'Configuration shared successfully'); + } catch (e) { + setState( + () => _nfcStatus = 'Failed to share configuration: $e'); + } + } + + await NfcManager.instance.stopSession(); + } catch (e) { + setState(() => _nfcStatus = 'Error during NFC exchange: $e'); + await NfcManager.instance.stopSession(); + } + }, + ); + } catch (e) { + setState(() => _nfcStatus = 'Error: $e'); + await NfcManager.instance.stopSession(); + } + } + + /// Displays a short explanation dialog about musig. + Future _showMultisigInfoDialog() async { + await showDialog( + context: context, + builder: (context) { + return const StackOkDialog( + title: "What is a multisignature account?", + message: + "Multisignature accounts, also called shared accounts, require " + "multiple signatures to authorize a transaction. This can " + "increase security by preventing a single point of failure or " + "allow multiple parties to jointly control funds." + "For example, in a 2-of-3 multisig account, two of the three " + "cosigners are required in order to sign a transaction and spend " + "funds.", + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final bool isDesktop = Util.isDesktop; + final setupData = ref.watch(multisigSetupStateProvider); + + // Required signatures<= total cosigners. + final clampedThreshold = (setupData.threshold > setupData.totalCosigners) + ? setupData.totalCosigners + : setupData.threshold; + + return Background( + child: SafeArea( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + automaticallyImplyLeading: false, + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + if (mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Multisignature account setup", + style: STextStyles.navBarTitle(context), + ), + titleSpacing: 0, + actions: [ + AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + size: 36, + icon: SvgPicture.asset( + Assets.svg.circleQuestion, + width: 20, + height: 20, + color: Theme.of(context) + .extension()! + .topNavIconPrimary, + ), + onPressed: _showMultisigInfoDialog, + ), + ), + ], + ), + body: LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // We'll add a method to share the config w/ cosigners + // so there's not much need to remind them here. + // RoundedWhiteContainer( + // child: Text( + // "Make sure all cosigners use the same " + // "configuration when creating the shared account.", + // style: STextStyles.w500_12(context).copyWith( + // color: Theme.of(context) + // .extension()! + // .textSubtitle1, + // ), + // ), + // ), + // const SizedBox(height: 16), + + Text( + "Configuration", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 16), + + // Script Type Selection + RoundedContainer( + padding: const EdgeInsets.all(16), + color: Theme.of(context) + .extension()! + .popupBG, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Script Type", + style: STextStyles.titleBold12(context), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: setupData.scriptType, + decoration: const InputDecoration( + border: OutlineInputBorder(), + ), + items: MultisigScriptType.values.map((type) { + String label; + switch (type) { + case MultisigScriptType.legacy: + label = "Legacy (P2SH)"; + break; + case MultisigScriptType.segwit: + label = "Nested SegWit (P2SH-P2WSH)"; + break; + case MultisigScriptType.nativeSegwit: + label = "Native SegWit (P2WSH)"; + break; + } + return DropdownMenuItem( + value: type, + child: Text(label), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + ref + .read( + multisigSetupStateProvider.notifier, + ) + .updateScriptType(value); + } + }, + ), + ], + ), + ), + + const SizedBox(height: 16), + + // Multisig params setup + + RoundedContainer( + padding: const EdgeInsets.all(16), + color: Theme.of(context) + .extension()! + .popupBG, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Total cosigners: ${setupData.totalCosigners}", + style: STextStyles.titleBold12(context), + ), + Slider( + value: setupData.totalCosigners.toDouble(), + min: 2, + max: 7, // There's not actually a max. + divisions: 7, // Match the above or look off. + label: + "${setupData.totalCosigners} cosigners", + onChanged: (value) { + ref + .read( + multisigSetupStateProvider.notifier) + .updateTotalCosigners(value.toInt()); + }, + ), + const SizedBox(height: 16), + Text( + "Required Signatures: $clampedThreshold of ${setupData.totalCosigners}", + style: STextStyles.titleBold12(context), + ), + Slider( + value: clampedThreshold.toDouble(), + min: 1, + max: setupData.totalCosigners.toDouble(), + divisions: setupData.totalCosigners - 1, + label: + "$clampedThreshold of ${setupData.totalCosigners}", + onChanged: (value) { + ref + .read( + multisigSetupStateProvider.notifier) + .updateThreshold(value.toInt()); + }, + ), + ], + ), + ), + + const SizedBox(height: 24), + + // We'll make a FROST-like progress indicator in a + // dialog to show the progress of the setup process. + // This simpler example will be removed soon. + // Text( + // "Exchange Method", + // style: STextStyles.itemSubtitle(context), + // ), + // const SizedBox(height: 16), + // + // // NFC exchange. + // RoundedContainer( + // padding: const EdgeInsets.all(16), + // color: Theme.of(context) + // .extension()! + // .popupBG, + // child: Column( + // crossAxisAlignment: CrossAxisAlignment.start, + // children: [ + // Row( + // children: [ + // Icon( + // _isNfcAvailable + // ? Icons.nfc + // : Icons.nfc_outlined, + // color: _isNfcAvailable + // ? Theme.of(context) + // .extension()! + // .accentColorGreen + // : Theme.of(context) + // .extension()! + // .textDark3, + // ), + // const SizedBox(width: 8), + // Text( + // "NFC Exchange", + // style: STextStyles.titleBold12(context), + // ), + // ], + // ), + // const SizedBox(height: 16), + // Text( + // _nfcStatus, + // style: STextStyles.baseXS(context), + // ), + // if (_isNfcAvailable) ...[ + // const SizedBox(height: 16), + // SizedBox( + // width: double.infinity, + // child: !isDesktop + // ? TextButton( + // onPressed: _startNfcSession, + // style: Theme.of(context) + // .extension()! + // .getPrimaryEnabledButtonStyle( + // context), + // child: Text( + // "Tap to Exchange Information", + // style: + // STextStyles.button(context), + // ), + // ) + // : PrimaryButton( + // label: + // "Tap to Exchange Information", + // onPressed: _startNfcSession, + // enabled: true, + // ), + // ), + // ], + // ], + // ), + // ), + + const Spacer(), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index d3a98ef16..eaf063b2d 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -73,6 +73,7 @@ import '../../widgets/wallet_navigation_bar/components/icons/coin_control_nav_ic import '../../widgets/wallet_navigation_bar/components/icons/exchange_nav_icon.dart'; import '../../widgets/wallet_navigation_bar/components/icons/frost_sign_nav_icon.dart'; import '../../widgets/wallet_navigation_bar/components/icons/fusion_nav_icon.dart'; +import '../../widgets/wallet_navigation_bar/components/icons/multisig_setup_nav_icon.dart'; import '../../widgets/wallet_navigation_bar/components/icons/ordinals_nav_icon.dart'; import '../../widgets/wallet_navigation_bar/components/icons/paynym_nav_icon.dart'; import '../../widgets/wallet_navigation_bar/components/icons/receive_nav_icon.dart'; @@ -96,6 +97,7 @@ import '../settings_views/wallet_settings_view/wallet_network_settings_view/wall import '../settings_views/wallet_settings_view/wallet_settings_view.dart'; import '../special/firo_rescan_recovery_error_dialog.dart'; import '../token_view/my_tokens_view.dart'; +import 'multisig_setup_view/multisig_setup_view.dart'; import 'sub_widgets/transactions_list.dart'; import 'sub_widgets/wallet_summary.dart'; import 'transaction_views/all_transactions_view.dart'; @@ -1233,6 +1235,18 @@ class _WalletViewState extends ConsumerState { ); }, ), + if (wallet.info.coin + is Bitcoin) // TODO [prio=low]: test if !isViewOnly is necessary... I think not, we should be able to make view-only shared multisig wallets. + WalletNavigationBarItemData( + label: "Make multisignature account", + icon: const MultisigSetupNavIcon(), + onTap: () { + Navigator.of(context).pushNamed( + MultisigSetupView.routeName, + arguments: walletId, + ); + }, + ), ], ), ], diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart index 0869b0b50..a370c0ce1 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart @@ -426,6 +426,14 @@ class _MoreFeaturesDialogState extends ConsumerState { ], ), ), + // TODO [prio=low]: Implement BIP48 accounts on desktop using copy/paste and/or webcam scanning. + // if (wallet.info.coin is Bitcoin) // TODO [prio=low]: test if !isViewOnly is necessary... I think not, we should be able to make view-only shared multisig wallets. + // _MoreFeaturesItem( + // label: "Make multisignature account", + // detail: "Share an account with other wallets", + // iconAsset: Assets.svg.peers, // I just picked a suitable icon, maybe another is more appropriate. + // onPressed: () async => widget.onBIP48Pressed?.call(), + // ), const SizedBox( height: 28, ), diff --git a/lib/route_generator.dart b/lib/route_generator.dart index bcfffa8cd..5db2b8632 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -11,6 +11,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:isar/isar.dart'; +import 'package:stackwallet/pages/wallet_view/multisig_setup_view/multisig_setup_view.dart'; import 'package:tuple/tuple.dart'; import 'models/add_wallet_list_entity/add_wallet_list_entity.dart'; @@ -2155,6 +2156,16 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case MultisigSetupView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const MultisigSetupView(), + settings: RouteSettings( + name: settings.name, + ), + ); + return _routeError("${settings.name} invalid args: ${args.toString()}"); + // == Desktop specific routes ============================================ case CreatePasswordView.routeName: if (args is bool) { diff --git a/lib/widgets/wallet_navigation_bar/components/icons/multisig_setup_nav_icon.dart b/lib/widgets/wallet_navigation_bar/components/icons/multisig_setup_nav_icon.dart new file mode 100644 index 000000000..ff285de00 --- /dev/null +++ b/lib/widgets/wallet_navigation_bar/components/icons/multisig_setup_nav_icon.dart @@ -0,0 +1,30 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/assets.dart'; + +class MultisigSetupNavIcon extends StatelessWidget { + const MultisigSetupNavIcon({super.key}); + + @override + Widget build(BuildContext context) { + return SvgPicture.asset( + Assets.svg + .peers, // I just picked a suitable icon, maybe another is more appropriate. + height: 20, + width: 20, + color: Theme.of(context).extension()!.bottomNavIconIcon, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 3ccce4760..e12ac86a4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -121,6 +121,14 @@ packages: url: "https://github.com/cypherstack/bip47.git" source: git version: "2.0.0" + bip48: + dependency: "direct main" + description: + name: bip48 + sha256: c31fa9a3fc1d755048c49317aa33b4cc8a396af387ffa1561010a981e4c9e8ca + url: "https://pub.dev" + source: hosted + version: "0.0.3" bitbox: dependency: "direct main" description: @@ -1376,6 +1384,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + nfc_manager: + dependency: "direct main" + description: + name: nfc_manager + sha256: f5be75e90f8f2bff3ee49fbd7ef65bdd4a86ee679c2412e71ab2846a8cff8c59 + url: "https://pub.dev" + source: hosted + version: "3.5.0" nm: dependency: transitive description: diff --git a/scripts/app_config/templates/pubspec.template b/scripts/app_config/templates/pubspec.template index e44cdcab4..e20f77bc3 100644 --- a/scripts/app_config/templates/pubspec.template +++ b/scripts/app_config/templates/pubspec.template @@ -204,6 +204,8 @@ dependencies: cbor: ^6.3.3 cs_monero: 1.0.0-pre.1 cs_monero_flutter_libs: 1.0.0-pre.0 + nfc_manager: ^3.5.0 + bip48: ^0.0.3 dev_dependencies: flutter_test: From d27aad2ad918306ba9c76b67dcfc0e4e1201a090 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 27 Dec 2024 21:35:54 -0600 Subject: [PATCH 2/3] feat: overhaul musig setup view iaw frost ui standards --- .../multisig_setup_view.dart | 703 ++++++++++-------- 1 file changed, 412 insertions(+), 291 deletions(-) diff --git a/lib/pages/wallet_view/multisig_setup_view/multisig_setup_view.dart b/lib/pages/wallet_view/multisig_setup_view/multisig_setup_view.dart index 80b63b863..d0930b40f 100644 --- a/lib/pages/wallet_view/multisig_setup_view/multisig_setup_view.dart +++ b/lib/pages/wallet_view/multisig_setup_view/multisig_setup_view.dart @@ -1,18 +1,19 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:typed_data' show Uint8List; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:nfc_manager/nfc_manager.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/assets.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../widgets/background.dart'; -import '../../../../widgets/custom_buttons/app_bar_icondart'; +import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/dialogs/simple_mobile_dialog.dart'; import '../../../widgets/stack_dialog.dart'; final multisigSetupStateProvider = @@ -118,81 +119,81 @@ class MultisigSetupView extends ConsumerStatefulWidget { } class _MultisigSetupViewState extends ConsumerState { - bool _isNfcAvailable = false; - String _nfcStatus = 'Checking NFC availability...'; + // bool _isNfcAvailable = false; + // String _nfcStatus = 'Checking NFC availability...'; @override void initState() { super.initState(); - _checkNfcAvailability(); + // _checkNfcAvailability(); } - Future _checkNfcAvailability() async { - try { - final availability = await NfcManager.instance.isAvailable(); - setState(() { - _isNfcAvailable = availability; - _nfcStatus = _isNfcAvailable - ? 'NFC is available' - : 'NFC is not available on this device'; - }); - } catch (e) { - setState(() { - _nfcStatus = 'Error checking NFC: $e'; - _isNfcAvailable = false; - }); - } - } - - Future _startNfcSession() async { - if (!_isNfcAvailable) return; - - setState(() => _nfcStatus = 'Ready to exchange information...'); - - try { - await NfcManager.instance.startSession( - onDiscovered: (tag) async { - try { - final ndef = Ndef.from(tag); - - if (ndef == null) { - setState(() => _nfcStatus = 'Tag is not NDEF compatible'); - return; - } - - final setupData = ref.watch(multisigSetupStateProvider); - - if (ndef.isWritable) { - final message = NdefMessage([ - NdefRecord.createMime( - 'application/x-multisig-setup', - Uint8List.fromList( - utf8.encode(jsonEncode(setupData.toJson()))), - ), - ]); - - try { - await ndef.write(message); - setState( - () => _nfcStatus = 'Configuration shared successfully'); - } catch (e) { - setState( - () => _nfcStatus = 'Failed to share configuration: $e'); - } - } - - await NfcManager.instance.stopSession(); - } catch (e) { - setState(() => _nfcStatus = 'Error during NFC exchange: $e'); - await NfcManager.instance.stopSession(); - } - }, - ); - } catch (e) { - setState(() => _nfcStatus = 'Error: $e'); - await NfcManager.instance.stopSession(); - } - } + // Future _checkNfcAvailability() async { + // try { + // final availability = await NfcManager.instance.isAvailable(); + // setState(() { + // _isNfcAvailable = availability; + // _nfcStatus = _isNfcAvailable + // ? 'NFC is available' + // : 'NFC is not available on this device'; + // }); + // } catch (e) { + // setState(() { + // _nfcStatus = 'Error checking NFC: $e'; + // _isNfcAvailable = false; + // }); + // } + // } + // + // Future _startNfcSession() async { + // if (!_isNfcAvailable) return; + // + // setState(() => _nfcStatus = 'Ready to exchange information...'); + // + // try { + // await NfcManager.instance.startSession( + // onDiscovered: (tag) async { + // try { + // final ndef = Ndef.from(tag); + // + // if (ndef == null) { + // setState(() => _nfcStatus = 'Tag is not NDEF compatible'); + // return; + // } + // + // final setupData = ref.watch(multisigSetupStateProvider); + // + // if (ndef.isWritable) { + // final message = NdefMessage([ + // NdefRecord.createMime( + // 'application/x-multisig-setup', + // Uint8List.fromList( + // utf8.encode(jsonEncode(setupData.toJson()))), + // ), + // ]); + // + // try { + // await ndef.write(message); + // setState( + // () => _nfcStatus = 'Configuration shared successfully'); + // } catch (e) { + // setState( + // () => _nfcStatus = 'Failed to share configuration: $e'); + // } + // } + // + // await NfcManager.instance.stopSession(); + // } catch (e) { + // setState(() => _nfcStatus = 'Error during NFC exchange: $e'); + // await NfcManager.instance.stopSession(); + // } + // }, + // ); + // } catch (e) { + // setState(() => _nfcStatus = 'Error: $e'); + // await NfcManager.instance.stopSession(); + // } + // } /// Displays a short explanation dialog about musig. Future _showMultisigInfoDialog() async { @@ -214,15 +215,180 @@ class _MultisigSetupViewState extends ConsumerState { ); } + void _showScriptTypeDialog() { + showDialog( + context: context, + builder: (_) => SimpleMobileDialog( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "What is a script type?", + style: STextStyles.w600_20(context), + ), + const SizedBox( + height: 12, + ), + Text( + "The script type you choose determines the type of wallet " + "addresses and the size and structure of transactions.", + style: STextStyles.w400_16(context), + ), + const SizedBox( + height: 12, + ), + Text( + "Legacy (P2SH):", + style: STextStyles.w600_18(context), + ), + Text( + "The original multisig format. Compatible with all wallets but has " + "higher transaction fees. P2SH addresses begin with 3.", + style: STextStyles.w400_16(context), + ), + const SizedBox( + height: 12, + ), + Text( + "Nested SegWit (P2SH-P2WSH):", + style: STextStyles.w600_18(context), + ), + Text( + "A newer format that reduces transaction fees while maintaining " + "broad compatibility. P2SH-P2WSH addresses begin with 3.", + style: STextStyles.w400_16(context), + ), + const SizedBox( + height: 12, + ), + Text( + "Native SegWit (P2WSH):", + style: STextStyles.w600_18(context), + ), + Text( + "The lowest transaction fees, but may not be compatible with older " + "wallets. P2WSH addresses begin with bc1.", + style: STextStyles.w400_16(context), + ), + ], + ), + ), + ); + } + + 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"; + } + + 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(() {}); + } + } + } + + void _showWhatIsThresholdDialog() { + showDialog( + context: context, + builder: (_) => SimpleMobileDialog( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "What is a threshold?", + style: STextStyles.w600_20(context), + ), + const SizedBox( + height: 12, + ), + Text( + "A threshold is the amount of people required to perform an " + "action. This does not have to be the same number as the " + "total number in the group.", + style: STextStyles.w400_16(context), + ), + const SizedBox( + height: 6, + ), + Text( + "For example, if you have 3 people in the group, but a threshold " + "of 2, then you only need 2 out of the 3 people to sign for an " + "action to take place.", + style: STextStyles.w400_16(context), + ), + const SizedBox( + height: 6, + ), + Text( + "Conversely if you have a group of 3 AND a threshold of 3, you " + "will need all 3 people in the group to sign to approve any " + "action.", + style: STextStyles.w400_16(context), + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _thresholdController.dispose(); + _participantsController.dispose(); + for (final controller in controllers) { + controller.dispose(); + } + super.dispose(); + } + @override Widget build(BuildContext context) { - final bool isDesktop = Util.isDesktop; final setupData = ref.watch(multisigSetupStateProvider); - - // Required signatures<= total cosigners. - final clampedThreshold = (setupData.threshold > setupData.totalCosigners) - ? setupData.totalCosigners - : setupData.threshold; + final bool isDesktop = Util.isDesktop; return Background( child: SafeArea( @@ -265,227 +431,182 @@ class _MultisigSetupViewState extends ConsumerState { ), ], ), - body: LayoutBuilder( - builder: (builderContext, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // We'll add a method to share the config w/ cosigners - // so there's not much need to remind them here. - // RoundedWhiteContainer( - // child: Text( - // "Make sure all cosigners use the same " - // "configuration when creating the shared account.", - // style: STextStyles.w500_12(context).copyWith( - // color: Theme.of(context) - // .extension()! - // .textSubtitle1, - // ), - // ), - // ), - // const SizedBox(height: 16), - - Text( - "Configuration", - style: STextStyles.itemSubtitle(context), - ), - const SizedBox(height: 16), - - // Script Type Selection - RoundedContainer( - padding: const EdgeInsets.all(16), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Configuration", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 16), + + // Script type. + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Script type", + style: STextStyles.w500_14(context).copyWith( color: Theme.of(context) .extension()! - .popupBG, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Script Type", - style: STextStyles.titleBold12(context), - ), - const SizedBox(height: 8), - DropdownButtonFormField( - value: setupData.scriptType, - decoration: const InputDecoration( - border: OutlineInputBorder(), - ), - items: MultisigScriptType.values.map((type) { - String label; - switch (type) { - case MultisigScriptType.legacy: - label = "Legacy (P2SH)"; - break; - case MultisigScriptType.segwit: - label = "Nested SegWit (P2SH-P2WSH)"; - break; - case MultisigScriptType.nativeSegwit: - label = "Native SegWit (P2WSH)"; - break; - } - return DropdownMenuItem( - value: type, - child: Text(label), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - ref - .read( - multisigSetupStateProvider.notifier, - ) - .updateScriptType(value); - } - }, - ), - ], - ), + .textDark3, ), - - const SizedBox(height: 16), - - // Multisig params setup - - RoundedContainer( - padding: const EdgeInsets.all(16), + ), + CustomTextButton( + text: "What is a script type?", + onTap: _showScriptTypeDialog, + ), + ], + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: setupData.scriptType, + decoration: const InputDecoration( + border: OutlineInputBorder(), + ), + items: MultisigScriptType.values.map((type) { + String label; + switch (type) { + case MultisigScriptType.legacy: + label = "Legacy (P2SH)"; + break; + case MultisigScriptType.segwit: + label = "Nested SegWit (P2SH-P2WSH)"; + break; + case MultisigScriptType.nativeSegwit: + label = "Native SegWit (P2WSH)"; + break; + } + return DropdownMenuItem( + value: type, + child: Text(label), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + ref + .read(multisigSetupStateProvider.notifier) + .updateScriptType(value); + } + }, + ), + ], + ), + const SizedBox(height: 16), + + // Threshold and Participants. + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Number of participants", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of(context) + .extension()! + .textDark3, + ), + ), + const SizedBox(height: 10), + TextField( + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + controller: _participantsController, + onChanged: _participantsCountChanged, + decoration: InputDecoration( + hintText: "Enter number of participants", + hintStyle: STextStyles.fieldLabel(context), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Threshold", + style: STextStyles.w500_14(context).copyWith( color: Theme.of(context) .extension()! - .popupBG, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Total cosigners: ${setupData.totalCosigners}", - style: STextStyles.titleBold12(context), - ), - Slider( - value: setupData.totalCosigners.toDouble(), - min: 2, - max: 7, // There's not actually a max. - divisions: 7, // Match the above or look off. - label: - "${setupData.totalCosigners} cosigners", - onChanged: (value) { - ref - .read( - multisigSetupStateProvider.notifier) - .updateTotalCosigners(value.toInt()); - }, - ), - const SizedBox(height: 16), - Text( - "Required Signatures: $clampedThreshold of ${setupData.totalCosigners}", - style: STextStyles.titleBold12(context), - ), - Slider( - value: clampedThreshold.toDouble(), - min: 1, - max: setupData.totalCosigners.toDouble(), - divisions: setupData.totalCosigners - 1, - label: - "$clampedThreshold of ${setupData.totalCosigners}", - onChanged: (value) { - ref - .read( - multisigSetupStateProvider.notifier) - .updateThreshold(value.toInt()); - }, - ), - ], - ), + .textDark3, ), - - const SizedBox(height: 24), - - // We'll make a FROST-like progress indicator in a - // dialog to show the progress of the setup process. - // This simpler example will be removed soon. - // Text( - // "Exchange Method", - // style: STextStyles.itemSubtitle(context), - // ), - // const SizedBox(height: 16), - // - // // NFC exchange. - // RoundedContainer( - // padding: const EdgeInsets.all(16), - // color: Theme.of(context) - // .extension()! - // .popupBG, - // child: Column( - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // Row( - // children: [ - // Icon( - // _isNfcAvailable - // ? Icons.nfc - // : Icons.nfc_outlined, - // color: _isNfcAvailable - // ? Theme.of(context) - // .extension()! - // .accentColorGreen - // : Theme.of(context) - // .extension()! - // .textDark3, - // ), - // const SizedBox(width: 8), - // Text( - // "NFC Exchange", - // style: STextStyles.titleBold12(context), - // ), - // ], - // ), - // const SizedBox(height: 16), - // Text( - // _nfcStatus, - // style: STextStyles.baseXS(context), - // ), - // if (_isNfcAvailable) ...[ - // const SizedBox(height: 16), - // SizedBox( - // width: double.infinity, - // child: !isDesktop - // ? TextButton( - // onPressed: _startNfcSession, - // style: Theme.of(context) - // .extension()! - // .getPrimaryEnabledButtonStyle( - // context), - // child: Text( - // "Tap to Exchange Information", - // style: - // STextStyles.button(context), - // ), - // ) - // : PrimaryButton( - // label: - // "Tap to Exchange Information", - // onPressed: _startNfcSession, - // enabled: true, - // ), - // ), - // ], - // ], - // ), - // ), - - const Spacer(), - ], + ), + CustomTextButton( + text: "What is a threshold?", + onTap: _showWhatIsThresholdDialog, + ), + ], + ), + const SizedBox(height: 10), + TextField( + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + controller: _thresholdController, + decoration: InputDecoration( + hintText: "Enter number of signatures", + hintStyle: STextStyles.fieldLabel(context), ), ), - ), + ], ), - ); - }, + const SizedBox(height: 24), + + // TODO: Push button to bottom of page. + PrimaryButton( + label: "Create multisignature account", + 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, + ), + ); + } + + // TODO: Adapt the FROST config steps UI. + // final config = Frost.createMultisigConfig( + // name: controllers.first.text.trim(), + // threshold: int.parse(_thresholdController.text), + // participants: + // controllers.map((e) => e.text.trim()).toList(), + // ); + // + // ref.read(pFrostMyName.notifier).state = + // controllers.first.text.trim(); + // ref.read(pFrostMultisigConfig.notifier).state = config; + // + // ref.read(pFrostScaffoldArgs.state).state = ( + // info: ( + // walletName: widget.walletName, + // frostCurrency: widget.frostCurrency, + // ), + // walletId: null, + // stepRoutes: FrostRouteGenerator.createNewConfigStepRoutes, + // frostInterruptionDialogType: + // FrostInterruptionDialogType.walletCreation, + // parentNav: Navigator.of(context), + // callerRouteName: CreateNewFrostMsWalletView.routeName, + // ); + // + // await Navigator.of(context).pushNamed( + // FrostStepScaffold.routeName, + // ); + }, + ), + ], + ), ), ), ), From 47525fb301f8924ed49c69222d7df0a13b1bfa71 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 27 Dec 2024 21:37:21 -0600 Subject: [PATCH 3/3] feat: WIP BIP48 wallet type and marker interface --- lib/pages/wallet_view/wallet_view.dart | 3 +- .../interfaces/bip48_currency_interface.dart | 5 + lib/wallets/wallet/impl/bip48_wallet.dart | 989 ++++++++++++++++++ 3 files changed, 996 insertions(+), 1 deletion(-) create mode 100644 lib/wallets/crypto_currency/interfaces/bip48_currency_interface.dart create mode 100644 lib/wallets/wallet/impl/bip48_wallet.dart diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index eaf063b2d..71517dc6f 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -46,6 +46,7 @@ import '../../utilities/logger.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/crypto_currency/interfaces/bip48_currency_interface.dart'; import '../../wallets/crypto_currency/intermediate/frost_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; @@ -1236,7 +1237,7 @@ class _WalletViewState extends ConsumerState { }, ), if (wallet.info.coin - is Bitcoin) // TODO [prio=low]: test if !isViewOnly is necessary... I think not, we should be able to make view-only shared multisig wallets. + is BIP48CurrencyInterface) // TODO [prio=low]: test if !isViewOnly is necessary... I think not, we should be able to make view-only shared multisig wallets. WalletNavigationBarItemData( label: "Make multisignature account", icon: const MultisigSetupNavIcon(), diff --git a/lib/wallets/crypto_currency/interfaces/bip48_currency_interface.dart b/lib/wallets/crypto_currency/interfaces/bip48_currency_interface.dart new file mode 100644 index 000000000..aa7da069e --- /dev/null +++ b/lib/wallets/crypto_currency/interfaces/bip48_currency_interface.dart @@ -0,0 +1,5 @@ +import '../intermediate/bip39_hd_currency.dart'; + +mixin BIP48CurrencyInterface on Bip39HDCurrency { + // This is just a marker interface. +} diff --git a/lib/wallets/wallet/impl/bip48_wallet.dart b/lib/wallets/wallet/impl/bip48_wallet.dart new file mode 100644 index 000000000..c91733729 --- /dev/null +++ b/lib/wallets/wallet/impl/bip48_wallet.dart @@ -0,0 +1,989 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:isar/isar.dart'; + +import '../../../electrumx_rpc/cached_electrumx_client.dart'; +import '../../../electrumx_rpc/electrumx_client.dart'; +import '../../../models/balance.dart'; +import '../../../models/isar/models/blockchain_data/address.dart'; +import '../../../models/isar/models/blockchain_data/transaction.dart'; +import '../../../models/isar/models/blockchain_data/utxo.dart'; +import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; +import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; +import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import '../../../models/paymint/fee_object_model.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/extensions/extensions.dart'; +import '../../../utilities/logger.dart'; +import '../../crypto_currency/crypto_currency.dart'; +import '../../crypto_currency/intermediate/bip39_hd_currency.dart'; +import '../../isar/models/wallet_info.dart'; +import '../../models/tx_data.dart'; +import '../wallet.dart'; +import '../wallet_mixin_interfaces/multi_address_interface.dart'; + +class BIP48Wallet extends Wallet + with MultiAddressInterface { + BIP48Wallet(CryptoCurrencyNetwork network) : super(Bitcoin(network) as T); + + late ElectrumXClient electrumXClient; + late CachedElectrumXClient electrumXCachedClient; + + 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, + cryptoCurrency.minCoinbaseConfirms, + )) { + available += output.value; + inputCount++; + } + } + + // transaction will only have 1 output minus the fee + final estimatedFee = _roughFeeEstimate(inputCount, 1, feeRate); + + return Amount( + rawValue: BigInt.from(available), + fractionDigits: cryptoCurrency.fractionDigits, + ) - + estimatedFee; + } + + // int _estimateTxFee({required int vSize, required int feeRatePerKB}) { + // return vSize * (feeRatePerKB / 1000).ceil(); + // } + + Amount _roughFeeEstimate(int inputCount, int outputCount, int feeRatePerKB) { + return Amount( + rawValue: BigInt.from( + ((42 + (272 * inputCount) + (128 * outputCount)) / 4).ceil() * + (feeRatePerKB / 1000).ceil(), + ), + fractionDigits: cryptoCurrency.fractionDigits, + ); + } + + // ==================== Overrides ============================================ + + @override + bool get supportsMultiRecipient => true; + + @override + int get isarTransactionVersion => 2; + + @override + FilterOperation? get changeAddressFilterOperation => FilterGroup.and( + [ + FilterCondition.equalTo( + property: r"type", + value: info.mainAddressType, + ), + const FilterCondition.equalTo( + property: r"subType", + value: AddressSubType.change, + ), + const FilterCondition.greaterThan( + property: r"derivationIndex", + value: 0, + ), + ], + ); + + @override + FilterOperation? get receivingAddressFilterOperation => FilterGroup.and( + [ + FilterCondition.equalTo( + property: r"type", + value: info.mainAddressType, + ), + const FilterCondition.equalTo( + property: r"subType", + value: AddressSubType.receiving, + ), + const FilterCondition.greaterThan( + property: r"derivationIndex", + value: 0, + ), + ], + ); + + @override + Future updateTransactions() async { + // Get all addresses. + final List
allAddressesOld = + await _fetchAddressesForElectrumXScan(); + + // Separate receiving and change addresses. + final Set receivingAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.receiving) + .map((e) => e.value) + .toSet(); + final Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => e.value) + .toSet(); + + // Remove duplicates. + final allAddressesSet = {...receivingAddresses, ...changeAddresses}; + + final currentHeight = await chainHeight; + + // Fetch history from ElectrumX. + final List> allTxHashes = + await _fetchHistory(allAddressesSet); + + final 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, + cryptoCurrency.minCoinbaseConfirms, + )) { + final tx = await electrumXCachedClient.getTransaction( + txHash: txHash["tx_hash"] as String, + verbose: true, + cryptoCurrency: cryptoCurrency, + ); + + 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; + BigInt changeAmountReceivedInThisWallet = 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, + cryptoCurrency: cryptoCurrency, + ); + + 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 (allAddressesSet.intersection(input.addresses.toSet()).isNotEmpty) { + 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 (receivingAddresses + .intersection(output.addresses.toSet()) + .isNotEmpty) { + wasReceivedInThisWallet = true; + amountReceivedInThisWallet += output.value; + output = output.copyWith(walletOwns: true); + } else if (changeAddresses + .intersection(output.addresses.toSet()) + .isNotEmpty) { + wasReceivedInThisWallet = true; + changeAmountReceivedInThisWallet += 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++) { + final 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 (changeAmountReceivedInThisWallet + 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; + + // TODO: [prio=none] Check for special Bitcoin outputs like ordinals. + } else { + Logging.instance.log( + "Unexpected tx found (ignoring it): $txData", + level: LogLevel.Error, + ); + continue; + } + + final tx = TransactionV2( + walletId: walletId, + blockHash: txData["blockhash"] as String?, + hash: txData["hash"] as String, + txid: txData["txid"] as String, + height: txData["height"] as int?, + version: txData["version"] as int, + timestamp: txData["blocktime"] as int? ?? + DateTime.timestamp().millisecondsSinceEpoch ~/ 1000, + inputs: List.unmodifiable(inputs), + outputs: List.unmodifiable(outputs), + type: type, + subType: subType, + otherData: null, + ); + + txns.add(tx); + } + + await mainDB.updateOrPutTransactionV2s(txns); + } + + @override + Future checkSaveInitialReceivingAddress() async { + final address = await getCurrentReceivingAddress(); + if (address == null) { + // TODO derive address. + } + } + + @override + Future confirmSend({required TxData txData}) async { + try { + Logging.instance.log("confirmSend txData: $txData", level: LogLevel.Info); + + final hex = txData.raw!; + + final txHash = await electrumXClient.broadcastTransaction(rawTx: hex); + Logging.instance.log("Sent txHash: $txHash", level: LogLevel.Info); + + // mark utxos as used + final usedUTXOs = txData.utxos!.map((e) => e.copyWith(used: true)); + await mainDB.putUTXOs(usedUTXOs.toList()); + + txData = txData.copyWith( + utxos: usedUTXOs.toSet(), + txHash: txHash, + txid: txHash, + ); + + return txData; + } catch (e, s) { + Logging.instance.log( + "Exception rethrown from confirmSend(): $e\n$s", + level: LogLevel.Error, + ); + rethrow; + } + } + + @override + Future estimateFeeFor(Amount amount, int feeRate) async { + final available = info.cachedBalance.spendable; + + if (available == amount) { + return amount - (await sweepAllEstimate(feeRate)); + } else if (amount <= Amount.zero || amount > available) { + return _roughFeeEstimate(1, 2, feeRate); + } + + Amount runningBalance = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + int inputCount = 0; + for (final output in (await mainDB.getUTXOs(walletId).findAll())) { + if (!output.isBlocked) { + runningBalance += Amount( + rawValue: BigInt.from(output.value), + fractionDigits: cryptoCurrency.fractionDigits, + ); + inputCount++; + if (runningBalance > amount) { + break; + } + } + } + + final oneOutPutFee = _roughFeeEstimate(inputCount, 1, feeRate); + final twoOutPutFee = _roughFeeEstimate(inputCount, 2, feeRate); + + if (runningBalance - amount > oneOutPutFee) { + if (runningBalance - amount > oneOutPutFee + cryptoCurrency.dustLimit) { + final change = runningBalance - amount - twoOutPutFee; + if (change > cryptoCurrency.dustLimit && + runningBalance - amount - change == twoOutPutFee) { + return runningBalance - amount - change; + } else { + return runningBalance - amount; + } + } else { + return runningBalance - amount; + } + } else if (runningBalance - amount == oneOutPutFee) { + return oneOutPutFee; + } else { + return twoOutPutFee; + } + } + + @override + Future get fees async { + try { + // adjust numbers for different speeds? + const int f = 1, m = 5, s = 20; + + final fast = await electrumXClient.estimateFee(blocks: f); + final medium = await electrumXClient.estimateFee(blocks: m); + final slow = await electrumXClient.estimateFee(blocks: s); + + final feeObject = FeeObject( + numberOfBlocksFast: f, + numberOfBlocksAverage: m, + numberOfBlocksSlow: s, + fast: Amount.fromDecimal( + fast, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(), + medium: Amount.fromDecimal( + medium, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(), + slow: Amount.fromDecimal( + slow, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(), + ); + + Logging.instance.log("fetched fees: $feeObject", level: LogLevel.Info); + return feeObject; + } catch (e) { + Logging.instance + .log("Exception rethrown from _getFees(): $e", level: LogLevel.Error); + rethrow; + } + } + + @override + Future prepareSend({required TxData txData}) { + // TODO: implement prepareSendpu + throw UnimplementedError(); + } + + @override + Future recover({ + required bool isRescan, + String? serializedKeys, + String? multisigConfig, + }) async { + // TODO. + } + + @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, + cryptoCurrency.minCoinbaseConfirms, + )) { + 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 allAddresses = await _fetchAddressesForElectrumXScan(); + + try { + final fetchedUtxoList = >>[]; + for (int i = 0; i < allAddresses.length; i++) { + final scriptHash = cryptoCurrency.addressToScriptHash( + address: allAddresses[i].value, + ); + + final utxos = await electrumXClient.getUTXOs(scripthash: scriptHash); + if (utxos.isNotEmpty) { + fetchedUtxoList.add(utxos); + } + } + + final List outputArray = []; + + for (int i = 0; i < fetchedUtxoList.length; i++) { + for (int j = 0; j < fetchedUtxoList[i].length; j++) { + final utxo = await _parseUTXO( + jsonUTXO: fetchedUtxoList[i][j], + ); + + 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; + } + } + + // =================== Private =============================================== + + Future _getCurrentElectrumXNode() async { + final node = getCurrentNode(); + + return ElectrumXNode( + address: node.host, + port: node.port, + name: node.name, + useSSL: node.useSSL, + id: node.id, + torEnabled: node.torEnabled, + clearnetEnabled: node.clearnetEnabled, + ); + } + + // TODO [prio=low]: Use ElectrumXInterface method. + Future _updateElectrumX() async { + final failovers = nodeService + .failoverNodesFor(currency: cryptoCurrency) + .map( + (e) => ElectrumXNode( + address: e.host, + port: e.port, + name: e.name, + id: e.id, + useSSL: e.useSSL, + torEnabled: e.torEnabled, + clearnetEnabled: e.clearnetEnabled, + ), + ) + .toList(); + + final newNode = await _getCurrentElectrumXNode(); + try { + await electrumXClient.closeAdapter(); + } catch (e) { + 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, + cryptoCurrency: cryptoCurrency, + ); + + electrumXCachedClient = CachedElectrumXClient.from( + electrumXClient: electrumXClient, + ); + } + + bool _duplicateTxCheck( + List> allTransactions, + String txid, + ) { + for (int i = 0; i < allTransactions.length; i++) { + if (allTransactions[i]["txid"] == txid) { + return true; + } + } + return false; + } + + Future _parseUTXO({ + required Map jsonUTXO, + }) async { + final txn = await electrumXCachedClient.getTransaction( + txHash: jsonUTXO["tx_hash"] as String, + verbose: true, + cryptoCurrency: cryptoCurrency, + ); + + 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; + } + + @override + Future checkChangeAddressForTransactions() async { + try { + final currentChange = await getCurrentChangeAddress(); + + final bool needsGenerate; + if (currentChange == null) { + // no addresses in db yet for some reason. + // Should not happen at this point... + + needsGenerate = true; + } else { + final txCount = await _fetchTxCount(address: currentChange); + needsGenerate = txCount > 0 || currentChange.derivationIndex < 0; + } + + if (needsGenerate) { + await generateNewChangeAddress(); + + // TODO: get rid of this? Could cause problems (long loading/infinite loop or something) + // keep checking until address with no tx history is set as current + await checkChangeAddressForTransactions(); + } + } catch (e, s) { + Logging.instance.log( + "Exception rethrown from _checkChangeAddressForTransactions" + "($cryptoCurrency): $e\n$s", + level: LogLevel.Error, + ); + rethrow; + } + } + + @override + Future checkReceivingAddressForTransactions() async { + if (info.otherData[WalletInfoKeys.reuseAddress] == true) { + try { + throw Exception(); + } catch (_, s) { + Logging.instance.log( + "checkReceivingAddressForTransactions called but reuse address flag set: $s", + level: LogLevel.Error, + ); + } + } + + try { + final currentReceiving = await getCurrentReceivingAddress(); + + final bool needsGenerate; + if (currentReceiving == null) { + // no addresses in db yet for some reason. + // Should not happen at this point... + + needsGenerate = true; + } else { + final txCount = await _fetchTxCount(address: currentReceiving); + needsGenerate = txCount > 0 || currentReceiving.derivationIndex < 0; + } + + if (needsGenerate) { + await generateNewReceivingAddress(); + + // TODO: [prio=low] Make sure we scan all addresses but only show one. + if (info.otherData[WalletInfoKeys.reuseAddress] != true) { + // TODO: get rid of this? Could cause problems (long loading/infinite loop or something) + // keep checking until address with no tx history is set as current + await checkReceivingAddressForTransactions(); + } + } + } catch (e, s) { + Logging.instance.log( + "Exception rethrown from _checkReceivingAddressForTransactions" + "($cryptoCurrency): $e\n$s", + level: LogLevel.Error, + ); + rethrow; + } + } + + @override + Future generateNewChangeAddress() async { + final current = await getCurrentChangeAddress(); + const chain = 0; // TODO. + const index = 0; // TODO. + + Address? address; + while (address == null) { + try { + // TODO. + // address = await _generateAddress( + // change: chain, + // index: index, + // ); + } catch (e) { + rethrow; + } + } + + await mainDB.updateOrPutAddresses([address]); + } + + @override + Future generateNewReceivingAddress() async { + final current = await getCurrentReceivingAddress(); + // TODO: Handle null assertion below. + int index = current!.derivationIndex + 1; + const chain = 0; // receiving address + + Address? address; + while (address == null) { + try { + // TODO. + // address = await _generateAddress( + // change: chain, + // index: index, + // ); + } catch (e) { + rethrow; + } + } + + await mainDB.updateOrPutAddresses([address]); + await info.updateReceivingAddress( + newAddress: address.value, + isar: mainDB.isar, + ); + } + + Future lookAhead() async { + Address? currentReceiving = await getCurrentReceivingAddress(); + if (currentReceiving == null) { + await generateNewReceivingAddress(); + currentReceiving = await getCurrentReceivingAddress(); + } + Address? currentChange = await getCurrentChangeAddress(); + if (currentChange == null) { + await generateNewChangeAddress(); + currentChange = await getCurrentChangeAddress(); + } + + final List
nextReceivingAddresses = []; + final List
nextChangeAddresses = []; + + int receiveIndex = currentReceiving!.derivationIndex; + int changeIndex = currentChange!.derivationIndex; + for (int i = 0; i < 10; i++) { + final receiveAddress = await _generateAddressSafe( + chain: 0, + startingIndex: receiveIndex + 1, + ); + receiveIndex = receiveAddress.derivationIndex; + nextReceivingAddresses.add(receiveAddress); + + final changeAddress = await _generateAddressSafe( + chain: 1, + startingIndex: changeIndex + 1, + ); + changeIndex = changeAddress.derivationIndex; + nextChangeAddresses.add(changeAddress); + } + + int activeReceiveIndex = currentReceiving.derivationIndex; + int activeChangeIndex = currentChange.derivationIndex; + for (final address in nextReceivingAddresses) { + final txCount = await _fetchTxCount(address: address); + if (txCount > 0) { + activeReceiveIndex = max(activeReceiveIndex, address.derivationIndex); + } + } + for (final address in nextChangeAddresses) { + final txCount = await _fetchTxCount(address: address); + if (txCount > 0) { + activeChangeIndex = max(activeChangeIndex, address.derivationIndex); + } + } + + nextReceivingAddresses + .removeWhere((e) => e.derivationIndex > activeReceiveIndex); + if (nextReceivingAddresses.isNotEmpty) { + await mainDB.updateOrPutAddresses(nextReceivingAddresses); + await info.updateReceivingAddress( + newAddress: nextReceivingAddresses.last.value, + isar: mainDB.isar, + ); + } + nextChangeAddresses + .removeWhere((e) => e.derivationIndex > activeChangeIndex); + if (nextChangeAddresses.isNotEmpty) { + await mainDB.updateOrPutAddresses(nextChangeAddresses); + } + } + + Future
_generateAddressSafe({ + required final int chain, + required int startingIndex, + }) async { + Address? address; + while (address == null) { + try { + // TODO. + // address = await _generateAddress( + // change: chain, + // index: startingIndex, + // ); + } catch (e) { + rethrow; + } + } + + return address; + } + + Future _fetchTxCount({required Address address}) async { + final transactions = await electrumXClient.getHistory( + scripthash: cryptoCurrency.addressToScriptHash( + address: address.value, + ), + ); + return transactions.length; + } + + Future> _fetchAddressesForElectrumXScan() async { + final allAddresses = await mainDB + .getAddresses(walletId) + .filter() + .not() + .group( + (q) => q + .typeEqualTo(AddressType.nonWallet) + .or() + .subTypeEqualTo(AddressSubType.nonWallet), + ) + .findAll(); + return allAddresses; + } + + Future>> _fetchHistory( + Iterable allAddresses, + ) async { + try { + final List> allTxHashes = []; + for (int i = 0; i < allAddresses.length; i++) { + final addressString = allAddresses.elementAt(i); + final scriptHash = cryptoCurrency.addressToScriptHash( + address: addressString, + ); + + final response = await electrumXClient.getHistory( + scripthash: scriptHash, + ); + + for (int j = 0; j < response.length; j++) { + response[j]["address"] = addressString; + if (!allTxHashes.contains(response[j])) { + allTxHashes.add(response[j]); + } + } + } + + return allTxHashes; + } catch (e, s) { + Logging.instance.log( + "$runtimeType._fetchHistory: $e\n$s", + level: LogLevel.Error, + ); + rethrow; + } + } +}