From b206fa709670fe2bc3385c429cd54d0fa1057b42 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 25 Sep 2026 22:56:43 +0800 Subject: [PATCH 01/11] Make ML-DSA-87 the default scheme for new mobile wallet accounts New wallets and new accounts use ML-DSA-87 again. Existing accounts keep their scheme and address. Import still discovers accounts of both schemes, and when the indexer is unreachable it falls back to adding the ML-DSA-65 root instead of the ML-DSA-87 one, which is now the default root. The per-wallet scheme rule that kept old wallets on their previous scheme is gone: every new account is ML-DSA-87. Discovery results and the account list sort the current scheme first. --- .../screens/import/import_wallet_screen.dart | 38 ++++++------- .../dilithium_scheme_extension.dart | 5 +- quantus_sdk/lib/src/models/account.dart | 8 +-- .../services/account_discovery_service.dart | 7 +-- .../lib/src/services/accounts_service.dart | 10 +--- .../test/models/account_scheme_test.dart | 20 +------ .../account_discovery_scheme_test.dart | 10 ++-- .../accounts_service_scheme_test.dart | 53 +++++++++++++++++++ 8 files changed, 91 insertions(+), 60 deletions(-) create mode 100644 quantus_sdk/test/services/accounts_service_scheme_test.dart diff --git a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart index 928015564..9de2d1fad 100644 --- a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart +++ b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart @@ -132,31 +132,33 @@ class _ImportWalletScreenV2State extends ConsumerState { } catch (e) { quantusPrint('error discovering accounts: $e'); TelemetryService().sendError('Error discovering accounts', error: e); - // Discovery is best-effort, but an old ML-DSA-87 seed must still yield its - // funded root account even when the indexer is unreachable. - await _addLegacyRootFallback(mnemonic); + // Discovery is best-effort, but a seed whose root was created under + // another scheme must still yield that funded root account even when the + // indexer is unreachable. + await _addOtherSchemeRootsFallback(mnemonic); } } - Future _addLegacyRootFallback(String mnemonic) async { + Future _addOtherSchemeRootsFallback(String mnemonic) async { try { - const legacy = DilithiumSchemeExtension.legacy; - final path = HdWalletService.pathForIndex(0, legacy); - final key = HdWalletService().keyPairAtPath(mnemonic, path, legacy); final existing = (await _accountsService.getAccounts()).map((e) => e.accountId).toSet(); - if (existing.contains(key.ss58Address)) return; - await _accountsService.addAccount( - Account.derived( - walletIndex: widget.walletIndex, - index: 0, - name: 'Account ${existing.length + 1}', - keypair: key, - derivationPath: path, - ), - ); + for (final scheme in DilithiumScheme.values.where((s) => s != DilithiumSchemeExtension.current)) { + final path = HdWalletService.pathForIndex(0, scheme); + final key = HdWalletService().keyPairAtPath(mnemonic, path, scheme); + if (!existing.add(key.ss58Address)) continue; + await _accountsService.addAccount( + Account.derived( + walletIndex: widget.walletIndex, + index: 0, + name: 'Account ${existing.length}', + keypair: key, + derivationPath: path, + ), + ); + } invalidateAccountProviders(ref); } catch (e) { - quantusPrint('legacy root fallback failed: $e'); + quantusPrint('other-scheme root fallback failed: $e'); } } diff --git a/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart b/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart index 72078161c..4d853937e 100644 --- a/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart +++ b/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart @@ -5,9 +5,10 @@ import 'package:quantus_sdk/src/rust/api/crypto.dart'; /// Scheme-dependent constants, in one place. Conventions match quantus-cli. extension DilithiumSchemeExtension on DilithiumScheme { /// Scheme new wallets and accounts use. - static const DilithiumScheme current = DilithiumScheme.mlDsa65; + static const DilithiumScheme current = DilithiumScheme.mlDsa87; - /// Scheme of accounts stored before the scheme was recorded. + /// Scheme of accounts stored before the scheme was recorded, when ML-DSA-87 + /// was the only one. static const DilithiumScheme legacy = DilithiumScheme.mlDsa87; /// Variant index of the chain's `DilithiumSignatureScheme`, written into every signed extrinsic. diff --git a/quantus_sdk/lib/src/models/account.dart b/quantus_sdk/lib/src/models/account.dart index 0b247cbce..09e80a3ee 100644 --- a/quantus_sdk/lib/src/models/account.dart +++ b/quantus_sdk/lib/src/models/account.dart @@ -117,11 +117,11 @@ class Account implements BaseAccount { return s != 0 ? s : a.index.compareTo(b.index); } - /// Sort position by scheme (current first, legacy next, keyless last). This is - /// an ordering key, not the derivation path index (which is 0 for 87, 1 for 65). + /// Sort position by scheme (current first, other schemes next, keyless last). + /// This is an ordering key, not the derivation path index (which is 0 for 87, 1 for 65). static int _schemeSortOrder(DilithiumScheme? scheme) => switch (scheme) { - DilithiumSchemeExtension.current => 0, - DilithiumSchemeExtension.legacy => 1, null => 2, + DilithiumSchemeExtension.current => 0, + _ => 1, }; } diff --git a/quantus_sdk/lib/src/services/account_discovery_service.dart b/quantus_sdk/lib/src/services/account_discovery_service.dart index b619bc719..061bf8e7d 100644 --- a/quantus_sdk/lib/src/services/account_discovery_service.dart +++ b/quantus_sdk/lib/src/services/account_discovery_service.dart @@ -18,17 +18,18 @@ class AccountDiscoveryService { /// Discovers on-chain HD accounts of every signature scheme using the BIP-44 /// gap-limit algorithm per scheme: scan HD indices in batches and keep going /// as long as accounts exist, stopping once [gapLimit] consecutive indices - /// have no on-chain account. Current-scheme accounts come first, by index. + /// have no on-chain account. Accounts come in [Account.compare] order: + /// current scheme first, then by index. Future> discoverAccounts({ required String mnemonic, required int walletIndex, int gapLimit = 20, }) async { final perScheme = await Future.wait([ - for (final scheme in [DilithiumSchemeExtension.current, DilithiumSchemeExtension.legacy]) + for (final scheme in DilithiumScheme.values) _discoverScheme(mnemonic: mnemonic, walletIndex: walletIndex, scheme: scheme, gapLimit: gapLimit), ]); - return perScheme.expand((accounts) => accounts).toList(); + return perScheme.expand((accounts) => accounts).toList()..sort(Account.compare); } Future> _discoverScheme({ diff --git a/quantus_sdk/lib/src/services/accounts_service.dart b/quantus_sdk/lib/src/services/accounts_service.dart index bc615114d..2e2f21abb 100644 --- a/quantus_sdk/lib/src/services/accounts_service.dart +++ b/quantus_sdk/lib/src/services/accounts_service.dart @@ -18,21 +18,13 @@ class AccountsService { final SettingsService _settingsService = SettingsService(); void Function()? onAccountsChanged; - /// Scheme new accounts of [walletIndex] use: the current scheme once the - /// wallet holds any account of it, otherwise the legacy one, so pre-existing - /// wallets stay uniform. - static DilithiumScheme walletScheme(Iterable accounts, int walletIndex) => - accounts.any((a) => a.walletIndex == walletIndex && a.scheme == DilithiumSchemeExtension.current) - ? DilithiumSchemeExtension.current - : DilithiumSchemeExtension.legacy; - 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 accounts = await getAccounts(); - final scheme = walletScheme(accounts, walletIndex); + const scheme = DilithiumSchemeExtension.current; final nextIndex = await _settingsService.getNextFreeAccountIndex(walletIndex, scheme: scheme); final path = HdWalletService.pathForIndex(nextIndex, scheme); return Account.derived( diff --git a/quantus_sdk/test/models/account_scheme_test.dart b/quantus_sdk/test/models/account_scheme_test.dart index e948afc09..369769e84 100644 --- a/quantus_sdk/test/models/account_scheme_test.dart +++ b/quantus_sdk/test/models/account_scheme_test.dart @@ -48,25 +48,7 @@ void main() { local(0, DilithiumScheme.mlDsa65), local(0, DilithiumScheme.mlDsa87), ]..sort(Account.compare); - expect(sorted.map((a) => a.accountId), ['ml-dsa-65_0', 'ml-dsa-87_0', 'ml-dsa-87_1', 'k']); - }); - }); - - group('AccountsService.walletScheme', () { - Account local(int wallet, DilithiumScheme s) => - Account(walletIndex: wallet, index: 0, name: 'a', accountId: '$wallet${s.storageName}', scheme: s); - - test('a wallet with only legacy accounts stays legacy', () { - expect(AccountsService.walletScheme([local(0, DilithiumScheme.mlDsa87)], 0), DilithiumScheme.mlDsa87); - }); - - test('a wallet holding any current-scheme account grows as current', () { - final accounts = [local(0, DilithiumScheme.mlDsa87), local(0, DilithiumScheme.mlDsa65)]; - expect(AccountsService.walletScheme(accounts, 0), DilithiumScheme.mlDsa65); - }); - - test('an empty or unrelated wallet defaults to legacy', () { - expect(AccountsService.walletScheme([local(1, DilithiumScheme.mlDsa65)], 0), DilithiumScheme.mlDsa87); + expect(sorted.map((a) => a.accountId), ['ml-dsa-87_0', 'ml-dsa-87_1', 'ml-dsa-65_0', 'k']); }); }); } diff --git a/quantus_sdk/test/services/account_discovery_scheme_test.dart b/quantus_sdk/test/services/account_discovery_scheme_test.dart index fc83e8d39..e544c4524 100644 --- a/quantus_sdk/test/services/account_discovery_scheme_test.dart +++ b/quantus_sdk/test/services/account_discovery_scheme_test.dart @@ -46,11 +46,11 @@ void main() { onChain, ).discoverAccounts(mnemonic: mnemonic, walletIndex: 0); - // Current scheme first (65 at index 0), then legacy by index (87 at 0 and 2). + // Current scheme first by index (87 at 0 and 2), then the other scheme (65 at 0). expect(discovered.map((a) => (a.scheme, a.index)).toList(), [ - (DilithiumScheme.mlDsa65, 0), (DilithiumScheme.mlDsa87, 0), (DilithiumScheme.mlDsa87, 2), + (DilithiumScheme.mlDsa65, 0), ]); for (final account in discovered) { expect(account.accountId, address(account.index, account.scheme!)); @@ -58,15 +58,15 @@ void main() { } }); - test('discovery finds legacy accounts even when the current-scheme root is empty', () async { - final onChain = {address(1, DilithiumScheme.mlDsa87)}; + test('discovery finds ML-DSA-65 accounts even when the current-scheme root is empty', () async { + final onChain = {address(1, DilithiumScheme.mlDsa65)}; final discovered = await _FakeDiscovery( HdWalletService(), onChain, ).discoverAccounts(mnemonic: mnemonic, walletIndex: 0); expect(discovered, hasLength(1)); - expect(discovered.single.scheme, DilithiumScheme.mlDsa87); + expect(discovered.single.scheme, DilithiumScheme.mlDsa65); expect(discovered.single.index, 1); }); } diff --git a/quantus_sdk/test/services/accounts_service_scheme_test.dart b/quantus_sdk/test/services/accounts_service_scheme_test.dart new file mode 100644 index 000000000..8791935a6 --- /dev/null +++ b/quantus_sdk/test/services/accounts_service_scheme_test.dart @@ -0,0 +1,53 @@ +@Tags(['native']) +library; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const mnemonic = + 'orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven'; + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await QuantusSdk.init(); + }); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + FlutterSecureStorage.setMockInitialValues({SettingsService().getMnemonicKey(0): mnemonic}); + await SettingsService().initialize(); + }); + + Account local(int index, DilithiumScheme scheme) => Account.derived( + walletIndex: 0, + index: index, + name: 'Account ${index + 1}', + keypair: HdWalletService().keyPairAtIndex(mnemonic, index, scheme), + derivationPath: HdWalletService.pathForIndex(index, scheme), + ); + + test('a new account uses ML-DSA-87 at the next free index of that scheme', () async { + await SettingsService().saveAccounts([local(0, DilithiumScheme.mlDsa87), local(1, DilithiumScheme.mlDsa87)]); + + final created = await AccountsService().createNewAccount(walletIndex: 0); + + expect(created.scheme, DilithiumScheme.mlDsa87); + expect(created.index, 2); + expect(created.accountId, HdWalletService().keyPairAtIndex(mnemonic, 2, DilithiumScheme.mlDsa87).ss58Address); + }); + + test('a wallet holding only ML-DSA-65 accounts still gets ML-DSA-87 accounts', () async { + await SettingsService().saveAccounts([local(0, DilithiumScheme.mlDsa65)]); + + final created = await AccountsService().createNewAccount(walletIndex: 0); + + expect(created.scheme, DilithiumScheme.mlDsa87); + expect(created.index, 0); + expect(created.derivationPath, HdWalletService.pathForIndex(0, DilithiumScheme.mlDsa87)); + }); +} From bf5cd9f598f56a09085e4e4487dd109f21616f21 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 26 Sep 2026 10:23:56 +0800 Subject: [PATCH 02/11] Drop the root-account fallback from wallet import Import always discovers accounts of both schemes and adds every account found, whatever its scheme or index. When discovery fails, the error is logged and reported, and no root account is guessed for another scheme. --- .../screens/import/import_wallet_screen.dart | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart index 9de2d1fad..44897e5be 100644 --- a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart +++ b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart @@ -132,33 +132,6 @@ class _ImportWalletScreenV2State extends ConsumerState { } catch (e) { quantusPrint('error discovering accounts: $e'); TelemetryService().sendError('Error discovering accounts', error: e); - // Discovery is best-effort, but a seed whose root was created under - // another scheme must still yield that funded root account even when the - // indexer is unreachable. - await _addOtherSchemeRootsFallback(mnemonic); - } - } - - Future _addOtherSchemeRootsFallback(String mnemonic) async { - try { - final existing = (await _accountsService.getAccounts()).map((e) => e.accountId).toSet(); - for (final scheme in DilithiumScheme.values.where((s) => s != DilithiumSchemeExtension.current)) { - final path = HdWalletService.pathForIndex(0, scheme); - final key = HdWalletService().keyPairAtPath(mnemonic, path, scheme); - if (!existing.add(key.ss58Address)) continue; - await _accountsService.addAccount( - Account.derived( - walletIndex: widget.walletIndex, - index: 0, - name: 'Account ${existing.length}', - keypair: key, - derivationPath: path, - ), - ); - } - invalidateAccountProviders(ref); - } catch (e) { - quantusPrint('other-scheme root fallback failed: $e'); } } From 62ab11eb5bdd395044ce3b6291f13d8fee374a9f Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 26 Sep 2026 10:48:13 +0800 Subject: [PATCH 03/11] Ask to retry the account scan when import cannot reach the indexer The scan step of wallet import moves into WalletCreationService with an injected AccountDiscoveryService. It adds every account found in either scheme at any index, and hands a failed scan to a callback that decides whether to run it again. The import screen answers that callback with a dialog: "Account scan failed", with Try again and Skip. Try again reruns the scan, Skip finishes the import with the root account only. The failure is still logged and reported to telemetry. Mockito tests cover the scan with a discovery service that fails first: accounts added on the retry, nothing added when the retry is declined, and the root kept active when the scan finds it. --- mobile-app/lib/l10n/app_en.arb | 16 ++ mobile-app/lib/l10n/app_id.arb | 4 + mobile-app/lib/l10n/app_localizations.dart | 24 ++ mobile-app/lib/l10n/app_localizations_en.dart | 12 + mobile-app/lib/l10n/app_localizations_id.dart | 12 + .../lib/services/wallet_creation_service.dart | 42 ++- .../screens/import/import_wallet_screen.dart | 56 ++-- .../unit/wallet_creation_service_test.dart | 116 +++++++- .../wallet_creation_service_test.mocks.dart | 248 ++++++++++++++---- 9 files changed, 456 insertions(+), 74 deletions(-) diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index d033d154e..ed227d2a8 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -143,6 +143,22 @@ "@importWalletValidationError": { "description": "Validation error when recovery phrase word count is invalid" }, + "importWalletScanFailedTitle": "Account scan failed", + "@importWalletScanFailedTitle": { + "description": "Title of the dialog shown when looking up the imported wallet's accounts fails" + }, + "importWalletScanFailedBody": "We could not reach the network to look for your accounts. Try again?", + "@importWalletScanFailedBody": { + "description": "Body of the dialog shown when looking up the imported wallet's accounts fails" + }, + "importWalletScanRetry": "Try again", + "@importWalletScanRetry": { + "description": "Button that runs the account scan again" + }, + "importWalletScanSkip": "Skip", + "@importWalletScanSkip": { + "description": "Button that finishes the import without scanning for accounts" + }, "homeError": "Error: {error}", "@homeError": { diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index ac1db31d0..f42003fcc 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -32,6 +32,10 @@ "importWalletHint": "Ketik atau tempel recovery phrase Anda. Pisahkan kata dengan spasi.", "importWalletButton": "Impor", "importWalletValidationError": "Recovery phrase harus 12 atau 24 kata", + "importWalletScanFailedTitle": "Pemindaian akun gagal", + "importWalletScanFailedBody": "Kami tidak dapat terhubung ke jaringan untuk mencari akun Anda. Coba lagi?", + "importWalletScanRetry": "Coba lagi", + "importWalletScanSkip": "Lewati", "homeError": "Gagal: {error}", "homeNoActiveAccount": "Tidak ada akun aktif", "homeErrorLoadingBalance": "Gagal memuat saldo", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index 5c50e0ceb..72a3e7021 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -290,6 +290,30 @@ abstract class AppLocalizations { /// **'Recovery phrase must be 12 or 24 words'** String get importWalletValidationError; + /// Title of the dialog shown when looking up the imported wallet's accounts fails + /// + /// In en, this message translates to: + /// **'Account scan failed'** + String get importWalletScanFailedTitle; + + /// Body of the dialog shown when looking up the imported wallet's accounts fails + /// + /// In en, this message translates to: + /// **'We could not reach the network to look for your accounts. Try again?'** + String get importWalletScanFailedBody; + + /// Button that runs the account scan again + /// + /// In en, this message translates to: + /// **'Try again'** + String get importWalletScanRetry; + + /// Button that finishes the import without scanning for accounts + /// + /// In en, this message translates to: + /// **'Skip'** + String get importWalletScanSkip; + /// Error message on the home screen /// /// In en, this message translates to: diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index aca4c8ed1..ed3fb3d32 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -111,6 +111,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get importWalletValidationError => 'Recovery phrase must be 12 or 24 words'; + @override + String get importWalletScanFailedTitle => 'Account scan failed'; + + @override + String get importWalletScanFailedBody => 'We could not reach the network to look for your accounts. Try again?'; + + @override + String get importWalletScanRetry => 'Try again'; + + @override + String get importWalletScanSkip => 'Skip'; + @override String homeError(String error) { return 'Error: $error'; diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index a3d71bbb2..71d306745 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -112,6 +112,18 @@ class AppLocalizationsId extends AppLocalizations { @override String get importWalletValidationError => 'Recovery phrase harus 12 atau 24 kata'; + @override + String get importWalletScanFailedTitle => 'Pemindaian akun gagal'; + + @override + String get importWalletScanFailedBody => 'Kami tidak dapat terhubung ke jaringan untuk mencari akun Anda. Coba lagi?'; + + @override + String get importWalletScanRetry => 'Coba lagi'; + + @override + String get importWalletScanSkip => 'Lewati'; + @override String homeError(String error) { return 'Gagal: $error'; diff --git a/mobile-app/lib/services/wallet_creation_service.dart b/mobile-app/lib/services/wallet_creation_service.dart index 463cfc5ee..c7f3797d5 100644 --- a/mobile-app/lib/services/wallet_creation_service.dart +++ b/mobile-app/lib/services/wallet_creation_service.dart @@ -14,10 +14,15 @@ import 'package:resonance_network_wallet/v2/screens/accounts/account_ready_scree class WalletCreationService { final SettingsService _settings; final AccountsService _accounts; + final AccountDiscoveryService _discovery; - WalletCreationService({SettingsService? settingsService, AccountsService? accountsService}) - : _settings = settingsService ?? SettingsService(), - _accounts = accountsService ?? AccountsService(); + WalletCreationService({ + SettingsService? settingsService, + AccountsService? accountsService, + AccountDiscoveryService? discoveryService, + }) : _settings = settingsService ?? SettingsService(), + _accounts = accountsService ?? AccountsService(), + _discovery = discoveryService ?? AccountDiscoveryService(HdWalletService()); /// Saves [mnemonic] for [walletIndex], inserts its root account and makes /// that the active account. @@ -61,6 +66,37 @@ class WalletCreationService { } return account; } + + /// Adds every on-chain account of [mnemonic] to [walletIndex]: both + /// signature schemes, any derivation index. When the root at + /// [defaultAccountId] has no history but funded accounts were found, the + /// first of them becomes active so a returning user lands on it. + /// + /// A failed scan is handed to [onScanFailed]; the scan runs again while it + /// answers true and stops once it answers false. + Future discoverImportedAccounts({ + required String mnemonic, + required int walletIndex, + required String defaultAccountId, + required Future Function(Object error) onScanFailed, + }) async { + while (true) { + try { + final discovered = await _discovery.discoverAccounts(mnemonic: mnemonic, walletIndex: walletIndex); + final existing = (await _accounts.getAccounts()).map((a) => a.accountId).toSet(); + var count = existing.length; + for (final account in discovered.where((a) => !existing.contains(a.accountId))) { + await _accounts.addAccount(account.copyWith(name: 'Account ${++count}')); + } + if (discovered.isNotEmpty && !discovered.any((a) => a.accountId == defaultAccountId)) { + await _settings.setActiveAccount(RegularAccount(discovered.first)); + } + return; + } catch (e) { + if (!await onScanFailed(e)) return; + } + } + } } /// Creates a software wallet on the next free wallet index from a fresh diff --git a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart index 44897e5be..485dc4980 100644 --- a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart +++ b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart @@ -10,6 +10,7 @@ import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/services/firebase_messaging_service.dart'; import 'package:resonance_network_wallet/services/telemetry_service.dart'; +import 'package:resonance_network_wallet/services/wallet_creation_service.dart'; import 'package:resonance_network_wallet/shared/constants/e2e_keys.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; import 'package:resonance_network_wallet/v2/screens/accounts/wallet_name_screen.dart'; @@ -34,7 +35,10 @@ class _ImportWalletScreenV2State extends ConsumerState { final _buttonKey = GlobalKey(); final _settingsService = SettingsService(); final _accountsService = AccountsService(); - final _discoveryService = AccountDiscoveryService(HdWalletService()); + late final _walletCreationService = WalletCreationService( + settingsService: _settingsService, + accountsService: _accountsService, + ); bool _isLoading = false; String? _error; @@ -108,31 +112,33 @@ class _ImportWalletScreenV2State extends ConsumerState { } } - /// Discovers on-chain HD accounts across both signature schemes. Multisigs - /// are added manually via Add Account → Discover Multisig. - /// - /// [defaultAccountId] is the current-scheme account 0 added before discovery. - /// When it has no on-chain history but discovery finds funded accounts, the - /// first funded one is made active so a returning user lands on it. + /// Adds the on-chain accounts of both signature schemes. Multisigs are added + /// manually via Add Account → Discover Multisig. [defaultAccountId] is the + /// current-scheme account 0 added before the scan. Future _discoverAccounts(String mnemonic, {required String defaultAccountId}) async { - try { - final discovered = await _discoveryService.discoverAccounts(mnemonic: mnemonic, walletIndex: widget.walletIndex); - final current = await _accountsService.getAccounts(); - final existing = current.map((e) => e.accountId).toSet(); - var count = current.length; - for (final account in discovered) { - if (existing.contains(account.accountId)) continue; - await _accountsService.addAccount(account.copyWith(name: 'Account ${++count}')); - } - if (!discovered.any((a) => a.accountId == defaultAccountId) && discovered.isNotEmpty) { - await _settingsService.setActiveAccount(RegularAccount(discovered.first)); - } - invalidateAccountProviders(ref); - unawaited(_discoverEncryptedAccount()); - } catch (e) { - quantusPrint('error discovering accounts: $e'); - TelemetryService().sendError('Error discovering accounts', error: e); - } + await _walletCreationService.discoverImportedAccounts( + mnemonic: mnemonic, + walletIndex: widget.walletIndex, + defaultAccountId: defaultAccountId, + onScanFailed: _askRetryScan, + ); + invalidateAccountProviders(ref); + unawaited(_discoverEncryptedAccount()); + } + + Future _askRetryScan(Object error) async { + quantusPrint('error discovering accounts: $error'); + TelemetryService().sendError('Error discovering accounts', error: error); + if (!mounted) return false; + final l10n = ref.read(l10nProvider); + return showQuantusDialog( + context, + title: l10n.importWalletScanFailedTitle, + body: l10n.importWalletScanFailedBody, + actionLabel: l10n.importWalletScanRetry, + cancelLabel: l10n.importWalletScanSkip, + barrierDismissible: false, + ); } /// Restores the wallet's encrypted account and warms its wormhole address diff --git a/mobile-app/test/unit/wallet_creation_service_test.dart b/mobile-app/test/unit/wallet_creation_service_test.dart index 08d073a71..4955e0015 100644 --- a/mobile-app/test/unit/wallet_creation_service_test.dart +++ b/mobile-app/test/unit/wallet_creation_service_test.dart @@ -4,7 +4,7 @@ import 'package:mockito/mockito.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/services/wallet_creation_service.dart'; -@GenerateNiceMocks([MockSpec(), MockSpec()]) +@GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) import 'wallet_creation_service_test.mocks.dart'; void main() { @@ -91,4 +91,118 @@ void main() { verifyNever(settings.deleteMnemonic(any)); }); }); + + group('WalletCreationService.discoverImportedAccounts', () { + const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + Account at(int index, DilithiumScheme scheme) => Account( + walletIndex: 0, + index: index, + name: 'Account ${index + 1}', + accountId: '${scheme.storageName}_$index', + scheme: scheme, + derivationPath: HdWalletService.pathForIndex(index, scheme), + ); + final root = at(0, DilithiumScheme.mlDsa87); + final found = [at(3, DilithiumScheme.mlDsa87), at(0, DilithiumScheme.mlDsa65)]; + Matcher account(String accountId, {String? name}) { + var m = isA().having((a) => a.accountId, 'accountId', accountId); + return name == null ? m : m.having((a) => a.name, 'name', name); + } + + late MockSettingsService settings; + late MockAccountsService accounts; + late MockAccountDiscoveryService discovery; + late WalletCreationService service; + + setUp(() { + settings = MockSettingsService(); + accounts = MockAccountsService(); + discovery = MockAccountDiscoveryService(); + service = WalletCreationService( + settingsService: settings, + accountsService: accounts, + discoveryService: discovery, + ); + when(accounts.getAccounts()).thenAnswer((_) async => [root]); + }); + + void scanReturns(Future> Function() answer) => when( + discovery.discoverAccounts( + mnemonic: anyNamed('mnemonic'), + walletIndex: anyNamed('walletIndex'), + gapLimit: anyNamed('gapLimit'), + ), + ).thenAnswer((_) => answer()); + + Future discover(Future Function(Object error) onScanFailed) => service.discoverImportedAccounts( + mnemonic: mnemonic, + walletIndex: 0, + defaultAccountId: root.accountId, + onScanFailed: onScanFailed, + ); + + test( + 'adds every account found, whatever its scheme or index, and activates the first when the root is empty', + () async { + scanReturns(() async => found); + final failures = []; + + await discover((e) async { + failures.add(e); + return false; + }); + + expect(failures, isEmpty); + verifyInOrder([ + accounts.addAccount(argThat(account('ml-dsa-87_3', name: 'Account 2'))), + accounts.addAccount(argThat(account('ml-dsa-65_0', name: 'Account 3'))), + settings.setActiveAccount( + argThat(isA().having((a) => a.account.accountId, 'accountId', 'ml-dsa-87_3')), + ), + ]); + }, + ); + + test('keeps the root active and does not re-add it when the scan finds it', () async { + scanReturns(() async => [root, found.first]); + + await discover((_) async => false); + + verify(accounts.addAccount(argThat(account('ml-dsa-87_3')))).called(1); + verifyNever(accounts.addAccount(argThat(account(root.accountId)))); + verifyNever(settings.setActiveAccount(any)); + }); + + test('a failed scan is retried when asked and adds the accounts on the second attempt', () async { + var scans = 0; + scanReturns(() async { + if (scans++ == 0) throw Exception('indexer unreachable'); + return found; + }); + final failures = []; + + await discover((e) async { + failures.add(e); + return true; + }); + + expect(failures, hasLength(1)); + expect(scans, 2); + verify(accounts.addAccount(any)).called(2); + }); + + test('declining the retry ends the scan without adding anything', () async { + scanReturns(() async => throw Exception('indexer unreachable')); + var asked = 0; + + await discover((_) async { + asked++; + return false; + }); + + expect(asked, 1); + verifyNever(accounts.addAccount(any)); + verifyNever(settings.setActiveAccount(any)); + }); + }); } diff --git a/mobile-app/test/unit/wallet_creation_service_test.mocks.dart b/mobile-app/test/unit/wallet_creation_service_test.mocks.dart index 26cda2f0b..39d57d097 100644 --- a/mobile-app/test/unit/wallet_creation_service_test.mocks.dart +++ b/mobile-app/test/unit/wallet_creation_service_test.mocks.dart @@ -9,6 +9,7 @@ import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i8; import 'package:quantus_sdk/quantus_sdk.dart' as _i3; import 'package:quantus_sdk/src/models/account.dart' as _i2; +import 'package:quantus_sdk/src/models/airdrop_claim_record.dart' as _i9; import 'package:quantus_sdk/src/models/display_account.dart' as _i5; import 'package:quantus_sdk/src/models/multisig_account.dart' as _i7; import 'package:quantus_sdk/src/rust/api/crypto.dart' as _i6; @@ -29,7 +30,8 @@ import 'package:quantus_sdk/src/rust/api/crypto.dart' as _i6; // ignore_for_file: invalid_use_of_internal_member class _FakeAccount_0 extends _i1.SmartFake implements _i2.Account { - _FakeAccount_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); + _FakeAccount_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } /// A class which mocks [SettingsService]. @@ -50,7 +52,9 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { (super.noSuchMethod( Invocation.method(#getAccounts, []), returnValue: _i4.Future>.value(<_i2.Account>[]), - returnValueForMissingStub: _i4.Future>.value(<_i2.Account>[]), + returnValueForMissingStub: _i4.Future>.value( + <_i2.Account>[], + ), ) as _i4.Future>); @@ -136,9 +140,16 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as _i4.Future<_i2.Account?>); @override - _i4.Future getNextFreeAccountIndex(int? walletIndex, {_i6.DilithiumScheme? scheme}) => + _i4.Future getNextFreeAccountIndex( + int? walletIndex, { + _i6.DilithiumScheme? scheme, + }) => (super.noSuchMethod( - Invocation.method(#getNextFreeAccountIndex, [walletIndex], {#scheme: scheme}), + Invocation.method( + #getNextFreeAccountIndex, + [walletIndex], + {#scheme: scheme}, + ), returnValue: _i4.Future.value(0), returnValueForMissingStub: _i4.Future.value(0), ) @@ -148,8 +159,13 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { _i4.Future> getMultisigAccounts() => (super.noSuchMethod( Invocation.method(#getMultisigAccounts, []), - returnValue: _i4.Future>.value(<_i7.MultisigAccount>[]), - returnValueForMissingStub: _i4.Future>.value(<_i7.MultisigAccount>[]), + returnValue: _i4.Future>.value( + <_i7.MultisigAccount>[], + ), + returnValueForMissingStub: + _i4.Future>.value( + <_i7.MultisigAccount>[], + ), ) as _i4.Future>); @@ -184,8 +200,12 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { _i4.Future> getAddressBook() => (super.noSuchMethod( Invocation.method(#getAddressBook, []), - returnValue: _i4.Future>.value({}), - returnValueForMissingStub: _i4.Future>.value({}), + returnValue: _i4.Future>.value( + {}, + ), + returnValueForMissingStub: _i4.Future>.value( + {}, + ), ) as _i4.Future>); @@ -247,8 +267,14 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { String getMnemonicKey(int? walletIndex) => (super.noSuchMethod( Invocation.method(#getMnemonicKey, [walletIndex]), - returnValue: _i8.dummyValue(this, Invocation.method(#getMnemonicKey, [walletIndex])), - returnValueForMissingStub: _i8.dummyValue(this, Invocation.method(#getMnemonicKey, [walletIndex])), + returnValue: _i8.dummyValue( + this, + Invocation.method(#getMnemonicKey, [walletIndex]), + ), + returnValueForMissingStub: _i8.dummyValue( + this, + Invocation.method(#getMnemonicKey, [walletIndex]), + ), ) as String); @@ -326,7 +352,11 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { @override bool isBalanceHidden() => - (super.noSuchMethod(Invocation.method(#isBalanceHidden, []), returnValue: false, returnValueForMissingStub: false) + (super.noSuchMethod( + Invocation.method(#isBalanceHidden, []), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); @override @@ -385,7 +415,11 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { @override bool? getBool(String? key) => - (super.noSuchMethod(Invocation.method(#getBool, [key]), returnValueForMissingStub: null) as bool?); + (super.noSuchMethod( + Invocation.method(#getBool, [key]), + returnValueForMissingStub: null, + ) + as bool?); @override _i4.Future setBool(String? key, bool? value) => @@ -398,7 +432,11 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { @override String? getString(String? key) => - (super.noSuchMethod(Invocation.method(#getString, [key]), returnValueForMissingStub: null) as String?); + (super.noSuchMethod( + Invocation.method(#getString, [key]), + returnValueForMissingStub: null, + ) + as String?); @override _i4.Future setString(String? key, String? value) => @@ -410,7 +448,10 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as _i4.Future); @override - void resetForTest() => super.noSuchMethod(Invocation.method(#resetForTest, []), returnValueForMissingStub: null); + void resetForTest() => super.noSuchMethod( + Invocation.method(#resetForTest, []), + returnValueForMissingStub: null, + ); @override _i4.Future clearAll() => @@ -431,8 +472,10 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as bool); @override - void setReferralCheckCompleted() => - super.noSuchMethod(Invocation.method(#setReferralCheckCompleted, []), returnValueForMissingStub: null); + void setReferralCheckCompleted() => super.noSuchMethod( + Invocation.method(#setReferralCheckCompleted, []), + returnValueForMissingStub: null, + ); @override bool isMainnetMigrationDone() => @@ -453,12 +496,16 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as _i4.Future); @override - void clearReferralCheckCompletedFlag() => - super.noSuchMethod(Invocation.method(#clearReferralCheckCompletedFlag, []), returnValueForMissingStub: null); + void clearReferralCheckCompletedFlag() => super.noSuchMethod( + Invocation.method(#clearReferralCheckCompletedFlag, []), + returnValueForMissingStub: null, + ); @override - void setReferralCode(String? code) => - super.noSuchMethod(Invocation.method(#setReferralCode, [code]), returnValueForMissingStub: null); + void setReferralCode(String? code) => super.noSuchMethod( + Invocation.method(#setReferralCode, [code]), + returnValueForMissingStub: null, + ); @override bool hasWatchedQuestsPromo() => @@ -470,12 +517,16 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as bool); @override - void setQuestsPromoWatched() => - super.noSuchMethod(Invocation.method(#setQuestsPromoWatched, []), returnValueForMissingStub: null); + void setQuestsPromoWatched() => super.noSuchMethod( + Invocation.method(#setQuestsPromoWatched, []), + returnValueForMissingStub: null, + ); @override - void clearQuestsPromoWatchedFlag() => - super.noSuchMethod(Invocation.method(#clearQuestsPromoWatchedFlag, []), returnValueForMissingStub: null); + void clearQuestsPromoWatchedFlag() => super.noSuchMethod( + Invocation.method(#clearQuestsPromoWatchedFlag, []), + returnValueForMissingStub: null, + ); @override bool recoveryPhraseViewed(int? walletIndex) => @@ -487,21 +538,52 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as bool); @override - void setRecoveryPhraseViewed(int? walletIndex) => - super.noSuchMethod(Invocation.method(#setRecoveryPhraseViewed, [walletIndex]), returnValueForMissingStub: null); + void setRecoveryPhraseViewed(int? walletIndex) => super.noSuchMethod( + Invocation.method(#setRecoveryPhraseViewed, [walletIndex]), + returnValueForMissingStub: null, + ); + + @override + _i9.AirdropClaimRecord? getAirdropClaim(int? walletIndex) => + (super.noSuchMethod( + Invocation.method(#getAirdropClaim, [walletIndex]), + returnValueForMissingStub: null, + ) + as _i9.AirdropClaimRecord?); + + @override + _i4.Future setAirdropClaim( + int? walletIndex, + _i9.AirdropClaimRecord? record, + ) => + (super.noSuchMethod( + Invocation.method(#setAirdropClaim, [walletIndex, record]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); @override _i2.WalletOrigin? getWalletOrigin(int? walletIndex) => - (super.noSuchMethod(Invocation.method(#getWalletOrigin, [walletIndex]), returnValueForMissingStub: null) + (super.noSuchMethod( + Invocation.method(#getWalletOrigin, [walletIndex]), + returnValueForMissingStub: null, + ) as _i2.WalletOrigin?); @override void setWalletOrigin(int? walletIndex, _i2.WalletOrigin? origin) => - super.noSuchMethod(Invocation.method(#setWalletOrigin, [walletIndex, origin]), returnValueForMissingStub: null); + super.noSuchMethod( + Invocation.method(#setWalletOrigin, [walletIndex, origin]), + returnValueForMissingStub: null, + ); @override String? getWalletName(int? walletIndex) => - (super.noSuchMethod(Invocation.method(#getWalletName, [walletIndex]), returnValueForMissingStub: null) + (super.noSuchMethod( + Invocation.method(#getWalletName, [walletIndex]), + returnValueForMissingStub: null, + ) as String?); @override @@ -523,12 +605,16 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as bool); @override - void setExistingUserSeenPromoVideo() => - super.noSuchMethod(Invocation.method(#setExistingUserSeenPromoVideo, []), returnValueForMissingStub: null); + void setExistingUserSeenPromoVideo() => super.noSuchMethod( + Invocation.method(#setExistingUserSeenPromoVideo, []), + returnValueForMissingStub: null, + ); @override - void clearExistingUserSeenPromoVideoFlag() => - super.noSuchMethod(Invocation.method(#clearExistingUserSeenPromoVideoFlag, []), returnValueForMissingStub: null); + void clearExistingUserSeenPromoVideoFlag() => super.noSuchMethod( + Invocation.method(#clearExistingUserSeenPromoVideoFlag, []), + returnValueForMissingStub: null, + ); } /// A class which mocks [AccountsService]. @@ -536,45 +622,75 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { /// See the documentation for Mockito's code generation for more information. class MockAccountsService extends _i1.Mock implements _i3.AccountsService { @override - set onAccountsChanged(void Function()? value) => - super.noSuchMethod(Invocation.setter(#onAccountsChanged, value), returnValueForMissingStub: null); + set onAccountsChanged(void Function()? value) => super.noSuchMethod( + Invocation.setter(#onAccountsChanged, value), + returnValueForMissingStub: null, + ); @override _i4.Future<_i2.Account> createNewAccount({required int? walletIndex}) => (super.noSuchMethod( - Invocation.method(#createNewAccount, [], {#walletIndex: walletIndex}), + Invocation.method(#createNewAccount, [], { + #walletIndex: walletIndex, + }), returnValue: _i4.Future<_i2.Account>.value( - _FakeAccount_0(this, Invocation.method(#createNewAccount, [], {#walletIndex: walletIndex})), + _FakeAccount_0( + this, + Invocation.method(#createNewAccount, [], { + #walletIndex: walletIndex, + }), + ), ), returnValueForMissingStub: _i4.Future<_i2.Account>.value( - _FakeAccount_0(this, Invocation.method(#createNewAccount, [], {#walletIndex: walletIndex})), + _FakeAccount_0( + this, + Invocation.method(#createNewAccount, [], { + #walletIndex: walletIndex, + }), + ), ), ) as _i4.Future<_i2.Account>); @override - _i4.Future<_i2.Account> createEncryptedAccount({required int? walletIndex, required String? name}) => + _i4.Future<_i2.Account> createEncryptedAccount({ + required int? walletIndex, + required String? name, + }) => (super.noSuchMethod( - Invocation.method(#createEncryptedAccount, [], {#walletIndex: walletIndex, #name: name}), + Invocation.method(#createEncryptedAccount, [], { + #walletIndex: walletIndex, + #name: name, + }), returnValue: _i4.Future<_i2.Account>.value( _FakeAccount_0( this, - Invocation.method(#createEncryptedAccount, [], {#walletIndex: walletIndex, #name: name}), + Invocation.method(#createEncryptedAccount, [], { + #walletIndex: walletIndex, + #name: name, + }), ), ), returnValueForMissingStub: _i4.Future<_i2.Account>.value( _FakeAccount_0( this, - Invocation.method(#createEncryptedAccount, [], {#walletIndex: walletIndex, #name: name}), + Invocation.method(#createEncryptedAccount, [], { + #walletIndex: walletIndex, + #name: name, + }), ), ), ) as _i4.Future<_i2.Account>); @override - _i4.Future ensureEncryptedAccountsForSoftwareWallets({required String? name}) => + _i4.Future ensureEncryptedAccountsForSoftwareWallets({ + required String? name, + }) => (super.noSuchMethod( - Invocation.method(#ensureEncryptedAccountsForSoftwareWallets, [], {#name: name}), + Invocation.method(#ensureEncryptedAccountsForSoftwareWallets, [], { + #name: name, + }), returnValue: _i4.Future.value(false), returnValueForMissingStub: _i4.Future.value(false), ) @@ -603,7 +719,9 @@ class MockAccountsService extends _i1.Mock implements _i3.AccountsService { (super.noSuchMethod( Invocation.method(#getAccounts, []), returnValue: _i4.Future>.value(<_i2.Account>[]), - returnValueForMissingStub: _i4.Future>.value(<_i2.Account>[]), + returnValueForMissingStub: _i4.Future>.value( + <_i2.Account>[], + ), ) as _i4.Future>); @@ -625,3 +743,43 @@ class MockAccountsService extends _i1.Mock implements _i3.AccountsService { ) as _i4.Future); } + +/// A class which mocks [AccountDiscoveryService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockAccountDiscoveryService extends _i1.Mock + implements _i3.AccountDiscoveryService { + @override + _i4.Future> discoverAccounts({ + required String? mnemonic, + required int? walletIndex, + int? gapLimit = 20, + }) => + (super.noSuchMethod( + Invocation.method(#discoverAccounts, [], { + #mnemonic: mnemonic, + #walletIndex: walletIndex, + #gapLimit: gapLimit, + }), + returnValue: _i4.Future>.value(<_i2.Account>[]), + returnValueForMissingStub: _i4.Future>.value( + <_i2.Account>[], + ), + ) + as _i4.Future>); + + @override + _i4.Future> discoverUsedIndices({ + required String Function(int)? addressAt, + int? gapLimit = 20, + }) => + (super.noSuchMethod( + Invocation.method(#discoverUsedIndices, [], { + #addressAt: addressAt, + #gapLimit: gapLimit, + }), + returnValue: _i4.Future>.value({}), + returnValueForMissingStub: _i4.Future>.value({}), + ) + as _i4.Future>); +} From 3915fe7553b71815c61e391061f0fc9032f53114 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 26 Sep 2026 11:01:24 +0800 Subject: [PATCH 04/11] Finish a skipped import scan on a later app start A skipped or interrupted import scan left a returning wallet without its ML-DSA-65 accounts for good, since nothing scans again after import. SettingsService now keeps a per-wallet "account scan pending" flag. The scan sets it when it starts and clears it when it finishes. The home screen runs the pending scans once the accounts have loaded, so a wallet imported while the indexer was down gets its accounts on the next start. The scan-failed dialog says that Skip means the app scans again later. The regenerated mock file is formatted with the CI formatter. --- mobile-app/lib/l10n/app_en.arb | 2 +- mobile-app/lib/l10n/app_id.arb | 2 +- mobile-app/lib/l10n/app_localizations.dart | 2 +- mobile-app/lib/l10n/app_localizations_en.dart | 3 +- mobile-app/lib/l10n/app_localizations_id.dart | 3 +- .../lib/services/wallet_creation_service.dart | 40 ++- .../lib/v2/screens/home/home_screen.dart | 27 ++ mobile-app/test/fakes.dart | 3 + .../unit/wallet_creation_service_test.dart | 40 +++ .../wallet_creation_service_test.mocks.dart | 230 ++++++------------ .../lib/src/services/settings_service.dart | 15 ++ .../test/services/settings_service_test.dart | 9 + 12 files changed, 203 insertions(+), 173 deletions(-) diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index ed227d2a8..38fc6b5a8 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -147,7 +147,7 @@ "@importWalletScanFailedTitle": { "description": "Title of the dialog shown when looking up the imported wallet's accounts fails" }, - "importWalletScanFailedBody": "We could not reach the network to look for your accounts. Try again?", + "importWalletScanFailedBody": "We could not reach the network to look for your accounts. Try again now, or skip and the app will scan again the next time it starts.", "@importWalletScanFailedBody": { "description": "Body of the dialog shown when looking up the imported wallet's accounts fails" }, diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index f42003fcc..0ab0a6180 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -33,7 +33,7 @@ "importWalletButton": "Impor", "importWalletValidationError": "Recovery phrase harus 12 atau 24 kata", "importWalletScanFailedTitle": "Pemindaian akun gagal", - "importWalletScanFailedBody": "Kami tidak dapat terhubung ke jaringan untuk mencari akun Anda. Coba lagi?", + "importWalletScanFailedBody": "Kami tidak dapat terhubung ke jaringan untuk mencari akun Anda. Coba lagi sekarang, atau lewati dan aplikasi akan memindai lagi saat dibuka berikutnya.", "importWalletScanRetry": "Coba lagi", "importWalletScanSkip": "Lewati", "homeError": "Gagal: {error}", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index 72a3e7021..c71643ddb 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -299,7 +299,7 @@ abstract class AppLocalizations { /// Body of the dialog shown when looking up the imported wallet's accounts fails /// /// In en, this message translates to: - /// **'We could not reach the network to look for your accounts. Try again?'** + /// **'We could not reach the network to look for your accounts. Try again now, or skip and the app will scan again the next time it starts.'** String get importWalletScanFailedBody; /// Button that runs the account scan again diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index ed3fb3d32..e5d564aa9 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -115,7 +115,8 @@ class AppLocalizationsEn extends AppLocalizations { String get importWalletScanFailedTitle => 'Account scan failed'; @override - String get importWalletScanFailedBody => 'We could not reach the network to look for your accounts. Try again?'; + String get importWalletScanFailedBody => + 'We could not reach the network to look for your accounts. Try again now, or skip and the app will scan again the next time it starts.'; @override String get importWalletScanRetry => 'Try again'; diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index 71d306745..a4000680b 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -116,7 +116,8 @@ class AppLocalizationsId extends AppLocalizations { String get importWalletScanFailedTitle => 'Pemindaian akun gagal'; @override - String get importWalletScanFailedBody => 'Kami tidak dapat terhubung ke jaringan untuk mencari akun Anda. Coba lagi?'; + String get importWalletScanFailedBody => + 'Kami tidak dapat terhubung ke jaringan untuk mencari akun Anda. Coba lagi sekarang, atau lewati dan aplikasi akan memindai lagi saat dibuka berikutnya.'; @override String get importWalletScanRetry => 'Coba lagi'; diff --git a/mobile-app/lib/services/wallet_creation_service.dart b/mobile-app/lib/services/wallet_creation_service.dart index c7f3797d5..dccfa6ead 100644 --- a/mobile-app/lib/services/wallet_creation_service.dart +++ b/mobile-app/lib/services/wallet_creation_service.dart @@ -68,18 +68,21 @@ class WalletCreationService { } /// Adds every on-chain account of [mnemonic] to [walletIndex]: both - /// signature schemes, any derivation index. When the root at - /// [defaultAccountId] has no history but funded accounts were found, the - /// first of them becomes active so a returning user lands on it. + /// signature schemes, any derivation index. When [defaultAccountId] is given + /// and has no history but funded accounts were found, the first of them + /// becomes active so a returning user lands on it. /// /// A failed scan is handed to [onScanFailed]; the scan runs again while it - /// answers true and stops once it answers false. + /// answers true and stops once it answers false. The wallet's scan stays + /// marked pending until a scan finishes, so [resumePendingAccountScans] can + /// complete it later. Future discoverImportedAccounts({ required String mnemonic, required int walletIndex, - required String defaultAccountId, + String? defaultAccountId, required Future Function(Object error) onScanFailed, }) async { + await _settings.setAccountScanPending(walletIndex, true); while (true) { try { final discovered = await _discovery.discoverAccounts(mnemonic: mnemonic, walletIndex: walletIndex); @@ -88,15 +91,40 @@ class WalletCreationService { for (final account in discovered.where((a) => !existing.contains(a.accountId))) { await _accounts.addAccount(account.copyWith(name: 'Account ${++count}')); } - if (discovered.isNotEmpty && !discovered.any((a) => a.accountId == defaultAccountId)) { + if (defaultAccountId != null && + discovered.isNotEmpty && + !discovered.any((a) => a.accountId == defaultAccountId)) { await _settings.setActiveAccount(RegularAccount(discovered.first)); } + await _settings.setAccountScanPending(walletIndex, false); return; } catch (e) { if (!await onScanFailed(e)) return; } } } + + /// Finishes the import scan of every wallet in [accounts] whose scan was + /// skipped or interrupted, so accounts missed while the indexer was + /// unreachable still appear. Returns whether any scan finished. A scan that + /// fails again stays pending for the next call. + Future resumePendingAccountScans(Iterable accounts) async { + var finished = false; + for (final walletIndex in accounts.map((a) => a.walletIndex).toSet().where(_settings.isAccountScanPending)) { + final mnemonic = await _settings.getMnemonic(walletIndex); + if (mnemonic == null) throw StateError('Wallet $walletIndex has a pending account scan but no mnemonic'); + await discoverImportedAccounts( + mnemonic: mnemonic, + walletIndex: walletIndex, + onScanFailed: (e) async { + quantusPrint('Resumed account scan of wallet $walletIndex failed: $e'); + return false; + }, + ); + finished = finished || !_settings.isAccountScanPending(walletIndex); + } + return finished; + } } /// Creates a software wallet on the next free wallet index from a fresh diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index 68e4a2947..c757c29ba 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -39,6 +41,7 @@ import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; import 'package:resonance_network_wallet/v2/components/global_toast_listener.dart'; import 'package:resonance_network_wallet/v2/screens/home/activity_section.dart'; import 'package:resonance_network_wallet/v2/screens/home/backup_reminder_banner.dart'; +import 'package:resonance_network_wallet/services/wallet_creation_service.dart'; class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @@ -48,6 +51,8 @@ class HomeScreen extends ConsumerStatefulWidget { } class _HomeScreenState extends ConsumerState { + bool _resumedAccountScans = false; + @override void initState() { super.initState(); @@ -76,7 +81,29 @@ class _HomeScreenState extends ConsumerState { } }); + // An import scan skipped or interrupted while the indexer was unreachable + // is finished here, once the accounts are known. + ref.listenManual>>( + accountsProvider, + (_, async) => _resumePendingAccountScans(async.value), + ); + Future.microtask(_drainPendingIntents); + Future.microtask(() => _resumePendingAccountScans(ref.read(accountsProvider).value)); + } + + Future _resumePendingAccountScans(List? accounts) async { + if (accounts == null || _resumedAccountScans) return; + _resumedAccountScans = true; + try { + final service = WalletCreationService( + settingsService: ref.read(settingsServiceProvider), + accountsService: ref.read(accountsServiceProvider), + ); + if (await service.resumePendingAccountScans(accounts) && mounted) invalidateAccountProviders(ref); + } catch (e) { + quantusPrint('Resuming pending account scans failed: $e'); + } } bool _isAuthStateUnlocked(LocalAuthState auth) => auth.isAuthenticated && !auth.isVisuallyLocked; diff --git a/mobile-app/test/fakes.dart b/mobile-app/test/fakes.dart index 778c8496a..19c0e97a2 100644 --- a/mobile-app/test/fakes.dart +++ b/mobile-app/test/fakes.dart @@ -43,6 +43,9 @@ class FakeSettingsService extends Fake implements SettingsService { @override String? getWalletName(int walletIndex) => null; + @override + bool isAccountScanPending(int walletIndex) => false; + @override AirdropClaimRecord? getAirdropClaim(int walletIndex) => null; diff --git a/mobile-app/test/unit/wallet_creation_service_test.dart b/mobile-app/test/unit/wallet_creation_service_test.dart index 4955e0015..fc8fb8545 100644 --- a/mobile-app/test/unit/wallet_creation_service_test.dart +++ b/mobile-app/test/unit/wallet_creation_service_test.dart @@ -154,11 +154,13 @@ void main() { expect(failures, isEmpty); verifyInOrder([ + settings.setAccountScanPending(0, true), accounts.addAccount(argThat(account('ml-dsa-87_3', name: 'Account 2'))), accounts.addAccount(argThat(account('ml-dsa-65_0', name: 'Account 3'))), settings.setActiveAccount( argThat(isA().having((a) => a.account.accountId, 'accountId', 'ml-dsa-87_3')), ), + settings.setAccountScanPending(0, false), ]); }, ); @@ -203,6 +205,44 @@ void main() { expect(asked, 1); verifyNever(accounts.addAccount(any)); verifyNever(settings.setActiveAccount(any)); + verify(settings.setAccountScanPending(0, true)).called(1); + verifyNever(settings.setAccountScanPending(0, false)); + }); + + test('a skipped scan finishes on a later start and restores the ML-DSA-65 account', () async { + var pending = false; + when(settings.setAccountScanPending(any, any)).thenAnswer((i) async { + pending = i.positionalArguments[1] as bool; + }); + when(settings.isAccountScanPending(0)).thenAnswer((_) => pending); + when(settings.getMnemonic(0)).thenAnswer((_) async => mnemonic); + var online = false; + scanReturns(() async => online ? [found.last] : throw Exception('indexer unreachable')); + + await discover((_) async => false); + expect(pending, isTrue); + verifyNever(accounts.addAccount(any)); + + online = true; + expect(await service.resumePendingAccountScans([root]), isTrue); + + expect(pending, isFalse); + verify(accounts.addAccount(argThat(account('ml-dsa-65_0', name: 'Account 2')))).called(1); + verifyNever(settings.setActiveAccount(any)); + }); + + test('resume leaves wallets whose scan finished alone', () async { + when(settings.isAccountScanPending(0)).thenReturn(false); + + expect(await service.resumePendingAccountScans([root]), isFalse); + + verifyNever( + discovery.discoverAccounts( + mnemonic: anyNamed('mnemonic'), + walletIndex: anyNamed('walletIndex'), + gapLimit: anyNamed('gapLimit'), + ), + ); }); }); } diff --git a/mobile-app/test/unit/wallet_creation_service_test.mocks.dart b/mobile-app/test/unit/wallet_creation_service_test.mocks.dart index 39d57d097..5bc531b3e 100644 --- a/mobile-app/test/unit/wallet_creation_service_test.mocks.dart +++ b/mobile-app/test/unit/wallet_creation_service_test.mocks.dart @@ -30,8 +30,7 @@ import 'package:quantus_sdk/src/rust/api/crypto.dart' as _i6; // ignore_for_file: invalid_use_of_internal_member class _FakeAccount_0 extends _i1.SmartFake implements _i2.Account { - _FakeAccount_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeAccount_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } /// A class which mocks [SettingsService]. @@ -52,9 +51,7 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { (super.noSuchMethod( Invocation.method(#getAccounts, []), returnValue: _i4.Future>.value(<_i2.Account>[]), - returnValueForMissingStub: _i4.Future>.value( - <_i2.Account>[], - ), + returnValueForMissingStub: _i4.Future>.value(<_i2.Account>[]), ) as _i4.Future>); @@ -140,16 +137,9 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as _i4.Future<_i2.Account?>); @override - _i4.Future getNextFreeAccountIndex( - int? walletIndex, { - _i6.DilithiumScheme? scheme, - }) => + _i4.Future getNextFreeAccountIndex(int? walletIndex, {_i6.DilithiumScheme? scheme}) => (super.noSuchMethod( - Invocation.method( - #getNextFreeAccountIndex, - [walletIndex], - {#scheme: scheme}, - ), + Invocation.method(#getNextFreeAccountIndex, [walletIndex], {#scheme: scheme}), returnValue: _i4.Future.value(0), returnValueForMissingStub: _i4.Future.value(0), ) @@ -159,13 +149,8 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { _i4.Future> getMultisigAccounts() => (super.noSuchMethod( Invocation.method(#getMultisigAccounts, []), - returnValue: _i4.Future>.value( - <_i7.MultisigAccount>[], - ), - returnValueForMissingStub: - _i4.Future>.value( - <_i7.MultisigAccount>[], - ), + returnValue: _i4.Future>.value(<_i7.MultisigAccount>[]), + returnValueForMissingStub: _i4.Future>.value(<_i7.MultisigAccount>[]), ) as _i4.Future>); @@ -200,12 +185,8 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { _i4.Future> getAddressBook() => (super.noSuchMethod( Invocation.method(#getAddressBook, []), - returnValue: _i4.Future>.value( - {}, - ), - returnValueForMissingStub: _i4.Future>.value( - {}, - ), + returnValue: _i4.Future>.value({}), + returnValueForMissingStub: _i4.Future>.value({}), ) as _i4.Future>); @@ -267,14 +248,8 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { String getMnemonicKey(int? walletIndex) => (super.noSuchMethod( Invocation.method(#getMnemonicKey, [walletIndex]), - returnValue: _i8.dummyValue( - this, - Invocation.method(#getMnemonicKey, [walletIndex]), - ), - returnValueForMissingStub: _i8.dummyValue( - this, - Invocation.method(#getMnemonicKey, [walletIndex]), - ), + returnValue: _i8.dummyValue(this, Invocation.method(#getMnemonicKey, [walletIndex])), + returnValueForMissingStub: _i8.dummyValue(this, Invocation.method(#getMnemonicKey, [walletIndex])), ) as String); @@ -352,11 +327,7 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { @override bool isBalanceHidden() => - (super.noSuchMethod( - Invocation.method(#isBalanceHidden, []), - returnValue: false, - returnValueForMissingStub: false, - ) + (super.noSuchMethod(Invocation.method(#isBalanceHidden, []), returnValue: false, returnValueForMissingStub: false) as bool); @override @@ -415,11 +386,7 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { @override bool? getBool(String? key) => - (super.noSuchMethod( - Invocation.method(#getBool, [key]), - returnValueForMissingStub: null, - ) - as bool?); + (super.noSuchMethod(Invocation.method(#getBool, [key]), returnValueForMissingStub: null) as bool?); @override _i4.Future setBool(String? key, bool? value) => @@ -432,11 +399,7 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { @override String? getString(String? key) => - (super.noSuchMethod( - Invocation.method(#getString, [key]), - returnValueForMissingStub: null, - ) - as String?); + (super.noSuchMethod(Invocation.method(#getString, [key]), returnValueForMissingStub: null) as String?); @override _i4.Future setString(String? key, String? value) => @@ -448,10 +411,7 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as _i4.Future); @override - void resetForTest() => super.noSuchMethod( - Invocation.method(#resetForTest, []), - returnValueForMissingStub: null, - ); + void resetForTest() => super.noSuchMethod(Invocation.method(#resetForTest, []), returnValueForMissingStub: null); @override _i4.Future clearAll() => @@ -472,10 +432,8 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as bool); @override - void setReferralCheckCompleted() => super.noSuchMethod( - Invocation.method(#setReferralCheckCompleted, []), - returnValueForMissingStub: null, - ); + void setReferralCheckCompleted() => + super.noSuchMethod(Invocation.method(#setReferralCheckCompleted, []), returnValueForMissingStub: null); @override bool isMainnetMigrationDone() => @@ -496,16 +454,12 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as _i4.Future); @override - void clearReferralCheckCompletedFlag() => super.noSuchMethod( - Invocation.method(#clearReferralCheckCompletedFlag, []), - returnValueForMissingStub: null, - ); + void clearReferralCheckCompletedFlag() => + super.noSuchMethod(Invocation.method(#clearReferralCheckCompletedFlag, []), returnValueForMissingStub: null); @override - void setReferralCode(String? code) => super.noSuchMethod( - Invocation.method(#setReferralCode, [code]), - returnValueForMissingStub: null, - ); + void setReferralCode(String? code) => + super.noSuchMethod(Invocation.method(#setReferralCode, [code]), returnValueForMissingStub: null); @override bool hasWatchedQuestsPromo() => @@ -517,16 +471,12 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as bool); @override - void setQuestsPromoWatched() => super.noSuchMethod( - Invocation.method(#setQuestsPromoWatched, []), - returnValueForMissingStub: null, - ); + void setQuestsPromoWatched() => + super.noSuchMethod(Invocation.method(#setQuestsPromoWatched, []), returnValueForMissingStub: null); @override - void clearQuestsPromoWatchedFlag() => super.noSuchMethod( - Invocation.method(#clearQuestsPromoWatchedFlag, []), - returnValueForMissingStub: null, - ); + void clearQuestsPromoWatchedFlag() => + super.noSuchMethod(Invocation.method(#clearQuestsPromoWatchedFlag, []), returnValueForMissingStub: null); @override bool recoveryPhraseViewed(int? walletIndex) => @@ -538,24 +488,16 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as bool); @override - void setRecoveryPhraseViewed(int? walletIndex) => super.noSuchMethod( - Invocation.method(#setRecoveryPhraseViewed, [walletIndex]), - returnValueForMissingStub: null, - ); + void setRecoveryPhraseViewed(int? walletIndex) => + super.noSuchMethod(Invocation.method(#setRecoveryPhraseViewed, [walletIndex]), returnValueForMissingStub: null); @override _i9.AirdropClaimRecord? getAirdropClaim(int? walletIndex) => - (super.noSuchMethod( - Invocation.method(#getAirdropClaim, [walletIndex]), - returnValueForMissingStub: null, - ) + (super.noSuchMethod(Invocation.method(#getAirdropClaim, [walletIndex]), returnValueForMissingStub: null) as _i9.AirdropClaimRecord?); @override - _i4.Future setAirdropClaim( - int? walletIndex, - _i9.AirdropClaimRecord? record, - ) => + _i4.Future setAirdropClaim(int? walletIndex, _i9.AirdropClaimRecord? record) => (super.noSuchMethod( Invocation.method(#setAirdropClaim, [walletIndex, record]), returnValue: _i4.Future.value(), @@ -564,26 +506,35 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as _i4.Future); @override - _i2.WalletOrigin? getWalletOrigin(int? walletIndex) => + bool isAccountScanPending(int? walletIndex) => (super.noSuchMethod( - Invocation.method(#getWalletOrigin, [walletIndex]), - returnValueForMissingStub: null, + Invocation.method(#isAccountScanPending, [walletIndex]), + returnValue: false, + returnValueForMissingStub: false, ) + as bool); + + @override + _i4.Future setAccountScanPending(int? walletIndex, bool? pending) => + (super.noSuchMethod( + Invocation.method(#setAccountScanPending, [walletIndex, pending]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i2.WalletOrigin? getWalletOrigin(int? walletIndex) => + (super.noSuchMethod(Invocation.method(#getWalletOrigin, [walletIndex]), returnValueForMissingStub: null) as _i2.WalletOrigin?); @override void setWalletOrigin(int? walletIndex, _i2.WalletOrigin? origin) => - super.noSuchMethod( - Invocation.method(#setWalletOrigin, [walletIndex, origin]), - returnValueForMissingStub: null, - ); + super.noSuchMethod(Invocation.method(#setWalletOrigin, [walletIndex, origin]), returnValueForMissingStub: null); @override String? getWalletName(int? walletIndex) => - (super.noSuchMethod( - Invocation.method(#getWalletName, [walletIndex]), - returnValueForMissingStub: null, - ) + (super.noSuchMethod(Invocation.method(#getWalletName, [walletIndex]), returnValueForMissingStub: null) as String?); @override @@ -605,16 +556,12 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as bool); @override - void setExistingUserSeenPromoVideo() => super.noSuchMethod( - Invocation.method(#setExistingUserSeenPromoVideo, []), - returnValueForMissingStub: null, - ); + void setExistingUserSeenPromoVideo() => + super.noSuchMethod(Invocation.method(#setExistingUserSeenPromoVideo, []), returnValueForMissingStub: null); @override - void clearExistingUserSeenPromoVideoFlag() => super.noSuchMethod( - Invocation.method(#clearExistingUserSeenPromoVideoFlag, []), - returnValueForMissingStub: null, - ); + void clearExistingUserSeenPromoVideoFlag() => + super.noSuchMethod(Invocation.method(#clearExistingUserSeenPromoVideoFlag, []), returnValueForMissingStub: null); } /// A class which mocks [AccountsService]. @@ -622,75 +569,45 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { /// See the documentation for Mockito's code generation for more information. class MockAccountsService extends _i1.Mock implements _i3.AccountsService { @override - set onAccountsChanged(void Function()? value) => super.noSuchMethod( - Invocation.setter(#onAccountsChanged, value), - returnValueForMissingStub: null, - ); + set onAccountsChanged(void Function()? value) => + super.noSuchMethod(Invocation.setter(#onAccountsChanged, value), returnValueForMissingStub: null); @override _i4.Future<_i2.Account> createNewAccount({required int? walletIndex}) => (super.noSuchMethod( - Invocation.method(#createNewAccount, [], { - #walletIndex: walletIndex, - }), + Invocation.method(#createNewAccount, [], {#walletIndex: walletIndex}), returnValue: _i4.Future<_i2.Account>.value( - _FakeAccount_0( - this, - Invocation.method(#createNewAccount, [], { - #walletIndex: walletIndex, - }), - ), + _FakeAccount_0(this, Invocation.method(#createNewAccount, [], {#walletIndex: walletIndex})), ), returnValueForMissingStub: _i4.Future<_i2.Account>.value( - _FakeAccount_0( - this, - Invocation.method(#createNewAccount, [], { - #walletIndex: walletIndex, - }), - ), + _FakeAccount_0(this, Invocation.method(#createNewAccount, [], {#walletIndex: walletIndex})), ), ) as _i4.Future<_i2.Account>); @override - _i4.Future<_i2.Account> createEncryptedAccount({ - required int? walletIndex, - required String? name, - }) => + _i4.Future<_i2.Account> createEncryptedAccount({required int? walletIndex, required String? name}) => (super.noSuchMethod( - Invocation.method(#createEncryptedAccount, [], { - #walletIndex: walletIndex, - #name: name, - }), + Invocation.method(#createEncryptedAccount, [], {#walletIndex: walletIndex, #name: name}), returnValue: _i4.Future<_i2.Account>.value( _FakeAccount_0( this, - Invocation.method(#createEncryptedAccount, [], { - #walletIndex: walletIndex, - #name: name, - }), + Invocation.method(#createEncryptedAccount, [], {#walletIndex: walletIndex, #name: name}), ), ), returnValueForMissingStub: _i4.Future<_i2.Account>.value( _FakeAccount_0( this, - Invocation.method(#createEncryptedAccount, [], { - #walletIndex: walletIndex, - #name: name, - }), + Invocation.method(#createEncryptedAccount, [], {#walletIndex: walletIndex, #name: name}), ), ), ) as _i4.Future<_i2.Account>); @override - _i4.Future ensureEncryptedAccountsForSoftwareWallets({ - required String? name, - }) => + _i4.Future ensureEncryptedAccountsForSoftwareWallets({required String? name}) => (super.noSuchMethod( - Invocation.method(#ensureEncryptedAccountsForSoftwareWallets, [], { - #name: name, - }), + Invocation.method(#ensureEncryptedAccountsForSoftwareWallets, [], {#name: name}), returnValue: _i4.Future.value(false), returnValueForMissingStub: _i4.Future.value(false), ) @@ -719,9 +636,7 @@ class MockAccountsService extends _i1.Mock implements _i3.AccountsService { (super.noSuchMethod( Invocation.method(#getAccounts, []), returnValue: _i4.Future>.value(<_i2.Account>[]), - returnValueForMissingStub: _i4.Future>.value( - <_i2.Account>[], - ), + returnValueForMissingStub: _i4.Future>.value(<_i2.Account>[]), ) as _i4.Future>); @@ -747,8 +662,7 @@ class MockAccountsService extends _i1.Mock implements _i3.AccountsService { /// A class which mocks [AccountDiscoveryService]. /// /// See the documentation for Mockito's code generation for more information. -class MockAccountDiscoveryService extends _i1.Mock - implements _i3.AccountDiscoveryService { +class MockAccountDiscoveryService extends _i1.Mock implements _i3.AccountDiscoveryService { @override _i4.Future> discoverAccounts({ required String? mnemonic, @@ -762,22 +676,14 @@ class MockAccountDiscoveryService extends _i1.Mock #gapLimit: gapLimit, }), returnValue: _i4.Future>.value(<_i2.Account>[]), - returnValueForMissingStub: _i4.Future>.value( - <_i2.Account>[], - ), + returnValueForMissingStub: _i4.Future>.value(<_i2.Account>[]), ) as _i4.Future>); @override - _i4.Future> discoverUsedIndices({ - required String Function(int)? addressAt, - int? gapLimit = 20, - }) => + _i4.Future> discoverUsedIndices({required String Function(int)? addressAt, int? gapLimit = 20}) => (super.noSuchMethod( - Invocation.method(#discoverUsedIndices, [], { - #addressAt: addressAt, - #gapLimit: gapLimit, - }), + Invocation.method(#discoverUsedIndices, [], {#addressAt: addressAt, #gapLimit: gapLimit}), returnValue: _i4.Future>.value({}), returnValueForMissingStub: _i4.Future>.value({}), ) diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index 38d96c277..aa5659549 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -172,6 +172,7 @@ class SettingsService { await saveAccounts(remaining); await deleteMnemonic(walletIndex); await _prefs.remove(_walletOriginKey(walletIndex)); + await _prefs.remove(_accountScanPendingKey(walletIndex)); await _prefs.remove(_recoveryPhraseViewedKey(walletIndex)); await _prefs.remove(_walletNameKey(walletIndex)); await _prefs.remove(_airdropClaimKey(walletIndex)); @@ -545,6 +546,20 @@ class SettingsService { String _walletOriginKey(int walletIndex) => 'wallet_origin_$walletIndex'; + String _accountScanPendingKey(int walletIndex) => 'account_scan_pending_$walletIndex'; + + /// Whether the account scan of an imported wallet has not finished yet. + bool isAccountScanPending(int walletIndex) => _prefs.getBool(_accountScanPendingKey(walletIndex)) ?? false; + + Future setAccountScanPending(int walletIndex, bool pending) async { + final key = _accountScanPendingKey(walletIndex); + if (pending) { + await _prefs.setBool(key, true); + } else { + await _prefs.remove(key); + } + } + WalletOrigin? getWalletOrigin(int walletIndex) { final value = _prefs.getString(_walletOriginKey(walletIndex)); return value == null ? null : WalletOrigin.values.asNameMap()[value]; diff --git a/quantus_sdk/test/services/settings_service_test.dart b/quantus_sdk/test/services/settings_service_test.dart index 394191cc4..4ae2fac19 100644 --- a/quantus_sdk/test/services/settings_service_test.dart +++ b/quantus_sdk/test/services/settings_service_test.dart @@ -267,5 +267,14 @@ void main() { // Assert: transparent indices stay contiguous (1), not 1025. expect(nextIndex, 1); }); + + test('account scan pending flag is set, read back and cleared per wallet', () async { + expect(settingsService.isAccountScanPending(1), isFalse); + await settingsService.setAccountScanPending(1, true); + expect(settingsService.isAccountScanPending(1), isTrue); + expect(settingsService.isAccountScanPending(0), isFalse); + await settingsService.setAccountScanPending(1, false); + expect(settingsService.isAccountScanPending(1), isFalse); + }); }); } From 997a20d10e1a9dc3c60bc16137a7d20abde4132d Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 26 Sep 2026 11:09:23 +0800 Subject: [PATCH 05/11] Make a resumed import scan activate the funded account A scan resumed on a later start left the empty ML-DSA-87 root selected after adding the funded ML-DSA-65 account. The resume now passes the wallet's active transparent account as the default, so the import rule applies: an active account with no history gives way to the first funded account found. An active encrypted account or an account of another wallet is left alone. --- .../lib/services/wallet_creation_service.dart | 9 ++++-- .../unit/wallet_creation_service_test.dart | 29 ++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/mobile-app/lib/services/wallet_creation_service.dart b/mobile-app/lib/services/wallet_creation_service.dart index dccfa6ead..bdbe02650 100644 --- a/mobile-app/lib/services/wallet_creation_service.dart +++ b/mobile-app/lib/services/wallet_creation_service.dart @@ -106,16 +106,21 @@ class WalletCreationService { /// Finishes the import scan of every wallet in [accounts] whose scan was /// skipped or interrupted, so accounts missed while the indexer was - /// unreachable still appear. Returns whether any scan finished. A scan that - /// fails again stays pending for the next call. + /// unreachable still appear. As on import, an active transparent account of + /// that wallet with no history gives way to the first funded account found. + /// Returns whether any scan finished. A scan that fails again stays pending + /// for the next call. Future resumePendingAccountScans(Iterable accounts) async { var finished = false; + final active = await _settings.getActiveRegularAccount(); for (final walletIndex in accounts.map((a) => a.walletIndex).toSet().where(_settings.isAccountScanPending)) { final mnemonic = await _settings.getMnemonic(walletIndex); if (mnemonic == null) throw StateError('Wallet $walletIndex has a pending account scan but no mnemonic'); + final activeHere = active != null && active.walletIndex == walletIndex && active.accountType == AccountType.local; await discoverImportedAccounts( mnemonic: mnemonic, walletIndex: walletIndex, + defaultAccountId: activeHere ? active.accountId : null, onScanFailed: (e) async { quantusPrint('Resumed account scan of wallet $walletIndex failed: $e'); return false; diff --git a/mobile-app/test/unit/wallet_creation_service_test.dart b/mobile-app/test/unit/wallet_creation_service_test.dart index fc8fb8545..5b0f0ed28 100644 --- a/mobile-app/test/unit/wallet_creation_service_test.dart +++ b/mobile-app/test/unit/wallet_creation_service_test.dart @@ -209,13 +209,14 @@ void main() { verifyNever(settings.setAccountScanPending(0, false)); }); - test('a skipped scan finishes on a later start and restores the ML-DSA-65 account', () async { + test('a skipped scan finishes on a later start, restores the ML-DSA-65 account and makes it active', () async { var pending = false; when(settings.setAccountScanPending(any, any)).thenAnswer((i) async { pending = i.positionalArguments[1] as bool; }); when(settings.isAccountScanPending(0)).thenAnswer((_) => pending); when(settings.getMnemonic(0)).thenAnswer((_) async => mnemonic); + when(settings.getActiveRegularAccount()).thenAnswer((_) async => root); var online = false; scanReturns(() async => online ? [found.last] : throw Exception('indexer unreachable')); @@ -228,6 +229,32 @@ void main() { expect(pending, isFalse); verify(accounts.addAccount(argThat(account('ml-dsa-65_0', name: 'Account 2')))).called(1); + verify( + settings.setActiveAccount( + argThat(isA().having((a) => a.account.accountId, 'accountId', 'ml-dsa-65_0')), + ), + ).called(1); + }); + + test("a resumed scan leaves an active account outside the wallet's transparent accounts alone", () async { + const encrypted = Account( + walletIndex: 0, + index: AppConstants.encryptedAccountIndex, + name: 'Encrypted', + accountId: 'encrypted', + accountType: AccountType.encrypted, + ); + final otherWallet = at(0, DilithiumScheme.mlDsa87).copyWith(walletIndex: 1, accountId: 'other_wallet'); + for (final active in [encrypted, otherWallet]) { + when(settings.isAccountScanPending(0)).thenReturn(true); + when(settings.getMnemonic(0)).thenAnswer((_) async => mnemonic); + when(settings.getActiveRegularAccount()).thenAnswer((_) async => active); + scanReturns(() async => [found.last]); + + await service.resumePendingAccountScans([root]); + } + + verify(accounts.addAccount(argThat(account('ml-dsa-65_0')))).called(2); verifyNever(settings.setActiveAccount(any)); }); From bc68ea46f907095bc7a4ecbf185eaf3f012d62b6 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 26 Sep 2026 11:20:37 +0800 Subject: [PATCH 06/11] Guard the background account scan against account switches and wallet removal The resumed scan runs while the user can act. Two races are closed: The active account is now re-read right before activation and only switched when it is still the one the scan started with, so a selection made during the scan stays. Removing a wallet clears its pending-scan flag before anything else, and the scan re-checks that flag after discovery and writes nothing when it is gone, so a removed wallet's accounts are not written back without a mnemonic. --- .../lib/services/wallet_creation_service.dart | 19 +++++- .../unit/wallet_creation_service_test.dart | 63 +++++++++++++------ .../lib/src/services/settings_service.dart | 3 +- .../test/services/settings_service_test.dart | 11 ++++ 4 files changed, 73 insertions(+), 23 deletions(-) diff --git a/mobile-app/lib/services/wallet_creation_service.dart b/mobile-app/lib/services/wallet_creation_service.dart index bdbe02650..f0f1bff0c 100644 --- a/mobile-app/lib/services/wallet_creation_service.dart +++ b/mobile-app/lib/services/wallet_creation_service.dart @@ -76,6 +76,10 @@ class WalletCreationService { /// answers true and stops once it answers false. The wallet's scan stays /// marked pending until a scan finishes, so [resumePendingAccountScans] can /// complete it later. + /// + /// The scan runs in the background, so nothing is written when the wallet + /// was removed meanwhile, and the active account is only switched when it + /// has not changed since the scan started. Future discoverImportedAccounts({ required String mnemonic, required int walletIndex, @@ -83,9 +87,14 @@ class WalletCreationService { required Future Function(Object error) onScanFailed, }) async { await _settings.setAccountScanPending(walletIndex, true); + final activeBefore = await _activeAccountId(); while (true) { try { final discovered = await _discovery.discoverAccounts(mnemonic: mnemonic, walletIndex: walletIndex); + if (!_settings.isAccountScanPending(walletIndex)) { + quantusPrint('Wallet $walletIndex was removed during its account scan'); + return; + } final existing = (await _accounts.getAccounts()).map((a) => a.accountId).toSet(); var count = existing.length; for (final account in discovered.where((a) => !existing.contains(a.accountId))) { @@ -93,7 +102,8 @@ class WalletCreationService { } if (defaultAccountId != null && discovered.isNotEmpty && - !discovered.any((a) => a.accountId == defaultAccountId)) { + !discovered.any((a) => a.accountId == defaultAccountId) && + await _activeAccountId() == activeBefore) { await _settings.setActiveAccount(RegularAccount(discovered.first)); } await _settings.setAccountScanPending(walletIndex, false); @@ -104,6 +114,8 @@ class WalletCreationService { } } + Future _activeAccountId() async => (await _settings.getActiveAccount())?.account.accountId; + /// Finishes the import scan of every wallet in [accounts] whose scan was /// skipped or interrupted, so accounts missed while the indexer was /// unreachable still appear. As on import, an active transparent account of @@ -112,11 +124,12 @@ class WalletCreationService { /// for the next call. Future resumePendingAccountScans(Iterable accounts) async { var finished = false; - final active = await _settings.getActiveRegularAccount(); + final active = (await _settings.getActiveAccount())?.account; for (final walletIndex in accounts.map((a) => a.walletIndex).toSet().where(_settings.isAccountScanPending)) { final mnemonic = await _settings.getMnemonic(walletIndex); if (mnemonic == null) throw StateError('Wallet $walletIndex has a pending account scan but no mnemonic'); - final activeHere = active != null && active.walletIndex == walletIndex && active.accountType == AccountType.local; + final activeHere = + active is Account && active.walletIndex == walletIndex && active.accountType == AccountType.local; await discoverImportedAccounts( mnemonic: mnemonic, walletIndex: walletIndex, diff --git a/mobile-app/test/unit/wallet_creation_service_test.dart b/mobile-app/test/unit/wallet_creation_service_test.dart index 5b0f0ed28..45d31e716 100644 --- a/mobile-app/test/unit/wallet_creation_service_test.dart +++ b/mobile-app/test/unit/wallet_creation_service_test.dart @@ -104,6 +104,13 @@ void main() { ); final root = at(0, DilithiumScheme.mlDsa87); final found = [at(3, DilithiumScheme.mlDsa87), at(0, DilithiumScheme.mlDsa65)]; + const encrypted = Account( + walletIndex: 0, + index: AppConstants.encryptedAccountIndex, + name: 'Encrypted', + accountId: 'encrypted', + accountType: AccountType.encrypted, + ); Matcher account(String accountId, {String? name}) { var m = isA().having((a) => a.accountId, 'accountId', accountId); return name == null ? m : m.having((a) => a.name, 'name', name); @@ -113,6 +120,10 @@ void main() { late MockAccountsService accounts; late MockAccountDiscoveryService discovery; late WalletCreationService service; + late bool pending; + + void activeIs(Account? active) => + when(settings.getActiveAccount()).thenAnswer((_) async => active == null ? null : RegularAccount(active)); setUp(() { settings = MockSettingsService(); @@ -123,7 +134,14 @@ void main() { accountsService: accounts, discoveryService: discovery, ); + pending = false; + when(settings.setAccountScanPending(any, any)).thenAnswer((i) async { + pending = i.positionalArguments[1] as bool; + }); + when(settings.isAccountScanPending(0)).thenAnswer((_) => pending); + when(settings.getMnemonic(0)).thenAnswer((_) async => mnemonic); when(accounts.getAccounts()).thenAnswer((_) async => [root]); + activeIs(root); }); void scanReturns(Future> Function() answer) => when( @@ -210,13 +228,6 @@ void main() { }); test('a skipped scan finishes on a later start, restores the ML-DSA-65 account and makes it active', () async { - var pending = false; - when(settings.setAccountScanPending(any, any)).thenAnswer((i) async { - pending = i.positionalArguments[1] as bool; - }); - when(settings.isAccountScanPending(0)).thenAnswer((_) => pending); - when(settings.getMnemonic(0)).thenAnswer((_) async => mnemonic); - when(settings.getActiveRegularAccount()).thenAnswer((_) async => root); var online = false; scanReturns(() async => online ? [found.last] : throw Exception('indexer unreachable')); @@ -237,18 +248,10 @@ void main() { }); test("a resumed scan leaves an active account outside the wallet's transparent accounts alone", () async { - const encrypted = Account( - walletIndex: 0, - index: AppConstants.encryptedAccountIndex, - name: 'Encrypted', - accountId: 'encrypted', - accountType: AccountType.encrypted, - ); final otherWallet = at(0, DilithiumScheme.mlDsa87).copyWith(walletIndex: 1, accountId: 'other_wallet'); for (final active in [encrypted, otherWallet]) { - when(settings.isAccountScanPending(0)).thenReturn(true); - when(settings.getMnemonic(0)).thenAnswer((_) async => mnemonic); - when(settings.getActiveRegularAccount()).thenAnswer((_) async => active); + pending = true; + activeIs(active); scanReturns(() async => [found.last]); await service.resumePendingAccountScans([root]); @@ -258,9 +261,31 @@ void main() { verifyNever(settings.setActiveAccount(any)); }); - test('resume leaves wallets whose scan finished alone', () async { - when(settings.isAccountScanPending(0)).thenReturn(false); + test('an account selected while the scan runs stays selected', () async { + scanReturns(() async { + activeIs(encrypted); + return found; + }); + await discover((_) async => false); + + verify(accounts.addAccount(any)).called(2); + verifyNever(settings.setActiveAccount(any)); + }); + + test('a wallet removed while the scan runs is not written back', () async { + scanReturns(() async { + pending = false; + return found; + }); + + await discover((_) async => false); + + verifyNever(accounts.addAccount(any)); + verifyNever(settings.setActiveAccount(any)); + }); + + test('resume leaves wallets whose scan finished alone', () async { expect(await service.resumePendingAccountScans([root]), isFalse); verifyNever( diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index aa5659549..40a2430fa 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -164,6 +164,8 @@ class SettingsService { if (remaining.isEmpty) { throw Exception('Cant remove last wallet!'); } + // First, so an account scan finishing later sees the wallet is gone. + await _prefs.remove(_accountScanPendingKey(walletIndex)); final activeId = await _getActiveAccountId(); final activeRemoved = accounts.any((a) => a.walletIndex == walletIndex && a.accountId == activeId); if (activeRemoved) { @@ -172,7 +174,6 @@ class SettingsService { await saveAccounts(remaining); await deleteMnemonic(walletIndex); await _prefs.remove(_walletOriginKey(walletIndex)); - await _prefs.remove(_accountScanPendingKey(walletIndex)); await _prefs.remove(_recoveryPhraseViewedKey(walletIndex)); await _prefs.remove(_walletNameKey(walletIndex)); await _prefs.remove(_airdropClaimKey(walletIndex)); diff --git a/quantus_sdk/test/services/settings_service_test.dart b/quantus_sdk/test/services/settings_service_test.dart index 4ae2fac19..63440ead1 100644 --- a/quantus_sdk/test/services/settings_service_test.dart +++ b/quantus_sdk/test/services/settings_service_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -276,5 +277,15 @@ void main() { await settingsService.setAccountScanPending(1, false); expect(settingsService.isAccountScanPending(1), isFalse); }); + + test('removing a wallet clears its pending account scan', () async { + FlutterSecureStorage.setMockInitialValues({}); + await settingsService.saveAccounts([account1, account1.copyWith(walletIndex: 1, accountId: 'id_w1')]); + await settingsService.setAccountScanPending(1, true); + + await settingsService.removeWallet(1); + + expect(settingsService.isAccountScanPending(1), isFalse); + }); }); } From 707b9f2240234043524d58210b8a9ea9d6850c28 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 26 Sep 2026 11:28:32 +0800 Subject: [PATCH 07/11] Check the pending flag before every write of the background account scan Only the import marks a wallet's scan pending. A resumed scan no longer sets the flag again after its awaited mnemonic read, so a wallet removed during that read stays removed. The scan checks the flag right before each account write and before switching the active account, so a removal at any point during the scan stops all further writes. --- .../lib/services/wallet_creation_service.dart | 38 ++++++++++++++----- .../unit/wallet_creation_service_test.dart | 26 +++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/mobile-app/lib/services/wallet_creation_service.dart b/mobile-app/lib/services/wallet_creation_service.dart index f0f1bff0c..3b93ab094 100644 --- a/mobile-app/lib/services/wallet_creation_service.dart +++ b/mobile-app/lib/services/wallet_creation_service.dart @@ -73,13 +73,9 @@ class WalletCreationService { /// becomes active so a returning user lands on it. /// /// A failed scan is handed to [onScanFailed]; the scan runs again while it - /// answers true and stops once it answers false. The wallet's scan stays + /// answers true and stops once it answers false. The wallet's scan is /// marked pending until a scan finishes, so [resumePendingAccountScans] can /// complete it later. - /// - /// The scan runs in the background, so nothing is written when the wallet - /// was removed meanwhile, and the active account is only switched when it - /// has not changed since the scan started. Future discoverImportedAccounts({ required String mnemonic, required int walletIndex, @@ -87,23 +83,45 @@ class WalletCreationService { required Future Function(Object error) onScanFailed, }) async { await _settings.setAccountScanPending(walletIndex, true); + await _finishPendingScan( + mnemonic: mnemonic, + walletIndex: walletIndex, + defaultAccountId: defaultAccountId, + onScanFailed: onScanFailed, + ); + } + + /// The scan runs while the user can act, so the pending flag, which wallet + /// removal clears first, is checked before every write, and the active + /// account is only switched when it has not changed since the scan started. + Future _finishPendingScan({ + required String mnemonic, + required int walletIndex, + required String? defaultAccountId, + required Future Function(Object error) onScanFailed, + }) async { final activeBefore = await _activeAccountId(); + bool removed() { + if (_settings.isAccountScanPending(walletIndex)) return false; + quantusPrint('Wallet $walletIndex was removed during its account scan'); + return true; + } + while (true) { try { final discovered = await _discovery.discoverAccounts(mnemonic: mnemonic, walletIndex: walletIndex); - if (!_settings.isAccountScanPending(walletIndex)) { - quantusPrint('Wallet $walletIndex was removed during its account scan'); - return; - } + if (removed()) return; final existing = (await _accounts.getAccounts()).map((a) => a.accountId).toSet(); var count = existing.length; for (final account in discovered.where((a) => !existing.contains(a.accountId))) { + if (removed()) return; await _accounts.addAccount(account.copyWith(name: 'Account ${++count}')); } if (defaultAccountId != null && discovered.isNotEmpty && !discovered.any((a) => a.accountId == defaultAccountId) && await _activeAccountId() == activeBefore) { + if (removed()) return; await _settings.setActiveAccount(RegularAccount(discovered.first)); } await _settings.setAccountScanPending(walletIndex, false); @@ -130,7 +148,7 @@ class WalletCreationService { if (mnemonic == null) throw StateError('Wallet $walletIndex has a pending account scan but no mnemonic'); final activeHere = active is Account && active.walletIndex == walletIndex && active.accountType == AccountType.local; - await discoverImportedAccounts( + await _finishPendingScan( mnemonic: mnemonic, walletIndex: walletIndex, defaultAccountId: activeHere ? active.accountId : null, diff --git a/mobile-app/test/unit/wallet_creation_service_test.dart b/mobile-app/test/unit/wallet_creation_service_test.dart index 45d31e716..8b3adfe22 100644 --- a/mobile-app/test/unit/wallet_creation_service_test.dart +++ b/mobile-app/test/unit/wallet_creation_service_test.dart @@ -285,6 +285,32 @@ void main() { verifyNever(settings.setActiveAccount(any)); }); + test('a wallet removed while a resumed scan reads its mnemonic is not written back', () async { + pending = true; + when(settings.getMnemonic(0)).thenAnswer((_) async { + pending = false; + return mnemonic; + }); + scanReturns(() async => found); + + await service.resumePendingAccountScans([root]); + + verifyNever(settings.setAccountScanPending(0, true)); + verifyNever(accounts.addAccount(any)); + verifyNever(settings.setActiveAccount(any)); + }); + + test('a wallet removed after discovery returns gets no further writes', () async { + scanReturns(() async => found); + when(accounts.addAccount(any)).thenAnswer((_) async => pending = false); + + await discover((_) async => false); + + verify(accounts.addAccount(any)).called(1); + verifyNever(settings.setActiveAccount(any)); + verifyNever(settings.setAccountScanPending(0, false)); + }); + test('resume leaves wallets whose scan finished alone', () async { expect(await service.resumePendingAccountScans([root]), isFalse); From 670a72af76d87828af106b1457820c9b3ac5d437 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 26 Sep 2026 11:38:50 +0800 Subject: [PATCH 08/11] Identify a pending account scan by the wallet's root account A boolean flag let an old scan write into a replacement wallet: after the wallet was removed and another seed imported at the same index, the new import set the flag again and the old scan's checks passed. SettingsService now records the root account of the pending scan. The scan compares that record before every write, so a replacement wallet from another seed, which has a different root, stops the old scan and keeps its own pending scan. --- .../lib/services/wallet_creation_service.dart | 47 ++++++++++------- .../screens/import/import_wallet_screen.dart | 2 +- mobile-app/test/fakes.dart | 2 +- .../unit/wallet_creation_service_test.dart | 52 ++++++++++++------- .../wallet_creation_service_test.mocks.dart | 14 ++--- .../lib/src/services/settings_service.dart | 18 ++++--- .../test/services/settings_service_test.dart | 18 +++---- 7 files changed, 86 insertions(+), 67 deletions(-) diff --git a/mobile-app/lib/services/wallet_creation_service.dart b/mobile-app/lib/services/wallet_creation_service.dart index 3b93ab094..2378bb6d2 100644 --- a/mobile-app/lib/services/wallet_creation_service.dart +++ b/mobile-app/lib/services/wallet_creation_service.dart @@ -68,41 +68,44 @@ class WalletCreationService { } /// Adds every on-chain account of [mnemonic] to [walletIndex]: both - /// signature schemes, any derivation index. When [defaultAccountId] is given - /// and has no history but funded accounts were found, the first of them - /// becomes active so a returning user lands on it. + /// signature schemes, any derivation index. [rootAccountId] is the wallet's + /// root added on import; when it has no history but funded accounts were + /// found, the first of them becomes active so a returning user lands on it. /// /// A failed scan is handed to [onScanFailed]; the scan runs again while it - /// answers true and stops once it answers false. The wallet's scan is - /// marked pending until a scan finishes, so [resumePendingAccountScans] can - /// complete it later. + /// answers true and stops once it answers false. The wallet's scan stays + /// pending, identified by its root, until a scan finishes, so + /// [resumePendingAccountScans] can complete it later. Future discoverImportedAccounts({ required String mnemonic, required int walletIndex, - String? defaultAccountId, + required String rootAccountId, required Future Function(Object error) onScanFailed, }) async { - await _settings.setAccountScanPending(walletIndex, true); + await _settings.setPendingAccountScan(walletIndex, rootAccountId); await _finishPendingScan( mnemonic: mnemonic, walletIndex: walletIndex, - defaultAccountId: defaultAccountId, + scan: rootAccountId, + defaultAccountId: rootAccountId, onScanFailed: onScanFailed, ); } - /// The scan runs while the user can act, so the pending flag, which wallet - /// removal clears first, is checked before every write, and the active - /// account is only switched when it has not changed since the scan started. + /// The scan runs while the user can act. Wallet removal clears the pending + /// scan first and a later import at the same index records another one, so + /// [scan] is checked before every write, and the active account is only + /// switched when it has not changed since the scan started. Future _finishPendingScan({ required String mnemonic, required int walletIndex, + required String scan, required String? defaultAccountId, required Future Function(Object error) onScanFailed, }) async { final activeBefore = await _activeAccountId(); - bool removed() { - if (_settings.isAccountScanPending(walletIndex)) return false; + bool superseded() { + if (_settings.pendingAccountScan(walletIndex) == scan) return false; quantusPrint('Wallet $walletIndex was removed during its account scan'); return true; } @@ -110,21 +113,22 @@ class WalletCreationService { while (true) { try { final discovered = await _discovery.discoverAccounts(mnemonic: mnemonic, walletIndex: walletIndex); - if (removed()) return; + if (superseded()) return; final existing = (await _accounts.getAccounts()).map((a) => a.accountId).toSet(); var count = existing.length; for (final account in discovered.where((a) => !existing.contains(a.accountId))) { - if (removed()) return; + if (superseded()) return; await _accounts.addAccount(account.copyWith(name: 'Account ${++count}')); } if (defaultAccountId != null && discovered.isNotEmpty && !discovered.any((a) => a.accountId == defaultAccountId) && await _activeAccountId() == activeBefore) { - if (removed()) return; + if (superseded()) return; await _settings.setActiveAccount(RegularAccount(discovered.first)); } - await _settings.setAccountScanPending(walletIndex, false); + if (superseded()) return; + await _settings.setPendingAccountScan(walletIndex, null); return; } catch (e) { if (!await onScanFailed(e)) return; @@ -143,7 +147,9 @@ class WalletCreationService { Future resumePendingAccountScans(Iterable accounts) async { var finished = false; final active = (await _settings.getActiveAccount())?.account; - for (final walletIndex in accounts.map((a) => a.walletIndex).toSet().where(_settings.isAccountScanPending)) { + for (final walletIndex in accounts.map((a) => a.walletIndex).toSet()) { + final scan = _settings.pendingAccountScan(walletIndex); + if (scan == null) continue; final mnemonic = await _settings.getMnemonic(walletIndex); if (mnemonic == null) throw StateError('Wallet $walletIndex has a pending account scan but no mnemonic'); final activeHere = @@ -151,13 +157,14 @@ class WalletCreationService { await _finishPendingScan( mnemonic: mnemonic, walletIndex: walletIndex, + scan: scan, defaultAccountId: activeHere ? active.accountId : null, onScanFailed: (e) async { quantusPrint('Resumed account scan of wallet $walletIndex failed: $e'); return false; }, ); - finished = finished || !_settings.isAccountScanPending(walletIndex); + finished = finished || _settings.pendingAccountScan(walletIndex) == null; } return finished; } diff --git a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart index 485dc4980..548c70126 100644 --- a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart +++ b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart @@ -119,7 +119,7 @@ class _ImportWalletScreenV2State extends ConsumerState { await _walletCreationService.discoverImportedAccounts( mnemonic: mnemonic, walletIndex: widget.walletIndex, - defaultAccountId: defaultAccountId, + rootAccountId: defaultAccountId, onScanFailed: _askRetryScan, ); invalidateAccountProviders(ref); diff --git a/mobile-app/test/fakes.dart b/mobile-app/test/fakes.dart index 19c0e97a2..11bd0fbed 100644 --- a/mobile-app/test/fakes.dart +++ b/mobile-app/test/fakes.dart @@ -44,7 +44,7 @@ class FakeSettingsService extends Fake implements SettingsService { String? getWalletName(int walletIndex) => null; @override - bool isAccountScanPending(int walletIndex) => false; + String? pendingAccountScan(int walletIndex) => null; @override AirdropClaimRecord? getAirdropClaim(int walletIndex) => null; diff --git a/mobile-app/test/unit/wallet_creation_service_test.dart b/mobile-app/test/unit/wallet_creation_service_test.dart index 8b3adfe22..7bd2c9148 100644 --- a/mobile-app/test/unit/wallet_creation_service_test.dart +++ b/mobile-app/test/unit/wallet_creation_service_test.dart @@ -120,7 +120,7 @@ void main() { late MockAccountsService accounts; late MockAccountDiscoveryService discovery; late WalletCreationService service; - late bool pending; + late String? pending; void activeIs(Account? active) => when(settings.getActiveAccount()).thenAnswer((_) async => active == null ? null : RegularAccount(active)); @@ -134,11 +134,11 @@ void main() { accountsService: accounts, discoveryService: discovery, ); - pending = false; - when(settings.setAccountScanPending(any, any)).thenAnswer((i) async { - pending = i.positionalArguments[1] as bool; + pending = null; + when(settings.setPendingAccountScan(any, any)).thenAnswer((i) async { + pending = i.positionalArguments[1] as String?; }); - when(settings.isAccountScanPending(0)).thenAnswer((_) => pending); + when(settings.pendingAccountScan(0)).thenAnswer((_) => pending); when(settings.getMnemonic(0)).thenAnswer((_) async => mnemonic); when(accounts.getAccounts()).thenAnswer((_) async => [root]); activeIs(root); @@ -155,7 +155,7 @@ void main() { Future discover(Future Function(Object error) onScanFailed) => service.discoverImportedAccounts( mnemonic: mnemonic, walletIndex: 0, - defaultAccountId: root.accountId, + rootAccountId: root.accountId, onScanFailed: onScanFailed, ); @@ -172,13 +172,13 @@ void main() { expect(failures, isEmpty); verifyInOrder([ - settings.setAccountScanPending(0, true), + settings.setPendingAccountScan(0, root.accountId), accounts.addAccount(argThat(account('ml-dsa-87_3', name: 'Account 2'))), accounts.addAccount(argThat(account('ml-dsa-65_0', name: 'Account 3'))), settings.setActiveAccount( argThat(isA().having((a) => a.account.accountId, 'accountId', 'ml-dsa-87_3')), ), - settings.setAccountScanPending(0, false), + settings.setPendingAccountScan(0, null), ]); }, ); @@ -223,8 +223,8 @@ void main() { expect(asked, 1); verifyNever(accounts.addAccount(any)); verifyNever(settings.setActiveAccount(any)); - verify(settings.setAccountScanPending(0, true)).called(1); - verifyNever(settings.setAccountScanPending(0, false)); + verify(settings.setPendingAccountScan(0, root.accountId)).called(1); + verifyNever(settings.setPendingAccountScan(0, null)); }); test('a skipped scan finishes on a later start, restores the ML-DSA-65 account and makes it active', () async { @@ -232,13 +232,13 @@ void main() { scanReturns(() async => online ? [found.last] : throw Exception('indexer unreachable')); await discover((_) async => false); - expect(pending, isTrue); + expect(pending, root.accountId); verifyNever(accounts.addAccount(any)); online = true; expect(await service.resumePendingAccountScans([root]), isTrue); - expect(pending, isFalse); + expect(pending, isNull); verify(accounts.addAccount(argThat(account('ml-dsa-65_0', name: 'Account 2')))).called(1); verify( settings.setActiveAccount( @@ -250,7 +250,7 @@ void main() { test("a resumed scan leaves an active account outside the wallet's transparent accounts alone", () async { final otherWallet = at(0, DilithiumScheme.mlDsa87).copyWith(walletIndex: 1, accountId: 'other_wallet'); for (final active in [encrypted, otherWallet]) { - pending = true; + pending = root.accountId; activeIs(active); scanReturns(() async => [found.last]); @@ -275,7 +275,7 @@ void main() { test('a wallet removed while the scan runs is not written back', () async { scanReturns(() async { - pending = false; + pending = null; return found; }); @@ -286,29 +286,43 @@ void main() { }); test('a wallet removed while a resumed scan reads its mnemonic is not written back', () async { - pending = true; + pending = root.accountId; when(settings.getMnemonic(0)).thenAnswer((_) async { - pending = false; + pending = null; return mnemonic; }); scanReturns(() async => found); await service.resumePendingAccountScans([root]); - verifyNever(settings.setAccountScanPending(0, true)); + verifyNever(settings.setPendingAccountScan(any, any)); verifyNever(accounts.addAccount(any)); verifyNever(settings.setActiveAccount(any)); }); test('a wallet removed after discovery returns gets no further writes', () async { scanReturns(() async => found); - when(accounts.addAccount(any)).thenAnswer((_) async => pending = false); + when(accounts.addAccount(any)).thenAnswer((_) async => pending = null); await discover((_) async => false); verify(accounts.addAccount(any)).called(1); verifyNever(settings.setActiveAccount(any)); - verifyNever(settings.setAccountScanPending(0, false)); + verifyNever(settings.setPendingAccountScan(0, null)); + }); + + test('a late scan of a removed wallet leaves a replacement wallet at the same index alone', () async { + const replacementRoot = 'replacement_root'; + scanReturns(() async { + pending = replacementRoot; + return found; + }); + + await discover((_) async => false); + + verifyNever(accounts.addAccount(any)); + verifyNever(settings.setActiveAccount(any)); + expect(pending, replacementRoot); }); test('resume leaves wallets whose scan finished alone', () async { diff --git a/mobile-app/test/unit/wallet_creation_service_test.mocks.dart b/mobile-app/test/unit/wallet_creation_service_test.mocks.dart index 5bc531b3e..94bec1296 100644 --- a/mobile-app/test/unit/wallet_creation_service_test.mocks.dart +++ b/mobile-app/test/unit/wallet_creation_service_test.mocks.dart @@ -506,18 +506,14 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { as _i4.Future); @override - bool isAccountScanPending(int? walletIndex) => - (super.noSuchMethod( - Invocation.method(#isAccountScanPending, [walletIndex]), - returnValue: false, - returnValueForMissingStub: false, - ) - as bool); + String? pendingAccountScan(int? walletIndex) => + (super.noSuchMethod(Invocation.method(#pendingAccountScan, [walletIndex]), returnValueForMissingStub: null) + as String?); @override - _i4.Future setAccountScanPending(int? walletIndex, bool? pending) => + _i4.Future setPendingAccountScan(int? walletIndex, String? rootAccountId) => (super.noSuchMethod( - Invocation.method(#setAccountScanPending, [walletIndex, pending]), + Invocation.method(#setPendingAccountScan, [walletIndex, rootAccountId]), returnValue: _i4.Future.value(), returnValueForMissingStub: _i4.Future.value(), ) diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index 40a2430fa..ee0b3672a 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -165,7 +165,7 @@ class SettingsService { throw Exception('Cant remove last wallet!'); } // First, so an account scan finishing later sees the wallet is gone. - await _prefs.remove(_accountScanPendingKey(walletIndex)); + await _prefs.remove(_pendingAccountScanKey(walletIndex)); final activeId = await _getActiveAccountId(); final activeRemoved = accounts.any((a) => a.walletIndex == walletIndex && a.accountId == activeId); if (activeRemoved) { @@ -547,15 +547,17 @@ class SettingsService { String _walletOriginKey(int walletIndex) => 'wallet_origin_$walletIndex'; - String _accountScanPendingKey(int walletIndex) => 'account_scan_pending_$walletIndex'; + String _pendingAccountScanKey(int walletIndex) => 'pending_account_scan_$walletIndex'; - /// Whether the account scan of an imported wallet has not finished yet. - bool isAccountScanPending(int walletIndex) => _prefs.getBool(_accountScanPendingKey(walletIndex)) ?? false; + /// Root account of an imported wallet whose account scan has not finished + /// yet, or null. The root identifies the scan: a wallet imported later at the + /// same index from another seed has a different one. + String? pendingAccountScan(int walletIndex) => _prefs.getString(_pendingAccountScanKey(walletIndex)); - Future setAccountScanPending(int walletIndex, bool pending) async { - final key = _accountScanPendingKey(walletIndex); - if (pending) { - await _prefs.setBool(key, true); + Future setPendingAccountScan(int walletIndex, String? rootAccountId) async { + final key = _pendingAccountScanKey(walletIndex); + if (rootAccountId != null) { + await _prefs.setString(key, rootAccountId); } else { await _prefs.remove(key); } diff --git a/quantus_sdk/test/services/settings_service_test.dart b/quantus_sdk/test/services/settings_service_test.dart index 63440ead1..c9936a0d5 100644 --- a/quantus_sdk/test/services/settings_service_test.dart +++ b/quantus_sdk/test/services/settings_service_test.dart @@ -269,23 +269,23 @@ void main() { expect(nextIndex, 1); }); - test('account scan pending flag is set, read back and cleared per wallet', () async { - expect(settingsService.isAccountScanPending(1), isFalse); - await settingsService.setAccountScanPending(1, true); - expect(settingsService.isAccountScanPending(1), isTrue); - expect(settingsService.isAccountScanPending(0), isFalse); - await settingsService.setAccountScanPending(1, false); - expect(settingsService.isAccountScanPending(1), isFalse); + test('pending account scan is set, read back and cleared per wallet', () async { + expect(settingsService.pendingAccountScan(1), isNull); + await settingsService.setPendingAccountScan(1, 'root_w1'); + expect(settingsService.pendingAccountScan(1), 'root_w1'); + expect(settingsService.pendingAccountScan(0), isNull); + await settingsService.setPendingAccountScan(1, null); + expect(settingsService.pendingAccountScan(1), isNull); }); test('removing a wallet clears its pending account scan', () async { FlutterSecureStorage.setMockInitialValues({}); await settingsService.saveAccounts([account1, account1.copyWith(walletIndex: 1, accountId: 'id_w1')]); - await settingsService.setAccountScanPending(1, true); + await settingsService.setPendingAccountScan(1, 'id_w1'); await settingsService.removeWallet(1); - expect(settingsService.isAccountScanPending(1), isFalse); + expect(settingsService.pendingAccountScan(1), isNull); }); }); } From f89ca8839359e5fbfe0dfa287396c97a1187fc29 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 26 Sep 2026 11:50:29 +0800 Subject: [PATCH 09/11] Record the pending scan before the root insert and keep a selection made during the mnemonic read WalletCreationService.importWallet now owns the import commit: it saves the mnemonic, records the pending account scan, then inserts the root. An app stopped right after the insert still finishes the scan on its next start. A failed insert leaves no pending scan behind. Dev seeds get no scan. A resumed scan compares the active account with the selection captured before its mnemonic read, so a switch made during that read is kept. --- .../lib/services/wallet_creation_service.dart | 33 +++++++++--- .../screens/import/import_wallet_screen.dart | 6 +-- .../unit/wallet_creation_service_test.dart | 53 +++++++++++++++++-- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/mobile-app/lib/services/wallet_creation_service.dart b/mobile-app/lib/services/wallet_creation_service.dart index 2378bb6d2..e5d5abe60 100644 --- a/mobile-app/lib/services/wallet_creation_service.dart +++ b/mobile-app/lib/services/wallet_creation_service.dart @@ -67,27 +67,46 @@ class WalletCreationService { return account; } + /// Saves [mnemonic] for the wallet of [root], records the wallet's pending + /// account scan and inserts [root]. The scan is recorded before the insert + /// so an app stopped right after the insert still finishes the scan on its + /// next start. A failed insert leaves no pending scan behind; the mnemonic + /// is not deleted, since at an index already in use it is the existing + /// wallet's. Dev seeds have no on-chain accounts to scan. + Future importWallet({required String mnemonic, required Account root}) async { + final walletIndex = root.walletIndex; + await _settings.setMnemonic(mnemonic, walletIndex); + if (HdWalletService.isDevAccount(mnemonic)) return _accounts.addAccount(root); + await _settings.setPendingAccountScan(walletIndex, root.accountId); + try { + await _accounts.addAccount(root); + } catch (_) { + await _settings.setPendingAccountScan(walletIndex, null); + rethrow; + } + } + /// Adds every on-chain account of [mnemonic] to [walletIndex]: both /// signature schemes, any derivation index. [rootAccountId] is the wallet's /// root added on import; when it has no history but funded accounts were /// found, the first of them becomes active so a returning user lands on it. /// /// A failed scan is handed to [onScanFailed]; the scan runs again while it - /// answers true and stops once it answers false. The wallet's scan stays - /// pending, identified by its root, until a scan finishes, so - /// [resumePendingAccountScans] can complete it later. + /// answers true and stops once it answers false. The pending scan recorded + /// by [importWallet] is cleared once a scan finishes; until then + /// [resumePendingAccountScans] can complete it. Future discoverImportedAccounts({ required String mnemonic, required int walletIndex, required String rootAccountId, required Future Function(Object error) onScanFailed, }) async { - await _settings.setPendingAccountScan(walletIndex, rootAccountId); await _finishPendingScan( mnemonic: mnemonic, walletIndex: walletIndex, scan: rootAccountId, defaultAccountId: rootAccountId, + activeBefore: await _activeAccountId(), onScanFailed: onScanFailed, ); } @@ -95,15 +114,16 @@ class WalletCreationService { /// The scan runs while the user can act. Wallet removal clears the pending /// scan first and a later import at the same index records another one, so /// [scan] is checked before every write, and the active account is only - /// switched when it has not changed since the scan started. + /// switched when it is still [activeBefore], the one selected when the scan + /// started. Future _finishPendingScan({ required String mnemonic, required int walletIndex, required String scan, required String? defaultAccountId, + required String? activeBefore, required Future Function(Object error) onScanFailed, }) async { - final activeBefore = await _activeAccountId(); bool superseded() { if (_settings.pendingAccountScan(walletIndex) == scan) return false; quantusPrint('Wallet $walletIndex was removed during its account scan'); @@ -159,6 +179,7 @@ class WalletCreationService { walletIndex: walletIndex, scan: scan, defaultAccountId: activeHere ? active.accountId : null, + activeBefore: active?.accountId, onScanFailed: (e) async { quantusPrint('Resumed account scan of wallet $walletIndex failed: $e'); return false; diff --git a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart index 548c70126..7f83026b3 100644 --- a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart +++ b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart @@ -66,9 +66,9 @@ class _ImportWalletScreenV2State extends ConsumerState { const scheme = DilithiumSchemeExtension.current; final path = HdWalletService.pathForIndex(0, scheme); final key = HdWalletService().keyPairAtPath(mnemonic, path, scheme); - await _settingsService.setMnemonic(mnemonic, widget.walletIndex); - await _accountsService.addAccount( - Account.derived( + await _walletCreationService.importWallet( + mnemonic: mnemonic, + root: Account.derived( walletIndex: widget.walletIndex, index: 0, name: 'Account ${accounts.length + 1}', diff --git a/mobile-app/test/unit/wallet_creation_service_test.dart b/mobile-app/test/unit/wallet_creation_service_test.dart index 7bd2c9148..770a63bf4 100644 --- a/mobile-app/test/unit/wallet_creation_service_test.dart +++ b/mobile-app/test/unit/wallet_creation_service_test.dart @@ -92,7 +92,7 @@ void main() { }); }); - group('WalletCreationService.discoverImportedAccounts', () { + group('WalletCreationService import scan', () { const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; Account at(int index, DilithiumScheme scheme) => Account( walletIndex: 0, @@ -134,7 +134,7 @@ void main() { accountsService: accounts, discoveryService: discovery, ); - pending = null; + pending = root.accountId; when(settings.setPendingAccountScan(any, any)).thenAnswer((i) async { pending = i.positionalArguments[1] as String?; }); @@ -172,7 +172,6 @@ void main() { expect(failures, isEmpty); verifyInOrder([ - settings.setPendingAccountScan(0, root.accountId), accounts.addAccount(argThat(account('ml-dsa-87_3', name: 'Account 2'))), accounts.addAccount(argThat(account('ml-dsa-65_0', name: 'Account 3'))), settings.setActiveAccount( @@ -223,7 +222,7 @@ void main() { expect(asked, 1); verifyNever(accounts.addAccount(any)); verifyNever(settings.setActiveAccount(any)); - verify(settings.setPendingAccountScan(0, root.accountId)).called(1); + expect(pending, root.accountId); verifyNever(settings.setPendingAccountScan(0, null)); }); @@ -325,7 +324,53 @@ void main() { expect(pending, replacementRoot); }); + test('import saves the mnemonic, records the pending scan, then inserts the root', () async { + pending = null; + + await service.importWallet(mnemonic: mnemonic, root: root); + + verifyInOrder([ + settings.setMnemonic(mnemonic, 0), + settings.setPendingAccountScan(0, root.accountId), + accounts.addAccount(argThat(account(root.accountId))), + ]); + expect(pending, root.accountId); + }); + + test('a dev seed is imported without a pending scan', () async { + pending = null; + + await service.importWallet(mnemonic: AppConstants.crystalAlice, root: root); + + verify(accounts.addAccount(argThat(account(root.accountId)))).called(1); + verifyNever(settings.setPendingAccountScan(any, any)); + }); + + test('a failed root insert on import leaves no pending scan behind', () async { + pending = null; + when(accounts.addAccount(any)).thenThrow(Exception('disk full')); + + await expectLater(service.importWallet(mnemonic: mnemonic, root: root), throwsException); + + expect(pending, isNull); + }); + + test('a selection made while a resumed scan reads its mnemonic is kept', () async { + when(settings.getMnemonic(0)).thenAnswer((_) async { + activeIs(encrypted); + return mnemonic; + }); + scanReturns(() async => found); + + await service.resumePendingAccountScans([root]); + + verify(accounts.addAccount(any)).called(2); + verifyNever(settings.setActiveAccount(any)); + }); + test('resume leaves wallets whose scan finished alone', () async { + pending = null; + expect(await service.resumePendingAccountScans([root]), isFalse); verifyNever( From 1731d55166e092369494cc196ff22cde88bef917 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 28 Sep 2026 13:06:24 +0800 Subject: [PATCH 10/11] Resume an unfinished import scan once at app start, not on account changes The home screen listened to the accounts provider and was rebuilt after every send, so the resume check ran far more often than needed. The scan now runs once from WalletInitializer and reads the wallets itself. --- .../lib/services/wallet_creation_service.dart | 39 ++++++------------- .../lib/v2/screens/home/home_screen.dart | 27 ------------- mobile-app/lib/wallet_initializer.dart | 15 +++++++ mobile-app/test/fakes.dart | 3 -- .../unit/wallet_creation_service_test.dart | 10 ++--- 5 files changed, 31 insertions(+), 63 deletions(-) diff --git a/mobile-app/lib/services/wallet_creation_service.dart b/mobile-app/lib/services/wallet_creation_service.dart index e5d5abe60..45cc0e9df 100644 --- a/mobile-app/lib/services/wallet_creation_service.dart +++ b/mobile-app/lib/services/wallet_creation_service.dart @@ -67,12 +67,8 @@ class WalletCreationService { return account; } - /// Saves [mnemonic] for the wallet of [root], records the wallet's pending - /// account scan and inserts [root]. The scan is recorded before the insert - /// so an app stopped right after the insert still finishes the scan on its - /// next start. A failed insert leaves no pending scan behind; the mnemonic - /// is not deleted, since at an index already in use it is the existing - /// wallet's. Dev seeds have no on-chain accounts to scan. + /// Saves [mnemonic] and inserts [root]. The account scan is marked pending + /// first, so an app stopped before the scan finishes it on the next start. Future importWallet({required String mnemonic, required Account root}) async { final walletIndex = root.walletIndex; await _settings.setMnemonic(mnemonic, walletIndex); @@ -86,15 +82,9 @@ class WalletCreationService { } } - /// Adds every on-chain account of [mnemonic] to [walletIndex]: both - /// signature schemes, any derivation index. [rootAccountId] is the wallet's - /// root added on import; when it has no history but funded accounts were - /// found, the first of them becomes active so a returning user lands on it. - /// - /// A failed scan is handed to [onScanFailed]; the scan runs again while it - /// answers true and stops once it answers false. The pending scan recorded - /// by [importWallet] is cleared once a scan finishes; until then - /// [resumePendingAccountScans] can complete it. + /// Adds the on-chain accounts of [mnemonic], both schemes, and activates the + /// first one found when the root has no history. Retries while + /// [onScanFailed] answers true. Future discoverImportedAccounts({ required String mnemonic, required int walletIndex, @@ -111,11 +101,8 @@ class WalletCreationService { ); } - /// The scan runs while the user can act. Wallet removal clears the pending - /// scan first and a later import at the same index records another one, so - /// [scan] is checked before every write, and the active account is only - /// switched when it is still [activeBefore], the one selected when the scan - /// started. + /// Stops writing once [scan] is no longer pending (wallet removed or + /// re-imported) and keeps any account the user selected meanwhile. Future _finishPendingScan({ required String mnemonic, required int walletIndex, @@ -158,16 +145,12 @@ class WalletCreationService { Future _activeAccountId() async => (await _settings.getActiveAccount())?.account.accountId; - /// Finishes the import scan of every wallet in [accounts] whose scan was - /// skipped or interrupted, so accounts missed while the indexer was - /// unreachable still appear. As on import, an active transparent account of - /// that wallet with no history gives way to the first funded account found. - /// Returns whether any scan finished. A scan that fails again stays pending - /// for the next call. - Future resumePendingAccountScans(Iterable accounts) async { + /// Finishes import scans that were skipped or interrupted. Called once at + /// app start; returns whether any scan finished. + Future resumePendingAccountScans() async { var finished = false; final active = (await _settings.getActiveAccount())?.account; - for (final walletIndex in accounts.map((a) => a.walletIndex).toSet()) { + for (final walletIndex in (await _accounts.getAccounts()).map((a) => a.walletIndex).toSet()) { final scan = _settings.pendingAccountScan(walletIndex); if (scan == null) continue; final mnemonic = await _settings.getMnemonic(walletIndex); diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index c757c29ba..68e4a2947 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -41,7 +39,6 @@ import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; import 'package:resonance_network_wallet/v2/components/global_toast_listener.dart'; import 'package:resonance_network_wallet/v2/screens/home/activity_section.dart'; import 'package:resonance_network_wallet/v2/screens/home/backup_reminder_banner.dart'; -import 'package:resonance_network_wallet/services/wallet_creation_service.dart'; class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @@ -51,8 +48,6 @@ class HomeScreen extends ConsumerStatefulWidget { } class _HomeScreenState extends ConsumerState { - bool _resumedAccountScans = false; - @override void initState() { super.initState(); @@ -81,29 +76,7 @@ class _HomeScreenState extends ConsumerState { } }); - // An import scan skipped or interrupted while the indexer was unreachable - // is finished here, once the accounts are known. - ref.listenManual>>( - accountsProvider, - (_, async) => _resumePendingAccountScans(async.value), - ); - Future.microtask(_drainPendingIntents); - Future.microtask(() => _resumePendingAccountScans(ref.read(accountsProvider).value)); - } - - Future _resumePendingAccountScans(List? accounts) async { - if (accounts == null || _resumedAccountScans) return; - _resumedAccountScans = true; - try { - final service = WalletCreationService( - settingsService: ref.read(settingsServiceProvider), - accountsService: ref.read(accountsServiceProvider), - ); - if (await service.resumePendingAccountScans(accounts) && mounted) invalidateAccountProviders(ref); - } catch (e) { - quantusPrint('Resuming pending account scans failed: $e'); - } } bool _isAuthStateUnlocked(LocalAuthState auth) => auth.isAuthenticated && !auth.isVisuallyLocked; diff --git a/mobile-app/lib/wallet_initializer.dart b/mobile-app/lib/wallet_initializer.dart index 3cabfe53e..603696aa9 100644 --- a/mobile-app/lib/wallet_initializer.dart +++ b/mobile-app/lib/wallet_initializer.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart' hide ScaffoldBase; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/mainnet_migration_provider.dart'; import 'package:resonance_network_wallet/v2/screens/home/home_screen.dart'; @@ -9,6 +12,8 @@ import 'package:resonance_network_wallet/v2/screens/welcome/mainnet_migration_sc import 'package:resonance_network_wallet/v2/screens/welcome/welcome_screen.dart'; import 'package:resonance_network_wallet/services/logout_service.dart'; import 'package:resonance_network_wallet/services/telemetry_service.dart'; +import 'package:resonance_network_wallet/services/wallet_creation_service.dart'; +import 'package:resonance_network_wallet/shared/utils/print.dart'; class WalletInitializer extends ConsumerStatefulWidget { const WalletInitializer({super.key}); @@ -39,6 +44,7 @@ class WalletInitializerState extends ConsumerState { if (mounted) await _showMnemonicLostDialog(); return; } + unawaited(_resumePendingAccountScans()); } setState(() { @@ -48,6 +54,15 @@ class WalletInitializerState extends ConsumerState { }); } + Future _resumePendingAccountScans() async { + try { + if (await WalletCreationService().resumePendingAccountScans() && mounted) invalidateAccountProviders(ref); + } catch (e) { + quantusPrint('Resuming pending account scans failed: $e'); + TelemetryService().sendError('Resuming pending account scans failed', error: e); + } + } + Future _showMnemonicLostDialog() async { final l10n = ref.read(l10nProvider); diff --git a/mobile-app/test/fakes.dart b/mobile-app/test/fakes.dart index 11bd0fbed..778c8496a 100644 --- a/mobile-app/test/fakes.dart +++ b/mobile-app/test/fakes.dart @@ -43,9 +43,6 @@ class FakeSettingsService extends Fake implements SettingsService { @override String? getWalletName(int walletIndex) => null; - @override - String? pendingAccountScan(int walletIndex) => null; - @override AirdropClaimRecord? getAirdropClaim(int walletIndex) => null; diff --git a/mobile-app/test/unit/wallet_creation_service_test.dart b/mobile-app/test/unit/wallet_creation_service_test.dart index 770a63bf4..839f2f331 100644 --- a/mobile-app/test/unit/wallet_creation_service_test.dart +++ b/mobile-app/test/unit/wallet_creation_service_test.dart @@ -235,7 +235,7 @@ void main() { verifyNever(accounts.addAccount(any)); online = true; - expect(await service.resumePendingAccountScans([root]), isTrue); + expect(await service.resumePendingAccountScans(), isTrue); expect(pending, isNull); verify(accounts.addAccount(argThat(account('ml-dsa-65_0', name: 'Account 2')))).called(1); @@ -253,7 +253,7 @@ void main() { activeIs(active); scanReturns(() async => [found.last]); - await service.resumePendingAccountScans([root]); + await service.resumePendingAccountScans(); } verify(accounts.addAccount(argThat(account('ml-dsa-65_0')))).called(2); @@ -292,7 +292,7 @@ void main() { }); scanReturns(() async => found); - await service.resumePendingAccountScans([root]); + await service.resumePendingAccountScans(); verifyNever(settings.setPendingAccountScan(any, any)); verifyNever(accounts.addAccount(any)); @@ -362,7 +362,7 @@ void main() { }); scanReturns(() async => found); - await service.resumePendingAccountScans([root]); + await service.resumePendingAccountScans(); verify(accounts.addAccount(any)).called(2); verifyNever(settings.setActiveAccount(any)); @@ -371,7 +371,7 @@ void main() { test('resume leaves wallets whose scan finished alone', () async { pending = null; - expect(await service.resumePendingAccountScans([root]), isFalse); + expect(await service.resumePendingAccountScans(), isFalse); verifyNever( discovery.discoverAccounts( From eb5224ce3424ed9e30369535b5444970d84614a0 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 28 Sep 2026 13:06:24 +0800 Subject: [PATCH 11/11] Drop the legacy scheme constant ML-DSA-87 is the default again, so calling it legacy is misleading. Uses that mean ML-DSA-87 now name it directly. --- .../lib/services/vault_service.dart | 2 +- cold-wallet-app/test/add_account_test.dart | 16 ++++----- .../test/audit_regressions_test.dart | 2 +- cold-wallet-app/test/call_display_test.dart | 2 +- .../test/change_password_test.dart | 6 ++-- .../test/every_call_renders_test.dart | 2 +- cold-wallet-app/test/multi_account_test.dart | 33 +++++++++---------- .../test/set_password_screen_test.dart | 2 +- .../test/show_secret_phrase_test.dart | 4 +-- .../dilithium_scheme_extension.dart | 7 ++-- quantus_sdk/lib/src/models/account.dart | 2 +- .../lib/src/services/settings_service.dart | 8 ++--- .../lib/src/services/substrate_service.dart | 2 +- 13 files changed, 38 insertions(+), 50 deletions(-) diff --git a/cold-wallet-app/lib/services/vault_service.dart b/cold-wallet-app/lib/services/vault_service.dart index d89d54740..5497c031e 100644 --- a/cold-wallet-app/lib/services/vault_service.dart +++ b/cold-wallet-app/lib/services/vault_service.dart @@ -33,7 +33,7 @@ class VaultContents { if (!plaintext.startsWith('{')) { return VaultContents( mnemonic: plaintext, - accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)], + accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumScheme.mlDsa87)], ); } final m = jsonDecode(plaintext) as Map; diff --git a/cold-wallet-app/test/add_account_test.dart b/cold-wallet-app/test/add_account_test.dart index 1d963ff8c..a47fc07c0 100644 --- a/cold-wallet-app/test/add_account_test.dart +++ b/cold-wallet-app/test/add_account_test.dart @@ -7,15 +7,15 @@ import 'package:quantus_cold_wallet/models/cold_account.dart'; void main() { group('the slot an account derives from', () { test('an indexed account sits at its index', () { - expect(ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy).templateIndex, 0); - expect(ColdAccount(label: 'Account 13', index: 12, scheme: DilithiumSchemeExtension.legacy).templateIndex, 12); + expect(ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumScheme.mlDsa87).templateIndex, 0); + expect(ColdAccount(label: 'Account 13', index: 12, scheme: DilithiumScheme.mlDsa87).templateIndex, 12); }); test('a path following the wallet template counts as the index it names', () { final typed = ColdAccount( label: 'Typed', - path: ColdAccount(label: 'x', index: 9, scheme: DilithiumSchemeExtension.legacy).derivationPath, - scheme: DilithiumSchemeExtension.legacy, + path: ColdAccount(label: 'x', index: 9, scheme: DilithiumScheme.mlDsa87).derivationPath, + scheme: DilithiumScheme.mlDsa87, ); expect(typed.templateIndex, 9); @@ -23,15 +23,11 @@ void main() { test('a path from another wallet claims no slot', () { expect( - ColdAccount(label: 'Elsewhere', path: "m/44'/1'/0'", scheme: DilithiumSchemeExtension.legacy).templateIndex, + ColdAccount(label: 'Elsewhere', path: "m/44'/1'/0'", scheme: DilithiumScheme.mlDsa87).templateIndex, isNull, ); expect( - ColdAccount( - label: 'Deeper', - path: "m/44'/189189'/7'/1'/2'", - scheme: DilithiumSchemeExtension.legacy, - ).templateIndex, + ColdAccount(label: 'Deeper', path: "m/44'/189189'/7'/1'/2'", scheme: DilithiumScheme.mlDsa87).templateIndex, isNull, ); }); diff --git a/cold-wallet-app/test/audit_regressions_test.dart b/cold-wallet-app/test/audit_regressions_test.dart index d78516d4d..c1f68527f 100644 --- a/cold-wallet-app/test/audit_regressions_test.dart +++ b/cold-wallet-app/test/audit_regressions_test.dart @@ -35,7 +35,7 @@ Future pumpRequest(WidgetTester tester, SigningRequest request) async { ProviderScope( overrides: [ addressesProvider.overrideWith( - (ref) => {wallet: ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)}, + (ref) => {wallet: ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumScheme.mlDsa87)}, ), checksumNameProvider.overrideWith((ref, address) async => 'check phrase'), ], diff --git a/cold-wallet-app/test/call_display_test.dart b/cold-wallet-app/test/call_display_test.dart index a22eac0c1..d602676cc 100644 --- a/cold-wallet-app/test/call_display_test.dart +++ b/cold-wallet-app/test/call_display_test.dart @@ -50,7 +50,7 @@ Future pumpSignScreen(WidgetTester tester, Uint8List payload) async { ProviderScope( overrides: [ addressesProvider.overrideWith( - (ref) => {signerAddress: ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)}, + (ref) => {signerAddress: ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumScheme.mlDsa87)}, ), checksumNameProvider.overrideWith((ref, address) async => 'check phrase'), ], diff --git a/cold-wallet-app/test/change_password_test.dart b/cold-wallet-app/test/change_password_test.dart index d54614420..a6c6a5341 100644 --- a/cold-wallet-app/test/change_password_test.dart +++ b/cold-wallet-app/test/change_password_test.dart @@ -24,7 +24,7 @@ class _FaultInjectingStorage extends TestFlutterSecureStoragePlatform { } } -final _accounts = [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)]; +final _accounts = [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumScheme.mlDsa87)]; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -101,7 +101,7 @@ void main() { await vault.createVault( mnemonic: _mnemonic, password: 'beta', - accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)], + accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumScheme.mlDsa87)], ); final fresh = ProviderContainer(); @@ -128,7 +128,7 @@ void main() { await vault.createVault( mnemonic: _mnemonic, password: 'beta', - accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)], + accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumScheme.mlDsa87)], ); expect(await vault.isBiometricEnabled(), isTrue, reason: 'a bare key carries no pairing to check at startup'); await expectLater(vault.unlockWithBiometricKey(), throwsA(isA())); diff --git a/cold-wallet-app/test/every_call_renders_test.dart b/cold-wallet-app/test/every_call_renders_test.dart index f59f84e04..d123a770f 100644 --- a/cold-wallet-app/test/every_call_renders_test.dart +++ b/cold-wallet-app/test/every_call_renders_test.dart @@ -33,7 +33,7 @@ Future pumpAt(WidgetTester tester, Uint8List payload, Size size, double sc ProviderScope( overrides: [ addressesProvider.overrideWith( - (ref) => {signerAddress: ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)}, + (ref) => {signerAddress: ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumScheme.mlDsa87)}, ), checksumNameProvider.overrideWith((ref, address) async => 'check phrase'), ], diff --git a/cold-wallet-app/test/multi_account_test.dart b/cold-wallet-app/test/multi_account_test.dart index 8b8c2e0d9..8beebe137 100644 --- a/cold-wallet-app/test/multi_account_test.dart +++ b/cold-wallet-app/test/multi_account_test.dart @@ -64,14 +64,14 @@ void main() { mnemonic: mnemonic, password: 'alpha', enableBiometric: false, - accounts: [ColdAccount(label: 'One', index: 0, scheme: DilithiumSchemeExtension.legacy)], + accounts: [ColdAccount(label: 'One', index: 0, scheme: DilithiumScheme.mlDsa87)], ); expect( await controller.changePassword(currentPassword: 'alpha', newPassword: 'beta'), PasswordChangeResult.changed, ); - await controller.addAccount(ColdAccount(label: 'Two', index: 1, scheme: DilithiumSchemeExtension.legacy)); + await controller.addAccount(ColdAccount(label: 'Two', index: 1, scheme: DilithiumScheme.mlDsa87)); final reopened = await VaultService().unlockWithPassword('beta'); expect(reopened.mnemonic, mnemonic); @@ -89,12 +89,12 @@ void main() { mnemonic: mnemonic, password: 'alpha', enableBiometric: true, - accounts: [ColdAccount(label: 'One', index: 0, scheme: DilithiumSchemeExtension.legacy)], + accounts: [ColdAccount(label: 'One', index: 0, scheme: DilithiumScheme.mlDsa87)], ); biometric.lock(); expect(await biometric.unlockWithBiometric(), isTrue); - await biometric.addAccount(ColdAccount(label: 'Two', index: 1, scheme: DilithiumSchemeExtension.legacy)); + await biometric.addAccount(ColdAccount(label: 'Two', index: 1, scheme: DilithiumScheme.mlDsa87)); expect(container.read(accountsProvider), hasLength(2)); expect((await VaultService().unlockWithPassword('alpha')).accounts, hasLength(2)); @@ -104,29 +104,26 @@ void main() { group('ColdAccount', () { test('an index fills the wallet template', () { expect( - ColdAccount(label: 'a', index: 3, scheme: DilithiumSchemeExtension.legacy).derivationPath, - HdWalletService.pathForIndex(3, DilithiumSchemeExtension.legacy), + ColdAccount(label: 'a', index: 3, scheme: DilithiumScheme.mlDsa87).derivationPath, + HdWalletService.pathForIndex(3, DilithiumScheme.mlDsa87), ); }); test('a path is taken verbatim', () { expect( - ColdAccount(label: 'a', path: "m/44'/189189'/9'/0'/0'", scheme: DilithiumSchemeExtension.legacy).derivationPath, + ColdAccount(label: 'a', path: "m/44'/189189'/9'/0'/0'", scheme: DilithiumScheme.mlDsa87).derivationPath, "m/44'/189189'/9'/0'/0'", ); }); test('needs exactly one of index or path', () { - expect(() => ColdAccount(label: 'a', scheme: DilithiumSchemeExtension.legacy), throwsArgumentError); + expect(() => ColdAccount(label: 'a', scheme: DilithiumScheme.mlDsa87), throwsArgumentError); expect( - () => ColdAccount(label: 'a', index: 0, path: "m/44'", scheme: DilithiumSchemeExtension.legacy), - throwsArgumentError, - ); - expect(() => ColdAccount(label: 'a', index: -1, scheme: DilithiumSchemeExtension.legacy), throwsArgumentError); - expect( - () => ColdAccount(label: 'a', path: 'not a path', scheme: DilithiumSchemeExtension.legacy), + () => ColdAccount(label: 'a', index: 0, path: "m/44'", scheme: DilithiumScheme.mlDsa87), throwsArgumentError, ); + expect(() => ColdAccount(label: 'a', index: -1, scheme: DilithiumScheme.mlDsa87), throwsArgumentError); + expect(() => ColdAccount(label: 'a', path: 'not a path', scheme: DilithiumScheme.mlDsa87), throwsArgumentError); }); }); @@ -135,8 +132,8 @@ void main() { final contents = VaultContents( mnemonic: mnemonic, accounts: [ - ColdAccount(label: 'One', index: 0, scheme: DilithiumSchemeExtension.legacy), - ColdAccount(label: 'Two', path: "m/44'/189189'/7'/0'/0'", scheme: DilithiumSchemeExtension.legacy), + ColdAccount(label: 'One', index: 0, scheme: DilithiumScheme.mlDsa87), + ColdAccount(label: 'Two', path: "m/44'/189189'/7'/0'/0'", scheme: DilithiumScheme.mlDsa87), ], ); final decoded = VaultContents.decode(contents.encode()); @@ -159,7 +156,7 @@ void main() { await pumpFor( tester, signerAddress, - held: {signerAddress: ColdAccount(label: 'One', index: 0, scheme: DilithiumSchemeExtension.legacy)}, + held: {signerAddress: ColdAccount(label: 'One', index: 0, scheme: DilithiumScheme.mlDsa87)}, ); expect(find.text('Sign'), findsOneWidget); @@ -170,7 +167,7 @@ void main() { await pumpFor( tester, otherAddress, - held: {signerAddress: ColdAccount(label: 'One', index: 0, scheme: DilithiumSchemeExtension.legacy)}, + held: {signerAddress: ColdAccount(label: 'One', index: 0, scheme: DilithiumScheme.mlDsa87)}, ); expect(find.textContaining('does not exist in this cold wallet'), findsOneWidget); diff --git a/cold-wallet-app/test/set_password_screen_test.dart b/cold-wallet-app/test/set_password_screen_test.dart index 04af141f9..10c2d3090 100644 --- a/cold-wallet-app/test/set_password_screen_test.dart +++ b/cold-wallet-app/test/set_password_screen_test.dart @@ -21,7 +21,7 @@ void main() { theme: AppTheme.darkTheme(context), home: SetPasswordScreen( mnemonic: 'test mnemonic', - accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)], + accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumScheme.mlDsa87)], ), ), ), diff --git a/cold-wallet-app/test/show_secret_phrase_test.dart b/cold-wallet-app/test/show_secret_phrase_test.dart index fb61931e8..323dbbb6f 100644 --- a/cold-wallet-app/test/show_secret_phrase_test.dart +++ b/cold-wallet-app/test/show_secret_phrase_test.dart @@ -37,7 +37,7 @@ void main() { mnemonic: _mnemonic, password: 'alpha', enableBiometric: false, - accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)], + accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumScheme.mlDsa87)], ), ); @@ -94,7 +94,7 @@ void main() { mnemonic: _mnemonic, password: 'alpha', enableBiometric: false, - accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)], + accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumScheme.mlDsa87)], ), ); diff --git a/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart b/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart index 4d853937e..e4a202f49 100644 --- a/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart +++ b/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart @@ -7,10 +7,6 @@ extension DilithiumSchemeExtension on DilithiumScheme { /// Scheme new wallets and accounts use. static const DilithiumScheme current = DilithiumScheme.mlDsa87; - /// Scheme of accounts stored before the scheme was recorded, when ML-DSA-87 - /// was the only one. - static const DilithiumScheme legacy = DilithiumScheme.mlDsa87; - /// Variant index of the chain's `DilithiumSignatureScheme`, written into every signed extrinsic. int get signatureTypeByte => switch (this) { DilithiumScheme.mlDsa87 => 0, @@ -29,8 +25,9 @@ extension DilithiumSchemeExtension on DilithiumScheme { DilithiumScheme.mlDsa87 => 'ml-dsa-87', }; + /// Accounts stored before the scheme was recorded are ML-DSA-87. static DilithiumScheme fromStorageName(String? name) { - if (name == null) return legacy; + if (name == null) return DilithiumScheme.mlDsa87; return DilithiumScheme.values.firstWhere( (s) => s.storageName == name, orElse: () => throw FormatException('Unknown signature scheme: $name'), diff --git a/quantus_sdk/lib/src/models/account.dart b/quantus_sdk/lib/src/models/account.dart index 09e80a3ee..c22a4a46b 100644 --- a/quantus_sdk/lib/src/models/account.dart +++ b/quantus_sdk/lib/src/models/account.dart @@ -107,7 +107,7 @@ class Account implements BaseAccount { /// account is the sender. Hardware (keystone) accounts hold no local key and /// their scheme is only known once the device signs, so the larger ML-DSA-87 /// is used to avoid ever understating the fee. - DilithiumScheme get feeSizingScheme => scheme ?? DilithiumSchemeExtension.legacy; + DilithiumScheme get feeSizingScheme => scheme ?? DilithiumScheme.mlDsa87; /// Wallet, then scheme (current first, keyless accounts last), then derivation index. static int compare(Account a, Account b) { diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index ee0b3672a..8ec7184bc 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:quantus_sdk/src/extensions/dilithium_scheme_extension.dart'; import 'package:quantus_sdk/src/models/account.dart'; import 'package:quantus_sdk/src/models/airdrop_claim_record.dart'; import 'package:quantus_sdk/src/models/display_account.dart'; @@ -78,14 +77,14 @@ class SettingsService { final oldAccountId = _prefs.getString('account_id'); if (oldAccountId != null) { final oldWalletName = _prefs.getString('wallet_name') ?? 'Account 1'; - const legacy = DilithiumSchemeExtension.legacy; + const scheme = DilithiumScheme.mlDsa87; final account = Account( walletIndex: 0, index: 0, name: oldWalletName, accountId: oldAccountId, - scheme: legacy, - derivationPath: HdWalletService.pathForIndex(0, legacy), + scheme: scheme, + derivationPath: HdWalletService.pathForIndex(0, scheme), ); await saveAccounts([account]); await setActiveAccount(RegularAccount(account)); @@ -164,7 +163,6 @@ class SettingsService { if (remaining.isEmpty) { throw Exception('Cant remove last wallet!'); } - // First, so an account scan finishing later sees the wallet is gone. await _prefs.remove(_pendingAccountScanKey(walletIndex)); final activeId = await _getActiveAccountId(); final activeRemoved = accounts.any((a) => a.walletIndex == walletIndex && a.accountId == activeId); diff --git a/quantus_sdk/lib/src/services/substrate_service.dart b/quantus_sdk/lib/src/services/substrate_service.dart index 2deb6138e..7c8074a80 100644 --- a/quantus_sdk/lib/src/services/substrate_service.dart +++ b/quantus_sdk/lib/src/services/substrate_service.dart @@ -79,7 +79,7 @@ class SubstrateService { /// runtime, so this is the one fee input that has to be asked from chain. Future queryDispatchWeight(RuntimeCall call) async { final info = await _paymentQueryInfo( - _dummySignedExtrinsic(Uint8List(32), call.encode(), scheme: DilithiumSchemeExtension.legacy), + _dummySignedExtrinsic(Uint8List(32), call.encode(), scheme: DilithiumScheme.mlDsa87), ); return BigInt.from((info['weight'] as Map)['ref_time'] as int); }