Updated on 2026-08-14
This commit is contained in:
parent
a73dfc58af
commit
5ce5d3dc6c
20 changed files with 877 additions and 11 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -904,6 +904,7 @@ private fun buildSwapDataModelDex(
|
|||
txData = txData,
|
||||
otherNativeFeeWei = null,
|
||||
gas = BigInteger.valueOf(21_000L),
|
||||
allowanceContract = null,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ internal class SwapInteractorImplLoadDexSwapDataNoFeeTest : SwapInteractorImplTe
|
|||
txData = "0xdata",
|
||||
otherNativeFeeWei = null,
|
||||
gas = BigInteger.valueOf(21_000L),
|
||||
allowanceContract = null,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -598,5 +598,6 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
txData = "dGVzdA==",
|
||||
otherNativeFeeWei = otherNativeFeeWei,
|
||||
gas = BigInteger.valueOf(21_000L),
|
||||
allowanceContract = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue