Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-16 20:41:42 +04:00
parent 50332186fa
commit 5f6767abaf
58 changed files with 85 additions and 717 deletions

View file

@ -14,26 +14,6 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
toggle = FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED,
)
override val isSwapSwitchToTransferEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED,
)
override val isSwapIntegratedApproveEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15120_SWAP_INTEGRATED_APPROVE,
)
override val isExpressShareButtonEnabled: Boolean
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
override val isHighFeeWarningEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.TWI_1367_HIGH_FEE_WARNING_ENABLED,

View file

@ -28,18 +28,11 @@ internal object SwapProviderResolver {
/**
* 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.
* When 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.
*/
fun findBest(
states: Map<SwapProvider, SwapState.QuotesLoadedState>,
isSwapBestDexRateEnabled: Boolean,
): SwapProvider? {
if (!isSwapBestDexRateEnabled) return findBestRated(states)
fun findBest(states: Map<SwapProvider, SwapState.QuotesLoadedState>): SwapProvider? {
val dexStates = states.filterKeys { it.type.isDex() }
return if (dexStates.isNotEmpty()) {
findBestRated(dexStates)
@ -54,7 +47,7 @@ internal object SwapProviderResolver {
* 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):
* provider mix:
* - [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
@ -62,22 +55,17 @@ internal object SwapProviderResolver {
* 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
@ -90,7 +78,7 @@ internal object SwapProviderResolver {
needApplyFCARestrictions && provider.isFCARestricted() -> AdditionalBadge.FCAWarningList
permissionState is PermissionDataState.PermissionRequired -> AdditionalBadge.PermissionRequired
provider.isRecommended -> AdditionalBadge.Recommended
isBestRateBadgeAllowed -> resolveBestRateBadge(states, provider, isSwapBestDexRateEnabled)
isBestRateBadgeAllowed -> resolveBestRateBadge(states, provider)
else -> AdditionalBadge.Empty
}
}
@ -102,16 +90,10 @@ internal object SwapProviderResolver {
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() }

View file

@ -281,7 +281,6 @@ internal class SwapModel @Inject constructor(
get() {
val permissionState = dataState.getCurrentLoadedSwapState()?.permissionState
return permissionState == PermissionDataState.Empty ||
swapFeatureToggles.isSwapIntegratedApproveEnabled &&
permissionState is PermissionDataState.PermissionSettings
}
@ -1175,7 +1174,6 @@ internal class SwapModel @Inject constructor(
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
states = loadedStates,
state = state,
isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled,
)
val swapFee = getSelectedSwapFee()
uiState = stateBuilder.createQuotesLoadedState(
@ -1272,7 +1270,6 @@ internal class SwapModel @Inject constructor(
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
states = loadedStates,
state = state,
isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled,
)
uiState = stateBuilder.createQuotesErrorState(
uiStateHolder = uiState,
@ -1327,10 +1324,7 @@ internal class SwapModel @Inject constructor(
return if (consideredProviders.isNotEmpty()) {
val successLoadedData = consideredProviders.getLastLoadedSuccessStates()
val bestQuotesProvider = SwapProviderResolver.findBest(
states = successLoadedData,
isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled,
)
val bestQuotesProvider = SwapProviderResolver.findBest(states = successLoadedData)
val currentSelected = dataState.selectedProvider
if (currentSelected != null && consideredProviders.keys.contains(currentSelected)) {
// logic for always choose best if already selected provider
@ -2111,7 +2105,6 @@ internal class SwapModel @Inject constructor(
selectedProviderId = providerId,
pricesLowerBest = pricesLowerBest,
providersStates = dataState.lastLoadedSwapStates,
isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled,
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
) { uiState = stateBuilder.dismissBottomSheet(uiState) }
},
@ -2663,8 +2656,7 @@ internal class SwapModel @Inject constructor(
val swapDataForCall = resolveDexSwapDataForFee(quoteState)
.getOrElse { return Either.Left(it) }
val integratedSettings = (quoteState.permissionState as? PermissionDataState.PermissionSettings)
?.takeIf { swapFeatureToggles.isSwapIntegratedApproveEnabled }
val integratedSettings = quoteState.permissionState as? PermissionDataState.PermissionSettings
return swapInteractor.loadSwapFee(
quotesLoadedState = quoteState,
@ -2833,8 +2825,7 @@ internal class SwapModel @Inject constructor(
private fun isPermissionNotificationShown(): Boolean {
val permissionState = dataState.getCurrentLoadedSwapState()?.permissionState
val isApprovalIntegrated = swapFeatureToggles.isSwapIntegratedApproveEnabled &&
permissionState is PermissionDataState.PermissionSettings
val isApprovalIntegrated = permissionState is PermissionDataState.PermissionSettings
return permissionState != null && permissionState !is PermissionDataState.Empty && !isApprovalIntegrated
}

View file

@ -1220,7 +1220,6 @@ internal class StateBuilder(
pricesLowerBest: Map<String, Float>,
providersStates: Map<SwapProvider, SwapState>,
needApplyFCARestrictions: Boolean,
isSwapBestDexRateEnabled: Boolean,
onDismiss: () -> Unit,
): SwapStateHolder {
val successStates = providersStates.getLastLoadedSuccessStates()
@ -1231,7 +1230,6 @@ internal class StateBuilder(
provider = entry.key,
needApplyFCARestrictions = needApplyFCARestrictions,
state = entry.value,
isSwapBestDexRateEnabled = isSwapBestDexRateEnabled,
)
entry.convertToProviderBottomSheetState(
pricesLowerBest = pricesLowerBest,

View file

@ -20,8 +20,8 @@ import java.math.BigDecimal
* 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.
* same `from` input. "Best DEX Rate" prefers the best-rated DEX/DEX_BRIDGE provider when any DEX is
* present.
*/
internal class SwapProviderResolverTest {
@ -50,7 +50,7 @@ internal class SwapProviderResolverTest {
dexBridge to quote(fromFiat = "100", toFiat = "105"),
)
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true)
val best = SwapProviderResolver.findBest(states)
assertThat(best).isEqualTo(dex1)
}
@ -62,23 +62,11 @@ internal class SwapProviderResolverTest {
cex2 to quote(fromFiat = "100", toFiat = "120"), // best CEX
)
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true)
val best = SwapProviderResolver.findBest(states)
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(
@ -87,7 +75,7 @@ internal class SwapProviderResolverTest {
dexBridge to quote(fromFiat = "100", toFiat = "115"), // best among DEX-based
)
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true)
val best = SwapProviderResolver.findBest(states)
assertThat(best).isEqualTo(dexBridge)
}
@ -110,7 +98,6 @@ internal class SwapProviderResolverTest {
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".
@ -129,7 +116,6 @@ internal class SwapProviderResolverTest {
provider = cex1,
needApplyFCARestrictions = false,
state = states.getValue(cex1),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
@ -150,7 +136,6 @@ internal class SwapProviderResolverTest {
provider = cex1,
needApplyFCARestrictions = false,
state = states.getValue(cex1),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
@ -169,7 +154,6 @@ internal class SwapProviderResolverTest {
provider = dex1,
needApplyFCARestrictions = false,
state = states.getValue(dex1),
isSwapBestDexRateEnabled = true,
)
// CEX wins overall, so the best DEX additionally gets the "Best DEX rate" badge.
@ -189,7 +173,6 @@ internal class SwapProviderResolverTest {
provider = dexBridge,
needApplyFCARestrictions = false,
state = states.getValue(dexBridge),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
@ -209,7 +192,6 @@ internal class SwapProviderResolverTest {
provider = dex1,
needApplyFCARestrictions = false,
state = states.getValue(dex1),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
@ -227,7 +209,6 @@ internal class SwapProviderResolverTest {
provider = dexBridge,
needApplyFCARestrictions = false,
state = states.getValue(dexBridge),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
@ -247,51 +228,11 @@ internal class SwapProviderResolverTest {
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)
@ -305,7 +246,6 @@ internal class SwapProviderResolverTest {
provider = restricted,
needApplyFCARestrictions = true,
state = states.getValue(restricted),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
@ -326,7 +266,6 @@ internal class SwapProviderResolverTest {
provider = restricted,
needApplyFCARestrictions = true,
state = states.getValue(restricted),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
@ -346,7 +285,6 @@ internal class SwapProviderResolverTest {
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.
@ -365,7 +303,6 @@ internal class SwapProviderResolverTest {
provider = cex1,
needApplyFCARestrictions = true,
state = states.getValue(cex1),
isSwapBestDexRateEnabled = true,
)
// FCA restrictions globally on suppress the best-rate badge even for non-restricted providers.
@ -388,7 +325,6 @@ internal class SwapProviderResolverTest {
provider = cex1,
needApplyFCARestrictions = false,
state = states.getValue(cex1),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired)
@ -407,7 +343,6 @@ internal class SwapProviderResolverTest {
provider = recommended,
needApplyFCARestrictions = false,
state = states.getValue(recommended),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Recommended)
@ -422,7 +357,6 @@ internal class SwapProviderResolverTest {
provider = cex1,
needApplyFCARestrictions = false,
state = states.getValue(cex1),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
@ -440,7 +374,6 @@ internal class SwapProviderResolverTest {
provider = cex2, // not the best
needApplyFCARestrictions = false,
state = states.getValue(cex2),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
@ -458,7 +391,6 @@ internal class SwapProviderResolverTest {
provider = cex1,
needApplyFCARestrictions = false,
state = states.getValue(cex1),
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
@ -476,7 +408,6 @@ internal class SwapProviderResolverTest {
provider = cex1,
needApplyFCARestrictions = false,
state = null,
isSwapBestDexRateEnabled = true,
)
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)

View file

@ -2,7 +2,6 @@ package com.tangem.feature.swap.model
import com.google.common.truth.Truth.assertThat
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import io.mockk.every
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -11,7 +10,7 @@ import org.junit.jupiter.api.Test
*
* The getter is `true` when the current loaded swap state needs no approval:
* - always when [PermissionDataState.Empty]
* - additionally for [PermissionDataState.PermissionSettings] when the integrated-approve toggle is ON
* - additionally for [PermissionDataState.PermissionSettings]
*/
internal class SwapModelIsPermissionNotNeededTest : SwapModelTestBase() {
@ -20,11 +19,7 @@ internal class SwapModelIsPermissionNotNeededTest : SwapModelTestBase() {
setUpBase()
}
private fun modelWithPermissionState(
permissionState: PermissionDataState,
isIntegratedApproveEnabled: Boolean,
): SwapModel {
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns isIntegratedApproveEnabled
private fun modelWithPermissionState(permissionState: PermissionDataState): SwapModel {
val provider = swapProvider()
val model = createModel()
model.dataState = model.dataState.copy(
@ -35,48 +30,23 @@ internal class SwapModelIsPermissionNotNeededTest : SwapModelTestBase() {
}
@Test
fun `GIVEN toggle OFF and Empty permission THEN permission is not needed`() {
val model = modelWithPermissionState(PermissionDataState.Empty, isIntegratedApproveEnabled = false)
fun `GIVEN Empty permission THEN permission is not needed`() {
val model = modelWithPermissionState(PermissionDataState.Empty)
assertThat(model.isPermissionNotNeeded).isTrue()
}
@Test
fun `GIVEN toggle OFF and PermissionSettings THEN permission is needed`() {
val model = modelWithPermissionState(permissionSettings(), isIntegratedApproveEnabled = false)
fun `GIVEN PermissionSettings THEN permission is not needed`() {
val model = modelWithPermissionState(permissionSettings())
assertThat(model.isPermissionNotNeeded).isFalse()
assertThat(model.isPermissionNotNeeded).isTrue()
}
@Test
fun `GIVEN toggle OFF and PermissionRequired THEN permission is needed`() {
fun `GIVEN PermissionRequired THEN permission is needed`() {
val model = modelWithPermissionState(
PermissionDataState.PermissionRequired(isResetApproval = false, spenderAddress = "0x"),
isIntegratedApproveEnabled = false,
)
assertThat(model.isPermissionNotNeeded).isFalse()
}
@Test
fun `GIVEN toggle ON and Empty permission THEN permission is not needed`() {
val model = modelWithPermissionState(PermissionDataState.Empty, isIntegratedApproveEnabled = true)
assertThat(model.isPermissionNotNeeded).isTrue()
}
@Test
fun `GIVEN toggle ON and PermissionSettings THEN permission is not needed`() {
val model = modelWithPermissionState(permissionSettings(), isIntegratedApproveEnabled = true)
assertThat(model.isPermissionNotNeeded).isTrue()
}
@Test
fun `GIVEN toggle ON and PermissionRequired THEN permission is needed`() {
val model = modelWithPermissionState(
PermissionDataState.PermissionRequired(isResetApproval = false, spenderAddress = "0x"),
isIntegratedApproveEnabled = true,
)
assertThat(model.isPermissionNotNeeded).isFalse()
@ -84,7 +54,6 @@ internal class SwapModelIsPermissionNotNeededTest : SwapModelTestBase() {
@Test
fun `GIVEN no current loaded state THEN permission is needed`() {
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns true
val model = createModel()
assertThat(model.isPermissionNotNeeded).isFalse()

View file

@ -130,7 +130,6 @@ internal abstract class SwapModelTestBase {
coEvery { shouldShowStoriesUseCase.invokeSync(any()) } returns false
coEvery { initialCurrenciesResolver.invoke(any(), any(), any(), any()) } returns (null to null)
every { getSelectedAppCurrencyUseCase.invoke() } returns emptyFlow()
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns true
}
protected fun createParams(): SwapComponent.Params = SwapComponent.Params(