Updated on 2026-08-14

This commit is contained in:
Tangem 2024-03-12 11:51:19 +01:00
parent 675d13ab6f
commit 742795d2d9
10 changed files with 205 additions and 62 deletions

View file

@ -102,7 +102,7 @@ fun ClickableWarningCard(
* >Figma component</a>
*/
@Composable
fun RefreshableWaringCard(
fun RefreshableWarningCard(
title: String,
description: String,
icon: @Composable (() -> Unit)? = null,
@ -193,7 +193,7 @@ private fun WarningsPreview() {
onClick = {},
)
SpacerH32()
RefreshableWaringCard(
RefreshableWarningCard(
title = "Exchange rate has expired",
description = "To access all the networks, you need to scan the card.",
onClick = {},

View file

@ -7,4 +7,6 @@ sealed class Warning {
data class ExistentialDepositWarning(val existentialDeposit: BigDecimal) : Warning()
data class MinAmountWarning(val dustValue: BigDecimal) : Warning()
data class ReduceAmountWarning(val tezosFeeThreshold: BigDecimal) : Warning()
}

View file

@ -79,8 +79,6 @@ interface SwapInteractor {
*/
fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount
fun isAvailableToSwap(networkId: String): Boolean
fun getSelectedWallet(): UserWallet?
suspend fun selectInitialCurrencyToSwap(

View file

@ -197,7 +197,6 @@ internal class SwapInteractorImpl @Inject constructor(
return repository.getPairs(initialCurrency, currenciesList)
}
@Deprecated("used in old swap mechanism")
override suspend fun givePermissionToSwap(networkId: String, permissionOptions: PermissionOptions): TxState {
val derivationPath = permissionOptions.fromToken.network.derivationPath.value
val dataToSign = if (permissionOptions.approveType == SwapApproveType.UNLIMITED) {
@ -241,7 +240,6 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
@Deprecated("used in old swap mechanism")
override suspend fun findBestQuote(
fromToken: CryptoCurrencyStatus,
toToken: CryptoCurrencyStatus,
@ -376,6 +374,7 @@ internal class SwapInteractorImpl @Inject constructor(
val warnings = mutableListOf<Warning>()
manageExistentialDepositWarning(warnings, userWalletId, amount, fromToken)
manageDustWarning(warnings, feeState, userWalletId, fromTokenStatus, amount)
manageReduceAmountWarning(warnings, fromTokenStatus, amount)
return warnings
}
@ -424,6 +423,17 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
private fun manageReduceAmountWarning(
warnings: MutableList<Warning>,
fromTokenStatus: CryptoCurrencyStatus,
amount: SwapAmount,
) {
val isTezos = fromTokenStatus.currency.network.id.value == Blockchain.Tezos.id
if (isTezos && amount.value == fromTokenStatus.value.amount) {
warnings.add(Warning.ReduceAmountWarning(TEZOS_FEE_THRESHOLD))
}
}
override suspend fun onSwap(
swapProvider: SwapProvider,
swapData: SwapDataModel?,
@ -729,16 +739,10 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
@Deprecated("used in old swap mechanism")
override fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount {
return SwapAmount(token.value.amount ?: BigDecimal.ZERO, token.currency.decimals)
}
@Deprecated("used in old swap mechanism")
override fun isAvailableToSwap(networkId: String): Boolean {
return ONE_INCH_SUPPORTED_NETWORKS.contains(networkId)
}
override suspend fun selectInitialCurrencyToSwap(
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
@ -1575,16 +1579,6 @@ internal class SwapInteractorImpl @Inject constructor(
private const val INCREASE_GAS_LIMIT_BY = 112 // 12%
private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5%
private const val INFINITY_SYMBOL = ""
private val ONE_INCH_SUPPORTED_NETWORKS = listOf(
"ethereum",
"binance-smart-chain",
"polygon-pos",
"optimistic-ethereum",
"arbitrum-one",
"xdai",
"avalanche",
"fantom",
)
private val TEZOS_FEE_THRESHOLD = BigDecimal("0.01")
}
}

View file

@ -67,4 +67,5 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -20,6 +20,7 @@ data class SwapStateHolder(
val alert: SwapWarning.GenericWarning? = null,
val changeCardsButtonState: ChangeCardsButtonState = ChangeCardsButtonState.ENABLED,
val providerState: ProviderState,
val reduceAmountIgnore: Boolean, // ignore warning about reducing XTZ amount by 0.01
val fee: FeeItemState = FeeItemState.Empty,
val permissionState: SwapPermissionState = SwapPermissionState.Empty,
@ -115,6 +116,8 @@ sealed interface SwapWarning {
data class GeneralWarning(val notificationConfig: NotificationConfig) : SwapWarning
data class GeneralInformational(val notificationConfig: NotificationConfig) : SwapWarning
data class TransactionInProgressWarning(val title: TextReference, val description: TextReference) : SwapWarning
data class NeedReserveToCreateAccount(val notificationConfig: NotificationConfig) : SwapWarning
data class ReduceAmount(val notificationConfig: NotificationConfig) : SwapWarning
}
enum class GenericWarningType {

View file

@ -1,6 +1,7 @@
package com.tangem.feature.swap.models
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.TxFee
data class UiActions(
@ -13,6 +14,8 @@ data class UiActions(
val onChangeCardsClicked: () -> Unit,
val onBackClicked: () -> Unit,
val onMaxAmountSelected: () -> Unit,
val onReduceAmount: (SwapAmount) -> Unit,
val onReduceAmountIgnoreClick: () -> Unit,
val openPermissionBottomSheet: () -> Unit,
val onChangeApproveType: (ApproveType) -> Unit,
// region new actions

View file

@ -88,6 +88,7 @@ internal class StateBuilder(
onShowPermissionBottomSheet = actions.openPermissionBottomSheet,
providerState = ProviderState.Empty(),
shouldShowMaxAmount = false,
reduceAmountIgnore = false,
priceImpact = PriceImpact.Empty(),
)
}
@ -213,7 +214,11 @@ internal class StateBuilder(
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
val warnings = getWarningsForSuccessState(quoteModel, fromToken)
val warnings = getWarningsForSuccessState(
quoteModel = quoteModel,
fromToken = fromToken,
ignoreAmountReduce = uiStateHolder.reduceAmountIgnore,
)
val feeState = createFeeState(quoteModel.txFee, selectedFeeType)
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus
@ -311,36 +316,23 @@ internal class StateBuilder(
private fun getWarningsForSuccessState(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
ignoreAmountReduce: Boolean,
): List<SwapWarning> {
val warnings = mutableListOf<SwapWarning>()
addDomainWarnings(quoteModel, warnings)
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
) {
warnings.add(
SwapWarning.PermissionNeeded(
createPermissionNotificationConfig(fromToken.symbol),
),
)
}
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
is IncludeFeeInAmount.Included ->
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(),
),
)
else -> Unit
}
addUnableCoverFeeWarning(quoteModel, fromToken, warnings)
// check isBalanceEnough, but for dex includeFeeInAmount always Excluded
if (!quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
) {
warnings.add(SwapWarning.InsufficientFunds)
}
maybeAddDomainWarnings(quoteModel, warnings, ignoreAmountReduce)
maybeAddNeedReserveToCreateAccountWarning(quoteModel, warnings)
maybeAddPermissionNeededWarning(quoteModel, warnings, fromToken)
maybeAddNetworkFeeCoverageWarning(quoteModel, warnings)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, warnings)
maybeAddInsufficientFundsWarning(quoteModel, warnings)
maybeAddTransactionInProgressWarning(quoteModel, warnings)
return warnings
}
private fun maybeAddTransactionInProgressWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
if (quoteModel.permissionState is PermissionDataState.PermissionLoading) {
warnings.add(
SwapWarning.TransactionInProgressWarning(
@ -361,10 +353,13 @@ internal class StateBuilder(
),
)
}
return warnings
}
private fun addDomainWarnings(quoteModel: SwapState.QuotesLoadedState, warnings: MutableList<SwapWarning>) {
private fun maybeAddDomainWarnings(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
ignoreAmountReduce: Boolean,
) {
quoteModel.warnings.forEach {
when (it) {
is Warning.ExistentialDepositWarning -> {
@ -401,11 +396,84 @@ internal class StateBuilder(
),
)
}
is Warning.ReduceAmountWarning -> {
if (!ignoreAmountReduce) {
warnings.add(
SwapWarning.ReduceAmount(
notificationConfig = createReduceAmountNotificationConfig(
amount = it.tezosFeeThreshold.toPlainString(),
onConfirmClick = {
val fromAmount = quoteModel.fromTokenInfo.tokenAmount
val patchedAmount = fromAmount.copy(
value = fromAmount.value - it.tezosFeeThreshold,
)
actions.onReduceAmount(patchedAmount)
},
onDismissClick = actions.onReduceAmountIgnoreClick,
),
),
)
}
}
}
}
}
private fun addUnableCoverFeeWarning(
private fun maybeAddNeedReserveToCreateAccountWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
if (status is CryptoCurrencyStatus.NoAccount) {
val amount = quoteModel.toTokenInfo.tokenAmount.value
val amountToCreateAccount = status.amountToCreateAccount
if (amount < amountToCreateAccount) {
warnings.add(
SwapWarning.NeedReserveToCreateAccount(
notificationConfig = createActivateAccountNotificationConfig(
status.amountToCreateAccount,
quoteModel.toTokenInfo.cryptoCurrencyStatus.currency.name,
),
),
)
}
}
}
private fun maybeAddPermissionNeededWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
fromToken: CryptoCurrency,
) {
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
) {
warnings.add(
SwapWarning.PermissionNeeded(
createPermissionNotificationConfig(fromToken.symbol),
),
)
}
}
private fun maybeAddNetworkFeeCoverageWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
is IncludeFeeInAmount.Included ->
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(),
),
)
else -> Unit
}
}
private fun maybeAddUnableCoverFeeWarning(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
warnings: MutableList<SwapWarning>,
@ -428,7 +496,29 @@ internal class StateBuilder(
}
}
private fun maybeAddInsufficientFundsWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
// check isBalanceEnough, but for dex includeFeeInAmount always Excluded
if (!quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
) {
warnings.add(SwapWarning.InsufficientFunds)
}
}
private fun getSwapButtonEnabled(quoteModel: SwapState.QuotesLoadedState): Boolean {
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
if (status is CryptoCurrencyStatus.NoAccount) {
val amount = quoteModel.toTokenInfo.tokenAmount.value
val amountToCreateAccount = status.amountToCreateAccount
if (amount < amountToCreateAccount) {
return false
}
}
val preparedSwapConfigState = quoteModel.preparedSwapConfigState
// check has has outgoing transaction
if (preparedSwapConfigState.hasOutgoingTransaction) return false
@ -1212,6 +1302,35 @@ internal class StateBuilder(
)
}
private fun createActivateAccountNotificationConfig(amount: BigDecimal, token: String): NotificationConfig {
return NotificationConfig(
title = resourceReference(
id = R.string.send_notification_invalid_reserve_amount_title,
formatArgs = wrappedList("$amount $token"),
),
subtitle = resourceReference(R.string.send_notification_invalid_reserve_amount_text),
iconResId = R.drawable.img_attention_20,
)
}
private fun createReduceAmountNotificationConfig(
amount: String,
onConfirmClick: () -> Unit,
onDismissClick: () -> Unit,
): NotificationConfig {
return NotificationConfig(
title = resourceReference(R.string.send_notification_high_fee_title),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(amount)),
iconResId = R.drawable.img_attention_20,
buttonsState = NotificationConfig.ButtonsState.PairButtonsConfig(
primaryText = resourceReference(R.string.xtz_withdrawal_message_reduce, wrappedList(amount)),
onPrimaryClick = onConfirmClick,
secondaryText = resourceReference(R.string.xtz_withdrawal_message_ignore),
onSecondaryClick = onDismissClick,
),
)
}
private fun createUnableToCoverFeeNotificationConfig(
fromToken: CryptoCurrency,
feeCurrency: CryptoCurrency?,

View file

@ -339,7 +339,7 @@ private fun SwapWarnings(warnings: List<SwapWarning>) {
is SwapWarning.GenericWarning -> {
val message = warning.message?.resolveReference()
?: stringResource(id = R.string.common_unknown_error)
RefreshableWaringCard(
RefreshableWarningCard(
title = stringResource(id = R.string.common_warning),
description = message,
onClick = warning.onClick,
@ -372,6 +372,16 @@ private fun SwapWarnings(warnings: List<SwapWarning>) {
iconTint = TangemTheme.colors.icon.accent,
)
}
is SwapWarning.NeedReserveToCreateAccount -> {
Notification(
config = warning.notificationConfig,
)
}
is SwapWarning.ReduceAmount -> {
Notification(
config = warning.notificationConfig,
)
}
is SwapWarning.TransactionInProgressWarning -> {
CardWithIcon(
title = warning.title.resolveReference(),
@ -494,6 +504,7 @@ private val state = SwapStateHolder(
providerState = ProviderState.Loading(),
priceImpact = PriceImpact.Empty(),
shouldShowMaxAmount = true,
reduceAmountIgnore = false,
tosState = TosState(
tosLink = LegalState(
title = stringReference("Terms of Use"),

View file

@ -27,6 +27,7 @@ import com.tangem.feature.swap.domain.BlockchainInteractor
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.ExpressException
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.formatToUIRepresentation
import com.tangem.feature.swap.domain.models.ui.*
@ -810,6 +811,10 @@ internal class SwapViewModel @Inject constructor(
}
}
private fun onReduceAmountClicked(newAmount: SwapAmount) {
onAmountChanged(newAmount.formatToUIRepresentation())
}
@Suppress("UnusedPrivateMember")
private fun onAmountSelected(selected: Boolean) {
if (selected) {
@ -872,7 +877,14 @@ internal class SwapViewModel @Inject constructor(
}
onSearchEntered("")
},
onMaxAmountSelected = { onMaxAmountClicked() },
onMaxAmountSelected = ::onMaxAmountClicked,
onReduceAmount = ::onReduceAmountClicked,
onReduceAmountIgnoreClick = {
uiState = uiState.copy(
reduceAmountIgnore = true,
warnings = uiState.warnings.filter { it !is SwapWarning.ReduceAmount },
)
},
openPermissionBottomSheet = {
singleTaskScheduler.cancelTask()
analyticsEventHandler.send(SwapEvents.ButtonGivePermissionClicked)
@ -1151,11 +1163,11 @@ internal class SwapViewModel @Inject constructor(
)
}
companion object {
private const val loggingTag = "SwapViewModel"
private const val INITIAL_AMOUNT = ""
private const val UPDATE_DELAY = 10000L
private const val DEBOUNCE_AMOUNT_DELAY = 1000L
private const val UPDATE_BALANCE_DELAY_MILLIS = 11000L
private companion object {
const val loggingTag = "SwapViewModel"
const val INITIAL_AMOUNT = ""
const val UPDATE_DELAY = 10000L
const val DEBOUNCE_AMOUNT_DELAY = 1000L
const val UPDATE_BALANCE_DELAY_MILLIS = 11000L
}
}