From 3088258c74e6d6a6a63e27e19c1ffb75a2d646aa Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 7 Nov 2025 11:32:19 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../tangemTech/models/UserTokensResponse.kt | 9 +- .../account/GetWalletAccountsResponse.kt | 10 +- .../account/converter/AccountListConverter.kt | 9 +- .../fetcher/DefaultWalletAccountsFetcher.kt | 13 +-- .../utils/GetWalletAccountsResponseExt.kt | 27 ++++- ...efaultWalletAccountsResponseFactoryTest.kt | 29 +++--- .../utils/GetWalletAccountsResponseExtTest.kt | 95 +++++++++++++----- ...UserTokensResponseAccountIdEnricherTest.kt | 28 +++++- .../DefaultManageTokensRepository.kt | 9 +- .../domain/account/models/AccountList.kt | 4 + .../GetAccountCurrencyStatusUseCase.kt | 17 ++++ .../AccountCryptoCurrencyStatusFinder.kt | 87 ++++++++++++++-- features/manage-tokens/impl/build.gradle.kts | 1 + .../list/CustomTokenFormUseCasesFacade.kt | 98 ++++++++++++++----- 14 files changed, 339 insertions(+), 97 deletions(-) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt index c5f172e68c..b1b4d31f95 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt @@ -3,6 +3,9 @@ package com.tangem.datasource.api.tangemTech.models import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.tangem.common.extensions.calculateHashCode +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse.GroupType +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse.GroupType.NONE +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse.SortType @JsonClass(generateAdapter = true) @Suppress("BooleanPropertyNaming") @@ -68,4 +71,8 @@ data class UserTokensResponse( @Json(name = "marketcap") MARKETCAP, } -} \ No newline at end of file +} + +fun GroupType?.orDefault(): GroupType = this ?: NONE + +fun SortType?.orDefault(): SortType = this ?: SortType.MANUAL \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt index 9ce3c0d9e0..757436e0fc 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt @@ -15,9 +15,9 @@ data class GetWalletAccountsResponse( @JsonClass(generateAdapter = true) data class Wallet( - @Json(name = "version") val version: Int = 0, - @Json(name = "group") val group: GroupType, - @Json(name = "sort") val sort: SortType, + @Json(name = "version") val version: Int? = 0, + @Json(name = "group") val group: GroupType?, + @Json(name = "sort") val sort: SortType?, @Json(name = "totalAccounts") val totalAccounts: Int, ) } @@ -30,8 +30,8 @@ fun GetWalletAccountsResponse.flattenTokens(): List { /** Converts the [GetWalletAccountsResponse] into a [UserTokensResponse] */ fun GetWalletAccountsResponse.toUserTokensResponse(): UserTokensResponse { return UserTokensResponse( - group = wallet.group, - sort = wallet.sort, + group = wallet.group ?: GroupType.NONE, + sort = wallet.sort ?: SortType.MANUAL, tokens = flattenTokens(), ) } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt index 6a3bf7a736..2da5ac22bc 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt @@ -3,6 +3,8 @@ package com.tangem.data.account.converter import arrow.core.getOrElse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.wallet.UserWallet import com.tangem.utils.converter.Converter import dagger.assisted.Assisted @@ -27,12 +29,15 @@ internal class AccountListConverter @AssistedInject constructor( } override fun convert(value: GetWalletAccountsResponse): AccountList { + val sortType = value.wallet.sort?.let(TokensSortTypeConverter::convert) ?: TokensSortType.NONE + val groupType = value.wallet.group?.let(TokensGroupTypeConverter::convert) ?: TokensGroupType.NONE + return AccountList( userWalletId = userWallet.walletId, accounts = value.accounts.map(cryptoPortfolioConverter::convert), totalAccounts = value.wallet.totalAccounts, - sortType = TokensSortTypeConverter.convert(value.wallet.sort), - groupType = TokensGroupTypeConverter.convert(value.wallet.group), + sortType = sortType, + groupType = groupType, ) .getOrElse { error("Failed to convert GetWalletAccountsResponse to AccountList: $it") 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 fdc78b4309..42a7783cc1 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 @@ -19,6 +19,7 @@ import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResp import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse +import com.tangem.datasource.api.tangemTech.models.orDefault import com.tangem.datasource.utils.getSyncOrNull import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -159,18 +160,18 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( val response = defaultWalletAccountsResponseFactory.create( userWalletId = userWalletId, userTokensResponse = UserTokensResponse( - group = accountsResponse.wallet.group, - sort = accountsResponse.wallet.sort, + group = accountsResponse.wallet.group.orDefault(), + sort = accountsResponse.wallet.sort.orDefault(), tokens = accountsResponse.unassignedTokens, ), ) + store(userWalletId = userWalletId, response = response) + + push(userWalletId = userWalletId, accounts = response.accounts) userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse()) - val syncedResponse = push(userWalletId = userWalletId, accounts = response.accounts) ?: response - store(userWalletId = userWalletId, response = syncedResponse) - - return syncedResponse + return response } private suspend fun assignTokens( 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 1111b2963f..5dd158b035 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 @@ -32,13 +32,30 @@ internal fun List.assignTokens( userWalletId: UserWalletId, tokens: List, ): List { - val enrichedTokens = UserTokensResponseAccountIdEnricher(userWalletId, tokens) + val enrichedTokensByAccountId = UserTokensResponseAccountIdEnricher(userWalletId, tokens) .groupBy { it.accountId } return map { accountDTO -> - accountDTO.copy( - tokens = enrichedTokens[accountDTO.id].orEmpty(), - ) + val accountTokens = enrichedTokensByAccountId[accountDTO.id].orEmpty() + val isMainAccount = accountDTO.derivationIndex == 0 + + val tokens = if (isMainAccount) { + val existingAccountIds = map(WalletAccountDTO::id).toSet() + val unexistingAccountIds = enrichedTokensByAccountId.keys - existingAccountIds + + val customTokens = unexistingAccountIds.flatMap { + enrichedTokensByAccountId[it].orEmpty().map { token -> + // Tokens from unexisting accounts should be copied to the main account + token.copy(accountId = accountDTO.id) + } + } + + accountTokens + customTokens + } else { + accountTokens + } + + accountDTO.copy(tokens = accountDTO.tokens.orEmpty() + tokens) } } @@ -49,5 +66,5 @@ internal fun WalletAccountDTO.assignTokens( val enrichedTokens = UserTokensResponseAccountIdEnricher(userWalletId, tokens) .filter { it.accountId == this.id } - return copy(tokens = enrichedTokens) + return copy(tokens = this.tokens.orEmpty() + enrichedTokens) } \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactoryTest.kt index 774de05498..674c15d34b 100644 --- a/data/account/src/test/java/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactoryTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactoryTest.kt @@ -3,6 +3,7 @@ package com.tangem.data.account.utils import com.google.common.truth.Truth import com.tangem.data.account.converter.CryptoPortfolioConverter import com.tangem.data.account.converter.createWalletAccountDTO +import com.tangem.data.account.utils.GetWalletAccountsResponseExtTest.Companion.createUserToken import com.tangem.data.common.currency.UserTokensResponseFactory import com.tangem.data.common.network.NetworkFactory import com.tangem.datasource.api.tangemTech.models.UserTokensResponse @@ -10,7 +11,6 @@ import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResp import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.models.AccountList import com.tangem.domain.models.account.Account -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import io.mockk.* @@ -104,15 +104,16 @@ class DefaultWalletAccountsResponseFactoryTest { every { walletId } returns userWalletId } + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet + val accounts = AccountList.empty(userWallet.walletId).accounts .filterIsInstance() - coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet - + val token = createUserToken(accountIndex = 0) val defaultResponse = UserTokensResponse( group = UserTokensResponse.GroupType.NETWORK, sort = UserTokensResponse.SortType.BALANCE, - tokens = listOf(mockk(relaxed = true)), + tokens = listOf(token), ) every { userTokensResponseFactory.createDefaultResponse( @@ -135,7 +136,7 @@ class DefaultWalletAccountsResponseFactoryTest { sort = defaultResponse.sort, totalAccounts = 1, ), - accounts = listOf(accountsDTO), + accounts = listOf(accountsDTO.copy(tokens = listOf(token))), unassignedTokens = emptyList(), ) @@ -202,24 +203,20 @@ class DefaultWalletAccountsResponseFactoryTest { val userWallet = mockk(relaxed = true) { every { walletId } returns userWalletId } - val assignedTokens = listOf(mockk(), mockk()) coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet + val userTokensResponse = UserTokensResponse( group = UserTokensResponse.GroupType.NETWORK, sort = UserTokensResponse.SortType.BALANCE, - tokens = listOf(mockk(relaxed = true)), + tokens = listOf( + createUserToken(accountIndex = 0), + createUserToken(accountIndex = 1), + ), ) - every { - userTokensResponseFactory.createUserTokensResponse( - currencies = assignedTokens, - isGroupedByNetwork = false, - isSortedByBalance = false, - ) - } returns userTokensResponse val accounts = AccountList.empty(userWallet.walletId).accounts .filterIsInstance() - val accountsDTO = createWalletAccountDTO(userWalletId) + val accountsDTO = createWalletAccountDTO(userWalletId = userWalletId) every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO) // Act @@ -232,7 +229,7 @@ class DefaultWalletAccountsResponseFactoryTest { sort = userTokensResponse.sort, totalAccounts = 1, ), - accounts = listOf(accountsDTO), + accounts = listOf(accountsDTO.copy(tokens = userTokensResponse.tokens)), unassignedTokens = emptyList(), ) Truth.assertThat(actual).isEqualTo(expected) diff --git a/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt b/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt index 94080c8fa3..9124a77517 100644 --- a/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt @@ -70,9 +70,9 @@ class GetWalletAccountsResponseExtTest { @Test fun `flattenTokens returns all tokens from multiple accounts`() { // Arrange - val token1 = createUserToken(id = "0") - val token2 = createUserToken(id = "1") - val token3 = createUserToken(id = "2") + val token1 = createUserToken(accountIndex = 0) + val token2 = createUserToken(accountIndex = 1) + val token3 = createUserToken(accountIndex = 2) val account1 = createWalletAccountDTO(derivationIndex = 0, tokens = listOf(token1, token2)) val account2 = createWalletAccountDTO(derivationIndex = 1, tokens = listOf(token3)) @@ -131,10 +131,9 @@ class GetWalletAccountsResponseExtTest { @Test fun `toUserTokensResponse includes tokens from accounts and unassignedTokens`() { // Arrange - val token1 = createUserToken(id = "0") - val token2 = createUserToken(id = "1") + val token1 = createUserToken(accountIndex = 0) + val token2 = createUserToken(accountIndex = 1) val account = createWalletAccountDTO(derivationIndex = 0, tokens = listOf(token1)) - val unassignedToken = token2 val response = GetWalletAccountsResponse( wallet = GetWalletAccountsResponse.Wallet( @@ -144,7 +143,7 @@ class GetWalletAccountsResponseExtTest { totalAccounts = 1, ), accounts = listOf(account), - unassignedTokens = listOf(unassignedToken), + unassignedTokens = listOf(token2), ) // Act @@ -169,8 +168,8 @@ class GetWalletAccountsResponseExtTest { fun `assignTokens correctly assigns tokens to accounts`() { // Arrange val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" - val token1 = createUserToken(id = "0", accountId = null) - val token2 = createUserToken(id = "1", accountId = null) + val token1 = createUserToken(accountIndex = 0, accountId = null) + val token2 = createUserToken(accountIndex = 1, accountId = null) val account1 = createWalletAccountDTO(derivationIndex = 0) val account2 = createWalletAccountDTO(derivationIndex = 1) val response = GetWalletAccountsResponse( @@ -194,10 +193,13 @@ class GetWalletAccountsResponseExtTest { account1.copy( tokens = listOf( token1.copy(accountId = accountId), + ), + ), + account2.copy( + tokens = listOf( token2.copy(accountId = accountId), ), ), - account2, ), unassignedTokens = emptyList(), ) @@ -232,6 +234,44 @@ class GetWalletAccountsResponseExtTest { Truth.assertThat(actual).isEqualTo(expected) } + + @Test + fun `unassignedTokens contain tokens for unexisting accounts`() { + // Arrange + val token1 = createUserToken(accountIndex = 0, accountId = null) + val token2 = createUserToken(accountIndex = 3, accountId = null) + val account = createWalletAccountDTO(derivationIndex = 0) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 2, + ), + accounts = listOf(account), + unassignedTokens = listOf(token1, token2), + ) + + // Act + val actual = response.assignTokens(userWalletId) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = response.wallet, + accounts = listOf( + account.copy( + tokens = listOf( + token1.copy(accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027"), + token2.copy(accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027"), + ), + ), + ), + unassignedTokens = emptyList(), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } } @Nested @@ -242,8 +282,8 @@ class GetWalletAccountsResponseExtTest { fun `assignTokens correctly assigns tokens to accounts`() { // Arrange val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" - val token1 = createUserToken(id = "0", accountId = null) - val token2 = createUserToken(id = "1", accountId = null) + val token1 = createUserToken(accountIndex = 0, accountId = null) + val token2 = createUserToken(accountIndex = 1, accountId = null) val account1 = createWalletAccountDTO(derivationIndex = 0) val account2 = createWalletAccountDTO(derivationIndex = 1) @@ -258,10 +298,13 @@ class GetWalletAccountsResponseExtTest { account1.copy( tokens = listOf( token1.copy(accountId = accountId), + ), + ), + account2.copy( + tokens = listOf( token2.copy(accountId = accountId), ), ), - account2, ) Truth.assertThat(actual).isEqualTo(expected) @@ -271,7 +314,7 @@ class GetWalletAccountsResponseExtTest { fun `assignTokens does not change accounts if there are no unassignedTokens`() { // Arrange val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" - val token1 = createUserToken(id = "0", accountId = accountId) + val token1 = createUserToken(accountIndex = 0, accountId = accountId) val account1 = createWalletAccountDTO(derivationIndex = 0) // Act @@ -308,19 +351,19 @@ class GetWalletAccountsResponseExtTest { totalNetworks = 1, ) - private fun createUserToken(id: String, accountId: String? = "account_id") = UserTokensResponse.Token( - id = id, - accountId = accountId, - networkId = "ethereum", - derivationPath = "m/44'/60'/0'/0/0", - name = "Token", - symbol = "T", - contractAddress = "0x$id", - decimals = 18, - ) + companion object { - private companion object { + private val userWalletId = UserWalletId("011") - val userWalletId = UserWalletId("011") + fun createUserToken(accountIndex: Int, accountId: String? = "account_id") = UserTokensResponse.Token( + id = accountIndex.toString(), + accountId = accountId, + networkId = "ethereum", + derivationPath = "m/44'/60'/0'/0/$accountIndex", + name = "Token", + symbol = "T", + contractAddress = "0x$accountIndex", + decimals = 18, + ) } } \ No newline at end of file diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt index bd7c253d03..8512f4f802 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt @@ -97,9 +97,9 @@ class UserTokensResponseAccountIdEnricherTest { val validNetworkId = mockCryptoCurrencyFactory.ethereum.network.rawId val tokenWithUnknownNetworkId = mockCryptoCurrencyFactory.ethereum.toResponseToken( + accountId = null, networkId = unknownNetworkId, derivationPath = "m/44'/60'/0'/0/0", - accountId = null, ) val tokenWithValidNetworkId = mockCryptoCurrencyFactory.ethereum.toResponseToken( @@ -122,6 +122,32 @@ class UserTokensResponseAccountIdEnricherTest { Truth.assertThat(actual).isEqualTo(expected) } + @Test + fun `skips tokens with existing account ids`() { + // Arrange + val tokenWithAccountId = mockCryptoCurrencyFactory.ethereum + .toResponseToken(derivationPath = "m/44'/60'/0'/0/0") + .enrichWithAccountId(accountIndex = 0) + + val tokenWithoutAccountId = mockCryptoCurrencyFactory.ethereum.toResponseToken( + accountId = null, + derivationPath = "m/44'/60'/0'/0/0", + ) + + val response = listOf(tokenWithAccountId, tokenWithoutAccountId).toResponse() + + // Act + val actual = UserTokensResponseAccountIdEnricher(userWalletId, response) + + // Assert + val expected = listOf( + tokenWithAccountId, + tokenWithoutAccountId.enrichWithAccountId(accountIndex = 0), + ).toResponse() + + Truth.assertThat(actual).isEqualTo(expected) + } + private fun CryptoCurrency.toResponseToken( accountId: AccountId? = null, networkId: String? = null, diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt index c511d37855..12ebfeda6d 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt @@ -19,6 +19,7 @@ import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.CoinsResponse import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.orDefault import com.tangem.datasource.local.config.testnet.TestnetTokensStorage import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore @@ -197,8 +198,8 @@ internal class DefaultManageTokensRepository( val tokensResponse = response?.let { UserTokensResponse( - group = response.wallet.group, - sort = response.wallet.sort, + group = response.wallet.group.orDefault(), + sort = response.wallet.sort.orDefault(), tokens = accountDTO?.tokens.orEmpty(), ) } @@ -295,8 +296,8 @@ internal class DefaultManageTokensRepository( val tokensResponse = response?.let { UserTokensResponse( - group = response.wallet.group, - sort = response.wallet.sort, + group = response.wallet.group.orDefault(), + sort = response.wallet.sort.orDefault(), tokens = accountDTO?.tokens.orEmpty(), ) } diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt index 5f7740206a..22bc8962bd 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt @@ -40,6 +40,10 @@ data class AccountList private constructor( val canAddMoreAccounts: Boolean get() = accounts.size < MAX_ACCOUNTS_COUNT + /** Returns the number of active accounts in the list */ + val activeAccounts: Int + get() = accounts.size + /** * Adds an account to the account list. * If an account with the same identifier already exists, it will be replaced. diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt index 3a6fed2955..60295d2709 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt @@ -76,6 +76,23 @@ class GetAccountCurrencyStatusUseCase( .toOption() } + suspend fun invokeSync( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + contractAddress: String?, + ): Option { + val accountStatusList = getAccountStatusListSync(userWalletId) ?: return none() + + return AccountCryptoCurrencyStatusFinder( + accountStatusList = accountStatusList, + networkId = networkId, + derivationPath = derivationPath, + contractAddress = contractAddress, + ) + .toOption() + } + /** * Retrieves the status of a specific cryptocurrency for a given user wallet as a [Flow]. * diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt index f00df7290d..dc33b171dd 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt @@ -1,7 +1,6 @@ package com.tangem.domain.account.status.utils import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatuses @@ -34,6 +33,15 @@ internal object AccountCryptoCurrencyStatusFinder { ) } + /** + * Retrieves the [AccountCryptoCurrencyStatus] for the specified [currencyId] and [network] + * from the given [accountStatusList]. + * + * @param accountStatusList the list of account statuses to search within. + * @param currencyId the ID of the cryptocurrency whose status is to be retrieved. + * @param network the network associated with the cryptocurrency. + * @return the [AccountCryptoCurrencyStatus] if found, otherwise null. + */ operator fun invoke( accountStatusList: AccountStatusList, currencyId: CryptoCurrency.ID, @@ -51,6 +59,14 @@ internal object AccountCryptoCurrencyStatusFinder { .firstOrNull() } + /** + * Retrieves a map of accounts to their corresponding list of [AccountCryptoCurrencyStatus] for the specified + * list of [currencies] from the given [accountStatusList]. + * + * @param accountStatusList the list of account statuses to search within. + * @param currencies the list of cryptocurrencies whose statuses are to be retrieved. + * @return a map where the key is the account and the value is a list of [AccountCryptoCurrencyStatus]. + */ operator fun invoke( accountStatusList: AccountStatusList, currencies: List, @@ -72,15 +88,66 @@ internal object AccountCryptoCurrencyStatusFinder { } /** - * Retrieves the expected account statuses based on the provided [network]. - * If the [network] is null, all account statuses are returned. - * If the network has a specific derivation index, it filters the accounts accordingly. + * Retrieves the [AccountCryptoCurrencyStatus] for the specified [networkId], [derivationPath], + * and optional [contractAddress] from the given [accountStatusList]. * - * @param network the network to filter accounts by, can be null. - * @return a set of [AccountStatus] that match the expected criteria. + * @param accountStatusList the list of account statuses to search within. + * @param networkId the ID of the network associated with the cryptocurrency. + * @param derivationPath the derivation path of the account. + * @param contractAddress the optional contract address of the token (if applicable). + * @return the [AccountCryptoCurrencyStatus] if found, otherwise null. */ + operator fun invoke( + accountStatusList: AccountStatusList, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + contractAddress: String?, + ): AccountCryptoCurrencyStatus? { + return accountStatusList.getExpectedAccountStatuses( + rawNetworkId = networkId.rawId.value, + derivationPath = derivationPath, + ) + .asSequence() + .filterIsInstance() + .mapNotNull { accountStatus -> + val status = accountStatus.flattenCurrencies().firstOrNull { + val currency = it.currency + val isContractAddressMatch = contractAddress == null || + currency.id.contractAddress.equals(contractAddress, ignoreCase = true) + + currency.network.rawId == networkId.rawId.value && + currency.network.derivationPath.value == derivationPath.value && + isContractAddressMatch + } + ?: return@mapNotNull null + + AccountCryptoCurrencyStatus(account = accountStatus.account, status = status) + } + .firstOrNull() + } + private fun AccountStatusList.getExpectedAccountStatuses(network: Network?): List { - val possibleAccountIndex = network?.getAccountIndexOrNull() + return getExpectedAccountStatuses(rawNetworkId = network?.rawId, derivationPath = network?.derivationPath) + } + + /** + * Retrieves the expected account statuses based on the provided [rawNetworkId] and [derivationPath]. + * If either parameter is null, all account statuses are returned. + * If both parameters are provided, it filters the accounts based on the derivation index. + * + * @param rawNetworkId the raw ID of the network to filter accounts by, can be null. + * @param derivationPath the derivation path of the network to filter accounts by, can be null. + * @return a list of [AccountStatus] that match the expected criteria. + */ + private fun AccountStatusList.getExpectedAccountStatuses( + rawNetworkId: String?, + derivationPath: Network.DerivationPath?, + ): List { + val possibleAccountIndex = if (rawNetworkId != null && derivationPath != null) { + getAccountIndexOrNull(rawNetworkId, derivationPath) + } else { + null + } return when (possibleAccountIndex) { // currency can be in any account @@ -115,7 +182,11 @@ internal object AccountCryptoCurrencyStatusFinder { } private fun Network.getAccountIndexOrNull(): Int? { - val blockchain = Blockchain.fromNetworkId(networkId = rawId) ?: return null + return getAccountIndexOrNull(rawNetworkId = rawId, derivationPath = derivationPath) + } + + private fun getAccountIndexOrNull(rawNetworkId: String, derivationPath: Network.DerivationPath): Int? { + val blockchain = Blockchain.fromId(id = rawNetworkId) val recognizer = AccountNodeRecognizer(blockchain) return recognizer.recognize(derivationPath)?.toInt() diff --git a/features/manage-tokens/impl/build.gradle.kts b/features/manage-tokens/impl/build.gradle.kts index 58cffdfd57..6fc7f5115a 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(projects.domain.notifications) // region Project - Libs + implementation(projects.libs.blockchainSdk) implementation(projects.libs.crypto) // endregion diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt index 1e2b301f45..ca9d684abe 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt @@ -1,41 +1,46 @@ package com.tangem.features.managetokens.utils.list import arrow.core.Either -import arrow.core.left +import arrow.core.raise.Raise +import arrow.core.raise.either +import arrow.core.raise.ensureNotNull import arrow.core.right -import com.tangem.domain.account.producer.SingleAccountProducer +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase -import com.tangem.domain.account.supplier.SingleAccountSupplier +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.managetokens.CheckIsCurrencyNotAddedUseCase +import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId -import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase import com.tangem.features.managetokens.component.AddCustomTokenMode +import com.tangem.lib.crypto.derivation.AccountNodeRecognizer import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import timber.log.Timber +@Suppress("LongParameterList") internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( + @Assisted private val mode: AddCustomTokenMode, private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, private val derivePublicKeysUseCase: DerivePublicKeysUseCase, private val checkIsCurrencyNotAddedUseCase: CheckIsCurrencyNotAddedUseCase, private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, - private val singleAccountSupplier: SingleAccountSupplier, - @Assisted private val mode: AddCustomTokenMode, + private val singleAccountListSupplier: SingleAccountListSupplier, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, ) { suspend fun addCryptoCurrenciesUseCase(currency: CryptoCurrency): Either = when (mode) { - is AddCustomTokenMode.Account -> { - manageCryptoCurrenciesUseCase( - accountId = AccountId.forCryptoPortfolio( - userWalletId = mode.userWalletId, - derivationIndex = DerivationIndex.Main, - ), - add = currency, - ) + is AddCustomTokenMode.Account -> either { + val accountId = getAccountId(currency) + + manageCryptoCurrenciesUseCase(accountId = accountId, add = currency).bind() } is AddCustomTokenMode.Wallet -> { addCryptoCurrenciesUseCase.invoke( @@ -59,16 +64,13 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( contractAddress: String?, ): Either = when (mode) { is AddCustomTokenMode.Account -> { - val account = singleAccountSupplier.getSyncOrNull( - params = SingleAccountProducer.Params(accountId = mode.accountId), + getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = mode.accountId.userWalletId, + networkId = networkId, + derivationPath = derivationPath, + contractAddress = contractAddress, ) - ?: return IllegalStateException("Account not found").left() - - account.cryptoCurrencies.none { currency -> - networkId == currency.network.id && - derivationPath == currency.network.derivationPath && - contractAddress.equals(currency.id.contractAddress, ignoreCase = true) - } + .fold(ifEmpty = { true }, ifSome = { false }) .right() } is AddCustomTokenMode.Wallet -> checkIsCurrencyNotAddedUseCase.invoke( @@ -79,6 +81,56 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( ) } + private suspend fun Raise.getAccountId(currency: CryptoCurrency): AccountId { + val accountList = singleAccountListSupplier.getSyncOrNull( + params = SingleAccountListProducer.Params(userWalletId = mode.userWalletId), + ) + + ensureNotNull(accountList) { + IllegalStateException("Account list not found: ${mode.userWalletId}") + } + + if (accountList.activeAccounts == 1) { + return AccountId.forMainCryptoPortfolio(userWalletId = mode.userWalletId) + } + + val currencyAccountIndex = currency.getAccountIndex().bind() + + val account = accountList.accounts.firstOrNull { account -> + val cryptoPortfolioAccount = account as? Account.CryptoPortfolio + + cryptoPortfolioAccount?.derivationIndex?.value == currencyAccountIndex + } + + return account?.accountId ?: AccountId.forMainCryptoPortfolio(userWalletId = mode.userWalletId) + } + + private fun CryptoCurrency.getAccountIndex(): Either = either { + val currency = this@getAccountIndex + val blockchain = Blockchain.fromNetworkId(networkId = currency.network.backendId) + if (blockchain == null) { + val exception = IllegalStateException("Token has unknown networkId: ${currency.id}") + Timber.e(exception) + raise(exception) + } + + val derivationPathValue = currency.network.derivationPath.value + if (derivationPathValue == null) { + val exception = IllegalStateException("Token has no derivation path: ${currency.id}") + Timber.e(exception) + raise(exception) + } + + val accountNodeRecognizer = AccountNodeRecognizer(blockchain) + val index = accountNodeRecognizer.recognize(derivationPathValue)?.toInt() + + ensureNotNull(index) { + val exception = IllegalStateException("Token has unrecognized derivation path: ${currency.id}") + Timber.e(exception) + exception + } + } + @AssistedFactory interface Factory { fun create(mode: AddCustomTokenMode): CustomTokenFormUseCasesFacade