From ee35d8f3011535dfba5a0acd63516295a81bb63e Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 14:48:10 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractorImpl.kt | 26 +-- .../swap/domain/fee/DexSwapFeeCalculator.kt | 5 - .../SwapInteractorImplFindBestQuoteTest.kt | 193 ++++++++++++++++++ .../domain/fee/DexSwapFeeCalculatorTest.kt | 93 +++++++-- .../tangem/feature/swap/model/SwapModel.kt | 1 + 5 files changed, 284 insertions(+), 34 deletions(-) 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 4d57c736b2..d50f8618cc 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 @@ -62,6 +62,7 @@ import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.toStringWithRightOffset import com.tangem.feature.swap.domain.models.ui.* import com.tangem.utils.coroutines.runSuspendCatching +import com.tangem.utils.extensions.isZero import com.tangem.utils.extensions.orZero import com.tangem.utils.logging.TangemLogger import jakarta.inject.Inject @@ -420,13 +421,20 @@ internal class SwapInteractorImpl @Inject constructor( val fromToken = fromSwapCurrencyStatus.currency val toToken = toSwapCurrencyStatus.currency - val includeFeeInAmount = getIncludeFeeInAmountInternal( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - amount = amount, - reduceBalanceBy = reduceBalanceBy, - feeValue = BigDecimal.ZERO, + val nativeBalance = walletManagersFacade.getNativeTokenBalance( + userWalletId = fromSwapCurrencyStatus.userWalletId, + networkId = fromToken.network.rawId, + derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value, ) + val includeFeeInAmount = if (nativeBalance.isZero()) { + IncludeFeeInAmountInternal.Excluded + } else { + IncludeFeeInAmountInternal.Included( + SwapAmount(nativeBalance - reduceBalanceBy, fromToken.decimals), + ) + } + val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmountInternal.Included) { includeFeeInAmount.amountSubtractFee } else { @@ -446,12 +454,6 @@ internal class SwapInteractorImpl @Inject constructor( 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, @@ -459,7 +461,7 @@ internal class SwapInteractorImpl @Inject constructor( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = true, - quoteBalanceStatus = quoteBalanceStatus, + quoteBalanceStatus = SwapBalanceStatus.Pending, ) } 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 8f711b7b88..99c5658eef 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 @@ -127,11 +127,6 @@ class DexSwapFeeCalculator( derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value, ) - // if native balance is zero - we can't calculate fee - if (nativeBalance.signum() == 0) { - raise(ExpressDataError.UnknownError()) - } - try { val txAmountValue = transaction.txValue ?: error("unable to get txValue") val amountToSend = createNativeAmountForDex(txAmountValue, fromSwapCurrencyStatus.currency.network) 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 5e5d2ab44b..ead54dc918 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,12 +20,15 @@ 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.SwapState import io.mockk.coEvery +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.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested @@ -717,6 +720,196 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase( } } + /** + * `manageCex` no longer derives `includeFeeInAmount` through `getIncludeFeeInAmountInternal`. + * It now reads the native-coin balance directly: + * - native balance non-zero → request the whole `nativeBalance - reduceBalanceBy` as `fromAmount` + * - native balance zero → request the original swap `amount` + * The resulting quote balance status is always `Pending` (resolved later by the fee selector). + */ + @Nested + inner class CexNativeBalanceAmount { + + @Test + fun `should request nativeBalance as fromAmount when native balance is non-zero`() = runTest { + // Given — native balance 10 (from base stub), decimals 18, reduceBalanceBy 0 + 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 = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — fromAmount is the full native balance (10 * 1e18), not the "1.0" swap amount + assertThat(fromAmountSlot.isCaptured).isTrue() + assertThat(fromAmountSlot.captured).isEqualTo("10000000000000000000") + 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 subtract reduceBalanceBy from native balance when building fromAmount`() = runTest { + // Given — native balance 10, reduceBalanceBy 2 → fromAmount = 8 * 1e18 + 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 + assertThat(fromAmountSlot.captured).isEqualTo("8000000000000000000") + } + + @Test + fun `should request the original swap amount as fromAmount when native balance is zero`() = runTest { + // Given — native balance ZERO → includeFeeInAmount Excluded → fromAmount = swap amount (1.0) + 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, not the native balance + 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 read native token balance for the from-token network`() = runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + 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.ZERO, + ) + + // Then — the CEX path resolves the fee-paying native balance for the from-token network + coVerify { + walletManagersFacade.getNativeTokenBalance( + userWalletId = any(), + networkId = ethNetwork, + derivationPath = any(), + ) + } + } + } + @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 9d57af4369..ef941b47ba 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 @@ -122,29 +122,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(ExpressDataError.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 45bc3613a8..453067c8a0 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 @@ -1728,6 +1728,7 @@ internal class SwapModel @Inject constructor( if (provider != null && swapState != null && isNotNullCurrency) { modelScope.launch { feeSelectorRepository.state.value = FeeSelectorUM.Loading + updateFeePaidCryptoCurrencyFor(fromSwapCurrencyStatus) feeSelectorReloadTrigger.triggerUpdate() } analyticsEventHandler.send(SwapEvents.ProviderChosen(provider))