From 22488e972680d95db509c684407cfb2d178aadec Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 4 Jun 2026 14:43:59 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractor.kt | 5 ++ .../feature/swap/domain/SwapInteractorImpl.kt | 10 ++-- .../swap/domain/models/ui/SwapState.kt | 2 + .../SwapInteractorImplLoadSwapFeeTest.kt | 58 +++++++++++++++++++ .../tangem/feature/swap/model/SwapModel.kt | 43 +++++++++++--- 5 files changed, 104 insertions(+), 14 deletions(-) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 01cd972d61..96a05a49ae 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -106,6 +106,10 @@ interface SwapInteractor { * Delegates to `DexSwapFeeCalculator` for DEX/DEX_BRIDGE or to `CexSwapFeeCalculator` for CEX, * then wraps the result in a [SwapFee]. * + * Flow is resolved by [txType], matching the quote-stage `resolveQuoteFlow`: a DEX/DEX_BRIDGE + * provider whose quote returned `txType=SEND` (swap-xyz native transfer) takes the CEX-style + * fee path even though [swapData] is `null`. `txType=SWAP`/`null` keeps the DEX path. + * * The DEX path consumes the pre-fetched [swapData] (which carries the `ExpressTransactionModel.DEX` payload); * the CEX path computes the fee directly from `amount`. * When [swapData] is `null` on the DEX path the call short-circuits to `Left(GetFeeError.UnknownError)` — @@ -130,5 +134,6 @@ interface SwapInteractor { swapData: SwapDataModel?, selectedFeeToken: CryptoCurrencyStatus?, isGasless: Boolean, + txType: ExpressTxType? = null, ): Either } \ 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 93eaea8fda..9ed0402e69 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 @@ -967,19 +967,18 @@ internal class SwapInteractorImpl @Inject constructor( swapData: SwapDataModel?, selectedFeeToken: CryptoCurrencyStatus?, isGasless: Boolean, + txType: ExpressTxType?, ): Either = either { if (amount.value.signum() == 0) { raise(GetFeeError.UnknownError) } - return when (provider.type) { - ExchangeProviderType.DEX, - ExchangeProviderType.DEX_BRIDGE, - -> loadDexSwapFee( + return when (resolveQuoteFlow(provider, txType)) { + ResolvedFlow.DexLike -> loadDexSwapFee( fromStatus = fromStatus, swapData = swapData, selectedFeeToken = selectedFeeToken, ) - ExchangeProviderType.CEX -> loadCexSwapFee( + ResolvedFlow.CexLike -> loadCexSwapFee( fromStatus = fromStatus, amount = amount, selectedFeeToken = selectedFeeToken, @@ -1306,6 +1305,7 @@ internal class SwapInteractorImpl @Inject constructor( feeValue = BigDecimal.ZERO, ), minAdaValue = null, + txType = quoteModel.txType, ) when (resolveQuoteFlow(provider, quoteModel.txType)) { diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 861066f760..ccc1db514d 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -8,6 +8,7 @@ import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExpressTxType import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus import com.tangem.feature.swap.domain.models.domain.SwapDataModel @@ -30,6 +31,7 @@ sealed interface SwapState { val validationResult: Throwable? = null, val minAdaValue: BigDecimal?, val swapProvider: SwapProvider, + val txType: ExpressTxType? = null, ) : SwapState data class Transfer( 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 e705f7c63e..51447a52f6 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 @@ -20,6 +20,7 @@ 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.ExpressTxType import com.tangem.feature.swap.domain.models.domain.SwapDataModel import com.tangem.feature.swap.domain.models.ui.FeeBucket import io.mockk.coEvery @@ -228,6 +229,63 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase() } } + @Test + fun `DEX provider with quote txType SEND and null swapData routes to CEX fee calculator`() = runTest { + // [REDACTED_TASK_KEY]: swap-xyz comes as provider.type=DEX but the quote returns txType=SEND, which + // re-routes to the CEX-style flow (no DEX swapData is built). Fee must load via the CEX + // calculator instead of short-circuiting to UnknownError. + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val extendedFee = mockk(relaxed = true) { + io.mockk.every { transactionFee } returns TransactionFee.Single(normal = mockk(relaxed = true)) + } + coEvery { + cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any()) + } returns CexFeeResult(transactionFee = TransactionFeeResult.LoadedExtended(extendedFee)).right() + + val result = sut.loadSwapFee( + provider = buildSwapProvider(ExchangeProviderType.DEX), + fromStatus = fromStatus, + toStatus = toStatus, + amount = SwapAmount(BigDecimal.ONE, 18), + swapData = null, + selectedFeeToken = null, + isGasless = false, + txType = ExpressTxType.SEND, + ) + + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) } + } + + @Test + fun `DEX_BRIDGE provider with quote txType SEND and null swapData routes to CEX fee calculator`() = runTest { + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val extendedFee = mockk(relaxed = true) { + io.mockk.every { transactionFee } returns TransactionFee.Single(normal = mockk(relaxed = true)) + } + coEvery { + cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any()) + } returns CexFeeResult(transactionFee = TransactionFeeResult.LoadedExtended(extendedFee)).right() + + val result = sut.loadSwapFee( + provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE), + fromStatus = fromStatus, + toStatus = toStatus, + amount = SwapAmount(BigDecimal.ONE, 18), + swapData = null, + selectedFeeToken = null, + isGasless = false, + txType = ExpressTxType.SEND, + ) + + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) } + } + @Test fun `DEX calculator Left ExpressDataError maps to Wrapped Left GetFeeError`() = runTest { val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 9772880a1c..a69376aedb 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -2135,6 +2135,28 @@ internal class SwapModel @Inject constructor( override val forceUpdateState = MutableSharedFlow() + /** + * Resolves the `swapData` to hand to [SwapInteractor.loadSwapFee] for the native (non-gasless) + * fee load. A DEX/DEX_BRIDGE provider whose quote returned `txType=SEND` (swap-xyz native + * transfer) re-routes to the CEX-style flow without DEX swapData → returns `null`. A real DEX + * quote without resolved swapData is an error. + */ + private fun resolveDexSwapDataForFee( + quoteState: SwapState.QuotesLoadedState, + ): Either { + return when (quoteState.swapProvider.type) { + ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { + if (quoteState.txType == ExpressTxType.SEND) { + Either.Right(null) + } else { + quoteState.swapDataModel?.let { Either.Right(it) } + ?: Either.Left(GetFeeError.UnknownError) + } + } + ExchangeProviderType.CEX -> Either.Right(null) + } + } + override suspend fun loadFee(): Either { val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) @@ -2165,12 +2187,8 @@ internal class SwapModel @Inject constructor( val amountDecimal = lastAmount.value.replace(",", ".").toBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError) val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) - val swapDataForCall = when (quoteState.swapProvider.type) { - ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - quoteState.swapDataModel ?: return Either.Left(GetFeeError.UnknownError) - } - ExchangeProviderType.CEX -> null - } + val swapDataForCall = resolveDexSwapDataForFee(quoteState) + .getOrElse { return Either.Left(it) } return swapInteractor.loadSwapFee( provider = quoteState.swapProvider, fromStatus = fromSwapCurrencyStatus, @@ -2179,6 +2197,7 @@ internal class SwapModel @Inject constructor( swapData = swapDataForCall, selectedFeeToken = null, isGasless = false, + txType = quoteState.txType, ).map { swapFee -> when (val res = swapFee.transactionFeeResult) { is TransactionFeeResult.LoadedExtended -> res.fee.transactionFee @@ -2216,11 +2235,16 @@ internal class SwapModel @Inject constructor( val amountDecimal = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError) val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) - // DEX path requires a SwapDataModel. + // DEX path requires a SwapDataModel and does not support gasless yet. swap-xyz native + // transfers (txType=SEND) re-route to the CEX-style flow, so they take the CEX fee path. val swapDataForCall = when (quoteState.swapProvider.type) { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - // TODO support gasless in DEX/DEX_BRIDGE - return Either.Left(GetFeeError.GaslessError.NetworkIsNotSupported) + if (quoteState.txType == ExpressTxType.SEND) { + null + } else { + // TODO support gasless in DEX/DEX_BRIDGE + return Either.Left(GetFeeError.GaslessError.NetworkIsNotSupported) + } } ExchangeProviderType.CEX -> null } @@ -2233,6 +2257,7 @@ internal class SwapModel @Inject constructor( swapData = swapDataForCall, selectedFeeToken = selectedToken, isGasless = true, + txType = quoteState.txType, ).map { swapFee -> // The fee selector block consumes TransactionFeeExtended; build one when // `transactionFeeResult` is LoadedExtended, else wrap the native fee in a