diff --git a/miner-app/lib/features/miner/miner_app_bar.dart b/miner-app/lib/features/miner/miner_app_bar.dart index e8f9bbc2c..880e7c639 100644 --- a/miner-app/lib/features/miner/miner_app_bar.dart +++ b/miner-app/lib/features/miner/miner_app_bar.dart @@ -2,10 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:quantus_miner/features/settings/settings_screen.dart'; +import 'package:quantus_miner/main.dart'; import 'package:quantus_miner/src/services/miner_settings_service.dart'; +import 'package:quantus_miner/src/services/miner_wallet_service.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; -enum _MenuValues { logout, setting } +enum _MenuValues { walletLogout, logout, setting } class MinerAppBar extends StatefulWidget { const MinerAppBar({super.key}); @@ -16,6 +18,39 @@ class MinerAppBar extends StatefulWidget { class _MinerAppBarState extends State { final _minerSettingsService = MinerSettingsService(); + final _walletService = MinerWalletService(); + + Future _performWalletLogout() async { + final confirmed = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Logout Wallet?'), + content: const Text( + 'This will wipe your secret phrase and inner hash from this app so you can enter a different one. Your node and node identity are kept. Mining will be stopped.\n\nContinue?', + ), + actions: [ + TextButton(child: const Text('Cancel'), onPressed: () => Navigator.of(context).pop(false)), + TextButton( + child: const Text('Logout Wallet', style: TextStyle(color: Colors.orange)), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ); + }, + ); + + if (confirmed != true) return; + + final orchestrator = GlobalMinerManager.getOrchestrator(); + if (orchestrator?.isRunning == true) { + await orchestrator!.stop(); + } + await _walletService.deleteWalletData(); + if (mounted) { + context.go('/rewards_address_setup'); + } + } Future _performLogout() async { final confirmed = await showDialog( @@ -107,6 +142,9 @@ class _MinerAppBarState extends State { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), onSelected: (_MenuValues item) async { switch (item) { + case _MenuValues.walletLogout: + await _performWalletLogout(); + break; case _MenuValues.logout: await _performLogout(); break; @@ -117,18 +155,28 @@ class _MinerAppBarState extends State { }, itemBuilder: (BuildContext context) => >[ PopupMenuItem<_MenuValues>( - value: _MenuValues.logout, + value: _MenuValues.walletLogout, child: Row( children: [ - Icon(Icons.logout, color: Colors.red.useOpacity(0.8), size: 20), + Icon(Icons.key_off, color: Colors.orange.useOpacity(0.9), size: 20), const SizedBox(width: 12), Text( - 'Logout (Full Reset)', + 'Logout Wallet', style: TextStyle(color: Colors.white.useOpacity(0.9), fontSize: 14), ), ], ), ), + PopupMenuItem<_MenuValues>( + value: _MenuValues.logout, + child: Row( + children: [ + Icon(Icons.logout, color: Colors.red.useOpacity(0.8), size: 20), + const SizedBox(width: 12), + Text('Reset App', style: TextStyle(color: Colors.white.useOpacity(0.9), fontSize: 14)), + ], + ), + ), PopupMenuItem<_MenuValues>( value: _MenuValues.setting, child: Row( diff --git a/miner-app/lib/features/setup/rewards_address_setup_screen.dart b/miner-app/lib/features/setup/rewards_address_setup_screen.dart index d7866c649..81c85ac3e 100644 --- a/miner-app/lib/features/setup/rewards_address_setup_screen.dart +++ b/miner-app/lib/features/setup/rewards_address_setup_screen.dart @@ -125,12 +125,22 @@ class _RewardsAddressSetupScreenState extends State { return; } - // Normalize: collapse any whitespace/newlines to single spaces final mnemonic = words.join(' '); + final invalidWords = _walletService.findInvalidMnemonicWords(mnemonic); + if (invalidWords.isNotEmpty) { + final formatted = invalidWords.map((w) => '"${w.word}" (word ${w.position})').join(', '); + setState(() { + _importError = 'Not in the BIP-39 wordlist: $formatted. Check for typos.'; + }); + return; + } + if (!_walletService.validateMnemonic(mnemonic)) { setState(() { - _importError = 'Invalid recovery phrase. Please check your words.'; + _importError = + 'Recovery phrase checksum is invalid. All words are valid, ' + 'but they may be in the wrong order or one word is misspelled into another valid word.'; }); return; } @@ -466,8 +476,8 @@ class _RewardsAddressSetupScreenState extends State { const SizedBox(height: 8), Text( _importMode == _ImportMode.mnemonic - ? 'Enter your 24-word recovery phrase to restore your wallet.' - : 'Enter your inner hash (hex format) from the CLI or another source.', + ? 'Enter your 24-word recovery phrase to restore your wallet. Required to mine AND redeem rewards.' + : 'Enter your inner hash to direct mining rewards to the correct address. Redeeming is NOT possible with only the inner hash.', textAlign: TextAlign.center, style: const TextStyle(fontSize: 14, color: Colors.grey), ), @@ -540,7 +550,7 @@ class _RewardsAddressSetupScreenState extends State { const SizedBox(width: 8), Expanded( child: Text( - 'Without the recovery phrase, you cannot withdraw rewards from this app. Use this option only if you plan to withdraw using the CLI.', + 'Mining will work and rewards will go to the correct address, but you will NOT be able to redeem them from this app — redeeming requires the secret phrase.', style: TextStyle(fontSize: 13, color: Colors.amber.shade200), ), ), @@ -608,7 +618,7 @@ class _RewardsAddressSetupScreenState extends State { const SizedBox(width: 12), Expanded( child: Text( - 'Without your recovery phrase, you cannot withdraw rewards from this app. Make sure you have access to your secret via the CLI or another tool.', + 'Mining will work and rewards will go to the correct address, but you cannot redeem them from this app. Redeeming requires the secret phrase — log out the wallet and re-enter the phrase when you are ready to redeem.', style: TextStyle(fontSize: 14, color: Colors.amber.shade200), ), ), diff --git a/miner-app/lib/src/services/miner_wallet_service.dart b/miner-app/lib/src/services/miner_wallet_service.dart index 800cc7dbe..94436eea9 100644 --- a/miner-app/lib/src/services/miner_wallet_service.dart +++ b/miner-app/lib/src/services/miner_wallet_service.dart @@ -40,9 +40,13 @@ class MinerWalletService { return Mnemonic(entropy, Language.english).sentence; } + /// Normalize user-entered mnemonic input: lowercase + collapse whitespace. + /// The BIP-39 English wordlist is lowercase only. + String normalizeMnemonic(String mnemonic) => mnemonic.trim().toLowerCase().split(RegExp(r'\s+')).join(' '); + bool validateMnemonic(String mnemonic) { try { - Mnemonic.fromSentence(mnemonic.trim(), Language.english); + Mnemonic.fromSentence(normalizeMnemonic(mnemonic), Language.english); return true; } catch (e) { _log.w('Invalid mnemonic: $e'); @@ -50,17 +54,31 @@ class MinerWalletService { } } + /// Returns words that are not in the BIP-39 English wordlist, with their + /// 1-based positions. Useful for telling the user which word(s) are typos. + List<({int position, String word})> findInvalidMnemonicWords(String mnemonic) { + final words = normalizeMnemonic(mnemonic).split(' '); + final invalid = <({int position, String word})>[]; + for (var i = 0; i < words.length; i++) { + if (words[i].isEmpty) continue; + if (!Language.english.isValid(words[i])) { + invalid.add((position: i + 1, word: words[i])); + } + } + return invalid; + } + /// Save the mnemonic via SDK secure storage and persist the rewards preimage. Future saveMnemonic(String mnemonic) async { if (!validateMnemonic(mnemonic)) { throw ArgumentError('Invalid mnemonic phrase'); } - final trimmed = mnemonic.trim(); - await _settings.setMnemonic(trimmed, _minerWalletIndex); + final normalized = normalizeMnemonic(mnemonic); + await _settings.setMnemonic(normalized, _minerWalletIndex); _log.i('Mnemonic saved securely via SDK settings'); - final keyPair = _hdWallet.deriveWormholeKeyPair(mnemonic: trimmed); + final keyPair = _hdWallet.deriveWormholeKeyPair(mnemonic: normalized); await _saveRewardsPreimage(keyPair.rewardsPreimage); _log.i('Wormhole address derived: ${keyPair.address}'); diff --git a/miner-app/pubspec.yaml b/miner-app/pubspec.yaml index e887a4de1..30a7e00ec 100644 --- a/miner-app/pubspec.yaml +++ b/miner-app/pubspec.yaml @@ -1,6 +1,6 @@ name: quantus_miner description: Quantus PoW miner (desktop / mobile) -version: 0.4.4 +version: 0.4.5 publish_to: none environment: diff --git a/miner-app/test/miner_wallet_service_test.dart b/miner-app/test/miner_wallet_service_test.dart new file mode 100644 index 000000000..79fa759df --- /dev/null +++ b/miner-app/test/miner_wallet_service_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_miner/src/services/miner_wallet_service.dart'; + +void main() { + final wallet = MinerWalletService(); + + // A valid 24-word BIP-39 mnemonic. Do NOT use for real wallets. + const validMnemonic = + 'situate more drip void arrest just action prepare engine undo honey delay ' + 'sponsor come achieve symptom crumble solution glass garden fury valid garbage old'; + + group('findInvalidMnemonicWords', () { + test('returns empty list for a fully valid mnemonic', () { + expect(wallet.findInvalidMnemonicWords(validMnemonic), isEmpty); + }); + + test('returns empty list for a valid mnemonic with extra whitespace', () { + final messy = ' ${validMnemonic.replaceAll(' ', ' ')}\n'; + expect(wallet.findInvalidMnemonicWords(messy), isEmpty); + }); + + test('flags a single typo with its 1-based position', () { + final words = validMnemonic.split(' '); + words[2] = 'driip'; + final result = wallet.findInvalidMnemonicWords(words.join(' ')); + + expect(result, hasLength(1)); + expect(result.first.word, 'driip'); + expect(result.first.position, 3); + }); + + test('flags multiple invalid words preserving order and positions', () { + final words = validMnemonic.split(' '); + words[0] = 'foobar'; + words[5] = 'qwerty'; + words[23] = 'notaword'; + final result = wallet.findInvalidMnemonicWords(words.join(' ')); + + expect(result.map((w) => w.word).toList(), ['foobar', 'qwerty', 'notaword']); + expect(result.map((w) => w.position).toList(), [1, 6, 24]); + }); + + test('does NOT flag words that are valid but in the wrong order', () { + final reversed = validMnemonic.split(' ').reversed.join(' '); + // All words still in the BIP-39 wordlist, so the wordlist check passes. + // The checksum would fail, but that is validateMnemonic's job. + expect(wallet.findInvalidMnemonicWords(reversed), isEmpty); + expect(wallet.validateMnemonic(reversed), isFalse); + }); + + test('accepts uppercase / mixed-case input (normalized to lowercase)', () { + expect(wallet.findInvalidMnemonicWords(validMnemonic.toUpperCase()), isEmpty); + expect(wallet.validateMnemonic(validMnemonic.toUpperCase()), isTrue); + + final words = validMnemonic.split(' '); + words[0] = 'Situate'; + words[1] = 'MORE'; + expect(wallet.findInvalidMnemonicWords(words.join(' ')), isEmpty); + expect(wallet.validateMnemonic(words.join(' ')), isTrue); + }); + + test('reports invalid words in their normalized (lowercase) form', () { + final words = validMnemonic.split(' '); + words[0] = 'FOOBAR'; + final result = wallet.findInvalidMnemonicWords(words.join(' ')); + expect(result, hasLength(1)); + expect(result.first.word, 'foobar'); + expect(result.first.position, 1); + }); + + test('returns empty list for an empty string', () { + expect(wallet.findInvalidMnemonicWords(''), isEmpty); + expect(wallet.findInvalidMnemonicWords(' '), isEmpty); + }); + + test('handles a 12-word mnemonic', () { + const valid12 = 'human snow truck virus now jaguar wall brisk shoe craft gravity diesel'; + expect(wallet.findInvalidMnemonicWords(valid12), isEmpty); + + final words = valid12.split(' '); + words[4] = 'nowww'; + final result = wallet.findInvalidMnemonicWords(words.join(' ')); + expect(result, hasLength(1)); + expect(result.first.word, 'nowww'); + expect(result.first.position, 5); + }); + }); +}