Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-05 16:00:06 +05:00
parent a78bfc6191
commit 2ab80154d8
13 changed files with 740 additions and 54 deletions

View file

@ -334,7 +334,6 @@ internal class ChildFactory @Inject constructor(
cryptoAmount = tangemPayInput.cryptoAmount,
fiatAmount = tangemPayInput.fiatAmount,
depositAddress = tangemPayInput.depositAddress,
isWithdrawal = tangemPayInput.isWithdrawal,
)
},
),

View file

@ -219,7 +219,6 @@ sealed class AppRoute(val path: String) : Route {
val cryptoAmount: SerializedBigDecimal,
val fiatAmount: SerializedBigDecimal,
val depositAddress: String,
val isWithdrawal: Boolean,
)
@Serializable

View file

@ -19,7 +19,6 @@ interface SwapComponent : ComposableContentComponent {
val cryptoAmount: BigDecimal,
val fiatAmount: BigDecimal,
val depositAddress: String,
val isWithdrawal: Boolean,
)
/** Preferred position of the pre-selected currency on the swap screen. */

View file

@ -33,6 +33,12 @@ interface SwapInteractor {
pairs: List<SwapPairLeast>,
): List<SwapProvider>
fun extractFromSwapCurrencyFromPair(
pair: SwapPairLeast,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
): SwapCurrencyStatus?
@Throws(IllegalStateException::class)
suspend fun findBestQuote(
fromSwapCurrencyStatus: SwapCurrencyStatus,

View file

@ -154,6 +154,25 @@ internal class SwapInteractorImpl @Inject constructor(
}?.providers.orEmpty()
}
override fun extractFromSwapCurrencyFromPair(
pair: SwapPairLeast,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
): SwapCurrencyStatus? {
return if (pair.from.network == fromSwapCurrencyStatus.currency.network.rawId &&
pair.from.contractAddress == fromSwapCurrencyStatus.currency.getContractAddress()
) {
fromSwapCurrencyStatus
} else if (
pair.from.network == toSwapCurrencyStatus.currency.network.rawId &&
pair.from.contractAddress == toSwapCurrencyStatus.currency.getContractAddress()
) {
toSwapCurrencyStatus
} else {
null
}
}
override suspend fun findProvidersForPairWithCheck(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,

View file

@ -2,7 +2,6 @@ package com.tangem.feature.swap.domain.models.domain
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import java.math.BigDecimal
/**
@ -18,11 +17,6 @@ data class SwapPairLeast(
val providers: List<SwapProvider>,
)
data class CryptoCurrencySwapInfo(
val currencyStatus: CryptoCurrencyStatus,
val providers: List<SwapProvider>,
)
/**
* Provider that could swap given cryptocurrencies
*

View file

@ -0,0 +1,352 @@
package com.tangem.feature.swap.domain
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.SwapPairLeast
import com.tangem.utils.extensions.filterIf
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Tests for the Tangem Pay provider-filtering logic that lives in
* `SwapModel.filterTangemPayProviders` (private extension on `List<SwapPairLeast>`).
*
* Because `SwapModel` is a `@ModelScoped` Decompose class with ~30 constructor dependencies
* and requires a Decompose component context, it cannot be instantiated in a unit test.
* Instead, we verify the *algorithm* end-to-end:
*
* 1. [SwapInteractorImpl.extractFromSwapCurrencyFromPair] resolves which
* [SwapCurrencyStatus] is the FROM side of a given pair.
* 2. `isTangemPayWithdrawal(status) = status?.account is Account.Payment` the check.
* 3. `List.filterIf(isWithdrawal) { provider.type == CEX }` the filtering.
*
* We exercise all three together in test-space so that every business rule of
* `filterTangemPayProviders` is covered, including all 9 edge cases from the task spec.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@DisplayName("filterTangemPayProviders — Payment-account provider filtering logic")
internal class SwapFilterTangemPayProvidersLogicTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val btcNetwork = Blockchain.Bitcoin.toNetworkId()
private val polygonNetwork = Blockchain.Polygon.toNetworkId()
private val userWalletId = UserWalletId(stringValue = "deadbeef")
// -----------------------------------------------------------------------
// Helpers — mirrors the private logic in SwapModel.filterTangemPayProviders
// -----------------------------------------------------------------------
/**
* Pure reimplementation of `SwapModel.filterTangemPayProviders` that delegates
* to the real [SwapInteractorImpl.extractFromSwapCurrencyFromPair] for the
* FROM-side resolution. This lets every unit test exercise the *exact same*
* algorithm as the production code without instantiating `SwapModel`.
*/
private fun List<SwapPairLeast>.applyTangemPayFilter(
fromStatus: SwapCurrencyStatus,
toStatus: SwapCurrencyStatus,
): List<SwapPairLeast> = map { pair ->
val resolvedFrom = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
val isTangemPayWithdrawal = resolvedFrom?.account is Account.Payment
val filterProviderTypes = if (isTangemPayWithdrawal) {
listOf(ExchangeProviderType.CEX)
} else {
emptyList()
}
pair.copy(
providers = pair.providers.filterIf(filterProviderTypes.isNotEmpty()) { provider ->
provider.type in filterProviderTypes
},
)
}
// -----------------------------------------------------------------------
// Builders
// -----------------------------------------------------------------------
private fun buildPaymentStatus(
networkRawId: String = ethNetwork,
contractAddress: String = "0",
isCoin: Boolean = true,
): SwapCurrencyStatus = buildSwapCurrencyStatus(
networkRawId = networkRawId,
contractAddress = contractAddress,
isCoin = isCoin,
).copy(account = Account.Payment(userWalletId))
private fun buildCryptoPortfolioStatus(
networkRawId: String = ethNetwork,
contractAddress: String = "0",
isCoin: Boolean = true,
): SwapCurrencyStatus = buildSwapCurrencyStatus(
networkRawId = networkRawId,
contractAddress = contractAddress,
isCoin = isCoin,
).copy(account = Account.CryptoPortfolio.createMainAccount(userWalletId))
private fun mixedProviders() = listOf(
buildSwapProvider(ExchangeProviderType.CEX, "cex-1"),
buildSwapProvider(ExchangeProviderType.DEX, "dex-1"),
buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, "bridge-1"),
)
private fun cexOnlyProviders() = listOf(
buildSwapProvider(ExchangeProviderType.CEX, "cex-only"),
)
private fun dexOnlyProviders() = listOf(
buildSwapProvider(ExchangeProviderType.DEX, "dex-only"),
)
// -----------------------------------------------------------------------
// Test cases
// -----------------------------------------------------------------------
@Nested
@DisplayName("Payment account FROM side — only CEX providers must remain")
inner class PaymentAccountFromSide {
@Test
@DisplayName("should keep only CEX when FROM status is Payment account and providers are mixed")
fun `should keep only CEX when FROM status is Payment account and providers are mixed`() {
// given — FROM is a Payment account, pair.from matches FROM
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = mixedProviders(),
)
// when
val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus)
// then — only CEX survives
assertThat(result).hasSize(1)
assertThat(result[0].providers).hasSize(1)
assertThat(result[0].providers[0].type).isEqualTo(ExchangeProviderType.CEX)
}
@Test
@DisplayName("should return empty providers when Payment account FROM and no CEX in list")
fun `should return empty providers when Payment account FROM and no CEX in list`() {
// given — FROM is Payment, no CEX provider exists
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = dexOnlyProviders(),
)
// when
val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus)
// then — all providers removed because none are CEX
assertThat(result[0].providers).isEmpty()
}
@Test
@DisplayName("should leave list unchanged when Payment account FROM and all providers already CEX")
fun `should leave list unchanged when Payment account FROM and all providers already CEX`() {
// given — FROM is Payment, list is already all CEX
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = cexOnlyProviders(),
)
// when
val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus)
// then — single CEX provider still present, unchanged
assertThat(result[0].providers).hasSize(1)
assertThat(result[0].providers[0].type).isEqualTo(ExchangeProviderType.CEX)
}
}
@Nested
@DisplayName("Non-Payment account — provider list must not be modified")
inner class NonPaymentAccount {
@Test
@DisplayName("should not filter providers when FROM status is CryptoPortfolio account")
fun `should not filter providers when FROM status is CryptoPortfolio account`() {
// given — FROM is a CryptoPortfolio account (regression guard)
val fromStatus = buildCryptoPortfolioStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = mixedProviders(),
)
// when
val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus)
// then — all 3 providers survive untouched
assertThat(result[0].providers).hasSize(3)
assertThat(result[0].providers.map { it.type })
.containsExactly(ExchangeProviderType.CEX, ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE)
}
}
@Nested
@DisplayName("Null resolved status — no filtering applied")
inner class NullResolvedStatus {
@Test
@DisplayName("should not filter when extractFromSwapCurrencyFromPair resolves null (unrelated pair)")
fun `should not filter when extractFromSwapCurrencyFromPair resolves null`() {
// given — pair.from is on an unrelated network (neither fromStatus nor toStatus)
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair = buildSwapPairLeast(
fromNetwork = polygonNetwork, // matches neither
fromContract = "0",
toNetwork = ethNetwork,
toContract = "0",
providers = mixedProviders(),
)
// when
val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus)
// then — null status → isTangemPayWithdrawal=false → no filter applied
assertThat(result[0].providers).hasSize(3)
}
}
@Nested
@DisplayName("Empty inputs — no crash, stable output")
inner class EmptyInputs {
@Test
@DisplayName("should return empty list when input pairs list is empty")
fun `should return empty list when input pairs list is empty`() {
// given
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
// when
val result = emptyList<SwapPairLeast>().applyTangemPayFilter(fromStatus, toStatus)
// then
assertThat(result).isEmpty()
}
@Test
@DisplayName("should handle empty provider list on a pair without crashing")
fun `should handle empty provider list on a pair without crashing`() {
// given — Payment account FROM, but the pair already has an empty provider list
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = emptyList(),
)
// when
val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus)
// then — stays empty, no crash
assertThat(result[0].providers).isEmpty()
}
}
@Nested
@DisplayName("Multiple pairs — filtering applied per-pair independently")
inner class MultiplePairs {
@Test
@DisplayName("should filter only pairs whose resolved FROM is a Payment account")
fun `should filter only pairs whose resolved FROM is a Payment account`() {
// given — 2 pairs:
// pair1: pair.from == ethNetwork → fromStatus (Payment) → filter to CEX only
// pair2: pair.from == btcNetwork → toStatus (non-Payment) → no filter
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair1 = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = mixedProviders(),
)
val pair2 = buildSwapPairLeast(
fromNetwork = btcNetwork, // matches toStatus (CryptoPortfolio)
fromContract = "0",
toNetwork = ethNetwork,
toContract = "0",
providers = mixedProviders(),
)
// when
val result = listOf(pair1, pair2).applyTangemPayFilter(fromStatus, toStatus)
// then
// pair1 resolved to Payment account → only CEX remains
assertThat(result[0].providers).hasSize(1)
assertThat(result[0].providers[0].type).isEqualTo(ExchangeProviderType.CEX)
// pair2 resolved to CryptoPortfolio → all 3 providers intact
assertThat(result[1].providers).hasSize(3)
}
@Test
@DisplayName("should filter all pairs when all resolved FROM statuses are Payment accounts")
fun `should filter all pairs when all resolved FROM statuses are Payment accounts`() {
// given — both pairs have their pair.from matching the Payment account
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair1 = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = mixedProviders(),
)
val pair2 = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = polygonNetwork,
toContract = "0",
providers = dexOnlyProviders(),
)
// when
val result = listOf(pair1, pair2).applyTangemPayFilter(fromStatus, toStatus)
// then — pair1: CEX kept; pair2: DEX removed → empty
assertThat(result[0].providers).hasSize(1)
assertThat(result[0].providers[0].type).isEqualTo(ExchangeProviderType.CEX)
assertThat(result[1].providers).isEmpty()
}
}
}

View file

@ -0,0 +1,282 @@
package com.tangem.feature.swap.domain
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Tests for [SwapInteractorImpl.extractFromSwapCurrencyFromPair].
*
* This function resolves which of the two [com.tangem.domain.swap.models.SwapCurrencyStatus]
* arguments corresponds to the `from` side of a given [com.tangem.feature.swap.domain.models.domain.SwapPairLeast].
*
* It is the building block behind the Tangem Pay provider-filtering logic in `SwapModel`:
* the resolved "from" currency status is inspected for an [com.tangem.domain.models.account.Account.Payment]
* account; when it belongs to a payment account, only CEX providers are kept for that pair.
*
* A pair is matched on both `network` (rawId) and `contractAddress` ("0" for coins, the token
* contract for tokens). The `from` side is checked first, then the `to` side, otherwise null.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplExtractFromSwapCurrencyTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val btcNetwork = Blockchain.Bitcoin.toNetworkId()
private val polygonNetwork = Blockchain.Polygon.toNetworkId()
@Nested
inner class MatchesFromSide {
@Test
fun `should return fromSwapCurrencyStatus when pair from matches the from coin by network and contract`() {
// Given — coin: getContractAddress() == "0"
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isSameInstanceAs(fromStatus)
}
@Test
fun `should return fromSwapCurrencyStatus when pair from matches the from token by network and contract`() {
// Given — token: getContractAddress() == contractAddress
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xToken",
isCoin = false,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0xToken",
toNetwork = btcNetwork,
toContract = "0",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isSameInstanceAs(fromStatus)
}
}
@Nested
inner class MatchesToSide {
@Test
fun `should return toSwapCurrencyStatus when pair from matches the to side (reverse-direction pair)`() {
// Given — pair.from points at the toStatus currency, not the fromStatus
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true)
val pair = buildSwapPairLeast(
fromNetwork = btcNetwork, // matches toStatus
fromContract = "0",
toNetwork = ethNetwork,
toContract = "0",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isSameInstanceAs(toStatus)
}
@Test
fun `should return toSwapCurrencyStatus when pair from matches to token by network and contract`() {
// Given
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(
networkRawId = polygonNetwork,
contractAddress = "0xUsdc",
isCoin = false,
)
val pair = buildSwapPairLeast(
fromNetwork = polygonNetwork, // matches toStatus token
fromContract = "0xUsdc",
toNetwork = ethNetwork,
toContract = "0",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isSameInstanceAs(toStatus)
}
}
@Nested
inner class NoMatch {
@Test
fun `should return null when pair from matches neither from nor to`() {
// Given — pair.from is on an unrelated network
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true)
val pair = buildSwapPairLeast(
fromNetwork = polygonNetwork, // matches neither
fromContract = "0",
toNetwork = ethNetwork,
toContract = "0",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isNull()
}
@Test
fun `should return null when network matches but contract address differs`() {
// Given — same eth network but different token contracts
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xAaa",
isCoin = false,
)
val toStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xBbb",
isCoin = false,
)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0xCcc", // matches neither contract
toNetwork = ethNetwork,
toContract = "0xAaa",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isNull()
}
@Test
fun `should return null when contract matches but network differs`() {
// Given — same contract address but on a different network than either status
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xShared",
isCoin = false,
)
val toStatus = buildSwapCurrencyStatus(
networkRawId = btcNetwork,
contractAddress = "0",
isCoin = true,
)
val pair = buildSwapPairLeast(
fromNetwork = polygonNetwork, // contract matches fromStatus but network does not
fromContract = "0xShared",
toNetwork = ethNetwork,
toContract = "0",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isNull()
}
}
@Nested
inner class Precedence {
@Test
fun `should prefer from side when both from and to would match the pair from`() {
// Given — both statuses are the same network+contract; from must win (checked first)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = ethNetwork,
toContract = "0",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then — from side has precedence and is returned, not the to side
assertThat(result).isSameInstanceAs(fromStatus)
assertThat(result).isNotSameInstanceAs(toStatus)
}
@Test
fun `pair providers are irrelevant to the resolution`() {
// Given — provider list should not affect which currency status is extracted
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = listOf(
buildSwapProvider(ExchangeProviderType.DEX, "dex"),
buildSwapProvider(ExchangeProviderType.CEX, "cex"),
),
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isSameInstanceAs(fromStatus)
}
}
}

