Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-07 14:31:10 +04:00
commit e29dd815ee
5 changed files with 418 additions and 53 deletions

View file

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

View file

@ -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)

View file

@ -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<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 = "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<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
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<String>()
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<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,
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(),
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<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)
}
}
@Nested
inner class MixedProviderDispatch {

View file

@ -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<TransactionData>(),
)
// 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<TransactionData>(),
)
}
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<TransactionData>(),
)
}
}
}
// -------------------------------------------------------------------------
// EVM IllegalStateException → fallback to GetEthSpecificFeeUseCase

View file

@ -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))