From 1a4405fd28ccba50ca465c319ae18acc09136408 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 31 Jul 2026 17:29:19 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../data/common/currency/UserTokensSaver.kt | 45 +++- .../common/currency/UserTokensSaverTest.kt | 71 ++++++ .../DefaultCustomTokensRepository.kt | 46 +++- .../usecase/ManageCryptoCurrenciesUseCase.kt | 16 +- .../ManageCryptoCurrenciesUseCaseTest.kt | 220 ++++++++++++++++++ 5 files changed, 382 insertions(+), 16 deletions(-) create mode 100644 domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCaseTest.kt diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt index a745dfe274..6397088254 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt @@ -41,7 +41,9 @@ class UserTokensSaver( return@withContext } - val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher) + val enrichedResponse = response + .withoutDuplicates() + .enrichIf(userWalletId = userWalletId, condition = useEnricher) push(userWallet = userWallet, response = enrichedResponse, onFailSend = onFailSend) } @@ -85,7 +87,46 @@ class UserTokensSaver( apiResponse.bind() } }, - onError = { onFailSend() }, + onError = { error -> + TangemLogger.e("Failed to push ${response.tokens.size} user tokens", error) + onFailSend() + }, + ) + } + + /** + * Drops fully identical tokens from the list. + * + * The API rejects the whole list with `400 All tokens's elements must be unique` if it contains two equal + * elements, so a single duplicate discards the update of the entire wallet's token list. Duplicates are not + * expected here, hence the error log. + * + * Tokens are compared by every field of the request instead of [UserTokensResponse.Token.equals], which + * deliberately ignores most of them: dropping an element that differs in any way would silently change what the + * user has saved. + */ + private fun UserTokensResponse.withoutDuplicates(): UserTokensResponse { + val uniqueTokens = tokens.distinctBy { it.toRequestKey() } + + if (uniqueTokens.size == tokens.size) return this + + TangemLogger.e("Dropped ${tokens.size - uniqueTokens.size} duplicated tokens before pushing them") + + return copy(tokens = uniqueTokens) + } + + private fun UserTokensResponse.Token.toRequestKey(): List { + return listOf( + id, + accountId, + networkId, + derivationPath, + name, + symbol, + decimals, + contractAddress, + addresses, + dynamicAddressesEnabled, ) } diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt index 2a6546928a..4ea92ebe09 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt @@ -95,4 +95,75 @@ class UserTokensSaverTest { assert(onFailSendCalled) { "onFailSend callback should be called when API call fails" } } + + @Test + fun `GIVEN response with duplicated tokens WHEN push THEN duplicates are dropped before the api call`() = runTest { + // GIVEN + val userWalletId = UserWalletId("1234567890abcdef") + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.name } returns "" + } + + val token = createToken() + val response = createResponse(tokens = listOf(token, token)) + val uniqueResponse = createResponse(tokens = listOf(token)) + + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) + coEvery { enricher(userWalletId, uniqueResponse) } returns uniqueResponse + coEvery { tangemTechApi.saveTokens(any(), any()) } returns ApiResponse.Success(Unit) + + // WHEN + userTokensSaver.push(userWalletId = userWalletId, response = response) + + // THEN + coVerify(exactly = 1) { tangemTechApi.saveTokens(userWalletId.stringValue, uniqueResponse) } + } + + @Test + fun `GIVEN tokens differing in account id only WHEN push THEN both of them are pushed`() = runTest { + // GIVEN + val userWalletId = UserWalletId("1234567890abcdef") + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.name } returns "" + } + + val token = createToken() + val response = createResponse(tokens = listOf(token, token.copy(accountId = "other-account"))) + + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) + coEvery { enricher(userWalletId, response) } returns response + coEvery { tangemTechApi.saveTokens(any(), any()) } returns ApiResponse.Success(Unit) + + // WHEN + userTokensSaver.push(userWalletId = userWalletId, response = response) + + // THEN + coVerify(exactly = 1) { tangemTechApi.saveTokens(userWalletId.stringValue, response) } + } + + private fun createResponse(tokens: List): UserTokensResponse { + return UserTokensResponse( + version = 0, + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + tokens = tokens, + walletName = null, + walletType = WalletType.COLD, + ) + } + + private fun createToken(): UserTokensResponse.Token { + return UserTokensResponse.Token( + id = "ethereum", + accountId = "account", + networkId = "ethereum", + derivationPath = "m/44'/60'/0'/0/1", + name = "Ethereum", + symbol = "ETH", + decimals = 18, + contractAddress = null, + ) + } } \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt index 669283b61e..e2c9e8cb1b 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt @@ -20,11 +20,13 @@ import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.managetokens.model.AddCustomTokenForm import com.tangem.domain.managetokens.model.ManagedCryptoCurrency import com.tangem.domain.managetokens.repository.CustomTokensRepository +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.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.config.curvesConfig +import com.tangem.lib.crypto.derivation.AccountNodeRecognizer import com.tangem.lib.crypto.derivation.supportsDerivationPath import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext @@ -139,11 +141,7 @@ internal class DefaultCustomTokensRepository( ): CryptoCurrency.Coin { val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) val network = requireNotNull( - networkFactory.create( - networkId = networkId, - derivationPath = derivationPath, - userWallet = userWallet, - ), + createNetwork(networkId = networkId, derivationPath = derivationPath, userWallet = userWallet), ) { "Network [$networkId] not found while creating coin" } @@ -151,6 +149,38 @@ internal class DefaultCustomTokensRepository( return cryptoCurrencyFactory.createCoin(network) } + /** + * Creates a [Network] for the given [derivationPath]. + * + * A derivation path typed in by the user always arrives as [Network.DerivationPath.Custom], even when it is the + + * currency shares its network with the currencies already present in the account instead of getting a separate + * "custom" network for the very same address. + */ + private fun createNetwork( + networkId: Network.ID, + derivationPath: Network.DerivationPath, + userWallet: UserWallet, + ): Network? { + val pathValue = derivationPath.value + val blockchain = networkId.toBlockchain() + + val accountIndex = pathValue + ?.let { AccountNodeRecognizer(blockchain).recognize(derivationPathValue = it) } + ?.let { DerivationIndex(value = it.toInt()).getOrNull() } + + return if (accountIndex == null) { + networkFactory.create(networkId = networkId, derivationPath = derivationPath, userWallet = userWallet) + } else { + networkFactory.create( + blockchain = blockchain, + extraDerivationPath = pathValue, + userWallet = userWallet, + accountIndex = accountIndex, + ) + } + } + override suspend fun createToken( managedCryptoCurrency: ManagedCryptoCurrency.Token, sourceNetwork: ManagedCryptoCurrency.SourceNetwork.Default, @@ -174,11 +204,7 @@ internal class DefaultCustomTokensRepository( ): CryptoCurrency.Token { val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) val network = requireNotNull( - networkFactory.create( - networkId = networkId, - derivationPath = derivationPath, - userWallet = userWallet, - ), + createNetwork(networkId = networkId, derivationPath = derivationPath, userWallet = userWallet), ) { "Network [$networkId] not found while creating custom token" } diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt index 82f735a3c9..150a899e4f 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt @@ -381,27 +381,35 @@ class ManageCryptoCurrenciesUseCase( } } + /** + * Identity of a currency inside an account. + * + * The derivation path is compared by its raw value, not by the [Network.DerivationPath] subtype: the same path + * may be represented as [Network.DerivationPath.Card] for one currency and as [Network.DerivationPath.Custom] + * for another one, while both describe the very same address. Comparing the subtypes made such currencies look + + */ private data class TempID( val networkId: String, - val derivationPath: Network.DerivationPath, + val derivationPath: String?, val contractAddress: String?, ) { constructor(network: Network) : this( networkId = network.rawId, - derivationPath = network.derivationPath, + derivationPath = network.derivationPath.value, contractAddress = null, ) constructor(currency: CryptoCurrency) : this( networkId = currency.network.rawId, - derivationPath = currency.network.derivationPath, + derivationPath = currency.network.derivationPath.value, contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress, ) constructor(status: CryptoCurrencyStatus) : this( networkId = status.currency.network.rawId, - derivationPath = status.currency.network.derivationPath, + derivationPath = status.currency.network.derivationPath.value, contractAddress = (status.currency as? CryptoCurrency.Token)?.contractAddress, ) } diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCaseTest.kt new file mode 100644 index 0000000000..d5b074cc58 --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCaseTest.kt @@ -0,0 +1,220 @@ +package com.tangem.domain.account.status.usecase + +import com.google.common.truth.Truth +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher +import com.tangem.domain.account.status.utils.CryptoCurrencyMetadataCleaner +import com.tangem.domain.account.status.utils.createStatus +import com.tangem.domain.account.status.utils.createUngrouped +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.* +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.derivations.DerivationsRepository +import com.tangem.test.core.TestAppCoroutineScope +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ManageCryptoCurrenciesUseCaseTest { + + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk() + private val accountsCRUDRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true) + private val currenciesRepository: CurrenciesRepository = mockk(relaxUnitFun = true) + private val derivationsRepository: DerivationsRepository = mockk(relaxUnitFun = true) + private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true) + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher = mockk(relaxed = true) + private val cryptoCurrencyMetadataCleaner: CryptoCurrencyMetadataCleaner = mockk(relaxed = true) + private val expressServiceFetcher: ExpressServiceFetcher = mockk(relaxed = true) + + private val useCase = ManageCryptoCurrenciesUseCase( + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + accountsCRUDRepository = accountsCRUDRepository, + currenciesRepository = currenciesRepository, + derivationsRepository = derivationsRepository, + walletManagersFacade = walletManagersFacade, + cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, + cryptoCurrencyMetadataCleaner = cryptoCurrencyMetadataCleaner, + expressServiceFetcher = expressServiceFetcher, + parallelUpdatingScope = TestAppCoroutineScope(), + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @BeforeEach + fun resetMocks() { + clearMocks( + singleAccountStatusListSupplier, + accountsCRUDRepository, + currenciesRepository, + derivationsRepository, + ) + } + + @Test + fun `GIVEN account coin has card derivation WHEN add token with the same custom derivation THEN coin is not duplicated`() = + runTest { + // Arrange + val coin = createCoin(derivationPath = Network.DerivationPath.Card(ACCOUNT_DERIVATION_PATH)) + val account = createAccount(currencies = listOf(coin)) + + coEvery { + singleAccountStatusListSupplier.getSyncOrNull(SingleAccountStatusListProducer.Params(userWalletId)) + } returns createAccountStatusList(account = account, currencies = listOf(coin)) + + // the derivation path typed in by the user always arrives as a custom one + val addedToken = createToken(derivationPath = Network.DerivationPath.Custom(ACCOUNT_DERIVATION_PATH)) + + // Act + val actual = useCase(accountId = account.accountId, add = addedToken, skipDerivationErrors = false) + + // Assert + Truth.assertThat(actual.isRight()).isTrue() + + coVerify(exactly = 1) { + accountsCRUDRepository.saveAccount(account.copy(cryptoCurrencies = listOf(coin, addedToken))) + } + coVerify(inverse = true) { currenciesRepository.createCoinCurrency(any()) } + } + + @Test + fun `GIVEN account without coin WHEN add token THEN coin is created`() = runTest { + // Arrange + val account = createAccount(currencies = emptyList()) + + coEvery { + singleAccountStatusListSupplier.getSyncOrNull(SingleAccountStatusListProducer.Params(userWalletId)) + } returns createAccountStatusList(account = account, currencies = emptyList()) + + val addedToken = createToken(derivationPath = Network.DerivationPath.Custom(ACCOUNT_DERIVATION_PATH)) + val createdCoin = createCoin(derivationPath = Network.DerivationPath.Custom(ACCOUNT_DERIVATION_PATH)) + + coEvery { currenciesRepository.createCoinCurrency(addedToken.network) } returns createdCoin + + // Act + val actual = useCase(accountId = account.accountId, add = addedToken, skipDerivationErrors = false) + + // Assert + Truth.assertThat(actual.isRight()).isTrue() + + coVerify(exactly = 1) { + accountsCRUDRepository.saveAccount(account.copy(cryptoCurrencies = listOf(createdCoin, addedToken))) + } + } + + private fun createAccount(currencies: List): Account.CryptoPortfolio { + return Account.CryptoPortfolio( + accountId = AccountId.forCryptoPortfolio(userWalletId = userWalletId, derivationIndex = derivationIndex), + accountName = AccountName("Account 1").getOrNull()!!, + icon = CryptoPortfolioIcon.ofCustomAccount( + value = CryptoPortfolioIcon.Icon.Star, + color = CryptoPortfolioIcon.Color.Azure, + ), + derivationIndex = derivationIndex, + cryptoCurrencies = currencies, + ) + } + + private fun createAccountStatusList( + account: Account.CryptoPortfolio, + currencies: List, + ): com.tangem.domain.account.models.AccountStatusList { + val statuses = currencies.map { createStatus(currency = it, fiatAmount = BigDecimal.ONE) } + + return com.tangem.domain.account.models.AccountStatusList( + userWalletId = userWalletId, + accountStatuses = listOf( + AccountStatus.CryptoPortfolio( + account = account, + tokenList = createUngrouped(statuses = statuses), + priceChangeLce = com.tangem.domain.core.utils.lceLoading(), + ), + ), + totalAccounts = 1, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = com.tangem.domain.models.TokensSortType.NONE, + groupType = com.tangem.domain.models.TokensGroupType.NONE, + ) + } + + private fun createNetwork(derivationPath: Network.DerivationPath): Network { + return Network( + id = Network.ID(value = NETWORK_ID, derivationPath = derivationPath), + name = "Ethereum", + isTestnet = false, + derivationPath = derivationPath, + currencySymbol = "ETH", + standardType = Network.StandardType.ERC20, + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + private fun createCoin(derivationPath: Network.DerivationPath): CryptoCurrency.Coin { + val network = createNetwork(derivationPath) + + return CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = NETWORK_ID, + derivationPath = ACCOUNT_DERIVATION_PATH, + ), + suffix = CryptoCurrency.ID.Suffix.RawID(rawId = NETWORK_ID), + ), + network = network, + name = "Ethereum", + symbol = "ETH", + decimals = 18, + iconUrl = null, + isCustom = false, + ) + } + + private fun createToken(derivationPath: Network.DerivationPath): CryptoCurrency.Token { + val network = createNetwork(derivationPath) + + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = NETWORK_ID, + derivationPath = ACCOUNT_DERIVATION_PATH, + ), + suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = CONTRACT_ADDRESS), + ), + network = network, + name = "Custom token", + symbol = "CST", + decimals = 6, + iconUrl = null, + contractAddress = CONTRACT_ADDRESS, + isCustom = true, + ) + } + + private companion object { + val userWalletId = UserWalletId("011") + val derivationIndex = DerivationIndex(value = 1).getOrNull()!! + + const val NETWORK_ID = "ethereum" + const val ACCOUNT_DERIVATION_PATH = "m/44'/60'/0'/0/1" + const val CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359" + } +} \ No newline at end of file