Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-26 19:25:07 +04:00
parent a73dfc58af
commit 5ce5d3dc6c
20 changed files with 877 additions and 11 deletions

View file

@ -13,6 +13,10 @@ android {
namespace = "com.tangem.feature.swap.data"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** AndroidX */
@ -61,4 +65,8 @@ dependencies {
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Test */
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
}

View file

@ -293,6 +293,7 @@ internal class DefaultSwapRepository(
QuoteModel(
toTokenAmount = createFromAmountWithOffset(response.toAmount, response.toDecimals),
allowanceContract = response.allowanceContract,
txType = response.txType?.toDomain(),
).right()
} catch (ex: Exception) {
getDataError(ex).left()

View file

@ -42,7 +42,9 @@ internal class ExpressDataConverter : Converter<ExchangeDataResponseWithTxDetail
txData = requireNotNull(transactionDto.txData),
txExtraId = transactionDto.txExtraId,
otherNativeFeeWei = otherNativeFeeWei,
gas = transactionDto.gas?.toBigIntegerOrNull() ?: error("gas is empty"),
// Nullable: providers may omit gas; the fee-fallback path handles null.
gas = transactionDto.gas?.toBigIntegerOrNull(),
allowanceContract = transactionDto.allowanceContract,
)
} else {
ExpressTransactionModel.CEX(

View file

@ -0,0 +1,9 @@
package com.tangem.feature.swap.converters
import com.tangem.datasource.api.express.models.response.TxType
import com.tangem.feature.swap.domain.models.domain.ExpressTxType
internal fun TxType.toDomain(): ExpressTxType = when (this) {
TxType.SEND -> ExpressTxType.SEND
TxType.SWAP -> ExpressTxType.SWAP
}

View file

@ -0,0 +1,169 @@
package com.tangem.feature.swap.converters
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.express.models.response.ExchangeDataResponse
import com.tangem.datasource.api.express.models.response.ExchangeDataResponseWithTxDetails
import com.tangem.datasource.api.express.models.response.TxDetails
import com.tangem.datasource.api.express.models.response.TxType
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.math.BigInteger
/**
* Unit tests for [ExpressDataConverter].
*
* Covered:
* - SWAP -> DEX with all fields propagated (allowanceContract, gas).
* - SWAP with gas null -> DEX without throwing.
* - SWAP with allowanceContract null -> DEX with allowanceContract null.
* - otherNativeFee "0" -> BigDecimal.ZERO parse path.
* - SEND -> CEX with externalTxId/externalTxUrl preserved.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ExpressDataConverterTest {
private val sut = ExpressDataConverter()
@Test
fun `GIVEN txType SWAP with allowanceContract and gas WHEN convert THEN returns DEX with all fields`() {
val dataResponse = buildDataResponse(fromAmount = "1000000000000000000", toAmount = "500000000000000000")
val txDetails = buildTxDetails(
txType = TxType.SWAP,
txFrom = "0xFrom",
txTo = "0xSwapContract",
txData = "0xdeadbeef",
txValue = "0",
gas = "21000",
allowanceContract = "0xSpender",
)
val result = sut.convert(ExchangeDataResponseWithTxDetails(dataResponse, txDetails))
assertThat(result.transaction).isInstanceOf(ExpressTransactionModel.DEX::class.java)
val dex = result.transaction as ExpressTransactionModel.DEX
assertThat(dex.txFrom).isEqualTo("0xFrom")
assertThat(dex.txTo).isEqualTo("0xSwapContract")
assertThat(dex.txData).isEqualTo("0xdeadbeef")
assertThat(dex.txValue).isEqualTo("0")
assertThat(dex.gas).isEqualTo(BigInteger.valueOf(21_000L))
assertThat(dex.allowanceContract).isEqualTo("0xSpender")
}
@Test
fun `GIVEN txType SWAP with gas null WHEN convert THEN returns DEX with gas null without throwing`() {
// Regression guard: the converter must accept a null gas value instead of raising.
val dataResponse = buildDataResponse()
val txDetails = buildTxDetails(txType = TxType.SWAP, gas = null)
val result = sut.convert(ExchangeDataResponseWithTxDetails(dataResponse, txDetails))
assertThat(result.transaction).isInstanceOf(ExpressTransactionModel.DEX::class.java)
val dex = result.transaction as ExpressTransactionModel.DEX
assertThat(dex.gas).isNull()
}
@Test
fun `GIVEN txType SWAP with allowanceContract null WHEN convert THEN returns DEX with allowanceContract null`() {
// Native EVM transfer / pre-approved scenario — no allowance required.
val dataResponse = buildDataResponse()
val txDetails = buildTxDetails(txType = TxType.SWAP, allowanceContract = null)
val result = sut.convert(ExchangeDataResponseWithTxDetails(dataResponse, txDetails))
assertThat(result.transaction).isInstanceOf(ExpressTransactionModel.DEX::class.java)
val dex = result.transaction as ExpressTransactionModel.DEX
assertThat(dex.allowanceContract).isNull()
}
@Test
fun `GIVEN txType SWAP with otherNativeFee zero string WHEN convert THEN returns DEX with otherNativeFeeWei zero`() {
// "0" string must round-trip to BigDecimal.ZERO without parse errors.
val dataResponse = buildDataResponse()
val txDetails = buildTxDetails(txType = TxType.SWAP, otherNativeFee = "0")
val result = sut.convert(ExchangeDataResponseWithTxDetails(dataResponse, txDetails))
val dex = result.transaction as ExpressTransactionModel.DEX
assertThat(dex.otherNativeFeeWei).isEquivalentAccordingToCompareTo(BigDecimal.ZERO)
}
@Test
fun `GIVEN txType SEND with externalTxId and externalTxUrl WHEN convert THEN returns CEX`() {
val dataResponse = buildDataResponse()
val txDetails = buildTxDetails(
txType = TxType.SEND,
txFrom = null,
txTo = "0xCexDepositAddress",
txData = null,
externalTxId = "ext-tx-id-1",
externalTxUrl = "https://explorer.example/tx/ext-tx-id-1",
txExtraIdName = "memo",
txExtraId = "12345",
)
val result = sut.convert(ExchangeDataResponseWithTxDetails(dataResponse, txDetails))
assertThat(result.transaction).isInstanceOf(ExpressTransactionModel.CEX::class.java)
val cex = result.transaction as ExpressTransactionModel.CEX
assertThat(cex.txTo).isEqualTo("0xCexDepositAddress")
assertThat(cex.externalTxId).isEqualTo("ext-tx-id-1")
assertThat(cex.externalTxUrl).isEqualTo("https://explorer.example/tx/ext-tx-id-1")
assertThat(cex.txExtraIdName).isEqualTo("memo")
assertThat(cex.txExtraId).isEqualTo("12345")
}
// -------------------------------------------------------------------------
// Builders
// -------------------------------------------------------------------------
private fun buildDataResponse(
fromAmount: String = "1000000000000000000",
fromDecimals: Int = 18,
toAmount: String = "500000",
toDecimals: Int = 6,
txId: String = "inner-tx-id",
): ExchangeDataResponse = ExchangeDataResponse(
fromAmount = fromAmount,
fromDecimals = fromDecimals,
toAmount = toAmount,
toDecimals = toDecimals,
txId = txId,
txDetailsJson = "{}",
signature = "sig",
)
@Suppress("LongParameterList")
private fun buildTxDetails(
txType: TxType = TxType.SWAP,
payoutAddress: String = "0xPayout",
requestId: String = "req-1",
txFrom: String? = "0xFrom",
txTo: String = "0xTo",
txData: String? = "0xdata",
txValue: String? = "0",
otherNativeFee: String? = null,
externalTxId: String? = null,
externalTxUrl: String? = null,
txExtraIdName: String? = null,
txExtraId: String? = null,
gas: String? = "21000",
allowanceContract: String? = null,
): TxDetails = TxDetails(
payoutAddress = payoutAddress,
requestId = requestId,
txType = txType,
txFrom = txFrom,
txTo = txTo,
txData = txData,
txValue = txValue,
otherNativeFee = otherNativeFee,
externalTxId = externalTxId,
externalTxUrl = externalTxUrl,
txExtraIdName = txExtraIdName,
txExtraId = txExtraId,
gas = gas,
allowanceContract = allowanceContract,
)
}

View file

@ -215,6 +215,7 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
provider = provider,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
expressOperationType = ExpressOperationType.SWAP,
)
} else {
@ -223,6 +224,7 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
provider = provider,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
expressOperationType = ExpressOperationType.SWAP,
)
}
@ -248,6 +250,7 @@ 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) {
@ -271,6 +274,16 @@ internal class SwapInteractorImpl @Inject constructor(
rateType = RateType.FLOAT,
)
if (maybeQuotes.getOrNull()?.txType == ExpressTxType.SEND) {
return manageCex(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
provider = provider,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
)
}
val fromTokenAddress = getTokenAddress(fromSwapCurrencyStatus.currency)
val isAllowedToSpend = maybeQuotes.fold(
ifRight = { quotes ->
@ -325,6 +338,7 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus: SwapCurrencyStatus,
provider: SwapProvider,
amount: SwapAmount,
reduceBalanceBy: BigDecimal,
expressOperationType: ExpressOperationType,
): Pair<SwapProvider, SwapState> {
val maybeQuotes = repository.findBestQuote(
@ -339,6 +353,17 @@ internal class SwapInteractorImpl @Inject constructor(
providerId = provider.providerId,
rateType = RateType.FLOAT,
)
if (maybeQuotes.getOrNull()?.txType == ExpressTxType.SEND) {
return manageCex(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
provider = provider,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
)
}
val quoteBalanceStatus = if (isBalanceEnough(fromSwapCurrencyStatus, amount, null)) {
SwapBalanceStatus.Pending // fee not resolved yet
} else {
@ -559,8 +584,8 @@ internal class SwapInteractorImpl @Inject constructor(
return SwapTransactionState.DemoMode
}
return when (swapProvider.type) {
ExchangeProviderType.CEX -> {
return when (resolveSwapDataFlow(swapProvider, swapData)) {
ResolvedFlow.CexLike -> {
val amountDecimal = toBigDecimalOrNull(amountToSwap)
val amount = SwapAmount(requireNotNull(amountDecimal), fromSwapCurrencyStatus.currency.decimals)
val amountToSwapWithFee = (balanceStatus as? SwapBalanceStatus.FeeAdjustedAmount)?.adjustedAmount
@ -575,7 +600,7 @@ internal class SwapInteractorImpl @Inject constructor(
isTangemPayWithdrawal = isTangemPayWithdrawal,
)
}
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
ResolvedFlow.DexLike -> {
val networkId = fromSwapCurrencyStatus.currency.network.rawId
if (isSolana(networkId)) {
onSwapSolanaDex(
@ -1278,8 +1303,8 @@ internal class SwapInteractorImpl @Inject constructor(
minAdaValue = null,
)
when (provider.type) {
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
when (resolveQuoteFlow(provider, quoteModel.txType)) {
ResolvedFlow.DexLike -> {
val state = updatePermissionState(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
quotesLoadedState = swapState,
@ -1294,7 +1319,7 @@ internal class SwapInteractorImpl @Inject constructor(
),
)
}
ExchangeProviderType.CEX -> {
ResolvedFlow.CexLike -> {
swapState.copy(
permissionState = PermissionDataState.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
@ -1905,6 +1930,39 @@ internal class SwapInteractorImpl @Inject constructor(
}
// endregion
/**
* Whether to drive the swap flow as a DEX (sign a provider-built transaction, possibly with
* allowance) or as a CEX-style transfer (send native funds to a provider-supplied address).
*/
private enum class ResolvedFlow { DexLike, CexLike }
/**
* `provider.type` is the primary gate. Inside the DEX/DEX_BRIDGE branch a quote with
* `txType=SEND` switches to the CEX-style path; other values keep the DEX path.
*/
private fun resolveQuoteFlow(provider: SwapProvider, quoteTxType: ExpressTxType?): ResolvedFlow =
when (provider.type) {
ExchangeProviderType.CEX -> ResolvedFlow.CexLike
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> when (quoteTxType) {
ExpressTxType.SEND -> ResolvedFlow.CexLike
ExpressTxType.SWAP, null -> ResolvedFlow.DexLike
}
}
/**
* Execution-stage counterpart of [resolveQuoteFlow]. For DEX/DEX_BRIDGE the shape is decided by
* `swapData.transaction`: a DEX transaction stays on the DEX path, a CEX transaction or null
* routes to the CEX path (null means the quote already re-routed and didn't pre-build swapData).
*/
private fun resolveSwapDataFlow(swapProvider: SwapProvider, swapData: SwapDataModel?): ResolvedFlow =
when (swapProvider.type) {
ExchangeProviderType.CEX -> ResolvedFlow.CexLike
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> when (swapData?.transaction) {
is ExpressTransactionModel.DEX -> ResolvedFlow.DexLike
is ExpressTransactionModel.CEX, null -> ResolvedFlow.CexLike
}
}
companion object {
private val PRICE_IMPACT_AMOUNT_MIN_THRESHOLD = 25.toBigDecimal() // in USD
private val PRICE_IMPACT_AMOUNT_MAX_THRESHOLD = 5000.toBigDecimal() // in USD

View file

@ -168,10 +168,12 @@ class DexSwapFeeCalculator(
).getOrNull()?.let { TransactionFeeResult.Loaded(it) } ?: error("unable to calculate fee")
}
} catch (_: IllegalStateException) {
// gas may be null — surface UnknownError so the provider becomes a SwapError.
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
getEthSpecificFeeUseCase(
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoCurrency = fromSwapCurrencyStatus.currency,
gasLimit = transaction.gas,
gasLimit = gasLimit,
).getOrNull()?.let { TransactionFeeResult.Loaded(it) }
?: raise(ExpressDataError.UnknownError())
}

View file

@ -15,6 +15,9 @@ sealed class ExpressTransactionModel {
/**
* @param txValue amount for tx, should use native coin decimals, this value will send as native amount in tx
* @param gas gas-limit from the express provider; only used by the fee fallback path. Nullable
* because providers may omit it.
* @param allowanceContract spender address for ERC-20 allowance, null when no approval is required.
*/
data class DEX(
override val fromAmount: SwapAmount,
@ -26,7 +29,8 @@ sealed class ExpressTransactionModel {
val txFrom: String,
val txData: String,
val otherNativeFeeWei: BigDecimal?,
val gas: BigInteger,
val gas: BigInteger?,
val allowanceContract: String?,
) : ExpressTransactionModel()
data class CEX(

View file

@ -0,0 +1,13 @@
package com.tangem.feature.swap.domain.models.domain
/**
* Type of transaction the express provider expects the app to execute, reported per quote.
*
* - [SWAP] sign and broadcast a provider-built transaction (e.g. EVM smart-contract call);
* may require ERC-20 allowance.
* - [SEND] plain native transfer to a provider-supplied address; routes to the CEX-style flow.
*/
enum class ExpressTxType {
SWAP,
SEND,
}

View file

@ -6,8 +6,12 @@ import com.tangem.feature.swap.domain.models.SwapAmount
* Quote model holds data about current amounts of exchange and fees
*
* @property toTokenAmount amount of token you want to receive
* @property allowanceContract spender address for ERC-20 allowance, null when not applicable
* @property txType expected execution flow returned by the express provider on the quote;
* null for legacy responses that don't yet carry this field
*/
data class QuoteModel(
val toTokenAmount: SwapAmount,
val allowanceContract: String?,
val txType: ExpressTxType?,
)

View file

@ -0,0 +1,368 @@
package com.tangem.feature.swap.domain
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.transaction.models.AllowanceInfo
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.ExpressTxType
import com.tangem.feature.swap.domain.models.domain.QuoteModel
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.SwapState
import io.mockk.coEvery
import io.mockk.coVerify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
* Verifies the bridge re-route in `manageDex` / `manageDexSolana`: when the quote response
* carries `txType == SEND`, the flow must switch to the CEX path. Cases without SEND are
* exercised as regression guards.
*
* Routing is asserted by side-effects: `repository.getExchangeData` and `getAllowanceInfoUseCase`
* run only on the DEX path, never on the CEX one.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplBridgeReRouteTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val solanaNetwork = Blockchain.Solana.toNetworkId()
@BeforeEach
fun setup() {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } returns emptySet()
coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right()
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right()
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
coEvery {
getCurrencyCheckUseCase.invoke(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = any(),
)
} returns com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck(
dustValue = null,
reserveAmount = null,
minimumSendAmount = null,
existentialDeposit = null,
utxoAmountLimit = null,
isAccountFunded = true,
rentWarning = null,
isMemoRequired = false,
)
coEvery {
validateTransactionUseCase.invoke(
amount = any(),
fee = any(),
memo = any(),
destination = any(),
userWalletId = any(),
network = any(),
)
} returns Unit.right()
// Default: allowance is Enough — pushes manageDex into the loadDexSwapDataNoFee path so
// we can validate routing by which side-effects ran (allowance + exchangeData for DEX,
// neither for CEX).
coEvery {
getAllowanceInfoUseCase.invoke(any(), any(), any(), any())
} returns (AllowanceInfo.Enough(allowance = BigDecimal("100")) as AllowanceInfo).right()
coEvery {
repository.getExchangeData(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
providerId = any(), rateType = any(), toAddress = any(),
expressOperationType = any(), refundAddress = any(),
)
} returns happyDexSwapData().right()
}
private fun happyDexSwapData(): SwapDataModel = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = ExpressTransactionModel.DEX(
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
toAmount = SwapAmount(BigDecimal("0.5"), 18),
txValue = "1000000000000000000",
txId = "tx-id",
txTo = "0xTo",
txExtraId = null,
txFrom = "0xFrom",
txData = "0xdata",
otherNativeFeeWei = null,
gas = java.math.BigInteger.valueOf(21_000L),
allowanceContract = null,
),
)
// -------------------------------------------------------------------------
// DEX provider on EVM
// -------------------------------------------------------------------------
@Test
fun `GIVEN DEX provider with quote txType SEND on EVM WHEN findBestQuote THEN routes to manageCex path`() = runTest {
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "dex-with-send")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = null, txType = ExpressTxType.SEND)
stubFindBestQuote(provider, quote)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
assertManageCexPathTaken(result, provider)
}
@Test
fun `GIVEN DEX provider with quote txType SWAP on EVM WHEN findBestQuote THEN routes to manageDex path`() = runTest {
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "real-dex")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = ExpressTxType.SWAP)
stubFindBestQuote(provider, quote)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
assertManageDexPathTaken(result, provider)
}
@Test
fun `GIVEN DEX provider with quote txType null on EVM WHEN findBestQuote THEN routes to manageDex path`() = runTest {
// Legacy backend that hasn't started returning txType on quote yet.
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "legacy-dex")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = null)
stubFindBestQuote(provider, quote)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
assertManageDexPathTaken(result, provider)
}
// -------------------------------------------------------------------------
// DEX_BRIDGE provider on EVM
// -------------------------------------------------------------------------
@Test
fun `GIVEN DEX_BRIDGE provider with quote txType SEND WHEN findBestQuote THEN routes to manageCex path`() = runTest {
val provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, providerId = "bridge-send")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = null, txType = ExpressTxType.SEND)
stubFindBestQuote(provider, quote)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
assertManageCexPathTaken(result, provider)
}
@Test
fun `GIVEN DEX_BRIDGE provider with quote txType SWAP WHEN findBestQuote THEN routes to manageDex path`() = runTest {
val provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, providerId = "li-fi-like")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = ExpressTxType.SWAP)
stubFindBestQuote(provider, quote)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
assertManageDexPathTaken(result, provider)
}
// -------------------------------------------------------------------------
// CEX provider — regression guard
// -------------------------------------------------------------------------
@Test
fun `GIVEN CEX provider with quote txType null WHEN findBestQuote THEN keeps manageCex path`() = runTest {
val provider = buildSwapProvider(ExchangeProviderType.CEX, providerId = "cex-legacy")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = null, txType = null)
stubFindBestQuote(provider, quote)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
assertManageCexPathTaken(result, provider)
}
@Test
fun `GIVEN CEX provider with quote txType SEND WHEN findBestQuote THEN keeps manageCex path`() = runTest {
// Defensive: even if backend starts sending txType=SEND for CEX, behavior stays CEX-only.
val provider = buildSwapProvider(ExchangeProviderType.CEX, providerId = "cex-with-txtype")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = null, txType = ExpressTxType.SEND)
stubFindBestQuote(provider, quote)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
assertManageCexPathTaken(result, provider)
}
// -------------------------------------------------------------------------
// DEX provider on Solana
// -------------------------------------------------------------------------
@Test
fun `GIVEN DEX provider with quote txType SEND on Solana WHEN findBestQuote THEN routes to manageCex path`() =
runTest {
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "dex-with-send-solana")
val from = buildSwapCurrencyStatus(networkRawId = solanaNetwork)
val to = buildSwapCurrencyStatus(networkRawId = solanaNetwork)
val quote = buildQuoteModel(allowanceContract = null, txType = ExpressTxType.SEND)
stubFindBestQuote(provider, quote)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
assertManageCexPathTaken(result, provider)
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun stubFindBestQuote(provider: SwapProvider, quote: QuoteModel) {
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = provider.providerId,
rateType = any(),
)
} returns quote.right()
}
/**
* Asserts that the result is a CEX-style quote state produced by `manageCex`:
* - repository.getExchangeData NOT called at the quote stage (it runs inside
* loadDexSwapDataNoFee, only on the DEX path).
* - getAllowanceInfoUseCase NOT called (DEX-only artifact).
*/
private fun assertManageCexPathTaken(
result: Map<SwapProvider, SwapState>,
provider: SwapProvider,
) {
assertThat(result).hasSize(1)
assertThat(result[provider]).isInstanceOf(SwapState.QuotesLoadedState::class.java)
coVerify(exactly = 0) { getAllowanceInfoUseCase.invoke(any(), any(), any(), any()) }
coVerify(exactly = 0) {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = any(),
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
}
}
/**
* Asserts that the result took the DEX path through `manageDex` / `manageDexSolana`. Both
* paths drive `loadDexSwapDataNoFee` -> `repository.getExchangeData` when the quote returns
* Right and balance is sufficient (the default setup ensures this). The presence of that
* call is therefore a reliable signal that the bridge re-route did NOT fire.
*/
private fun assertManageDexPathTaken(
result: Map<SwapProvider, SwapState>,
provider: SwapProvider,
) {
assertThat(result).hasSize(1)
assertThat(result[provider]).isInstanceOf(SwapState.QuotesLoadedState::class.java)
coVerify(atLeast = 1) {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = provider.providerId,
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
}
}
}

View file

@ -904,6 +904,7 @@ private fun buildSwapDataModelDex(
txData = txData,
otherNativeFeeWei = null,
gas = BigInteger.valueOf(21_000L),
allowanceContract = null,
),
)

View file

@ -99,6 +99,7 @@ internal class SwapInteractorImplLoadDexSwapDataNoFeeTest : SwapInteractorImplTe
txData = "0xdata",
otherNativeFeeWei = null,
gas = BigInteger.valueOf(21_000L),
allowanceContract = null,
),
)

View file

@ -598,5 +598,6 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
txData = "dGVzdA==",
otherNativeFeeWei = otherNativeFeeWei,
gas = BigInteger.valueOf(21_000L),
allowanceContract = null,
)
}

View file

@ -0,0 +1,186 @@
package com.tangem.feature.swap.domain
import arrow.core.left
import arrow.core.right
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionExtras
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
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 io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.math.BigInteger
/**
* Covers the `onSwap` flow resolution added for swap-xyz: for DEX / DEX_BRIDGE providers the
* executed path is chosen by the shape of `swapData.transaction`, not by `provider.type`:
* - `ExpressTransactionModel.DEX` -> DEX path (`createTransactionUseCase`, no `getExchangeData`)
* - `ExpressTransactionModel.CEX` / null -> CEX path (`repository.getExchangeData`)
*
* Routing is asserted by side-effects only: the CEX path always re-fetches via `getExchangeData`,
* the DEX path never does. The CEX/DEX terminal calls are stubbed to fail fast (Left) so the test
* stays focused on the dispatch decision and needs no full send wiring.
*
* Existing real-CEX behavior is kept as a regression guard.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplOnSwapTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
@BeforeEach
fun setup() {
every { isDemoCardUseCase(any()) } returns false
// CEX path: return early on a Left so we only observe the getExchangeData side-effect.
coEvery {
repository.getExchangeData(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
providerId = any(), rateType = any(), toAddress = any(),
expressOperationType = any(), refundAddress = any(),
)
} returns ExpressDataError.UnknownError().left()
// DEX path: extras must resolve (createDexTxExtras errors on null), then createTransaction
// returns a Left so onSwapDex returns early after the call is recorded.
coEvery {
createTransactionExtrasUseCase(data = any(), network = any(), gasLimit = any())
} returns mockk<TransactionExtras>(relaxed = true).right()
coEvery {
createTransactionUseCase(
amount = any(), fee = any(), memo = any(),
destination = any(), userWalletId = any(), network = any(), txExtras = any(),
)
} returns Throwable("stub").left()
}
@Test
fun `GIVEN DEX provider with DEX swapData WHEN onSwap THEN takes DEX path`() = runTest {
onSwap(provider = ExchangeProviderType.DEX, swapData = dexSwapData())
coVerifyCreateTransaction(times = 1)
coVerifyGetExchangeData(times = 0)
}
@Test
fun `GIVEN DEX provider with CEX swapData WHEN onSwap THEN takes CEX path`() = runTest {
onSwap(provider = ExchangeProviderType.DEX, swapData = cexSwapData())
coVerifyGetExchangeData(times = 1)
coVerifyCreateTransaction(times = 0)
}
@Test
fun `GIVEN DEX provider with null swapData WHEN onSwap THEN takes CEX path`() = runTest {
// The bridge re-route nulled swapData at the quote stage; onSwap must fall through to CEX.
onSwap(provider = ExchangeProviderType.DEX, swapData = null)
coVerifyGetExchangeData(times = 1)
coVerifyCreateTransaction(times = 0)
}
@Test
fun `GIVEN DEX_BRIDGE provider with DEX swapData WHEN onSwap THEN takes DEX path`() = runTest {
onSwap(provider = ExchangeProviderType.DEX_BRIDGE, swapData = dexSwapData())
coVerifyCreateTransaction(times = 1)
coVerifyGetExchangeData(times = 0)
}
@Test
fun `GIVEN CEX provider WHEN onSwap THEN takes CEX path`() = runTest {
// Regression guard: real CEX provider is unaffected by the new resolution.
onSwap(provider = ExchangeProviderType.CEX, swapData = null)
coVerifyGetExchangeData(times = 1)
}
// region helpers
private suspend fun onSwap(provider: ExchangeProviderType, swapData: SwapDataModel?) {
sut.onSwap(
fromSwapCurrencyStatus = hotStatus(),
toSwapCurrencyStatus = hotStatus(),
swapProvider = buildSwapProvider(provider),
swapData = swapData,
amountToSwap = "1.0",
balanceStatus = SwapBalanceStatus.Sufficient,
fee = buildSwapFee(),
expressOperationType = ExpressOperationType.SWAP,
isTangemPayWithdrawal = false,
)
}
/** Backed by an explicit [UserWallet.Hot] mock so the `is UserWallet.Cold` demo check is false. */
private fun hotStatus(): SwapCurrencyStatus {
val hotWallet = mockk<UserWallet.Hot>(relaxed = true)
return buildSwapCurrencyStatus(networkRawId = ethNetwork).let {
SwapCurrencyStatus(userWallet = hotWallet, status = it.status, account = it.account)
}
}
private fun dexSwapData(): SwapDataModel = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = ExpressTransactionModel.DEX(
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
toAmount = SwapAmount(BigDecimal("0.5"), 18),
txValue = "1000000000000000000",
txId = "tx-id",
txTo = "0xTo",
txExtraId = null,
txFrom = "0xFrom",
txData = "0xdata",
otherNativeFeeWei = null,
gas = BigInteger.valueOf(21_000L),
allowanceContract = null,
),
)
private fun cexSwapData(): SwapDataModel = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = ExpressTransactionModel.CEX(
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
toAmount = SwapAmount(BigDecimal("0.5"), 18),
txValue = null,
txId = "cex-tx-id",
txTo = "0xCexAddress",
txExtraId = null,
externalTxId = "ext-id",
externalTxUrl = "https://explorer/tx",
txExtraIdName = null,
),
)
private fun coVerifyGetExchangeData(times: Int) = coVerify(exactly = times) {
repository.getExchangeData(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
providerId = any(), rateType = any(), toAddress = any(),
expressOperationType = any(), refundAddress = any(),
)
}
private fun coVerifyCreateTransaction(times: Int) = coVerify(exactly = times) {
createTransactionUseCase(
amount = any(), fee = any(), memo = any(),
destination = any(), userWalletId = any(), network = any(), txExtras = any(),
)
}
// endregion
}

