Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-04 14:43:59 +04:00
parent 0bf1a5cfe0
commit 22488e9726
5 changed files with 104 additions and 14 deletions

View file

@ -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<GetFeeError, SwapFee>
}

View file

@ -967,19 +967,18 @@ internal class SwapInteractorImpl @Inject constructor(
swapData: SwapDataModel?,
selectedFeeToken: CryptoCurrencyStatus?,
isGasless: Boolean,
txType: ExpressTxType?,
): Either<GetFeeError, SwapFee> = 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)) {

View file

@ -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(

View file

@ -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<TransactionFeeExtended>(relaxed = true) {
io.mockk.every { transactionFee } returns TransactionFee.Single(normal = mockk<Fee.Common>(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<TransactionFeeExtended>(relaxed = true) {
io.mockk.every { transactionFee } returns TransactionFee.Single(normal = mockk<Fee.Common>(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)

View file

@ -2135,6 +2135,28 @@ internal class SwapModel @Inject constructor(
override val forceUpdateState = MutableSharedFlow<FeeSelectorUM>()
/**
* 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<GetFeeError, SwapDataModel?> {
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<GetFeeError, TransactionFee> {
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