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
This commit is contained in:
sneurlax 2024-12-27 20:11:09 -06:00
parent 4fc2a7acfa
commit 7b629e81b4
8 changed files with 791 additions and 0 deletions

View file

@ -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<CosignerInfo> _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<String> sharedAddresses,
{required List<int> 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<String> getVerificationAddresses(
{required List<int> 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();
}
}

View file

@ -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<MultisigSetupState, MultisigSetupData>((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<String> cosignerXpubs;
MultisigSetupData copyWith({
int? threshold,
int? totalCosigners,
int? coinType,
int? accountIndex,
MultisigScriptType? scriptType,
List<String>? 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<String, dynamic> toJson() => {
'threshold': threshold,
'totalCosigners': totalCosigners,
'coinType': coinType,
'accountIndex': accountIndex,
'scriptType': scriptType.index,
'cosignerXpubs': cosignerXpubs,
};
factory MultisigSetupData.fromJson(Map<String, dynamic> 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<String>(),
);
}
}
enum MultisigScriptType {
legacy, // P2SH.
segwit, // P2SH-P2WSH.
nativeSegwit, // P2WSH.
}
class MultisigSetupState extends StateNotifier<MultisigSetupData> {
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<MultisigSetupView> createState() => _MultisigSetupViewState();
}
class _MultisigSetupViewState extends ConsumerState<MultisigSetupView> {
bool _isNfcAvailable = false;
String _nfcStatus = 'Checking NFC availability...';
@override
void initState() {
super.initState();
_checkNfcAvailability();
}
Future<void> _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<void> _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<void> _showMultisigInfoDialog() async {
await showDialog<void>(
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<StackColors>()!.background,
appBar: AppBar(
automaticallyImplyLeading: false,
leading: AppBarBackButton(
onPressed: () async {
if (FocusScope.of(context).hasFocus) {
FocusScope.of(context).unfocus();
await Future<void>.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<StackColors>()!
.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<StackColors>()!
// .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<StackColors>()!
.popupBG,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Script Type",
style: STextStyles.titleBold12(context),
),
const SizedBox(height: 8),
DropdownButtonFormField<MultisigScriptType>(
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<StackColors>()!
.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<StackColors>()!
// .popupBG,
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Row(
// children: [
// Icon(
// _isNfcAvailable
// ? Icons.nfc
// : Icons.nfc_outlined,
// color: _isNfcAvailable
// ? Theme.of(context)
// .extension<StackColors>()!
// .accentColorGreen
// : Theme.of(context)
// .extension<StackColors>()!
// .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<StackColors>()!
// .getPrimaryEnabledButtonStyle(
// context),
// child: Text(
// "Tap to Exchange Information",
// style:
// STextStyles.button(context),
// ),
// )
// : PrimaryButton(
// label:
// "Tap to Exchange Information",
// onPressed: _startNfcSession,
// enabled: true,
// ),
// ),
// ],
// ],
// ),
// ),
const Spacer(),
],
),
),
),
),
);
},
),
),
),
);
}
}

View file

@ -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<WalletView> {
);
},
),
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,
);
},
),
],
),
],

View file

@ -426,6 +426,14 @@ class _MoreFeaturesDialogState extends ConsumerState<MoreFeaturesDialog> {
],
),
),
// 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,
),

View file

@ -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) {

View file

@ -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<StackColors>()!.bottomNavIconIcon,
);
}
}

View file

@ -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:

View file

@ -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: