From dc7e286c59dea59309f4694bd33c2ff725c0fce4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 12:15:35 +0300 Subject: [PATCH 1/3] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 0f32ca5313..7cee5f99e1 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.39-1589" +tangemBlockchainSdk = "releases-5.39-1601" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.39-623" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From ee35d8f3011535dfba5a0acd63516295a81bb63e Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 14:48:10 +0500 Subject: [PATCH 2/3] 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)) From 50ed36218178f2aa4a694ab5b2e7fad178211c24 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 19:35:12 +0500 Subject: [PATCH 3/3] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractorImpl.kt | 39 +--- .../SwapInteractorImplFindBestQuoteTest.kt | 206 +++++++++++++++--- 2 files changed, 180 insertions(+), 65 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 d50f8618cc..957e5188f0 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,7 +62,6 @@ 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 @@ -236,7 +235,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, expressOperationType = ExpressOperationType.SWAP, ) } else { @@ -245,7 +243,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, expressOperationType = ExpressOperationType.SWAP, ) } @@ -256,7 +253,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, ) } } @@ -271,7 +267,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, amount: SwapAmount, - reduceBalanceBy: BigDecimal, expressOperationType: ExpressOperationType, ): Pair { if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true) { @@ -301,7 +296,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, ) } @@ -359,7 +353,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, amount: SwapAmount, - reduceBalanceBy: BigDecimal, expressOperationType: ExpressOperationType, ): Pair { val maybeQuotes = repository.findBestQuote( @@ -381,7 +374,6 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, amount = amount, - reduceBalanceBy = reduceBalanceBy, ) } @@ -416,38 +408,21 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, amount: SwapAmount, - reduceBalanceBy: BigDecimal, ): Pair { val fromToken = fromSwapCurrencyStatus.currency val toToken = toSwapCurrencyStatus.currency - 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 { - 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, @@ -1388,8 +1363,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/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt index ead54dc918..c29b33cdde 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 @@ -24,7 +24,6 @@ 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 @@ -721,18 +720,18 @@ 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). + * [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 CexNativeBalanceAmount { + inner class CexQuoteAmount { @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 + 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, @@ -764,21 +763,21 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase( fromSwapCurrencyStatus = fromStatus, toSwapCurrencyStatus = toStatus, providers = listOf(cexProvider), - amountToSwap = "1.0", + amountToSwap = "0.014", reduceBalanceBy = BigDecimal.ZERO, ) - // Then — fromAmount is the full native balance (10 * 1e18), not the "1.0" swap amount + // Then — fromAmount is the entered 0.014 (0.014 * 1e18), NOT the full balance assertThat(fromAmountSlot.isCaptured).isTrue() - assertThat(fromAmountSlot.captured).isEqualTo("10000000000000000000") + 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 subtract reduceBalanceBy from native balance when building fromAmount`() = runTest { - // Given — native balance 10, reduceBalanceBy 2 → fromAmount = 8 * 1e18 + 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, @@ -814,13 +813,13 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase( reduceBalanceBy = BigDecimal("2"), ) - // Then - assertThat(fromAmountSlot.captured).isEqualTo("8000000000000000000") + // Then — still the entered 1.0 * 1e18, unaffected by reduceBalanceBy + assertThat(fromAmountSlot.captured).isEqualTo("1000000000000000000") } @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) + 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, @@ -857,24 +856,75 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase( reduceBalanceBy = BigDecimal.ZERO, ) - // Then — 1.0 * 1e18, not the native balance + // 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 read native token balance for the from-token network`() = runTest { - // Given + 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, - isCoin = true, - amount = BigDecimal("10"), + 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(), @@ -882,7 +932,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase( fromNetwork = any(), toContractAddress = any(), toNetwork = any(), - fromAmount = any(), + fromAmount = capture(fromAmountSlot), fromDecimals = any(), toDecimals = any(), providerId = cexProvider.providerId, @@ -891,22 +941,112 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase( } returns quoteModel.right() // When - sut.findBestQuote( + val result = sut.findBestQuote( fromSwapCurrencyStatus = fromStatus, toSwapCurrencyStatus = toStatus, providers = listOf(cexProvider), - amountToSwap = "1.0", + amountToSwap = "5", 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(), + // 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) } }