diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt index b240920600..8106261415 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt @@ -75,6 +75,9 @@ data class TxDetails( @Json(name = "gas") val gas: String?, + + @Json(name = "allowanceContract") + val allowanceContract: String? = null, ) @JsonClass(generateAdapter = false) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeQuoteResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeQuoteResponse.kt index 4326d871bf..61dd36de58 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeQuoteResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeQuoteResponse.kt @@ -28,4 +28,7 @@ data class ExchangeQuoteResponse( @Json(name = "quoteId") val quoteId: String? = null, + @Json(name = "txType") + val txType: TxType? = null, + ) \ No newline at end of file diff --git a/features/swap/data/build.gradle.kts b/features/swap/data/build.gradle.kts index efe6438a1b..518d23cd2a 100644 --- a/features/swap/data/build.gradle.kts +++ b/features/swap/data/build.gradle.kts @@ -13,6 +13,10 @@ android { namespace = "com.tangem.feature.swap.data" } +tasks.withType().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) } \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index 3cfbf7fa8f..2a5c95d5a0 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -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() diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExpressDataConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExpressDataConverter.kt index 8d605d8dfd..b6206d27ee 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExpressDataConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExpressDataConverter.kt @@ -42,7 +42,9 @@ internal class ExpressDataConverter : Converter ExpressTxType.SEND + TxType.SWAP -> ExpressTxType.SWAP +} \ No newline at end of file diff --git a/features/swap/data/src/test/kotlin/com/tangem/feature/swap/converters/ExpressDataConverterTest.kt b/features/swap/data/src/test/kotlin/com/tangem/feature/swap/converters/ExpressDataConverterTest.kt new file mode 100644 index 0000000000..206ede1001 --- /dev/null +++ b/features/swap/data/src/test/kotlin/com/tangem/feature/swap/converters/ExpressDataConverterTest.kt @@ -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, + ) +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index b1b03a0bb9..c7b7cb9778 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -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 { 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 { 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 diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt index 1509a7338f..8f711b7b88 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt @@ -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()) } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt index 05d1d541e3..0986144ef2 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt @@ -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( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTxType.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTxType.kt new file mode 100644 index 0000000000..3b9ef2edb9 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTxType.kt @@ -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, +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt index df74da7af3..817a03abb2 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt @@ -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?, ) \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplBridgeReRouteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplBridgeReRouteTest.kt new file mode 100644 index 0000000000..bb0f71b3e6 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplBridgeReRouteTest.kt @@ -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, + 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, + 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(), + ) + } + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt index 0c0243150f..5e5d2ab44b 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt @@ -904,6 +904,7 @@ private fun buildSwapDataModelDex( txData = txData, otherNativeFeeWei = null, gas = BigInteger.valueOf(21_000L), + allowanceContract = null, ), ) diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt index b1af123e7b..732ae7d1f8 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt @@ -99,6 +99,7 @@ internal class SwapInteractorImplLoadDexSwapDataNoFeeTest : SwapInteractorImplTe txData = "0xdata", otherNativeFeeWei = null, gas = BigInteger.valueOf(21_000L), + allowanceContract = null, ), ) diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt index 066fba5fe5..cf265e96d2 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt @@ -598,5 +598,6 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase() txData = "dGVzdA==", otherNativeFeeWei = otherNativeFeeWei, gas = BigInteger.valueOf(21_000L), + allowanceContract = null, ) } \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt index e69de29bb2..e4444c20c3 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt @@ -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(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(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 +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplStoreSwapTransactionTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplStoreSwapTransactionTest.kt index b560b6cfa8..098bfbd676 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplStoreSwapTransactionTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplStoreSwapTransactionTest.kt @@ -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, ), ) diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt index 71f8911ed2..e6d4aaf1cb 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt @@ -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, ) /** diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt index bbd55adec7..9d57af4369 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt @@ -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, ) } \ No newline at end of file