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/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index d033d154e..38fc6b5a8 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 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" + }, + "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..0ab0a6180 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 sekarang, atau lewati dan aplikasi akan memindai lagi saat dibuka berikutnya.", + "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..c71643ddb 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 now, or skip and the app will scan again the next time it starts.'** + 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..e5d564aa9 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -111,6 +111,19 @@ 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 now, or skip and the app will scan again the next time it starts.'; + + @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..a4000680b 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -112,6 +112,19 @@ 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 sekarang, atau lewati dan aplikasi akan memindai lagi saat dibuka berikutnya.'; + + @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..45cc0e9df 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,112 @@ class WalletCreationService { } return account; } + + /// 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); + 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 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, + required String rootAccountId, + required Future Function(Object error) onScanFailed, + }) async { + await _finishPendingScan( + mnemonic: mnemonic, + walletIndex: walletIndex, + scan: rootAccountId, + defaultAccountId: rootAccountId, + activeBefore: await _activeAccountId(), + onScanFailed: onScanFailed, + ); + } + + /// 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, + required String scan, + required String? defaultAccountId, + required String? activeBefore, + required Future Function(Object error) onScanFailed, + }) async { + bool superseded() { + if (_settings.pendingAccountScan(walletIndex) == scan) 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 (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 (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 (superseded()) return; + await _settings.setActiveAccount(RegularAccount(discovered.first)); + } + if (superseded()) return; + await _settings.setPendingAccountScan(walletIndex, null); + return; + } catch (e) { + if (!await onScanFailed(e)) return; + } + } + } + + Future _activeAccountId() async => (await _settings.getActiveAccount())?.account.accountId; + + /// 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 (await _accounts.getAccounts()).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 = + active is Account && active.walletIndex == walletIndex && active.accountType == AccountType.local; + await _finishPendingScan( + mnemonic: mnemonic, + 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; + }, + ); + finished = finished || _settings.pendingAccountScan(walletIndex) == null; + } + return finished; + } } /// 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 928015564..7f83026b3 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; @@ -62,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}', @@ -108,56 +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); - // 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); - } + await _walletCreationService.discoverImportedAccounts( + mnemonic: mnemonic, + walletIndex: widget.walletIndex, + rootAccountId: defaultAccountId, + onScanFailed: _askRetryScan, + ); + invalidateAccountProviders(ref); + unawaited(_discoverEncryptedAccount()); } - Future _addLegacyRootFallback(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, - ), - ); - invalidateAccountProviders(ref); - } catch (e) { - quantusPrint('legacy root fallback failed: $e'); - } + 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/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/unit/wallet_creation_service_test.dart b/mobile-app/test/unit/wallet_creation_service_test.dart index 08d073a71..839f2f331 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,295 @@ void main() { verifyNever(settings.deleteMnemonic(any)); }); }); + + 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, + 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)]; + 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); + } + + late MockSettingsService settings; + late MockAccountsService accounts; + late MockAccountDiscoveryService discovery; + late WalletCreationService service; + late String? pending; + + void activeIs(Account? active) => + when(settings.getActiveAccount()).thenAnswer((_) async => active == null ? null : RegularAccount(active)); + + setUp(() { + settings = MockSettingsService(); + accounts = MockAccountsService(); + discovery = MockAccountDiscoveryService(); + service = WalletCreationService( + settingsService: settings, + accountsService: accounts, + discoveryService: discovery, + ); + pending = root.accountId; + when(settings.setPendingAccountScan(any, any)).thenAnswer((i) async { + pending = i.positionalArguments[1] as String?; + }); + when(settings.pendingAccountScan(0)).thenAnswer((_) => pending); + when(settings.getMnemonic(0)).thenAnswer((_) async => mnemonic); + when(accounts.getAccounts()).thenAnswer((_) async => [root]); + activeIs(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, + rootAccountId: 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')), + ), + settings.setPendingAccountScan(0, null), + ]); + }, + ); + + 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)); + expect(pending, root.accountId); + 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 { + var online = false; + scanReturns(() async => online ? [found.last] : throw Exception('indexer unreachable')); + + await discover((_) async => false); + expect(pending, root.accountId); + verifyNever(accounts.addAccount(any)); + + online = true; + expect(await service.resumePendingAccountScans(), isTrue); + + expect(pending, isNull); + 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 { + final otherWallet = at(0, DilithiumScheme.mlDsa87).copyWith(walletIndex: 1, accountId: 'other_wallet'); + for (final active in [encrypted, otherWallet]) { + pending = root.accountId; + activeIs(active); + scanReturns(() async => [found.last]); + + await service.resumePendingAccountScans(); + } + + verify(accounts.addAccount(argThat(account('ml-dsa-65_0')))).called(2); + verifyNever(settings.setActiveAccount(any)); + }); + + 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 = null; + return found; + }); + + await discover((_) async => false); + + verifyNever(accounts.addAccount(any)); + verifyNever(settings.setActiveAccount(any)); + }); + + test('a wallet removed while a resumed scan reads its mnemonic is not written back', () async { + pending = root.accountId; + when(settings.getMnemonic(0)).thenAnswer((_) async { + pending = null; + return mnemonic; + }); + scanReturns(() async => found); + + await service.resumePendingAccountScans(); + + 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 = null); + + await discover((_) async => false); + + verify(accounts.addAccount(any)).called(1); + verifyNever(settings.setActiveAccount(any)); + 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('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(); + + 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(), 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 26cda2f0b..94bec1296 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; @@ -490,6 +491,34 @@ class MockSettingsService extends _i1.Mock implements _i3.SettingsService { 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 + String? pendingAccountScan(int? walletIndex) => + (super.noSuchMethod(Invocation.method(#pendingAccountScan, [walletIndex]), returnValueForMissingStub: null) + as String?); + + @override + _i4.Future setPendingAccountScan(int? walletIndex, String? rootAccountId) => + (super.noSuchMethod( + Invocation.method(#setPendingAccountScan, [walletIndex, rootAccountId]), + 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) @@ -625,3 +654,34 @@ 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>); +} diff --git a/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart b/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart index 72078161c..e4a202f49 100644 --- a/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart +++ b/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart @@ -5,10 +5,7 @@ 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; - - /// Scheme of accounts stored before the scheme was recorded. - static const DilithiumScheme legacy = DilithiumScheme.mlDsa87; + static const DilithiumScheme current = DilithiumScheme.mlDsa87; /// Variant index of the chain's `DilithiumSignatureScheme`, written into every signed extrinsic. int get signatureTypeByte => switch (this) { @@ -28,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 0b247cbce..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) { @@ -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/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index 38d96c277..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,6 +163,7 @@ class SettingsService { if (remaining.isEmpty) { throw Exception('Cant remove last wallet!'); } + await _prefs.remove(_pendingAccountScanKey(walletIndex)); final activeId = await _getActiveAccountId(); final activeRemoved = accounts.any((a) => a.walletIndex == walletIndex && a.accountId == activeId); if (activeRemoved) { @@ -545,6 +545,22 @@ class SettingsService { String _walletOriginKey(int walletIndex) => 'wallet_origin_$walletIndex'; + String _pendingAccountScanKey(int walletIndex) => 'pending_account_scan_$walletIndex'; + + /// 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 setPendingAccountScan(int walletIndex, String? rootAccountId) async { + final key = _pendingAccountScanKey(walletIndex); + if (rootAccountId != null) { + await _prefs.setString(key, rootAccountId); + } 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/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); } 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)); + }); +} diff --git a/quantus_sdk/test/services/settings_service_test.dart b/quantus_sdk/test/services/settings_service_test.dart index 394191cc4..c9936a0d5 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'; @@ -267,5 +268,24 @@ void main() { // Assert: transparent indices stay contiguous (1), not 1025. expect(nextIndex, 1); }); + + 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.setPendingAccountScan(1, 'id_w1'); + + await settingsService.removeWallet(1); + + expect(settingsService.pendingAccountScan(1), isNull); + }); }); }