Updated on 2026-08-14

This commit is contained in:
Tangem 2024-01-19 16:59:31 +03:00
commit e7f19c6936
886 changed files with 11772 additions and 23078 deletions

View file

@ -102,4 +102,13 @@ internal object TokensDataModule {
quotesRepository = quotesRepository,
)
}
@Provides
@Singleton
fun provideCardNetworksRepository(
userWalletsStore: UserWalletsStore,
dispatchers: CoroutineDispatcherProvider,
): NetworksCompatibilityRepository {
return DefaultNetworksCompatibilityRepository(userWalletsStore = userWalletsStore, dispatchers = dispatchers)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.tokens.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
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.domain.tokens.model.CryptoCurrency
@ -41,7 +42,7 @@ internal class CoinsPagingSource(
searchText = searchText,
offset = page * params.loadSize,
limit = params.loadSize,
)
).getOrThrow()
}.fold(
onSuccess = { response ->
val coinsIds = response.coins.map { coin ->

View file

@ -32,6 +32,7 @@ internal object CoinsResponseConverter : Converter<CoinsData, List<Token>> {
Token.Network(
networkId = network.networkId,
standardType = getNetworkStandardType(blockchain).name,
name = blockchain.fullName,
address = network.contractAddress,
iconUrl = getIconUrl(network.networkId, value.imageHost),
decimalCount = network.decimalCount?.toInt(),

View file

@ -49,6 +49,7 @@ internal class DefaultCurrenciesRepository(
private val cardCurrenciesFactory = CardCryptoCurrenciesFactory(demoConfig)
private val userTokensResponseFactory = UserTokensResponseFactory()
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
private val customTokensMerger = CustomTokensMerger(tangemTechApi, dispatchers)
override suspend fun saveTokens(
userWalletId: UserWalletId,
@ -313,6 +314,7 @@ internal class DefaultCurrenciesRepository(
): Boolean {
val blockchain = Blockchain.fromId(cryptoCurrencyStatus.currency.network.id.value)
val isBitcoinBlockchain = blockchain == Blockchain.Bitcoin || blockchain == Blockchain.BitcoinTestnet
return if (cryptoCurrencyStatus.currency is CryptoCurrency.Coin && isBitcoinBlockchain) {
val outgoingTransactions = cryptoCurrencyStatus.value.pendingTransactions.filter { it.isOutgoing }
outgoingTransactions.isNotEmpty()
@ -341,40 +343,32 @@ internal class DefaultCurrenciesRepository(
private suspend fun fetchTokens(userWallet: UserWallet) {
val userWalletId = userWallet.walletId
if (demoConfig.isDemoCardId(userWallet.cardId) && userTokensStore.getSyncOrNull(key = userWalletId) == null) {
userTokensStore.store(
key = userWalletId,
value = userTokensResponseFactory.createUserTokensResponse(
currencies = cardCurrenciesFactory.createDefaultCoinsForMultiCurrencyCard(userWallet.scanResponse),
isGroupedByNetwork = false,
isSortedByBalance = false,
),
)
return
val response = if (checkIsEmptyDemoWallet(userWallet)) {
createDefaultUserTokensResponse(userWallet)
} else {
safeApiCall({ tangemTechApi.getUserTokens(userWalletId.stringValue).bind() }) {
handleFetchTokensError(userWallet, it)
}
}
val response = safeApiCall(
call = {
tangemTechApi.getUserTokens(userWalletId.stringValue).bind().let {
it.copy(tokens = it.tokens.distinct())
}
},
onError = { handleFetchTokensError(userWallet, it) },
)
val compatibleUserTokensResponse = response
.let { it.copy(tokens = it.tokens.distinct()) }
.let { customTokensMerger.mergeIfPresented(userWalletId, response) }
.let(userTokensBackwardCompatibility::applyCompatibilityAndGetUpdated)
val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response)
userTokensStore.store(userWallet.walletId, compatibleUserTokensResponse)
fetchExchangeableUserMarketCoinsByIds(userWalletId, compatibleUserTokensResponse)
}
private suspend fun checkIsEmptyDemoWallet(userWallet: UserWallet): Boolean {
return demoConfig.isDemoCardId(userWallet.cardId) && userTokensStore.getSyncOrNull(userWallet.walletId) == null
}
private suspend fun storeAndPushTokens(userWalletId: UserWalletId, response: UserTokensResponse) {
val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response)
userTokensStore.store(userWalletId, compatibleUserTokensResponse)
try {
tangemTechApi.saveUserTokens(userWalletId.stringValue, response)
} catch (e: Throwable) {
Timber.e("Unable to save user tokens for: ${userWalletId.stringValue}")
}
pushTokens(userWalletId, response)
}
private suspend fun fetchExchangeableUserMarketCoinsByIds(
@ -407,16 +401,12 @@ internal class DefaultCurrenciesRepository(
private suspend fun handleFetchTokensError(userWallet: UserWallet, e: ApiResponseError): UserTokensResponse {
val userWalletId = userWallet.walletId
val response = userTokensStore.getSyncOrNull(userWalletId)
?: userTokensResponseFactory.createUserTokensResponse(
currencies = cardCurrenciesFactory.createDefaultCoinsForMultiCurrencyCard(userWallet.scanResponse),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
?: createDefaultUserTokensResponse(userWallet)
if (e is ApiResponseError.HttpException && e.code == ApiResponseError.HttpException.Code.NOT_FOUND) {
Timber.w(e, "Requested currencies could not be found in the remote store for: $userWalletId")
tangemTechApi.saveUserTokens(userWalletId.stringValue, response)
pushTokens(userWalletId, response)
} else {
cacheRegistry.invalidate(getTokensCacheKey(userWalletId))
}
@ -424,6 +414,19 @@ internal class DefaultCurrenciesRepository(
return response
}
private suspend fun pushTokens(userWalletId: UserWalletId, response: UserTokensResponse) {
safeApiCall({ tangemTechApi.saveUserTokens(userWalletId.stringValue, response).bind() }) {
Timber.e(it, "Unable to save user tokens for: ${userWalletId.stringValue}")
}
}
private fun createDefaultUserTokensResponse(userWallet: UserWallet) =
userTokensResponseFactory.createUserTokensResponse(
currencies = cardCurrenciesFactory.createDefaultCoinsForMultiCurrencyCard(userWallet.scanResponse),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
private suspend fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"Unable to find a user wallet with provided ID: $userWalletId"

View file

@ -0,0 +1,89 @@
package com.tangem.data.tokens.repository
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.tokens.utils.getNetwork
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.extensions.*
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.NetworksCompatibilityRepository
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
internal class DefaultNetworksCompatibilityRepository(
private val userWalletsStore: UserWalletsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : NetworksCompatibilityRepository {
/**
* @return returns true if either the network is not Solana (check is not relevant) or if it is Solana and
* UserWallet supports tokens on Solana Network
*/
@Throws(IllegalArgumentException::class)
override suspend fun areSolanaTokensSupportedIfRelevant(networkId: String, userWalletId: UserWalletId): Boolean {
return withContext(dispatchers.io) {
val scanResponse = getWalletOrThrow(userWalletId).scanResponse
val blockchain = getBlockchainOrThrow(networkId)
val blockchainsSupportingTokens = scanResponse.card.supportedTokens(scanResponse.cardTypesResolver)
blockchain != Blockchain.Solana || blockchainsSupportingTokens.contains(Blockchain.Solana)
}
}
@Throws(IllegalArgumentException::class)
override suspend fun areTokensSupportedByNetwork(networkId: String, userWalletId: UserWalletId): Boolean {
return withContext(dispatchers.io) {
val scanResponse = getWalletOrThrow(userWalletId).scanResponse
val blockchain = getBlockchainOrThrow(networkId)
val blockchainsSupportingTokens = scanResponse.card.supportedTokens(scanResponse.cardTypesResolver)
scanResponse.card.canHandleToken(
supportedTokens = blockchainsSupportingTokens,
blockchain = blockchain,
cardTypesResolver = scanResponse.cardTypesResolver,
)
}
}
@Throws(IllegalArgumentException::class)
override suspend fun isNetworkSupported(networkId: String, userWalletId: UserWalletId): Boolean {
return withContext(dispatchers.io) {
val scanResponse = getWalletOrThrow(userWalletId).scanResponse
val blockchain = getBlockchainOrThrow(networkId)
scanResponse.card.canHandleBlockchain(
blockchain = blockchain,
cardTypesResolver = scanResponse.cardTypesResolver,
)
}
}
@Throws(IllegalArgumentException::class)
override suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network> {
val scanResponse = getWalletOrThrow(userWalletId).scanResponse
return Blockchain.values()
.filter { blockchain ->
scanResponse.card.supportedBlockchains(scanResponse.cardTypesResolver).contains(blockchain)
}
.sortedBy(Blockchain::fullName)
.mapNotNull { blockchain ->
getNetwork(blockchain, null, scanResponse.derivationStyleProvider)
}
}
override fun areTokensSupportedByNetwork(networkId: String): Boolean {
return Blockchain.fromNetworkId(networkId)?.canHandleTokens() ?: false
}
private suspend fun getWalletOrThrow(userWalletId: UserWalletId): UserWallet {
return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"Requested UserWallet not found"
}
}
private fun getBlockchainOrThrow(networkId: String): Blockchain {
return requireNotNull(Blockchain.fromNetworkId(networkId)) {
"Requested network not found"
}
}
}

View file

@ -186,6 +186,7 @@ internal class DefaultNetworksRepository(
is UpdateWalletManagerResult.NoAccount,
-> Unit
is UpdateWalletManagerResult.Unreachable,
is UpdateWalletManagerResult.UnreachableWithoutAddresses,
is UpdateWalletManagerResult.MissedDerivation,
-> {
Timber.w(

View file

@ -6,7 +6,6 @@ import com.tangem.data.tokens.utils.QuotesConverter
import com.tangem.data.tokens.utils.QuotesUnsupportedCurrenciesIdAdapter
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.local.*
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject

View file

@ -3,13 +3,19 @@ package com.tangem.data.tokens.repository
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.tokens.paging.CoinsPagingSource
import com.tangem.data.tokens.utils.FoundTokenConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.tokens.model.FoundToken
import com.tangem.domain.tokens.model.Token
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.tokens.repository.TokensListRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
/**
* Default repository implementation for managing operations related to a complete set of tokens
@ -42,4 +48,32 @@ internal class DefaultTokensListRepository(
},
).flow
}
override suspend fun findToken(contractAddress: String, networkId: String): FoundToken? {
return withContext(dispatchers.io) {
val foundCoin = tangemTechApi.getCoins(
contractAddress = contractAddress,
networkIds = networkId,
).getOrThrow().coins.firstNotNullOfOrNull { coin ->
val tokenNetwork = coin.networks.filter { network ->
network.contractAddress != null && network.decimalCount != null &&
network.contractAddress?.equals(contractAddress, ignoreCase = true) == true &&
networkId == network.networkId
}
if (tokenNetwork.isNotEmpty()) {
coin.copy(networks = tokenNetwork)
} else {
null
}
}
foundCoin?.let { FoundTokenConverter.convert(foundCoin) }
}
}
override fun validateAddress(contractAddress: String, networkId: String): Boolean {
return when (val blockchain = Blockchain.fromNetworkId(networkId) ?: Blockchain.Unknown) {
Blockchain.Unknown, Blockchain.Binance, Blockchain.BinanceTestnet -> true
else -> blockchain.validateAddress(contractAddress)
}
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.tokens.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.common.DerivationStyleProvider
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.common.extensions.toCoinId
import com.tangem.domain.tokens.model.CryptoCurrency
import timber.log.Timber
@ -57,4 +58,43 @@ class CryptoCurrencyFactory {
isCustom = isCustomCoin(network),
)
}
fun createCoin(
networkId: String,
extraDerivationPath: String?,
derivationStyleProvider: DerivationStyleProvider,
): CryptoCurrency.Coin? {
val blockchain = Blockchain.fromNetworkId(networkId) ?: Blockchain.Unknown
return createCoin(blockchain, extraDerivationPath, derivationStyleProvider)
}
fun createToken(
token: Token,
networkId: String,
extraDerivationPath: String?,
derivationStyleProvider: DerivationStyleProvider,
): CryptoCurrency.Token? {
val sdkToken = SdkToken(
name = token.name,
symbol = token.symbol,
contractAddress = token.contractAddress,
decimals = token.decimals,
id = token.id,
)
val blockchain = Blockchain.fromNetworkId(networkId) ?: Blockchain.Unknown
return createToken(
sdkToken = sdkToken,
blockchain = blockchain,
extraDerivationPath = extraDerivationPath,
derivationStyleProvider = derivationStyleProvider,
)
}
data class Token(
val name: String,
val symbol: String,
val contractAddress: String,
val decimals: Int,
val id: String? = null,
)
}

View file

@ -0,0 +1,95 @@
package com.tangem.data.tokens.utils
import com.tangem.data.common.api.safeApiCall
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
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.
*/
internal class CustomTokensMerger(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
) {
/**
* Merges custom tokens into the user's token response if needed.
*
* This function processes each token in the response asynchronously, checking if an update
* 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.
*/
suspend fun mergeIfPresented(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse {
val mergedTokens = withContext(dispatchers.default) {
response.tokens
.map { token ->
async { mergeIfPresented(token) }
}
.awaitAll()
}
val updatedResponse = response.copy(tokens = mergedTokens)
if (response.tokens != updatedResponse.tokens) {
pushTokens(userWalletId, updatedResponse)
}
return updatedResponse
}
private suspend fun mergeIfPresented(token: UserTokensResponse.Token): UserTokensResponse.Token {
if (isCoinOrNonCustomToken(token)) return token
return merge(token)
}
private suspend fun merge(customToken: UserTokensResponse.Token): UserTokensResponse.Token {
val foundToken = fetchToken(customToken)
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(
call = {
tangemTechApi.getCoins(
contractAddress = token.contractAddress,
networkIds = token.networkId,
).bind()
},
onError = {
Timber.w(it, "Unable to fetch token")
null
},
)
}
val foundToken = response?.coins?.firstOrNull() ?: return null
return token.copy(
id = foundToken.id,
name = foundToken.name,
symbol = foundToken.symbol,
)
}
private suspend fun pushTokens(userWalletId: UserWalletId, response: UserTokensResponse) {
safeApiCall({ tangemTechApi.saveUserTokens(userWalletId.stringValue, response).bind() }) {
Timber.e(it, "Unable to save user tokens for: $userWalletId")
}
}
}

View file

@ -0,0 +1,18 @@
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

@ -19,7 +19,10 @@ internal class NetworkStatusFactory {
network = network,
value = when (result) {
is UpdateWalletManagerResult.MissedDerivation -> NetworkStatus.MissedDerivation
is UpdateWalletManagerResult.Unreachable -> NetworkStatus.Unreachable
is UpdateWalletManagerResult.UnreachableWithoutAddresses -> NetworkStatus.UnreachableWithoutAddresses
is UpdateWalletManagerResult.Unreachable -> NetworkStatus.Unreachable(
address = getNetworkAddress(result.selectedAddress, result.addresses),
)
is UpdateWalletManagerResult.NoAccount -> NetworkStatus.NoAccount(
address = getNetworkAddress(result.selectedAddress, result.addresses),
amountToCreateAccount = result.amountToCreateAccount,