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..d0930b40f --- /dev/null +++ b/lib/pages/wallet_view/multisig_setup_view/multisig_setup_view.dart @@ -0,0 +1,615 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/assets.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../utilities/util.dart'; +import '../../../../widgets/background.dart'; +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 = + 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.", + ); + }, + ); + } + + 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 setupData = ref.watch(multisigSetupStateProvider); + final bool isDesktop = Util.isDesktop; + + 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: 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()! + .textDark3, + ), + ), + 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()! + .textDark3, + ), + ), + 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, + // ); + }, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index d3a98ef16..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'; @@ -73,6 +74,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 +98,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 +1236,18 @@ class _WalletViewState extends ConsumerState { ); }, ), + if (wallet.info.coin + 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(), + 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/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; + } + } +} 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: