Updated on 2026-08-14
This commit is contained in:
commit
4e12b27308
6 changed files with 419 additions and 54 deletions
|
|
@ -235,7 +235,6 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
provider = provider,
|
||||
amount = amount,
|
||||
reduceBalanceBy = reduceBalanceBy,
|
||||
expressOperationType = ExpressOperationType.SWAP,
|
||||
)
|
||||
} else {
|
||||
|
|
@ -244,7 +243,6 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
provider = provider,
|
||||
amount = amount,
|
||||
reduceBalanceBy = reduceBalanceBy,
|
||||
expressOperationType = ExpressOperationType.SWAP,
|
||||
)
|
||||
}
|
||||
|
|
@ -255,7 +253,6 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
provider = provider,
|
||||
amount = amount,
|
||||
reduceBalanceBy = reduceBalanceBy,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -270,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) {
|
||||
|
|
@ -300,7 +296,6 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
provider = provider,
|
||||
amount = amount,
|
||||
reduceBalanceBy = reduceBalanceBy,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -358,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(
|
||||
|
|
@ -380,7 +374,6 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
provider = provider,
|
||||
amount = amount,
|
||||
reduceBalanceBy = reduceBalanceBy,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -415,43 +408,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,
|
||||
|
|
@ -459,7 +436,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
isAllowedToSpend = true,
|
||||
quoteBalanceStatus = quoteBalanceStatus,
|
||||
quoteBalanceStatus = SwapBalanceStatus.Pending,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1386,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,
|
||||
|
|
|
|||
|
|
@ -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,14 @@ 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.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 +719,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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 ^
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue