Updated on 2026-08-14
This commit is contained in:
parent
dc7e286c59
commit
ee35d8f301
5 changed files with 284 additions and 34 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue