Updated on 2026-08-14
This commit is contained in:
parent
0bf1a5cfe0
commit
22488e9726
5 changed files with 104 additions and 14 deletions
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue