Updated on 2026-08-14

This commit is contained in:
Tangem 2024-07-24 15:31:02 +03:00
parent 933dfb16e7
commit 8fc579ba5c
14 changed files with 228 additions and 27 deletions

View file

@ -18,6 +18,12 @@ data class ExchangeStatusResponse(
@Json(name = "error")
val error: ExchangeStatusError?,
@Json(name = "refundNetwork")
val refundNetwork: String? = null,
@Json(name = "refundContractAddress")
val refundContractAddress: String? = null,
)
enum class ExchangeStatus {

View file

@ -39,7 +39,7 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
import com.tangem.blockchain.common.FeePaidCurrency as FeePaidSdkCurrency
@Suppress("LargeClass", "LongParameterList")
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
internal class DefaultCurrenciesRepository(
private val tangemTechApi: TangemTechApi,
private val tangemExpressApi: TangemExpressApi,
@ -53,6 +53,7 @@ internal class DefaultCurrenciesRepository(
private val demoConfig = DemoConfig()
private val responseCurrenciesFactory = ResponseCryptoCurrenciesFactory()
private val cryptoCurrencyFactory = CryptoCurrencyFactory()
private val cardCurrenciesFactory = CardCryptoCurrenciesFactory(demoConfig)
private val userTokensResponseFactory = UserTokensResponseFactory()
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
@ -125,7 +126,7 @@ internal class DefaultCurrenciesRepository(
return newTokens
.filterNot { savedCurrencies.hasCoinForToken(it) } // tokens without coins
.mapNotNull {
CryptoCurrencyFactory().createCoin(
cryptoCurrencyFactory.createCoin(
blockchain = getBlockchain(networkId = it.network.id),
extraDerivationPath = it.network.derivationPath.value,
derivationStyleProvider = getUserWallet(userWalletId).scanResponse.derivationStyleProvider,
@ -413,12 +414,54 @@ internal class DefaultCurrenciesRepository(
}
override fun createTokenCurrency(cryptoCurrency: CryptoCurrency.Token, network: Network): CryptoCurrency.Token {
return CryptoCurrencyFactory().createToken(
return cryptoCurrencyFactory.createToken(
cryptoCurrency = cryptoCurrency,
network = network,
)
}
override suspend fun createTokenCurrency(
userWalletId: UserWalletId,
contractAddress: String,
networkId: String,
): CryptoCurrency.Token {
val userWallet = getUserWallet(userWalletId)
val token = withContext(dispatchers.io) {
val foundToken = tangemTechApi.getCoins(
contractAddress = contractAddress,
networkIds = networkId,
)
.getOrThrow()
.coins
.firstNotNullOfOrNull { coin ->
val networksWithTheSameAddress = coin.networks.filter { network ->
(network.contractAddress != null || network.decimalCount != null) &&
network.contractAddress?.equals(contractAddress, ignoreCase = true) == true
}
if (networksWithTheSameAddress.isNotEmpty()) {
coin.copy(networks = networksWithTheSameAddress)
} else {
null
}
} ?: error("Token not found")
val network = foundToken.networks.firstOrNull { it.networkId == networkId } ?: error("Network not found")
CryptoCurrencyFactory.Token(
symbol = foundToken.symbol,
name = foundToken.name,
contractAddress = contractAddress,
decimals = network.decimalCount?.toInt() ?: error("Decimals not found"),
id = foundToken.id,
)
}
return cryptoCurrencyFactory.createToken(
token = token,
networkId = networkId,
extraDerivationPath = null,
derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider,
) ?: error("Unable to create token")
}
private fun getMultiCurrencyWalletCurrencies(userWallet: UserWallet): Flow<List<CryptoCurrency>> {
return userTokensStore.get(userWallet.walletId).map { storedTokens ->
responseCurrenciesFactory.createCurrencies(

View file

@ -79,13 +79,35 @@ class AddCryptoCurrenciesUseCase(
.toNonEmptyListOrNull()
?: return@either
catch({ currenciesRepository.addCurrencies(userWalletId, currenciesToAdd) }) {
raise(it)
}
addCurrencies(userWalletId, currenciesToAdd)
refreshUpdatedNetworks(userWalletId, currenciesToAdd, existingCurrencies)
}
suspend operator fun invoke(
userWalletId: UserWalletId,
contractAddress: String,
networkId: String,
): Either<Throwable, CryptoCurrency> = either {
val existingCurrencies =
catch({ currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) }) {
raise(it)
}
val foundToken = existingCurrencies
.filterIsInstance<CryptoCurrency.Token>()
.firstOrNull {
it.network.backendId == networkId &&
!it.isCustom &&
it.contractAddress.equals(contractAddress, true)
}
if (foundToken != null) {
return@either foundToken
}
val tokenToAdd = createTokenCurrency(userWalletId, contractAddress, networkId)
addCurrencies(userWalletId, listOf(tokenToAdd))
refreshUpdatedNetworks(userWalletId, listOf(tokenToAdd), existingCurrencies)
tokenToAdd
}
/**
* Refreshes the network statuses for tokens that have corresponding coins in the
* [existingCurrencies] list.
@ -117,6 +139,33 @@ class AddCryptoCurrenciesUseCase(
}
}
private suspend fun Raise<Throwable>.createTokenCurrency(
userWalletId: UserWalletId,
contractAddress: String,
networkId: String,
): CryptoCurrency.Token {
return catch(
block = {
currenciesRepository.createTokenCurrency(
userWalletId = userWalletId,
contractAddress = contractAddress,
networkId = networkId,
)
},
catch = {
raise(it)
},
)
}
private suspend fun Raise<Throwable>.addCurrencies(userWalletId: UserWalletId, tokens: List<CryptoCurrency>) {
catch(
{ currenciesRepository.addCurrencies(userWalletId, tokens) },
) {
raise(it)
}
}
/**
* Determines if the [existingCurrencies] list contains a coin that corresponds
* to the given [token].

View file

@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.Flow
/**
* Repository for everything related to the tokens of user wallet
* */
@Suppress("TooManyFunctions")
interface CurrenciesRepository {
/**
@ -210,4 +211,13 @@ interface CurrenciesRepository {
* Creates token [cryptoCurrency] based on current token and [network] it`s will be added
*/
fun createTokenCurrency(cryptoCurrency: CryptoCurrency.Token, network: Network): CryptoCurrency.Token
/**
* Creates token [cryptoCurrency] based on [contractAddress] and [networkId] it`s will be added
*/
suspend fun createTokenCurrency(
userWalletId: UserWalletId,
contractAddress: String,
networkId: String,
): CryptoCurrency.Token
}

View file

@ -136,4 +136,12 @@ internal class MockCurrenciesRepository(
override fun createTokenCurrency(cryptoCurrency: CryptoCurrency.Token, network: Network): CryptoCurrency.Token {
return cryptoCurrency
}
override suspend fun createTokenCurrency(
userWalletId: UserWalletId,
contractAddress: String,
networkId: String,
): CryptoCurrency.Token {
error("not implemented")
}
}

View file

@ -15,6 +15,8 @@ internal class ExchangeStatusConverter : Converter<ExchangeStatusResponse, Excha
txId = value.externalTxId,
txExternalUrl = value.externalTxUrl,
txExternalId = value.externalTxId,
refundNetwork = value.refundNetwork,
refundContractAddress = value.refundContractAddress,
)
}
}

View file

@ -6,6 +6,8 @@ data class ExchangeStatusModel(
val txId: String? = null,
val txExternalUrl: String? = null,
val txExternalId: String? = null,
val refundNetwork: String? = null,
val refundContractAddress: String? = null,
)
enum class ExchangeStatus {

View file

@ -29,6 +29,7 @@ internal data class SwapTransactionsState(
val fromFiatAmount: String,
val fromCurrencyIcon: TokenIconState,
val showProviderLink: Boolean,
val isRefundTerminalStatus: Boolean = true,
val onClick: () -> Unit,
val onGoToProviderClick: (String) -> Unit,
)

View file

@ -3,6 +3,8 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.componen
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.tokendetails.impl.R
@Immutable
@ -35,4 +37,19 @@ internal sealed class ExchangeStatusNotifications(val config: NotificationConfig
),
),
)
data class TokenRefunded(
val cryptoCurrency: CryptoCurrency,
val onGoToTokenClick: () -> Unit,
) : ExchangeStatusNotifications(
config = NotificationConfig(
title = stringReference("TITLE FOR TOKEN REFUND"),
subtitle = stringReference("SUBTITLE FOR TOKEN REFUND"),
iconResId = R.drawable.ic_alert_triangle_20,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = stringReference("Go to token"),
onClick = onGoToTokenClick,
),
),
)
}

View file

@ -62,7 +62,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
}?.fiatRate?.multiply(fromAmount)
val timestamp = transaction.timestamp
val notifications =
getNotification(transaction.status?.status, transaction.status?.txExternalUrl)
getNotification(transaction.status?.status, transaction.status?.txExternalUrl, null)
val showProviderLink = getShowProviderLink(notifications, transaction.status)
result.add(
SwapTransactionsState(
@ -77,10 +77,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
statuses = getStatuses(transaction.status?.status),
hasFailed = transaction.status?.status == ExchangeStatus.Failed,
activeStatus = transaction.status?.status,
notification = getNotification(
transaction.status?.status,
transaction.status?.txExternalUrl,
),
notification = notifications,
toCryptoCurrency = toCryptoCurrency,
toCryptoAmount = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = toAmount,
@ -110,10 +107,15 @@ internal class TokenDetailsSwapTransactionsStateConverter(
return result.toPersistentList()
}
fun updateTxStatus(tx: SwapTransactionsState, statusModel: ExchangeStatusModel?): SwapTransactionsState {
fun updateTxStatus(
tx: SwapTransactionsState,
statusModel: ExchangeStatusModel?,
refundToken: CryptoCurrency?,
isRefundTerminalStatus: Boolean,
): SwapTransactionsState {
if (statusModel == null || tx.activeStatus == statusModel.status) return tx
val hasFailed = tx.hasFailed || statusModel.status == ExchangeStatus.Failed
val notifications = getNotification(statusModel.status, statusModel.txExternalUrl)
val notifications = getNotification(statusModel.status, statusModel.txExternalUrl, refundToken)
val showProviderLink = getShowProviderLink(notifications, statusModel)
return tx.copy(
activeStatus = statusModel.status,
@ -122,6 +124,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
statuses = getStatuses(statusModel.status, hasFailed),
txUrl = statusModel.txExternalUrl,
showProviderLink = showProviderLink,
isRefundTerminalStatus = isRefundTerminalStatus,
)
}
@ -133,7 +136,11 @@ internal class TokenDetailsSwapTransactionsStateConverter(
)
}
private fun getNotification(status: ExchangeStatus?, txUrl: String?): ExchangeStatusNotifications? {
private fun getNotification(
status: ExchangeStatus?,
txUrl: String?,
refundToken: CryptoCurrency?,
): ExchangeStatusNotifications? {
if (txUrl == null) return null
return when (status) {
ExchangeStatus.Failed -> {
@ -152,6 +159,15 @@ internal class TokenDetailsSwapTransactionsStateConverter(
clickIntents.onGoToProviderClick(txUrl)
}
}
ExchangeStatus.Refunded -> {
if (refundToken == null) {
null
} else {
ExchangeStatusNotifications.TokenRefunded(refundToken) {
clickIntents.onGoToRefundedTokenClick(refundToken)
}
}
}
else -> null
}
}
@ -287,7 +303,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
status = ExchangeStatus.Refunded,
text = TextReference.Res(R.string.express_exchange_status_refunded),
isActive = false,
isDone = isRefunded,
isDone = false,
)
else -> ExchangeStatusState(
status = ExchangeStatus.Sending,

View file

@ -115,6 +115,11 @@ private fun ExchangeStatusStep(
color = TangemTheme.colors.icon.warning,
isDone = it.isDone,
)
it.status == ExchangeStatus.Refunded -> ExchangeStep(
iconRes = R.drawable.ic_close_24,
color = TangemTheme.colors.icon.warning,
isDone = it.isDone,
)
it.status == ExchangeStatus.Verifying -> ExchangeStep(
iconRes = R.drawable.ic_exclamation_24,
color = TangemTheme.colors.icon.attention,
@ -141,6 +146,7 @@ private fun ExchangeStatusStep(
private fun ExchangeStatusStepText(stepStatus: ExchangeStatusState) {
val textColor = when {
stepStatus.status == ExchangeStatus.Cancelled -> TangemTheme.colors.icon.warning
stepStatus.status == ExchangeStatus.Refunded -> TangemTheme.colors.icon.warning
stepStatus.status == ExchangeStatus.Failed && !stepStatus.isDone -> TangemTheme.colors.icon.warning
stepStatus.status == ExchangeStatus.Verifying && !stepStatus.isDone -> TangemTheme.colors.icon.attention
stepStatus.isDone -> TangemTheme.colors.text.primary1

View file

@ -4,6 +4,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.datasource.local.swaptx.ExchangeAnalyticsStatus
import com.tangem.datasource.local.swaptx.SwapTransactionStatusStore
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.domain.tokens.models.analytics.TokenExchangeAnalyticsEvent
@ -38,6 +39,7 @@ internal class ExchangeStatusFactory(
private val swapRepository: SwapRepository,
private val quotesRepository: QuotesRepository,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val swapTransactionStatusStore: SwapTransactionStatusStore,
private val dispatchers: CoroutineDispatcherProvider,
private val clickIntents: TokenDetailsClickIntents,
@ -85,7 +87,7 @@ internal class ExchangeStatusFactory(
val bottomSheetConfig = state.bottomSheetConfig?.content as? ExchangeStatusBottomSheetConfig ?: return state
val selectedTx = bottomSheetConfig.value
return if (selectedTx.activeStatus.isTerminal()) {
return if (selectedTx.activeStatus.isTerminal(selectedTx.isRefundTerminalStatus)) {
swapTransactionRepository.removeTransaction(
userWalletId = userWalletId,
fromCryptoCurrency = selectedTx.fromCryptoCurrency,
@ -104,12 +106,19 @@ internal class ExchangeStatusFactory(
suspend fun updateSwapTxStatuses(swapTxList: PersistentList<SwapTransactionsState>) = withContext(dispatchers.io) {
swapTxList.map { tx ->
async {
if (tx.activeStatus.isTerminal()) {
val statusModel = getExchangeStatus(tx.txId)
val isRefundTerminalStatus = statusModel?.refundNetwork == null &&
statusModel?.refundContractAddress == null
if (tx.activeStatus.isTerminal(isRefundTerminalStatus)) {
tx
} else {
val statusModel = getExchangeStatus(tx.txId)
swapTransactionsStateConverter
.updateTxStatus(tx, statusModel)
val addedRefundToken = addRefundCurrencyIfNeeded(statusModel)
swapTransactionsStateConverter.updateTxStatus(
tx = tx,
statusModel = statusModel,
refundToken = addedRefundToken,
isRefundTerminalStatus = isRefundTerminalStatus,
)
}
}
}
@ -142,6 +151,20 @@ internal class ExchangeStatusFactory(
}
}
private suspend fun addRefundCurrencyIfNeeded(status: ExchangeStatusModel?): CryptoCurrency? {
status ?: return null
val refundNetwork = status.refundNetwork
val refundContractAddress = status.refundContractAddress
if (refundNetwork != null && refundContractAddress != null) {
return addCryptoCurrenciesUseCase(
userWalletId = userWalletId,
contractAddress = refundContractAddress,
networkId = refundNetwork,
).getOrNull()
}
return null
}
private fun getExchangeStatusState(
savedTransactions: List<SavedSwapTransactionListModel>?,
quotes: Set<Quote>,
@ -156,11 +179,14 @@ internal class ExchangeStatusFactory(
)
}
private fun ExchangeStatus?.isTerminal() = this == ExchangeStatus.Refunded ||
this == ExchangeStatus.Finished ||
this == ExchangeStatus.Cancelled ||
this == ExchangeStatus.TxFailed ||
this == ExchangeStatus.Unknown
private fun ExchangeStatus?.isTerminal(isRefundTerminal: Boolean): Boolean {
val needTerminalRefund = this == ExchangeStatus.Refunded && isRefundTerminal
return needTerminalRefund ||
this == ExchangeStatus.Finished ||
this == ExchangeStatus.Cancelled ||
this == ExchangeStatus.TxFailed ||
this == ExchangeStatus.Unknown
}
private fun toAnalyticStatus(status: ExchangeStatus?): ExchangeAnalyticsStatus? {
return when (status) {

View file

@ -55,4 +55,6 @@ interface TokenDetailsClickIntents {
fun onCopyAddress(): TextReference?
fun onAssociateClick()
fun onGoToRefundedTokenClick(cryptoCurrency: CryptoCurrency)
}

View file

@ -96,6 +96,7 @@ internal class TokenDetailsViewModel @Inject constructor(
private val getCurrencyWarningsUseCase: GetCurrencyWarningsUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val shouldShowSwapPromoTokenUseCase: ShouldShowSwapPromoTokenUseCase,
private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase,
private val getExtendedPublicKeyForCurrencyUseCase: GetExtendedPublicKeyForCurrencyUseCase,
@ -156,6 +157,7 @@ internal class TokenDetailsViewModel @Inject constructor(
swapRepository = swapRepository,
quotesRepository = quotesRepository,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
addCryptoCurrenciesUseCase = addCryptoCurrenciesUseCase,
swapTransactionStatusStore = swapTransactionStatusStore,
dispatchers = dispatchers,
clickIntents = this,
@ -691,7 +693,8 @@ internal class TokenDetailsViewModel @Inject constructor(
}
override fun onDismissBottomSheet() {
if (internalUiState.value.bottomSheetConfig?.content is ExchangeStatusBottomSheetConfig) {
val bsContent = internalUiState.value.bottomSheetConfig?.content
if (bsContent is ExchangeStatusBottomSheetConfig) {
viewModelScope.launch(dispatchers.main) {
internalUiState.value = exchangeStatusFactory.removeTransactionOnBottomSheetClosed()
}
@ -713,6 +716,16 @@ internal class TokenDetailsViewModel @Inject constructor(
router.openUrl(url)
}
override fun onGoToRefundedTokenClick(cryptoCurrency: CryptoCurrency) {
if (internalUiState.value.bottomSheetConfig?.content is ExchangeStatusBottomSheetConfig) {
viewModelScope.launch(dispatchers.main) {
internalUiState.value = exchangeStatusFactory.removeTransactionOnBottomSheetClosed()
}
}
internalUiState.value = stateFactory.getStateWithClosedBottomSheet()
router.openTokenDetails(userWalletId, cryptoCurrency)
}
override fun onSwapPromoDismiss() {
viewModelScope.launch(dispatchers.main) {
shouldShowSwapPromoTokenUseCase.neverToShow()