Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-16 17:58:26 +07:00
parent 7bbab0a40b
commit 46dd1333ff
5 changed files with 306 additions and 2 deletions

View file

@ -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,

View file

@ -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? {

View file

@ -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<Throwable, GetWalletAccountsResponse> = 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<DerivationIndex, List<UserTokensResponse.Token>> =
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<UserTokensResponse.Token>? {

View file

@ -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<GetWalletAccountsResponse?>(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) {

View file

@ -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",