From dc021f014ba487b3bc8af77d156d57d8e377ecca Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 15 Dec 2025 12:59:38 +0800 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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()); }