diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 67b1f7056c..4373d09847 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -25,6 +25,7 @@ import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNet import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExpressTxType import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus import com.tangem.feature.swap.domain.models.ui.PermissionDataState import com.tangem.feature.swap.domain.models.ui.PriceImpact @@ -318,11 +319,16 @@ internal class SwapNotificationsFactory( val shouldShowCoverWarning = quoteModel.permissionState !is PermissionDataState.PermissionLoading && feeCryptoCurrencyStatus.currency != fromCurrency - val isCEXProvider = quoteModel.swapProvider.type == ExchangeProviderType.CEX + // A DEX-typed provider whose quote returned txType=SEND executes as a CEX-style transfer, + // so it must follow the same gasless suppression rule as a real CEX provider. + val isCexLikeFlow = quoteModel.swapProvider.type == ExchangeProviderType.CEX || + quoteModel.txType == ExpressTxType.SEND - val isNotEnoughFee = insufficientFee != null && !isCEXProvider + val isNotEnoughFee = insufficientFee != null - val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCEXProvider + // Suppress only when the user can actually switch the fee to a token via the gasless + // selector; on networks without gasless support the warning must show for CEX too. + val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCexLikeFlow if (shouldShowCoverWarning && !isGaslessAvailable && isNotEnoughFee) { add( if (fromCurrency.id == feeCryptoCurrencyStatus.currency.id) { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapNotificationsFactoryTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapNotificationsFactoryTest.kt new file mode 100644 index 0000000000..52c26f5da8 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapNotificationsFactoryTest.kt @@ -0,0 +1,276 @@ +package com.tangem.feature.swap.model + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRouter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExpressTxType +import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState +import com.tangem.feature.swap.domain.models.domain.RateType +import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus +import com.tangem.feature.swap.domain.models.domain.SwapProvider +import com.tangem.feature.swap.domain.models.ui.PermissionDataState +import com.tangem.feature.swap.domain.models.ui.PriceImpact +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.feature.swap.models.UiActions +import com.tangem.feature.swap.models.states.SwapNotificationUM +import com.tangem.utils.Provider +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Tests for [SwapNotificationsFactory.getConfirmationStateNotifications], focused on the + * `UnableToCoverFeeWarning` gating ([REDACTED_TASK_KEY]): + * + * | flow | gasless network | expected for InsufficientFee | + * |-------------------------------|-----------------|--------------------------------| + * | CEX | no | warning shown (the bug fix) | + * | CEX | yes | suppressed (fee → token) | + * | DEX (txType=null) | yes | warning shown (DEX unchanged) | + * | DEX + txType=SEND (CEX-like) | yes | suppressed like a real CEX | + * | DEX + txType=SEND (CEX-like) | no | warning shown | + */ +internal class SwapNotificationsFactoryTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + private val appCurrencyProvider: Provider = Provider { AppCurrency.Default } + private val appRouter: AppRouter = mockk() + + private val factory = SwapNotificationsFactory( + actions = actions, + isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, + appCurrencyProvider = appCurrencyProvider, + ) + + private val userWalletId = UserWalletId(stringValue = "deadbeef") + private val userWallet: UserWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + @BeforeEach + fun resetMocks() { + clearMocks(isGaslessFeeSupportedForNetwork, appRouter) + every { appRouter.stack } returns emptyList() + } + + @Test + fun `GIVEN CEX and no gasless support WHEN insufficient fee THEN cover fee warning shown`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.CEX) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + val warning = notifications.filterIsInstance().single() + assertThat(warning.currencyName).isEqualTo("Ethereum") + assertThat(warning.currencySymbol).isEqualTo("ETH") + } + + @Test + fun `GIVEN CEX and gasless support WHEN insufficient fee THEN cover fee warning suppressed`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns true + val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.CEX) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + assertThat(notifications.filterIsInstance()).isEmpty() + } + + @Test + fun `GIVEN DEX and gasless support WHEN insufficient fee THEN cover fee warning shown`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns true + val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.DEX) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + assertThat(notifications.filterIsInstance()).hasSize(1) + } + + @Test + fun `GIVEN DEX with SEND txType and gasless support WHEN insufficient fee THEN warning suppressed like CEX`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns true + val quoteModel = buildQuotesLoadedState( + providerType = ExchangeProviderType.DEX, + txType = ExpressTxType.SEND, + ) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + assertThat(notifications.filterIsInstance()).isEmpty() + } + + @Test + fun `GIVEN DEX with SEND txType and no gasless support WHEN insufficient fee THEN warning shown`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuotesLoadedState( + providerType = ExchangeProviderType.DEX, + txType = ExpressTxType.SEND, + ) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + assertThat(notifications.filterIsInstance()).hasSize(1) + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private fun buildEthNetwork(): Network = mockk(relaxed = true) { + every { rawId } returns "ethereum" + every { name } returns "Ethereum" + every { currencySymbol } returns "ETH" + } + + /** Token from-currency, so the fee is paid in a different (native coin) currency. */ + private fun buildTokenFromStatus(): SwapCurrencyStatus { + val network = buildEthNetwork() + val currency = mockk(relaxed = true) { + every { this@mockk.network } returns network + every { symbol } returns "USDT" + every { name } returns "Tether" + every { decimals } returns 6 + } + val statusValue = mockk(relaxed = true) { + every { amount } returns BigDecimal("100") + every { pendingTransactions } returns emptySet() + } + return SwapCurrencyStatus( + userWallet = userWallet, + status = CryptoCurrencyStatus(currency = currency, value = statusValue), + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + ) + } + + private fun buildCoinFeeStatus(): CryptoCurrencyStatus { + val network = buildEthNetwork() + val currency = mockk(relaxed = true) { + every { this@mockk.network } returns network + every { symbol } returns "ETH" + every { name } returns "Ethereum" + every { decimals } returns 18 + } + val statusValue = mockk(relaxed = true) { + every { amount } returns BigDecimal.ZERO + } + return CryptoCurrencyStatus(currency = currency, value = statusValue) + } + + private fun buildQuotesLoadedState( + providerType: ExchangeProviderType, + txType: ExpressTxType? = null, + ): SwapState.QuotesLoadedState { + val toStatusValue = mockk(relaxed = true) { + every { amount } returns BigDecimal("1") + } + val toCurrency = mockk(relaxed = true) { + every { network } returns buildEthNetwork() + every { symbol } returns "BTC" + every { decimals } returns 8 + } + val toSwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = CryptoCurrencyStatus(currency = toCurrency, value = toStatusValue), + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + ) + return SwapState.QuotesLoadedState( + fromTokenInfo = TokenSwapInfo( + tokenAmount = SwapAmount(BigDecimal("50"), 6), + swapCurrencyStatus = buildTokenFromStatus(), + amountFiat = BigDecimal.ZERO, + ), + toTokenInfo = TokenSwapInfo( + tokenAmount = SwapAmount(BigDecimal("0.5"), 8), + swapCurrencyStatus = toSwapCurrencyStatus, + amountFiat = BigDecimal.ZERO, + ), + priceImpact = PriceImpact.Empty, + preparedSwapConfigState = PreparedSwapConfigState( + balanceStatus = SwapBalanceStatus.InsufficientFee( + feeCurrencyName = "Ethereum", + feeCurrencySymbol = "ETH", + ), + hasOutgoingTransaction = false, + ), + permissionState = PermissionDataState.Empty, + swapDataModel = null, + currencyCheck = null, + validationResult = null, + minAdaValue = null, + swapProvider = buildProvider(providerType), + txType = txType, + ) + } + + private fun buildProvider(type: ExchangeProviderType): SwapProvider = SwapProvider( + providerId = "p", + rateTypes = listOf(RateType.FLOAT), + name = "Provider", + type = type, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + isRecommended = false, + slippage = null, + isExtraIdSupported = false, + ) +} \ No newline at end of file