diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart index a1ea19c405..a778cf6412 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart @@ -98,7 +98,6 @@ class RestoreWalletView extends ConsumerStatefulWidget { } class _RestoreWalletViewState extends ConsumerState { - final _formKey = GlobalKey(); late final int _seedWordCount; late final bool isDesktop; @@ -125,7 +124,7 @@ class _RestoreWalletViewState extends ConsumerState { return; } - final words = text.split(" "); + final words = _splitMnemonic(text); if (words.isEmpty) { unawaited(delegate.pasteText(SelectionChangedCause.toolbar)); return; @@ -175,14 +174,57 @@ class _RestoreWalletViewState extends ConsumerState { super.dispose(); } - // TODO: check for wownero wordlist? + // Legacy seeds may be written as truncated words, cut at the unique prefix + // length their wordlist declares. Only English's is applied here; the other + // lists declare 4 (most Latin scripts) or 1 (Chinese) and are matched whole. + static const int _cryptonoteEnglishPrefixLength = 3; + + // The wallet library identifies a legacy seed's language by trying every + // wordlist it ships, so a phrase in any of these restores natively and must + // not be refused here. + static const List _otherLegacyCryptonoteLanguages = [ + "Chinese (simplified)", + "Dutch", + "French", + "German", + "Italian", + "Japanese", + "Portuguese", + "Russian", + "Spanish", + ]; + + late final Set _otherLegacyCryptonoteWords = { + for (final language in _otherLegacyCryptonoteLanguages) + for (final word in csMonero.getMoneroWordList(language)) + word.toLowerCase(), + }; + + bool _isValidCryptonoteWord(String word, List wordList) { + if (word.length < _cryptonoteEnglishPrefixLength) { + return _otherLegacyCryptonoteWords.contains(word); + } + + final prefix = word.substring(0, _cryptonoteEnglishPrefixLength); + return wordList.any((candidate) => candidate.startsWith(prefix)) || + _otherLegacyCryptonoteWords.contains(word); + } + + List _splitMnemonic(String mnemonic) { + final trimmed = mnemonic.trim(); + return trimmed.isEmpty ? const [] : trimmed.split(RegExp(r"\s+")); + } + bool _isValidMnemonicWord(String word) { // TODO: get the actual language if (widget.coin is Monero || widget.coin is Salvium) { - // Salvium use's Monero's wordlists. + // Salvium uses Monero's wordlists. switch (widget.seedWordsLength) { case 25: - return csMonero.getMoneroWordList("English").contains(word); + return _isValidCryptonoteWord( + word, + csMonero.getMoneroWordList("English"), + ); case 16: return Monero.sixteenWordsWordList.contains(word); default: @@ -194,6 +236,9 @@ class _RestoreWalletViewState extends ConsumerState { "English", widget.seedWordsLength, ); + if (widget.seedWordsLength == 25) { + return _isValidCryptonoteWord(word, wowneroWordList); + } return wowneroWordList.contains(word); } if (widget.coin is Xelis) { @@ -210,273 +255,310 @@ class _RestoreWalletViewState extends ConsumerState { } Future attemptRestore() async { - if (_formKey.currentState!.validate()) { - if (mounted) setState(() => _hideSeedWords = true); - - String mnemonic = ""; - for (final element in _controllers) { - mnemonic += " ${element.text.trim().toLowerCase()}"; - } - mnemonic = mnemonic.trim(); - - int height = widget.restoreBlockHeight; - String? otherDataJsonString; - - // TODO: make more robust estimate of date maybe using https://explorer.epic.tech/api-index - if (widget.coin is Epiccash) { - otherDataJsonString = jsonEncode({ - WalletInfoKeys.epiccashData: jsonEncode( - ExtraEpiccashWalletInfo( - receivingIndex: 0, - changeIndex: 0, - slatesToAddresses: {}, - slatesToCommits: {}, - lastScannedBlock: height, - restoreHeight: height, - creationHeight: height, - ).toMap(), - ), - }); - } else if (widget.coin is Mimblewimblecoin) { - // final int secondsSinceEpoch = - // widget.restoreFromDate!.millisecondsSinceEpoch ~/ 1000; - final int secondsSinceEpoch = - DateTime.now().millisecondsSinceEpoch ~/ 1000; - const int mimblewimblecoinFirstBlock = 1573462801; - const double overestimateSecondsPerBlock = 61; - final int chosenSeconds = - secondsSinceEpoch - mimblewimblecoinFirstBlock; - final int approximateHeight = - chosenSeconds ~/ overestimateSecondsPerBlock; - height = approximateHeight; - if (height < 0) { - height = 0; - } - otherDataJsonString = jsonEncode({ - WalletInfoKeys.mimblewimblecoinData: jsonEncode( - ExtraMimblewimblecoinWalletInfo( - receivingIndex: 0, - changeIndex: 0, - slatesToAddresses: {}, - slatesToCommits: {}, - lastScannedBlock: height, - restoreHeight: height, - creationHeight: height, - ).toMap(), - ), - }); - } + final words = _controllers + .map((controller) => controller.text.trim().toLowerCase()) + .toList(growable: false); - // TODO: do actual check to make sure it is a valid mnemonic for monero + xelis - if (bip39.validateMnemonic(mnemonic) == false && - !(widget.coin is Monero || - widget.coin is Wownero || - widget.coin is Salvium || - widget.coin is Xelis)) { + final wordCount = words.where((word) => word.isNotEmpty).length; + if (wordCount != _seedWordCount) { + if (mounted) { unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: "Invalid seed phrase!", + message: + "Expected $_seedWordCount words but got $wordCount. " + "Please fill in all fields.", context: context, ), ); - } else { - if (!Platform.isLinux) await WakelockPlus.enable(); + setState(() => _hideSeedWords = false); + } + return; + } - final info = WalletInfo.createNew( - coin: widget.coin, - name: widget.walletName, - restoreHeight: height, - otherDataJsonString: otherDataJsonString, - ); + final statuses = words + .map( + (word) => _isValidMnemonicWord(word) + ? FormInputStatus.valid + : FormInputStatus.invalid, + ) + .toList(growable: false); + final hasInvalidWords = statuses.contains(FormInputStatus.invalid); - bool isRestoring = true; - // show restoring in progress + if (mounted) { + setState(() { + _inputStatuses + ..clear() + ..addAll(statuses); + if (hasInvalidWords) { + _hideSeedWords = false; + } + }); + } - if (mounted) { - unawaited( - showDialog( - context: context, - useSafeArea: false, - barrierDismissible: false, - builder: (context) { - return RestoringDialog( - onCancel: () async { - isRestoring = false; + if (hasInvalidWords) { + return; + } + + final mnemonic = words.join(" "); + + int height = widget.restoreBlockHeight; + String? otherDataJsonString; + + // TODO: make more robust estimate of date maybe using https://explorer.epic.tech/api-index + if (widget.coin is Epiccash) { + otherDataJsonString = jsonEncode({ + WalletInfoKeys.epiccashData: jsonEncode( + ExtraEpiccashWalletInfo( + receivingIndex: 0, + changeIndex: 0, + slatesToAddresses: {}, + slatesToCommits: {}, + lastScannedBlock: height, + restoreHeight: height, + creationHeight: height, + ).toMap(), + ), + }); + } else if (widget.coin is Mimblewimblecoin) { + // final int secondsSinceEpoch = + // widget.restoreFromDate!.millisecondsSinceEpoch ~/ 1000; + final int secondsSinceEpoch = + DateTime.now().millisecondsSinceEpoch ~/ 1000; + const int mimblewimblecoinFirstBlock = 1573462801; + const double overestimateSecondsPerBlock = 61; + final int chosenSeconds = secondsSinceEpoch - mimblewimblecoinFirstBlock; + final int approximateHeight = + chosenSeconds ~/ overestimateSecondsPerBlock; + height = approximateHeight; + if (height < 0) { + height = 0; + } + otherDataJsonString = jsonEncode({ + WalletInfoKeys.mimblewimblecoinData: jsonEncode( + ExtraMimblewimblecoinWalletInfo( + receivingIndex: 0, + changeIndex: 0, + slatesToAddresses: {}, + slatesToCommits: {}, + lastScannedBlock: height, + restoreHeight: height, + creationHeight: height, + ).toMap(), + ), + }); + } - if (mounted) setState(() => _hideSeedWords = false); + // TODO: do actual check to make sure it is a valid mnemonic for monero + xelis + if (bip39.validateMnemonic(mnemonic) == false && + !(widget.coin is Monero || + widget.coin is Wownero || + widget.coin is Salvium || + widget.coin is Xelis)) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid seed phrase!", + context: context, + ), + ); + } else { + // Hide the words only once the phrase has cleared every check; a + // rejected one has to stay readable so the user can correct it. + if (mounted) setState(() => _hideSeedWords = true); - await ref - .read(pWallets) - .deleteWallet(info, ref.read(secureStoreProvider)); - }, - ); - }, - ), - ); - } + if (!Platform.isLinux) await WakelockPlus.enable(); - var node = ref - .read(nodeServiceChangeNotifierProvider) - .getPrimaryNodeFor(currency: widget.coin); + final info = WalletInfo.createNew( + coin: widget.coin, + name: widget.walletName, + restoreHeight: height, + otherDataJsonString: otherDataJsonString, + ); - if (node == null) { - node = widget.coin.defaultNode(isPrimary: true); - await ref - .read(nodeServiceChangeNotifierProvider) - .save(node, null, false); - } + bool isRestoring = true; + // show restoring in progress - final txTracker = TransactionNotificationTracker( - walletId: info.walletId, + if (mounted) { + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return RestoringDialog( + onCancel: () async { + isRestoring = false; + + if (mounted) setState(() => _hideSeedWords = false); + + await ref + .read(pWallets) + .deleteWallet(info, ref.read(secureStoreProvider)); + }, + ); + }, + ), ); + } - try { - final wallet = await Wallet.create( - walletInfo: info, - mainDB: ref.read(mainDBProvider), - secureStorageInterface: ref.read(secureStoreProvider), - nodeService: ref.read(nodeServiceChangeNotifierProvider), - prefs: ref.read(prefsChangeNotifierProvider), - mnemonicPassphrase: widget.mnemonicPassphrase, - mnemonic: mnemonic, - ); + var node = ref + .read(nodeServiceChangeNotifierProvider) + .getPrimaryNodeFor(currency: widget.coin); - // TODO: extract interface with isRestore param - switch (wallet) { - case EpiccashWallet(): - await wallet.init(isRestore: true); - break; + if (node == null) { + node = widget.coin.defaultNode(isPrimary: true); + await ref + .read(nodeServiceChangeNotifierProvider) + .save(node, null, false); + } - case MimblewimblecoinWallet(): - await wallet.init(isRestore: true); - break; + final txTracker = TransactionNotificationTracker(walletId: info.walletId); + + try { + final wallet = await Wallet.create( + walletInfo: info, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + mnemonicPassphrase: widget.mnemonicPassphrase, + mnemonic: mnemonic, + ); - case CryptonoteWallet(): - await wallet.init(isRestore: true); - break; + // TODO: extract interface with isRestore param + switch (wallet) { + case EpiccashWallet(): + await wallet.init(isRestore: true); + break; - case XelisWallet(): - await wallet.init(isRestore: true); - break; + case MimblewimblecoinWallet(): + await wallet.init(isRestore: true); + break; - default: - await wallet.init(); - } - await wallet.recover(isRescan: false); + case CryptonoteWallet(): + await wallet.init(isRestore: true); + break; - if (wallet is ExternalWallet) { - await wallet.exit(); - } + case XelisWallet(): + await wallet.init(isRestore: true); + break; - // check if state is still active before continuing - if (mounted) { - await wallet.info.setMnemonicVerified( - isar: ref.read(mainDBProvider).isar, - ); + default: + await wallet.init(); + } + await wallet.recover(isRescan: false); - if (ref.read(pDuress)) { - await wallet.info.updateDuressVisibilityStatus( - isDuressVisible: true, - isar: ref.read(mainDBProvider).isar, - ); - } + if (wallet is ExternalWallet) { + await wallet.exit(); + } - ref.read(pWallets).addWallet(wallet); + // check if state is still active before continuing + if (mounted) { + await wallet.info.setMnemonicVerified( + isar: ref.read(mainDBProvider).isar, + ); - final isCreateSpecialEthWallet = ref.read( - createSpecialEthWalletRoutingFlag, + if (ref.read(pDuress)) { + await wallet.info.updateDuressVisibilityStatus( + isDuressVisible: true, + isar: ref.read(mainDBProvider).isar, ); - if (isCreateSpecialEthWallet) { - ref.read(createSpecialEthWalletRoutingFlag.notifier).state = - false; - ref - .read(newEthWalletTriggerTempUntilHiveCompletelyDeleted.state) - .state = !ref - .read(newEthWalletTriggerTempUntilHiveCompletelyDeleted.state) - .state; - } + } - if (mounted) { - if (isDesktop) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(DesktopHomeView.routeName)); + ref.read(pWallets).addWallet(wallet); + + final isCreateSpecialEthWallet = ref.read( + createSpecialEthWalletRoutingFlag, + ); + if (isCreateSpecialEthWallet) { + ref.read(createSpecialEthWalletRoutingFlag.notifier).state = false; + ref + .read(newEthWalletTriggerTempUntilHiveCompletelyDeleted.state) + .state = !ref + .read(newEthWalletTriggerTempUntilHiveCompletelyDeleted.state) + .state; + } + + if (mounted) { + if (isDesktop) { + Navigator.of( + context, + ).popUntil(ModalRoute.withName(DesktopHomeView.routeName)); + } else { + if (isCreateSpecialEthWallet) { + Navigator.of(context).popUntil( + ModalRoute.withName(SelectWalletForTokenView.routeName), + ); } else { - if (isCreateSpecialEthWallet) { - Navigator.of(context).popUntil( - ModalRoute.withName(SelectWalletForTokenView.routeName), - ); - } else { + unawaited( + Navigator.of(context).pushNamedAndRemoveUntil( + HomeView.routeName, + (route) => false, + ), + ); + if (info.coin is Ethereum || info.coin is Solana) { unawaited( - Navigator.of(context).pushNamedAndRemoveUntil( - HomeView.routeName, - (route) => false, + Navigator.of(context).pushNamed( + EditWalletTokensView.routeName, + arguments: wallet.walletId, ), ); - if (info.coin is Ethereum || info.coin is Solana) { - unawaited( - Navigator.of(context).pushNamed( - EditWalletTokensView.routeName, - arguments: wallet.walletId, - ), - ); - } } } - - await showDialog( - context: context, - useSafeArea: false, - barrierDismissible: true, - builder: (context) { - return const RestoreSucceededDialog(); - }, - ); } - if (!Platform.isLinux && !isDesktop) { - await WakelockPlus.disable(); - } - } - } catch (e) { - if (!Platform.isLinux && !isDesktop) { - await WakelockPlus.disable(); - } - - // if (e is HiveError && - // e.message == "Box has already been closed.") { - // // restore was cancelled - // return; - // } - - // check if state is still active and restore wasn't cancelled - // before continuing - if (mounted && isRestoring) { - // pop waiting dialog - Navigator.pop(context); - - // show restoring wallet failed dialog await showDialog( context: context, useSafeArea: false, barrierDismissible: true, builder: (context) { - return RestoreFailedDialog( - errorMessage: e.toString(), - walletId: info.walletId, - walletName: info.name, - ); + return const RestoreSucceededDialog(); }, ); + } - if (mounted) setState(() => _hideSeedWords = false); + if (!Platform.isLinux && !isDesktop) { + await WakelockPlus.disable(); } } - + } catch (e) { if (!Platform.isLinux && !isDesktop) { await WakelockPlus.disable(); } + + // if (e is HiveError && + // e.message == "Box has already been closed.") { + // // restore was cancelled + // return; + // } + + // check if state is still active and restore wasn't cancelled + // before continuing + if (mounted && isRestoring) { + // pop waiting dialog + Navigator.pop(context); + + // show restoring wallet failed dialog + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return RestoreFailedDialog( + errorMessage: e.toString(), + walletId: info.walletId, + walletName: info.name, + ); + }, + ); + + if (mounted) setState(() => _hideSeedWords = false); + } + } + + if (!Platform.isLinux && !isDesktop) { + await WakelockPlus.disable(); } } } @@ -660,7 +742,10 @@ class _RestoreWalletViewState extends ConsumerState { if (data?.text != null && data!.text!.isNotEmpty) { final content = data.text!.trim(); - final list = content.split(" "); + final list = _splitMnemonic(content); + if (list.isEmpty) { + return; + } _clearAndPopulateMnemonic(list); } } @@ -845,283 +930,262 @@ class _RestoreWalletViewState extends ConsumerState { return Column( children: [ - Form( - key: _formKey, - child: TableView( - shrinkWrap: true, - rowSpacing: 20, - rows: [ - for (int i = 0; i < rows; i++) - TableViewRow( - crossAxisAlignment: - CrossAxisAlignment.start, - spacing: 16, - cells: [ - for (int j = 1; j <= cols; j++) - TableViewCell( - flex: 1, - child: Column( - children: [ - TextFormField( - enableIMEPersonalizedLearning: - false, - obscureText: _hideSeedWords, - autocorrect: !isDesktop, - enableSuggestions: !isDesktop, - textCapitalization: - TextCapitalization.none, - key: Key( - "restoreMnemonicFormField_$i", - ), - decoration: - _getInputDecorationFor( - _inputStatuses[i * 4 + - j - - 1], - "${i * 4 + j}", - ), - autovalidateMode: - AutovalidateMode - .onUserInteraction, - selectionControls: - i * 4 + j - 1 == 1 - ? textSelectionControls - : null, - // focusNode: - // _focusNodes[i * 4 + j - 1], - onChanged: (value) { - final FormInputStatus - formInputStatus; - - if (value.isEmpty) { - formInputStatus = - FormInputStatus.empty; - } else if (_isValidMnemonicWord( - value - .trim() - .toLowerCase(), - )) { - formInputStatus = - FormInputStatus.valid; - } else { - formInputStatus = - FormInputStatus - .invalid; - } - - // if (formInputStatus == - // FormInputStatus.valid) { - // if (i * 4 + j < - // _focusNodes.length) { - // _focusNodes[i * 4 + j] - // .requestFocus(); - // } else if (i * 4 + j == - // _focusNodes.length) { - // _focusNodes[i * 4 + j - 1] - // .unfocus(); - // } - // } - setState(() { + TableView( + shrinkWrap: true, + rowSpacing: 20, + rows: [ + for (int i = 0; i < rows; i++) + TableViewRow( + crossAxisAlignment: + CrossAxisAlignment.start, + spacing: 16, + cells: [ + for (int j = 1; j <= cols; j++) + TableViewCell( + flex: 1, + child: Column( + children: [ + TextFormField( + enableIMEPersonalizedLearning: + false, + obscureText: _hideSeedWords, + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + textCapitalization: + TextCapitalization.none, + key: Key( + "restoreMnemonicFormField_$i", + ), + decoration: + _getInputDecorationFor( _inputStatuses[i * 4 + - j - - 1] = - formInputStatus; - }); - }, - controller: - _controllers[i * 4 + j - 1], - style: - STextStyles.field( - context, - ).copyWith( - color: Theme.of(context) - .extension< - StackColors - >()! - .textRestore, - fontSize: isDesktop - ? 16 - : 14, - ), - ), - if (_inputStatuses[i * 4 + - j - - 1] == - FormInputStatus.invalid) - Align( - alignment: - Alignment.topLeft, - child: Padding( - padding: - const EdgeInsets.only( - left: 12.0, - bottom: 4.0, + "${i * 4 + j}", + ), + selectionControls: + i * 4 + j - 1 == 1 + ? textSelectionControls + : null, + // focusNode: + // _focusNodes[i * 4 + j - 1], + onChanged: (value) { + final FormInputStatus + formInputStatus; + + if (value.isEmpty) { + formInputStatus = + FormInputStatus.empty; + } else if (_isValidMnemonicWord( + value.trim().toLowerCase(), + )) { + formInputStatus = + FormInputStatus.valid; + } else { + formInputStatus = + FormInputStatus.invalid; + } + + // if (formInputStatus == + // FormInputStatus.valid) { + // if (i * 4 + j < + // _focusNodes.length) { + // _focusNodes[i * 4 + j] + // .requestFocus(); + // } else if (i * 4 + j == + // _focusNodes.length) { + // _focusNodes[i * 4 + j - 1] + // .unfocus(); + // } + // } + setState(() { + _inputStatuses[i * 4 + + j - + 1] = + formInputStatus; + }); + }, + controller: + _controllers[i * 4 + j - 1], + style: + STextStyles.field( + context, + ).copyWith( + color: Theme.of(context) + .extension< + StackColors + >()! + .textRestore, + fontSize: isDesktop + ? 16 + : 14, + ), + ), + if (_inputStatuses[i * 4 + + j - + 1] == + FormInputStatus.invalid) + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: + const EdgeInsets.only( + left: 12.0, + bottom: 4.0, + ), + child: Text( + "Please check spelling", + textAlign: TextAlign.left, + style: + STextStyles.label( + context, + ).copyWith( + color: + Theme.of( + context, + ) + .extension< + StackColors + >()! + .textError, ), - child: Text( - "Please check spelling", - textAlign: - TextAlign.left, - style: - STextStyles.label( - context, - ).copyWith( - color: - Theme.of( - context, - ) - .extension< - StackColors - >()! - .textError, - ), - ), ), ), - ], - ), + ), + ], ), - ], - expandingChild: null, - ), - if (remainder > 0) - TableViewRow( - spacing: 16, - cells: [ - for ( - int i = rows * cols; - i < _seedWordCount - remainder; - i++ - ) ...[ - const TableViewCell( - flex: 1, - child: Column( - // ... (existing code for input field) - ), + ), + ], + expandingChild: null, + ), + if (remainder > 0) + TableViewRow( + spacing: 16, + cells: [ + for ( + int i = rows * cols; + i < _seedWordCount - remainder; + i++ + ) ...[ + const TableViewCell( + flex: 1, + child: Column( + // ... (existing code for input field) ), - ], - for ( - int i = _seedWordCount - remainder; - i < _seedWordCount; - i++ - ) ...[ - TableViewCell( - flex: 1, - child: Column( - children: [ - TextFormField( - enableIMEPersonalizedLearning: - false, - obscureText: _hideSeedWords, - autocorrect: !isDesktop, - enableSuggestions: !isDesktop, - textCapitalization: - TextCapitalization.none, - key: Key( - "restoreMnemonicFormField_$i", - ), - decoration: - _getInputDecorationFor( - _inputStatuses[i], - "${i + 1}", - ), - autovalidateMode: - AutovalidateMode - .onUserInteraction, - selectionControls: i == 1 - ? textSelectionControls - : null, - onChanged: (value) { - final FormInputStatus - formInputStatus; - - if (value.isEmpty) { - formInputStatus = - FormInputStatus.empty; - } else if (_isValidMnemonicWord( - value - .trim() - .toLowerCase(), - )) { - formInputStatus = - FormInputStatus.valid; - } else { - formInputStatus = - FormInputStatus - .invalid; - } - - setState(() { - _inputStatuses[i] = - formInputStatus; - }); - }, - controller: _controllers[i], - style: - STextStyles.field( - context, - ).copyWith( - color: Theme.of(context) - .extension< - StackColors - >()! - .overlay, - fontSize: isDesktop - ? 16 - : 14, - ), + ), + ], + for ( + int i = _seedWordCount - remainder; + i < _seedWordCount; + i++ + ) ...[ + TableViewCell( + flex: 1, + child: Column( + children: [ + TextFormField( + enableIMEPersonalizedLearning: + false, + obscureText: _hideSeedWords, + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + textCapitalization: + TextCapitalization.none, + key: Key( + "restoreMnemonicFormField_$i", ), - if (_inputStatuses[i] == - FormInputStatus.invalid) - Align( - alignment: - Alignment.topLeft, - child: Padding( - padding: - const EdgeInsets.only( - left: 12.0, - bottom: 4.0, + decoration: + _getInputDecorationFor( + _inputStatuses[i], + "${i + 1}", + ), + selectionControls: i == 1 + ? textSelectionControls + : null, + onChanged: (value) { + final FormInputStatus + formInputStatus; + + if (value.isEmpty) { + formInputStatus = + FormInputStatus.empty; + } else if (_isValidMnemonicWord( + value.trim().toLowerCase(), + )) { + formInputStatus = + FormInputStatus.valid; + } else { + formInputStatus = + FormInputStatus.invalid; + } + + setState(() { + _inputStatuses[i] = + formInputStatus; + }); + }, + controller: _controllers[i], + style: + STextStyles.field( + context, + ).copyWith( + color: Theme.of(context) + .extension< + StackColors + >()! + .overlay, + fontSize: isDesktop + ? 16 + : 14, + ), + ), + if (_inputStatuses[i] == + FormInputStatus.invalid) + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: + const EdgeInsets.only( + left: 12.0, + bottom: 4.0, + ), + child: Text( + "Please check spelling", + textAlign: TextAlign.left, + style: + STextStyles.label( + context, + ).copyWith( + color: + Theme.of( + context, + ) + .extension< + StackColors + >()! + .textError, ), - child: Text( - "Please check spelling", - textAlign: - TextAlign.left, - style: - STextStyles.label( - context, - ).copyWith( - color: - Theme.of( - context, - ) - .extension< - StackColors - >()! - .textError, - ), - ), ), ), - ], - ), - ), - ], - for ( - int i = 0; - i < cols - remainder; - i++ - ) ...[ - TableViewCell( - flex: 1, - child: Container(), + ), + ], ), - ], + ), ], - expandingChild: null, - ), - ], - ), + for ( + int i = 0; + i < cols - remainder; + i++ + ) ...[ + TableViewCell( + flex: 1, + child: Container(), + ), + ], + ], + expandingChild: null, + ), + ], ), const SizedBox(height: 32), PrimaryButton( @@ -1141,102 +1205,96 @@ class _RestoreWalletViewState extends ConsumerState { if (!isDesktop) Padding( padding: const EdgeInsets.all(4.0), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (int i = 1; i <= _seedWordCount; i++) - Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - vertical: 4, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (int i = 1; i <= _seedWordCount; i++) + Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4, + ), + child: TextFormField( + enableIMEPersonalizedLearning: false, + obscureText: _hideSeedWords, + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + textCapitalization: TextCapitalization.none, + key: Key("restoreMnemonicFormField_$i"), + decoration: _getInputDecorationFor( + _inputStatuses[i - 1], + "$i", ), - child: TextFormField( - enableIMEPersonalizedLearning: false, - obscureText: _hideSeedWords, - autocorrect: !isDesktop, - enableSuggestions: !isDesktop, - textCapitalization: TextCapitalization.none, - key: Key("restoreMnemonicFormField_$i"), - decoration: _getInputDecorationFor( - _inputStatuses[i - 1], - "$i", - ), - autovalidateMode: - AutovalidateMode.onUserInteraction, - selectionControls: i == 1 - ? textSelectionControls - : null, - // focusNode: _focusNodes[i - 1], - onChanged: (value) { - final FormInputStatus formInputStatus; - - if (value.isEmpty) { - formInputStatus = FormInputStatus.empty; - } else if (_isValidMnemonicWord( - value.trim().toLowerCase(), - )) { - formInputStatus = FormInputStatus.valid; - } else { - formInputStatus = - FormInputStatus.invalid; - } - - // if (formInputStatus == - // FormInputStatus.valid) { - // if (i < _focusNodes.length) { - // _focusNodes[i].requestFocus(); - // } else if (i == _focusNodes.length) { - // _focusNodes[i - 1].unfocus(); - // } - // } - setState(() { - _inputStatuses[i - 1] = formInputStatus; - }); - }, - controller: _controllers[i - 1], - style: STextStyles.field(context).copyWith( - color: Theme.of( - context, - ).extension()!.textRestore, - fontSize: isDesktop ? 16 : 14, - ), + selectionControls: i == 1 + ? textSelectionControls + : null, + // focusNode: _focusNodes[i - 1], + onChanged: (value) { + final FormInputStatus formInputStatus; + + if (value.isEmpty) { + formInputStatus = FormInputStatus.empty; + } else if (_isValidMnemonicWord( + value.trim().toLowerCase(), + )) { + formInputStatus = FormInputStatus.valid; + } else { + formInputStatus = FormInputStatus.invalid; + } + + // if (formInputStatus == + // FormInputStatus.valid) { + // if (i < _focusNodes.length) { + // _focusNodes[i].requestFocus(); + // } else if (i == _focusNodes.length) { + // _focusNodes[i - 1].unfocus(); + // } + // } + setState(() { + _inputStatuses[i - 1] = formInputStatus; + }); + }, + controller: _controllers[i - 1], + style: STextStyles.field(context).copyWith( + color: Theme.of( + context, + ).extension()!.textRestore, + fontSize: isDesktop ? 16 : 14, ), ), - if (_inputStatuses[i - 1] == - FormInputStatus.invalid) - Align( - alignment: Alignment.topLeft, - child: Padding( - padding: const EdgeInsets.only( - left: 12.0, - bottom: 4.0, - ), - child: Text( - "Please check spelling", - textAlign: TextAlign.left, - style: STextStyles.label(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textError, - ), - ), + ), + if (_inputStatuses[i - 1] == + FormInputStatus.invalid) + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 12.0, + bottom: 4.0, + ), + child: Text( + "Please check spelling", + textAlign: TextAlign.left, + style: STextStyles.label(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textError, + ), ), ), - ], - ), - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: PrimaryButton( - onPressed: requestRestore, - label: "Restore", - ), + ), + ], ), - ], - ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: PrimaryButton( + onPressed: requestRestore, + label: "Restore", + ), + ), + ], ), ), ], diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index 6c0c49884e..5648561751 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -1577,7 +1577,7 @@ abstract class LibMoneroWallet height: height, ); - if (this.wallet == null) { + if (this.wallet != null) { await exit(); } this.wallet = wallet; diff --git a/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart b/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart index 5ebd2191a3..eb9dc1096c 100644 --- a/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart @@ -1555,7 +1555,7 @@ abstract class LibWowneroWallet height: height, ); - if (this.wallet == null) { + if (this.wallet != null) { await exit(); } this.wallet = wallet; diff --git a/test/pages/add_wallet_views/restore_wallet_view/restore_wallet_view_test.dart b/test/pages/add_wallet_views/restore_wallet_view/restore_wallet_view_test.dart new file mode 100644 index 0000000000..821ac8d939 --- /dev/null +++ b/test/pages/add_wallet_views/restore_wallet_view/restore_wallet_view_test.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/add_wallet_views/restore_wallet_view/confirm_recovery_dialog.dart'; +import 'package:stackwallet/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart'; +import 'package:stackwallet/pages/add_wallet_views/restore_wallet_view/sub_widgets/restore_failed_dialog.dart'; +import 'package:stackwallet/pages/add_wallet_views/restore_wallet_view/sub_widgets/restoring_dialog.dart'; +import 'package:stackwallet/providers/global/node_service_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/clipboard_interface.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/wl_gen/interfaces/cs_monero_interface.dart'; + +import '../../../sample_data/theme_json.dart'; +import '../../../screen_tests/onboarding/restore_wallet_view_screen_test.mocks.dart'; + +const _moneroMnemonic = + "agreed aquarium wallets uptight karate wonders afoot guys itself " + "nucleus reduce lamb fully fewest bimonthly dazed skulls magically " + "mocked fugitive imbalance saga calamity dialect itself"; + +void main() { + testWidgets("mnemonic errors use the custom field validation", ( + tester, + ) async { + final stackColors = await _pumpRestoreView( + tester, + coin: Bitcoin(CryptoCurrencyNetwork.main), + seedWordsLength: 12, + ); + + final firstWordField = find.byType(TextFormField).first; + await tester.enterText(firstWordField, "notaword"); + await tester.pump(); + + expect(find.text("Invalid word"), findsNothing); + expect(find.text("Please check spelling"), findsOneWidget); + final errorText = tester.widget(find.text("Please check spelling")); + expect(errorText.style?.color, stackColors.textError); + + await tester.enterText(firstWordField, "abandon"); + await tester.pump(); + expect(find.text("Please check spelling"), findsNothing); + + await tester.tap(find.widgetWithText(PrimaryButton, "Restore wallet")); + await tester.pump(const Duration(milliseconds: 101)); + await tester.pumpAndSettle(); + + expect(find.byType(ConfirmRecoveryDialog), findsOneWidget); + await tester.tap(find.widgetWithText(PrimaryButton, "Restore")); + await tester.pump(); + + expect( + find.text("Expected 12 words but got 1. Please fill in all fields."), + findsOneWidget, + ); + }); + + testWidgets("legacy Monero words and multiline paste are accepted", ( + tester, + ) async { + final words = _moneroMnemonic.split(" ")..first = "agr"; + final clipboard = FakeClipboard(); + await clipboard.setData( + ClipboardData( + text: + "${words.take(12).join(" ")}\n\t" + "${words.skip(12).join(" ")}", + ), + ); + + await _pumpRestoreView( + tester, + coin: Monero(CryptoCurrencyNetwork.main), + seedWordsLength: 25, + clipboard: clipboard, + ); + + await tester.tap(find.widgetWithText(TextButton, "Paste")); + await tester.pump(); + + final fields = tester + .widgetList(find.byType(TextFormField)) + .toList(growable: false); + expect(fields, hasLength(25)); + expect(fields.map((field) => field.controller!.text), orderedEquals(words)); + expect(find.text("Please check spelling"), findsNothing); + + await tester.enterText(find.byType(TextFormField).first, "zzzz"); + await tester.pump(); + expect(find.text("Please check spelling"), findsOneWidget); + }); + + testWidgets("restore revalidates every mnemonic field", (tester) async { + final clipboard = FakeClipboard(); + await clipboard.setData( + const ClipboardData( + text: + "abandon abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon about", + ), + ); + await _pumpRestoreView( + tester, + coin: Bitcoin(CryptoCurrencyNetwork.main), + seedWordsLength: 12, + clipboard: clipboard, + ); + await tester.tap(find.widgetWithText(TextButton, "Paste")); + await tester.pump(); + expect(find.text("Please check spelling"), findsNothing); + + final firstField = tester.widget( + find.byType(TextFormField).first, + ); + firstField.controller!.text = "zzzz"; + + await tester.tap(find.widgetWithText(PrimaryButton, "Restore wallet")); + await tester.pump(const Duration(milliseconds: 101)); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(PrimaryButton, "Restore")); + await tester.pump(); + + expect(find.byType(RestoringDialog), findsNothing); + expect(find.text("Please check spelling"), findsOneWidget); + expect( + tester + .widget( + find.descendant( + of: find.byType(TextFormField).first, + matching: find.byType(EditableText), + ), + ) + .obscureText, + isFalse, + ); + }); + + testWidgets("legacy words from any CryptoNote language are accepted", ( + tester, + ) async { + // Spanish members of the Monero wordlist, none of which is an English word + // or shares an English word's three letter prefix. + const mnemonic = + "abeja aldea arpa atar barco brecha capucha chico conejo cuento " + "derrota escala fase fobia gozar hebra huir leyenda lujo marea " + "minero nuera odio ombligo pez"; + final english = csMonero.getMoneroWordList("English"); + final englishPrefixes = english.map((word) => word.substring(0, 3)).toSet(); + for (final word in mnemonic.split(" ")) { + expect(csMonero.getMoneroWordList("Spanish"), contains(word)); + expect(english, isNot(contains(word))); + expect(englishPrefixes, isNot(contains(word.substring(0, 3)))); + } + + final clipboard = FakeClipboard(); + await clipboard.setData(const ClipboardData(text: mnemonic)); + await _pumpRestoreView( + tester, + coin: Monero(CryptoCurrencyNetwork.main), + seedWordsLength: 25, + clipboard: clipboard, + ); + + await tester.tap(find.widgetWithText(TextButton, "Paste")); + await tester.pump(); + expect(find.text("Please check spelling"), findsNothing); + + await tester.tap(find.widgetWithText(PrimaryButton, "Restore wallet")); + await tester.pump(const Duration(milliseconds: 101)); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(PrimaryButton, "Restore")); + await tester.pump(); + + expect(find.text("Please check spelling"), findsNothing); + // Validation passed and the restore started; what fails is Wallet.create, + // which cannot run against a widget test's storage. + expect(find.byType(RestoreFailedDialog), findsOneWidget); + }); + + testWidgets("an unknown legacy word still stops wallet creation", ( + tester, + ) async { + final words = _moneroMnemonic.split(" ")..[12] = "zzz"; + final clipboard = FakeClipboard(); + await clipboard.setData(ClipboardData(text: words.join(" "))); + await _pumpRestoreView( + tester, + coin: Monero(CryptoCurrencyNetwork.main), + seedWordsLength: 25, + clipboard: clipboard, + ); + + await tester.tap(find.widgetWithText(TextButton, "Paste")); + await tester.pump(); + await tester.tap(find.widgetWithText(PrimaryButton, "Restore wallet")); + await tester.pump(const Duration(milliseconds: 101)); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(PrimaryButton, "Restore")); + await tester.pump(); + + expect(find.text("Please check spelling"), findsOneWidget); + expect(find.byType(RestoringDialog), findsNothing); + expect(find.byType(RestoreFailedDialog), findsNothing); + }); + + testWidgets("a rejected seed phrase stays readable", (tester) async { + // Every word is in the BIP39 list but the checksum does not match. + final clipboard = FakeClipboard(); + await clipboard.setData( + ClipboardData(text: List.filled(12, "abandon").join(" ")), + ); + await _pumpRestoreView( + tester, + coin: Bitcoin(CryptoCurrencyNetwork.main), + seedWordsLength: 12, + clipboard: clipboard, + ); + + await tester.tap(find.widgetWithText(TextButton, "Paste")); + await tester.pump(); + await tester.tap(find.widgetWithText(PrimaryButton, "Restore wallet")); + await tester.pump(const Duration(milliseconds: 101)); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(PrimaryButton, "Restore")); + await tester.pump(); + + expect(find.text("Invalid seed phrase!"), findsOneWidget); + expect(find.byType(RestoringDialog), findsNothing); + expect(find.byType(RestoreFailedDialog), findsNothing); + for (var i = 0; i < 12; i++) { + expect( + tester + .widget( + find.descendant( + of: find.byType(TextFormField).at(i), + matching: find.byType(EditableText), + ), + ) + .obscureText, + isFalse, + reason: "field $i must stay readable so the user can correct it", + ); + } + }); + + testWidgets("a whitespace only paste leaves the fields untouched", ( + tester, + ) async { + final clipboard = FakeClipboard(); + await clipboard.setData(const ClipboardData(text: " \n\t ")); + await _pumpRestoreView( + tester, + coin: Bitcoin(CryptoCurrencyNetwork.main), + seedWordsLength: 12, + clipboard: clipboard, + ); + + await tester.tap(find.widgetWithText(TextButton, "Paste")); + await tester.pump(); + + final first = tester.widget( + find.byType(TextFormField).first, + ); + expect(first.controller!.text, isEmpty); + expect(find.text("Please check spelling"), findsNothing); + }); +} + +Future _pumpRestoreView( + WidgetTester tester, { + required CryptoCurrency coin, + required int seedWordsLength, + ClipboardInterface clipboard = const ClipboardWrapper(), +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1400, 1000); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + final nodeService = MockNodeService(); + when( + nodeService.getPrimaryNodeFor(currency: anyNamed("currency")), + ).thenReturn(null); + + final stackTheme = StackTheme.fromJson(json: lightThemeJsonMap); + final stackColors = StackColors.fromStackColorTheme(stackTheme); + await tester.pumpWidget( + ProviderScope( + overrides: [ + themeProvider.overrideWithValue(StateController(stackTheme)), + nodeServiceChangeNotifierProvider.overrideWithValue(nodeService), + ], + child: MaterialApp( + theme: ThemeData(extensions: [stackColors]), + home: RestoreWalletView( + walletName: "Test wallet", + coin: coin, + seedWordsLength: seedWordsLength, + mnemonicPassphrase: "", + restoreBlockHeight: 0, + clipboard: clipboard, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return stackColors; +}