Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-16 17:37:36 +05:00
parent dc9de9265c
commit f0898f2873
18 changed files with 861 additions and 405 deletions

View file

@ -110,7 +110,7 @@ data class PriceImpact(
}
fun shouldShowWarning(): Boolean {
return type.ordinal > Type.LOW.ordinal || amountSignificance.ordinal > AmountSignificance.LOW.ordinal
return type.ordinal > Type.LOW.ordinal && amountSignificance.ordinal > AmountSignificance.LOW.ordinal
}
companion object {

View file

@ -9,4 +9,5 @@ interface SwapFeatureToggles {
val isSwapRateExperienceEnabled: Boolean
val isSwapPredefinedButtonsEnabled: Boolean
val isExpressShareButtonEnabled: Boolean
val isSwapBestDexRateEnabled: Boolean
}

View file

@ -63,6 +63,9 @@ enum class ExchangeProviderType(val providerName: String) {
DEX_BRIDGE("DEX/Bridge"),
;
/** Returns true for DEX-based providers ([DEX] and [DEX_BRIDGE]). */
fun isDex(): Boolean = this == DEX || this == DEX_BRIDGE
companion object {
fun getSwapProviderTypes(): List<ExchangeProviderType> {
return listOf(CEX, DEX, DEX_BRIDGE)

View file

@ -95,7 +95,7 @@ data class PriceImpact(
}
fun shouldShowWarning(): Boolean {
return type.ordinal > Type.LOW.ordinal || amountSignificance.ordinal > AmountSignificance.LOW.ordinal
return type.ordinal > Type.LOW.ordinal && amountSignificance.ordinal > AmountSignificance.LOW.ordinal
}
companion object {

View file

@ -47,4 +47,9 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
get() = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15489_EXPRESS_SHARE_BUTTON_ENABLED,
)
override val isSwapBestDexRateEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15715_SWAP_BEST_DEX_RATE_ENABLED,
) && isSwapIntegratedApproveEnabled
}

View file

@ -0,0 +1,150 @@
package com.tangem.feature.swap.converters
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.model.consideredProvidersStates
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.ProviderState.AdditionalBadge
import com.tangem.utils.isNullOrZero
import java.math.BigDecimal
import java.math.RoundingMode
/**
* Pure provider-level decisions for the swap UI:
* - [findBest] which provider is the "best" among the loaded quotes, and
* - [resolveBadge] which [ProviderState.AdditionalBadge] a provider row should show.
*/
internal object SwapProviderResolver {
private val FCA_RESTRICTED_PROVIDER_IDS = setOf(
"changelly",
"changenow",
"okx-cross-chain",
"okx-on-chain",
"simpleswap",
)
/**
* Picks the best provider among [states].
*
* When [isSwapBestDexRateEnabled] is on and at least one DEX/DEX_BRIDGE provider is present, the
* best-rated DEX provider wins; otherwise the overall best-rated provider is returned (the best
* CEX when no DEX is available). "Best rated" = lowest from/to fiat ratio (most output per unit
* of input). Returns null when [states] is empty.
*
* @param isSwapBestDexRateEnabled whether the Best DEX Rate feature toggle is on.
*/
fun findBest(
states: Map<SwapProvider, SwapState.QuotesLoadedState>,
isSwapBestDexRateEnabled: Boolean,
): SwapProvider? {
if (!isSwapBestDexRateEnabled) return findBestRated(states)
val dexStates = states.filterKeys { it.type.isDex() }
return if (dexStates.isNotEmpty()) {
findBestRated(dexStates)
} else {
findBestRated(states)
}
}
/**
* Resolves the badge for a single provider row.
*
* Priority: FCA restriction permission required recommended best rate none. A best-rate
* badge is shown only when more than one provider is considered, FCA restrictions are not applied,
* and this row's quote carries no price-impact warning. Which best-rate badge it is depends on the
* provider mix (only relevant when [isSwapBestDexRateEnabled] is on):
* - [AdditionalBadge.BestTrade] ("Best rate") always on the overall best-rated provider,
* regardless of its type.
* - [AdditionalBadge.BestDexRate] ("Best DEX rate") only when both CEX and DEX providers are
* present and a CEX is the overall best (so the best DEX is not the overall best); it is then
* shown on the best-rated DEX. When a DEX already is the overall best, or the set is CEX-only /
* DEX-only, no separate "Best DEX rate" badge is shown.
*
* When [isSwapBestDexRateEnabled] is off, only the overall best provider gets [AdditionalBadge.BestTrade]
* (legacy behaviour) and [AdditionalBadge.BestDexRate] is never produced.
*
* @param states all loaded quotes used to find the best providers and to count considered providers.
* @param provider the provider this row represents.
* @param needApplyFCARestrictions whether FCA restrictions apply to the current user.
* @param state this provider's [SwapState]; price-impact and permission are read from it when it
* is a [SwapState.QuotesLoadedState]. Null for error rows (which only resolve to FCA / recommended / none).
* @param isSwapBestDexRateEnabled whether the Best DEX Rate feature toggle is on.
*/
fun resolveBadge(
states: Map<SwapProvider, SwapState.QuotesLoadedState>,
provider: SwapProvider,
needApplyFCARestrictions: Boolean,
state: SwapState? = null,
isSwapBestDexRateEnabled: Boolean,
): AdditionalBadge {
val priceImpact = (state as? SwapState.QuotesLoadedState)?.priceImpact
val permissionState = (state as? SwapState.QuotesLoadedState)?.permissionState
val isNeedBestRateBadge = states.consideredProvidersStates().size > 1
val isBestRateBadgeAllowed = !needApplyFCARestrictions && isNeedBestRateBadge &&
priceImpact != null && !priceImpact.shouldShowWarning()
return when {
needApplyFCARestrictions && provider.isFCARestricted() -> AdditionalBadge.FCAWarningList
permissionState is PermissionDataState.PermissionRequired -> AdditionalBadge.PermissionRequired
provider.isRecommended -> AdditionalBadge.Recommended
isBestRateBadgeAllowed -> resolveBestRateBadge(states, provider, isSwapBestDexRateEnabled)
else -> AdditionalBadge.Empty
}
}
/**
* Picks the best-rate badge for [provider] once it has passed the eligibility gate in [resolveBadge].
* Returns [AdditionalBadge.Empty] when this row is neither the overall best nor the eligible best DEX.
*/
private fun resolveBestRateBadge(
states: Map<SwapProvider, SwapState.QuotesLoadedState>,
provider: SwapProvider,
isSwapBestDexRateEnabled: Boolean,
): AdditionalBadge {
val overallBest = findBestRated(states)
val isOverallBest = provider.providerId == overallBest?.providerId
// Toggle off → legacy behaviour: only the overall best provider gets the "Best rate" badge.
if (!isSwapBestDexRateEnabled) {
return if (isOverallBest) AdditionalBadge.BestTrade else AdditionalBadge.Empty
}
val dexStates = states.filterKeys { it.type.isDex() }
val hasDex = dexStates.isNotEmpty()
val hasCex = states.keys.any { !it.type.isDex() }
val bestDex = findBestRated(dexStates)
val isBestDex = bestDex != null && provider.providerId == bestDex.providerId
// Both types present and a CEX is the overall best (i.e. overall best != best DEX).
val isCexBeatsDex = hasDex && hasCex && overallBest?.providerId != bestDex?.providerId
return when {
isOverallBest -> AdditionalBadge.BestTrade
isCexBeatsDex && isBestDex -> AdditionalBadge.BestDexRate
else -> AdditionalBadge.Empty
}
}
/** Best provider following the default best-rate behaviour over all providers. */
private fun findBestRated(states: Map<SwapProvider, SwapState.QuotesLoadedState>): SwapProvider? {
return states.minByOrNull { entry -> entry.value.rateRatio() }?.key
}
private fun SwapProvider.isFCARestricted(): Boolean = providerId in FCA_RESTRICTED_PROVIDER_IDS
private fun SwapState.QuotesLoadedState.rateRatio(): BigDecimal {
val fromAmountFiat = fromTokenInfo.amountFiat
val toAmountFiat = toTokenInfo.amountFiat
return if (!fromAmountFiat.isNullOrZero() && !toAmountFiat.isNullOrZero()) {
fromAmountFiat.divide(
toAmountFiat,
toTokenInfo.swapCurrencyStatus.currency.decimals,
RoundingMode.HALF_UP,
)
} else {
BigDecimal.ZERO
}
}
}

View file

@ -7,56 +7,36 @@ import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.models.states.PercentDifference
import com.tangem.feature.swap.models.states.ProviderState
/**
* Builds [ProviderState.Content] for the swap provider list / row.
*
* Pure: takes everything it needs as parameters. Designed to be unit-tested in isolation.
*/
internal object SwapProviderStateBuilder {
private val FCA_RESTRICTED_PROVIDER_IDS = setOf(
"changelly",
"changenow",
"okx-cross-chain",
"okx-on-chain",
"simpleswap",
)
/**
* Provider row on the main swap screen shows the exchange rate `1 base rate quote`
* (see [SwapRateFormatter]) and allows the user to open the provider picker.
*/
@Suppress("LongParameterList")
fun buildContentClickable(
provider: SwapProvider,
fromTokenInfo: TokenSwapInfo,
toTokenInfo: TokenSwapInfo,
permissionState: PermissionDataState,
state: SwapState.QuotesLoadedState,
selectionType: ProviderState.SelectionType,
isBestRate: Boolean,
isNeedBestRateBadge: Boolean,
needApplyFCARestrictions: Boolean,
additionalBadge: ProviderState.AdditionalBadge,
onProviderClick: (String) -> Unit,
): ProviderState.Content {
val rateString = SwapRateFormatter.formatRate(
from = fromTokenInfo.swapCurrencyStatus.currency,
to = toTokenInfo.swapCurrencyStatus.currency,
fromAmount = fromTokenInfo.tokenAmount.value,
toAmount = toTokenInfo.tokenAmount.value,
from = state.fromTokenInfo.swapCurrencyStatus.currency,
to = state.toTokenInfo.swapCurrencyStatus.currency,
fromAmount = state.fromTokenInfo.tokenAmount.value,
toAmount = state.toTokenInfo.tokenAmount.value,
)
return provider.toContent(
subtitle = stringReference(rateString),
additionalBadge = resolveBadge(
provider = provider,
needApplyFCARestrictions = needApplyFCARestrictions,
permissionState = permissionState,
isBestRate = isBestRate,
isNeedBestRateBadge = isNeedBestRateBadge,
),
additionalBadge = additionalBadge,
selectionType = selectionType,
percentLowerThenBest = PercentDifference.Empty,
approvalSettings = ProviderState.ApprovalSettings.Empty,
@ -71,30 +51,21 @@ internal object SwapProviderStateBuilder {
@Suppress("LongParameterList")
fun buildContentSelectable(
provider: SwapProvider,
toTokenInfo: TokenSwapInfo,
permissionState: PermissionDataState,
state: SwapState.QuotesLoadedState,
pricesLowerBest: Map<String, Float>,
selectionType: ProviderState.SelectionType,
isBestRate: Boolean = false,
isNeedBestRateBadge: Boolean = false,
needApplyFCARestrictions: Boolean,
additionalBadge: ProviderState.AdditionalBadge,
onProviderClick: (String) -> Unit,
onApprovalSelectClick: (SwapProvider) -> Unit = {},
): ProviderState.Content {
return provider.toContent(
subtitle = buildSelectableSubtitle(toTokenInfo),
additionalBadge = resolveBadge(
provider = provider,
needApplyFCARestrictions = needApplyFCARestrictions,
permissionState = permissionState,
isBestRate = isBestRate,
isNeedBestRateBadge = isNeedBestRateBadge,
),
subtitle = buildSelectableSubtitle(state.toTokenInfo),
additionalBadge = additionalBadge,
selectionType = selectionType,
percentLowerThenBest = pricesLowerBest[provider.providerId]
?.let(PercentDifference::Value)
?: PercentDifference.Value(0f),
approvalSettings = when (permissionState) {
approvalSettings = when (state.permissionState) {
is PermissionDataState.PermissionSettings -> ProviderState.ApprovalSettings.Content(
onApprovalSelectClick = { onApprovalSelectClick(provider) },
)
@ -112,15 +83,12 @@ internal object SwapProviderStateBuilder {
provider: SwapProvider,
alertText: TextReference,
selectionType: ProviderState.SelectionType,
needApplyFCARestrictions: Boolean,
additionalBadge: ProviderState.AdditionalBadge,
onProviderClick: (String) -> Unit,
): ProviderState.Content {
return provider.toContent(
subtitle = alertText,
additionalBadge = resolveBadge(
provider = provider,
needApplyFCARestrictions = needApplyFCARestrictions,
),
additionalBadge = additionalBadge,
selectionType = selectionType,
percentLowerThenBest = PercentDifference.Empty,
approvalSettings = ProviderState.ApprovalSettings.Empty,
@ -139,27 +107,6 @@ internal object SwapProviderStateBuilder {
return stringReference(toAmount)
}
private fun resolveBadge(
provider: SwapProvider,
needApplyFCARestrictions: Boolean,
permissionState: PermissionDataState? = null,
isBestRate: Boolean = false,
isNeedBestRateBadge: Boolean = false,
): ProviderState.AdditionalBadge {
return when {
needApplyFCARestrictions && provider.isFCARestricted() ->
ProviderState.AdditionalBadge.FCAWarningList
permissionState is PermissionDataState.PermissionRequired ->
ProviderState.AdditionalBadge.PermissionRequired
provider.isRecommended ->
ProviderState.AdditionalBadge.Recommended
isNeedBestRateBadge && isBestRate && !needApplyFCARestrictions ->
ProviderState.AdditionalBadge.BestTrade
else ->
ProviderState.AdditionalBadge.Empty
}
}
@Suppress("LongParameterList")
private fun SwapProvider.toContent(
subtitle: TextReference,
@ -183,6 +130,4 @@ internal object SwapProviderStateBuilder {
approvalSettings = approvalSettings,
)
}
private fun SwapProvider.isFCARestricted(): Boolean = providerId in FCA_RESTRICTED_PROVIDER_IDS
}

View file

@ -29,7 +29,6 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
@ -81,6 +80,7 @@ import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.analytics.SwapQuotePerformanceTracker
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.converters.SwapProviderResolver
import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter
import com.tangem.feature.swap.domain.AllowPermissionsHandler
import com.tangem.feature.swap.domain.GetSwapUiModeUseCase
@ -108,12 +108,12 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.impl.R
import com.tangem.features.swap.SwapComponent
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
import com.tangem.utils.extensions.filterIf
import com.tangem.utils.isNullOrZero
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
@ -125,8 +125,6 @@ import java.math.RoundingMode
import java.util.Locale
import javax.inject.Inject
typealias SuccessLoadedSwapData = Map<SwapProvider, SwapState.QuotesLoadedState>
@Suppress("LongParameterList", "LargeClass")
@Stable
@ModelScoped
@ -231,14 +229,6 @@ internal class SwapModel @Inject constructor(
private val isFiatInput = mutableStateOf(false)
private var userCountry: UserCountry? = null
private val isUserResolvableError: (SwapState) -> Boolean = { swapState ->
swapState is SwapState.SwapError &&
(
swapState.error is ExpressDataError.ExchangeTooSmallAmountError ||
swapState.error is ExpressDataError.ExchangeTooBigAmountError
)
}
private val fromTokenBalanceJobHolder = JobHolder()
private val toTokenBalanceJobHolder = JobHolder()
private val swapPairsJobHolder = JobHolder()
@ -1043,7 +1033,7 @@ internal class SwapModel @Inject constructor(
)
}
val successStates = providersState.getLastLoadedSuccessStates()
val successStates = dataState.getLastLoadedSuccessStates()
val pricesLowerBest = getPricesLowerBest(provider.providerId, successStates)
uiState = stateBuilder.updateProvidersBottomSheetContent(
uiState = uiState,
@ -1097,16 +1087,20 @@ internal class SwapModel @Inject constructor(
}
private fun setupQuotesLoadedUiState(provider: SwapProvider, state: SwapState.QuotesLoadedState) {
val loadedStates = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates()
val bestRatedProviderId = findBestQuoteProvider(loadedStates)?.providerId ?: provider.providerId
val loadedStates = dataState.getLastLoadedSuccessStates()
val additionalBadge = SwapProviderResolver.resolveBadge(
provider = provider,
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
states = loadedStates,
state = state,
isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled,
)
uiState = stateBuilder.createQuotesLoadedState(
uiStateHolder = uiState,
quoteModel = state,
feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
swapProvider = provider,
bestRatedProviderId = bestRatedProviderId,
isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1,
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
additionalBadge = additionalBadge,
swapFee = getSelectedSwapFee(),
feeError = feeSelectorRepository.state.value as? FeeSelectorUM.Error,
)
@ -1181,6 +1175,14 @@ internal class SwapModel @Inject constructor(
private fun setupErrorUiState(provider: SwapProvider, state: SwapState.SwapError) {
singleTaskScheduler.cancelTask()
val loadedStates = dataState.getLastLoadedSuccessStates()
val additionalBadge = SwapProviderResolver.resolveBadge(
provider = provider,
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
states = loadedStates,
state = state,
isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled,
)
uiState = stateBuilder.createQuotesErrorState(
uiStateHolder = uiState,
swapProvider = provider,
@ -1188,7 +1190,7 @@ internal class SwapModel @Inject constructor(
toSwapCurrencyStatus = dataState.toSwapCurrencyStatus,
expressDataError = state.error,
balanceStatus = state.balanceStatus,
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
additionalBadge = additionalBadge,
swapFee = getSelectedSwapFee(),
)
sendErrorAnalyticsEvent(state.error, provider)
@ -1234,7 +1236,10 @@ internal class SwapModel @Inject constructor(
return if (consideredProviders.isNotEmpty()) {
val successLoadedData = consideredProviders.getLastLoadedSuccessStates()
val bestQuotesProvider = findBestQuoteProvider(successLoadedData)
val bestQuotesProvider = SwapProviderResolver.findBest(
states = successLoadedData,
isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled,
)
val currentSelected = dataState.selectedProvider
if (currentSelected != null && consideredProviders.keys.contains(currentSelected)) {
// logic for always choose best if already selected provider
@ -1976,17 +1981,15 @@ internal class SwapModel @Inject constructor(
onProviderClick = { providerId ->
singleTaskScheduler.cancelTask()
analyticsEventHandler.send(SwapEvents.ProviderClicked())
val states = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates()
val states = dataState.getLastLoadedSuccessStates()
val pricesLowerBest = getPricesLowerBest(providerId, states)
val bestRatedProviderId = findBestQuoteProvider(states)?.providerId ?: providerId
uiState = stateBuilder.showSelectProviderBottomSheet(
uiState = uiState,
selectedProviderId = providerId,
pricesLowerBest = pricesLowerBest,
providersStates = dataState.lastLoadedSwapStates,
isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled,
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
bestRatedProviderId = bestRatedProviderId,
isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1,
) { uiState = stateBuilder.dismissBottomSheet(uiState) }
},
onProviderSelect = { providerId ->
@ -1996,7 +1999,7 @@ internal class SwapModel @Inject constructor(
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
val isNotNullCurrency = fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null
if (provider != null && swapState != null && isNotNullCurrency) {
modelScope.launch {
modelScope.launch(dispatchers.default) {
feeSelectorRepository.state.value = FeeSelectorUM.Loading
feeSelectorReloadTrigger.triggerUpdate()
}
@ -2156,24 +2159,6 @@ internal class SwapModel @Inject constructor(
return selectedProvider
}
private fun findBestQuoteProvider(state: SuccessLoadedSwapData): SwapProvider? {
// finding best quotes
return state.minByOrNull { entry ->
val toTokenInfo = entry.value.toTokenInfo
val fromAmountFiat = entry.value.fromTokenInfo.amountFiat
val toAmountFiat = toTokenInfo.amountFiat
if (!fromAmountFiat.isNullOrZero() && !toAmountFiat.isNullOrZero()) {
fromAmountFiat.divide(
toAmountFiat,
toTokenInfo.swapCurrencyStatus.currency.decimals,
RoundingMode.HALF_UP,
)
} else {
BigDecimal.ZERO
}
}?.key
}
private fun getPricesLowerBest(selectedProviderId: String, state: SuccessLoadedSwapData): Map<String, Float> {
val selectedProviderEntry =
state.filter { entry -> entry.key.providerId == selectedProviderId }.entries.firstOrNull()
@ -2257,17 +2242,6 @@ internal class SwapModel @Inject constructor(
)
}
private fun Map<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {
return this.filter { entry -> entry.value is SwapState.QuotesLoadedState }
.mapValues { entry -> entry.value as SwapState.QuotesLoadedState }
}
private fun Map<SwapProvider, SwapState>.consideredProvidersStates(): Map<SwapProvider, SwapState> {
return this.filter { entry ->
entry.value is SwapState.QuotesLoadedState || isUserResolvableError(entry.value)
}
}
private fun sendNoticePermissionNeededEvent() {
val sendTokenSymbol = dataState.fromSwapCurrencyStatus?.currency?.symbol ?: return
val receiveTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return

View file

@ -2,11 +2,14 @@ package com.tangem.feature.swap.model
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.domain.SwapPairLeast
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.SwapState
import java.math.BigDecimal
typealias SuccessLoadedSwapData = Map<SwapProvider, SwapState.QuotesLoadedState>
data class SwapProcessDataState(
// Initial network id
val fromSwapCurrencyStatus: SwapCurrencyStatus? = null,
@ -30,4 +33,28 @@ data class SwapProcessDataState(
fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? {
return lastLoadedSwapStates[selectedProvider] as? SwapState.QuotesLoadedState
}
fun getLastLoadedSuccessStates(): SuccessLoadedSwapData {
return lastLoadedSwapStates.filter { entry -> entry.value is SwapState.QuotesLoadedState }
.mapValues { entry -> entry.value as SwapState.QuotesLoadedState }
}
}
internal fun Map<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {
return this.filter { entry -> entry.value is SwapState.QuotesLoadedState }
.mapValues { entry -> entry.value as SwapState.QuotesLoadedState }
}
internal fun Map<SwapProvider, SwapState>.consideredProvidersStates(): Map<SwapProvider, SwapState> {
fun isUserResolvableError(swapState: SwapState): Boolean {
return swapState is SwapState.SwapError &&
(
swapState.error is ExpressDataError.ExchangeTooSmallAmountError ||
swapState.error is ExpressDataError.ExchangeTooBigAmountError
)
}
return this.filter { entry ->
entry.value is SwapState.QuotesLoadedState || isUserResolvableError(entry.value)
}
}

View file

@ -47,6 +47,7 @@ sealed class ProviderState {
sealed class AdditionalBadge {
data object FCAWarningList : AdditionalBadge()
data object BestTrade : AdditionalBadge()
data object BestDexRate : AdditionalBadge()
data object Empty : AdditionalBadge()
data object PermissionRequired : AdditionalBadge()
data object Recommended : AdditionalBadge()

View file

@ -160,6 +160,7 @@ private fun ProviderContentState(
when (state.additionalBadge) {
ProviderState.AdditionalBadge.FCAWarningList -> FCABadgeItem(badgeModifier)
ProviderState.AdditionalBadge.BestTrade -> BestTradeItem(badgeModifier)
ProviderState.AdditionalBadge.BestDexRate -> BestDexRateItem(badgeModifier)
ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem(badgeModifier)
ProviderState.AdditionalBadge.Recommended -> RecommendedItem(badgeModifier)
ProviderState.AdditionalBadge.Empty -> Unit
@ -403,6 +404,24 @@ private fun BestTradeItem(modifier: Modifier = Modifier) {
}
}
@Composable
private fun BestDexRateItem(modifier: Modifier = Modifier) {
Box(
modifier = modifier.background(
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
shape = TangemTheme.shapes.roundedCornersLarge,
),
) {
Text(
text = stringResourceSafe(R.string.express_provider_best_dex_rate),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),
maxLines = 1,
)
}
}
@Composable
private fun PermissionBadgeItem(modifier: Modifier = Modifier) {
Box(

View file

@ -92,7 +92,9 @@ private fun SimpleProviderTrailing(state: ProviderState) {
.size(TangemTheme.dimens.size20)
.clip(RoundedCornerShape(TangemTheme.dimens.radius4)),
)
if (state.additionalBadge is ProviderState.AdditionalBadge.BestTrade) {
if (state.additionalBadge is ProviderState.AdditionalBadge.BestTrade ||
state.additionalBadge is ProviderState.AdditionalBadge.BestDexRate
) {
SimpleBestRateBadge(
modifier = Modifier
.align(Alignment.BottomEnd)

View file

@ -36,6 +36,7 @@ import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.converters.SwapProviderResolver
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
@ -43,6 +44,7 @@ import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.model.SwapNotificationsFactory
import com.tangem.feature.swap.model.SwapProcessDataState
import com.tangem.feature.swap.model.getLastLoadedSuccessStates
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.SwapButton.Mode
import com.tangem.feature.swap.models.states.*
@ -529,9 +531,7 @@ internal class StateBuilder(
quoteModel: SwapState.QuotesLoadedState,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
swapProvider: SwapProvider,
bestRatedProviderId: String,
isNeedBestRateBadge: Boolean,
needApplyFCARestrictions: Boolean,
additionalBadge: ProviderState.AdditionalBadge,
swapFee: SwapFee?,
feeError: FeeSelectorUM.Error?,
): SwapStateHolder {
@ -641,13 +641,9 @@ internal class StateBuilder(
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
providerState = SwapProviderStateBuilder.buildContentClickable(
provider = swapProvider,
fromTokenInfo = quoteModel.fromTokenInfo,
toTokenInfo = quoteModel.toTokenInfo,
permissionState = quoteModel.permissionState,
state = quoteModel,
selectionType = ProviderState.SelectionType.CLICK,
isBestRate = bestRatedProviderId == swapProvider.providerId && !priceImpact.shouldShowWarning(),
isNeedBestRateBadge = isNeedBestRateBadge,
needApplyFCARestrictions = needApplyFCARestrictions,
additionalBadge = additionalBadge,
onProviderClick = actions.onProviderClick,
),
priceImpact = priceImpact,
@ -769,7 +765,7 @@ internal class StateBuilder(
toSwapCurrencyStatus: SwapCurrencyStatus?,
balanceStatus: SwapBalanceStatus,
expressDataError: ExpressDataError,
needApplyFCARestrictions: Boolean,
additionalBadge: ProviderState.AdditionalBadge,
swapFee: SwapFee?,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
@ -789,7 +785,7 @@ internal class StateBuilder(
expressDataError = expressDataError,
onProviderClick = actions.onProviderClick,
selectionType = ProviderState.SelectionType.CLICK,
needApplyFCARestrictions = needApplyFCARestrictions,
additionalBadge = additionalBadge,
)
val type = TransactionCardType.ReadOnly(
accountTitleUM = getCardAccountTitle(
@ -837,7 +833,7 @@ internal class StateBuilder(
expressDataError: ExpressDataError,
onProviderClick: (String) -> Unit,
selectionType: ProviderState.SelectionType,
needApplyFCARestrictions: Boolean,
additionalBadge: ProviderState.AdditionalBadge,
): ProviderState {
return when (expressDataError) {
is ExpressDataError.ExchangeTooSmallAmountError -> {
@ -848,7 +844,7 @@ internal class StateBuilder(
wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
),
selectionType = selectionType,
needApplyFCARestrictions = needApplyFCARestrictions,
additionalBadge = additionalBadge,
onProviderClick = onProviderClick,
)
}
@ -860,7 +856,7 @@ internal class StateBuilder(
wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
),
selectionType = selectionType,
needApplyFCARestrictions = needApplyFCARestrictions,
additionalBadge = additionalBadge,
onProviderClick = onProviderClick,
)
}
@ -1177,19 +1173,24 @@ internal class StateBuilder(
pricesLowerBest: Map<String, Float>,
providersStates: Map<SwapProvider, SwapState>,
needApplyFCARestrictions: Boolean,
bestRatedProviderId: String,
isNeedBestRateBadge: Boolean,
isSwapBestDexRateEnabled: Boolean,
onDismiss: () -> Unit,
): SwapStateHolder {
val successStates = providersStates.getLastLoadedSuccessStates()
val availableProvidersStates = providersStates.entries
.mapNotNull { entry ->
val additionalBadge = SwapProviderResolver.resolveBadge(
states = successStates,
provider = entry.key,
needApplyFCARestrictions = needApplyFCARestrictions,
state = entry.value,
isSwapBestDexRateEnabled = isSwapBestDexRateEnabled,
)
entry.convertToProviderBottomSheetState(
pricesLowerBest = pricesLowerBest,
onProviderSelect = actions.onProviderSelect,
needApplyFCARestrictions = needApplyFCARestrictions,
onApprovalSelectClick = actions.onApproveTypeSelect,
bestRatedProviderId = bestRatedProviderId,
isNeedBestRateBadge = isNeedBestRateBadge,
additionalBadge = additionalBadge,
)
}
.sortedWith(ProviderPercentDiffComparator)
@ -1294,23 +1295,18 @@ internal class StateBuilder(
pricesLowerBest: Map<String, Float>,
onProviderSelect: (String) -> Unit,
onApprovalSelectClick: (SwapProvider) -> Unit,
needApplyFCARestrictions: Boolean,
bestRatedProviderId: String,
isNeedBestRateBadge: Boolean,
additionalBadge: ProviderState.AdditionalBadge,
): ProviderState? {
val provider = this.key
return when (val state = this.value) {
val (provider, state) = this
return when (state) {
is SwapState.EmptyAmountState, is SwapState.Transfer -> null
is SwapState.QuotesLoadedState -> {
SwapProviderStateBuilder.buildContentSelectable(
provider = provider,
toTokenInfo = state.toTokenInfo,
permissionState = state.permissionState,
state = state,
pricesLowerBest = pricesLowerBest,
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = needApplyFCARestrictions,
isBestRate = bestRatedProviderId == provider.providerId && !state.priceImpact.shouldShowWarning(),
isNeedBestRateBadge = isNeedBestRateBadge,
additionalBadge = additionalBadge,
onProviderClick = onProviderSelect,
onApprovalSelectClick = onApprovalSelectClick,
)
@ -1321,7 +1317,7 @@ internal class StateBuilder(
expressDataError = state.error,
onProviderClick = onProviderSelect,
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = needApplyFCARestrictions,
additionalBadge = additionalBadge,
)
}
}

View file

@ -23,6 +23,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
@ -105,9 +106,7 @@ internal class StateBuilderSwapButtonTest {
quoteModel = state,
feeCryptoCurrencyStatus = null,
swapProvider = buildProvider(ExchangeProviderType.CEX),
bestRatedProviderId = "p",
isNeedBestRateBadge = false,
needApplyFCARestrictions = false,
additionalBadge = ProviderState.AdditionalBadge.Empty,
swapFee = null,
feeError = null,
)
@ -132,9 +131,7 @@ internal class StateBuilderSwapButtonTest {
quoteModel = state,
feeCryptoCurrencyStatus = null,
swapProvider = buildProvider(ExchangeProviderType.CEX),
bestRatedProviderId = "p",
isNeedBestRateBadge = false,
needApplyFCARestrictions = false,
additionalBadge = ProviderState.AdditionalBadge.Empty,
swapFee = null,
feeError = null,
)
@ -163,9 +160,7 @@ internal class StateBuilderSwapButtonTest {
quoteModel = state,
feeCryptoCurrencyStatus = null,
swapProvider = buildProvider(ExchangeProviderType.CEX),
bestRatedProviderId = "p",
isNeedBestRateBadge = false,
needApplyFCARestrictions = false,
additionalBadge = ProviderState.AdditionalBadge.Empty,
swapFee = null,
feeError = null,
)
@ -189,9 +184,7 @@ internal class StateBuilderSwapButtonTest {
quoteModel = state,
feeCryptoCurrencyStatus = null,
swapProvider = buildProvider(ExchangeProviderType.CEX),
bestRatedProviderId = "p",
isNeedBestRateBadge = false,
needApplyFCARestrictions = false,
additionalBadge = ProviderState.AdditionalBadge.Empty,
swapFee = buildSwapFee(),
feeError = null,
)
@ -219,9 +212,7 @@ internal class StateBuilderSwapButtonTest {
quoteModel = state,
feeCryptoCurrencyStatus = null,
swapProvider = buildProvider(ExchangeProviderType.CEX),
bestRatedProviderId = "p",
isNeedBestRateBadge = false,
needApplyFCARestrictions = false,
additionalBadge = ProviderState.AdditionalBadge.Empty,
swapFee = buildSwapFee(),
feeError = null,
)

View file

@ -0,0 +1,523 @@
package com.tangem.feature.swap.converters
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.models.states.ProviderState
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
* Tests for [SwapProviderResolver] best-provider selection ([SwapProviderResolver.findBest]) and
* row-badge resolution ([SwapProviderResolver.resolveBadge]).
*
* Ranking metric: best provider == lowest `from/to` fiat ratio == highest `to` fiat output for the
* same `from` input. "Best DEX Rate" prefers the best-rated DEX/DEX_BRIDGE provider when the feature
* is on and any DEX is present.
*/
internal class SwapProviderResolverTest {
private val cex1 = provider(id = "cex1", type = ExchangeProviderType.CEX)
private val cex2 = provider(id = "cex2", type = ExchangeProviderType.CEX)
private val dex1 = provider(id = "dex1", type = ExchangeProviderType.DEX)
private val dexBridge = provider(id = "dexBridge", type = ExchangeProviderType.DEX_BRIDGE)
/** Mirror of the provider ids the resolver treats as FCA restricted. */
private val fcaRestrictedProviderIds = setOf(
"changelly",
"changenow",
"okx-cross-chain",
"okx-on-chain",
"simpleswap",
)
// region findBest
@Test
fun `GIVEN best dex rate on AND a DEX present WHEN findBest THEN best DEX is selected`() {
// CEX has the best overall rate (highest output), but a DEX is present.
val states = mapOf(
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
dex1 to quote(fromFiat = "100", toFiat = "110"), // best among DEX
dexBridge to quote(fromFiat = "100", toFiat = "105"),
)
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true)
assertThat(best).isEqualTo(dex1)
}
@Test
fun `GIVEN best dex rate on AND no DEX present WHEN findBest THEN best CEX fallback`() {
val states = mapOf(
cex1 to quote(fromFiat = "100", toFiat = "110"),
cex2 to quote(fromFiat = "100", toFiat = "120"), // best CEX
)
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true)
assertThat(best).isEqualTo(cex2)
}
@Test
fun `GIVEN best dex rate off WHEN findBest THEN best overall regardless of type`() {
val states = mapOf(
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall (a CEX)
dex1 to quote(fromFiat = "100", toFiat = "110"),
)
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = false)
assertThat(best).isEqualTo(cex1)
}
@Test
fun `GIVEN DEX_BRIDGE is the best DEX WHEN findBest with best dex rate on THEN DEX_BRIDGE selected`() {
val states = mapOf(
cex1 to quote(fromFiat = "100", toFiat = "130"),
dex1 to quote(fromFiat = "100", toFiat = "108"),
dexBridge to quote(fromFiat = "100", toFiat = "115"), // best among DEX-based
)
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true)
assertThat(best).isEqualTo(dexBridge)
}
// endregion
// region resolveBadge
// --- Both CEX + DEX present, a DEX is the overall best → only "Best rate" (BestTrade) on that DEX.
@Test
fun `GIVEN both types present AND DEX is overall best WHEN resolveBadge for that DEX THEN BestTrade`() {
val states = mapOf(
dex1 to quote(fromFiat = "100", toFiat = "120"), // best overall AND best DEX
cex1 to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = dex1,
needApplyFCARestrictions = false,
state = states.getValue(dex1),
isSwapBestDexRateEnabled = true,
)
// Overall best is the DEX → it gets the single "Best rate" badge, NOT "Best DEX rate".
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
}
@Test
fun `GIVEN both types present AND DEX is overall best WHEN resolveBadge for the CEX THEN Empty`() {
val states = mapOf(
dex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
cex1 to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = cex1,
needApplyFCARestrictions = false,
state = states.getValue(cex1),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
// --- Both CEX + DEX present, a CEX is the overall best → BestTrade on the CEX, BestDexRate on the best DEX.
@Test
fun `GIVEN both types present AND CEX is overall best WHEN resolveBadge for the CEX THEN BestTrade`() {
val states = mapOf(
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall (a CEX)
dex1 to quote(fromFiat = "100", toFiat = "110"), // best DEX
dexBridge to quote(fromFiat = "100", toFiat = "105"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = cex1,
needApplyFCARestrictions = false,
state = states.getValue(cex1),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
}
@Test
fun `GIVEN both types present AND CEX is overall best WHEN resolveBadge for the best DEX THEN BestDexRate`() {
val states = mapOf(
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall (a CEX)
dex1 to quote(fromFiat = "100", toFiat = "110"), // best DEX
dexBridge to quote(fromFiat = "100", toFiat = "105"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = dex1,
needApplyFCARestrictions = false,
state = states.getValue(dex1),
isSwapBestDexRateEnabled = true,
)
// CEX wins overall, so the best DEX additionally gets the "Best DEX rate" badge.
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestDexRate)
}
@Test
fun `GIVEN both types present AND CEX is overall best WHEN resolveBadge for a non-best DEX THEN Empty`() {
val states = mapOf(
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
dex1 to quote(fromFiat = "100", toFiat = "110"), // best DEX
dexBridge to quote(fromFiat = "100", toFiat = "105"), // not the best DEX
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = dexBridge,
needApplyFCARestrictions = false,
state = states.getValue(dexBridge),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
// --- DEX-only → only "Best rate" (BestTrade) on the best DEX; no separate "Best DEX rate".
@Test
fun `GIVEN DEX-only providers WHEN resolveBadge for the best DEX THEN BestTrade`() {
val states = mapOf(
dex1 to quote(fromFiat = "100", toFiat = "120"), // best DEX
dexBridge to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = dex1,
needApplyFCARestrictions = false,
state = states.getValue(dex1),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
}
@Test
fun `GIVEN DEX-only providers WHEN resolveBadge for a non-best DEX THEN Empty`() {
val states = mapOf(
dex1 to quote(fromFiat = "100", toFiat = "120"), // best DEX
dexBridge to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = dexBridge,
needApplyFCARestrictions = false,
state = states.getValue(dexBridge),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
// --- CEX-only → only "Best rate" (BestTrade) on the best CEX (no DEX exists).
@Test
fun `GIVEN CEX-only providers WHEN resolveBadge for the best CEX THEN BestTrade`() {
val states = mapOf(
cex1 to quote(fromFiat = "100", toFiat = "120"), // best CEX
cex2 to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = cex1,
needApplyFCARestrictions = false,
state = states.getValue(cex1),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
}
// --- Toggle off → only the overall best gets BestTrade; "Best DEX rate" is never produced.
@Test
fun `GIVEN toggle off AND both types present with CEX best WHEN resolveBadge for the CEX THEN BestTrade`() {
val states = mapOf(
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
dex1 to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = cex1,
needApplyFCARestrictions = false,
state = states.getValue(cex1),
isSwapBestDexRateEnabled = false,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
}
@Test
fun `GIVEN toggle off AND both types present with CEX best WHEN resolveBadge for the DEX THEN Empty`() {
val states = mapOf(
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
dex1 to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = dex1,
needApplyFCARestrictions = false,
state = states.getValue(dex1),
isSwapBestDexRateEnabled = false,
)
// Toggle off → no "Best DEX rate" badge even though a DEX is present and not the overall best.
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
@Test
fun `GIVEN FCA restricted provider AND restrictions on WHEN resolveBadge THEN FCAWarningList`() {
val restricted = provider(id = "changelly", type = ExchangeProviderType.CEX, isRecommended = true)
val states = mapOf(
restricted to quote(fromFiat = "100", toFiat = "120"),
cex2 to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = restricted,
needApplyFCARestrictions = true,
state = states.getValue(restricted),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
}
@Test
fun `GIVEN restrictions on WHEN resolveBadge for each FCA restricted id THEN FCAWarningList`() {
// FCA badge must win regardless of rate — the restricted provider here is NOT the best rate.
fcaRestrictedProviderIds.forEach { restrictedId ->
val restricted = provider(id = restrictedId, type = ExchangeProviderType.CEX)
val states = mapOf(
restricted to quote(fromFiat = "100", toFiat = "110"),
cex2 to quote(fromFiat = "100", toFiat = "120"), // best, but not FCA restricted
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = restricted,
needApplyFCARestrictions = true,
state = states.getValue(restricted),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
}
}
@Test
fun `GIVEN FCA restricted provider but restrictions off WHEN resolveBadge THEN no FCA badge`() {
val restricted = provider(id = "changelly", type = ExchangeProviderType.CEX)
val states = mapOf(
restricted to quote(fromFiat = "100", toFiat = "120"), // best rated
cex2 to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = restricted,
needApplyFCARestrictions = false,
state = states.getValue(restricted),
isSwapBestDexRateEnabled = false,
)
// Restrictions are off → the restricted id is ignored and the normal best-rate badge wins.
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
}
@Test
fun `GIVEN restrictions on for a non-restricted best provider WHEN resolveBadge THEN best rate suppressed`() {
val states = mapOf(
cex1 to quote(fromFiat = "100", toFiat = "120"), // best & not restricted
cex2 to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = cex1,
needApplyFCARestrictions = true,
state = states.getValue(cex1),
isSwapBestDexRateEnabled = true,
)
// FCA restrictions globally on suppress the best-rate badge even for non-restricted providers.
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
@Test
fun `GIVEN permission required WHEN resolveBadge THEN PermissionRequired`() {
val states = mapOf(
cex1 to quote(
fromFiat = "100",
toFiat = "120",
permission = PermissionDataState.PermissionRequired(isResetApproval = false, spenderAddress = "0x"),
),
cex2 to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = cex1,
needApplyFCARestrictions = false,
state = states.getValue(cex1),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired)
}
@Test
fun `GIVEN recommended provider WHEN resolveBadge THEN Recommended takes priority over best rate`() {
val recommended = provider(id = "cex1", type = ExchangeProviderType.CEX, isRecommended = true)
val states = mapOf(
recommended to quote(fromFiat = "100", toFiat = "120"), // also the best rate
cex2 to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = recommended,
needApplyFCARestrictions = false,
state = states.getValue(recommended),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Recommended)
}
@Test
fun `GIVEN only one considered provider WHEN resolveBadge THEN Empty`() {
val states = mapOf(cex1 to quote(fromFiat = "100", toFiat = "120"))
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = cex1,
needApplyFCARestrictions = false,
state = states.getValue(cex1),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
@Test
fun `GIVEN provider is not the best rated WHEN resolveBadge THEN Empty`() {
val states = mapOf(
cex1 to quote(fromFiat = "100", toFiat = "120"), // best
cex2 to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = cex2, // not the best
needApplyFCARestrictions = false,
state = states.getValue(cex2),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
@Test
fun `GIVEN best rated provider but price impact warning WHEN resolveBadge THEN Empty`() {
val states = mapOf(
cex1 to quote(fromFiat = "100", toFiat = "120", priceImpactWarning = true), // best, but warning
cex2 to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = cex1,
needApplyFCARestrictions = false,
state = states.getValue(cex1),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
@Test
fun `GIVEN no quote state (error row) WHEN resolveBadge THEN Empty`() {
val states = mapOf(
cex1 to quote(fromFiat = "100", toFiat = "120"),
cex2 to quote(fromFiat = "100", toFiat = "110"),
)
val badge = SwapProviderResolver.resolveBadge(
states = states,
provider = cex1,
needApplyFCARestrictions = false,
state = null,
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
// endregion
private fun provider(
id: String,
type: ExchangeProviderType,
isRecommended: Boolean = false,
): SwapProvider = mockk {
every { providerId } returns id
every { this@mockk.type } returns type
every { this@mockk.isRecommended } returns isRecommended
}
private fun quote(
fromFiat: String,
toFiat: String,
priceImpactWarning: Boolean = false,
permission: PermissionDataState = PermissionDataState.Empty,
): SwapState.QuotesLoadedState {
val currency = mockk<CryptoCurrency.Coin> {
every { decimals } returns 6
}
val swapStatus = mockk<SwapCurrencyStatus> {
every { this@mockk.currency } returns currency
}
val fromInfo = mockk<TokenSwapInfo> {
every { amountFiat } returns BigDecimal(fromFiat)
}
val toInfo = mockk<TokenSwapInfo> {
every { amountFiat } returns BigDecimal(toFiat)
every { swapCurrencyStatus } returns swapStatus
}
return mockk {
every { fromTokenInfo } returns fromInfo
every { toTokenInfo } returns toInfo
every { permissionState } returns permission
every { priceImpact } returns mockk<PriceImpact> { every { shouldShowWarning() } returns priceImpactWarning }
}
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.feature.swap.models.states.PercentDifference
@ -20,6 +21,14 @@ import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.util.Locale
/**
* Tests for [SwapProviderStateBuilder].
*
* Badge selection lives in [SwapProviderResolver]; here the badge is supplied as [additionalBadge]
* and the builder is expected to render it verbatim. These tests therefore focus on the builder's
* own responsibilities subtitle formatting, percent-delta mapping, provider identity, and passing
* the badge through not on badge-decision logic.
*/
internal class SwapProviderStateBuilderTest {
private var originalLocale: Locale = Locale.getDefault()
@ -40,24 +49,20 @@ internal class SwapProviderStateBuilderTest {
// region buildContentClickable
@Test
fun `GIVEN best rate AND no FCA AND no permission WHEN buildContentClickable THEN BestTrade badge`() {
val provider = provider(id = "1inch", isRecommended = false)
fun `GIVEN a badge WHEN buildContentClickable THEN it is rendered with a rate subtitle`() {
val provider = provider(id = "1inch")
val from = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
val to = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("3000"))
val result = SwapProviderStateBuilder.buildContentClickable(
provider = provider,
fromTokenInfo = from,
toTokenInfo = to,
permissionState = PermissionDataState.Empty,
state = quoteState(toTokenInfo = to, fromTokenInfo = from),
selectionType = ProviderState.SelectionType.CLICK,
isBestRate = true,
isNeedBestRateBadge = true,
needApplyFCARestrictions = false,
additionalBadge = ProviderState.AdditionalBadge.BestDexRate,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.BestDexRate)
assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Empty)
assertThat(result.subtitle).isInstanceOf(TextReference.Str::class.java)
val subtitle = result.subtitle as TextReference.Str
@ -65,106 +70,16 @@ internal class SwapProviderStateBuilderTest {
assertThat(subtitle.value).contains("USDT")
}
@Test
fun `GIVEN recommended provider WHEN buildContentClickable THEN Recommended badge`() {
val provider = provider(id = "any", isRecommended = true)
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
val result = SwapProviderStateBuilder.buildContentClickable(
provider = provider,
fromTokenInfo = info,
toTokenInfo = info,
permissionState = PermissionDataState.Empty,
selectionType = ProviderState.SelectionType.CLICK,
isBestRate = true,
isNeedBestRateBadge = true,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Recommended)
}
@Test
fun `GIVEN permission required WHEN buildContentClickable THEN PermissionRequired badge`() {
val provider = provider(id = "any", isRecommended = false)
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
val result = SwapProviderStateBuilder.buildContentClickable(
provider = provider,
fromTokenInfo = info,
toTokenInfo = info,
permissionState = PermissionDataState.PermissionRequired(
isResetApproval = false,
spenderAddress = "0xspender",
),
selectionType = ProviderState.SelectionType.CLICK,
isBestRate = true,
isNeedBestRateBadge = true,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired)
}
@Test
fun `GIVEN FCA restricted provider WHEN buildContentClickable THEN FCAWarningList badge`() {
val provider = provider(id = "changelly", isRecommended = true)
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
val result = SwapProviderStateBuilder.buildContentClickable(
provider = provider,
fromTokenInfo = info,
toTokenInfo = info,
permissionState = PermissionDataState.PermissionRequired(
isResetApproval = false,
spenderAddress = "0xspender",
),
selectionType = ProviderState.SelectionType.CLICK,
isBestRate = true,
isNeedBestRateBadge = true,
needApplyFCARestrictions = true,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
}
@Test
fun `GIVEN best rate badge disabled WHEN buildContentClickable THEN Empty badge`() {
val provider = provider(id = "any", isRecommended = false)
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
val result = SwapProviderStateBuilder.buildContentClickable(
provider = provider,
fromTokenInfo = info,
toTokenInfo = info,
permissionState = PermissionDataState.Empty,
selectionType = ProviderState.SelectionType.CLICK,
isBestRate = true,
isNeedBestRateBadge = false,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
@Test
fun `GIVEN provider WHEN buildContentClickable THEN content carries provider identity`() {
val provider = provider(id = "1inch", isRecommended = false, name = "1inch", iconUrl = "https://x")
val provider = provider(id = "1inch", name = "1inch", iconUrl = "https://x")
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
val result = SwapProviderStateBuilder.buildContentClickable(
provider = provider,
fromTokenInfo = info,
toTokenInfo = info,
permissionState = PermissionDataState.Empty,
state = quoteState(toTokenInfo = info),
selectionType = ProviderState.SelectionType.CLICK,
isBestRate = false,
isNeedBestRateBadge = false,
needApplyFCARestrictions = false,
additionalBadge = ProviderState.AdditionalBadge.Empty,
onProviderClick = onProviderClick,
)
@ -182,16 +97,15 @@ internal class SwapProviderStateBuilderTest {
@Test
fun `GIVEN provider in pricesLowerBest WHEN buildContentSelectable THEN percentLowerThenBest is mapped`() {
val provider = provider(id = "1inch", isRecommended = false)
val provider = provider(id = "1inch")
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
val result = SwapProviderStateBuilder.buildContentSelectable(
provider = provider,
toTokenInfo = info,
permissionState = PermissionDataState.Empty,
state = quoteState(toTokenInfo = info),
pricesLowerBest = mapOf("1inch" to 0.5f),
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
additionalBadge = ProviderState.AdditionalBadge.Empty,
onProviderClick = onProviderClick,
)
@ -203,16 +117,15 @@ internal class SwapProviderStateBuilderTest {
@Test
fun `GIVEN provider not in pricesLowerBest WHEN buildContentSelectable THEN percentLowerThenBest is zero`() {
val provider = provider(id = "any", isRecommended = false)
val provider = provider(id = "any")
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
val result = SwapProviderStateBuilder.buildContentSelectable(
provider = provider,
toTokenInfo = info,
permissionState = PermissionDataState.Empty,
state = quoteState(toTokenInfo = info),
pricesLowerBest = emptyMap(),
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
additionalBadge = ProviderState.AdditionalBadge.Empty,
onProviderClick = onProviderClick,
)
@ -220,84 +133,22 @@ internal class SwapProviderStateBuilderTest {
}
@Test
fun `GIVEN best rate AND no FCA AND no permission WHEN buildContentSelectable THEN BestTrade badge`() {
val provider = provider(id = "any", isRecommended = false)
fun `GIVEN a badge WHEN buildContentSelectable THEN it is rendered`() {
val provider = provider(id = "any")
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
val result = SwapProviderStateBuilder.buildContentSelectable(
provider = provider,
toTokenInfo = info,
permissionState = PermissionDataState.Empty,
state = quoteState(toTokenInfo = info),
pricesLowerBest = emptyMap(),
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
isBestRate = true,
isNeedBestRateBadge = true,
additionalBadge = ProviderState.AdditionalBadge.BestTrade,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
}
@Test
fun `GIVEN isNeedBestRateBadge false WHEN buildContentSelectable THEN no BestTrade badge`() {
val provider = provider(id = "any", isRecommended = false)
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
val result = SwapProviderStateBuilder.buildContentSelectable(
provider = provider,
toTokenInfo = info,
permissionState = PermissionDataState.Empty,
pricesLowerBest = emptyMap(),
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
isBestRate = true,
isNeedBestRateBadge = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
@Test
fun `GIVEN isBestRate false AND badge enabled WHEN buildContentSelectable THEN no BestTrade badge`() {
val provider = provider(id = "any", isRecommended = false)
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
val result = SwapProviderStateBuilder.buildContentSelectable(
provider = provider,
toTokenInfo = info,
permissionState = PermissionDataState.Empty,
pricesLowerBest = emptyMap(),
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
isBestRate = false,
isNeedBestRateBadge = true,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
@Test
fun `GIVEN permission required WHEN buildContentSelectable THEN PermissionRequired badge`() {
val provider = provider(id = "any", isRecommended = false)
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
val result = SwapProviderStateBuilder.buildContentSelectable(
provider = provider,
toTokenInfo = info,
permissionState = PermissionDataState.PermissionRequired(
isResetApproval = false,
spenderAddress = "0xspender",
),
pricesLowerBest = emptyMap(),
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired)
assertThat(result.subtitle).isInstanceOf(TextReference.Str::class.java)
assertThat((result.subtitle as TextReference.Str).value).contains("USDT")
}
// endregion
@ -305,67 +156,23 @@ internal class SwapProviderStateBuilderTest {
// region buildAvailableFrom
@Test
fun `GIVEN alert text WHEN buildAvailableFrom THEN subtitle is the alert text`() {
val provider = provider(id = "any", isRecommended = false)
fun `GIVEN alert text WHEN buildAvailableFrom THEN subtitle is the alert text and badge is rendered`() {
val provider = provider(id = "any")
val alert: TextReference = stringReference("min amount 0.01 ETH")
val result = SwapProviderStateBuilder.buildAvailableFrom(
provider = provider,
alertText = alert,
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
additionalBadge = ProviderState.AdditionalBadge.FCAWarningList,
onProviderClick = onProviderClick,
)
assertThat(result.subtitle).isEqualTo(alert)
assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Empty)
}
@Test
fun `GIVEN FCA restricted WHEN buildAvailableFrom THEN FCAWarningList badge`() {
val provider = provider(id = "okx-on-chain", isRecommended = true)
val result = SwapProviderStateBuilder.buildAvailableFrom(
provider = provider,
alertText = TextReference.EMPTY,
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = true,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
}
@Test
fun `GIVEN recommended WHEN buildAvailableFrom THEN Recommended badge`() {
val provider = provider(id = "any", isRecommended = true)
val result = SwapProviderStateBuilder.buildAvailableFrom(
provider = provider,
alertText = TextReference.EMPTY,
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Recommended)
}
@Test
fun `GIVEN no flags WHEN buildAvailableFrom THEN Empty badge`() {
val provider = provider(id = "any", isRecommended = false)
val result = SwapProviderStateBuilder.buildAvailableFrom(
provider = provider,
alertText = TextReference.EMPTY,
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
// endregion
// region buildSelectableSubtitle
@ -386,7 +193,6 @@ internal class SwapProviderStateBuilderTest {
private fun provider(
id: String,
isRecommended: Boolean,
name: String = "Provider",
iconUrl: String = "https://icon",
): SwapProvider = mockk {
@ -394,7 +200,16 @@ internal class SwapProviderStateBuilderTest {
every { this@mockk.name } returns name
every { imageLarge } returns iconUrl
every { type } returns ExchangeProviderType.DEX
every { this@mockk.isRecommended } returns isRecommended
}
private fun quoteState(
toTokenInfo: TokenSwapInfo,
fromTokenInfo: TokenSwapInfo = toTokenInfo,
permissionState: PermissionDataState = PermissionDataState.Empty,
): SwapState.QuotesLoadedState = mockk {
every { this@mockk.fromTokenInfo } returns fromTokenInfo
every { this@mockk.toTokenInfo } returns toTokenInfo
every { this@mockk.permissionState } returns permissionState
}
private fun tokenInfo(symbol: String, decimals: Int, amount: BigDecimal): TokenSwapInfo {