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 9e3e2227b4..fa252262e5 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 @@ -297,7 +297,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, expressOperationType = ExpressOperationType.SWAP, ) } else { @@ -306,7 +305,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, expressOperationType = ExpressOperationType.SWAP, ) } @@ -317,7 +315,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, ) } } @@ -332,7 +329,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, amount: SwapAmount, - reduceBalanceBy: BigDecimal, expressOperationType: ExpressOperationType, ): Pair? { if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true && @@ -364,7 +360,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, ) } @@ -460,7 +455,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, amount: SwapAmount, - reduceBalanceBy: BigDecimal, expressOperationType: ExpressOperationType, ): Pair { val maybeQuotes = repository.findBestQuote( @@ -482,7 +476,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, ) } @@ -520,43 +513,27 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, amount: SwapAmount, - reduceBalanceBy: BigDecimal, ): Pair { val fromToken = fromSwapCurrencyStatus.currency val toToken = toSwapCurrencyStatus.currency - val includeFeeInAmount = getIncludeFeeInAmountInternal( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - amount = amount, - reduceBalanceBy = reduceBalanceBy, - feeValue = BigDecimal.ZERO, - ) - - val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmountInternal.Included) { - includeFeeInAmount.amountSubtractFee - } else { - amount - } - + // Always request the user-entered amount. The real balance/fee decision is deferred to the fee + // selector (`computeBalanceStatus` / `applySwapFee`), which correctly handles gasless (token) fee + // payment even when the native coin balance is zero. Do NOT derive the quote amount from the native + // balance here — that discards the entered amount ([REDACTED_TASK_KEY] regression: CEX always sent max). val quotes = repository.findBestQuote( userWallet = fromSwapCurrencyStatus.userWallet, fromContractAddress = fromToken.getContractAddress(), fromNetwork = fromToken.network.rawId, toContractAddress = toToken.getContractAddress(), toNetwork = toToken.network.rawId, - fromAmount = amountToRequest.toStringWithRightOffset(), + fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, toDecimals = toToken.decimals, providerId = provider.providerId, rateType = RateType.FLOAT, ) - val quoteBalanceStatus = if (includeFeeInAmount == IncludeFeeInAmountInternal.BalanceNotEnough) { - SwapBalanceStatus.InsufficientAmount - } else { - SwapBalanceStatus.Pending // fee not resolved yet - } - return provider to getQuotesState( provider = provider, quoteDataModel = quotes, @@ -564,7 +541,7 @@ internal class SwapInteractorImpl @Inject constructor( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = true, - quoteBalanceStatus = quoteBalanceStatus, + quoteBalanceStatus = SwapBalanceStatus.Pending, ) } @@ -1809,8 +1786,8 @@ internal class SwapInteractorImpl @Inject constructor( * same-currency-token path: balance check on the from-token's own balance. * - Otherwise → native-fee branch via [getIncludeFeeInAmountForNative]. * - * Used both by [loadCexQuoteData] (with `feeValue = ZERO` at quote stage) and by - * [computeBalanceStatus] (with the actual fee once the selector resolves). + * Used by [computeBalanceStatus] with the actual fee once the fee selector resolves. The quote stage + * ([manageCex]) no longer consults this — it always requests the user-entered amount. */ private suspend fun getIncludeFeeInAmountInternal( fromSwapCurrencyStatus: SwapCurrencyStatus, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt index 0dd14bd531..1287b38606 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt @@ -334,11 +334,6 @@ class DexSwapFeeCalculator( derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value, ) - // if native balance is zero - we can't calculate fee - if (nativeBalance.signum() == 0) { - raise(GetFeeError.UnknownError) - } - val txAmountValue = transaction.txValue ?: error("unable to get txValue") val amountToSend = if (permissionState is PermissionDataState.PermissionSettings) { transaction.fromAmount.value.convertToSdkAmount(fromSwapCurrencyStatus.status) diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt index dd3da80960..9e0f36436b 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt @@ -20,6 +20,7 @@ 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.ExpressTransactionModel +import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus import com.tangem.feature.swap.domain.models.domain.SwapDataModel import com.tangem.feature.swap.domain.models.ui.PermissionDataState import com.tangem.feature.swap.domain.models.ui.SwapState @@ -28,6 +29,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.slot import kotlinx.coroutines.test.runTest import org.junit.Ignore import org.junit.jupiter.api.BeforeEach @@ -720,6 +722,337 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase( } } + /** + * [REDACTED_TASK_KEY]: the CEX quote stage must request the **user-entered** amount as `fromAmount`, regardless of + * the native-coin balance or `reduceBalanceBy`. A prior fix derived the quote amount from the native + * balance (`nativeBalance - reduceBalanceBy`), which discarded the entered amount and made CEX always + * quote the max balance (and, for tokens, sent the native balance under the token's decimals). The real + * balance/fee decision is deferred to the fee selector, so the quote status is always `Pending`. + */ + @Nested + inner class CexQuoteAmount { + + @Test + fun `should request the entered amount for a coin with non-zero native balance`() = runTest { + // Given — coin balance 10, native balance 10 (base stub); user enters 0.014 (the reported case) + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + decimals = 18, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val fromAmountSlot = slot() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = capture(fromAmountSlot), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "0.014", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — fromAmount is the entered 0.014 (0.014 * 1e18), NOT the full balance + assertThat(fromAmountSlot.isCaptured).isTrue() + assertThat(fromAmountSlot.captured).isEqualTo("14000000000000000") + assertThat(result[cexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java) + val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending) + } + + @Test + fun `should ignore reduceBalanceBy when building the CEX quote fromAmount`() = runTest { + // Given — reduceBalanceBy must NOT affect the CEX quote amount anymore + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + decimals = 18, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val fromAmountSlot = slot() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = capture(fromAmountSlot), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal("2"), + ) + + // Then — still the entered 1.0 * 1e18, unaffected by reduceBalanceBy + assertThat(fromAmountSlot.captured).isEqualTo("1000000000000000000") + } + + @Test + fun `should request the entered amount for a coin with zero native balance`() = runTest { + // Given — native balance ZERO must not block or override the entered amount + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + decimals = 18, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val fromAmountSlot = slot() + + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = capture(fromAmountSlot), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — 1.0 * 1e18, status Pending + assertThat(fromAmountSlot.captured).isEqualTo("1000000000000000000") + val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending) + } + + @Test + fun `should request the entered token amount with token decimals for a token with non-zero native balance`() = + runTest { + // Given — token (6 decimals) balance 100, native ETH balance 10 (base stub); user enters 5. + // The quote must send 5 in token units, NOT the native balance under token decimals. + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0xToken", + isCoin = false, + amount = BigDecimal("100"), + decimals = 6, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val fromAmountSlot = slot() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = capture(fromAmountSlot), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "5", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — 5 * 1e6 (token decimals), NOT 10 (native balance) + assertThat(fromAmountSlot.captured).isEqualTo("5000000") + val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending) + } + + @Test + fun `should not block a token swap with zero native balance (gasless)`() = runTest { + // Given — the original [REDACTED_TASK_KEY] case: token with zero native (ETH) balance, gasless supported. + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0xToken", + isCoin = false, + amount = BigDecimal("100"), + decimals = 6, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val fromAmountSlot = slot() + + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = capture(fromAmountSlot), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "5", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — entered token amount is quoted and status is Pending (not InsufficientAmount) + assertThat(fromAmountSlot.captured).isEqualTo("5000000") + val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending) + } + + @Test + fun `should request the full entered balance for a coin when max is tapped`() = runTest { + // Given — "Max" sets the entered amount to the full coin balance (10). The native balance stub is + // deliberately different (3) so a regression to the old `nativeBalance - reduceBalanceBy` logic + // would flip the asserted value (3e18) instead of the entered 10e18. + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + decimals = 18, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val fromAmountSlot = slot() + + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("3") + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = capture(fromAmountSlot), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When — user taps Max: entered amount == full coin balance + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "10", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — full entered balance 10 * 1e18, NOT the native balance (3); no quote-stage fee subtraction + assertThat(fromAmountSlot.captured).isEqualTo("10000000000000000000") + val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending) + } + + @Test + fun `should request the full entered token balance when max is tapped`() = runTest { + // Given — token (6 decimals) balance 100, native ETH balance 10 (base stub). "Max" enters 100. + // native (10) naturally differs from the token balance (100), so a regression to the native-balance + // logic would send 10 (as "10000000") instead of the entered 100 (as "100000000"). + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0xToken", + isCoin = false, + amount = BigDecimal("100"), + decimals = 6, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val fromAmountSlot = slot() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = capture(fromAmountSlot), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When — user taps Max: entered amount == full token balance + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "100", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — full entered token balance 100 * 1e6, NOT the native balance (10) + assertThat(fromAmountSlot.captured).isEqualTo("100000000") + val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending) + } + } + @Nested inner class MixedProviderDispatch { diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt index 39bc1e1c00..f19c66e597 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt @@ -120,29 +120,88 @@ internal class DexSwapFeeCalculatorTest { } // ------------------------------------------------------------------------- - // EVM zero-balance short-circuit + // EVM zero-balance no longer short-circuits (guard removed) + // + // Previously a zero native balance raised UnknownError *before* any fee call. That guard was + // removed, so a zero-balance quote must still surface a fee: when the tx amount fits the (zero) + // balance the normal getFeeUseCase path runs; when it does not, the balance check throws and the + // calculator falls back to getEthSpecificFeeUseCase via the IllegalStateException branch. // ------------------------------------------------------------------------- @Test - fun `EVM DEX swap with native balance ZERO returns Left UnknownError`() = runTest { - val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) - val transaction = buildDex(txValue = "0") - coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO + fun `EVM DEX swap with native balance ZERO no longer short-circuits and computes fee via getFeeUseCase`() = + runTest { + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + // txValue "0" → amountToSend 0, so `nativeBalance(0) < 0` is false and the main path runs. + val transaction = buildDex(txValue = "0") + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO + coEvery { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } returns TransactionFee.Single(normal = ethLegacyFee()).right() - val result = sut.calculate(fromStatus, transaction) + val result = sut.calculate(fromStatus, transaction) - assertThat(result.isLeft()).isTrue() - result.onLeft { assertThat(it).isEqualTo(GetFeeError.UnknownError) } - // getFeeUseCase should not have been called because balance check short-circuits first. - // Use a more permissive verify to avoid clashing with the other overload signatures. - coVerify(exactly = 0) { - getFeeUseCase.invoke( - userWallet = any(), - network = any(), - transactionData = any(), - ) + // The removed guard means the fee is now computed instead of raising UnknownError. + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { + getFeeUseCase.invoke( + userWallet = any(), + network = any(), + transactionData = any(), + ) + } + coVerify(exactly = 0) { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = any(), + gasPrice = any(), + ) + } + } + + @Test + fun `EVM DEX swap with native balance ZERO falls back to getEthSpecificFeeUseCase when txValue exceeds balance`() = + runTest { + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val gas = BigInteger.valueOf(120_000L) + // txValue 0.001 ETH > zero balance → `nativeBalance < amountToSend` throws → gas fallback. + val transaction = buildDex(txValue = "1000000000000000", gas = gas) + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO + coEvery { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = any(), + gasPrice = any(), + ) + } returns TransactionFee.Choosable( + minimum = ethLegacyFee(), + normal = ethLegacyFee(), + priority = ethLegacyFee(), + ).right() + + val result = sut.calculate(fromStatus, transaction) + + // Zero balance now falls back instead of raising UnknownError up-front. + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = gas, + gasPrice = any(), + ) + } + // The balance check throws before the main fee call, so getFeeUseCase is never reached. + coVerify(exactly = 0) { + getFeeUseCase.invoke( + userWallet = any(), + network = any(), + transactionData = any(), + ) + } } - } // ------------------------------------------------------------------------- // EVM IllegalStateException → fallback to GetEthSpecificFeeUseCase 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 95c0ebfffb..a83a6c4317 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 @@ -2073,6 +2073,7 @@ internal class SwapModel @Inject constructor( if (provider != null && swapState != null && isNotNullCurrency) { modelScope.launch(dispatchers.default) { feeSelectorRepository.state.value = FeeSelectorUM.Loading + updateFeePaidCryptoCurrencyFor(fromSwapCurrencyStatus) feeSelectorReloadTrigger.triggerUpdate() } analyticsEventHandler.send(SwapEvents.ProviderChosen(provider))