Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 14:48:10 +05:00
parent dc7e286c59
commit ee35d8f301
5 changed files with 284 additions and 34 deletions

View file

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

View file

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

View file

@ -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<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 = "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<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
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<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, 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 {

View file

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

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