From 46dd1333ff7e4da627d77d690df31ccb4a32d496 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 17:58:26 +0700 Subject: [PATCH] Updated on 2026-08-14 --- .../data/account/di/AccountDataModule.kt | 8 +- .../fetcher/DefaultWalletAccountsFetcher.kt | 5 +- .../DefaultMainAccountTokensMigration.kt | 78 +++++++ .../DefaultWalletAccountsFetcherTest.kt | 9 + .../DefaultMainAccountTokensMigrationTest.kt | 208 ++++++++++++++++++ 5 files changed, 306 insertions(+), 2 deletions(-) diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt index 42eb66d25b..10b55769a9 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -108,10 +108,16 @@ internal object AccountDataModule { @Provides @Singleton fun provideMainAccountTokensMigration( + defaultMainAccountTokensMigration: DefaultMainAccountTokensMigration, + ): MainAccountTokensMigration = defaultMainAccountTokensMigration + + @Provides + @Singleton + fun provideDefaultMainAccountTokensMigration( accountsResponseStoreFactory: AccountsResponseStoreFactory, userTokensSaver: UserTokensSaver, accountTokenMigrationStore: AccountTokenMigrationStore, - ): MainAccountTokensMigration { + ): DefaultMainAccountTokensMigration { return DefaultMainAccountTokensMigration( accountsResponseStoreFactory = accountsResponseStoreFactory, accountTokenMigrationStore = accountTokenMigrationStore, diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt index 8c475f3b58..bd25eb1add 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt @@ -2,6 +2,7 @@ package com.tangem.data.account.fetcher import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.account.utils.assignTokens import com.tangem.data.common.account.WalletAccountsFetcher @@ -50,6 +51,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory, private val eTagsStore: ETagsStore, private val dispatchers: CoroutineDispatcherProvider, + private val mainAccountTokensMigration: DefaultMainAccountTokensMigration, ) : WalletAccountsFetcher, WalletAccountsSaver { override suspend fun fetch(userWalletId: UserWalletId): GetWalletAccountsResponse { @@ -71,7 +73,8 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( throw fetchResult.error } - return updatedResponse + val migratedResponse = mainAccountTokensMigration.migrate(userWalletId).getOrNull() ?: updatedResponse + return migratedResponse } override suspend fun getSaved(userWalletId: UserWalletId): GetWalletAccountsResponse? { diff --git a/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt b/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt index d4e9d0325f..8e65c680ce 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt @@ -7,6 +7,7 @@ import arrow.core.raise.ensureNotNull import arrow.core.toNonEmptyListOrNull import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.data.account.converter.toDerivationIndex import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.utils.assignTokens import com.tangem.data.common.currency.UserTokensSaver @@ -37,6 +38,67 @@ internal class DefaultMainAccountTokensMigration( private val userTokensSaver: UserTokensSaver, ) : MainAccountTokensMigration { + internal suspend fun migrate(userWalletId: UserWalletId): Either = either { + val store = accountsResponseStoreFactory.create(userWalletId) + val response = store.getSyncOrNull() + ensureNotNull(response) { + val exception = IllegalStateException("No cached accounts response found") + Timber.e(exception) + exception + } + val mainAccount = findAccount(response = response, derivationIndex = DerivationIndex.Main) + val notMainAccounts = response.accounts + .filterNot { accountDTO -> accountDTO.derivationIndex.toDerivationIndex().isMain } + + if (notMainAccounts.isEmpty()) { + Timber.i("There is only the Main account. Nothing to migrate") + return@either response + } + + val unassignedTokens = mainAccount.groupUnassignedTokens() + + if (unassignedTokens.isEmpty()) { + Timber.i("No unassigned tokens found for migration") + return@either response + } + + var updatedMainAccount = mainAccount + val assignedTokensAccounts = notMainAccounts.mapNotNull { accountDTO -> + val derivationIndex = accountDTO.derivationIndex.toDerivationIndex() + val tokensForAccount = unassignedTokens[derivationIndex] + if (tokensForAccount.isNullOrEmpty()) return@mapNotNull null + updatedMainAccount = updatedMainAccount.copy( + tokens = updatedMainAccount.tokens.orEmpty() - tokensForAccount, + ) + accountDTO.assignTokens(userWalletId, tokensForAccount) + } + + val updatedResponse = response.copy( + accounts = response.accounts.map { account -> + val assignAccount = assignedTokensAccounts.find { it.id == account.id } + when { + account.id == mainAccount.id -> updatedMainAccount + assignAccount != null -> assignAccount + else -> account + } + }, + ) + + store.updateData { updatedResponse } + + val userTokensResponse = updatedResponse.toUserTokensResponse() + userTokensSaver.pushWithRetryer( + userWalletId = userWalletId, + response = userTokensResponse, + onFailSend = { + val exception = IllegalStateException("Failed to push updated tokens after migration") + Timber.e(exception) + raise(exception) + }, + ) + return@either updatedResponse + } + override suspend fun migrate( userWalletId: UserWalletId, derivationIndex: DerivationIndex, @@ -113,6 +175,22 @@ internal class DefaultMainAccountTokensMigration( } } + private fun WalletAccountDTO.groupUnassignedTokens(): Map> = + this.tokens + .orEmpty() + .mapNotNull { token -> + val blockchain = Blockchain.fromNetworkId(token.networkId) ?: return@mapNotNull null + val derivationPath = token.derivationPath ?: return@mapNotNull null + val accountNode = AccountNodeRecognizer(blockchain) + .recognize(derivationPath) + ?: return@mapNotNull null + + if (accountNode == DerivationIndex.Main.value.toLong()) return@mapNotNull null + val derivationIndex = DerivationIndex(accountNode.toInt()).getOrNull() ?: return@mapNotNull null + derivationIndex to token.copy(accountId = null) + } + .groupBy({ it.first }, { it.second }) + private fun WalletAccountDTO.findUnassignedTokens( derivationIndex: DerivationIndex, ): List? { diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt index 1bfbb38da1..4f358b2d09 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt @@ -1,11 +1,13 @@ package com.tangem.data.account.fetcher +import arrow.core.right import com.google.common.truth.Truth import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.converter.createWalletAccountDTO import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher.FetchResult import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.UserTokensSaver @@ -35,6 +37,7 @@ class DefaultWalletAccountsFetcherTest { private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() private val accountsResponseStore: AccountsResponseStore = mockk() private val accountsResponseStoreFlow = MutableStateFlow(value = null) + private val tokensMigration: DefaultMainAccountTokensMigration = mockk(relaxed = true) private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler = mockk(relaxUnitFun = true) @@ -49,16 +52,19 @@ class DefaultWalletAccountsFetcherTest { defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory, eTagsStore = eTagsStore, dispatchers = TestingCoroutineDispatcherProvider(), + mainAccountTokensMigration = tokensMigration, ) private val userWalletId = UserWalletId("011") private val eTag = "etag" + private val migratedAccountsResponse = createGetWalletAccountsResponse(userWalletId) @BeforeAll fun setUp() { every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore every { accountsResponseStore.data } returns accountsResponseStoreFlow + coEvery { tokensMigration.migrate(userWalletId) } returns migratedAccountsResponse.right() coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag } @@ -134,6 +140,7 @@ class DefaultWalletAccountsFetcherTest { accountsResponseStore.updateData(any()) accountsResponseStoreFactory.create(userWalletId = userWalletId) accountsResponseStore.updateData(any()) + tokensMigration.migrate(userWalletId) } coVerify(inverse = true) { @@ -181,6 +188,7 @@ class DefaultWalletAccountsFetcherTest { eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = newETag) accountsResponseStoreFactory.create(userWalletId = userWalletId) accountsResponseStore.updateData(any()) + tokensMigration.migrate(userWalletId) } coVerify(inverse = true) { @@ -236,6 +244,7 @@ class DefaultWalletAccountsFetcherTest { pushWalletAccounts = any(), storeWalletAccounts = any(), ) + tokensMigration.migrate(userWalletId) } coVerify(inverse = true) { diff --git a/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt b/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt index 025b7d07bd..e4d0df9606 100644 --- a/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt @@ -1,5 +1,6 @@ package com.tangem.data.account.token +import arrow.core.right import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.converter.createWalletAccountDTO import com.tangem.data.account.store.AccountsResponseStore @@ -13,6 +14,7 @@ import com.tangem.datasource.local.accounts.AccountTokenMigrationStore import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.assertEither import com.tangem.test.core.assertEitherLeft import com.tangem.test.core.assertEitherRight import io.mockk.* @@ -214,6 +216,212 @@ class DefaultMainAccountTokensMigrationTest { } } + @Test + fun `migrate updates tokens for all accounts`() = runTest { + // Arrange + val unassignedToken1 = createBitcoin(accountIndex = 1) + val unassignedToken2 = createBitcoin(accountIndex = 2) + + val derivationIndex1 = DerivationIndex(1).getOrNull()!! + val derivationIndex2 = DerivationIndex(2).getOrNull()!! + + val mainAccount = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value, + derivationIndex = DerivationIndex.Main.value, + tokens = listOf( + createBitcoin(accountIndex = 0), + unassignedToken1, + unassignedToken2, + ), + ) + + val account1 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex1).value, + derivationIndex = derivationIndex1.value, + tokens = emptyList(), + ) + + val account2 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex2).value, + derivationIndex = derivationIndex2.value, + tokens = emptyList(), + ) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 3, + totalArchivedAccounts = 0, + ), + accounts = listOf(mainAccount, account1, account2), + unassignedTokens = emptyList(), + ) + + accountsResponseStoreFlow.value = response + + coEvery { accountsResponseStore.updateData(any()) } returns mockk() + + // Act + val actual = migration.migrate(userWalletId) + + val migratedResponse = response.copy( + accounts = listOf( + mainAccount.copy(tokens = mainAccount.tokens!! - unassignedToken1 - unassignedToken2), + account1.copy(tokens = listOf(unassignedToken1)), + account2.copy(tokens = listOf(unassignedToken2)), + ), + ) + + // Assert + assertEither(actual, migratedResponse.right()) + + coVerifySequence { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + accountsResponseStore.updateData(any()) + userTokensSaver.pushWithRetryer( + userWalletId = userWalletId, + response = migratedResponse.toUserTokensResponse(), + onFailSend = any(), + ) + } + } + + @Test + fun `migrate updates tokens only for one account`() = runTest { + // Arrange + val unassignedToken1 = createBitcoin(accountIndex = 1) + + val derivationIndex1 = DerivationIndex(1).getOrNull()!! + val derivationIndex2 = DerivationIndex(2).getOrNull()!! + + val mainAccount = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value, + derivationIndex = DerivationIndex.Main.value, + tokens = listOf( + createBitcoin(accountIndex = 0), + unassignedToken1, + ), + ) + + val account1 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex1).value, + derivationIndex = derivationIndex1.value, + tokens = emptyList(), + ) + + val account2 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex2).value, + derivationIndex = derivationIndex2.value, + tokens = emptyList(), + ) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 3, + totalArchivedAccounts = 0, + ), + accounts = listOf(mainAccount, account1, account2), + unassignedTokens = emptyList(), + ) + + accountsResponseStoreFlow.value = response + + coEvery { accountsResponseStore.updateData(any()) } returns mockk() + + // Act + val actual = migration.migrate(userWalletId) + + val migratedResponse = response.copy( + accounts = listOf( + mainAccount.copy(tokens = mainAccount.tokens!! - unassignedToken1), + account1.copy(tokens = listOf(unassignedToken1)), + account2, + ), + ) + + // Assert + assertEither(actual, migratedResponse.right()) + + coVerifySequence { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + accountsResponseStore.updateData(any()) + userTokensSaver.pushWithRetryer( + userWalletId = userWalletId, + response = migratedResponse.toUserTokensResponse(), + onFailSend = any(), + ) + } + } + + @Test + fun `migrate all skips when no unassigned tokens`() = runTest { + // Arrange + val derivationIndex1 = DerivationIndex(1).getOrNull()!! + val derivationIndex2 = DerivationIndex(2).getOrNull()!! + + val mainAccount = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value, + derivationIndex = DerivationIndex.Main.value, + tokens = listOf( + createBitcoin(accountIndex = 0), + ), + ) + + val account1 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex1).value, + derivationIndex = derivationIndex1.value, + tokens = emptyList(), + ) + + val account2 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex2).value, + derivationIndex = derivationIndex2.value, + tokens = emptyList(), + ) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 3, + totalArchivedAccounts = 0, + ), + accounts = listOf(mainAccount, account1, account2), + unassignedTokens = emptyList(), + ) + + accountsResponseStoreFlow.value = response + + // Act + val actual = migration.migrate(userWalletId) + + // Assert + assertEither(actual, response.right()) + + coVerifySequence { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + coVerify(inverse = true) { + userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any()) + } + } + private fun createBitcoin(accountIndex: Int): UserTokensResponse.Token { return UserTokensResponse.Token( id = "ne",