Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-30 19:53:10 +05:00
commit 0b86264bbf
20 changed files with 285 additions and 131 deletions

View file

@ -46,6 +46,7 @@ internal sealed class SwapAmountUM {
// selected swap route
val swapRateType: ExpressRateType,
val swapRateMode: SwapRateMode,
val priceImpact: PriceImpact?,
// swap models
val swapCurrencies: SwapCurrencies,
@ -79,7 +80,6 @@ sealed class SwapAmountFieldUM {
data class Content(
override val amountType: SwapAmountType,
override val amountField: AmountState,
val priceImpact: TextReference?,
val title: TextReference,
val subtitleLeft: TextReference,
val subtitleRight: TextReference,
@ -90,9 +90,33 @@ sealed class SwapAmountFieldUM {
}
@Immutable
sealed class PriceImpactUM {
data class PriceImpact(
val value: TextReference,
val amountSignificance: AmountSignificance,
val type: Type,
) {
data object Empty : PriceImpactUM()
enum class Type {
NONE, LOW, MEDIUM, HIGH
}
data class Value(val value: Float) : PriceImpactUM()
enum class AmountSignificance {
LOW, MEDIUM, HIGH
}
fun shouldDisableButton(): Boolean {
return type == Type.HIGH && amountSignificance == AmountSignificance.HIGH
}
fun shouldShowWarning(): Boolean {
return type.ordinal > Type.LOW.ordinal || amountSignificance.ordinal > AmountSignificance.LOW.ordinal
}
companion object {
val Empty = PriceImpact(
value = TextReference.EMPTY,
amountSignificance = AmountSignificance.LOW,
type = Type.NONE,
)
}
}

View file

@ -21,13 +21,14 @@ import com.tangem.datasource.local.swap.SwapBestRateAnimationStore
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.notifications.ShouldShowNotificationUseCase
import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase
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.express.models.ExpressRateType
import com.tangem.domain.swap.models.*
import com.tangem.domain.swap.models.SwapDirection.Companion.withSwapDirection
import com.tangem.domain.swap.usecase.GetSwapQuoteUseCase
@ -58,13 +59,13 @@ import com.tangem.utils.coroutines.PeriodicTask
import com.tangem.utils.coroutines.SingleTaskScheduler
import com.tangem.utils.extensions.orZero
import com.tangem.utils.isNullOrZero
import com.tangem.utils.logging.TangemLogger
import com.tangem.utils.transformer.update
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal
import java.util.Locale
import javax.inject.Inject
@ -93,6 +94,7 @@ internal class SwapAmountModel @Inject constructor(
private val shouldShowNotificationUseCase: ShouldShowNotificationUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val getWalletsUseCase: GetWalletsUseCase,
private val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase,
) : Model(), SwapAmountClickIntents, SwapChooseProviderComponent.ModelCallback {
private val params: SwapAmountComponentParams = paramsContainer.require()
@ -107,6 +109,9 @@ internal class SwapAmountModel @Inject constructor(
private var secondaryMaximumAmountBoundary: EnterAmountBoundary? = null
private var secondaryMinimumAmountBoundary: EnterAmountBoundary? = null
private var primaryFiatRateUSD: BigDecimal? = null
private var secondaryFiatRateUSD: BigDecimal? = null
private var userCountry: UserCountry = UserCountry.Other(Locale.getDefault().country)
val bottomSheetNavigation: SlotNavigation<SwapChooseProviderConfig> = SlotNavigation()
val rateInfoNavigation: SlotNavigation<ExpressRateType> = SlotNavigation()
@ -177,6 +182,8 @@ internal class SwapAmountModel @Inject constructor(
isBalanceHidden = params.isBalanceHidingFlow.value,
primaryMaximumAmountBoundary = primaryMaximumAmountBoundary,
primaryMinimumAmountBoundary = primaryMinimumAmountBoundary,
primaryFiatRateUSD = primaryFiatRateUSD,
secondaryFiatRateUSD = secondaryFiatRateUSD,
),
)
}
@ -218,7 +225,7 @@ internal class SwapAmountModel @Inject constructor(
val selectedProvider = amountUM.selectedQuote.provider ?: return
swapAmountAlertFactory.priceImpactAlert(
hasPriceImpact = (amountUM.secondaryAmount as? SwapAmountFieldUM.Content)?.priceImpact != null,
hasPriceImpact = amountUM.priceImpact != null,
currencySymbol = amountUM.primaryCryptoCurrencyStatus.currency.symbol,
provider = selectedProvider,
)
@ -560,6 +567,8 @@ internal class SwapAmountModel @Inject constructor(
val secondaryStatus = secondaryCurrency?.currencyStatus
val primaryStatus = (uiState.value as? SwapAmountUM.Content)?.primaryCryptoCurrencyStatus
if (secondaryStatus != null && primaryStatus != null) {
val rawId = secondaryStatus.currency.id.rawCurrencyId
secondaryFiatRateUSD = rawId?.let { id -> getCurrencyUSDQuoteUseCase(id) }
initCurrencies(primaryStatus, secondaryStatus)
val isOnlyOneWallet = getWalletsUseCase.invokeSync().size == 1
uiState.transformerUpdate(
@ -611,7 +620,13 @@ internal class SwapAmountModel @Inject constructor(
)
primaryMaximumAmountBoundary = MaxEnterAmountConverter().convert(primaryStatus)
val primaryRawId = primaryStatus.currency.id.rawCurrencyId
primaryFiatRateUSD = primaryRawId?.let { id -> getCurrencyUSDQuoteUseCase(id) }
if (secondaryStatus != null) {
val secondaryRawId = secondaryStatus.currency.id.rawCurrencyId
secondaryFiatRateUSD = secondaryRawId?.let { id -> getCurrencyUSDQuoteUseCase(id) }
secondaryMinimumAmountBoundary = EnterAmountBoundary(
amount = getMinimumTransactionAmountSyncUseCase
.invoke(
@ -727,6 +742,8 @@ internal class SwapAmountModel @Inject constructor(
isBalanceHidden = params.isBalanceHidingFlow.value,
primaryMaximumAmountBoundary = primaryMaximumAmountBoundary,
primaryMinimumAmountBoundary = primaryMinimumAmountBoundary,
primaryFiatRateUSD = primaryFiatRateUSD,
secondaryFiatRateUSD = secondaryFiatRateUSD,
),
)
feeSelectorReloadTrigger.triggerUpdate()

View file

@ -1,16 +1,17 @@
package com.tangem.features.swap.v2.impl.amount.model
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.utils.extensions.isZero
import com.tangem.utils.isNullOrZero
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.extensions.orZero
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.min
@ -20,31 +21,74 @@ internal object SwapAmountQuoteUtils {
private const val MAX_DECIMALS = 8
private const val MIN_DECIMALS = 2
fun calculatePriceImpact(
fromTokenAmount: BigDecimal,
toTokenAmount: BigDecimal,
private val PRICE_IMPACT_AMOUNT_MIN_THRESHOLD = 25.toBigDecimal() // in USD
private val PRICE_IMPACT_AMOUNT_MAX_THRESHOLD = 5000.toBigDecimal() // in USD
private val PRICE_IMPACT_LOW_THRESHOLD = 0.1.toBigDecimal() // 10%
private val PRICE_IMPACT_HIGH_THRESHOLD = 0.5.toBigDecimal() // 50%
@Suppress("ComplexMethod", "LongParameterList")
internal fun calculatePriceImpact(
quoteContent: SwapQuoteUM.Content?,
swapDirection: SwapDirection,
primaryFiatRateUSD: BigDecimal?,
secondaryFiatRateUSD: BigDecimal?,
primaryCryptoCurrencyStatus: CryptoCurrencyStatus,
secondaryCryptoCurrencyStatus: CryptoCurrencyStatus,
): TextReference? {
secondaryCryptoCurrencyStatus: CryptoCurrencyStatus?,
): PriceImpact? {
if (quoteContent == null) return null
val fromAmount = quoteContent.fromAmount ?: return null
val toAmount = quoteContent.toAmount
val (fromRate, toRate) = if (swapDirection == SwapDirection.Direct) {
primaryCryptoCurrencyStatus.value.fiatRate to secondaryCryptoCurrencyStatus.value.fiatRate
primaryCryptoCurrencyStatus.value.fiatRate to secondaryCryptoCurrencyStatus?.value?.fiatRate
} else {
secondaryCryptoCurrencyStatus.value.fiatRate to primaryCryptoCurrencyStatus.value.fiatRate
secondaryCryptoCurrencyStatus?.value?.fiatRate to primaryCryptoCurrencyStatus.value.fiatRate
}
val isRatesNull = fromRate.isNullOrZero() || toRate.isNullOrZero()
val isAmountNull = fromTokenAmount.isZero() || toTokenAmount.isZero()
if (isRatesNull || isAmountNull) return null
val fromTokenFiatValue = fromTokenAmount.multiply(fromRate)
val toTokenFiatValue = toTokenAmount.multiply(toRate)
val value = BigDecimal.ONE - toTokenFiatValue.divide(fromTokenFiatValue, 2, RoundingMode.HALF_UP)
return stringReference("$(-${value.format { percent(withoutSign = false) }})").takeIf {
value > 0.1.toBigDecimal()
val fromRateUsd = if (swapDirection == SwapDirection.Direct) {
primaryFiatRateUSD
} else {
secondaryFiatRateUSD
}
val fromTokenFiatValue = fromRate?.let { fromAmount.multiply(fromRate).orZero() }
val toTokenFiatValue = toRate?.let { toAmount.multiply(toRate) }
val isFromNotZero = fromTokenFiatValue != null && fromTokenFiatValue != BigDecimal.ZERO
val isToNotZero = toTokenFiatValue != null && toTokenFiatValue != BigDecimal.ZERO
val value = if (isFromNotZero && isToNotZero) {
BigDecimal.ONE - toTokenFiatValue.divide(fromTokenFiatValue, 2, RoundingMode.HALF_UP)
} else {
null
}
val fromAmountUSD = fromAmount.multiply(fromRateUsd.orZero())
val type = when {
value == null -> PriceImpact.Type.NONE
value < PRICE_IMPACT_LOW_THRESHOLD -> PriceImpact.Type.LOW
value in PRICE_IMPACT_LOW_THRESHOLD..PRICE_IMPACT_HIGH_THRESHOLD -> PriceImpact.Type.MEDIUM
else -> PriceImpact.Type.HIGH
}
val amountSignificance = when {
fromAmountUSD <= PRICE_IMPACT_AMOUNT_MIN_THRESHOLD -> PriceImpact.AmountSignificance.LOW
fromAmountUSD > PRICE_IMPACT_AMOUNT_MAX_THRESHOLD -> PriceImpact.AmountSignificance.HIGH
else -> PriceImpact.AmountSignificance.MEDIUM
}
return PriceImpact(
value = stringReference("(${StringsSigns.MINUS}${value.format { percent() }})"),
amountSignificance = amountSignificance,
type = type,
)
}
fun isHighPriceImpact(amountUM: SwapAmountUM): Boolean {
val priceImpact = (amountUM as? SwapAmountUM.Content)?.priceImpact ?: return false
return priceImpact.shouldDisableButton()
}
fun calculateRate(fromAmount: BigDecimal, toAmount: BigDecimal, toAmountDecimals: Int): BigDecimal {

View file

@ -58,7 +58,6 @@ internal class SwapAmountFieldConverter(
subtitleEllipsisLeft = subtitles.subtitleEllipsisLeft,
subtitleRight = subtitles.subtitleRight,
subtitleEllipsisRight = subtitles.subtitleEllipsisRight,
priceImpact = null,
isClickEnabled = true,
amountField = AmountStateConverter(
clickIntents = clickIntents,

View file

@ -64,6 +64,7 @@ internal class SwapAmountPrimaryReadyStateTransformer(
appCurrency = appCurrency,
isShowBestRateAnimation = isShowBestRateAnimation,
isShowFCAWarning = false,
priceImpact = null,
swapRateMode = (prevState as? SwapAmountUM.Content)?.swapRateMode ?: SwapRateMode.FLOAT_ONLY,
)
}

View file

@ -80,6 +80,7 @@ internal class SwapAmountSecondaryReadyStateTransformer(
appCurrency = appCurrency,
isShowBestRateAnimation = isShowBestRateAnimation,
isShowFCAWarning = false,
priceImpact = null,
swapRateMode = swapRateMode,
)
}

View file

@ -7,17 +7,18 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.model.SwapAmountQuoteUtils.calculatePriceImpact
import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountErrorConverter
import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountUpdateSubtitleConverter
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.features.swap.v2.impl.common.isRestrictedByFCA
import com.tangem.utils.extensions.orZero
import com.tangem.utils.transformer.Transformer
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class SwapAmountSelectQuoteTransformer(
private val quoteUM: SwapQuoteUM,
private val secondaryMaximumAmountBoundary: EnterAmountBoundary?,
@ -26,35 +27,64 @@ internal class SwapAmountSelectQuoteTransformer(
private val isBalanceHidden: Boolean,
private val primaryMaximumAmountBoundary: EnterAmountBoundary? = null,
private val primaryMinimumAmountBoundary: EnterAmountBoundary? = null,
private val primaryFiatRateUSD: BigDecimal?,
private val secondaryFiatRateUSD: BigDecimal?,
) : Transformer<SwapAmountUM> {
@Suppress("CyclomaticComplexMethod", "LongMethod")
override fun transform(prevState: SwapAmountUM): SwapAmountUM {
if (prevState !is SwapAmountUM.Content) return prevState
val isPrimarySelected = prevState.selectedAmountType == SwapAmountType.From
val isSecondarySelected = prevState.selectedAmountType == SwapAmountType.To
val primaryProviderErrorConverter = SwapAmountErrorConverter(
cryptoCurrency = prevState.primaryCryptoCurrencyStatus.currency,
)
val secondaryProviderErrorConverter = prevState.secondaryCryptoCurrencyStatus?.let {
SwapAmountErrorConverter(cryptoCurrency = it.currency)
}
val quoteContent = quoteUM as? SwapQuoteUM.Content
val fromAmount = quoteContent?.fromAmount
val toAmount = quoteContent?.toAmount
val primarySwapAmountField = prevState.primaryAmount as? SwapAmountFieldUM.Content
val secondarySwapAmountField = prevState.secondaryAmount as? SwapAmountFieldUM.Content
val subtitleConverter = SwapAmountUpdateSubtitleConverter(
selectedAmountType = prevState.selectedAmountType,
isBalanceHidden = isBalanceHidden,
)
val newPrimaryAmount = when {
val newPrimaryAmount = getPrimaryAmount(
prevState = prevState,
quoteContent = quoteContent,
subtitleConverter = subtitleConverter,
)
val newSecondaryAmount = getSecondaryAmount(
prevState = prevState,
quoteContent = quoteContent,
subtitleConverter = subtitleConverter,
)
val priceImpact = calculatePriceImpact(
quoteContent = quoteContent,
swapDirection = prevState.swapDirection,
primaryFiatRateUSD = primaryFiatRateUSD,
secondaryFiatRateUSD = secondaryFiatRateUSD,
primaryCryptoCurrencyStatus = prevState.primaryCryptoCurrencyStatus,
secondaryCryptoCurrencyStatus = prevState.secondaryCryptoCurrencyStatus,
)
return prevState.copy(
isPrimaryButtonEnabled = quoteUM is SwapQuoteUM.Content,
selectedQuote = quoteUM,
isShowFCAWarning = isNeedApplyFCARestrictions && quoteUM.provider?.isRestrictedByFCA() == true,
primaryAmount = newPrimaryAmount,
priceImpact = priceImpact,
secondaryAmount = newSecondaryAmount,
)
}
private fun getPrimaryAmount(
prevState: SwapAmountUM.Content,
quoteContent: SwapQuoteUM.Content?,
subtitleConverter: SwapAmountUpdateSubtitleConverter,
): SwapAmountFieldUM {
val isPrimarySelected = prevState.selectedAmountType == SwapAmountType.From
val primarySwapAmountField = prevState.primaryAmount as? SwapAmountFieldUM.Content
val primaryProviderErrorConverter = SwapAmountErrorConverter(
cryptoCurrency = prevState.primaryCryptoCurrencyStatus.currency,
)
val fromAmount = quoteContent?.fromAmount
return when {
fromAmount != null && primaryMaximumAmountBoundary != null -> {
primarySwapAmountField?.let { fromField ->
subtitleConverter.updateSubtitles(
@ -103,22 +133,26 @@ internal class SwapAmountSelectQuoteTransformer(
}
else -> prevState.primaryAmount
}
}
val newSecondaryAmount = if (
@Suppress("CyclomaticComplexMethod")
private fun getSecondaryAmount(
prevState: SwapAmountUM.Content,
quoteContent: SwapQuoteUM.Content?,
subtitleConverter: SwapAmountUpdateSubtitleConverter,
): SwapAmountFieldUM {
val isSecondarySelected = prevState.selectedAmountType == SwapAmountType.To
val secondaryProviderErrorConverter = prevState.secondaryCryptoCurrencyStatus?.let {
SwapAmountErrorConverter(cryptoCurrency = it.currency)
}
val fromAmount = quoteContent?.fromAmount
val toAmount = quoteContent?.toAmount
val secondarySwapAmountField = prevState.secondaryAmount as? SwapAmountFieldUM.Content
return if (
prevState.secondaryCryptoCurrencyStatus != null &&
secondaryMaximumAmountBoundary != null &&
secondarySwapAmountField != null
) {
val fromAmountForPriceImpact = fromAmount
?: (prevState.primaryAmount.amountField as? AmountState.Data)
?.amountTextField?.cryptoAmount?.value.orZero()
val priceImpact = calculatePriceImpact(
swapDirection = prevState.swapDirection,
fromTokenAmount = fromAmountForPriceImpact,
toTokenAmount = toAmount.orZero(),
primaryCryptoCurrencyStatus = prevState.primaryCryptoCurrencyStatus,
secondaryCryptoCurrencyStatus = prevState.secondaryCryptoCurrencyStatus,
)
val isAmountEmpty = toAmount == null
val secondaryAmountError = if (isSecondarySelected) {
(quoteUM as? SwapQuoteUM.Error)?.expressError
@ -166,19 +200,10 @@ internal class SwapAmountSelectQuoteTransformer(
isAmountEmpty = isAmountEmpty,
displayAmount = toAmount,
).copy(
priceImpact = priceImpact,
amountField = secondaryAmountFieldWithError,
)
} else {
prevState.secondaryAmount
}
return prevState.copy(
isPrimaryButtonEnabled = quoteUM is SwapQuoteUM.Content,
selectedQuote = quoteUM,
isShowFCAWarning = isNeedApplyFCARestrictions && quoteUM.provider?.isRestrictedByFCA() == true,
primaryAmount = newPrimaryAmount,
secondaryAmount = newSecondaryAmount,
)
}
}

View file

@ -18,6 +18,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class SwapAmountSetQuotesTransformer(
private val quotes: List<SwapQuoteUM>,
private val secondaryMaximumAmountBoundary: EnterAmountBoundary?,
@ -27,6 +28,8 @@ internal class SwapAmountSetQuotesTransformer(
private val isBalanceHidden: Boolean,
private val primaryMaximumAmountBoundary: EnterAmountBoundary? = null,
private val primaryMinimumAmountBoundary: EnterAmountBoundary? = null,
private val primaryFiatRateUSD: BigDecimal?,
private val secondaryFiatRateUSD: BigDecimal?,
) : Transformer<SwapAmountUM> {
override fun transform(prevState: SwapAmountUM): SwapAmountUM {
@ -53,6 +56,8 @@ internal class SwapAmountSetQuotesTransformer(
val updatedState = SwapAmountSelectQuoteTransformer(
quoteUM = selectedQuote,
primaryFiatRateUSD = primaryFiatRateUSD,
secondaryFiatRateUSD = secondaryFiatRateUSD,
secondaryMaximumAmountBoundary = secondaryMaximumAmountBoundary,
secondaryMinimumAmountBoundary = secondaryMinimumAmountBoundary,
isNeedApplyFCARestrictions = isNeedApplyFcaRestrictions &&

View file

@ -37,9 +37,9 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.ui.preview.SwapAmountContentPreview
import com.tangem.features.swap.v2.impl.chooseprovider.ui.SwapChooseProviderContent
@ -86,7 +86,7 @@ internal fun SwapAmountBlockContent(
val quoteContent = amountUM.selectedQuote as? SwapQuoteUM.Content
val isBestRate = quoteContent?.diffPercent is SwapQuoteUM.Content.DifferencePercent.Best
SwapChooseProviderContent(
isBestRate = isBestRate,
isBestRate = isBestRate && amountUM.priceImpact?.shouldShowWarning() != true,
isSingleProvider = quoteContent?.isSingleProvider == true,
showBestRateAnimation = amountUM.isShowBestRateAnimation,
expressProvider = amountUM.selectedQuote.provider,
@ -119,13 +119,6 @@ private fun ConstraintLayoutScope.SwapAmountBlock(
start.linkTo(parent.start)
end.linkTo(parent.end)
},
extraContent = {
SwapPriceImpact(
amountFieldUM = amountUM.primaryAmount,
selectedAmountType = amountUM.selectedAmountType,
onInfoClick = onInfoClick,
)
},
)
AmountBlockV2(
amountState = (amountUM.secondaryAmount.amountField as? AmountState.Data)?.copy(
@ -140,35 +133,33 @@ private fun ConstraintLayoutScope.SwapAmountBlock(
end.linkTo(parent.end)
},
extraContent = {
SwapPriceImpact(
amountFieldUM = amountUM.secondaryAmount,
selectedAmountType = amountUM.selectedAmountType,
onInfoClick = onInfoClick,
)
if (amountUM.secondaryAmount.amountType == SwapAmountType.To) {
SwapPriceImpact(
priceImpact = amountUM.priceImpact,
onInfoClick = onInfoClick,
)
}
},
)
}
@Composable
private fun SwapPriceImpact(
amountFieldUM: SwapAmountFieldUM,
selectedAmountType: SwapAmountType,
onInfoClick: () -> Unit,
) {
if (amountFieldUM.amountType == selectedAmountType) return
val priceImpact = (amountFieldUM as? SwapAmountFieldUM.Content)?.priceImpact
val iconColor = if (priceImpact != null) {
TangemTheme.colors.icon.attention
} else {
TangemTheme.colors.icon.informative
private fun SwapPriceImpact(priceImpact: PriceImpact?, onInfoClick: () -> Unit) {
val iconColor = when (priceImpact?.type) {
PriceImpact.Type.MEDIUM -> TangemTheme.colors.icon.attention
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
else -> TangemTheme.colors.icon.informative
}
if (priceImpact != null) {
if (priceImpact != null && priceImpact.type.ordinal > PriceImpact.Type.LOW.ordinal) {
Text(
text = priceImpact.resolveReference(),
text = priceImpact.value.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.attention,
color = when (priceImpact.type) {
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
else -> TangemTheme.colors.text.tertiary
},
)
}
Icon(

View file

@ -12,11 +12,11 @@ import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapCurrencies
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.domain.swap.models.SwapRateMode
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.utils.StringsSigns
@ -95,6 +95,7 @@ internal data object SwapAmountContentPreview {
appCurrency = AppCurrency.Default,
isShowBestRateAnimation = false,
isShowFCAWarning = false,
priceImpact = null,
swapRateMode = SwapRateMode.FLOAT_ONLY,
)
@ -105,7 +106,6 @@ internal data object SwapAmountContentPreview {
title = stringReference("Tether"),
subtitleLeft = stringReference("11 101,123123456 BTC"),
subtitleRight = stringReference(" ${StringsSigns.DOT} 1 212,12 $"),
priceImpact = null,
isClickEnabled = false,
subtitleEllipsisLeft = TextEllipsis.OffsetEnd(3),
subtitleEllipsisRight = TextEllipsis.OffsetEnd(1),
@ -116,7 +116,6 @@ internal data object SwapAmountContentPreview {
accountTitleUM = AccountTitleUM.Text(stringReference("Amount to receive")),
),
title = stringReference("Shiba Inu"),
priceImpact = stringReference("(-10%)"),
subtitleLeft = TextReference.EMPTY,
subtitleRight = TextReference.EMPTY,
isClickEnabled = false,
@ -135,6 +134,7 @@ internal data object SwapAmountContentPreview {
isPrimaryButtonEnabled = true,
isShowBestRateAnimation = false,
isShowFCAWarning = true,
priceImpact = null,
swapRateMode = SwapRateMode.FLOAT_AND_FIXED,
)

View file

@ -6,6 +6,7 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import java.math.BigDecimal
@ -24,4 +25,5 @@ internal data class ConfirmData(
val quote: SwapQuoteUM?,
val rateType: ExpressRateType?,
val amountType: SwapAmountType,
val priceImpact: PriceImpact?,
)

View file

@ -9,6 +9,7 @@ import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact
import java.math.BigDecimal
import com.tangem.features.swap.v2.impl.notifications.model.SwapNotificationsModel
import com.tangem.features.swap.v2.impl.notifications.ui.swapNotifications
@ -49,6 +50,7 @@ internal class SwapNotificationsComponent(
val userWalletId: UserWalletId? = null,
val enteredFromAmount: BigDecimal? = null,
val fromCryptoCurrencyStatus: CryptoCurrencyStatus? = null,
val priceImpact: PriceImpact? = null,
)
}
}

View file

@ -82,5 +82,16 @@ internal object SwapNotificationUM {
onClick = onConfirmClick,
),
)
data object HighPriceImpact : Warning(
title = resourceReference(R.string.swapping_high_price_impact_title),
subtitle = resourceReference(R.string.swapping_high_price_impact_text),
)
data object TradeTooHigh : Warning(
title = resourceReference(R.string.swapping_trade_too_large_title),
subtitle = resourceReference(R.string.swapping_trade_too_large_text),
iconResId = R.drawable.ic_alert_circle_24,
)
}
}

View file

@ -11,13 +11,13 @@ import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger
import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact
import com.tangem.features.swap.v2.impl.notifications.DefaultSwapNotificationsUpdateTrigger
import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent
import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent.Params.SwapNotificationData
import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsUpdateListener
import com.tangem.features.swap.v2.impl.notifications.entity.SwapNotificationUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import java.math.BigDecimal
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
@ModelScoped
@ -68,9 +69,16 @@ internal class SwapNotificationsModel @Inject constructor(
addInsufficientFundsNotification()
addExpressErrorNotification()
addDestinationTagRequiredNotification()
maybeAddPriceImpactNotification()
}
swapNotificationsUpdateTrigger.callbackHasError(notifications.isNotEmpty())
val hasErrorNotification = notifications
.filterNot { notification ->
notification == SwapNotificationUM.Warning.TradeTooHigh ||
notification == SwapNotificationUM.Warning.HighPriceImpact
}
.isNotEmpty()
swapNotificationsUpdateTrigger.callbackHasError(hasErrorNotification)
uiState.value = notifications.toImmutableList()
}
@ -135,4 +143,17 @@ internal class SwapNotificationsModel @Inject constructor(
add(errorNotification)
}
private fun MutableList<NotificationUM>.maybeAddPriceImpactNotification() {
val priceImpact = notificationData.priceImpact ?: return
if (!priceImpact.shouldShowWarning()) return
val notification = when (priceImpact.type) {
PriceImpact.Type.HIGH -> SwapNotificationUM.Warning.TradeTooHigh
PriceImpact.Type.MEDIUM -> SwapNotificationUM.Warning.HighPriceImpact
else -> return
}
add(notification)
}
}

View file

@ -38,6 +38,7 @@ internal fun LazyListScope.swapNotifications(
else -> TangemTheme.colors.button.disabled
},
iconTint = when (item) {
is SwapNotificationUM.Warning.TradeTooHigh -> TangemTheme.colors.icon.warning
is SwapNotificationUM.Error,
is NotificationUM.Error.TokenExceedsBalance,
is NotificationUM.Warning,

View file

@ -139,6 +139,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
userWalletId = params.userWallet.walletId,
enteredFromAmount = model.confirmData.enteredFromAmount,
fromCryptoCurrencyStatus = model.confirmData.fromCryptoCurrencyStatus,
priceImpact = model.confirmData.priceImpact,
),
),
)

View file

@ -52,6 +52,7 @@ import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.SwapAmountReduceTrigger
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.model.SwapAmountQuoteUtils
import com.tangem.features.swap.v2.impl.common.ConfirmData
import com.tangem.features.swap.v2.impl.common.SwapAlertFactory
import com.tangem.features.swap.v2.impl.common.SwapUtils.INCREASE_GAS_LIMIT_FOR_CEX
@ -126,7 +127,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
private val feeUMV2
get() = uiState.value.feeSelectorUM as? FeeSelectorUMRedesigned.Content
val secondaryCurrencyStatus: CryptoCurrencyStatus? = amountUM?.secondaryCryptoCurrencyStatus
private val secondaryCurrencyStatus: CryptoCurrencyStatus? = amountUM?.secondaryCryptoCurrencyStatus
val secondaryCurrency: CryptoCurrency = requireNotNull(amountUM?.secondaryCryptoCurrencyStatus?.currency) {
"Crypto currency must not be null"
}
@ -166,6 +167,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
quote = amountUM?.selectedQuote,
rateType = amountUM?.swapRateType,
amountType = amountUM?.selectedAmountType ?: SwapAmountType.From,
priceImpact = amountUM?.priceImpact,
)
}
@ -444,6 +446,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
userWalletId = params.userWallet.walletId,
enteredFromAmount = confirmData.enteredFromAmount,
fromCryptoCurrencyStatus = confirmData.fromCryptoCurrencyStatus,
priceImpact = confirmData.priceImpact,
),
)
uiState.transformerUpdate(
@ -461,9 +464,11 @@ internal class SendWithSwapConfirmModel @Inject constructor(
uiState.update { state ->
val feeUM = state.feeSelectorUM as? FeeSelectorUM.Content
val isTransactionInProcess = (state.confirmUM as? ConfirmUM.Content)?.isTransactionInProcess == true
val isHighPriceImpact = SwapAmountQuoteUtils.isHighPriceImpact(state.amountUM)
val isPrimaryButtonEnabled = !hasError && feeUM != null && !isTransactionInProcess && !isHighPriceImpact
state.copy(
confirmUM = (state.confirmUM as? ConfirmUM.Content)?.copy(
isPrimaryButtonEnabled = !hasError && feeUM != null && !isTransactionInProcess,
isPrimaryButtonEnabled = isPrimaryButtonEnabled,
) ?: state.confirmUM,
)
}

View file

@ -68,7 +68,6 @@ import com.tangem.feature.swap.domain.models.toStringWithRightOffset
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.extensions.orZero
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -2457,7 +2456,8 @@ internal class SwapInteractorImpl @AssistedInject constructor(
if (fromQuoteStatus == null) return PriceImpact.Empty
val fromTokenFiatValue = fromTokenAmount.multiply(fromQuoteStatus.fiatRate)
val toTokenFiatValue = toTokenAmount.multiply(toRate.orZero())
val toTokenFiatValue = toRate?.let { toTokenAmount.multiply(toRate) } ?: return PriceImpact.Empty
val value = BigDecimal.ONE - toTokenFiatValue.divide(fromTokenFiatValue, 2, RoundingMode.HALF_UP)
val fromAmountUSD = if (fromQuoteStatus.fiatRateUSD != BigDecimal.ZERO) {
@ -2467,7 +2467,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
}
val amountSignificance = when {
fromAmountUSD < PRICE_IMPACT_AMOUNT_MIN_THRESHOLD -> PriceImpact.AmountSignificance.LOW
fromAmountUSD <= PRICE_IMPACT_AMOUNT_MIN_THRESHOLD -> PriceImpact.AmountSignificance.LOW
fromAmountUSD > PRICE_IMPACT_AMOUNT_MAX_THRESHOLD -> PriceImpact.AmountSignificance.HIGH
else -> PriceImpact.AmountSignificance.MEDIUM
}
@ -2552,7 +2552,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
companion object {
private const val INCREASE_GAS_LIMIT_FOR_DEX = 112 // 12%
private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5%
private val PRICE_IMPACT_AMOUNT_MIN_THRESHOLD = 50.toBigDecimal() // in USD
private val PRICE_IMPACT_AMOUNT_MIN_THRESHOLD = 25.toBigDecimal() // in USD
private val PRICE_IMPACT_AMOUNT_MAX_THRESHOLD = 5000.toBigDecimal() // in USD
private val PRICE_IMPACT_LOW_THRESHOLD = 0.1.toBigDecimal() // 10%
private val PRICE_IMPACT_HIGH_THRESHOLD = 0.5.toBigDecimal() // 50%

View file

@ -66,6 +66,10 @@ data class PriceImpact(
return type == Type.HIGH && amountSignificance == AmountSignificance.HIGH
}
fun shouldShowWarning(): Boolean {
return type.ordinal > Type.LOW.ordinal || amountSignificance.ordinal > AmountSignificance.LOW.ordinal
}
companion object {
val Empty = PriceImpact(
value = BigDecimal.ZERO,

View file

@ -387,26 +387,26 @@ internal class StateBuilder(
.formatToUIRepresentation()
.appendApproximateSign(),
),
amountEquivalent = combinedReference(
getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat),
stringReference(StringsSigns.WHITE_SPACE),
styledStringReference(
priceImpact.value.format {
percent(withoutSign = false)
},
spanStyleReference = {
SpanStyle(
color = when (priceImpact.type) {
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
PriceImpact.Type.LOW,
PriceImpact.Type.NONE,
-> TangemTheme.colors.text.tertiary
},
)
},
),
),
amountEquivalent = if (priceImpact.type.ordinal > PriceImpact.Type.LOW.ordinal) {
combinedReference(
getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat),
stringReference(StringsSigns.WHITE_SPACE),
styledStringReference(
value = "(${StringsSigns.MINUS}${priceImpact.value.format { percent() }})",
spanStyleReference = {
SpanStyle(
color = when (priceImpact.type) {
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
else -> TangemTheme.colors.text.tertiary
},
)
},
),
)
} else {
getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat)
},
token = toCurrencyStatus,
tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl,
coinId = toCurrencyStatus.currency.network.backendId,
@ -436,7 +436,7 @@ internal class StateBuilder(
),
changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible),
providerState = swapProvider.convertToContentClickableProviderState(
isBestRate = bestRatedProviderId == swapProvider.providerId,
isBestRate = bestRatedProviderId == swapProvider.providerId && !priceImpact.shouldShowWarning(),
fromTokenInfo = quoteModel.fromTokenInfo,
toTokenInfo = quoteModel.toTokenInfo,
isNeedBestRateBadge = isNeedBestRateBadge,
@ -444,7 +444,7 @@ internal class StateBuilder(
onProviderClick = actions.onProviderClick,
needApplyFCARestrictions = needApplyFCARestrictions,
),
priceImpact = quoteModel.priceImpact,
priceImpact = priceImpact,
tosState = createTosState(swapProvider),
shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrencyStatus.currency),
)