diff --git a/lib/bloc/auth_bloc/auth_bloc.dart b/lib/bloc/auth_bloc/auth_bloc.dart index 5536c29d7d..cae9570924 100644 --- a/lib/bloc/auth_bloc/auth_bloc.dart +++ b/lib/bloc/auth_bloc/auth_bloc.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' @@ -34,6 +35,20 @@ class AuthBloc extends Bloc with TrezorAuthMixin { 'This wallet appears to have already been migrated. ' 'Use the migrated wallet entry and its current password.'; static const Duration _postLoginStepTimeout = Duration(seconds: 5); + static const DeepCollectionEquality _metadataEquality = + DeepCollectionEquality(); + static const Set _optimisticAuthMetadataKeys = { + 'type', + 'wallet_provenance', + 'wallet_created_at', + 'has_backup', + 'activated_coins', + legacySourceKindMetadataKey, + legacySourceWalletIdMetadataKey, + legacySourceWalletNameMetadataKey, + legacyCleanupStatusMetadataKey, + legacyWalletExtrasMetadataKey, + }; /// Handles [AuthBlocEvent]s and emits [AuthBlocState]s. /// [_kdfSdk] is an instance of [KomodoDefiSdk] used for authentication. @@ -222,10 +237,9 @@ class AuthBloc extends Bloc with TrezorAuthMixin { } if (event.currentUser != null) { - // After optimistic login, the SDK watcher fires with the bare user - // before the background finalizer persists metadata. Suppress only if - // the incoming metadata carries no new or changed values; allow updates - // from finalizers (e.g. cleanup status, activated coins) through. + // After optimistic login, the SDK watcher can fire with a less-initialized + // user before the background finalizer persists metadata. Suppress those + // stale snapshots while still allowing complete finalizer updates through. if (state.status == AuthenticationStatus.completed && state.currentUser?.walletId == event.currentUser!.walletId && !_hasNewerMetadata( @@ -517,8 +531,7 @@ class AuthBloc extends Bloc with TrezorAuthMixin { allowWeakPassword: weakPasswordsAllowed, ), ); - final LegacyWalletSource? linkageSource = - event.sourceWallet.legacySource; + final LegacyWalletSource? linkageSource = event.sourceWallet.legacySource; if (linkageSource != null) { await _kdfSdk.setMigratedLegacySource( source: linkageSource, @@ -987,22 +1000,71 @@ class AuthBloc extends Bloc with TrezorAuthMixin { } } - /// Returns `true` if [incoming] contains at least one key whose value - /// differs from [current], or a key that [current] does not have at all. - /// Used to distinguish bare-user watcher re-emissions (no new data) from - /// post-login finalizer updates that carry meaningful metadata changes. + /// Returns `true` when [incoming] carries metadata that should replace + /// [current] for same-wallet watcher updates. bool _hasNewerMetadata( Map incoming, Map current, ) { + if (_dropsOptimisticMetadata(incoming, current)) { + return false; + } + if (_regressesLegacyCleanupStatus(incoming, current)) { + return false; + } + for (final entry in incoming.entries) { - if (!current.containsKey(entry.key) || current[entry.key] != entry.value) { + if (!current.containsKey(entry.key) || + !_metadataEquality.equals(current[entry.key], entry.value)) { return true; } } return false; } + bool _dropsOptimisticMetadata( + Map incoming, + Map current, + ) { + for (final key in _optimisticAuthMetadataKeys) { + if (_hasOptimisticMetadataValue(current, key) && + !_preservesOptimisticMetadataValue(incoming, key)) { + return true; + } + } + return false; + } + + bool _hasOptimisticMetadataValue(Map metadata, String key) { + if (!_preservesOptimisticMetadataValue(metadata, key)) return false; + + final value = metadata[key]; + if (value is Iterable || value is Map) { + return value.isNotEmpty; + } + return true; + } + + bool _preservesOptimisticMetadataValue( + Map metadata, + String key, + ) { + if (!metadata.containsKey(key)) return false; + + final value = metadata[key]; + return !_isMissingMetadataStringValue(value); + } + + bool _regressesLegacyCleanupStatus( + Map incoming, + Map current, + ) { + final currentStatus = current[legacyCleanupStatusMetadataKey]; + final incomingStatus = incoming[legacyCleanupStatusMetadataKey]; + return currentStatus == LegacyMigrationCleanupStatus.complete.name && + incomingStatus == LegacyMigrationCleanupStatus.incomplete.name; + } + bool _isMissingMetadataStringValue(dynamic value) { return value == null || value is String && value.trim().isEmpty; } diff --git a/test_units/tests/wallet/legacy_native_wallet_migration_test.dart b/test_units/tests/wallet/legacy_native_wallet_migration_test.dart index 59dd34974c..e6487dfbc1 100644 --- a/test_units/tests/wallet/legacy_native_wallet_migration_test.dart +++ b/test_units/tests/wallet/legacy_native_wallet_migration_test.dart @@ -601,6 +601,158 @@ void main() { }); group('AuthBloc legacy migration', () { + test( + 'auth watcher ignores less-initialized metadata for the active wallet', + () async { + final auth = _FakeAuth(users: const []); + final bloc = AuthBloc( + _FakeSdk(auth: auth), + WalletsRepository( + _FakeSdk(auth: auth), + _FakeMm2Api(), + _FakeStorage(), + ), + SettingsRepository(storage: _FakeStorage()), + _FakeTradingStatusService(), + ); + addTearDown(bloc.close); + + final optimisticUser = _buildUser( + walletName: 'Migrated_Wallet', + derivationMethod: DerivationMethod.iguana, + metadata: { + 'type': WalletType.iguana.name, + 'wallet_provenance': WalletProvenance.imported.name, + 'wallet_created_at': 1, + 'has_backup': true, + 'activated_coins': ['BTC'], + legacySourceKindMetadataKey: LegacyWalletSourceKind.nativeApp.name, + legacySourceWalletIdMetadataKey: 'native-1', + legacySourceWalletNameMetadataKey: 'Legacy Wallet!', + legacyCleanupStatusMetadataKey: + LegacyMigrationCleanupStatus.incomplete.name, + }, + ); + final staleUser = _buildUser( + walletName: 'Migrated_Wallet', + derivationMethod: DerivationMethod.iguana, + metadata: const {'activated_coins': []}, + ); + + final optimisticStateFuture = bloc.stream.firstWhere( + (state) => state.currentUser?.metadata['wallet_created_at'] == 1, + ); + bloc.add( + AuthModeChanged( + mode: AuthorizeMode.logIn, + currentUser: optimisticUser, + ), + ); + await optimisticStateFuture; + + bloc.add( + AuthModeChanged(mode: AuthorizeMode.logIn, currentUser: staleUser), + ); + await pumpEventQueue(times: 10); + + expect(bloc.state.currentUser?.metadata['activated_coins'], [ + 'BTC', + ]); + expect( + bloc.state.currentUser?.metadata['type'], + WalletType.iguana.name, + ); + }, + ); + + test( + 'auth watcher accepts complete finalizer metadata for the active wallet', + () async { + final auth = _FakeAuth(users: const []); + final bloc = AuthBloc( + _FakeSdk(auth: auth), + WalletsRepository( + _FakeSdk(auth: auth), + _FakeMm2Api(), + _FakeStorage(), + ), + SettingsRepository(storage: _FakeStorage()), + _FakeTradingStatusService(), + ); + addTearDown(bloc.close); + + final optimisticUser = _buildUser( + walletName: 'Migrated_Wallet', + derivationMethod: DerivationMethod.iguana, + metadata: { + 'type': WalletType.iguana.name, + 'wallet_provenance': WalletProvenance.imported.name, + 'wallet_created_at': 1, + 'has_backup': true, + 'activated_coins': ['BTC'], + legacySourceKindMetadataKey: LegacyWalletSourceKind.nativeApp.name, + legacySourceWalletIdMetadataKey: 'native-1', + legacySourceWalletNameMetadataKey: 'Legacy Wallet!', + legacyCleanupStatusMetadataKey: + LegacyMigrationCleanupStatus.incomplete.name, + }, + ); + final finalizedUser = _buildUser( + walletName: 'Migrated_Wallet', + derivationMethod: DerivationMethod.iguana, + metadata: { + 'type': WalletType.iguana.name, + 'wallet_provenance': WalletProvenance.imported.name, + 'wallet_created_at': 1, + 'has_backup': true, + 'activated_coins': ['BTC', 'KMD'], + legacySourceKindMetadataKey: LegacyWalletSourceKind.nativeApp.name, + legacySourceWalletIdMetadataKey: 'native-1', + legacySourceWalletNameMetadataKey: 'Legacy Wallet!', + legacyCleanupStatusMetadataKey: + LegacyMigrationCleanupStatus.complete.name, + }, + ); + + final optimisticStateFuture = bloc.stream.firstWhere( + (state) => state.currentUser?.metadata['wallet_created_at'] == 1, + ); + bloc.add( + AuthModeChanged( + mode: AuthorizeMode.logIn, + currentUser: optimisticUser, + ), + ); + await optimisticStateFuture; + + final finalizedStateFuture = bloc.stream.firstWhere( + (state) => + state.currentUser?.metadata[legacyCleanupStatusMetadataKey] == + LegacyMigrationCleanupStatus.complete.name, + ); + bloc.add( + AuthModeChanged( + mode: AuthorizeMode.logIn, + currentUser: finalizedUser, + ), + ); + final finalizedState = await finalizedStateFuture; + + expect( + finalizedState.currentUser?.metadata['activated_coins'], + ['BTC', 'KMD'], + ); + expect( + finalizedState + .currentUser + ?.wallet + .migratedLegacySource + ?.cleanupStatus, + LegacyMigrationCleanupStatus.complete, + ); + }, + ); + test( 'legacy migration registers with the KDF password and stores migrated metadata', () async {