From 5783163ac8e5638e578eb4404314427bac2c77d4 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sun, 17 May 2026 12:40:27 +0800 Subject: [PATCH 1/4] miner improvements logout without losing all data tell user that inner hash won't have redeem function --- .../lib/features/miner/miner_app_bar.dart | 53 ++++++++++++++++++- .../setup/rewards_address_setup_screen.dart | 8 +-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/miner-app/lib/features/miner/miner_app_bar.dart b/miner-app/lib/features/miner/miner_app_bar.dart index e8f9bbc2..19ce1f18 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; @@ -116,6 +154,19 @@ class _MinerAppBarState extends State { } }, itemBuilder: (BuildContext context) => >[ + PopupMenuItem<_MenuValues>( + value: _MenuValues.walletLogout, + child: Row( + children: [ + Icon(Icons.key_off, color: Colors.orange.useOpacity(0.9), size: 20), + const SizedBox(width: 12), + Text( + 'Logout Wallet', + style: TextStyle(color: Colors.white.useOpacity(0.9), fontSize: 14), + ), + ], + ), + ), PopupMenuItem<_MenuValues>( value: _MenuValues.logout, 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 d7866c64..499686a0 100644 --- a/miner-app/lib/features/setup/rewards_address_setup_screen.dart +++ b/miner-app/lib/features/setup/rewards_address_setup_screen.dart @@ -466,8 +466,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 +540,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 +608,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), ), ), From 7dfcfa9a0832bec28ac2f4c31471e4dffcaa3998 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sun, 17 May 2026 12:51:52 +0800 Subject: [PATCH 2/4] mnemonics handling updated --- .../lib/features/miner/miner_app_bar.dart | 2 +- .../setup/rewards_address_setup_screen.dart | 13 ++- .../src/services/miner_wallet_service.dart | 27 +++++- miner-app/test/miner_wallet_service_test.dart | 88 +++++++++++++++++++ 4 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 miner-app/test/miner_wallet_service_test.dart diff --git a/miner-app/lib/features/miner/miner_app_bar.dart b/miner-app/lib/features/miner/miner_app_bar.dart index 19ce1f18..f550ff78 100644 --- a/miner-app/lib/features/miner/miner_app_bar.dart +++ b/miner-app/lib/features/miner/miner_app_bar.dart @@ -174,7 +174,7 @@ class _MinerAppBarState extends State { Icon(Icons.logout, color: Colors.red.useOpacity(0.8), size: 20), const SizedBox(width: 12), Text( - 'Logout (Full Reset)', + 'Reset App', style: TextStyle(color: Colors.white.useOpacity(0.9), fontSize: 14), ), ], 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 499686a0..07a2ffc0 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,21 @@ 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; } diff --git a/miner-app/lib/src/services/miner_wallet_service.dart b/miner-app/lib/src/services/miner_wallet_service.dart index 800cc7db..f076bde4 100644 --- a/miner-app/lib/src/services/miner_wallet_service.dart +++ b/miner-app/lib/src/services/miner_wallet_service.dart @@ -40,9 +40,14 @@ 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 +55,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/test/miner_wallet_service_test.dart b/miner-app/test/miner_wallet_service_test.dart new file mode 100644 index 00000000..79fa759d --- /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); + }); + }); +} From 2a837ba14904b0cd8092701ee2e6f59897d49c12 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sun, 17 May 2026 12:52:00 +0800 Subject: [PATCH 3/4] format --- miner-app/lib/features/miner/miner_app_bar.dart | 5 +---- .../lib/features/setup/rewards_address_setup_screen.dart | 3 ++- miner-app/lib/src/services/miner_wallet_service.dart | 3 +-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/miner-app/lib/features/miner/miner_app_bar.dart b/miner-app/lib/features/miner/miner_app_bar.dart index f550ff78..880e7c63 100644 --- a/miner-app/lib/features/miner/miner_app_bar.dart +++ b/miner-app/lib/features/miner/miner_app_bar.dart @@ -173,10 +173,7 @@ class _MinerAppBarState extends State { 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), - ), + Text('Reset App', style: TextStyle(color: Colors.white.useOpacity(0.9), fontSize: 14)), ], ), ), 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 07a2ffc0..81c85ac3 100644 --- a/miner-app/lib/features/setup/rewards_address_setup_screen.dart +++ b/miner-app/lib/features/setup/rewards_address_setup_screen.dart @@ -138,7 +138,8 @@ class _RewardsAddressSetupScreenState extends State { if (!_walletService.validateMnemonic(mnemonic)) { setState(() { - _importError = 'Recovery phrase checksum is invalid. All words are valid, ' + _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; diff --git a/miner-app/lib/src/services/miner_wallet_service.dart b/miner-app/lib/src/services/miner_wallet_service.dart index f076bde4..94436eea 100644 --- a/miner-app/lib/src/services/miner_wallet_service.dart +++ b/miner-app/lib/src/services/miner_wallet_service.dart @@ -42,8 +42,7 @@ class MinerWalletService { /// 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(' '); + String normalizeMnemonic(String mnemonic) => mnemonic.trim().toLowerCase().split(RegExp(r'\s+')).join(' '); bool validateMnemonic(String mnemonic) { try { From d37767e1e9cac6bcdae70dce404c6d36456b49b6 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sun, 17 May 2026 12:56:59 +0800 Subject: [PATCH 4/4] version 0.4.5 --- miner-app/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/miner-app/pubspec.yaml b/miner-app/pubspec.yaml index e887a4de..30a7e00e 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: