Updated on 2026-08-14
This commit is contained in:
parent
a78bfc6191
commit
2ab80154d8
13 changed files with 740 additions and 54 deletions
|
|
@ -334,7 +334,6 @@ internal class ChildFactory @Inject constructor(
|
|||
cryptoAmount = tangemPayInput.cryptoAmount,
|
||||
fiatAmount = tangemPayInput.fiatAmount,
|
||||
depositAddress = tangemPayInput.depositAddress,
|
||||
isWithdrawal = tangemPayInput.isWithdrawal,
|
||||
)
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -219,7 +219,6 @@ sealed class AppRoute(val path: String) : Route {
|
|||
val cryptoAmount: SerializedBigDecimal,
|
||||
val fiatAmount: SerializedBigDecimal,
|
||||
val depositAddress: String,
|
||||
val isWithdrawal: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -232,7 +232,6 @@ internal class TangemPayCardPageModel @Inject constructor(
|
|||
cryptoAmount = data.cryptoBalance,
|
||||
fiatAmount = data.fiatBalance,
|
||||
depositAddress = data.depositAddress,
|
||||
isWithdrawal = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue