Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 19:35:12 +05:00
parent ee35d8f301
commit 50ed362181
2 changed files with 180 additions and 65 deletions

View file

@ -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<SwapProvider, SwapState> {
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<SwapProvider, SwapState> {
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<SwapProvider, SwapState> {
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,

View file

@ -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<String>()
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<String>()
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<String>()
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<String>()
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)
}
}