Updated on 2026-08-14
This commit is contained in:
parent
e83898b9ee
commit
9aecc175bf
5 changed files with 420 additions and 1 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue