From b869a6f64483f36824e76b01bb55dc6f257b5f20 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 2 Mar 2026 11:19:56 -0600 Subject: [PATCH 1/2] fix: add mnemonic validation and fix view-only restore for Monero-family coins closes #1167 --- .../restore_wallet_view.dart | 88 ++++++++++++++++++- .../intermediate/lib_monero_wallet.dart | 2 +- .../intermediate/lib_wownero_wallet.dart | 2 +- 3 files changed, 89 insertions(+), 3 deletions(-) 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 a1ea19c40..23d88656e 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 @@ -175,6 +175,28 @@ class _RestoreWalletViewState extends ConsumerState { super.dispose(); } + // The number of leading characters CryptoNote (Monero/Wownero/Salvium) + // mnemonics use when matching words. Words are compared by this unique + // prefix rather than by their full spelling, so valid seeds may contain + // truncations or inflections that are not verbatim wordlist entries. + // English and the other supported languages use a prefix length of 3. + static const int _cryptonotePrefixLength = 3; + + String _cryptonotePrefix(String word) => + word.length <= _cryptonotePrefixLength + ? word + : word.substring(0, _cryptonotePrefixLength); + + bool _isValidCryptonoteWord(String word, List wordList) { + // Fast path: exact match. + if (wordList.contains(word)) { + return true; + } + // CryptoNote matches words by their unique prefix, not the full word. + final prefix = _cryptonotePrefix(word); + return wordList.any((w) => _cryptonotePrefix(w) == prefix); + } + // TODO: check for wownero wordlist? bool _isValidMnemonicWord(String word) { // TODO: get the actual language @@ -182,8 +204,13 @@ class _RestoreWalletViewState extends ConsumerState { // Salvium use's Monero's wordlists. switch (widget.seedWordsLength) { case 25: - return csMonero.getMoneroWordList("English").contains(word); + return _isValidCryptonoteWord( + word, + csMonero.getMoneroWordList("English"), + ); case 16: + // The 16 word seed is a BIP39 style (Polyseed) wordlist whose words + // must match exactly, so the CryptoNote prefix rule does not apply. return Monero.sixteenWordsWordList.contains(word); default: return false; @@ -194,6 +221,11 @@ class _RestoreWalletViewState extends ConsumerState { "English", widget.seedWordsLength, ); + // Only the 25 word seed uses the CryptoNote prefix wordlist. The 14 word + // seed is a BIP39 style (Polyseed) wordlist requiring an exact match. + if (widget.seedWordsLength == 25) { + return _isValidCryptonoteWord(word, wowneroWordList); + } return wowneroWordList.contains(word); } if (widget.coin is Xelis) { @@ -219,6 +251,24 @@ class _RestoreWalletViewState extends ConsumerState { } mnemonic = mnemonic.trim(); + // Verify word count matches expected seed length. + final wordCount = mnemonic.split(" ").length; + if (wordCount != _seedWordCount) { + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "Expected $_seedWordCount words but got $wordCount. " + "Please fill in all fields.", + context: context, + ), + ); + setState(() => _hideSeedWords = false); + } + return; + } + int height = widget.restoreBlockHeight; String? otherDataJsonString; @@ -887,6 +937,18 @@ class _RestoreWalletViewState extends ConsumerState { i * 4 + j - 1 == 1 ? textSelectionControls : null, + validator: (value) { + if (value == null || + value.trim().isEmpty) { + return "Required"; + } + if (!_isValidMnemonicWord( + value.trim().toLowerCase(), + )) { + return "Invalid word"; + } + return null; + }, // focusNode: // _focusNodes[i * 4 + j - 1], onChanged: (value) { @@ -1032,6 +1094,18 @@ class _RestoreWalletViewState extends ConsumerState { selectionControls: i == 1 ? textSelectionControls : null, + validator: (value) { + if (value == null || + value.trim().isEmpty) { + return "Required"; + } + if (!_isValidMnemonicWord( + value.trim().toLowerCase(), + )) { + return "Invalid word"; + } + return null; + }, onChanged: (value) { final FormInputStatus formInputStatus; @@ -1169,6 +1243,18 @@ class _RestoreWalletViewState extends ConsumerState { selectionControls: i == 1 ? textSelectionControls : null, + validator: (value) { + if (value == null || + value.trim().isEmpty) { + return "Required"; + } + if (!_isValidMnemonicWord( + value.trim().toLowerCase(), + )) { + return "Invalid word"; + } + return null; + }, // focusNode: _focusNodes[i - 1], onChanged: (value) { final FormInputStatus formInputStatus; diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index 6c0c49884..564856175 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 5ebd2191a..eb9dc1096 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; From 4dc034469a61c4d7a35024bb6bc75a2ce8b9f0da Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 11:36:31 -0500 Subject: [PATCH 2/2] chore: dart format --- .../restore_wallet_view/restore_wallet_view.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 23d88656e..077693270 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 @@ -943,7 +943,9 @@ class _RestoreWalletViewState extends ConsumerState { return "Required"; } if (!_isValidMnemonicWord( - value.trim().toLowerCase(), + value + .trim() + .toLowerCase(), )) { return "Invalid word"; } @@ -1100,7 +1102,9 @@ class _RestoreWalletViewState extends ConsumerState { return "Required"; } if (!_isValidMnemonicWord( - value.trim().toLowerCase(), + value + .trim() + .toLowerCase(), )) { return "Invalid word"; }