View file

@ -60,6 +60,7 @@ internal class SwapInteractorImplStoreSwapTransactionTest : SwapInteractorImplTe
txData = "dGVzdA==",
otherNativeFeeWei = null,
gas = BigInteger.valueOf(21_000L),
allowanceContract = null,
),
)
val timestamp = 1_700_000_000L
@ -126,6 +127,7 @@ internal class SwapInteractorImplStoreSwapTransactionTest : SwapInteractorImplTe
txData = "dGVzdA==",
otherNativeFeeWei = null,
gas = BigInteger.valueOf(21_000L),
allowanceContract = null,
),
)

View file

@ -315,15 +315,17 @@ internal fun buildSwapPairLeast(
)
/**
* Builds a [QuoteModel] with optional allowance contract.
* Builds a [QuoteModel] with optional allowance contract and txType.
*/
internal fun buildQuoteModel(
toAmount: BigDecimal = BigDecimal("0.5"),
decimals: Int = 18,
allowanceContract: String? = null,
txType: ExpressTxType? = null,
): QuoteModel = QuoteModel(
toTokenAmount = SwapAmount(toAmount, decimals),
allowanceContract = allowanceContract,
txType = txType,
)
/**

View file

@ -239,6 +239,33 @@ internal class DexSwapFeeCalculatorTest {
}
}
@Test
fun `EVM DEX swap raises UnknownError when getFeeUseCase fails and transaction gas is null`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val transaction = buildDex(txValue = "1000000000000000", gas = null)
// Force ISE in the main path so we enter the gas-fallback branch.
coEvery {
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
} returns GetFeeError.UnknownError.left()
val result = sut.calculate(fromStatus, transaction)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isEqualTo(ExpressDataError.UnknownError())
}
// Fallback use-case must NOT be invoked when gas is null — there's nothing to feed it.
coVerify(exactly = 0) {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = any(),
gasPrice = any(),
)
}
}
// -------------------------------------------------------------------------
// 12% gas patch — golden numbers
// -------------------------------------------------------------------------
@ -424,9 +451,10 @@ internal class DexSwapFeeCalculatorTest {
txValue: String? = "0",
toAmount: BigDecimal = BigDecimal("0.5"),
otherNativeFeeWei: BigDecimal? = null,
gas: BigInteger = BigInteger.valueOf(21_000L),
gas: BigInteger? = BigInteger.valueOf(21_000L),
txTo: String = "0xRecipient",
txFrom: String = "0xSender",
allowanceContract: String? = null,
): ExpressTransactionModel.DEX = ExpressTransactionModel.DEX(
fromAmount = SwapAmount(BigDecimal.ONE, 18),
toAmount = SwapAmount(toAmount, 18),
@ -438,5 +466,6 @@ internal class DexSwapFeeCalculatorTest {
txData = txData,
otherNativeFeeWei = otherNativeFeeWei,
gas = gas,
allowanceContract = allowanceContract,
)
}