View file

@ -98,7 +98,8 @@ internal class CexSwapFeeCalculatorTest {
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.5"),
selectedFeeToken = null, isGasless = true,
selectedFeeToken = null,
isGasless = true,
)
assertThat(result.isRight()).isTrue()
@ -131,7 +132,8 @@ internal class CexSwapFeeCalculatorTest {
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = null, isGasless = true,
selectedFeeToken = null,
isGasless = true,
)
assertThat(result.isLeft()).isTrue()
@ -161,7 +163,8 @@ internal class CexSwapFeeCalculatorTest {
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("2.0"),
selectedFeeToken = tokenStatus, isGasless = true,
selectedFeeToken = tokenStatus,
isGasless = true,
)
assertThat(result.isRight()).isTrue()
@ -208,7 +211,8 @@ internal class CexSwapFeeCalculatorTest {
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("3.0"),
selectedFeeToken = coinStatus, isGasless = true,
selectedFeeToken = coinStatus,
isGasless = true,
)
assertThat(result.isRight()).isTrue()
@ -234,33 +238,33 @@ internal class CexSwapFeeCalculatorTest {
}
@Test
fun `GIVEN explicit native selectedFeeToken with non-Ethereum fee WHEN calculate THEN bump is a no-op`() =
runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
val coinStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns coinCurrency
}
val rawFee = Fee.Common(
amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8),
)
coEvery {
estimateFeeUseCase(any(), any(), any())
} returns TransactionFee.Single(normal = rawFee).right()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = coinStatus, isGasless = true,
)
result.onRight { cexResult ->
val loaded = cexResult.transactionFee as TransactionFeeResult.Loaded
val unchanged = (loaded.fee as TransactionFee.Single).normal as Fee.Common
assertThat(unchanged).isSameInstanceAs(rawFee)
}
fun `GIVEN explicit native selectedFeeToken with non-Ethereum fee WHEN calculate THEN bump is a no-op`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
val coinStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns coinCurrency
}
val rawFee = Fee.Common(
amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8),
)
coEvery {
estimateFeeUseCase(any(), any(), any())
} returns TransactionFee.Single(normal = rawFee).right()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = coinStatus,
isGasless = true,
)
result.onRight { cexResult ->
val loaded = cexResult.transactionFee as TransactionFeeResult.Loaded
val unchanged = (loaded.fee as TransactionFee.Single).normal as Fee.Common
assertThat(unchanged).isSameInstanceAs(rawFee)
}
}
@Test
fun `GIVEN native path returns Left WHEN calculate THEN error is propagated`() = runTest {
@ -277,7 +281,8 @@ internal class CexSwapFeeCalculatorTest {
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = coinStatus, isGasless = true,
selectedFeeToken = coinStatus,
isGasless = true,
)
assertThat(result.isLeft()).isTrue()
@ -322,7 +327,8 @@ internal class CexSwapFeeCalculatorTest {
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = coinStatus, isGasless = true,
selectedFeeToken = coinStatus,
isGasless = true,
)
result.onRight { cexResult ->

View file

@ -153,6 +153,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } }
val shouldHideBlock by remember {
derivedStateOf {
// TODO collapse this and move to model
val isAmountEmptyOrZero = dataState.amount?.parseBigDecimalOrNull().isNullOrZero()
val isInsufficientFunds = model.uiState.isInsufficientFunds
val isProviderMissing = dataState.selectedProvider == null

View file

@ -110,6 +110,7 @@ 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
@ -546,7 +547,15 @@ internal class SwapModel @Inject constructor(
fromSwapCurrencyStatus = newFromSwapCurrencyStatus,
toSwapCurrencyStatus = newToSwapCurrencyStatus,
pairs = dataState.pairs,
selectedPairProviders = dataState.selectedPairProviders,
selectedPairProviders = if (newFromSwapCurrencyStatus == null || newToSwapCurrencyStatus == null) {
emptyList()
} else {
swapInteractor.findProvidersForPairWithCheck(
fromSwapCurrencyStatus = newFromSwapCurrencyStatus,
toSwapCurrencyStatus = newToSwapCurrencyStatus,
pairs = dataState.pairs,
)
},
)
filterTokensFromSelector()
uiState = stateBuilder.updateCurrenciesState(
@ -614,11 +623,7 @@ internal class SwapModel @Inject constructor(
swapInteractor.getPair(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
filterProviderTypes = if (tangemPayInput?.isWithdrawal == true) {
listOf(ExchangeProviderType.CEX)
} else {
ExchangeProviderType.getSwapProviderTypes()
},
filterProviderTypes = ExchangeProviderType.getSwapProviderTypes(),
).fold(
ifLeft = { error ->
uiState = stateBuilder.createInitialErrorState(
@ -629,7 +634,11 @@ internal class SwapModel @Inject constructor(
)
TangemLogger.e("Error getting swap pair", error)
},
ifRight = { pairs ->
ifRight = { pairsRaw ->
val pairs = pairsRaw.filterTangemPayProviders(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
val providerList = swapInteractor.findProvidersForPairWithCheck(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
@ -1187,7 +1196,7 @@ internal class SwapModel @Inject constructor(
val isTangemPayWithdrawal = isTangemPayWithdrawal()
if (swapFee == null && !isTangemPayWithdrawal) {
TangemLogger.e("onSwapClick: fee is null and isWithdrawal is ${tangemPayInput?.isWithdrawal}")
TangemLogger.e("onSwapClick: fee is null and isTangemPayWithdrawal is $isTangemPayWithdrawal")
showAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
modelScope.launch {
delay(SWAP_IN_PROGRESS_DELAY)
@ -1950,8 +1959,31 @@ internal class SwapModel @Inject constructor(
)
}
fun isTangemPayWithdrawal(): Boolean {
return tangemPayInput?.isWithdrawal == true || dataState.fromSwapCurrencyStatus?.account is Account.Payment
fun isTangemPayWithdrawal(fromSwapCurrencyStatus: SwapCurrencyStatus? = dataState.fromSwapCurrencyStatus): Boolean {
return fromSwapCurrencyStatus?.account is Account.Payment
}
private fun List<SwapPairLeast>.filterTangemPayProviders(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
) = map { pair ->
val isTangemPayWithdrawal = isTangemPayWithdrawal(
swapInteractor.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
),
)
val filterProviderTypes = if (isTangemPayWithdrawal) {
listOf(ExchangeProviderType.CEX)
} else {
emptyList()
}
pair.copy(
providers = pair.providers.filterIf(filterProviderTypes.isNotEmpty()) { provider ->
provider.type in filterProviderTypes
},
)
}
private fun Map<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {

View file

@ -232,7 +232,6 @@ internal class TangemPayCardPageModel @Inject constructor(
cryptoAmount = data.cryptoBalance,
fiatAmount = data.fiatBalance,
depositAddress = data.depositAddress,
isWithdrawal = false,
),
),
)

View file

@ -214,7 +214,6 @@ internal class TangemPayDetailsModel @Inject constructor(
cryptoAmount = currentBalance.availableForWithdrawal,
fiatAmount = currentBalance.availableForWithdrawal,
depositAddress = depositAddress,
isWithdrawal = true,
),
),
)
@ -319,7 +318,6 @@ internal class TangemPayDetailsModel @Inject constructor(
cryptoAmount = data.cryptoBalance,
fiatAmount = data.fiatBalance,
depositAddress = data.depositAddress,
isWithdrawal = false,
),
),
)