Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-28 18:13:46 +04:00
parent 9163a956aa
commit b70933a1e0
47 changed files with 848 additions and 74 deletions

View file

@ -20,6 +20,7 @@ interface SwapComponent : ComposableContentComponent {
val cryptoAmount: BigDecimal,
val fiatAmount: BigDecimal,
val depositAddress: String,
val isWithdrawal: Boolean,
)
}

View file

@ -1,7 +1,13 @@
package com.tangem.feature.swap.domain.models.ui
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import java.math.BigDecimal
sealed class SwapTransactionState {
@ -16,6 +22,31 @@ sealed class SwapTransactionState {
val timestamp: Long,
) : SwapTransactionState()
data class TangemPayWithdrawalData(
val cryptoAmount: BigDecimal,
val cryptoCurrencyId: CryptoCurrency.RawID,
val cexAddress: String,
val fromAmount: String?,
val fromAmountValue: BigDecimal?,
val toAmount: String?,
val toAmountValue: BigDecimal?,
val storeData: StoreTransactionData,
) : SwapTransactionState() {
data class StoreTransactionData(
val currencyToSend: CryptoCurrencyStatus,
val currencyToGet: CryptoCurrencyStatus,
val fromAccount: Account.CryptoPortfolio?,
val toAccount: Account.CryptoPortfolio?,
val amount: SwapAmount,
val swapProvider: SwapProvider,
val swapDataModel: SwapDataModel,
val txExternalUrl: String?,
val txExternalId: String?,
val averageDuration: Int?,
)
}
data object DemoMode : SwapTransactionState()
sealed class Error : SwapTransactionState() {

View file

@ -61,7 +61,8 @@ interface SwapInteractor {
* @param currencyToSend [Currency]
* @param currencyToGet [Currency]
* @param amountToSwap amount to swap
* @param fee for tx
* @param fee for tx (can be null only for tangem pay withdrawal)
* @return [SwapTransactionState]
*/
@Suppress("LongParameterList")
@ -75,8 +76,9 @@ interface SwapInteractor {
toAccount: Account.CryptoPortfolio?,
amountToSwap: String,
includeFeeInAmount: IncludeFeeInAmount,
fee: TxFee,
fee: TxFee?,
expressOperationType: ExpressOperationType,
isTangemPayWithdrawal: Boolean,
): SwapTransactionState
// suspend fun updateQuotesStateWithSelectedFee(
@ -115,6 +117,21 @@ interface SwapInteractor {
fun getNativeToken(networkId: String): CryptoCurrency
@Suppress("LongParameterList")
suspend fun storeSwapTransaction(
currencyToSend: CryptoCurrencyStatus,
currencyToGet: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toAccount: Account.CryptoPortfolio?,
amount: SwapAmount,
swapProvider: SwapProvider,
swapDataModel: SwapDataModel,
timestamp: Long,
txExternalUrl: String? = null,
txExternalId: String? = null,
averageDuration: Int? = null,
)
interface Factory {
fun create(selectedWalletId: UserWalletId): SwapInteractor
}

View file

@ -709,6 +709,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
return result
}
@Suppress("NullableToStringCall")
override suspend fun onSwap(
swapProvider: SwapProvider,
swapData: SwapDataModel?,
@ -718,8 +719,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
toAccount: Account.CryptoPortfolio?,
amountToSwap: String,
includeFeeInAmount: IncludeFeeInAmount,
fee: TxFee,
fee: TxFee?,
expressOperationType: ExpressOperationType,
isTangemPayWithdrawal: Boolean,
): SwapTransactionState {
Timber.i(
"""
@ -757,6 +759,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
txFee = fee,
swapProvider = swapProvider,
expressOperationType = expressOperationType,
isTangemPayWithdrawal = isTangemPayWithdrawal,
)
}
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
@ -773,6 +776,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
amountToSwap = amountToSwap,
)
} else {
if (fee == null) return SwapTransactionState.Error.UnknownError
onSwapDex(
provider = swapProvider,
networkId = currencyToSend.currency.network.backendId,
@ -978,16 +982,17 @@ internal class SwapInteractorImpl @AssistedInject constructor(
).getOrNull() ?: error("failed to create extras")
}
@Suppress("LongMethod")
@Suppress("LongMethod", "CanBeNonNullable")
private suspend fun onSwapCex(
currencyToSend: CryptoCurrencyStatus,
currencyToGet: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toAccount: Account.CryptoPortfolio?,
amount: SwapAmount,
txFee: TxFee,
txFee: TxFee?,
swapProvider: SwapProvider,
expressOperationType: ExpressOperationType,
isTangemPayWithdrawal: Boolean,
): SwapTransactionState {
val exchangeData = repository.getExchangeData(
userWallet = userWallet,
@ -1010,6 +1015,33 @@ internal class SwapInteractorImpl @AssistedInject constructor(
val exchangeDataCex =
exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.Error.UnknownError
if (isTangemPayWithdrawal) {
return SwapTransactionState.TangemPayWithdrawalData(
cryptoAmount = amount.value,
cryptoCurrencyId = requireNotNull(currencyToSend.currency.id.rawCurrencyId),
cexAddress = exchangeDataCex.txTo,
fromAmount = amount.toStringWithRightOffset(),
fromAmountValue = amount.value,
toAmount = amountFormatter.formatSwapAmountToUI(
exchangeData.toTokenAmount,
currencyToGet.currency.symbol,
),
toAmountValue = exchangeData.toTokenAmount.value,
storeData = SwapTransactionState.TangemPayWithdrawalData.StoreTransactionData(
currencyToSend = currencyToSend,
currencyToGet = currencyToGet,
fromAccount = fromAccount,
toAccount = toAccount,
amount = amount,
swapProvider = swapProvider,
swapDataModel = exchangeData,
txExternalUrl = "",
txExternalId = exchangeDataCex.externalTxId,
averageDuration = null,
),
)
}
val userWallet = userWallet
if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) {
return SwapTransactionState.Error.UnknownError
@ -1017,7 +1049,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
val txData = createTransferTransactionUseCase(
amount = amount.value.convertToSdkAmount(currencyToSend),
fee = txFee.fee,
fee = requireNotNull(txFee).fee,
memo = exchangeDataCex.txExtraId,
destination = exchangeDataCex.txTo,
userWalletId = userWalletId,
@ -1087,7 +1119,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
)
}
private suspend fun storeSwapTransaction(
override suspend fun storeSwapTransaction(
currencyToSend: CryptoCurrencyStatus,
currencyToGet: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
@ -1096,9 +1128,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
swapProvider: SwapProvider,
swapDataModel: SwapDataModel,
timestamp: Long,
txExternalUrl: String? = null,
txExternalId: String? = null,
averageDuration: Int? = null,
txExternalUrl: String?,
txExternalId: String?,
averageDuration: Int?,
) {
swapTransactionRepository.storeTransaction(
userWalletId = userWalletId,

View file

@ -22,6 +22,7 @@ dependencies {
implementation(projects.common.routing)
implementation(projects.common.ui)
implementation(projects.core.decompose) // For Route supertype
implementation(projects.core.error)
/** Domain modules **/
implementation(projects.domain.models)

View file

@ -45,6 +45,7 @@ import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
@ -115,6 +116,7 @@ internal class SwapModel @Inject constructor(
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase,
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
) : Model() {
private val params = paramsContainer.require<SwapComponent.Params>()
@ -555,6 +557,7 @@ internal class SwapModel @Inject constructor(
selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
isReverseSwapPossible = isReverseSwapPossible(),
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
hideFee = tangemPayInput?.isWithdrawal == true,
)
if (uiState.notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }) {
analyticsEventHandler.send(
@ -702,7 +705,7 @@ internal class SwapModel @Inject constructor(
val fromCurrency = requireNotNull(dataState.fromCryptoCurrency)
val fee = dataState.selectedFee
if (fee == null) {
if (fee == null && tangemPayInput?.isWithdrawal != true) {
makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
return
}
@ -719,13 +722,18 @@ internal class SwapModel @Inject constructor(
includeFeeInAmount = lastLoadedQuotesState.preparedSwapConfigState.includeFeeInAmount,
fee = fee,
expressOperationType = ExpressOperationType.SWAP,
isTangemPayWithdrawal = tangemPayInput?.isWithdrawal == true,
)
}.onSuccess {
when (it) {
}.onSuccess { swapTransactionState ->
when (swapTransactionState) {
is SwapTransactionState.TxSent -> {
if (fee == null) {
makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
return@onSuccess
}
sendSuccessSwapEvent(fromCurrency.currency, fee.feeType)
val url = getExplorerTransactionUrlUseCase(
txHash = it.txHash,
txHash = swapTransactionState.txHash,
networkId = fromCurrency.currency.network.id,
).getOrElse {
Timber.i("tx hash explore not supported")
@ -735,11 +743,11 @@ internal class SwapModel @Inject constructor(
updateWalletBalance()
uiState = stateBuilder.createSuccessState(
uiState = uiState,
swapTransactionState = it,
swapTransactionState = swapTransactionState,
dataState = dataState,
txUrl = url,
onExploreClick = {
if (it.txHash.isNotEmpty()) {
if (swapTransactionState.txHash.isNotEmpty()) {
urlOpener.openUrl(url)
}
analyticsEventHandler.send(
@ -747,7 +755,7 @@ internal class SwapModel @Inject constructor(
)
},
onStatusClick = {
val txExternalUrl = it.txExternalUrl
val txExternalUrl = swapTransactionState.txExternalUrl
if (!txExternalUrl.isNullOrBlank()) {
urlOpener.openUrl(txExternalUrl)
analyticsEventHandler.send(
@ -771,12 +779,15 @@ internal class SwapModel @Inject constructor(
startLoadingQuotesFromLastState()
uiState = stateBuilder.createErrorTransactionAlert(
uiState = uiState,
error = it,
error = swapTransactionState,
onDismiss = { uiState = stateBuilder.clearAlert(uiState) },
onSupportClick = ::onFailedTxEmailClick,
isReverseSwapPossible = isReverseSwapPossible(),
)
}
is SwapTransactionState.TangemPayWithdrawalData -> {
processTangemPayWithdrawal(swapTransactionState = swapTransactionState)
}
}
}.onFailure {
Timber.e(it)
@ -786,6 +797,40 @@ internal class SwapModel @Inject constructor(
}
}
private suspend fun processTangemPayWithdrawal(swapTransactionState: SwapTransactionState.TangemPayWithdrawalData) {
tangemPayWithdrawUseCase(
userWalletId = userWalletId,
cryptoAmount = swapTransactionState.cryptoAmount,
cryptoCurrencyId = swapTransactionState.cryptoCurrencyId,
receiverCexAddress = swapTransactionState.cexAddress,
)
.onLeft {
startLoadingQuotesFromLastState()
makeDefaultAlert()
}
.onRight {
swapInteractor.storeSwapTransaction(
currencyToSend = swapTransactionState.storeData.currencyToSend,
currencyToGet = swapTransactionState.storeData.currencyToGet,
fromAccount = swapTransactionState.storeData.fromAccount,
toAccount = swapTransactionState.storeData.toAccount,
amount = swapTransactionState.storeData.amount,
swapProvider = swapTransactionState.storeData.swapProvider,
swapDataModel = swapTransactionState.storeData.swapDataModel,
txExternalUrl = "",
timestamp = System.currentTimeMillis(),
txExternalId = swapTransactionState.storeData.txExternalId,
averageDuration = null,
)
uiState = stateBuilder.createTangemPayWithdrawalSuccessState(
uiState = uiState,
swapTransactionState = swapTransactionState,
dataState = dataState,
)
swapRouter.openScreen(SwapNavScreen.Success)
}
}
private fun sendSuccessEvent() {
val provider = dataState.selectedProvider ?: return
val fee = dataState.selectedFee?.feeType ?: return
@ -804,6 +849,7 @@ internal class SwapModel @Inject constructor(
)
}
@Suppress("LongMethod")
private fun givePermissionsToSwap() {
modelScope.launch(dispatchers.main) {
runCatching {
@ -840,8 +886,8 @@ internal class SwapModel @Inject constructor(
spenderAddress = approveDataModel.spenderAddress,
),
)
}.onSuccess {
when (it) {
}.onSuccess { swapTransactionState ->
when (swapTransactionState) {
is SwapTransactionState.TxSent -> {
sendApproveSuccessEvent(fromToken, feeForPermission.feeType, approveType)
updateWalletBalance()
@ -852,7 +898,7 @@ internal class SwapModel @Inject constructor(
is SwapTransactionState.Error -> {
uiState = stateBuilder.createErrorTransactionAlert(
uiState = uiState,
error = it,
error = swapTransactionState,
onDismiss = { uiState = stateBuilder.clearAlert(uiState) },
onSupportClick = ::onFailedTxEmailClick,
isReverseSwapPossible = isReverseSwapPossible(),
@ -865,6 +911,9 @@ internal class SwapModel @Inject constructor(
isReverseSwapPossible = isReverseSwapPossible(),
)
}
is SwapTransactionState.TangemPayWithdrawalData -> {
processTangemPayWithdrawal(swapTransactionState = swapTransactionState)
}
}
}.onFailure { makeDefaultAlert() }
}.onFailure {
@ -1475,7 +1524,17 @@ internal class SwapModel @Inject constructor(
} else {
groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value }
?.providers
}.orEmpty()
}
?.filterForTangemPayWithdrawal()
.orEmpty()
}
private fun List<SwapProvider>.filterForTangemPayWithdrawal(): List<SwapProvider> {
return if (tangemPayInput?.isWithdrawal == true) {
filter { it.type == ExchangeProviderType.CEX }
} else {
this
}
}
private fun Map<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {

View file

@ -92,12 +92,14 @@ internal class SwapNotificationsFactory(
return updatedNotifications.toPersistentList()
}
@Suppress("LongParameterList")
fun getConfirmationStateNotifications(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
selectedFeeType: FeeType,
providerName: String,
hideFee: Boolean,
): ImmutableList<NotificationUM> {
val warnings = buildList {
maybeAddRentExemptionError(quoteModel)
@ -105,7 +107,7 @@ internal class SwapNotificationsFactory(
maybeAddNeedReserveToCreateAccountWarning(quoteModel)
maybeAddPermissionNeededWarning(quoteModel, fromToken, providerName)
maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, hideFee)
maybeAddTransactionInProgressWarning(quoteModel)
}
return warnings.toPersistentList()
@ -284,7 +286,9 @@ internal class SwapNotificationsFactory(
private fun MutableList<NotificationUM>.maybeAddUnableCoverFeeWarning(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
hideFee: Boolean,
) {
if (hideFee) return
val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough ?: return
val needShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.permissionState !is PermissionDataState.PermissionLoading &&

View file

@ -277,6 +277,7 @@ internal class StateBuilder(
selectedFeeType: FeeType,
isReverseSwapPossible: Boolean,
needApplyFCARestrictions: Boolean,
hideFee: Boolean,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
@ -286,8 +287,9 @@ internal class StateBuilder(
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
selectedFeeType = selectedFeeType,
providerName = swapProvider.name,
hideFee = hideFee,
)
val feeState = createFeeState(quoteModel.txFee, selectedFeeType)
val feeState = if (hideFee) FeeItemState.Empty else createFeeState(quoteModel.txFee, selectedFeeType)
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus
val isInsufficientFunds = isInsufficientFundsCondition(quoteModel)
@ -853,6 +855,45 @@ internal class StateBuilder(
)
}
fun createTangemPayWithdrawalSuccessState(
uiState: SwapStateHolder,
swapTransactionState: SwapTransactionState.TangemPayWithdrawalData,
dataState: SwapProcessDataState,
): SwapStateHolder {
val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency)
val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency)
val fromAmount = swapTransactionState.fromAmountValue ?: BigDecimal.ZERO
val toAmount = swapTransactionState.toAmountValue ?: BigDecimal.ZERO
val providerState = uiState.providerState as ProviderState.Content
val fromFiatAmount = getFormattedFiatAmount(fromCryptoCurrency.value.fiatRate?.multiply(fromAmount))
val toFiatAmount = getFormattedFiatAmount(toCryptoCurrency.value.fiatRate?.multiply(toAmount))
val shouldShowStatus = providerState.type == ExchangeProviderType.CEX.providerName
return uiState.copy(
successState = SwapSuccessStateHolder(
timestamp = System.currentTimeMillis(),
txUrl = "",
providerName = stringReference(providerState.name),
providerType = stringReference(providerState.type),
showStatusButton = shouldShowStatus,
providerIcon = providerState.iconUrl,
rate = providerState.subtitle,
fee = TextReference.EMPTY,
fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount),
toTitle = getToCardAccountTitle(toAccount = dataState.toAccount),
fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()),
toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()),
fromTokenFiatAmount = stringReference(fromFiatAmount),
toTokenFiatAmount = stringReference(toFiatAmount),
fromTokenIconState = iconStateConverter.convert(fromCryptoCurrency),
toTokenIconState = iconStateConverter.convert(toCryptoCurrency),
onExploreButtonClick = {},
onStatusButtonClick = {},
),
)
}
fun createErrorTransactionAlert(
uiState: SwapStateHolder,
error: SwapTransactionState.Error,