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: