diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 91d8e2b8d8..b11bbff6bb 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -334,7 +334,6 @@ internal class ChildFactory @Inject constructor( cryptoAmount = tangemPayInput.cryptoAmount, fiatAmount = tangemPayInput.fiatAmount, depositAddress = tangemPayInput.depositAddress, - isWithdrawal = tangemPayInput.isWithdrawal, ) }, ), diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 7c574717fe..79db7cf50a 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -219,7 +219,6 @@ sealed class AppRoute(val path: String) : Route { val cryptoAmount: SerializedBigDecimal, val fiatAmount: SerializedBigDecimal, val depositAddress: String, - val isWithdrawal: Boolean, ) @Serializable diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt index 6b8f708a35..b7b5ca195d 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt @@ -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. */ diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 96a05a49ae..2e12817fde 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -33,6 +33,12 @@ interface SwapInteractor { pairs: List, ): List + fun extractFromSwapCurrencyFromPair( + pair: SwapPairLeast, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + ): SwapCurrencyStatus? + @Throws(IllegalStateException::class) suspend fun findBestQuote( fromSwapCurrencyStatus: SwapCurrencyStatus, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 9ed0402e69..4d57c736b2 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -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, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt index 9c1d643ad4..166682b781 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt @@ -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, ) -data class CryptoCurrencySwapInfo( - val currencyStatus: CryptoCurrencyStatus, - val providers: List, -) - /** * Provider that could swap given cryptocurrencies * diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapFilterTangemPayProvidersLogicTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapFilterTangemPayProvidersLogicTest.kt new file mode 100644 index 0000000000..7570a1a891 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapFilterTangemPayProvidersLogicTest.kt @@ -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`). + * + * 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.applyTangemPayFilter( + fromStatus: SwapCurrencyStatus, + toStatus: SwapCurrencyStatus, + ): List = 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().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() + } + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplExtractFromSwapCurrencyTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplExtractFromSwapCurrencyTest.kt new file mode 100644 index 0000000000..2beb4986b2 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplExtractFromSwapCurrencyTest.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt index 846dc194ee..93efa70ff3 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt @@ -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(relaxed = true) - val coinStatus = mockk(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(relaxed = true) + val coinStatus = mockk(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 -> diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 16cb6b2bcb..6568a43cc5 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -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 diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index a69376aedb..45bc3613a8 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -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.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.getLastLoadedSuccessStates(): SuccessLoadedSwapData { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 89584be4c6..07136a21aa 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -232,7 +232,6 @@ internal class TangemPayCardPageModel @Inject constructor( cryptoAmount = data.cryptoBalance, fiatAmount = data.fiatBalance, depositAddress = data.depositAddress, - isWithdrawal = false, ), ), ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 1304c613aa..d124201ef9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -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, ), ), )