CW-472-QR-code-restore-If-a-user-scans-a-wallet-seed-that-does-NOT-include (#1081)

* add restor from qr option * minor fixes * merge OR fixes * add restoring nano from QR seed mode

Serhii committed Oct 25, 2023 at 03:19 UTC db7f025b71643776f933d6a6816783d8f4ddfc78
4 files changed +113 -114
lib/router.dart
+5
@@ -189,6 +189,11 @@ Route<dynamic> createRoute(RouteSettings settings) {
189 param2: false));
190 }
191
192 + case Routes.restoreWalletTypeFromQR:
193 + return CupertinoPageRoute<void>(
194 + builder: (_) => getIt.get<NewWalletTypePage>(
195 + param1: (BuildContext context, WalletType type) => Navigator.of(context).pop(type)));
196 +
197 case Routes.seed:
198 return MaterialPageRoute<void>(
199 fullscreenDialog: true,
lib/routes.dart
+1
@@ -6,6 +6,7 @@ class Routes {
6 static const seed = '/seed';
7 static const restoreOptions = '/restore_options';
8 static const restoreWalletFromSeedKeys = '/restore_wallet_from_seeds_keys';
9 + static const restoreWalletTypeFromQR = '/restore_wallet_from_qr_code';
10 static const restoreWalletChooseDerivation = '/restore_wallet_choose_derivation';
11 static const dashboard = '/dashboard';
12 static const send = '/send';
lib/view_model/restore/restore_from_qr_vm.dart
+4
@@ -1,6 +1,7 @@
1 import 'package:cake_wallet/bitcoin/bitcoin.dart';
2 import 'package:cake_wallet/bitcoin_cash/bitcoin_cash.dart';
3 import 'package:cake_wallet/ethereum/ethereum.dart';
4 +import 'package:cake_wallet/nano/nano.dart';
5 import 'package:cake_wallet/view_model/restore/restore_mode.dart';
6 import 'package:cake_wallet/view_model/restore/restore_wallet.dart';
7 import 'package:hive/hive.dart';
@@ -91,6 +92,9 @@ abstract class WalletRestorationFromQRVMBase extends WalletCreationVM with Store
92 case WalletType.ethereum:
93 return ethereum!.createEthereumRestoreWalletFromSeedCredentials(
94 name: name, mnemonic: restoreWallet.mnemonicSeed ?? '', password: password);
95 + case WalletType.nano:
96 + return nano!.createNanoRestoreWalletFromSeedCredentials(
97 + name: name, mnemonic: restoreWallet.mnemonicSeed ?? '', password: password);
98 default:
99 throw Exception('Unexpected type: ${type.toString()}');
100 }
lib/view_model/restore/wallet_restore_from_qr_code.dart
+103 -114
@@ -1,44 +1,106 @@
1 import 'package:cake_wallet/core/seed_validator.dart';
2 import 'package:cake_wallet/entities/parse_address_from_domain.dart';
3 import 'package:cake_wallet/entities/qr_scanner.dart';
4 +import 'package:cake_wallet/routes.dart';
5 +import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
6 +import 'package:cake_wallet/utils/show_pop_up.dart';
7 import 'package:cake_wallet/view_model/restore/restore_mode.dart';
8 import 'package:cake_wallet/view_model/restore/restore_wallet.dart';
9 import 'package:cw_core/wallet_type.dart';
10 import 'package:flutter/cupertino.dart';
11 +import 'package:cake_wallet/generated/i18n.dart';
12 +import 'package:collection/collection.dart';
13
14 class WalletRestoreFromQRCode {
15 WalletRestoreFromQRCode();
16
17 + static const Map<String, WalletType> _walletTypeMap = {
18 + 'monero': WalletType.monero,
19 + 'monero-wallet': WalletType.monero,
20 + 'monero_wallet': WalletType.monero,
21 + 'bitcoin': WalletType.bitcoin,
22 + 'bitcoin-wallet': WalletType.bitcoin,
23 + 'bitcoin_wallet': WalletType.bitcoin,
24 + 'litecoin': WalletType.litecoin,
25 + 'litecoin-wallet': WalletType.litecoin,
26 + 'litecoin_wallet': WalletType.litecoin,
27 + 'ethereum-wallet': WalletType.ethereum,
28 + 'nano-wallet': WalletType.nano,
29 + 'nano_wallet': WalletType.nano,
30 + 'bitcoincash': WalletType.bitcoinCash,
31 + 'bitcoincash-wallet': WalletType.bitcoinCash,
32 + 'bitcoincash_wallet': WalletType.bitcoinCash,
33 + };
34 +
35 + static bool _containsAssetSpecifier(String code) => _extractWalletType(code) != null;
36 +
37 + static WalletType? _extractWalletType(String code) {
38 + final sortedKeys = _walletTypeMap.keys.toList()..sort((a, b) => b.length.compareTo(a.length));
39 +
40 + final extracted = sortedKeys.firstWhereOrNull((key) => code.toLowerCase().contains(key));
41 +
42 + return _walletTypeMap[extracted];
43 + }
44 +
45 + static String? _extractAddressFromUrl(String rawString, WalletType type) {
46 + return AddressResolver.extractAddressByType(
47 + raw: rawString, type: walletTypeToCryptoCurrency(type));
48 + }
49 +
50 + static String? _extractSeedPhraseFromUrl(String rawString, WalletType walletType) {
51 + RegExp _getPattern(int wordCount) =>
52 + RegExp(r'(?<=\W|^)((?:\w+\s+){' + (wordCount - 1).toString() + r'}\w+)(?=\W|$)');
53 +
54 + List<int> patternCounts = walletType == WalletType.monero ? [25, 14, 13] : [24, 18, 12];
55 +
56 + for (final count in patternCounts) {
57 + final pattern = _getPattern(count);
58 + final match = pattern.firstMatch(rawString);
59 + if (match != null) {
60 + return match.group(1)?.trim();
61 + }
62 + }
63 + return null;
64 + }
65 +
66 static Future<RestoredWallet> scanQRCodeForRestoring(BuildContext context) async {
67 String code = await presentQRScanner();
14 - Map<String, dynamic> credentials = {};
68 + if (code.isEmpty) throw Exception('Unexpected scan QR code value: value is empty');
69 +
70 + WalletType? walletType;
71 + String formattedUri = '';
72 +
73 + if (!_containsAssetSpecifier(code)) {
74 + await _specifyWalletAssets(context, "Can't determine wallet type, please pick it manually");
75 + walletType =
76 + await Navigator.pushNamed(context, Routes.restoreWalletTypeFromQR) as WalletType?;
77 + if (walletType == null) throw Exception("Failed to determine wallet type.");
78 +
79 + final seedPhrase = _extractSeedPhraseFromUrl(code, walletType);
80
16 - if (code.isEmpty) {
17 - throw Exception('Unexpected scan QR code value: value is empty');
81 + formattedUri = seedPhrase != null
82 + ? '$walletType:?seed=$seedPhrase'
83 + : throw Exception('Failed to determine valid seed phrase');
84 + } else {
85 + walletType = _extractWalletType(code);
86 + final index = code.indexOf(':');
87 + final query = code.substring(index + 1).replaceAll('?', '&');
88 + formattedUri = '$walletType:?$query';
89 }
19 - final formattedUri = getFormattedUri(code);
90 +
91 final uri = Uri.parse(formattedUri);
21 - final queryParameters = uri.queryParameters;
22 - credentials['type'] = getWalletTypeFromUrl(uri.scheme);
23 -
24 - final address = getAddressFromUrl(
25 - type: credentials['type'] as WalletType,
26 - rawString: queryParameters.toString(),
27 - );
28 - if (address != null) {
29 - credentials['address'] = address;
30 - }
92 + Map<String, dynamic> queryParameters = {...uri.queryParameters};
93
32 - final seed =
33 - getSeedPhraseFromUrl(queryParameters.toString(), credentials['type'] as WalletType);
34 - if (seed != null) {
35 - credentials['seed'] = seed;
36 - } else {
37 - credentials['private_key'] = queryParameters['private_key'];
94 + if (queryParameters['seed'] == null) {
95 + queryParameters['seed'] = _extractSeedPhraseFromUrl(code, walletType!);
96 + }
97 + if (queryParameters['address'] == null) {
98 + queryParameters['address'] = _extractAddressFromUrl(code, walletType!);
99 }
100
40 - credentials.addAll(queryParameters);
41 - credentials['mode'] = getWalletRestoreMode(credentials);
101 + Map<String, dynamic> credentials = {'type': walletType, ...queryParameters};
102 +
103 + credentials['mode'] = _determineWalletRestoreMode(credentials);
104
105 switch (credentials['mode']) {
106 case WalletRestoreMode.txids:
@@ -52,106 +114,21 @@ class WalletRestoreFromQRCode {
114 }
115 }
116
55 - static String getFormattedUri(String code) {
56 - final index = code.indexOf(':');
57 - if (index == -1) return throw Exception('Unexpected wallet type: $code, try to scan again');
58 - final scheme = code.substring(0, index).replaceAll('_', '-');
59 - final query = code.substring(index + 1).replaceAll('?', '&');
60 - final formattedUri = '$scheme:?$query';
61 - return formattedUri;
62 - }
63 -
64 - static WalletType getWalletTypeFromUrl(String scheme) {
65 - switch (scheme) {
66 - case 'monero':
67 - case 'monero-wallet':
68 - return WalletType.monero;
69 - case 'bitcoin':
70 - case 'bitcoin-wallet':
71 - return WalletType.bitcoin;
72 - case 'litecoin':
73 - case 'litecoin-wallet':
74 - return WalletType.litecoin;
75 - case 'bitcoincash':
76 - case 'bitcoincash-wallet':
77 - return WalletType.bitcoinCash;
78 - case 'ethereum':
79 - case 'ethereum-wallet':
80 - return WalletType.ethereum;
81 - case 'nano':
82 - case 'nano-wallet':
83 - return WalletType.nano;
84 - default:
85 - throw Exception('Unexpected wallet type: ${scheme.toString()}');
86 - }
87 - }
88 -
89 - static String? getAddressFromUrl({required WalletType type, required String rawString}) {
90 - return AddressResolver.extractAddressByType(
91 - raw: rawString, type: walletTypeToCryptoCurrency(type));
92 - }
93 -
94 - static String? getSeedPhraseFromUrl(String rawString, WalletType walletType) {
95 - switch (walletType) {
96 - case WalletType.monero:
97 - RegExp regex25 = RegExp(r'\b(\S+\b\s+){24}\S+\b');
98 - RegExp regex14 = RegExp(r'\b(\S+\b\s+){13}\S+\b');
99 - RegExp regex13 = RegExp(r'\b(\S+\b\s+){12}\S+\b');
100 -
101 - if (regex25.firstMatch(rawString) == null) {
102 - if (regex14.firstMatch(rawString) == null) {
103 - if (regex13.firstMatch(rawString) == null) {
104 - return null;
105 - } else {
106 - return regex13.firstMatch(rawString)!.group(0)!;
107 - }
108 - } else {
109 - return regex14.firstMatch(rawString)!.group(0)!;
110 - }
111 - } else {
112 - return regex25.firstMatch(rawString)!.group(0)!;
113 - }
114 - case WalletType.bitcoin:
115 - case WalletType.litecoin:
116 - case WalletType.ethereum:
117 - case WalletType.bitcoinCash:
118 - RegExp regex24 = RegExp(r'\b(\S+\b\s+){23}\S+\b');
119 - RegExp regex18 = RegExp(r'\b(\S+\b\s+){17}\S+\b');
120 - RegExp regex12 = RegExp(r'\b(\S+\b\s+){11}\S+\b');
121 -
122 - if (regex24.firstMatch(rawString) == null) {
123 - if (regex18.firstMatch(rawString) == null) {
124 - if (regex12.firstMatch(rawString) == null) {
125 - return null;
126 - } else {
127 - return regex12.firstMatch(rawString)!.group(0)!;
128 - }
129 - } else {
130 - return regex18.firstMatch(rawString)!.group(0)!;
131 - }
132 - } else {
133 - return regex24.firstMatch(rawString)!.group(0)!;
134 - }
135 - default:
136 - return null;
137 - }
138 - }
139 -
140 - static WalletRestoreMode getWalletRestoreMode(Map<String, dynamic> credentials) {
117 + static WalletRestoreMode _determineWalletRestoreMode(Map<String, dynamic> credentials) {
118 final type = credentials['type'] as WalletType;
119 if (credentials.containsKey('tx_payment_id')) {
120 final txIdValue = credentials['tx_payment_id'] as String? ?? '';
144 - return txIdValue.isNotEmpty
145 - ? WalletRestoreMode.txids
146 - : throw Exception('Unexpected restore mode: tx_payment_id is invalid');
121 + if (txIdValue.isNotEmpty) return WalletRestoreMode.txids;
122 + throw Exception('Unexpected restore mode: tx_payment_id is invalid');
123 }
124
149 - if (credentials.containsKey('seed')) {
150 - final seedValue = credentials['seed'] as String;
125 + if (credentials['seed'] != null) {
126 + final seedValue = credentials['seed'];
127 final words = SeedValidator.getWordList(type: type, language: 'english');
128 seedValue.split(' ').forEach((element) {
129 if (!words.contains(element)) {
154 - throw Exception('Unexpected restore mode: mnemonic_seed is invalid');
130 + throw Exception(
131 + 'Unexpected restore mode: mnemonic_seed is invalid or does\'t match wallet type');
132 }
133 });
134 return WalletRestoreMode.seed;
@@ -177,3 +154,15 @@ class WalletRestoreFromQRCode {
154 throw Exception('Unexpected restore mode: restore params are invalid');
155 }
156 }
157 +
158 +Future<void> _specifyWalletAssets(BuildContext context, String error) async {
159 + await showPopUp<void>(
160 + context: context,
161 + builder: (BuildContext context) {
162 + return AlertWithOneAction(
163 + alertTitle: S.current.error,
164 + alertContent: error,
165 + buttonText: S.of(context).ok,
166 + buttonAction: () => Navigator.of(context).pop());
167 + });
168 +}