From 9aecc175bf1fb188c55fb9c844285be8cc064a5f Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Sep 2025 15:01:51 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../DefaultMainAccountTokensMigration.kt | 152 ++++++++++++ .../utils/GetWalletAccountsResponseExt.kt | 10 + .../DefaultMainAccountTokensMigrationTest.kt | 234 ++++++++++++++++++ .../tokens/MainAccountTokensMigration.kt | 23 ++ .../GetUnoccupiedAccountIndexUseCase.kt | 2 +- 5 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt create mode 100644 domain/account/src/main/java/com/tangem/domain/account/tokens/MainAccountTokensMigration.kt 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 new file mode 100644 index 0000000000..acdc6e4578 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt @@ -0,0 +1,152 @@ +package com.tangem.data.account.tokens + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.either +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.store.AccountsResponseStoreFactory +import com.tangem.data.account.utils.assignTokens +import com.tangem.data.account.utils.toUserTokensResponse +import com.tangem.data.common.account.WalletAccountsSaver +import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.utils.getSyncOrNull +import com.tangem.domain.account.tokens.MainAccountTokensMigration +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.lib.crypto.derivation.AccountNodeRecognizer +import timber.log.Timber + +/** + * Implementation of [MainAccountTokensMigration] for migrating tokens associated with a main account. + * The migration process involves transferring unassigned tokens from the main account to a selected account. + * + * @property accountsResponseStoreFactory Factory for creating stores to access cached account responses. + * @property userTokensSaver Saver for updating user tokens in persistent storage. + * @property walletAccountsSaver Saver for updating wallet accounts in persistent storage. + * +[REDACTED_AUTHOR] + */ +internal class DefaultMainAccountTokensMigration( + private val accountsResponseStoreFactory: AccountsResponseStoreFactory, + private val userTokensSaver: UserTokensSaver, + private val walletAccountsSaver: WalletAccountsSaver, +) : MainAccountTokensMigration { + + override suspend fun migrate( + userWalletId: UserWalletId, + derivationIndex: DerivationIndex, + ): Either = either { + if (derivationIndex == DerivationIndex.Main) { + Timber.i("Migration skipped: derivation index is Main") + return@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 selectedAccount = findAccount(response = response, derivationIndex = derivationIndex) + + val unassignedTokens = mainAccount.findUnassignedTokens(derivationIndex) + + if (unassignedTokens == null) { + Timber.i("No unassigned tokens found for migration") + return@either + } + + val updatedResponse = response.copy( + accounts = response.accounts.map { account -> + when (account.id) { + mainAccount.id -> { + account.copy(tokens = account.tokens.orEmpty() - unassignedTokens) + } + selectedAccount.id -> { + selectedAccount.assignTokens(userWalletId, unassignedTokens) + } + else -> account + } + }, + ) + + walletAccountsSaver.store(userWalletId = userWalletId, response = updatedResponse) + + userTokensSaver.push( + userWalletId = userWalletId, + response = updatedResponse.toUserTokensResponse(), + onFailSend = { + // TODO: save failed state to retry later + // [REDACTED_JIRA] + val exception = IllegalStateException("Failed to push updated tokens after migration") + Timber.e(exception) + raise(exception) + }, + ) + } + + private fun Raise.findAccount( + response: GetWalletAccountsResponse, + derivationIndex: DerivationIndex, + ): WalletAccountDTO { + val account = response.accounts.firstOrNull { it.derivationIndex == derivationIndex.value } + + return ensureNotNull(account) { + val exception = IllegalStateException("No account found with derivation index: $derivationIndex") + Timber.e(exception) + exception + } + } + + private fun WalletAccountDTO.findUnassignedTokens( + derivationIndex: DerivationIndex, + ): List? { + val tokens = this.tokens + + if (tokens.isNullOrEmpty()) return tokens + + return tokens + .filterByDerivationIndex(derivationIndex) + .map { it.copy(accountId = null) } + .toNonEmptyListOrNull() + } + + private fun List.filterByDerivationIndex( + derivationIndex: DerivationIndex, + ): List { + return filter { savedToken -> + val blockchain = Blockchain.fromNetworkId(networkId = savedToken.networkId) + if (blockchain == null) { + Timber.e("Token has unknown networkId: $savedToken") + return@filter false + } + + val derivationPathValue = savedToken.derivationPath + if (derivationPathValue == null) { + Timber.e("Token has no derivation path: $savedToken") + return@filter false + } + + val accountNodeRecognizer = AccountNodeRecognizer(blockchain) + val accountNodeValue = accountNodeRecognizer.recognize(derivationPathValue) + + if (accountNodeValue == null) { + Timber.e("Token has unrecognized derivation path: $savedToken") + return@filter false + } + + accountNodeValue == derivationIndex.value.toLong() + } + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt b/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt index caace442d6..ee6c308a81 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt @@ -54,4 +54,14 @@ internal fun List.assignTokens( tokens = enrichedTokens[accountDTO.id].orEmpty(), ) } +} + +internal fun WalletAccountDTO.assignTokens( + userWalletId: UserWalletId, + tokens: List, +): WalletAccountDTO { + val enrichedTokens = UserTokensResponseAccountIdEnricher(userWalletId, tokens) + .filter { it.accountId == this.id } + + return copy(tokens = enrichedTokens) } \ No newline at end of file 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 new file mode 100644 index 0000000000..685272f7ac --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt @@ -0,0 +1,234 @@ +package com.tangem.data.account.token + +import com.tangem.common.test.utils.assertEitherLeft +import com.tangem.common.test.utils.assertEitherRight +import com.tangem.data.account.converter.createGetWalletAccountsResponse +import com.tangem.data.account.converter.createWalletAccountDTO +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.toUserTokensResponse +import com.tangem.data.common.account.WalletAccountsSaver +import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultMainAccountTokensMigrationTest { + + private val accountsResponseStoreFactory = mockk() + private val accountsResponseStore = mockk() + private val accountsResponseStoreFlow = MutableStateFlow(value = null) + + private val userTokensSaver = mockk(relaxed = true) + private val walletAccountsSaver = mockk(relaxed = true) + private val migration = DefaultMainAccountTokensMigration( + accountsResponseStoreFactory = accountsResponseStoreFactory, + userTokensSaver = userTokensSaver, + walletAccountsSaver = walletAccountsSaver, + ) + + private val userWalletId = UserWalletId("011") + private val derivationIndex = DerivationIndex(1).getOrNull()!! + + @BeforeEach + fun setupAll() { + every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore + every { accountsResponseStore.data } returns accountsResponseStoreFlow + } + + @AfterEach + fun tearDown() { + clearMocks(accountsResponseStoreFactory, accountsResponseStore, userTokensSaver, walletAccountsSaver) + accountsResponseStoreFlow.value = null + } + + @Test + fun `migrate skips when derivation index is Main`() = runTest { + // Act + val actual = migration.migrate(userWalletId, DerivationIndex.Main) + + // Assert + assertEitherRight(actual) + + coVerify(inverse = true) { + accountsResponseStoreFactory.create(any()) + accountsResponseStore.data + walletAccountsSaver.store(userWalletId = any(), response = any()) + userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any()) + } + } + + @Test + fun `migrate fails when no cached accounts response`() = runTest { + // Act + val actual = migration.migrate(userWalletId, derivationIndex) + + // Assert + val expected = IllegalStateException("No cached accounts response found") + assertEitherLeft(actual, expected) + + coVerifySequence { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + coVerify(inverse = true) { + walletAccountsSaver.store(userWalletId = any(), response = any()) + userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any()) + } + } + + @Test + fun `migrate fails when selected account DTO not found`() = runTest { + // Arrange + val response = createGetWalletAccountsResponse( + userWalletId = userWalletId, + tokens = listOf( + createBitcoin(accountIndex = DerivationIndex.Main.value), + ), + ) + + accountsResponseStoreFlow.value = response + + // Act + val actual = migration.migrate(userWalletId, derivationIndex) + + // Assert + val expected = IllegalStateException("No account found with derivation index: $derivationIndex") + assertEitherLeft(actual, expected) + + coVerifySequence { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + coVerify(inverse = true) { + walletAccountsSaver.store(userWalletId = any(), response = any()) + userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any()) + } + } + + @Test + fun `migrate skips when no unassigned tokens`() = runTest { + // Arrange + val response = createGetWalletAccountsResponse( + userWalletId = userWalletId, + tokens = listOf( + createBitcoin(accountIndex = DerivationIndex.Main.value), + ), + ) + + val selectedAccount = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex).value, + derivationIndex = derivationIndex.value, + tokens = emptyList(), + ) + + accountsResponseStoreFlow.value = response.copy(accounts = response.accounts + selectedAccount) + + // Act + val actual = migration.migrate(userWalletId, derivationIndex) + + // Assert + assertEitherRight(actual) + + coVerifySequence { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + coVerify(inverse = true) { + walletAccountsSaver.store(userWalletId = any(), response = any()) + userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any()) + } + } + + @Test + fun `migrate updates tokens for selected account`() = runTest { + // Arrange + val unassignedToken = createBitcoin(accountIndex = 1) + + val mainAccount = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value, + derivationIndex = DerivationIndex.Main.value, + tokens = listOf( + createBitcoin(accountIndex = 0), + unassignedToken, + ), + ) + + val selectedAccount = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex).value, + derivationIndex = derivationIndex.value, + tokens = emptyList(), + ) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 2, + ), + accounts = listOf(mainAccount, selectedAccount), + unassignedTokens = emptyList(), + ) + + accountsResponseStoreFlow.value = response + + // Act + val actual = migration.migrate(userWalletId, derivationIndex) + + // Assert + assertEitherRight(actual) + + val migratedResponse = response.copy( + accounts = listOf( + mainAccount.copy(tokens = mainAccount.tokens!! - unassignedToken), + selectedAccount.copy(tokens = listOf(unassignedToken)), + ), + ) + + coVerifySequence { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + walletAccountsSaver.store(userWalletId = userWalletId, response = migratedResponse) + userTokensSaver.push( + userWalletId = userWalletId, + response = migratedResponse.toUserTokensResponse(), + onFailSend = any(), + ) + } + } + + private fun createBitcoin(accountIndex: Int): UserTokensResponse.Token { + return UserTokensResponse.Token( + id = "ne", + accountId = AccountId.forCryptoPortfolio( + userWalletId = userWalletId, + derivationIndex = DerivationIndex(accountIndex).getOrNull()!!, + ).value, + networkId = "bitcoin", + derivationPath = "m/44'/60'/$accountIndex'/0/0", + name = "Phil Hinton", + symbol = "graeci", + decimals = 6487, + contractAddress = "vim", + addresses = listOf(), + ) + } +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/tokens/MainAccountTokensMigration.kt b/domain/account/src/main/java/com/tangem/domain/account/tokens/MainAccountTokensMigration.kt new file mode 100644 index 0000000000..0071b6c972 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/tokens/MainAccountTokensMigration.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.account.tokens + +import arrow.core.Either +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Interface for migrating tokens associated with a main account, + * but belonging to another account according to the derivation path. + * +[REDACTED_AUTHOR] + */ +interface MainAccountTokensMigration { + + /** + * Migration of non-native tokens from the main account to the account with the provided [derivationIndex]. + * If there are no such tokens, the function will complete successfully. + * + * @param userWalletId The unique identifier of the user's wallet. + * @param derivationIndex The derivation index associated with the account. + */ + suspend fun migrate(userWalletId: UserWalletId, derivationIndex: DerivationIndex): Either +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt index 9fbb2ed7d5..54da8c1d75 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt @@ -28,7 +28,7 @@ class GetUnoccupiedAccountIndexUseCase( suspend operator fun invoke(userWalletId: UserWalletId): Either = either { val totalAccountsCount = getTotalAccountsCount(userWalletId = userWalletId) - DerivationIndex(totalAccountsCount + 1).getOrElse { + DerivationIndex(value = totalAccountsCount).getOrElse { raise(Error.InvalidDerivationIndex(it)) } }