Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-29 15:01:51 +04:00
parent e83898b9ee
commit 9aecc175bf
5 changed files with 420 additions and 1 deletions

View file

@ -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<Throwable, Unit> = 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<Throwable>.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<UserTokensResponse.Token>? {
val tokens = this.tokens
if (tokens.isNullOrEmpty()) return tokens
return tokens
.filterByDerivationIndex(derivationIndex)
.map { it.copy(accountId = null) }
.toNonEmptyListOrNull()
}
private fun List<UserTokensResponse.Token>.filterByDerivationIndex(
derivationIndex: DerivationIndex,
): List<UserTokensResponse.Token> {
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()
}
}
}

View file

@ -54,4 +54,14 @@ internal fun List<WalletAccountDTO>.assignTokens(
tokens = enrichedTokens[accountDTO.id].orEmpty(),
)
}
}
internal fun WalletAccountDTO.assignTokens(
userWalletId: UserWalletId,
tokens: List<UserTokensResponse.Token>,
): WalletAccountDTO {
val enrichedTokens = UserTokensResponseAccountIdEnricher(userWalletId, tokens)
.filter { it.accountId == this.id }
return copy(tokens = enrichedTokens)
}

View file

@ -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<AccountsResponseStoreFactory>()
private val accountsResponseStore = mockk<AccountsResponseStore>()
private val accountsResponseStoreFlow = MutableStateFlow<GetWalletAccountsResponse?>(value = null)
private val userTokensSaver = mockk<UserTokensSaver>(relaxed = true)
private val walletAccountsSaver = mockk<WalletAccountsSaver>(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(),
)
}
}

View file

@ -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<Throwable, Unit>
}

View file

@ -28,7 +28,7 @@ class GetUnoccupiedAccountIndexUseCase(
suspend operator fun invoke(userWalletId: UserWalletId): Either<Error, DerivationIndex> = either {
val totalAccountsCount = getTotalAccountsCount(userWalletId = userWalletId)
DerivationIndex(totalAccountsCount + 1).getOrElse {
DerivationIndex(value = totalAccountsCount).getOrElse {
raise(Error.InvalidDerivationIndex(it))
}
}