Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-01 16:10:30 +04:00
parent 5db12ad1e6
commit 2f160f54d9
6 changed files with 234 additions and 57 deletions

View file

@ -16,6 +16,7 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.core.error.DataError
import com.tangem.domain.demo.DemoConfig
@ -573,6 +574,10 @@ internal class DefaultCurrenciesRepository(
)
}
override fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver {
return userWalletsStore.getSyncStrict(userWalletId).requireColdWallet().cardTypesResolver
}
private fun getMultiCurrencyWalletCurrencies(userWallet: UserWallet): Flow<List<CryptoCurrency>> {
return getSavedUserTokensResponse(userWallet.walletId).map { storedTokens ->
responseCryptoCurrenciesFactory.createCurrencies(

View file

@ -4,6 +4,7 @@ import arrow.atomic.AtomicBoolean
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.currency.UserTokensSaver
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.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -15,11 +16,15 @@ import timber.log.Timber
/**
* Responsible for merging custom tokens into a user's token response.
* It handles the logic to update tokens with additional details if necessary.
*
* @property tangemTechApi Tangem Tech API
* @property userTokensSaver user tokens saver
* @property dispatchers dispatchers
*/
internal class CustomTokensMerger(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val userTokensSaver: UserTokensSaver,
private val dispatchers: CoroutineDispatcherProvider,
) {
/**
@ -29,21 +34,17 @@ internal class CustomTokensMerger(
* is needed, and if so, updating the token from [TangemTechApi.getCoins] response. It then pushes to the backend
* and returns an updated UserTokensResponse.
*
* @param userWalletId The identifier for the user's wallet, used when pushing updates.
* @param response The original user tokens response that may need to be updated.
* @return A potentially updated UserTokensResponse, with custom tokens merged if necessary.
* @param userWalletId the identifier for the user's wallet, used when pushing updates
* @param response the original user tokens response that may need to be updated
*
* @return a potentially updated UserTokensResponse, with custom tokens merged if necessary
*/
suspend fun mergeIfPresented(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse {
// use flag to check: we can't compare two token list after merge because Token equals don't include some fields
val wasMerged = AtomicBoolean(false)
val mergedTokens = withContext(dispatchers.default) {
response.tokens
.map { token ->
async { mergeIfPresented(token, wasMerged) }
}
.awaitAll()
}
val mergedTokens = mergeTokens(response = response, wasMerged = wasMerged)
val updatedResponse = response.copy(tokens = mergedTokens)
// previously here was used compare response.tokens, but it's not working correctly
@ -55,33 +56,48 @@ internal class CustomTokensMerger(
return updatedResponse
}
private suspend fun mergeIfPresented(
token: UserTokensResponse.Token,
private suspend fun mergeTokens(
response: UserTokensResponse,
wasMerged: AtomicBoolean,
): UserTokensResponse.Token {
if (isCoinOrNonCustomToken(token)) return token
): List<UserTokensResponse.Token> {
return withContext(dispatchers.default) {
response.tokens
.map { token ->
async {
if (isCoinOrNonCustomToken(token)) return@async token
return merge(token, wasMerged)
}
private suspend fun merge(
customToken: UserTokensResponse.Token,
wasMerged: AtomicBoolean,
): UserTokensResponse.Token {
val foundToken = fetchToken(customToken)
if (foundToken != null) {
wasMerged.set(true)
mergeToken(customToken = token, wasMerged = wasMerged)
}
}
.awaitAll()
}
return foundToken ?: customToken
}
private fun isCoinOrNonCustomToken(token: UserTokensResponse.Token): Boolean {
return token.contractAddress.isNullOrEmpty() || token.id != null
}
private suspend fun fetchToken(token: UserTokensResponse.Token): UserTokensResponse.Token? {
val response = withContext(dispatchers.io) {
safeApiCall(
private suspend fun mergeToken(
customToken: UserTokensResponse.Token,
wasMerged: AtomicBoolean,
): UserTokensResponse.Token {
val foundToken = findToken(token = customToken)
return if (foundToken != null) {
wasMerged.set(true)
customToken.mergeWith(foundToken)
} else {
customToken
}
}
/**
* Find a [token] among the cryptocurrencies available for application.
* If result is not null, then the token is available and should not be custom.
*/
private suspend fun findToken(token: UserTokensResponse.Token): CoinsResponse.Coin? {
return withContext(dispatchers.io) {
val response = safeApiCall(
call = {
tangemTechApi.getCoins(
contractAddress = token.contractAddress,
@ -89,19 +105,20 @@ internal class CustomTokensMerger(
).bind()
},
onError = {
Timber.w(it, "Unable to fetch token")
Timber.e(it, "Unable to fetch token:\n$token")
null
},
)
}
val foundToken = response?.coins?.firstOrNull() ?: return null
return token.copy(
id = foundToken.id,
name = foundToken.name,
symbol = foundToken.symbol.ifEmpty {
token.symbol
},
response?.coins?.firstOrNull()
}
}
private fun UserTokensResponse.Token.mergeWith(coin: CoinsResponse.Coin): UserTokensResponse.Token {
return copy(
id = coin.id,
name = coin.name,
symbol = coin.symbol.ifEmpty { symbol },
)
}
}

View file

@ -1,18 +0,0 @@
package com.tangem.data.tokens.utils
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.tokens.model.FoundToken
import com.tangem.utils.converter.Converter
internal object FoundTokenConverter : Converter<CoinsResponse.Coin, FoundToken> {
override fun convert(value: CoinsResponse.Coin): FoundToken {
return FoundToken(
id = value.id,
name = value.name,
symbol = value.symbol,
contractAddress = requireNotNull(value.networks.first().contractAddress),
decimals = requireNotNull(value.networks.first().decimalCount).intValueExact(),
)
}
}

View file

@ -0,0 +1,165 @@
package com.tangem.data.tokens.utils
import com.google.common.truth.Truth
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
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.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class CustomTokensMergerTest {
private val tangemTechApi: TangemTechApi = mockk()
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
private val merger = CustomTokensMerger(
tangemTechApi = tangemTechApi,
userTokensSaver = userTokensSaver,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun resetMocks() {
clearMocks(tangemTechApi, userTokensSaver)
}
@Test
fun `mergeIfPresented successfully if UserTokensResponse DOESN'T CONTAIN custom tokens`() = runTest {
// Act
val actual = merger.mergeIfPresented(userWalletId = userWalletId, response = defaultUserTokensResponse)
// Assert
val expected = defaultUserTokensResponse
Truth.assertThat(actual).isEqualTo(expected)
coVerify(inverse = true) {
tangemTechApi.getCoins(contractAddress = any(), networkIds = any())
userTokensSaver.push(any(), any())
}
}
@Test
fun `mergeIfPresented successfully if UserTokensResponse CONTAINS custom tokens`() = runTest {
// Arrange
val customToken = UserTokensResponse.Token(
id = null,
networkId = "cum",
name = "Matilda Rich",
symbol = "consectetuer",
decimals = 9582,
contractAddress = "contractAddress",
)
val userTokensResponse = defaultUserTokensResponse.copy(
tokens = defaultUserTokensResponse.tokens + customToken,
)
coEvery {
tangemTechApi.getCoins(contractAddress = customToken.contractAddress, networkIds = customToken.networkId)
} returns ApiResponse.Success(data = createCoinResponse(token = customToken))
// Act
val actual = merger.mergeIfPresented(userWalletId = userWalletId, response = userTokensResponse)
// Assert
val updatedCustomToken = customToken.copy(
id = "NEW_${customToken.id}",
name = "NEW_${customToken.name}",
symbol = "NEW_${customToken.symbol}",
)
val expected = defaultUserTokensResponse.copy(
tokens = defaultUserTokensResponse.tokens + updatedCustomToken,
)
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
tangemTechApi.getCoins(
contractAddress = customToken.contractAddress,
networkIds = customToken.networkId,
)
userTokensSaver.push(userWalletId = userWalletId, response = expected)
}
}
@Test
fun `mergeIfPresented successfully if API returns error`() = runTest {
// Arrange
val customToken = UserTokensResponse.Token(
id = null,
networkId = "cum",
name = "Matilda Rich",
symbol = "consectetuer",
decimals = 9582,
contractAddress = "contractAddress",
)
val userTokensResponse = defaultUserTokensResponse.copy(
tokens = defaultUserTokensResponse.tokens + customToken,
)
@Suppress("UNCHECKED_CAST")
val apiResponse = ApiResponse.Error(ApiResponseError.TimeoutException) as ApiResponse<CoinsResponse>
coEvery {
tangemTechApi.getCoins(contractAddress = customToken.contractAddress, networkIds = customToken.networkId)
} returns apiResponse
// Act
val actual = merger.mergeIfPresented(userWalletId = userWalletId, response = userTokensResponse)
// Assert
val expected = userTokensResponse
Truth.assertThat(actual).isEqualTo(expected)
coVerify {
tangemTechApi.getCoins(
contractAddress = customToken.contractAddress,
networkIds = customToken.networkId,
)
}
coVerify(inverse = true) { userTokensSaver.push(userWalletId = any(), response = any()) }
}
private companion object {
val userWalletId = UserWalletId("011")
val defaultUserTokensResponse = UserTokensResponseFactory().createUserTokensResponse(
currencies = MockCryptoCurrencyFactory().ethereumAndStellar,
isGroupedByNetwork = false,
isSortedByBalance = false,
)
fun createCoinResponse(token: UserTokensResponse.Token): CoinsResponse {
return CoinsResponse(
imageHost = null,
coins = listOf(
CoinsResponse.Coin(
id = "NEW_${token.id}",
name = "NEW_${token.name}",
symbol = "NEW_${token.symbol}",
active = true,
),
),
total = 1,
)
}
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.domain.tokens.repository
import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.core.error.DataError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
@ -8,7 +9,6 @@ import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlin.jvm.Throws
/**
* Repository for everything related to the tokens of user wallet
@ -263,4 +263,7 @@ interface CurrenciesRepository {
*/
@Throws
suspend fun syncTokens(userWalletId: UserWalletId)
@Throws
fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.tokens.repository
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.core.error.DataError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
@ -166,4 +167,8 @@ internal class MockCurrenciesRepository(
override suspend fun syncTokens(userWalletId: UserWalletId) {
return Unit
}
override fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver {
error("No-op")
}
}