diff --git a/mobile-app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile-app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 38b693e4..0c1a4bd6 100644 --- a/mobile-app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/mobile-app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,4 @@ { - "originHash" : "e607eae77ccb0782f74cb2019f9acb34e4cb4d5442b994870e3fbe938b2c4386", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -117,7 +116,16 @@ "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", "version" : "2.4.1" } + }, + { + "identity" : "swiftsdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TelemetryDeck/SwiftSDK", + "state" : { + "revision" : "ad4a03ec7ea7416a4081370f21c86e55b02a5b88", + "version" : "2.14.1" + } } ], - "version" : 3 + "version" : 2 } diff --git a/mobile-app/lib/features/components/migration_dialog.dart b/mobile-app/lib/features/components/migration_dialog.dart index ccfe113b..508d2819 100644 --- a/mobile-app/lib/features/components/migration_dialog.dart +++ b/mobile-app/lib/features/components/migration_dialog.dart @@ -1,19 +1,21 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; -class MigrationDialog extends StatefulWidget { - final List migrationData; +class MigrationDialog extends ConsumerStatefulWidget { + final List migrationResults; final Future Function() onMigrate; final Future Function()? onTryLater; - const MigrationDialog({super.key, required this.migrationData, required this.onMigrate, this.onTryLater}); + const MigrationDialog({super.key, required this.migrationResults, required this.onMigrate, this.onTryLater}); static Future show({ required BuildContext context, - required List migrationData, + required List migrationResults, required Future Function() onMigrate, Future Function()? onTryLater, }) { @@ -24,21 +26,24 @@ class MigrationDialog extends StatefulWidget { isDismissible: false, enableDrag: false, constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width), - builder: (ctx) => MigrationDialog(migrationData: migrationData, onMigrate: onMigrate, onTryLater: onTryLater), + builder: (ctx) => + MigrationDialog(migrationResults: migrationResults, onMigrate: onMigrate, onTryLater: onTryLater), ); } @override - State createState() => _MigrationDialogState(); + ConsumerState createState() => _MigrationDialogState(); } -class _MigrationDialogState extends State { +class _MigrationDialogState extends ConsumerState { bool _isMigrating = false; String? _errorMessage; @override Widget build(BuildContext context) { - final accountCount = widget.migrationData.length; + final successCount = widget.migrationResults.whereType().length; + final failureCount = widget.migrationResults.whereType().length; + final l10n = ref.watch(l10nProvider); final colors = context.colors; final text = context.themeText; @@ -53,20 +58,21 @@ class _MigrationDialogState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Migrate your accounts', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + Text(l10n.migrationDialogTitle, style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), const SizedBox(height: 24), - Text( - 'We\'ll record your old\u2011chain testnet rewards and actions to determine ' - 'rewards on the new Quantus Testnet.\n\n' - 'Balances do not migrate.\n\n' - 'Use the new testnet faucet for funds.', - style: text.smallParagraph?.copyWith(color: colors.textSecondary), - ), + Text(l10n.migrationDialogBody, style: text.smallParagraph?.copyWith(color: colors.textSecondary)), const SizedBox(height: 24), Text( - '$accountCount ${accountCount > 1 ? 'Accounts' : 'Account'} to migrate.', + l10n.migrationDialogAccountsToMigrate(successCount), style: text.paragraph?.copyWith(fontWeight: FontWeight.w600, color: colors.accentGreen), ), + if (failureCount > 0) ...[ + const SizedBox(height: 8), + Text( + l10n.migrationDialogAccountsCannotMigrate(failureCount), + style: text.smallParagraph?.copyWith(color: colors.accentOrange), + ), + ], const SizedBox(height: 40), if (_errorMessage != null) Container( @@ -77,27 +83,32 @@ class _MigrationDialogState extends State { child: Text(_errorMessage!, style: text.smallParagraph?.copyWith(color: colors.textError)), ), QuantusButton.simple( - label: _errorMessage != null ? 'Retry' : 'Migrate Accounts', + label: _errorMessage != null ? l10n.migrationDialogRetry : l10n.migrationDialogMigrate, isLoading: _isMigrating, - onTap: () async { - setState(() => _isMigrating = true); - try { - await widget.onMigrate(); - // ignore: use_build_context_synchronously - if (mounted) Navigator.of(context).pop(); - } catch (e) { - if (mounted) { - setState(() => _errorMessage = 'We couldn\'t upload migration data. Please retry or try later.'); - } - } finally { - if (mounted) setState(() => _isMigrating = false); - } - }, + onTap: successCount == 0 + ? null + : () async { + setState(() => _isMigrating = true); + try { + await widget.onMigrate(); + // ignore: use_build_context_synchronously + if (mounted) Navigator.of(context).pop(); + } catch (e) { + if (mounted) { + setState(() => _errorMessage = ref.read(l10nProvider).migrationDialogUploadError); + } + } finally { + if (mounted) setState(() => _isMigrating = false); + } + }, ), - if (_errorMessage != null) ...[ + // Show "Try later" when there's an error OR when there are no migratable accounts + if (_errorMessage != null || successCount == 0) ...[ const SizedBox(height: 12), QuantusButton.simple( - label: 'Try later', + label: successCount == 0 && _errorMessage == null + ? l10n.migrationDialogSkip + : l10n.migrationDialogTryLater, variant: ButtonVariant.transparent, onTap: () async { if (widget.onTryLater != null) await widget.onTryLater!(); diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index bc16da1c..3a1d76c5 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -12,6 +12,62 @@ "description": "Label for the button on the error dialog when the wallet is not found" }, + "migrationDialogTitle": "Migrate your accounts", + "@migrationDialogTitle": { + "description": "Title of the account migration dialog" + }, + "migrationDialogBody": "We'll record your old\u2011chain testnet rewards and actions to determine rewards on the new Quantus Testnet.\n\nBalances do not migrate.\n\nUse the new testnet faucet for funds.", + "@migrationDialogBody": { + "description": "Body text of the account migration dialog" + }, + "migrationDialogAccountsToMigrate": "{count, plural, =1{1 Account to migrate.} other{{count} Accounts to migrate.}}", + "@migrationDialogAccountsToMigrate": { + "description": "Number of accounts that will be migrated", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "migrationDialogAccountsCannotMigrate": "{count, plural, =1{1 account cannot be migrated (missing wallet data).} other{{count} accounts cannot be migrated (missing wallet data).}}", + "@migrationDialogAccountsCannotMigrate": { + "description": "Number of accounts that cannot be migrated", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "migrationDialogUploadError": "We couldn't upload migration data. Please retry or try later.", + "@migrationDialogUploadError": { + "description": "Error shown in the migration dialog when the upload fails" + }, + "migrationDialogMigrate": "Migrate Accounts", + "@migrationDialogMigrate": { + "description": "Label for the migrate button in the migration dialog" + }, + "migrationDialogRetry": "Retry", + "@migrationDialogRetry": { + "description": "Label for the retry button in the migration dialog" + }, + "migrationDialogTryLater": "Try later", + "@migrationDialogTryLater": { + "description": "Label for the try-later button in the migration dialog" + }, + "migrationDialogSkip": "Skip", + "@migrationDialogSkip": { + "description": "Label for the skip button in the migration dialog when no accounts can be migrated" + }, + "migrationPartialFailureToast": "{count, plural, =1{1 account could not be migrated. Migration will retry on next app launch.} other{{count} accounts could not be migrated. Migration will retry on next app launch.}}", + "@migrationPartialFailureToast": { + "description": "Toast shown when some accounts failed to migrate", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "authUseDeviceBiometricsToUnlock": "Use device biometrics to unlock", "@authUseDeviceBiometricsToUnlock": { "description": "Text for the text on the lock screen when using device biometrics to unlock" diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index ea1ecd2c..9412cd25 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -3,6 +3,17 @@ "walletInitErrorMessage": "Gagal mencari secret phrase. Coba pulihkan wallet anda.", "walletInitErrorButtonLabel": "OK", + "migrationDialogTitle": "Migrasikan akun Anda", + "migrationDialogBody": "Kami akan mencatat hadiah dan aktivitas testnet chain lama Anda untuk menentukan hadiah di Quantus Testnet yang baru.\n\nSaldo tidak ikut dimigrasikan.\n\nGunakan faucet testnet baru untuk mendapatkan dana.", + "migrationDialogAccountsToMigrate": "{count, plural, other{{count} Akun akan dimigrasikan.}}", + "migrationDialogAccountsCannotMigrate": "{count, plural, other{{count} akun tidak dapat dimigrasikan (data wallet hilang).}}", + "migrationDialogUploadError": "Kami tidak dapat mengunggah data migrasi. Silakan coba lagi atau coba nanti.", + "migrationDialogMigrate": "Migrasikan Akun", + "migrationDialogRetry": "Coba Lagi", + "migrationDialogTryLater": "Coba nanti", + "migrationDialogSkip": "Lewati", + "migrationPartialFailureToast": "{count, plural, other{{count} akun tidak dapat dimigrasikan. Migrasi akan diulang saat aplikasi dibuka berikutnya.}}", + "authUseDeviceBiometricsToUnlock": "Gunakan biometrik untuk mengakses wallet", "authAuthenticating": "Mengotentikasi...", "authUnlockWallet": "Buka Wallet", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index 78daba83..6e91f138 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -110,6 +110,66 @@ abstract class AppLocalizations { /// **'OK'** String get walletInitErrorButtonLabel; + /// Title of the account migration dialog + /// + /// In en, this message translates to: + /// **'Migrate your accounts'** + String get migrationDialogTitle; + + /// Body text of the account migration dialog + /// + /// In en, this message translates to: + /// **'We\'ll record your old‑chain testnet rewards and actions to determine rewards on the new Quantus Testnet.\n\nBalances do not migrate.\n\nUse the new testnet faucet for funds.'** + String get migrationDialogBody; + + /// Number of accounts that will be migrated + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 Account to migrate.} other{{count} Accounts to migrate.}}'** + String migrationDialogAccountsToMigrate(int count); + + /// Number of accounts that cannot be migrated + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 account cannot be migrated (missing wallet data).} other{{count} accounts cannot be migrated (missing wallet data).}}'** + String migrationDialogAccountsCannotMigrate(int count); + + /// Error shown in the migration dialog when the upload fails + /// + /// In en, this message translates to: + /// **'We couldn\'t upload migration data. Please retry or try later.'** + String get migrationDialogUploadError; + + /// Label for the migrate button in the migration dialog + /// + /// In en, this message translates to: + /// **'Migrate Accounts'** + String get migrationDialogMigrate; + + /// Label for the retry button in the migration dialog + /// + /// In en, this message translates to: + /// **'Retry'** + String get migrationDialogRetry; + + /// Label for the try-later button in the migration dialog + /// + /// In en, this message translates to: + /// **'Try later'** + String get migrationDialogTryLater; + + /// Label for the skip button in the migration dialog when no accounts can be migrated + /// + /// In en, this message translates to: + /// **'Skip'** + String get migrationDialogSkip; + + /// Toast shown when some accounts failed to migrate + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 account could not be migrated. Migration will retry on next app launch.} other{{count} accounts could not be migrated. Migration will retry on next app launch.}}'** + String migrationPartialFailureToast(int count); + /// Text for the text on the lock screen when using device biometrics to unlock /// /// 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 c07e27f4..64074456 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -17,6 +17,61 @@ class AppLocalizationsEn extends AppLocalizations { @override String get walletInitErrorButtonLabel => 'OK'; + @override + String get migrationDialogTitle => 'Migrate your accounts'; + + @override + String get migrationDialogBody => + 'We\'ll record your old‑chain testnet rewards and actions to determine rewards on the new Quantus Testnet.\n\nBalances do not migrate.\n\nUse the new testnet faucet for funds.'; + + @override + String migrationDialogAccountsToMigrate(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Accounts to migrate.', + one: '1 Account to migrate.', + ); + return '$_temp0'; + } + + @override + String migrationDialogAccountsCannotMigrate(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count accounts cannot be migrated (missing wallet data).', + one: '1 account cannot be migrated (missing wallet data).', + ); + return '$_temp0'; + } + + @override + String get migrationDialogUploadError => 'We couldn\'t upload migration data. Please retry or try later.'; + + @override + String get migrationDialogMigrate => 'Migrate Accounts'; + + @override + String get migrationDialogRetry => 'Retry'; + + @override + String get migrationDialogTryLater => 'Try later'; + + @override + String get migrationDialogSkip => 'Skip'; + + @override + String migrationPartialFailureToast(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count accounts could not be migrated. Migration will retry on next app launch.', + one: '1 account could not be migrated. Migration will retry on next app launch.', + ); + return '$_temp0'; + } + @override String get authUseDeviceBiometricsToUnlock => 'Use device biometrics to unlock'; diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index 6efa8c4a..44250885 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -17,6 +17,55 @@ class AppLocalizationsId extends AppLocalizations { @override String get walletInitErrorButtonLabel => 'OK'; + @override + String get migrationDialogTitle => 'Migrasikan akun Anda'; + + @override + String get migrationDialogBody => + 'Kami akan mencatat hadiah dan aktivitas testnet chain lama Anda untuk menentukan hadiah di Quantus Testnet yang baru.\n\nSaldo tidak ikut dimigrasikan.\n\nGunakan faucet testnet baru untuk mendapatkan dana.'; + + @override + String migrationDialogAccountsToMigrate(int count) { + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count Akun akan dimigrasikan.'); + return '$_temp0'; + } + + @override + String migrationDialogAccountsCannotMigrate(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count akun tidak dapat dimigrasikan (data wallet hilang).', + ); + return '$_temp0'; + } + + @override + String get migrationDialogUploadError => + 'Kami tidak dapat mengunggah data migrasi. Silakan coba lagi atau coba nanti.'; + + @override + String get migrationDialogMigrate => 'Migrasikan Akun'; + + @override + String get migrationDialogRetry => 'Coba Lagi'; + + @override + String get migrationDialogTryLater => 'Coba nanti'; + + @override + String get migrationDialogSkip => 'Lewati'; + + @override + String migrationPartialFailureToast(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count akun tidak dapat dimigrasikan. Migrasi akan diulang saat aplikasi dibuka berikutnya.', + ); + return '$_temp0'; + } + @override String get authUseDeviceBiometricsToUnlock => 'Gunakan biometrik untuk mengakses wallet'; diff --git a/mobile-app/lib/wallet_initializer.dart b/mobile-app/lib/wallet_initializer.dart index 6d77b918..56e9b94f 100644 --- a/mobile-app/lib/wallet_initializer.dart +++ b/mobile-app/lib/wallet_initializer.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/migration_dialog.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/shared/extensions/toaster_extensions.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; import 'package:resonance_network_wallet/v2/components/bottom_sheet_container.dart'; import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; @@ -26,7 +27,7 @@ class WalletInitializerState extends ConsumerState { bool _loading = true; bool _walletExists = false; bool _needsMigration = false; - List? _migrationData; + List? _migrationResults; final SettingsService _settingsService = SettingsService(); late final MigrationService _migrationService; @@ -53,16 +54,32 @@ class WalletInitializerState extends ConsumerState { if (needsMigration) { try { - final migrationData = await _migrationService.getMigrationData(); + final migrationResults = await _migrationService.getMigrationData(); - for (final data in migrationData) { - quantusDebugPrint( - 'MIGRATION: \nold index: ${data.oldAccount.index} \nold name: ${data.oldAccount.name} \nold accountId: ${data.oldAccount.accountId} \nnew accountId: ${data.newAccountId}', - ); + for (final result in migrationResults) { + switch (result) { + case MigrationSuccess(:final oldAccount, :final newAccountId): + quantusDebugPrint( + 'MIGRATION SUCCESS: \n' + ' walletIndex: ${oldAccount.walletIndex} \n' + ' old index: ${oldAccount.index} \n' + ' old name: ${oldAccount.name} \n' + ' old accountId: ${oldAccount.accountId} \n' + ' new accountId: $newAccountId', + ); + case MigrationFailure(:final oldAccount, :final reason): + quantusDebugPrint( + 'MIGRATION FAILURE: \n' + ' walletIndex: ${oldAccount.walletIndex} \n' + ' old index: ${oldAccount.index} \n' + ' old name: ${oldAccount.name} \n' + ' reason: $reason', + ); + } } setState(() { _needsMigration = true; - _migrationData = migrationData; + _migrationResults = migrationResults; _loading = false; }); @@ -70,7 +87,7 @@ class WalletInitializerState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { MigrationDialog.show( context: context, - migrationData: _migrationData!, + migrationResults: _migrationResults!, onMigrate: _performMigration, onTryLater: _tryLater, ); @@ -120,22 +137,49 @@ class WalletInitializerState extends ConsumerState { } Future _performMigration() async { - if (_migrationData == null) return; + if (_migrationResults == null) return; try { - // First, upload migration data to Supabase - await _uploadMigrationDataToSupabase(_migrationData!); + // Upload successful migrations to Supabase first. Encrypted (wormhole) + // accounts are excluded: their addresses are meant to be unlinkable to + // the user's identity. Accounts already saved by a previous partial + // migration are excluded so a retry doesn't upload duplicate rows. + final migratedIds = (await _settingsService.getAccounts()).map((a) => a.accountId).toSet(); + final uploadable = _migrationResults! + .whereType() + .where((s) => s.oldAccount.accountType != AccountType.encrypted && !migratedIds.contains(s.newAccountId)) + .toList(); + if (uploadable.isNotEmpty) { + await _uploadMigrationDataToSupabase(uploadable); + } // Then perform the actual migration - await _migrationService.performMigration(_migrationData!); + final failures = await _migrationService.performMigration(_migrationResults!); + + if (failures.isNotEmpty) { + quantusDebugPrint('Migration completed with ${failures.length} failures'); + for (final failure in failures) { + TelemetryService().sendEvent( + 'migration_account_failure', + parameters: { + 'wallet_index': failure.oldAccount.walletIndex.toString(), + 'account_index': failure.oldAccount.index.toString(), + 'reason': failure.reason, + }, + ); + } + } _reloadAccounts(); - // Migration completed successfully. Update state to show the main app. setState(() { _needsMigration = false; _walletExists = true; _loading = false; }); + + if (failures.isNotEmpty && mounted) { + context.showErrorToaster(message: ref.read(l10nProvider).migrationPartialFailureToast(failures.length)); + } } catch (e) { quantusDebugPrint('migration error: $e'); rethrow; @@ -148,9 +192,9 @@ class WalletInitializerState extends ConsumerState { await _settingsService.setAccountsToMigrate(oldAccounts); // Proceed with local migration immediately - if (_migrationData != null) { + if (_migrationResults != null) { try { - await _migrationService.performMigration(_migrationData!); + await _migrationService.performMigration(_migrationResults!); } catch (e, stackTrace) { quantusDebugPrint('error in tryLater: $e'); quantusDebugPrint('stack trace: $stackTrace'); @@ -169,13 +213,13 @@ class WalletInitializerState extends ConsumerState { }); } - Future _uploadMigrationDataToSupabase(List migrationData) async { + Future _uploadMigrationDataToSupabase(List migrationSuccesses) async { quantusDebugPrint('_uploadMigrationDataToSupabase'); final supabase = EnvUtils.supabaseClient; try { // Prepare the data for insertion - final dataToInsert = migrationData + final dataToInsert = migrationSuccesses .map( (data) => { 'old_account_id': data.oldAccount.accountId, @@ -190,7 +234,7 @@ class WalletInitializerState extends ConsumerState { // Insert all records at once await supabase.from('account_id_mappings').insert(dataToInsert); - quantusDebugPrint('Successfully uploaded ${migrationData.length} migration records to Supabase'); + quantusDebugPrint('Successfully uploaded ${migrationSuccesses.length} migration records to Supabase'); } catch (e) { quantusDebugPrint('Failed to upload migration data to Supabase: $e'); // Re-throw the error so it gets caught by the caller diff --git a/quantus_sdk/lib/src/services/chain_history_service.dart b/quantus_sdk/lib/src/services/chain_history_service.dart index 7aafbc96..4f402a4a 100644 --- a/quantus_sdk/lib/src/services/chain_history_service.dart +++ b/quantus_sdk/lib/src/services/chain_history_service.dart @@ -10,14 +10,22 @@ class OtherTransfersResult { final List transfers; final bool hasMore; - const OtherTransfersResult({required this.transfers, required this.hasMore}); + /// The number of raw rows consumed from the query result, including skipped + /// rows. Use this to advance pagination cursors, not [transfers.length]. + final int rawRowsConsumed; + + const OtherTransfersResult({required this.transfers, required this.hasMore, required this.rawRowsConsumed}); } class _Page { final List items; final bool hasMore; - const _Page({required this.items, required this.hasMore}); + /// The number of raw rows consumed from the query result, including rows + /// that parsed to null. Use this to advance pagination cursors. + final int rawRowsConsumed; + + const _Page({required this.items, required this.hasMore, required this.rawRowsConsumed}); } class ChainHistoryService { @@ -451,17 +459,21 @@ ${MultisigGraphql.cancelledMultisigProposalAccountEventSelection} _Page _pageFromEvents(List? events, int limit, T? Function(dynamic event) parseEvent) { if (events == null || events.isEmpty) { - return _Page(items: [], hasMore: false); + return _Page(items: [], hasMore: false, rawRowsConsumed: 0); } + // hasMore is true if the query returned more rows than requested (lookahead). final hasMore = events.length > limit; final items = []; + var rawRowsConsumed = 0; for (final event in events) { + // Stop once we have enough parsed items, but track all consumed rows. if (items.length >= limit) break; + rawRowsConsumed++; final parsed = parseEvent(event); if (parsed != null) items.add(parsed); } - return _Page(items: items, hasMore: hasMore); + return _Page(items: items, hasMore: hasMore, rawRowsConsumed: rawRowsConsumed); } ReversibleTransferEvent _parseScheduledTransferEvent(dynamic event) { @@ -673,7 +685,7 @@ ${MultisigGraphql.cancelledMultisigProposalAccountEventSelection} final List? events = responseBody['data']?['accountEvents']; final page = _pageFromEvents(events, limit, tryParseOtherTransferEvent); - return OtherTransfersResult(transfers: page.items, hasMore: page.hasMore); + return OtherTransfersResult(transfers: page.items, hasMore: page.hasMore, rawRowsConsumed: page.rawRowsConsumed); } catch (e, stackTrace) { sw.stop(); printTiming('fetchOtherTransfers FAILED', sw.elapsedMilliseconds); @@ -703,8 +715,10 @@ ${MultisigGraphql.cancelledMultisigProposalAccountEventSelection} final scheduledReversibleTransfers = results[0] as _Page; final otherTransfers = results[1] as OtherTransfersResult; - final nextOtherOffset = otherOffset + otherTransfers.transfers.length; - final nextScheduledOffset = scheduledOffset + scheduledReversibleTransfers.items.length; + // Advance offsets by raw rows consumed, not parsed item count, so the + // cursor doesn't drift when rows are skipped (null-parsed). + final nextOtherOffset = otherOffset + otherTransfers.rawRowsConsumed; + final nextScheduledOffset = scheduledOffset + scheduledReversibleTransfers.rawRowsConsumed; return SortedTransactionsList( scheduledReversibleTransfers: scheduledReversibleTransfers.items, diff --git a/quantus_sdk/lib/src/services/high_security_service.dart b/quantus_sdk/lib/src/services/high_security_service.dart index 52043310..f1d8d834 100644 --- a/quantus_sdk/lib/src/services/high_security_service.dart +++ b/quantus_sdk/lib/src/services/high_security_service.dart @@ -7,13 +7,13 @@ import 'package:quantus_sdk/generated/planck/types/qp_scheduler/block_number_or_ import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:quantus_sdk/src/extensions/address_extension.dart'; import 'package:quantus_sdk/src/extensions/duration_extension.dart'; +import 'package:quantus_sdk/src/rust/api/crypto.dart' as crypto; class HighSecurityService { static final HighSecurityService _instance = HighSecurityService._internal(); factory HighSecurityService() => _instance; HighSecurityService._internal(); - // ignore: unused_field final SubstrateService _substrateService = SubstrateService(); final ReversibleTransfersService _reversibleTransfersService = ReversibleTransfersService(); @@ -30,18 +30,7 @@ class HighSecurityService { String guardianAccountId, Duration safeguardDuration, ) async { - final transactionFee = await _reversibleTransfersService.getHighSecuritySetupFee( - account, - guardianAccountId, - safeguardDuration, - ); - final recoveryPalletFee = RecoveryService().configDepositBase + RecoveryService().friendDepositFactor; - - return ExtrinsicFeeData( - fee: transactionFee.fee + recoveryPalletFee, - blockHash: transactionFee.blockHash, - blockNumber: transactionFee.blockNumber, - ); + return await _reversibleTransfersService.getHighSecuritySetupFee(account, guardianAccountId, safeguardDuration); } Future isHighSecurity(Account account) async { @@ -89,53 +78,27 @@ class HighSecurityService { } } + /// Guardian-initiated emergency fund recovery for a high-security account. + /// + /// Uses `reversibleTransfers.recoverFunds`, which atomically cancels all + /// pending transfers and moves the remaining balance to the guardian. The + /// previous implementation batched recovery-pallet calls, but that pallet + /// was never configured for accounts, so the rescue path could not work. Future pullAllFunds(String lostAccountAddress, Account guardianAccount) async { - print('pullAllFunds: $lostAccountAddress, $guardianAccount'); - // 1. Initiate recovery (rescuer = guardian) - Utility batchCall = _getPullAllFundsCall(lostAccountAddress, guardianAccount); - // Submit batch signed by guardian - return await _substrateService.submitExtrinsic(guardianAccount, batchCall); + print('pullAllFunds: $lostAccountAddress, guardian: ${guardianAccount.accountId}'); + final call = _getRecoverFundsCall(lostAccountAddress); + return await _substrateService.submitExtrinsic(guardianAccount, call); } Future getPullAllFundsFee(String lostAccountAddress, Account guardianAccount) async { - // Batch all calls - final batchCall = _getPullAllFundsCall(lostAccountAddress, guardianAccount); - - // Get transaction fee - final transactionFee = await _substrateService.getFeeForCall(guardianAccount, batchCall); - - // Add recovery deposit - final recoveryDeposit = RecoveryService().recoveryDeposit; - - return ExtrinsicFeeData( - fee: transactionFee.fee + recoveryDeposit, - blockHash: transactionFee.blockHash, - blockNumber: transactionFee.blockNumber, - ); + final call = _getRecoverFundsCall(lostAccountAddress); + return await _substrateService.getFeeForCall(guardianAccount, call); } - Utility _getPullAllFundsCall(String lostAccountAddress, Account guardianAccount) { - final calls = []; - - final recoveryService = RecoveryService(); - final balancesService = BalancesService(); + ReversibleTransfers _getRecoverFundsCall(String lostAccountAddress) { final quantusApi = Planck(_substrateService.provider!); + final lostAccountId = crypto.ss58ToAccountId(s: lostAccountAddress); - // 1. Initiate recovery (rescuer = guardian) - calls.add(recoveryService.getInitiateRecoveryCall(lostAccountAddress)); - - // 2. Vouch for recovery (friend = guardian) - calls.add(recoveryService.getVouchRecoveryCall(lostAccountAddress, guardianAccount.accountId)); - - // 3. Claim recovery (rescuer = guardian) - calls.add(recoveryService.getClaimRecoveryCall(lostAccountAddress)); - - // 4. Transfer all funds to guardian (as recovered) - final transferAllCall = balancesService.getTransferAllCall(guardianAccount.accountId, keepAlive: false); - calls.add(recoveryService.getAsRecoveredCall(lostAccountAddress, transferAllCall)); - - // Batch all calls - final batchCall = quantusApi.tx.utility.batch(calls: calls); - return batchCall; + return quantusApi.tx.reversibleTransfers.recoverFunds(account: lostAccountId); } } diff --git a/quantus_sdk/lib/src/services/migration_service.dart b/quantus_sdk/lib/src/services/migration_service.dart index bdbea75e..df1613db 100644 --- a/quantus_sdk/lib/src/services/migration_service.dart +++ b/quantus_sdk/lib/src/services/migration_service.dart @@ -1,16 +1,38 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:quantus_sdk/src/constants/app_constants.dart'; import 'package:quantus_sdk/src/models/account.dart'; import 'package:quantus_sdk/src/models/display_account.dart'; import 'package:quantus_sdk/src/rust/api/crypto.dart' as crypto; import 'package:quantus_sdk/src/services/hd_wallet_service.dart'; import 'package:quantus_sdk/src/services/settings_service.dart'; +/// Result of attempting to migrate an account. +sealed class MigrationResult { + final Account oldAccount; + const MigrationResult(this.oldAccount); +} + +/// Successfully migrated account with new derived address. +class MigrationSuccess extends MigrationResult { + final String publicKeyHex; + final String newAccountId; + + const MigrationSuccess({required Account oldAccount, required this.publicKeyHex, required this.newAccountId}) + : super(oldAccount); +} + +/// Account that cannot be migrated due to missing mnemonic or other error. +class MigrationFailure extends MigrationResult { + final String reason; + + const MigrationFailure({required Account oldAccount, required this.reason}) : super(oldAccount); +} + class MigrationService { final SettingsService _settingsService; final HdWalletService _hdWalletService; - final int baseWalletIndex = 0; MigrationService(this._settingsService, this._hdWalletService); @@ -19,56 +41,133 @@ class MigrationService { return _settingsService.hasOldAccounts(); } - /// Get migration data including old accounts with their public keys - Future> getMigrationData() async { + /// Get migration data including old accounts with their public keys. + /// + /// Each result is either a [MigrationSuccess] with the derived address or a + /// [MigrationFailure] (e.g. missing mnemonic). Uses the correct mnemonic and + /// derivation path for each account's [Account.walletIndex] and + /// [Account.accountType]. + Future> getMigrationData() async { final oldAccounts = _settingsService.getOldAccounts(); - final mnemonic = await _settingsService.getMnemonic(baseWalletIndex); - - if (mnemonic == null) { - throw Exception('No mnemonic found for migration'); + final migrationResults = []; + + final mnemonicCache = {}; + + for (final rawAccount in oldAccounts) { + // Normalize the account type so every downstream check (derivation, + // upload filters, saved account type) agrees on what is a wormhole + // account. + final isWormhole = + rawAccount.accountType == AccountType.encrypted || rawAccount.index == AppConstants.encryptedAccountIndex; + final account = isWormhole ? rawAccount.copyWith(accountType: AccountType.encrypted) : rawAccount; + try { + final walletIndex = account.walletIndex; + if (!mnemonicCache.containsKey(walletIndex)) { + mnemonicCache[walletIndex] = await _settingsService.getMnemonic(walletIndex); + } + final mnemonic = mnemonicCache[walletIndex]; + + if (mnemonic == null) { + migrationResults.add( + MigrationFailure(oldAccount: account, reason: 'No mnemonic found for wallet $walletIndex'), + ); + continue; + } + + final String publicKeyHex; + final String newAccountId; + + if (isWormhole) { + final wormholeKeyPair = _hdWalletService.deriveWormholeKeyPair(mnemonic: mnemonic, index: 0); + publicKeyHex = wormholeKeyPair.addressHex.replaceFirst('0x', ''); + newAccountId = wormholeKeyPair.address; + } else { + final keypair = _hdWalletService.keyPairAtIndex(mnemonic, account.index); + publicKeyHex = _uint8ListToHex(keypair.publicKey); + newAccountId = crypto.toAccountId(obj: keypair); + } + + migrationResults.add( + MigrationSuccess(oldAccount: account, publicKeyHex: publicKeyHex, newAccountId: newAccountId), + ); + } catch (e) { + migrationResults.add(MigrationFailure(oldAccount: account, reason: 'Derivation error: $e')); + } } - final migrationData = []; - - for (final account in oldAccounts) { - final keypair = _hdWalletService.keyPairAtIndex(mnemonic, account.index); - final publicKeyHex = _uint8ListToHex(keypair.publicKey); + return migrationResults; + } - migrationData.add( - MigrationAccountData( - oldAccount: account, - publicKeyHex: publicKeyHex, - newAccountId: crypto.toAccountId(obj: keypair), - ), - ); + /// Perform the migration by creating new accounts and clearing old data. + /// + /// Only [MigrationSuccess] results are migrated. Old accounts are only + /// cleared when every account migrated successfully, preventing data loss. + /// + /// Returns the list of accounts that failed to migrate (if any). + Future> performMigration(List migrationResults) async { + final newAccounts = []; + final failures = []; + + for (final result in migrationResults) { + switch (result) { + case MigrationSuccess(:final oldAccount, :final newAccountId): + print( + 'performMigration: \n' + ' walletIndex: ${oldAccount.walletIndex} \n' + ' old index: ${oldAccount.index} \n' + ' old name: ${oldAccount.name} \n' + ' old accountId: ${oldAccount.accountId} \n' + ' accountType: ${oldAccount.accountType} \n' + ' new accountId: $newAccountId', + ); + + newAccounts.add( + Account( + walletIndex: oldAccount.walletIndex, + index: oldAccount.index, + name: oldAccount.name, + accountId: newAccountId, + accountType: oldAccount.accountType, + ), + ); + + case MigrationFailure(:final oldAccount, :final reason): + print( + 'performMigration SKIPPED: \n' + ' walletIndex: ${oldAccount.walletIndex} \n' + ' index: ${oldAccount.index} \n' + ' name: ${oldAccount.name} \n' + ' reason: $reason', + ); + failures.add(result); + } } - return migrationData; - } + if (newAccounts.isNotEmpty) { + // Merge by accountId so a retried migration never wipes accounts + // created since the last attempt. + final existing = await _settingsService.getAccounts(); + final existingIds = existing.map((a) => a.accountId).toSet(); + await _settingsService.saveAccounts([ + ...existing, + ...newAccounts.where((a) => !existingIds.contains(a.accountId)), + ]); + if (existing.isEmpty) { + await _settingsService.setActiveAccount(RegularAccount(newAccounts.first)); + } + } - /// Perform the migration by creating new accounts and clearing old data - Future performMigration(List migrationData) async { - List newAccounts = []; - for (final data in migrationData) { + // Only clear old accounts if all migrations succeeded, to prevent data loss. + if (failures.isEmpty) { + await _settingsService.clearOldAccounts(); + } else { print( - 'performMigration: \nold index: ${data.oldAccount.index} \nold name: ${data.oldAccount.name} \nold accountId: ${data.oldAccount.accountId} \nnew accountId: ${data.newAccountId}', - ); - - final newAccount = Account( - walletIndex: baseWalletIndex, - index: data.oldAccount.index, - name: data.oldAccount.name, - accountId: data.newAccountId, + 'WARNING: ${failures.length} account(s) failed to migrate. ' + 'Old accounts NOT cleared to prevent data loss.', ); - - newAccounts.add(newAccount); } - await _settingsService.saveAccounts(newAccounts); - if (newAccounts.isNotEmpty) { - await _settingsService.setActiveAccount(RegularAccount(newAccounts.first)); - } - await _settingsService.clearOldAccounts(); + return failures; } String _uint8ListToHex(Uint8List bytes) { @@ -86,17 +185,19 @@ class MigrationService { ), const Account(walletIndex: 0, index: 0, name: 'Account 0', accountId: 'debug_id_0'), const Account(walletIndex: 0, index: 1, name: 'Account 1', accountId: 'debug_id_1'), + // Test multi-wallet migration + const Account(walletIndex: 1, index: 0, name: 'Wallet 1 Account', accountId: 'debug_wallet1_id'), + // Test encrypted account migration + const Account( + walletIndex: 0, + index: AppConstants.encryptedAccountIndex, + name: 'Encrypted Account', + accountId: 'debug_encrypted_id', + accountType: AccountType.encrypted, + ), ]; final jsonData = jsonEncode(debugAccounts.map((a) => a.toJson()).toList()); await _settingsService.setOldAccountsData(jsonData); } } - -class MigrationAccountData { - final Account oldAccount; - final String publicKeyHex; - final String newAccountId; - - const MigrationAccountData({required this.oldAccount, required this.publicKeyHex, required this.newAccountId}); -} diff --git a/quantus_sdk/lib/src/services/recovery_service.dart b/quantus_sdk/lib/src/services/recovery_service.dart index 86ed9c1d..1406cd63 100644 --- a/quantus_sdk/lib/src/services/recovery_service.dart +++ b/quantus_sdk/lib/src/services/recovery_service.dart @@ -6,6 +6,7 @@ import 'package:quantus_sdk/generated/planck/types/pallet_recovery/active_recove import 'package:quantus_sdk/generated/planck/types/pallet_recovery/recovery_config.dart'; import 'package:quantus_sdk/generated/planck/types/sp_runtime/multiaddress/multi_address.dart' as multi_address; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:quantus_sdk/src/extensions/address_extension.dart'; import 'package:quantus_sdk/src/rust/api/crypto.dart' as crypto; /// Service for managing account recovery functionality @@ -210,10 +211,11 @@ class RecoveryService { final proxyId = crypto.ss58ToAccountId(s: proxyAddress); final recoveredAccountId = await quantusApi.query.recovery.proxy(proxyId); + // The storage map returns the final AccountId32, so encode it directly to + // SS58. crypto.toAccountId would incorrectly Poseidon-hash the + // already-derived account ID. return recoveredAccountId != null - ? crypto.toAccountId( - obj: crypto.Keypair(publicKey: Uint8List.fromList(recoveredAccountId), secretKey: Uint8List(0)), - ) + ? AddressExtension.ss58AddressFromBytes(Uint8List.fromList(recoveredAccountId)) : null; } catch (e) { throw Exception('Failed to get proxy recovered account: $e'); diff --git a/quantus_sdk/lib/src/services/reversible_transfers_service.dart b/quantus_sdk/lib/src/services/reversible_transfers_service.dart index 8247521e..d6b2317f 100644 --- a/quantus_sdk/lib/src/services/reversible_transfers_service.dart +++ b/quantus_sdk/lib/src/services/reversible_transfers_service.dart @@ -266,10 +266,6 @@ class ReversibleTransfersService { return address; }).toList(); - // for testing , add random valid address... - if (result.isNotEmpty) { - result.add('qzkaf6wMjRqXzWyBuxc6VwfYtUmjUF5tqJXsFs47PXspR67wh'); - } return result; } catch (e) { throw Exception('Failed to get intercepted accounts: $e'); diff --git a/quantus_sdk/lib/src/services/senoti_service.dart b/quantus_sdk/lib/src/services/senoti_service.dart index 7d0d9da4..2d3ef78d 100644 --- a/quantus_sdk/lib/src/services/senoti_service.dart +++ b/quantus_sdk/lib/src/services/senoti_service.dart @@ -59,10 +59,18 @@ class SenotiService { final SettingsService _settingsService = SettingsService(); SenotiAuthClient get _client => SenotiAuthClient(AppConstants.senotiEndpoint); + /// Wormhole addresses are meant to be unlinkable to the user's identity, so + /// registering them with the notification service would deanonymize them. + static List notifiableAddresses(List accounts, List multisigAccounts) => [ + ...accounts.where((a) => a.accountType != AccountType.encrypted).map((a) => a.accountId), + ...multisigAccounts.map((a) => a.accountId), + ]; + Future registerDevice(String token, String platform) async { - final regularAddresses = (await _settingsService.getAccounts()).map((a) => a.accountId).toList(); - final multisigAddresses = (await _settingsService.getMultisigAccounts()).map((a) => a.accountId).toList(); - final allAddresses = [...regularAddresses, ...multisigAddresses]; + final allAddresses = notifiableAddresses( + await _settingsService.getAccounts(), + await _settingsService.getMultisigAccounts(), + ); if (allAddresses.isEmpty) return; diff --git a/quantus_sdk/test/chain_history_service_test.dart b/quantus_sdk/test/chain_history_service_test.dart index f0f3467c..b86ce944 100644 --- a/quantus_sdk/test/chain_history_service_test.dart +++ b/quantus_sdk/test/chain_history_service_test.dart @@ -5,6 +5,40 @@ import 'package:quantus_sdk/quantus_sdk.dart'; void main() { final service = ChainHistoryService(); + group('OtherTransfersResult', () { + test('includes rawRowsConsumed for cursor advancement', () { + const result = OtherTransfersResult(transfers: [], hasMore: true, rawRowsConsumed: 10); + + expect(result.rawRowsConsumed, 10); + expect(result.transfers.length, 0); + expect(result.hasMore, true); + }); + + test('rawRowsConsumed can differ from transfers.length', () { + // Simulates when some rows are skipped during parsing + final result = OtherTransfersResult( + transfers: [ + TransferEvent( + id: 'test', + from: 'from', + to: 'to', + amount: BigInt.one, + fee: BigInt.one, + timestamp: DateTime.now(), + blockNumber: 1, + blockHash: '0xabc', + ), + ], + hasMore: true, + rawRowsConsumed: 5, // 5 rows consumed, only 1 parsed successfully + ); + + expect(result.transfers.length, 1); + expect(result.rawRowsConsumed, 5); + expect(result.rawRowsConsumed, greaterThan(result.transfers.length)); + }); + }); + const accountEventFixture = { 'id': 'ae-multisig-qzo4qS1Lw6J66JuXcxLEWgzBLX2sBe3Ak3kmN1oA17pXLKCFH-qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7', diff --git a/quantus_sdk/test/services/migration_derivation_test.dart b/quantus_sdk/test/services/migration_derivation_test.dart new file mode 100644 index 00000000..3305e1ea --- /dev/null +++ b/quantus_sdk/test/services/migration_derivation_test.dart @@ -0,0 +1,77 @@ +@Tags(['native']) +library; + +import 'dart:convert'; + +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:quantus_sdk/src/rust/api/crypto.dart' as crypto; +import 'package:quantus_sdk/src/rust/frb_generated.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await RustLib.init(); + }); + + late SettingsService settings; + late MigrationService service; + final hdWallet = HdWalletService(); + + Future seedOldAccounts(List accounts) async { + await settings.setOldAccountsData(jsonEncode(accounts.map((a) => a.toJson()).toList())); + } + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + FlutterSecureStorage.setMockInitialValues({'mnemonic': _mnemonic}); + settings = SettingsService(); + await settings.initialize(); + service = MigrationService(settings, hdWallet); + }); + + group('MigrationService.getMigrationData', () { + test('derives transparent accounts from their wallet mnemonic and index', () async { + const old = Account(walletIndex: 0, index: 0, name: 'A', accountId: 'old_a'); + await seedOldAccounts([old]); + + final results = await service.getMigrationData(); + + final success = results.single as MigrationSuccess; + expect(success.newAccountId, crypto.toAccountId(obj: hdWallet.keyPairAtIndex(_mnemonic, 0))); + }); + + test('index-flagged wormhole accounts derive via the wormhole path and are typed encrypted', () async { + // Legacy data may carry the reserved index without an accountType. + const old = Account( + walletIndex: 0, + index: AppConstants.encryptedAccountIndex, + name: 'Wormhole', + accountId: 'old_wormhole', + ); + await seedOldAccounts([old]); + + final results = await service.getMigrationData(); + + final success = results.single as MigrationSuccess; + expect(success.newAccountId, hdWallet.deriveWormholeKeyPair(mnemonic: _mnemonic).address); + // Normalized so the Supabase upload and Senoti filters exclude it. + expect(success.oldAccount.accountType, AccountType.encrypted); + }); + + test('accounts of a wallet with no mnemonic become failures', () async { + const old = Account(walletIndex: 1, index: 0, name: 'B', accountId: 'old_b'); + await seedOldAccounts([old]); + + final results = await service.getMigrationData(); + + final failure = results.single as MigrationFailure; + expect(failure.reason, contains('wallet 1')); + }); + }); +} diff --git a/quantus_sdk/test/services/migration_service_test.dart b/quantus_sdk/test/services/migration_service_test.dart new file mode 100644 index 00000000..39e9563a --- /dev/null +++ b/quantus_sdk/test/services/migration_service_test.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +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(); + + late SettingsService settings; + late MigrationService service; + + const oldA = Account(walletIndex: 0, index: 0, name: 'A', accountId: 'old_a'); + const oldB = Account(walletIndex: 1, index: 0, name: 'B', accountId: 'old_b'); + + const successA = MigrationSuccess(oldAccount: oldA, publicKeyHex: 'hex_a', newAccountId: 'new_a'); + const failureB = MigrationFailure(oldAccount: oldB, reason: 'No mnemonic found for wallet 1'); + + Future seedOldAccounts(List accounts) async { + await settings.setOldAccountsData(jsonEncode(accounts.map((a) => a.toJson()).toList())); + } + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + settings = SettingsService(); + await settings.initialize(); + service = MigrationService(settings, HdWalletService()); + }); + + group('MigrationService.performMigration', () { + test('full success saves accounts, sets active account and clears old accounts', () async { + await seedOldAccounts([oldA]); + + final failures = await service.performMigration([successA]); + + expect(failures, isEmpty); + expect((await settings.getAccounts()).map((a) => a.accountId), ['new_a']); + expect((await settings.getActiveRegularAccount())?.accountId, 'new_a'); + expect(settings.hasOldAccounts(), isFalse); + }); + + test('partial failure reports failures and keeps old accounts for retry', () async { + await seedOldAccounts([oldA, oldB]); + + final failures = await service.performMigration([successA, failureB]); + + expect(failures.map((f) => f.oldAccount.accountId), ['old_b']); + expect((await settings.getAccounts()).map((a) => a.accountId), ['new_a']); + expect(settings.hasOldAccounts(), isTrue); + }); + + test('retry merges by accountId and never wipes accounts created in between', () async { + await seedOldAccounts([oldA, oldB]); + await service.performMigration([successA, failureB]); + + // User creates an account between the failed attempt and the retry. + const created = Account(walletIndex: 0, index: 1, name: 'Created', accountId: 'created_id'); + await settings.addAccount(created); + await settings.setActiveAccount(const RegularAccount(created)); + + final failures = await service.performMigration([successA, failureB]); + + expect(failures, hasLength(1)); + final ids = (await settings.getAccounts()).map((a) => a.accountId).toList(); + expect(ids, containsAll(['new_a', 'created_id'])); + expect(ids.where((id) => id == 'new_a'), hasLength(1)); + expect((await settings.getActiveRegularAccount())?.accountId, 'created_id'); + }); + + test('all-failure migration saves nothing and keeps old accounts', () async { + await seedOldAccounts([oldB]); + + final failures = await service.performMigration([failureB]); + + expect(failures, hasLength(1)); + expect(await settings.getAccounts(), isEmpty); + expect(settings.hasOldAccounts(), isTrue); + }); + }); +} diff --git a/quantus_sdk/test/services/recovery_proxy_encoding_test.dart b/quantus_sdk/test/services/recovery_proxy_encoding_test.dart new file mode 100644 index 00000000..50317453 --- /dev/null +++ b/quantus_sdk/test/services/recovery_proxy_encoding_test.dart @@ -0,0 +1,104 @@ +@Tags(['native']) +library; + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/src/extensions/address_extension.dart'; +import 'package:quantus_sdk/src/rust/api/crypto.dart' as crypto; +import 'package:quantus_sdk/src/rust/frb_generated.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:ss58/ss58.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await RustLib.init(); + }); + + group('Recovery proxy account encoding', () { + test('ss58AddressFromBytes round-trips AccountId32 bytes correctly', () { + // Generate a keypair and get its account ID address + final keypair = crypto.generateKeypair( + mnemonicStr: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ); + final address = crypto.toAccountId(obj: keypair); + + // Decode the address to get the raw AccountId32 bytes + final accountIdBytes = crypto.ss58ToAccountId(s: address); + + // Encode using ss58AddressFromBytes (uses Quantus prefix) + final quantusAddress = AddressExtension.ss58AddressFromBytes(Uint8List.fromList(accountIdBytes)); + + // Decode the Quantus address back to bytes using ss58 package (supports any prefix) + final decodedAddress = Address.decode(quantusAddress); + final decodedBytes = decodedAddress.pubkey; + + expect( + decodedBytes, + equals(accountIdBytes), + reason: 'ss58AddressFromBytes should round-trip: encoding then decoding should give original bytes', + ); + }); + + test('crypto.toAccountId with AccountId32 bytes as publicKey produces different bytes', () { + // Generate a keypair and get its correct AccountId32 bytes + final keypair = crypto.generateKeypair( + mnemonicStr: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ); + final correctAddress = crypto.toAccountId(obj: keypair); + final correctAccountIdBytes = crypto.ss58ToAccountId(s: correctAddress); + + // WRONG: If we use toAccountId with the AccountId32 as publicKey, it will hash again + final doubleHashedAddress = crypto.toAccountId( + obj: crypto.Keypair(publicKey: Uint8List.fromList(correctAccountIdBytes), secretKey: Uint8List(0)), + ); + + // Decode to get the bytes - they should NOT match the original AccountId32 + final doubleHashedBytes = crypto.ss58ToAccountId(s: doubleHashedAddress); + + expect( + doubleHashedBytes, + isNot(equals(correctAccountIdBytes)), + reason: 'toAccountId hashes its input, so using AccountId32 as publicKey produces wrong bytes', + ); + }); + + test('ss58AddressFromBytes is the correct way to encode AccountId32 from storage', () { + final keypair = crypto.generateKeypair( + mnemonicStr: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ); + + // Get the correct AccountId32 bytes (what recovery.proxy storage returns) + final correctAddress = crypto.toAccountId(obj: keypair); + final storageReturnedAccountId = crypto.ss58ToAccountId(s: correctAddress); + + // CORRECT: Encode directly without additional hashing + final correctQuantusAddress = AddressExtension.ss58AddressFromBytes(Uint8List.fromList(storageReturnedAccountId)); + final correctDecodedAddress = Address.decode(correctQuantusAddress); + final correctAddressBytes = correctDecodedAddress.pubkey; + + // WRONG: What the bug was doing - passing to toAccountId which hashes again + final wrongAddress = crypto.toAccountId( + obj: crypto.Keypair(publicKey: Uint8List.fromList(storageReturnedAccountId), secretKey: Uint8List(0)), + ); + final wrongAddressBytes = crypto.ss58ToAccountId(s: wrongAddress); + + // The correct encoding should preserve the original AccountId32 bytes + expect( + correctAddressBytes, + equals(storageReturnedAccountId), + reason: 'ss58AddressFromBytes preserves the AccountId32 bytes', + ); + + // The wrong encoding produces different bytes (double-hashed) + expect( + wrongAddressBytes, + isNot(equals(storageReturnedAccountId)), + reason: 'toAccountId with AccountId32 as publicKey double-hashes and produces wrong bytes', + ); + }); + }); +} diff --git a/quantus_sdk/test/services/senoti_privacy_test.dart b/quantus_sdk/test/services/senoti_privacy_test.dart new file mode 100644 index 00000000..e7bf6766 --- /dev/null +++ b/quantus_sdk/test/services/senoti_privacy_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +void main() { + group('SenotiService.notifiableAddresses', () { + const regular = Account(walletIndex: 0, index: 0, name: 'Regular', accountId: 'qzRegular'); + const encrypted = Account( + walletIndex: 0, + index: AppConstants.encryptedAccountIndex, + name: 'Wormhole', + accountId: 'qzWormhole', + accountType: AccountType.encrypted, + ); + const keystone = Account( + walletIndex: 1, + index: 0, + name: 'Hardware', + accountId: 'qzKeystone', + accountType: AccountType.keystone, + ); + final multisig = MultisigAccount( + name: 'Multisig', + accountId: 'qzMultisig', + signers: const ['qzRegular'], + threshold: 1, + nonce: BigInt.zero, + myMemberAccountId: 'qzRegular', + ); + + test('excludes encrypted accounts and keeps regular, keystone and multisig addresses', () { + final addresses = SenotiService.notifiableAddresses([regular, encrypted, keystone], [multisig]); + + expect(addresses, ['qzRegular', 'qzKeystone', 'qzMultisig']); + }); + }); +}