Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions miner-app/lib/features/miner/miner_app_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand All @@ -16,6 +18,39 @@ class MinerAppBar extends StatefulWidget {

class _MinerAppBarState extends State<MinerAppBar> {
final _minerSettingsService = MinerSettingsService();
final _walletService = MinerWalletService();

Future<void> _performWalletLogout() async {
final confirmed = await showDialog<bool>(
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: <Widget>[
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<void> _performLogout() async {
final confirmed = await showDialog<bool>(
Expand Down Expand Up @@ -107,6 +142,9 @@ class _MinerAppBarState extends State<MinerAppBar> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
onSelected: (_MenuValues item) async {
switch (item) {
case _MenuValues.walletLogout:
await _performWalletLogout();
break;
case _MenuValues.logout:
await _performLogout();
break;
Expand All @@ -117,18 +155,28 @@ class _MinerAppBarState extends State<MinerAppBar> {
},
itemBuilder: (BuildContext context) => <PopupMenuEntry<_MenuValues>>[
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(
Expand Down
22 changes: 16 additions & 6 deletions miner-app/lib/features/setup/rewards_address_setup_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,22 @@ class _RewardsAddressSetupScreenState extends State<RewardsAddressSetupScreen> {
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;
}
Expand Down Expand Up @@ -466,8 +476,8 @@ class _RewardsAddressSetupScreenState extends State<RewardsAddressSetupScreen> {
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),
),
Expand Down Expand Up @@ -540,7 +550,7 @@ class _RewardsAddressSetupScreenState extends State<RewardsAddressSetupScreen> {
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),
),
),
Expand Down Expand Up @@ -608,7 +618,7 @@ class _RewardsAddressSetupScreenState extends State<RewardsAddressSetupScreen> {
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),
),
),
Expand Down
26 changes: 22 additions & 4 deletions miner-app/lib/src/services/miner_wallet_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,27 +40,45 @@ 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');
return false;
}
}

/// 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<WormholeKeyPair> 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}');
Expand Down
2 changes: 1 addition & 1 deletion miner-app/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
88 changes: 88 additions & 0 deletions miner-app/test/miner_wallet_service_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading