Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-16 18:10:00 +04:00
parent 2e8d0bc03b
commit 298e7a5565
29 changed files with 687 additions and 43 deletions

View file

@ -34,4 +34,5 @@ dependencies {
testImplementation(deps.test.junit5)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
testImplementation(deps.test.coroutine)
}

View file

@ -38,11 +38,14 @@ fun SwapCurrencies.getGroupWithDirection(swapDirection: SwapDirection): SwapCurr
* @param available list of available currencies to swap
* @param available list of unavailable currencies to swap
* @param isAfterSearch flag indicates whether user searched token
* @param availableForSwap currencies that are unavailable for the current flow (e.g. Send with Swap), but
* available in the regular Swap flow. Empty by default for backward compatibility (regular Swap doesn't fill it).
*/
data class SwapCurrenciesGroup(
val available: List<SwapCryptoCurrency>,
val unavailable: List<SwapCryptoCurrency>,
val isAfterSearch: Boolean,
val availableForSwap: List<SwapCryptoCurrency> = emptyList(),
)
/**

View file

@ -24,11 +24,13 @@ class GetSwapSupportedPairsUseCase(
filterProviderTypes: List<ExpressProviderType>,
swapTxType: SwapTxType,
) = Either.catch {
// Request all provider types so the use case can tell apart currencies available for the current flow
// from those available only in the regular Swap flow. The current-flow filter is applied below.
val pairs = swapRepositoryV2.getSupportedPairs(
userWallet = userWallet,
initialCurrency = initialCurrency,
cryptoCurrencyList = cryptoCurrencyList,
filterProviderTypes = filterProviderTypes,
filterProviderTypes = emptyList(),
swapTxType = swapTxType,
)
@ -42,12 +44,14 @@ class GetSwapSupportedPairsUseCase(
filteringCurrency = { it.from },
groupingCurrency = { it.to },
cryptoCurrencyList = filteredOutInitial,
allowedProviderTypes = filterProviderTypes,
)
val toGroup = pairs.groupPairs(
initialCurrency = initialCurrency,
filteringCurrency = { it.to },
groupingCurrency = { it.from },
cryptoCurrencyList = filteredOutInitial,
allowedProviderTypes = filterProviderTypes,
)
SwapCurrencies(
@ -61,8 +65,9 @@ class GetSwapSupportedPairsUseCase(
filteringCurrency: (SwapPairModel) -> CryptoCurrencyStatus,
groupingCurrency: (SwapPairModel) -> CryptoCurrencyStatus,
cryptoCurrencyList: List<CryptoCurrency>,
allowedProviderTypes: List<ExpressProviderType>,
): SwapCurrenciesGroup {
val availableCryptoCurrencies = asSequence()
val candidates = asSequence()
.filter { pair ->
filteringCurrency(pair).currency.id.rawCurrencyId == initialCurrency.id.rawCurrencyId
}
@ -75,18 +80,32 @@ class GetSwapSupportedPairsUseCase(
.map { pair ->
val toCurrency = groupingCurrency(pair)
val isTxExtrasSupported = toCurrency.currency.network.transactionExtrasType.isTxExtrasSupported()
val filteredProviders = if (isTxExtrasSupported) {
pair.providers.filter { it.isExtraIdSupported }
} else {
pair.providers
}
SwapCryptoCurrency(toCurrency, filteredProviders)
// Providers eligible for the current flow (e.g. Send with Swap): extra-id support + allowed type
val eligibleProviders = pair.providers
.filter { !isTxExtrasSupported || it.isExtraIdSupported }
.filter { allowedProviderTypes.isEmpty() || it.type in allowedProviderTypes }
// The pair has any provider => it can still be swapped in the regular Swap flow
SwapCryptoCurrency(toCurrency, eligibleProviders) to pair.providers.isNotEmpty()
}
.filter { it.providers.isNotEmpty() }
.toList()
val unavailableCryptoCurrencies =
cryptoCurrencyList - availableCryptoCurrencies.map { it.currencyStatus.currency }.toSet()
val availableCryptoCurrencies = candidates
.filter { (currency, _) -> currency.providers.isNotEmpty() }
.map { (currency, _) -> currency }
val availableCurrencies = availableCryptoCurrencies.map { it.currencyStatus.currency }.toSet()
// Currencies with no providers for the current flow, but available in the regular Swap flow
val availableForSwapCryptoCurrencies = candidates
.filter { (currency, isAvailableForRegularSwap) ->
currency.providers.isEmpty() && isAvailableForRegularSwap
}
.map { (currency, _) -> currency }
.distinctBy { it.currencyStatus.currency }
.filterNot { it.currencyStatus.currency in availableCurrencies }
val usedCurrencies = availableCurrencies +
availableForSwapCryptoCurrencies.map { it.currencyStatus.currency }.toSet()
val unavailableCryptoCurrencies = cryptoCurrencyList - usedCurrencies
return SwapCurrenciesGroup(
available = availableCryptoCurrencies,
@ -100,6 +119,7 @@ class GetSwapSupportedPairsUseCase(
)
},
isAfterSearch = false,
availableForSwap = availableForSwapCryptoCurrencies,
)
}
}

View file

@ -0,0 +1,190 @@
package com.tangem.domain.swap.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressProviderType
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.models.wallet.UserWallet
import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.models.SwapPairModel
import com.tangem.domain.swap.models.SwapTxType
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class GetSwapSupportedPairsUseCaseTest {
private val swapRepositoryV2: SwapRepositoryV2 = mockk()
private val swapErrorResolver: SwapErrorResolver = mockk()
private val useCase = GetSwapSupportedPairsUseCase(
swapRepositoryV2 = swapRepositoryV2,
swapErrorResolver = swapErrorResolver,
)
private val initialCurrency = createCurrency("initial")
private val cexCurrency = createCurrency("cex")
private val dexOnlyCurrency = createCurrency("dex-only")
private val unavailableCurrency = createCurrency("unavailable")
@BeforeEach
fun setup() {
clearMocks(swapRepositoryV2)
}
@Test
fun `GIVEN cex, dex-only and missing currencies WHEN invoke THEN split into three buckets`() = runTest {
// Arrange
val pairs = listOf(
createPair(from = initialCurrency, to = cexCurrency, providers = listOf(cexProvider)),
createPair(from = initialCurrency, to = dexOnlyCurrency, providers = listOf(dexProvider)),
// unavailableCurrency intentionally has no pair at all
)
coEvery {
swapRepositoryV2.getSupportedPairs(any(), any(), any(), any(), any())
} returns pairs
// Act
val result = useCase.invoke(
userWallet = userWallet,
initialCurrency = initialCurrency,
cryptoCurrencyList = listOf(initialCurrency, cexCurrency, dexOnlyCurrency, unavailableCurrency),
filterProviderTypes = listOf(ExpressProviderType.CEX),
swapTxType = SwapTxType.SendWithSwap,
)
// Assert
val fromGroup = result.getOrNull()!!.fromGroup
assertThat(fromGroup.available.map { it.currencyStatus.currency }).containsExactly(cexCurrency)
assertThat(fromGroup.availableForSwap.map { it.currencyStatus.currency }).containsExactly(dexOnlyCurrency)
assertThat(fromGroup.unavailable.map { it.currencyStatus.currency }).containsExactly(unavailableCurrency)
}
@Test
fun `GIVEN dex pair AND no type restriction WHEN invoke THEN currency is available not availableForSwap`() =
runTest {
// Arrange — allowedProviderTypes empty means any provider type is eligible for the current flow
val pairs = listOf(
createPair(from = initialCurrency, to = dexOnlyCurrency, providers = listOf(dexProvider)),
)
coEvery {
swapRepositoryV2.getSupportedPairs(any(), any(), any(), any(), any())
} returns pairs
// Act
val result = useCase.invoke(
userWallet = userWallet,
initialCurrency = initialCurrency,
cryptoCurrencyList = listOf(initialCurrency, dexOnlyCurrency),
filterProviderTypes = emptyList(),
swapTxType = SwapTxType.SendWithSwap,
)
// Assert
val fromGroup = result.getOrNull()!!.fromGroup
assertThat(fromGroup.available.map { it.currencyStatus.currency }).containsExactly(dexOnlyCurrency)
assertThat(fromGroup.availableForSwap).isEmpty()
}
@Test
fun `GIVEN memo network with provider without extra-id WHEN invoke THEN currency is availableForSwap`() = runTest {
// Arrange — to-network requires extra id, but the only CEX provider doesn't support it
val memoCurrency = createCurrency(rawId = "memo", txExtras = Network.TransactionExtrasType.MEMO)
val pairs = listOf(
createPair(from = initialCurrency, to = memoCurrency, providers = listOf(cexProviderNoExtraId)),
)
coEvery {
swapRepositoryV2.getSupportedPairs(any(), any(), any(), any(), any())
} returns pairs
// Act
val result = useCase.invoke(
userWallet = userWallet,
initialCurrency = initialCurrency,
cryptoCurrencyList = listOf(initialCurrency, memoCurrency),
filterProviderTypes = listOf(ExpressProviderType.CEX),
swapTxType = SwapTxType.SendWithSwap,
)
// Assert
val fromGroup = result.getOrNull()!!.fromGroup
assertThat(fromGroup.available).isEmpty()
assertThat(fromGroup.availableForSwap.map { it.currencyStatus.currency }).containsExactly(memoCurrency)
}
@Test
fun `GIVEN pair with both cex and dex providers WHEN invoke THEN currency is available not availableForSwap`() =
runTest {
// Arrange
val pairs = listOf(
createPair(from = initialCurrency, to = cexCurrency, providers = listOf(cexProvider, dexProvider)),
)
coEvery {
swapRepositoryV2.getSupportedPairs(any(), any(), any(), any(), any())
} returns pairs
// Act
val result = useCase.invoke(
userWallet = userWallet,
initialCurrency = initialCurrency,
cryptoCurrencyList = listOf(initialCurrency, cexCurrency),
filterProviderTypes = listOf(ExpressProviderType.CEX),
swapTxType = SwapTxType.SendWithSwap,
)
// Assert
val fromGroup = result.getOrNull()!!.fromGroup
assertThat(fromGroup.available.map { it.currencyStatus.currency }).containsExactly(cexCurrency)
assertThat(fromGroup.availableForSwap).isEmpty()
}
private fun createPair(from: CryptoCurrency, to: CryptoCurrency, providers: List<ExpressProvider>) = SwapPairModel(
from = CryptoCurrencyStatus(currency = from, value = mockk(relaxed = true)),
to = CryptoCurrencyStatus(currency = to, value = mockk(relaxed = true)),
providers = providers,
)
private companion object {
val userWallet: UserWallet = mockk(relaxed = true)
val cexProvider = createProvider("cex-1", ExpressProviderType.CEX, isExtraIdSupported = true)
val cexProviderNoExtraId = createProvider("cex-2", ExpressProviderType.CEX, isExtraIdSupported = false)
val dexProvider = createProvider("dex-1", ExpressProviderType.DEX, isExtraIdSupported = false)
fun createProvider(id: String, type: ExpressProviderType, isExtraIdSupported: Boolean) = ExpressProvider(
providerId = id,
rateTypes = listOf(ExpressRateType.Float),
name = id,
type = type,
imageLarge = "",
termsOfUse = null,
privacyPolicy = null,
slippage = null,
isExtraIdSupported = isExtraIdSupported,
)
fun createCurrency(
rawId: String,
txExtras: Network.TransactionExtrasType = Network.TransactionExtrasType.NONE,
): CryptoCurrency {
val id: CryptoCurrency.ID = mockk {
every { rawCurrencyId } returns CryptoCurrency.RawID(rawId)
every { rawNetworkId } returns rawId
}
return mockk<CryptoCurrency>(relaxed = true).also { currency ->
every { currency.id } returns id
every { currency.network.transactionExtrasType } returns txExtras
}
}
}
}