From 0668eb9016ca364b652e3e34de8cc90a514633da Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sun, 14 Dec 2025 16:53:31 +0800 Subject: [PATCH 01/54] add wallet index --- .../features/main/screens/create_account_screen.dart | 2 +- .../screens/create_wallet_and_backup_screen.dart | 6 +++--- .../features/main/screens/import_wallet_screen.dart | 8 ++++---- .../main/screens/show_recovery_phrase_screen.dart | 6 ++++-- .../test/widget/send_screen_widget_test.mocks.dart | 4 ++-- quantus_sdk/lib/src/constants/app_constants.dart | 1 - .../lib/src/extensions/account_extension.dart | 2 +- quantus_sdk/lib/src/models/account.dart | 11 ++++++----- .../lib/src/services/account_discovery_service.dart | 4 +++- quantus_sdk/lib/src/services/accounts_service.dart | 5 +++-- quantus_sdk/lib/src/services/migration_service.dart | 10 ++++++---- quantus_sdk/lib/src/services/settings_service.dart | 12 +++++++----- quantus_sdk/lib/src/services/taskmaster_service.dart | 4 ++-- quantus_sdk/test/services/settings_service_test.dart | 6 +++--- 14 files changed, 45 insertions(+), 36 deletions(-) diff --git a/mobile-app/lib/features/main/screens/create_account_screen.dart b/mobile-app/lib/features/main/screens/create_account_screen.dart index 6453247e..4179af72 100644 --- a/mobile-app/lib/features/main/screens/create_account_screen.dart +++ b/mobile-app/lib/features/main/screens/create_account_screen.dart @@ -76,7 +76,7 @@ class _CreateAccountScreenState extends ConsumerState { _isLoading = true; }); try { - final account = await _accountsService.createNewAccount(); + final account = await _accountsService.createNewAccount(walletIndex: 0); final checkphrase = await _checksumService.getHumanReadableName(account.accountId); if (mounted) { diff --git a/mobile-app/lib/features/main/screens/create_wallet_and_backup_screen.dart b/mobile-app/lib/features/main/screens/create_wallet_and_backup_screen.dart index 92d590ad..18183d9b 100644 --- a/mobile-app/lib/features/main/screens/create_wallet_and_backup_screen.dart +++ b/mobile-app/lib/features/main/screens/create_wallet_and_backup_screen.dart @@ -100,14 +100,14 @@ class CreateWalletAndBackupScreenState extends ConsumerState[]; // Extract data or empty list if (accounts.isEmpty) { - await _accountsService.addAccount(Account(index: 0, name: _accountName.value.text, accountId: _address)); + await _accountsService.addAccount(Account(walletIndex: walletIndex, index: 0, name: _accountName.value.text, accountId: _address)); await _referralService.submitAddressToBackend(); } ref.invalidate(accountsProvider); diff --git a/mobile-app/lib/features/main/screens/import_wallet_screen.dart b/mobile-app/lib/features/main/screens/import_wallet_screen.dart index 67145d3c..6fe955c3 100644 --- a/mobile-app/lib/features/main/screens/import_wallet_screen.dart +++ b/mobile-app/lib/features/main/screens/import_wallet_screen.dart @@ -59,7 +59,7 @@ class ImportWalletScreenState extends ConsumerState { } } - Future _importWallet() async { + Future _importWallet({required int walletIndex }) async { setState(() { _isLoading = true; _errorMessage = ''; @@ -81,8 +81,8 @@ class ImportWalletScreenState extends ConsumerState { } final key = HdWalletService().keyPairAtIndex(mnemonic, 0); - await _settingsService.setMnemonic(mnemonic); - await _accountsService.addAccount(Account(index: 0, name: 'Account 1', accountId: key.ss58Address)); + await _settingsService.setMnemonic(mnemonic, walletIndex); + await _accountsService.addAccount(Account(walletIndex: walletIndex, index: 0, name: 'Account 1', accountId: key.ss58Address)); await _discoverAccounts(mnemonic); // We set check status to true so we will not prompt user to input refferal code. @@ -190,7 +190,7 @@ class ImportWalletScreenState extends ConsumerState { Button( variant: ButtonVariant.primary, label: 'Import Wallet', - onPressed: _importWallet, + onPressed: () => _importWallet(walletIndex: 0), isLoading: _isLoading, ), SizedBox(height: context.themeSize.bottomButtonSpacing), diff --git a/mobile-app/lib/features/main/screens/show_recovery_phrase_screen.dart b/mobile-app/lib/features/main/screens/show_recovery_phrase_screen.dart index ef3c6c63..04e41b7e 100644 --- a/mobile-app/lib/features/main/screens/show_recovery_phrase_screen.dart +++ b/mobile-app/lib/features/main/screens/show_recovery_phrase_screen.dart @@ -12,7 +12,9 @@ import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions. import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; class ShowRecoveryPhraseScreen extends StatefulWidget { - const ShowRecoveryPhraseScreen({super.key}); + const ShowRecoveryPhraseScreen({super.key, this.walletIndex = 0}); + + final int walletIndex; @override State createState() => _ShowRecoveryPhraseScreenState(); @@ -30,7 +32,7 @@ class _ShowRecoveryPhraseScreenState extends State { } Future _loadMnemonic() async { - final mnemonic = await _settingsService.getMnemonic(); + final mnemonic = await _settingsService.getMnemonic(widget.walletIndex); if (mnemonic != null) { setState(() { _recoveryPhrase = mnemonic.split(' '); diff --git a/mobile-app/test/widget/send_screen_widget_test.mocks.dart b/mobile-app/test/widget/send_screen_widget_test.mocks.dart index 48f41cc2..c534fa15 100644 --- a/mobile-app/test/widget/send_screen_widget_test.mocks.dart +++ b/mobile-app/test/widget/send_screen_widget_test.mocks.dart @@ -169,7 +169,7 @@ class MockSettingsService extends _i1.Mock implements _i2.SettingsService { as _i3.Future); @override - _i3.Future setMnemonic(String? mnemonic) => + _i3.Future setMnemonic(String? mnemonic, int? mnemonicIndex) => (super.noSuchMethod( Invocation.method(#setMnemonic, [mnemonic]), returnValue: _i3.Future.value(), @@ -178,7 +178,7 @@ class MockSettingsService extends _i1.Mock implements _i2.SettingsService { as _i3.Future); @override - _i3.Future getMnemonic() => + _i3.Future getMnemonic(int? mnemonicIndex) => (super.noSuchMethod(Invocation.method(#getMnemonic, []), returnValue: _i3.Future.value()) as _i3.Future); diff --git a/quantus_sdk/lib/src/constants/app_constants.dart b/quantus_sdk/lib/src/constants/app_constants.dart index f938ae93..fedb6db2 100644 --- a/quantus_sdk/lib/src/constants/app_constants.dart +++ b/quantus_sdk/lib/src/constants/app_constants.dart @@ -40,7 +40,6 @@ class AppConstants { // Shared Preferences keys static const String hasWalletKey = 'has_wallet'; - static const String mnemonicKey = 'mnemonic'; static const String accountIdKey = 'account_id'; // Reversible time settings diff --git a/quantus_sdk/lib/src/extensions/account_extension.dart b/quantus_sdk/lib/src/extensions/account_extension.dart index 4a4fc633..b743ab4a 100644 --- a/quantus_sdk/lib/src/extensions/account_extension.dart +++ b/quantus_sdk/lib/src/extensions/account_extension.dart @@ -7,6 +7,6 @@ extension HDWalletAccount on Account { } Future getMnemonic() async { - return SettingsService().getMnemonic(); + return SettingsService().getMnemonic(walletIndex); } } diff --git a/quantus_sdk/lib/src/models/account.dart b/quantus_sdk/lib/src/models/account.dart index 6a894d23..b8ac8b33 100644 --- a/quantus_sdk/lib/src/models/account.dart +++ b/quantus_sdk/lib/src/models/account.dart @@ -2,20 +2,21 @@ import 'package:flutter/foundation.dart'; @immutable class Account { + final int walletIndex; final int index; // derivation index final String name; final String accountId; // address - const Account({required this.index, required this.name, required this.accountId}); + const Account({required this.walletIndex, required this.index, required this.name, required this.accountId}); factory Account.fromJson(Map json) { - return Account(index: json['index'] as int, name: json['name'] as String, accountId: json['accountId'] as String); + return Account(walletIndex: (json['walletIndex'] ?? 0) as int, index: json['index'] as int, name: json['name'] as String, accountId: json['accountId'] as String); } Map toJson() { - return {'index': index, 'name': name, 'accountId': accountId}; + return {'walletIndex': walletIndex, 'index': index, 'name': name, 'accountId': accountId}; } - Account copyWith({int? index, String? name, String? accountId, int? uiPosition}) { - return Account(index: index ?? this.index, name: name ?? this.name, accountId: accountId ?? this.accountId); + Account copyWith({int? walletIndex, int? index, String? name, String? accountId, int? uiPosition}) { + return Account(walletIndex: walletIndex ?? this.walletIndex, index: index ?? this.index, name: name ?? this.name, accountId: accountId ?? this.accountId); } } diff --git a/quantus_sdk/lib/src/services/account_discovery_service.dart b/quantus_sdk/lib/src/services/account_discovery_service.dart index 7ffc43db..9be1f9d4 100644 --- a/quantus_sdk/lib/src/services/account_discovery_service.dart +++ b/quantus_sdk/lib/src/services/account_discovery_service.dart @@ -21,7 +21,9 @@ class AccountDiscoveryService { // Add raw account final rawKeyPair = _substrateService.nonHDdilithiumKeypairFromMnemonic(mnemonic); + final baseWalletIndex = 0; final rawAccount = Account( + walletIndex: baseWalletIndex, index: -1, // indicator for a raw account name: 'Primary Account', accountId: rawKeyPair.ss58Address, @@ -31,7 +33,7 @@ class AccountDiscoveryService { // Add HD accounts for (var i = 0; i < count; i++) { final keyPair = _hdWalletService.keyPairAtIndex(mnemonic, i); - final account = Account(index: i, name: 'Account ${i + 1}', accountId: keyPair.ss58Address); + final account = Account(walletIndex: baseWalletIndex, index: i, name: 'Account ${i + 1}', accountId: keyPair.ss58Address); allPossibleAccounts.add(account); } diff --git a/quantus_sdk/lib/src/services/accounts_service.dart b/quantus_sdk/lib/src/services/accounts_service.dart index ef0d05df..0d285a1f 100644 --- a/quantus_sdk/lib/src/services/accounts_service.dart +++ b/quantus_sdk/lib/src/services/accounts_service.dart @@ -18,14 +18,15 @@ class AccountsService { final SettingsService _settingsService = SettingsService(); void Function()? onAccountsChanged; - Future createNewAccount() async { - final mnemonic = await _settingsService.getMnemonic(); + Future createNewAccount({required int walletIndex}) async { + final mnemonic = await _settingsService.getMnemonic(walletIndex); if (mnemonic == null) { throw Exception('Mnemonic not found. Cannot create new account.'); } final nextIndex = await _settingsService.getNextFreeAccountIndex(); final keypair = HdWalletService().keyPairAtIndex(mnemonic, nextIndex); final newAccount = Account( + walletIndex: walletIndex, index: nextIndex, name: 'Account ${nextIndex + 1}', // Default name accountId: keypair.ss58Address, diff --git a/quantus_sdk/lib/src/services/migration_service.dart b/quantus_sdk/lib/src/services/migration_service.dart index 4ec5217b..b6627fee 100644 --- a/quantus_sdk/lib/src/services/migration_service.dart +++ b/quantus_sdk/lib/src/services/migration_service.dart @@ -9,6 +9,7 @@ import 'package:quantus_sdk/src/services/settings_service.dart'; class MigrationService { final SettingsService _settingsService; final HdWalletService _hdWalletService; + final int baseWalletIndex = 0; MigrationService(this._settingsService, this._hdWalletService); @@ -20,7 +21,7 @@ class MigrationService { /// Get migration data including old accounts with their public keys Future> getMigrationData() async { final oldAccounts = _settingsService.getOldAccounts(); - final mnemonic = await _settingsService.getMnemonic(); + final mnemonic = await _settingsService.getMnemonic(baseWalletIndex); if (mnemonic == null) { throw Exception('No mnemonic found for migration'); @@ -54,6 +55,7 @@ class MigrationService { ); final newAccount = Account( + walletIndex: baseWalletIndex, index: data.oldAccount.index, name: data.oldAccount.name, accountId: data.newAccountId, @@ -76,9 +78,9 @@ class MigrationService { /// Debug method to create test old accounts Future createDebugOldAccounts() async { final debugAccounts = [ - const Account(index: -1, name: 'Primary Account', accountId: 'qznd1YWbgQrviV76psu5n8d24mHSuHtAc9JmJLB42gTELksvQ'), - const Account(index: 0, name: 'Account 0', accountId: 'debug_id_0'), - const Account(index: 1, name: 'Account 1', accountId: 'debug_id_1'), + const Account(walletIndex: 0, index: -1, name: 'Primary Account', accountId: 'qznd1YWbgQrviV76psu5n8d24mHSuHtAc9JmJLB42gTELksvQ'), + const Account(walletIndex: 0, index: 0, name: 'Account 0', accountId: 'debug_id_0'), + const Account(walletIndex: 0, index: 1, name: 'Account 1', accountId: 'debug_id_1'), ]; final jsonData = jsonEncode(debugAccounts.map((a) => a.toJson()).toList()); diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index c35685a4..e633896b 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -52,7 +52,7 @@ class SettingsService { final oldAccountId = _prefs.getString('account_id'); if (oldAccountId != null) { final oldWalletName = _prefs.getString('wallet_name') ?? 'Account 1'; - final account = Account(index: 0, name: oldWalletName, accountId: oldAccountId); + final account = Account(walletIndex: 0, index: 0, name: oldWalletName, accountId: oldAccountId); await saveAccounts([account]); await setActiveAccount(account); // Clean up old keys after migration @@ -178,13 +178,15 @@ class SettingsService { return accounts.isEmpty; } + String getMnemonicKey(int mnemonicIndex) => mnemonicIndex == 0 ? 'mnemonic' : 'mnemonic_$mnemonicIndex'; + // Mnemonic Settings - Using secure storage - Future setMnemonic(String mnemonic) async { - await _secureStorage.write(key: 'mnemonic', value: mnemonic); + Future setMnemonic(String mnemonic, int mnemonicIndex) async { + await _secureStorage.write(key: getMnemonicKey(mnemonicIndex), value: mnemonic); } - Future getMnemonic() async { - return await _secureStorage.read(key: 'mnemonic'); + Future getMnemonic(int mnemonicIndex) async { + return await _secureStorage.read(key: getMnemonicKey(mnemonicIndex)); } // Reversible Time Settings diff --git a/quantus_sdk/lib/src/services/taskmaster_service.dart b/quantus_sdk/lib/src/services/taskmaster_service.dart index 3656b271..9b0f1922 100644 --- a/quantus_sdk/lib/src/services/taskmaster_service.dart +++ b/quantus_sdk/lib/src/services/taskmaster_service.dart @@ -165,7 +165,7 @@ class TaskmasterService { } Future getOldMiningAccountId() async { - final mnemonic = await _settingsService.getMnemonic(); + final mnemonic = await _settingsService.getMnemonic(0); if (mnemonic == null) { throw Exception('Mnemonic not found.'); } @@ -174,7 +174,7 @@ class TaskmasterService { } Future loginWithAccount1() async { - final mnemonic = await _settingsService.getMnemonic(); + final mnemonic = await _settingsService.getMnemonic(0); if (mnemonic == null) { throw Exception('Mnemonic not found.'); } diff --git a/quantus_sdk/test/services/settings_service_test.dart b/quantus_sdk/test/services/settings_service_test.dart index 77a039bd..258904c2 100644 --- a/quantus_sdk/test/services/settings_service_test.dart +++ b/quantus_sdk/test/services/settings_service_test.dart @@ -9,9 +9,9 @@ void main() { late SettingsService settingsService; // Accounts for testing - const account1 = Account(index: 0, name: 'Account 1', accountId: 'id_1'); - const account2 = Account(index: 1, name: 'Account 2', accountId: 'id_2'); - const account3 = Account(index: 2, name: 'Account 3', accountId: 'id_3'); + const account1 = Account(walletIndex: 0, index: 0, name: 'Account 1', accountId: 'id_1'); + const account2 = Account(walletIndex: 0, index: 1, name: 'Account 2', accountId: 'id_2'); + const account3 = Account(walletIndex: 0, index: 2, name: 'Account 3', accountId: 'id_3'); setUp(() async { // Reset mock storage BEFORE any SharedPreferences.getInstance() calls From aa207a8db1da3924d65058de12c1120d3445b295 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sun, 14 Dec 2025 16:57:40 +0800 Subject: [PATCH 02/54] add account type --- quantus_sdk/lib/src/models/account.dart | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/quantus_sdk/lib/src/models/account.dart b/quantus_sdk/lib/src/models/account.dart index b8ac8b33..70d9b3c7 100644 --- a/quantus_sdk/lib/src/models/account.dart +++ b/quantus_sdk/lib/src/models/account.dart @@ -1,19 +1,31 @@ import 'package:flutter/foundation.dart'; +enum AccountType { + local, + keystone, +} + @immutable class Account { final int walletIndex; final int index; // derivation index final String name; final String accountId; // address - const Account({required this.walletIndex, required this.index, required this.name, required this.accountId}); + final AccountType accountType; + const Account({required this.walletIndex, required this.index, required this.name, required this.accountId, this.accountType = AccountType.local}); factory Account.fromJson(Map json) { - return Account(walletIndex: (json['walletIndex'] ?? 0) as int, index: json['index'] as int, name: json['name'] as String, accountId: json['accountId'] as String); + return Account( + walletIndex: (json['walletIndex'] ?? 0) as int, + index: json['index'] as int, + name: json['name'] as String, + accountId: json['accountId'] as String, + accountType: AccountType.values.byName(json['accountType'] as String? ?? AccountType.local.name), + ); } Map toJson() { - return {'walletIndex': walletIndex, 'index': index, 'name': name, 'accountId': accountId}; + return {'walletIndex': walletIndex, 'index': index, 'name': name, 'accountId': accountId, 'accountType': accountType.name}; } Account copyWith({int? walletIndex, int? index, String? name, String? accountId, int? uiPosition}) { From dc021f014ba487b3bc8af77d156d57d8e377ecca Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 15 Dec 2025 12:59:38 +0800 Subject: [PATCH 03/54] UX for adding hardware --- .../components/select_action_sheet.dart | 2 +- .../main/screens/accounts_screen.dart | 202 ++++++++++++++++-- .../screens/add_hardware_account_screen.dart | 123 +++++++++++ .../main/screens/create_account_screen.dart | 5 +- .../create_wallet_and_backup_screen.dart | 19 +- .../main/screens/import_wallet_screen.dart | 16 +- .../main/screens/settings_screen.dart | 3 +- .../test/widget/send_screen_widget_test.dart | 2 +- .../widget/send_screen_widget_test.mocks.dart | 4 +- .../services/account_discovery_service.dart | 7 +- .../lib/src/services/accounts_service.dart | 2 +- .../lib/src/services/settings_service.dart | 64 ++++-- .../lib/src/services/taskmaster_service.dart | 3 +- .../test/services/settings_service_test.dart | 2 +- 14 files changed, 400 insertions(+), 54 deletions(-) create mode 100644 mobile-app/lib/features/main/screens/add_hardware_account_screen.dart diff --git a/mobile-app/lib/features/components/select_action_sheet.dart b/mobile-app/lib/features/components/select_action_sheet.dart index ad3efc66..c7ae9d83 100644 --- a/mobile-app/lib/features/components/select_action_sheet.dart +++ b/mobile-app/lib/features/components/select_action_sheet.dart @@ -28,8 +28,8 @@ class _SelectActionSheetState extends State> { children: widget.items.map((item) { return InkWell( onTap: () { - widget.onSelect(item); Navigator.pop(context); + Future.microtask(() => widget.onSelect(item)); }, child: Container( padding: const EdgeInsets.symmetric(vertical: 16), diff --git a/mobile-app/lib/features/main/screens/accounts_screen.dart b/mobile-app/lib/features/main/screens/accounts_screen.dart index cbcdd3fe..b6ac2d25 100644 --- a/mobile-app/lib/features/main/screens/accounts_screen.dart +++ b/mobile-app/lib/features/main/screens/accounts_screen.dart @@ -5,10 +5,15 @@ import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/account_gradient_image.dart'; import 'package:resonance_network_wallet/features/components/button.dart'; import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/features/components/select.dart'; +import 'package:resonance_network_wallet/features/components/select_action_sheet.dart'; import 'package:resonance_network_wallet/features/components/sphere.dart'; import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; import 'package:resonance_network_wallet/features/main/screens/account_settings_screen.dart'; +import 'package:resonance_network_wallet/features/main/screens/add_hardware_account_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/create_account_screen.dart'; +import 'package:resonance_network_wallet/features/main/screens/create_wallet_and_backup_screen.dart'; +import 'package:resonance_network_wallet/features/main/screens/import_wallet_screen.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; @@ -16,6 +21,8 @@ import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; +enum _WalletMoreAction { createWallet, importWallet, addHardwareWallet } + class AccountsScreen extends ConsumerStatefulWidget { const AccountsScreen({super.key}); @@ -28,13 +35,55 @@ class _AccountsScreenState extends ConsumerState { final NumberFormattingService _formattingService = NumberFormattingService(); bool _isCreatingAccount = false; + int? _selectedWalletIndex; + + bool _isHardwareWallet(List accounts) { + return accounts.isNotEmpty && accounts.every((a) => a.accountType == AccountType.keystone); + } + + int _nextWalletIndex(List accounts) { + if (accounts.isEmpty) return 0; + final maxIndex = accounts.map((a) => a.walletIndex).reduce((a, b) => a > b ? a : b); + return maxIndex + 1; + } + + Map> _groupByWallet(List accounts) { + final grouped = >{}; + for (final a in accounts) { + grouped.putIfAbsent(a.walletIndex, () => []).add(a); + } + for (final entry in grouped.entries) { + entry.value.sort((a, b) => a.index.compareTo(b.index)); + } + return Map.fromEntries(grouped.entries.toList()..sort((a, b) => a.key.compareTo(b.key))); + } + + String _walletLabel(int walletIndex, List accounts) { + if (_isHardwareWallet(accounts)) return 'Hardware Wallet'; + return 'Wallet ${walletIndex + 1}'; + } Future _createNewAccount() async { setState(() { _isCreatingAccount = true; }); try { - await Navigator.push(context, MaterialPageRoute(builder: (context) => const CreateAccountScreen())); + final accounts = ref.read(accountsProvider).value ?? []; + final selectedWallet = _selectedWalletIndex ?? (accounts.isNotEmpty ? accounts.first.walletIndex : 0); + final grouped = _groupByWallet(accounts); + final selectedWalletAccounts = grouped[selectedWallet] ?? const []; + + if (_isHardwareWallet(selectedWalletAccounts)) { + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => AddHardwareAccountScreen(walletIndex: selectedWallet)), + ); + } else { + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => CreateAccountScreen(walletIndex: selectedWallet)), + ); + } // Providers will automatically refresh when a new account is added } finally { if (mounted) { @@ -45,6 +94,48 @@ class _AccountsScreenState extends ConsumerState { } } + Future _openWalletMoreActions() async { + final accounts = ref.read(accountsProvider).value ?? []; + final nextWalletIndex = _nextWalletIndex(accounts); + + final items = [ + Item(value: _WalletMoreAction.createWallet, label: 'Create new wallet'), + Item(value: _WalletMoreAction.importWallet, label: 'Import wallet'), + Item(value: _WalletMoreAction.addHardwareWallet, label: 'Add hardware wallet'), + ]; + + showSelectActionSheet<_WalletMoreAction>( + context, + items, + (item) async { + final result = await (switch (item.value) { + _WalletMoreAction.createWallet => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CreateWalletAndBackupScreen(walletIndex: nextWalletIndex, popOnComplete: true), + ), + ), + _WalletMoreAction.importWallet => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ImportWalletScreen(walletIndex: nextWalletIndex, popOnComplete: true), + ), + ), + _WalletMoreAction.addHardwareWallet => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AddHardwareAccountScreen(walletIndex: nextWalletIndex, isNewWallet: true), + ), + ), + }); + if (result == true && mounted) { + ref.invalidate(accountsProvider); + ref.invalidate(activeAccountProvider); + } + }, + ); + } + @override Widget build(BuildContext context) { return ScaffoldBase( @@ -56,14 +147,23 @@ class _AccountsScreenState extends ConsumerState { ), const Positioned(left: -40, bottom: 0, child: Sphere(variant: 7, size: 240.681)), ], - appBar: WalletAppBar(title: 'Your Accounts'), + appBar: WalletAppBar( + title: 'Your Accounts', + actions: [ + IconButton( + onPressed: _openWalletMoreActions, + icon: const Icon(Icons.more_horiz), + ), + ], + ), child: Column( children: [ + _buildWalletSelector(), Expanded(child: _buildAccountsList()), Button( variant: ButtonVariant.glassOutline, - label: 'Create New Account', + label: _walletActionLabel(), onPressed: _isCreatingAccount ? null : _createNewAccount, ), @@ -73,6 +173,42 @@ class _AccountsScreenState extends ConsumerState { ); } + String _walletActionLabel() { + final accounts = ref.watch(accountsProvider).value ?? []; + final grouped = _groupByWallet(accounts); + final selectedWallet = _selectedWalletIndex ?? (accounts.isNotEmpty ? accounts.first.walletIndex : 0); + final selectedAccounts = grouped[selectedWallet] ?? const []; + return _isHardwareWallet(selectedAccounts) ? 'Add Hardware Account' : 'Add Account'; + } + + Widget _buildWalletSelector() { + final accounts = ref.watch(accountsProvider).value ?? []; + final grouped = _groupByWallet(accounts); + if (grouped.length <= 1) return const SizedBox(height: 0); + + final walletIndexes = grouped.keys.toList()..sort(); + final initialWallet = _selectedWalletIndex ?? walletIndexes.first; + + final items = walletIndexes + .map((ix) => Item(value: ix, label: _walletLabel(ix, grouped[ix] ?? const []))) + .toList(); + + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 10), + child: Align( + alignment: Alignment.centerLeft, + child: Select( + width: 220, + items: items, + initialValue: initialWallet, + onSelect: (item) { + setState(() => _selectedWalletIndex = item.value); + }, + ), + ), + ); + } + Widget _buildAccountsList() { final accountsAsync = ref.watch(accountsProvider); final activeAccountAsync = ref.watch(activeAccountProvider); @@ -98,16 +234,58 @@ class _AccountsScreenState extends ConsumerState { child: Text('Failed to load active account: $error', style: const TextStyle(color: Colors.white70)), ), data: (activeAccount) { - return ListView.separated( - padding: const EdgeInsets.symmetric(vertical: 16.0), - itemCount: accounts.length, - separatorBuilder: (context, index) => const SizedBox(height: 25), - itemBuilder: (context, index) { - final account = accounts[index]; + if (_selectedWalletIndex == null) { + final initial = activeAccount?.walletIndex ?? accounts.first.walletIndex; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _selectedWalletIndex == null) setState(() => _selectedWalletIndex = initial); + }); + } + + final grouped = _groupByWallet(accounts); + if (grouped.length <= 1) { + final walletAccounts = grouped.values.first; + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 16.0), + itemCount: walletAccounts.length, + separatorBuilder: (context, index) => const SizedBox(height: 25), + itemBuilder: (context, index) { + final account = walletAccounts[index]; + final bool isActive = account.accountId == activeAccount?.accountId; + return _buildAccountListItem(account, isActive, index); + }, + ); + } + + final selectedWallet = _selectedWalletIndex ?? grouped.keys.first; + final children = []; + var sectionIndex = 0; + for (final entry in grouped.entries) { + final walletIndex = entry.key; + final walletAccounts = entry.value; + + if (sectionIndex > 0) children.add(const SizedBox(height: 18)); + children.add( + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text( + _walletLabel(walletIndex, walletAccounts), + style: context.themeText.detail?.copyWith( + color: walletIndex == selectedWallet ? context.themeColors.textPrimary : context.themeColors.textMuted, + ), + ), + ), + ); + + for (var i = 0; i < walletAccounts.length; i++) { + if (i > 0) children.add(const SizedBox(height: 25)); + final account = walletAccounts[i]; final bool isActive = account.accountId == activeAccount?.accountId; - return _buildAccountListItem(account, isActive, index); - }, - ); + children.add(_buildAccountListItem(account, isActive, i)); + } + sectionIndex++; + } + + return ListView(padding: const EdgeInsets.symmetric(vertical: 16.0), children: children); }, ); }, diff --git a/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart b/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart new file mode 100644 index 00000000..07fc8713 --- /dev/null +++ b/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/button.dart'; +import 'package:resonance_network_wallet/features/components/custom_text_field.dart'; +import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; +import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; +import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; + +class AddHardwareAccountScreen extends ConsumerStatefulWidget { + const AddHardwareAccountScreen({super.key, required this.walletIndex, this.isNewWallet = false}); + + final int walletIndex; + final bool isNewWallet; + + @override + ConsumerState createState() => _AddHardwareAccountScreenState(); +} + +class _AddHardwareAccountScreenState extends ConsumerState { + final _name = TextEditingController(); + final _address = TextEditingController(); + + final _accountsService = AccountsService(); + final _settingsService = SettingsService(); + final _substrateService = SubstrateService(); + + bool _isSaving = false; + String? _error; + + Future _save() async { + final name = _name.text.trim(); + final address = _address.text.trim(); + + if (name.isEmpty) { + setState(() => _error = 'Name is required'); + return; + } + if (!_substrateService.isValidSS58Address(address)) { + setState(() => _error = 'Invalid address'); + return; + } + + setState(() { + _isSaving = true; + _error = null; + }); + + try { + final nextIndex = await _settingsService.getNextFreeAccountIndex(widget.walletIndex); + final account = Account( + walletIndex: widget.walletIndex, + index: nextIndex, + name: name, + accountId: address, + accountType: AccountType.keystone, + ); + await _accountsService.addAccount(account); + ref.invalidate(accountsProvider); + ref.invalidate(activeAccountProvider); + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + @override + void dispose() { + _name.dispose(); + _address.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final title = widget.isNewWallet ? 'Add Hardware Wallet' : 'Add Hardware Account'; + return ScaffoldBase( + appBar: WalletAppBar(title: title), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 20), + Text(title, style: context.themeText.smallTitle), + const SizedBox(height: 16), + CustomTextField( + controller: _name, + labelText: 'NAME', + hintText: widget.isNewWallet ? 'Hardware Wallet' : 'Account', + onChanged: (_) { + if (_error != null) setState(() => _error = null); + }, + ), + const SizedBox(height: 16), + CustomTextField( + controller: _address, + labelText: 'ADDRESS', + hintText: 'SS58 address', + onChanged: (_) { + if (_error != null) setState(() => _error = null); + }, + ), + if (_error != null) ...[ + const SizedBox(height: 10), + Text(_error!, style: context.themeText.tiny?.copyWith(color: Colors.red)), + ], + const Spacer(), + Button( + variant: ButtonVariant.primary, + label: widget.isNewWallet ? 'Add Hardware Wallet' : 'Add Hardware Account', + onPressed: _isSaving ? null : _save, + isLoading: _isSaving, + ), + SizedBox(height: context.themeSize.bottomButtonSpacing), + ], + ), + ); + } +} + diff --git a/mobile-app/lib/features/main/screens/create_account_screen.dart b/mobile-app/lib/features/main/screens/create_account_screen.dart index 4179af72..c6c12c8a 100644 --- a/mobile-app/lib/features/main/screens/create_account_screen.dart +++ b/mobile-app/lib/features/main/screens/create_account_screen.dart @@ -16,8 +16,9 @@ import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions. class CreateAccountScreen extends ConsumerStatefulWidget { final Account? accountToEdit; + final int walletIndex; - const CreateAccountScreen({super.key, this.accountToEdit}); + const CreateAccountScreen({super.key, this.accountToEdit, this.walletIndex = 0}); @override ConsumerState createState() => _CreateAccountScreenState(); @@ -76,7 +77,7 @@ class _CreateAccountScreenState extends ConsumerState { _isLoading = true; }); try { - final account = await _accountsService.createNewAccount(walletIndex: 0); + final account = await _accountsService.createNewAccount(walletIndex: widget.walletIndex); final checkphrase = await _checksumService.getHumanReadableName(account.accountId); if (mounted) { diff --git a/mobile-app/lib/features/main/screens/create_wallet_and_backup_screen.dart b/mobile-app/lib/features/main/screens/create_wallet_and_backup_screen.dart index 18183d9b..26371854 100644 --- a/mobile-app/lib/features/main/screens/create_wallet_and_backup_screen.dart +++ b/mobile-app/lib/features/main/screens/create_wallet_and_backup_screen.dart @@ -22,7 +22,10 @@ import 'package:resonance_network_wallet/services/telemetry_service.dart'; import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; class CreateWalletAndBackupScreen extends ConsumerStatefulWidget { - const CreateWalletAndBackupScreen({super.key}); + const CreateWalletAndBackupScreen({super.key, this.walletIndex = 0, this.popOnComplete = false}); + + final int walletIndex; + final bool popOnComplete; @override CreateWalletAndBackupScreenState createState() => CreateWalletAndBackupScreenState(); @@ -100,20 +103,26 @@ class CreateWalletAndBackupScreenState extends ConsumerState[]; // Extract data or empty list - if (accounts.isEmpty) { - await _accountsService.addAccount(Account(walletIndex: walletIndex, index: 0, name: _accountName.value.text, accountId: _address)); + final hasRootForWallet = accounts.any((a) => a.walletIndex == widget.walletIndex && a.index == 0); + if (!hasRootForWallet) { + await _accountsService.addAccount( + Account(walletIndex: widget.walletIndex, index: 0, name: _accountName.value.text, accountId: _address), + ); await _referralService.submitAddressToBackend(); } ref.invalidate(accountsProvider); ref.invalidate(activeAccountProvider); if (mounted) { + if (widget.popOnComplete) { + Navigator.of(context).pop(true); + return; + } Navigator.pushAndRemoveUntil( context, MaterialPageRoute( diff --git a/mobile-app/lib/features/main/screens/import_wallet_screen.dart b/mobile-app/lib/features/main/screens/import_wallet_screen.dart index 6fe955c3..50069320 100644 --- a/mobile-app/lib/features/main/screens/import_wallet_screen.dart +++ b/mobile-app/lib/features/main/screens/import_wallet_screen.dart @@ -12,7 +12,10 @@ import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; class ImportWalletScreen extends ConsumerStatefulWidget { - const ImportWalletScreen({super.key}); + const ImportWalletScreen({super.key, this.walletIndex = 0, this.popOnComplete = false}); + + final int walletIndex; + final bool popOnComplete; @override ImportWalletScreenState createState() => ImportWalletScreenState(); @@ -37,7 +40,10 @@ class ImportWalletScreenState extends ConsumerState { }); try { - final discoveredAccounts = await _accountDiscoveryService.discoverAccounts(mnemonic: mnemonic); + final discoveredAccounts = await _accountDiscoveryService.discoverAccounts( + mnemonic: mnemonic, + walletIndex: widget.walletIndex, + ); final existingAccountsSet = (await _accountsService.getAccounts()).map((e) => e.accountId).toSet(); @@ -94,6 +100,10 @@ class ImportWalletScreenState extends ConsumerState { _settingsService.setExistingUserSeenPromoVideo(); if (context.mounted && mounted) { + if (widget.popOnComplete) { + Navigator.of(context).pop(true); + return; + } Navigator.pushAndRemoveUntil( context, MaterialPageRoute( @@ -190,7 +200,7 @@ class ImportWalletScreenState extends ConsumerState { Button( variant: ButtonVariant.primary, label: 'Import Wallet', - onPressed: () => _importWallet(walletIndex: 0), + onPressed: () => _importWallet(walletIndex: widget.walletIndex), isLoading: _isLoading, ), SizedBox(height: context.themeSize.bottomButtonSpacing), diff --git a/mobile-app/lib/features/main/screens/settings_screen.dart b/mobile-app/lib/features/main/screens/settings_screen.dart index f573d512..03594777 100644 --- a/mobile-app/lib/features/main/screens/settings_screen.dart +++ b/mobile-app/lib/features/main/screens/settings_screen.dart @@ -145,7 +145,8 @@ class _SettingsScreenState extends ConsumerState { }), const SizedBox(height: 22), _buildSettingsItem(context, 'Show Recovery Phrase', () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const ShowRecoveryPhraseScreen())); + final walletIndex = ref.read(activeAccountProvider).value?.walletIndex ?? 0; + Navigator.push(context, MaterialPageRoute(builder: (context) => ShowRecoveryPhraseScreen(walletIndex: walletIndex))); }), const SizedBox(height: 22), _buildSettingsItem(context, 'Referral', () { diff --git a/mobile-app/test/widget/send_screen_widget_test.dart b/mobile-app/test/widget/send_screen_widget_test.dart index 2e08ba42..4ee6a14d 100644 --- a/mobile-app/test/widget/send_screen_widget_test.dart +++ b/mobile-app/test/widget/send_screen_widget_test.dart @@ -45,7 +45,7 @@ void main() { // --- 1. Settings Service Stubs --- when(mockSettingsService.getActiveAccount()).thenAnswer((_) async { - return const Account(index: 0, name: 'Test User', accountId: 'test_account_id'); + return const Account(walletIndex: 0, index: 0, name: 'Test User', accountId: 'test_account_id'); }); when(mockSettingsService.getReversibleTimeSeconds()).thenAnswer((_) async => 600); diff --git a/mobile-app/test/widget/send_screen_widget_test.mocks.dart b/mobile-app/test/widget/send_screen_widget_test.mocks.dart index c534fa15..11cd81b9 100644 --- a/mobile-app/test/widget/send_screen_widget_test.mocks.dart +++ b/mobile-app/test/widget/send_screen_widget_test.mocks.dart @@ -149,12 +149,12 @@ class MockSettingsService extends _i1.Mock implements _i2.SettingsService { as _i3.Future<_i4.Account?>); @override - _i3.Future<_i4.Account?> getAccount(int? index) => + _i3.Future<_i4.Account?> getAccount({required int index, required int walletIndex}) => (super.noSuchMethod(Invocation.method(#getAccount, [index]), returnValue: _i3.Future<_i4.Account?>.value()) as _i3.Future<_i4.Account?>); @override - _i3.Future getNextFreeAccountIndex() => + _i3.Future getNextFreeAccountIndex(int walletIndex) => (super.noSuchMethod(Invocation.method(#getNextFreeAccountIndex, []), returnValue: _i3.Future.value(0)) as _i3.Future); diff --git a/quantus_sdk/lib/src/services/account_discovery_service.dart b/quantus_sdk/lib/src/services/account_discovery_service.dart index 9be1f9d4..0f4ab884 100644 --- a/quantus_sdk/lib/src/services/account_discovery_service.dart +++ b/quantus_sdk/lib/src/services/account_discovery_service.dart @@ -16,14 +16,13 @@ class AccountDiscoveryService { } '''; - Future> discoverAccounts({required String mnemonic, int count = 20}) async { + Future> discoverAccounts({required String mnemonic, required int walletIndex, int count = 20}) async { final allPossibleAccounts = []; // Add raw account final rawKeyPair = _substrateService.nonHDdilithiumKeypairFromMnemonic(mnemonic); - final baseWalletIndex = 0; final rawAccount = Account( - walletIndex: baseWalletIndex, + walletIndex: walletIndex, index: -1, // indicator for a raw account name: 'Primary Account', accountId: rawKeyPair.ss58Address, @@ -33,7 +32,7 @@ class AccountDiscoveryService { // Add HD accounts for (var i = 0; i < count; i++) { final keyPair = _hdWalletService.keyPairAtIndex(mnemonic, i); - final account = Account(walletIndex: baseWalletIndex, index: i, name: 'Account ${i + 1}', accountId: keyPair.ss58Address); + final account = Account(walletIndex: walletIndex, index: i, name: 'Account ${i + 1}', accountId: keyPair.ss58Address); allPossibleAccounts.add(account); } diff --git a/quantus_sdk/lib/src/services/accounts_service.dart b/quantus_sdk/lib/src/services/accounts_service.dart index 0d285a1f..e66c7e0e 100644 --- a/quantus_sdk/lib/src/services/accounts_service.dart +++ b/quantus_sdk/lib/src/services/accounts_service.dart @@ -23,7 +23,7 @@ class AccountsService { if (mnemonic == null) { throw Exception('Mnemonic not found. Cannot create new account.'); } - final nextIndex = await _settingsService.getNextFreeAccountIndex(); + final nextIndex = await _settingsService.getNextFreeAccountIndex(walletIndex); final keypair = HdWalletService().keyPairAtIndex(mnemonic, nextIndex); final newAccount = Account( walletIndex: walletIndex, diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index e633896b..46859c8c 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -20,6 +20,7 @@ class SettingsService { static const String _oldAccountsKeyV2 = 'accounts_v2'; static const String _oldAccountsKeyV1 = 'accounts'; static const String _activeAccountIndexKey = 'active_account_index'; + static const String _activeAccountIdKey = 'active_account_id'; // Local authentication keys static const String _isLocalAuthEnabledKey = 'is_local_auth_enabled'; @@ -46,7 +47,12 @@ class SettingsService { final accountsJson = _prefs.getString(_accountsKey); if (accountsJson != null) { final decoded = jsonDecode(accountsJson) as List; - return decoded.map((e) => Account.fromJson(e)).toList()..sort((a, b) => a.index.compareTo(b.index)); + return decoded + .map((e) => Account.fromJson(e)) + .toList() + ..sort( + (a, b) => a.walletIndex != b.walletIndex ? a.walletIndex.compareTo(b.walletIndex) : a.index.compareTo(b.index), + ); } // Migration for existing single-account users final oldAccountId = _prefs.getString('account_id'); @@ -93,7 +99,7 @@ class SettingsService { Future addAccount(Account account) async { final accounts = await getAccounts(); // Check for duplicates by index or accountId before adding - if (!accounts.any((a) => a.index == account.index || a.accountId == account.accountId)) { + if (!accounts.any((a) => (a.walletIndex == account.walletIndex && a.index == account.index) || a.accountId == account.accountId)) { accounts.add(account); await saveAccounts(accounts); if (accounts.length == 1) { @@ -107,7 +113,7 @@ class SettingsService { Future updateAccount(Account account) async { final accounts = await getAccounts(); - final index = accounts.indexWhere((a) => a.index == account.index); + final index = accounts.indexWhere((a) => a.accountId == account.accountId); if (index != -1) { accounts[index] = account; await saveAccounts(accounts); @@ -122,47 +128,67 @@ class SettingsService { if (account.index == 0) { throw Exception("Can't remove the root account"); } - if (account.index == _getActiveAccountIndex()) { - _setActiveAccountIndex(accounts[0].index); + if (account.accountId == await _getActiveAccountId()) { + await _setActiveAccountId(accounts[0].accountId); } - accounts.removeWhere((a) => a.index == account.index); + accounts.removeWhere((a) => a.accountId == account.accountId); await saveAccounts(accounts); } Future setActiveAccount(Account account) async { - final accountExists = await getAccount(account.index); - if (accountExists != null) { - _setActiveAccountIndex(account.index); + final exists = (await getAccounts()).any((a) => a.accountId == account.accountId); + if (exists) { + await _setActiveAccountId(account.accountId); } else { throw Exception('Account index does not exist'); } } + Future _getActiveAccountId() async { + final id = _prefs.getString(_activeAccountIdKey); + if (id != null && id.isNotEmpty) return id; + + final legacyIndex = _getActiveAccountIndex(); + final accounts = await getAccounts(); + if (accounts.isEmpty) return null; + final legacyAccount = accounts.firstWhere( + (a) => a.walletIndex == 0 && a.index == legacyIndex, + orElse: () => accounts.first, + ); + + await _setActiveAccountId(legacyAccount.accountId); + return legacyAccount.accountId; + } + int _getActiveAccountIndex() { return _prefs.getInt(_activeAccountIndexKey) ?? 0; } - void _setActiveAccountIndex(int index) { - final oldIndex = _getActiveAccountIndex(); - if (index != oldIndex) { - _prefs.setInt(_activeAccountIndexKey, index); + Future _setActiveAccountId(String accountId) async { + final oldId = _prefs.getString(_activeAccountIdKey); + if (oldId != accountId) { + await _prefs.setString(_activeAccountIdKey, accountId); } } Future getActiveAccount() async { - final activeIndex = _getActiveAccountIndex(); - return getAccount(activeIndex); + final activeAccountId = await _getActiveAccountId(); + final accounts = await getAccounts(); + final ix = accounts.indexWhere((a) => a.accountId == activeAccountId); + return ix != -1 ? accounts[ix] : (accounts.isNotEmpty ? accounts.first : null); } - Future getAccount(int index) async { + Future getAccount({required int walletIndex, required int index}) async { final accounts = await getAccounts(); - final ix = accounts.indexWhere((a) => a.index == index); + final ix = accounts.indexWhere((a) => a.walletIndex == walletIndex && a.index == index); return ix != -1 ? accounts[ix] : null; } - Future getNextFreeAccountIndex() async { + Future getNextFreeAccountIndex(int walletIndex) async { final accounts = await getAccounts(); - final maxIndex = accounts.map((a) => a.index).reduce((a, b) => a > b ? a : b); + final walletAccounts = accounts.where((a) => a.walletIndex == walletIndex && a.index >= 0).toList(); + if (walletAccounts.isEmpty) return 0; + final maxIndex = walletAccounts.map((a) => a.index).reduce((a, b) => a > b ? a : b); return maxIndex + 1; } diff --git a/quantus_sdk/lib/src/services/taskmaster_service.dart b/quantus_sdk/lib/src/services/taskmaster_service.dart index 9b0f1922..2d19c890 100644 --- a/quantus_sdk/lib/src/services/taskmaster_service.dart +++ b/quantus_sdk/lib/src/services/taskmaster_service.dart @@ -146,7 +146,6 @@ class TaskmasterService { final SettingsService _settingsService = SettingsService(); final HdWalletService _hd = HdWalletService(); - final _mainAccountIndex = 0; TokenInfo? _tokenInfo; String? get accessToken => _tokenInfo?.accessToken; bool get isLoggedIn => _tokenInfo != null && !_tokenInfo!.isExpired; @@ -328,7 +327,7 @@ class TaskmasterService { } Future getMainAccount() async { - final account = await _settingsService.getAccount(_mainAccountIndex); + final account = await _settingsService.getAccount(walletIndex: 0, index: 0); if (account == null) { throw Exception('No main account - this method should probably not be called when logged out'); } diff --git a/quantus_sdk/test/services/settings_service_test.dart b/quantus_sdk/test/services/settings_service_test.dart index 258904c2..842127c6 100644 --- a/quantus_sdk/test/services/settings_service_test.dart +++ b/quantus_sdk/test/services/settings_service_test.dart @@ -174,7 +174,7 @@ void main() { await settingsService.saveAccounts([account1, account3]); // Indices 0 and 2 // Act - final nextIndex = await settingsService.getNextFreeAccountIndex(); + final nextIndex = await settingsService.getNextFreeAccountIndex(0); // Assert expect(nextIndex, 3); From bd9f22c366789360413a8c28b11d0935bf44c637 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 15 Dec 2025 13:07:00 +0800 Subject: [PATCH 04/54] melos format --- .../main/screens/accounts_screen.dart | 67 +++++++++---------- .../screens/add_hardware_account_screen.dart | 1 - .../main/screens/import_wallet_screen.dart | 6 +- .../main/screens/settings_screen.dart | 5 +- quantus_sdk/lib/src/models/account.dart | 32 ++++++--- .../services/account_discovery_service.dart | 7 +- .../lib/src/services/migration_service.dart | 7 +- .../lib/src/services/settings_service.dart | 13 ++-- 8 files changed, 79 insertions(+), 59 deletions(-) diff --git a/mobile-app/lib/features/main/screens/accounts_screen.dart b/mobile-app/lib/features/main/screens/accounts_screen.dart index b6ac2d25..09ad0610 100644 --- a/mobile-app/lib/features/main/screens/accounts_screen.dart +++ b/mobile-app/lib/features/main/screens/accounts_screen.dart @@ -104,36 +104,32 @@ class _AccountsScreenState extends ConsumerState { Item(value: _WalletMoreAction.addHardwareWallet, label: 'Add hardware wallet'), ]; - showSelectActionSheet<_WalletMoreAction>( - context, - items, - (item) async { - final result = await (switch (item.value) { - _WalletMoreAction.createWallet => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CreateWalletAndBackupScreen(walletIndex: nextWalletIndex, popOnComplete: true), - ), - ), - _WalletMoreAction.importWallet => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ImportWalletScreen(walletIndex: nextWalletIndex, popOnComplete: true), - ), - ), - _WalletMoreAction.addHardwareWallet => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => AddHardwareAccountScreen(walletIndex: nextWalletIndex, isNewWallet: true), - ), - ), - }); - if (result == true && mounted) { - ref.invalidate(accountsProvider); - ref.invalidate(activeAccountProvider); - } - }, - ); + showSelectActionSheet<_WalletMoreAction>(context, items, (item) async { + final result = await (switch (item.value) { + _WalletMoreAction.createWallet => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CreateWalletAndBackupScreen(walletIndex: nextWalletIndex, popOnComplete: true), + ), + ), + _WalletMoreAction.importWallet => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ImportWalletScreen(walletIndex: nextWalletIndex, popOnComplete: true), + ), + ), + _WalletMoreAction.addHardwareWallet => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AddHardwareAccountScreen(walletIndex: nextWalletIndex, isNewWallet: true), + ), + ), + }); + if (result == true && mounted) { + ref.invalidate(accountsProvider); + ref.invalidate(activeAccountProvider); + } + }); } @override @@ -149,12 +145,7 @@ class _AccountsScreenState extends ConsumerState { ], appBar: WalletAppBar( title: 'Your Accounts', - actions: [ - IconButton( - onPressed: _openWalletMoreActions, - icon: const Icon(Icons.more_horiz), - ), - ], + actions: [IconButton(onPressed: _openWalletMoreActions, icon: const Icon(Icons.more_horiz))], ), child: Column( children: [ @@ -270,7 +261,9 @@ class _AccountsScreenState extends ConsumerState { child: Text( _walletLabel(walletIndex, walletAccounts), style: context.themeText.detail?.copyWith( - color: walletIndex == selectedWallet ? context.themeColors.textPrimary : context.themeColors.textMuted, + color: walletIndex == selectedWallet + ? context.themeColors.textPrimary + : context.themeColors.textMuted, ), ), ), diff --git a/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart b/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart index 07fc8713..418df10b 100644 --- a/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart +++ b/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart @@ -120,4 +120,3 @@ class _AddHardwareAccountScreenState extends ConsumerState { } } - Future _importWallet({required int walletIndex }) async { + Future _importWallet({required int walletIndex}) async { setState(() { _isLoading = true; _errorMessage = ''; @@ -88,7 +88,9 @@ class ImportWalletScreenState extends ConsumerState { final key = HdWalletService().keyPairAtIndex(mnemonic, 0); await _settingsService.setMnemonic(mnemonic, walletIndex); - await _accountsService.addAccount(Account(walletIndex: walletIndex, index: 0, name: 'Account 1', accountId: key.ss58Address)); + await _accountsService.addAccount( + Account(walletIndex: walletIndex, index: 0, name: 'Account 1', accountId: key.ss58Address), + ); await _discoverAccounts(mnemonic); // We set check status to true so we will not prompt user to input refferal code. diff --git a/mobile-app/lib/features/main/screens/settings_screen.dart b/mobile-app/lib/features/main/screens/settings_screen.dart index 03594777..a85bf302 100644 --- a/mobile-app/lib/features/main/screens/settings_screen.dart +++ b/mobile-app/lib/features/main/screens/settings_screen.dart @@ -146,7 +146,10 @@ class _SettingsScreenState extends ConsumerState { const SizedBox(height: 22), _buildSettingsItem(context, 'Show Recovery Phrase', () { final walletIndex = ref.read(activeAccountProvider).value?.walletIndex ?? 0; - Navigator.push(context, MaterialPageRoute(builder: (context) => ShowRecoveryPhraseScreen(walletIndex: walletIndex))); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => ShowRecoveryPhraseScreen(walletIndex: walletIndex)), + ); }), const SizedBox(height: 22), _buildSettingsItem(context, 'Referral', () { diff --git a/quantus_sdk/lib/src/models/account.dart b/quantus_sdk/lib/src/models/account.dart index 70d9b3c7..da2baac5 100644 --- a/quantus_sdk/lib/src/models/account.dart +++ b/quantus_sdk/lib/src/models/account.dart @@ -1,9 +1,6 @@ import 'package:flutter/foundation.dart'; -enum AccountType { - local, - keystone, -} +enum AccountType { local, keystone } @immutable class Account { @@ -12,23 +9,40 @@ class Account { final String name; final String accountId; // address final AccountType accountType; - const Account({required this.walletIndex, required this.index, required this.name, required this.accountId, this.accountType = AccountType.local}); + const Account({ + required this.walletIndex, + required this.index, + required this.name, + required this.accountId, + this.accountType = AccountType.local, + }); factory Account.fromJson(Map json) { return Account( walletIndex: (json['walletIndex'] ?? 0) as int, - index: json['index'] as int, - name: json['name'] as String, + index: json['index'] as int, + name: json['name'] as String, accountId: json['accountId'] as String, accountType: AccountType.values.byName(json['accountType'] as String? ?? AccountType.local.name), ); } Map toJson() { - return {'walletIndex': walletIndex, 'index': index, 'name': name, 'accountId': accountId, 'accountType': accountType.name}; + return { + 'walletIndex': walletIndex, + 'index': index, + 'name': name, + 'accountId': accountId, + 'accountType': accountType.name, + }; } Account copyWith({int? walletIndex, int? index, String? name, String? accountId, int? uiPosition}) { - return Account(walletIndex: walletIndex ?? this.walletIndex, index: index ?? this.index, name: name ?? this.name, accountId: accountId ?? this.accountId); + return Account( + walletIndex: walletIndex ?? this.walletIndex, + index: index ?? this.index, + name: name ?? this.name, + accountId: accountId ?? this.accountId, + ); } } diff --git a/quantus_sdk/lib/src/services/account_discovery_service.dart b/quantus_sdk/lib/src/services/account_discovery_service.dart index 0f4ab884..4c3d6927 100644 --- a/quantus_sdk/lib/src/services/account_discovery_service.dart +++ b/quantus_sdk/lib/src/services/account_discovery_service.dart @@ -32,7 +32,12 @@ class AccountDiscoveryService { // Add HD accounts for (var i = 0; i < count; i++) { final keyPair = _hdWalletService.keyPairAtIndex(mnemonic, i); - final account = Account(walletIndex: walletIndex, index: i, name: 'Account ${i + 1}', accountId: keyPair.ss58Address); + final account = Account( + walletIndex: walletIndex, + index: i, + name: 'Account ${i + 1}', + accountId: keyPair.ss58Address, + ); allPossibleAccounts.add(account); } diff --git a/quantus_sdk/lib/src/services/migration_service.dart b/quantus_sdk/lib/src/services/migration_service.dart index b6627fee..7e812004 100644 --- a/quantus_sdk/lib/src/services/migration_service.dart +++ b/quantus_sdk/lib/src/services/migration_service.dart @@ -78,7 +78,12 @@ class MigrationService { /// Debug method to create test old accounts Future createDebugOldAccounts() async { final debugAccounts = [ - const Account(walletIndex: 0, index: -1, name: 'Primary Account', accountId: 'qznd1YWbgQrviV76psu5n8d24mHSuHtAc9JmJLB42gTELksvQ'), + const Account( + walletIndex: 0, + index: -1, + name: 'Primary Account', + accountId: 'qznd1YWbgQrviV76psu5n8d24mHSuHtAc9JmJLB42gTELksvQ', + ), const Account(walletIndex: 0, index: 0, name: 'Account 0', accountId: 'debug_id_0'), const Account(walletIndex: 0, index: 1, name: 'Account 1', accountId: 'debug_id_1'), ]; diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index 46859c8c..9ea64cf2 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -47,12 +47,9 @@ class SettingsService { final accountsJson = _prefs.getString(_accountsKey); if (accountsJson != null) { final decoded = jsonDecode(accountsJson) as List; - return decoded - .map((e) => Account.fromJson(e)) - .toList() - ..sort( - (a, b) => a.walletIndex != b.walletIndex ? a.walletIndex.compareTo(b.walletIndex) : a.index.compareTo(b.index), - ); + return decoded.map((e) => Account.fromJson(e)).toList()..sort( + (a, b) => a.walletIndex != b.walletIndex ? a.walletIndex.compareTo(b.walletIndex) : a.index.compareTo(b.index), + ); } // Migration for existing single-account users final oldAccountId = _prefs.getString('account_id'); @@ -99,7 +96,9 @@ class SettingsService { Future addAccount(Account account) async { final accounts = await getAccounts(); // Check for duplicates by index or accountId before adding - if (!accounts.any((a) => (a.walletIndex == account.walletIndex && a.index == account.index) || a.accountId == account.accountId)) { + if (!accounts.any( + (a) => (a.walletIndex == account.walletIndex && a.index == account.index) || a.accountId == account.accountId, + )) { accounts.add(account); await saveAccounts(accounts); if (accounts.length == 1) { From ec5dd30c9a5e928df1bd310655f50dc9fb72a405 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Tue, 16 Dec 2025 10:06:48 +0800 Subject: [PATCH 05/54] scan QR added, debug fill button added --- .../screens/add_hardware_account_screen.dart | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart b/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart index 418df10b..c64b99e2 100644 --- a/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart +++ b/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; @@ -5,6 +6,7 @@ import 'package:resonance_network_wallet/features/components/button.dart'; import 'package:resonance_network_wallet/features/components/custom_text_field.dart'; import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; +import 'package:resonance_network_wallet/features/main/screens/send/qr_scanner_screen.dart'; import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; @@ -20,7 +22,7 @@ class AddHardwareAccountScreen extends ConsumerStatefulWidget { } class _AddHardwareAccountScreenState extends ConsumerState { - final _name = TextEditingController(); + final _name = TextEditingController(text: 'Keystone Wallet'); final _address = TextEditingController(); final _accountsService = AccountsService(); @@ -30,6 +32,22 @@ class _AddHardwareAccountScreenState extends ConsumerState _scanQRCode() async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const QRScannerScreen(), fullscreenDialog: true), + ); + if (result != null && mounted) { + _address.text = result.trim(); + if (_error != null) setState(() => _error = null); + } + } + + void _fillDebugAddress() { + _address.text = 'qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG'; + if (_error != null) setState(() => _error = null); + } + Future _save() async { final name = _name.text.trim(); final address = _address.text.trim(); @@ -103,6 +121,28 @@ class _AddHardwareAccountScreenState extends ConsumerState _error = null); }, ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Button( + variant: ButtonVariant.neutral, + label: 'Scan QR Code', + onPressed: _scanQRCode, + ), + ), + if (kDebugMode) ...[ + const SizedBox(width: 12), + Expanded( + child: Button( + variant: ButtonVariant.neutral, + label: 'Debug Fill', + onPressed: _fillDebugAddress, + ), + ), + ], + ], + ), if (_error != null) ...[ const SizedBox(height: 10), Text(_error!, style: context.themeText.tiny?.copyWith(color: Colors.red)), From 2a2464714d8a8e588217e5a741b04a5fdb30b845 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Tue, 16 Dec 2025 11:34:56 +0800 Subject: [PATCH 06/54] do fee estimate without signing the transaction this helps the hardware wallet. there may be another way to do this but for now dummy signature works well. --- .../main/screens/send/send_screen.dart | 4 +- mobile-app/pubspec.yaml | 2 +- .../lib/src/models/extrinsic_fee_data.dart | 7 +- .../lib/src/services/substrate_service.dart | 133 +++++++++++++++--- 4 files changed, 120 insertions(+), 26 deletions(-) diff --git a/mobile-app/lib/features/main/screens/send/send_screen.dart b/mobile-app/lib/features/main/screens/send/send_screen.dart index 9e90263a..1feb2b64 100644 --- a/mobile-app/lib/features/main/screens/send/send_screen.dart +++ b/mobile-app/lib/features/main/screens/send/send_screen.dart @@ -296,7 +296,7 @@ class SendScreenState extends ConsumerState { setState(() { _networkFee = estimatedFee.fee; - _blockHeight = estimatedFee.extrinsicData.blockNumber; + _blockHeight = estimatedFee.blockNumber; _isFetchingFee = false; _hasAmountError = SendScreenLogic.hasAmountError( amount: _amount, @@ -349,7 +349,7 @@ class SendScreenState extends ConsumerState { // we keep track of block number so we can set it on pending transactions setState(() { - _blockHeight = estimatedFee.extrinsicData.blockNumber; + _blockHeight = estimatedFee.blockNumber; }); final maxSendableAmount = SendScreenLogic.calculateMaxSendableAmount( diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml index 80f3cf28..a7ec195f 100644 --- a/mobile-app/pubspec.yaml +++ b/mobile-app/pubspec.yaml @@ -40,7 +40,7 @@ dependencies: url: https://github.com/Quantus-Network/human-checkphrase.git path: dart provider: ^6.1.5 - polkadart: ^0.7.1 + polkadart: ^0.7.3 share_plus: ^12.0.1 flutter_riverpod: ^2.6.1 riverpod_annotation: ^2.6.1 diff --git a/quantus_sdk/lib/src/models/extrinsic_fee_data.dart b/quantus_sdk/lib/src/models/extrinsic_fee_data.dart index 004ac35e..e43bbdcf 100644 --- a/quantus_sdk/lib/src/models/extrinsic_fee_data.dart +++ b/quantus_sdk/lib/src/models/extrinsic_fee_data.dart @@ -1,7 +1,6 @@ -import 'package:quantus_sdk/src/models/extrinsic_data.dart'; - class ExtrinsicFeeData { BigInt fee; - ExtrinsicData extrinsicData; - ExtrinsicFeeData({required this.fee, required this.extrinsicData}); + String blockHash; + int blockNumber; + ExtrinsicFeeData({required this.fee, required this.blockHash, required this.blockNumber}); } diff --git a/quantus_sdk/lib/src/services/substrate_service.dart b/quantus_sdk/lib/src/services/substrate_service.dart index a4e7f210..b5b4fc09 100644 --- a/quantus_sdk/lib/src/services/substrate_service.dart +++ b/quantus_sdk/lib/src/services/substrate_service.dart @@ -40,6 +40,9 @@ class SubstrateService { }); print('getFee: $result'); + if (result.error != null) { + throw Exception('RPC Error: ${result.error}'); + } final partialFeeString = result.result['partialFee'] as String; final partialFee = BigInt.parse(partialFeeString); print('partialFee: $partialFee'); @@ -96,9 +99,11 @@ class SubstrateService { } Future getFeeForCall(Account account, RuntimeCall call) async { - final extrinsic = await getExtrinsicPayload(account, call); + // We use a dummy signature for fee estimation to avoid prompting for password/device. + // The node needs a properly formatted signed extrinsic to estimate fees, even if the signature is invalid. + final extrinsic = await getExtrinsicPayload(account, call, isSigned: false); final fee = await getFee(extrinsic.payload); - return ExtrinsicFeeData(fee: fee, extrinsicData: extrinsic); + return ExtrinsicFeeData(fee: fee, blockHash: extrinsic.blockHash, blockNumber: extrinsic.blockNumber); } /// Submit a fully formatted extrinsic for block inclusion. @@ -146,13 +151,8 @@ class SubstrateService { throw Exception('Failed to submit extrinsic after $maxRetries retries.'); } - Future getExtrinsicPayload(Account account, RuntimeCall call) async { - final mnemonic = await account.getMnemonic(); - if (mnemonic == null) { - throw Exception('Mnemonic not found for signing.'); - } - final senderWallet = HdWalletService().keyPairAtIndex(mnemonic, account.index); - + Future getExtrinsicPayload(Account account, RuntimeCall call, + {bool isSigned = true}) async { final [runtimeVersion, genesisHash, blockNumber, blockHash, nonce] = await Future.wait([ _rpcEndpointService.rpcTask((uri) async { final provider = Provider.fromUri(uri); @@ -162,7 +162,7 @@ class SubstrateService { _getGenesisHash(), _getBlockNumber(), _getBlockHash(), - _getNextAccountNonce(senderWallet), + _getNextAccountNonceFromAddress(account.accountId), ]); final [specVersion, transactionVersion] = [runtimeVersion.specVersion, runtimeVersion.transactionVersion]; @@ -187,26 +187,121 @@ class SubstrateService { final payload = payloadToSign.encode(registry); - final signature = crypto.signMessage(keypair: senderWallet, message: payload); - final signatureWithPublicKeyBytes = _combineSignatureAndPubkey(signature, senderWallet.publicKey); + if (isSigned) { + final mnemonic = await account.getMnemonic(); + if (mnemonic == null) { + throw Exception('Mnemonic not found for signing.'); + } + final senderWallet = HdWalletService().keyPairAtIndex(mnemonic, account.index); + + final signature = crypto.signMessage(keypair: senderWallet, message: payload); + final signatureWithPublicKeyBytes = _combineSignatureAndPubkey(signature, senderWallet.publicKey); + + final extrinsic = ResonanceExtrinsicPayload( + signer: Uint8List.fromList(senderWallet.addressBytes), + method: encodedCall, + signature: signatureWithPublicKeyBytes, + eraPeriod: 64, + blockNumber: blockNumber, + nonce: nonce, + tip: 0, + ).encodeResonance(registry, ResonanceSignatureType.resonance); + + return ExtrinsicData(payload: extrinsic, blockNumber: blockNumber, blockHash: blockHash, nonce: nonce); + } else { + // Use a dummy signature for fee estimation + // 7219 is the size of the Dilithium signature + public key + final dummySignature = Uint8List(7219); + final signerBytes = getAccountId32(account.accountId); + + final extrinsic = ResonanceExtrinsicPayload( + signer: signerBytes, + method: encodedCall, + signature: dummySignature, + eraPeriod: 64, + blockNumber: blockNumber, + nonce: nonce, + tip: 0, + ).encodeResonance(registry, ResonanceSignatureType.resonance); + + return ExtrinsicData(payload: extrinsic, blockNumber: blockNumber, blockHash: blockHash, nonce: nonce); + } + } - final extrinsic = ResonanceExtrinsicPayload( - signer: Uint8List.fromList(senderWallet.addressBytes), + Future getUnsignedTransactionPayload(Account account, RuntimeCall call) async { + final accountIdBytes = crypto.ss58ToAccountId(s: account.accountId); + + final [runtimeVersion, genesisHash, blockNumber, blockHash, nonce] = await Future.wait([ + _rpcEndpointService.rpcTask((uri) async { + final provider = Provider.fromUri(uri); + final stateApi = StateApi(provider); + return await stateApi.getRuntimeVersion(); + }), + _getGenesisHash(), + _getBlockNumber(), + _getBlockHash(), + _getNextAccountNonceFromAddress(account.accountId), + ]); + + final [specVersion, transactionVersion] = [runtimeVersion.specVersion, runtimeVersion.transactionVersion]; + final encodedCall = call.encode(); + + final payloadToSign = SigningPayload( + method: encodedCall, + specVersion: specVersion, + transactionVersion: transactionVersion, + genesisHash: genesisHash, + blockHash: blockHash, + blockNumber: blockNumber, + eraPeriod: 64, + nonce: nonce, + tip: 0, + ); + + final registry = await _rpcEndpointService.rpcTask((uri) async { + final provider = Provider.fromUri(uri); + return Schrodinger(provider).registry; + }); + + final payload = payloadToSign.encode(registry); + + return UnsignedTransactionData( + payloadToSign: payload, + signer: accountIdBytes, method: encodedCall, - signature: signatureWithPublicKeyBytes, eraPeriod: 64, blockNumber: blockNumber, + blockHash: blockHash, nonce: nonce, tip: 0, - ).encodeResonance(registry, ResonanceSignatureType.resonance); + registry: registry, + ); + } + + Future submitExtrinsicWithExternalSignature( + UnsignedTransactionData unsignedData, + Uint8List signature, + Uint8List publicKey, + ) async { + final signatureWithPublicKeyBytes = _combineSignatureAndPubkey(signature, publicKey); + + final extrinsic = ResonanceExtrinsicPayload( + signer: unsignedData.signer, + method: unsignedData.method, + signature: signatureWithPublicKeyBytes, + eraPeriod: unsignedData.eraPeriod, + blockNumber: unsignedData.blockNumber, + nonce: unsignedData.nonce, + tip: unsignedData.tip, + ).encodeResonance(unsignedData.registry, ResonanceSignatureType.resonance); - return ExtrinsicData(payload: extrinsic, blockNumber: blockNumber, blockHash: blockHash, nonce: nonce); + return await _submitExtrinsic(extrinsic); } - Future _getNextAccountNonce(Keypair senderWallet) async { + Future _getNextAccountNonceFromAddress(String address) async { final nonceResult = await _rpcEndpointService.rpcTask((uri) async { final provider = Provider.fromUri(uri); - return await provider.send('system_accountNextIndex', [senderWallet.ss58Address]); + return await provider.send('system_accountNextIndex', [address]); }); return int.parse(nonceResult.result.toString()); } From dcc4ee26a4fe5c9ccb869e8ce6d11c9e9b67e3a5 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Tue, 16 Dec 2025 12:08:49 +0800 Subject: [PATCH 07/54] hardware wallet simulator --- .../main/screens/send/qr_scanner_screen.dart | 61 +- .../screens/send/send_progress_overlay.dart | 143 +- .../send/transaction_qr_display_screen.dart | 80 + .../transaction_submission_service.dart | 6 +- .../test/widget/send_screen_widget_test.dart | 565 ++++--- .../widget/send_screen_widget_test.mocks.dart | 1426 ++++++++--------- quantus_sdk/lib/quantus_sdk.dart | 1 + .../src/models/unsigned_transaction_data.dart | 25 + 8 files changed, 1283 insertions(+), 1024 deletions(-) create mode 100644 mobile-app/lib/features/main/screens/send/transaction_qr_display_screen.dart create mode 100644 quantus_sdk/lib/src/models/unsigned_transaction_data.dart diff --git a/mobile-app/lib/features/main/screens/send/qr_scanner_screen.dart b/mobile-app/lib/features/main/screens/send/qr_scanner_screen.dart index 16031de4..605a6fac 100644 --- a/mobile-app/lib/features/main/screens/send/qr_scanner_screen.dart +++ b/mobile-app/lib/features/main/screens/send/qr_scanner_screen.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:quantus_sdk/src/rust/api/crypto.dart' as crypto; +import 'package:convert/convert.dart'; +import 'dart:typed_data'; import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; class QRScannerScreen extends StatefulWidget { - const QRScannerScreen({super.key}); + final List? payloadToSign; // Optional payload for debug simulation + const QRScannerScreen({super.key, this.payloadToSign}); @override State createState() => _QRScannerScreenState(); @@ -16,6 +20,42 @@ class _QRScannerScreenState extends State { final MobileScannerController controller = MobileScannerController(); bool _hasScanned = false; // Add flag to track if we've already scanned + Future _simulateHardwareSignature() async { + if (widget.payloadToSign == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('No payload provided for simulation'))); + return; + } + + try { + // 1. Get the debug wallet (Crystal Alice) + final debugWallet = crypto.crystalAlice(); + + // 2. Sign the payload using the debug wallet + // We use signMessage which returns the raw signature + final signature = crypto.signMessage(keypair: debugWallet, message: widget.payloadToSign!); + + // 3. Combine signature and public key (this is what the hardware wallet should return) + final signatureWithPublicKey = Uint8List(signature.length + debugWallet.publicKey.length); + signatureWithPublicKey.setAll(0, signature); + signatureWithPublicKey.setAll(signature.length, debugWallet.publicKey); + + // 4. Encode as hex string (simulating QR code content) + final hexSignature = '0x${hex.encode(signatureWithPublicKey)}'; + + print('Simulated Hardware Signature: $hexSignature'); + + // 5. Return the result as if it was scanned + if (mounted) { + Navigator.pop(context, hexSignature); + } + } catch (e) { + print('Simulation error: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Simulation failed: $e'))); + } + } + } + @override void dispose() { controller.dispose(); @@ -101,6 +141,25 @@ class _QRScannerScreenState extends State { style: context.themeText.paragraph?.copyWith(color: context.themeColors.textPrimary.useOpacity(0.8)), ), ), + + // Debug Simulation Button (Only visible in debug mode or if payload is provided) + if (widget.payloadToSign != null) + Positioned( + bottom: 40, + left: 0, + right: 0, + child: Center( + child: TextButton( + onPressed: _simulateHardwareSignature, + style: TextButton.styleFrom( + backgroundColor: Colors.red.withOpacity(0.7), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + child: const Text('DEBUG: SIMULATE SIGNATURE'), + ), + ), + ), ], ), ); diff --git a/mobile-app/lib/features/main/screens/send/send_progress_overlay.dart b/mobile-app/lib/features/main/screens/send/send_progress_overlay.dart index cba74151..2cb72549 100644 --- a/mobile-app/lib/features/main/screens/send/send_progress_overlay.dart +++ b/mobile-app/lib/features/main/screens/send/send_progress_overlay.dart @@ -1,14 +1,21 @@ +import 'dart:typed_data'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:quantus_sdk/generated/schrodinger/types/qp_scheduler/block_number_or_timestamp.dart' as qp; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/button.dart'; import 'package:resonance_network_wallet/features/main/screens/navbar.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:convert/convert.dart'; +import 'package:resonance_network_wallet/features/main/screens/send/qr_scanner_screen.dart'; +import 'package:resonance_network_wallet/features/main/screens/send/transaction_qr_display_screen.dart'; +import 'package:resonance_network_wallet/providers/pending_transactions_provider.dart'; +import 'package:resonance_network_wallet/services/telemetry_service.dart'; import 'package:resonance_network_wallet/services/transaction_submission_service.dart'; import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; @@ -101,31 +108,10 @@ class SendConfirmationOverlayState extends ConsumerState _handleLocalWalletTransaction(Account account) async { + final submissionService = ref.read(transactionSubmissionServiceProvider); + + debugPrint('Attempting balance transfer...'); + debugPrint(' Recipient: ${widget.recipientAddress}'); + debugPrint(' Amount (BigInt): ${widget.amount}'); + debugPrint(' Fee: ${widget.fee}'); + debugPrint(' Reversible time: ${widget.reversibleTimeSeconds}'); + + if (widget.reversibleTimeSeconds <= 0) { + await submissionService.balanceTransfer( + account, + widget.recipientAddress, + widget.amount, + widget.fee, + widget.blockHeight, + ); + } else { + await submissionService.scheduleReversibleTransferWithDelaySeconds( + account: account, + recipientAddress: widget.recipientAddress, + amount: widget.amount, + delaySeconds: widget.reversibleTimeSeconds, + feeEstimate: widget.fee, + blockHeight: widget.blockHeight, + ); + } + } + + Future _handleHardwareWalletTransaction(Account account) async { + final substrateService = SubstrateService(); + final balancesService = BalancesService(); + final reversibleTransfersService = ReversibleTransfersService(); + + RuntimeCall call; + if (widget.reversibleTimeSeconds <= 0) { + call = balancesService.getBalanceTransferCall(widget.recipientAddress, widget.amount); + } else { + final delay = qp.Timestamp(BigInt.from(widget.reversibleTimeSeconds) * BigInt.from(1000)); + call = reversibleTransfersService.getReversibleTransferCall( + widget.recipientAddress, + widget.amount, + delay, + ); + } + + final unsignedData = await substrateService.getUnsignedTransactionPayload(account, call); + + if (!mounted) return; + + final qrDisplayResult = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TransactionQRDisplayScreen(payloadToSign: unsignedData.payloadToSign), + ), + ); + + if (qrDisplayResult != true || !mounted) { + throw Exception('Transaction cancelled'); + } + + final signatureQR = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => QRScannerScreen(payloadToSign: unsignedData.payloadToSign), fullscreenDialog: true), + ); + + if (signatureQR == null || !mounted) { + throw Exception('Signature scan cancelled'); + } + + final signatureHex = signatureQR.replaceAll('0x', '').replaceAll('0X', ''); + final signatureBytes = hex.decode(signatureHex); + + if (signatureBytes.length < 64) { + throw Exception('Invalid signature length'); + } + + // For Dilithium, the signature + public key are combined in the signatureBytes. + // We pass the full blob as signature and an empty list as public key, + // because submitExtrinsicWithExternalSignature will concatenate them back anyway. + final signature = Uint8List.fromList(signatureBytes); + final publicKey = Uint8List(0); + + final submissionService = ref.read(transactionSubmissionServiceProvider); + final pendingTx = PendingTransactionEvent( + tempId: 'pending_${DateTime.now().millisecondsSinceEpoch}', + from: account.accountId, + to: widget.recipientAddress, + amount: widget.amount, + timestamp: DateTime.now(), + transactionState: TransactionState.created, + fee: widget.fee, + blockNumber: widget.blockHeight, + ); + + ref.read(pendingTransactionsProvider.notifier).add(pendingTx); + + final submissionBuilder = () async { + return await substrateService.submitExtrinsicWithExternalSignature( + unsignedData, + signature, + publicKey, + ); + }; + + TelemetryService().sendEvent('send_transfer_hardware'); + await submissionService.submitAndTrackTransaction(submissionBuilder, pendingTx); + } + Widget _buildConfirmState() { final formattedAmount = _formattingService.formatBalance(widget.amount); final formattedFee = _formattingService.formatBalance(widget.fee); diff --git a/mobile-app/lib/features/main/screens/send/transaction_qr_display_screen.dart b/mobile-app/lib/features/main/screens/send/transaction_qr_display_screen.dart new file mode 100644 index 00000000..18898259 --- /dev/null +++ b/mobile-app/lib/features/main/screens/send/transaction_qr_display_screen.dart @@ -0,0 +1,80 @@ +import 'package:convert/convert.dart'; +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:resonance_network_wallet/features/components/button.dart'; +import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; +import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; +import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; + +class TransactionQRDisplayScreen extends StatelessWidget { + const TransactionQRDisplayScreen({super.key, required this.payloadToSign}); + + final List payloadToSign; + + @override + Widget build(BuildContext context) { + final hexPayload = '0x${hex.encode(payloadToSign)}'; + + return ScaffoldBase( + appBar: WalletAppBar(title: 'Sign Transaction'), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 40), + Text( + 'Please scan with Keystone Wallet', + style: context.themeText.smallTitle, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Container( + width: context.isTablet ? 300 : 250, + height: context.isTablet ? 300 : 250, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: QrImageView( + data: hexPayload, + version: QrVersions.auto, + size: double.infinity, + padding: EdgeInsets.zero, + backgroundColor: Colors.white, + eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.square, color: Colors.black), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Colors.black, + ), + ), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Text( + 'Scan this QR code with your Keystone hardware wallet to sign the transaction.', + style: context.themeText.smallParagraph?.copyWith(color: Colors.white70), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + Button( + variant: ButtonVariant.primary, + label: 'Done', + onPressed: () => Navigator.of(context).pop(true), + ), + SizedBox(height: context.themeSize.bottomButtonSpacing), + ], + ), + ); + } +} diff --git a/mobile-app/lib/services/transaction_submission_service.dart b/mobile-app/lib/services/transaction_submission_service.dart index f7dd073b..e593a725 100644 --- a/mobile-app/lib/services/transaction_submission_service.dart +++ b/mobile-app/lib/services/transaction_submission_service.dart @@ -53,7 +53,7 @@ class TransactionSubmissionService { TelemetryService().sendEvent('send_transfer'); // D. Submit and track the transaction - await _submitAndTrack(submissionBuilder, pendingTx); + await submitAndTrackTransaction(submissionBuilder, pendingTx); } Future scheduleReversibleTransferWithDelaySeconds({ @@ -90,7 +90,7 @@ class TransactionSubmissionService { TelemetryService().sendEvent('send_reversible'); - await _submitAndTrack(submissionBuilder, pending, maxRetries: maxRetries); + await submitAndTrackTransaction(submissionBuilder, pending, maxRetries: maxRetries); } PendingTransactionEvent createPendingTransaction({ @@ -123,7 +123,7 @@ class TransactionSubmissionService { /// waiting. /// Handles retries in the background for 'invalid' status. /// submissionBuilder: Function that creates fresh submission on each retry - Future _submitAndTrack( + Future submitAndTrackTransaction( Future Function() submissionBuilder, PendingTransactionEvent pendingTx, { int maxRetries = 3, diff --git a/mobile-app/test/widget/send_screen_widget_test.dart b/mobile-app/test/widget/send_screen_widget_test.dart index 4ee6a14d..97a6c7d1 100644 --- a/mobile-app/test/widget/send_screen_widget_test.dart +++ b/mobile-app/test/widget/send_screen_widget_test.dart @@ -1,283 +1,282 @@ -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:quantus_sdk/quantus_sdk.dart'; -import 'package:resonance_network_wallet/features/components/button.dart'; -import 'package:resonance_network_wallet/features/main/screens/send/send_progress_overlay.dart'; -import 'package:resonance_network_wallet/features/main/screens/send/send_providers.dart'; -import 'package:resonance_network_wallet/features/main/screens/send/send_screen.dart'; -import 'package:resonance_network_wallet/providers/wallet_providers.dart'; - -import '../extensions.dart'; - -// Generate the mocks -@GenerateMocks([ - SettingsService, - SubstrateService, - HumanReadableChecksumService, - BalancesService, - ReversibleTransfersService, - NumberFormattingService, -]) -import 'send_screen_widget_test.mocks.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - late MockSettingsService mockSettingsService; - late MockSubstrateService mockSubstrateService; - late MockHumanReadableChecksumService mockChecksumService; - late MockBalancesService mockBalancesService; - late MockReversibleTransfersService mockReversibleService; - late MockNumberFormattingService mockFormattingService; - - setUp(() { - mockSettingsService = MockSettingsService(); - mockSubstrateService = MockSubstrateService(); - mockChecksumService = MockHumanReadableChecksumService(); - mockBalancesService = MockBalancesService(); - mockReversibleService = MockReversibleTransfersService(); - mockFormattingService = MockNumberFormattingService(); - - // --- 1. Settings Service Stubs --- - when(mockSettingsService.getActiveAccount()).thenAnswer((_) async { - return const Account(walletIndex: 0, index: 0, name: 'Test User', accountId: 'test_account_id'); - }); - when(mockSettingsService.getReversibleTimeSeconds()).thenAnswer((_) async => 600); - - // --- 2. Substrate Service Stubs --- - when(mockSubstrateService.isValidSS58Address(any)).thenAnswer((invocation) { - final String? arg = invocation.positionalArguments.first; - return arg != null && arg.isNotEmpty; - }); - - // --- 3. Checksum/Identity Stubs --- - when(mockChecksumService.getHumanReadableName(any)).thenAnswer((_) async => 'Alice'); - - // --- 4. Balances/Fee Stubs --- - final dummyFeeData = ExtrinsicFeeData( - fee: BigInt.from(1000000), - extrinsicData: ExtrinsicData(blockNumber: 100, nonce: 1, blockHash: '0xHash', payload: Uint8List(0)), - ); - - when(mockBalancesService.getBalanceTransferFee(any, any, any)).thenAnswer((_) async => dummyFeeData); - - when( - mockReversibleService.getReversibleTransferWithDelayFeeEstimate( - account: anyNamed('account'), - recipientAddress: anyNamed('recipientAddress'), - amount: anyNamed('amount'), - delaySeconds: anyNamed('delaySeconds'), - ), - ).thenAnswer((_) async => dummyFeeData); - - // --- 5. Number Formatting Stubs --- - when(mockFormattingService.parseAmount(any)).thenAnswer((invocation) { - final String input = invocation.positionalArguments.first; - if (input == '1.23') return BigInt.from(1230000000000); - if (input == '0') return BigInt.zero; - return BigInt.from(100); - }); - - when( - mockFormattingService.formatBalance( - any, - addSymbol: anyNamed('addSymbol'), - addThousandsSeparators: anyNamed('addThousandsSeparators'), - ), - ).thenAnswer((_) => '100.00'); // Simplified return for finding text - }); - - testWidgets('Send Screen full flow: Enter Address -> Verify Identity -> Enter Amount -> Verify Fee', (tester) async { - tester.view.physicalSize = tester.devicePixel; - tester.view.devicePixelRatio = tester.devicePixelRatio; - - // Reset size after test to avoid affecting other tests - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - final overrides = [ - settingsServiceProvider.overrideWithValue(mockSettingsService), - substrateServiceProvider.overrideWithValue(mockSubstrateService), - humanReadableChecksumServiceProvider.overrideWithValue(mockChecksumService), - balancesServiceProvider.overrideWithValue(mockBalancesService), - reversibleTransfersServiceProvider.overrideWithValue(mockReversibleService), - numberFormattingServiceProvider.overrideWithValue(mockFormattingService), - effectiveMaxBalanceProvider.overrideWithValue(AsyncValue.data(BigInt.from(5000000000000))), - existentialDepositToggleProvider.overrideWith((ref) => true), - ]; - - await tester.pumpApp(const ProviderScope(child: SendScreen()), overrides: overrides); - await tester.pumpAndSettle(); - - // 1. Verify Initial State - expect(find.text('To:'), findsOneWidget); - - // 2. Enter Recipient Address - final textFields = find.byType(TextField); - await tester.enterText(textFields.at(0), '5ValidAddressOfRecipient'); - await tester.pump(); - - // Wait for debounce - await tester.pump(const Duration(milliseconds: 350)); - await tester.pumpAndSettle(); - - // 3. Verify Identity Lookup - expect(find.text('Alice'), findsOneWidget); - - // 4. Enter Amount - await tester.enterText(textFields.at(1), '1.23'); - await tester.pump(); - - // Wait for debounce and fee fetch - await tester.pump(const Duration(milliseconds: 250)); - await tester.pumpAndSettle(); - - // 5. Click Send to trigger the Overlay - final sendButton = find.byType(Button); - expect(sendButton, findsOneWidget); - - // Tap the button to open the overlay (This is where it previously crashed) - await tester.tap(sendButton); - await tester.pumpAndSettle(); - - // 6. Verify Overlay Content - expect(find.byType(SendConfirmationOverlay), findsOneWidget); - - // You can now assert that the overlay details are correct - expect(find.text('SEND'), findsOneWidget); - expect(find.text('Alice'), findsNWidgets(2)); - }); - - testWidgets('Amount error because of insufficient balance', (tester) async { - tester.view.physicalSize = tester.devicePixel; - tester.view.devicePixelRatio = tester.devicePixelRatio; - - // Reset size after test to avoid affecting other tests - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - // 1. SETUP: Mock a LOW balance so we can easily trigger an "Insufficient Balance" error - final lowBalanceOverrides = [ - settingsServiceProvider.overrideWithValue(mockSettingsService), - substrateServiceProvider.overrideWithValue(mockSubstrateService), - humanReadableChecksumServiceProvider.overrideWithValue(mockChecksumService), - balancesServiceProvider.overrideWithValue(mockBalancesService), - reversibleTransfersServiceProvider.overrideWithValue(mockReversibleService), - numberFormattingServiceProvider.overrideWithValue(mockFormattingService), - effectiveMaxBalanceProvider.overrideWithValue(AsyncValue.data(BigInt.from(2_000_000_000))), - existentialDepositToggleProvider.overrideWith((ref) => true), - ]; - - await tester.pumpApp(const ProviderScope(child: SendScreen()), overrides: lowBalanceOverrides); - await tester.pumpAndSettle(); - - // 2. ACTION: Enter a valid address first (to ensure button isn't disabled due to address) - final textFields = find.byType(TextField); - await tester.enterText(textFields.at(0), '5ValidAddress'); - await tester.pump(const Duration(milliseconds: 350)); // Debounce - await tester.pumpAndSettle(); - - // 3. ACTION: Enter an amount HIGHER than the balance (e.g., 20.0 > 10.0) - when(mockFormattingService.parseAmount('1.0')).thenReturn(BigInt.from(1_000_000_000_000)); - - await tester.enterText(textFields.at(1), '1.0'); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 250)); // Debounce - await tester.pumpAndSettle(); - - // --- CHECK 1: IS BUTTON DISABLED? --- - // Find the widget by type - final buttonFinder = find.byType(Button); - expect(buttonFinder, findsOneWidget); - - // Get the actual widget instance to check properties - final buttonWidget = tester.widget