Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-29 17:48:29 +04:00
parent 7f2e5650e2
commit 16c7de9054
27 changed files with 796 additions and 70 deletions

View file

@ -1,6 +1,7 @@
package com.tangem.features.swap
interface SwapFeatureToggles {
val isYieldSwapEnabled: Boolean
val isSwapSwitchToTransferEnabled: Boolean
val isSwapIntegratedApproveEnabled: Boolean
val isSwapAbEnabled: Boolean

View file

@ -51,8 +51,10 @@ dependencies {
implementation(projects.domain.visa)
implementation(projects.domain.visa.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.yieldSupply)
/** Core modules */
implementation(projects.core.configToggles)
implementation(projects.core.utils)
implementation(projects.core.ui)
implementation(projects.core.datasource)

View file

@ -51,6 +51,7 @@ import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator
import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator
@ -61,6 +62,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.toStringWithRightOffset
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.extensions.orZero
import com.tangem.utils.logging.TangemLogger
@ -99,12 +101,17 @@ internal class SwapInteractorImpl @Inject constructor(
private val getSwapPairUseCase: GetSwapPairUseCase,
private val dexSwapFeeCalculator: DexSwapFeeCalculator,
private val cexSwapFeeCalculator: CexSwapFeeCalculator,
private val swapFeatureToggles: SwapFeatureToggles,
private val yieldModuleAddressProvider: YieldModuleAddressProvider,
) : SwapInteractor {
private val getSelectedAppCurrencyUseCase by lazy(LazyThreadSafetyMode.NONE) {
GetSelectedAppCurrencyUseCase(appCurrencyRepository)
}
private val SwapCurrencyStatus.isYieldSwapActive: Boolean
get() = swapFeatureToggles.isYieldSwapEnabled && isYieldSupplyActive
override suspend fun getPair(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
@ -254,7 +261,9 @@ internal class SwapInteractorImpl @Inject constructor(
reduceBalanceBy: BigDecimal,
expressOperationType: ExpressOperationType,
): Pair<SwapProvider, SwapState> {
if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true) {
if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true &&
!swapFeatureToggles.isYieldSwapEnabled
) {
return provider to produceDexSwapDataError(
error = ExpressDataError.DexActiveSupplyError(),
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -286,19 +295,26 @@ internal class SwapInteractorImpl @Inject constructor(
}
val fromTokenAddress = getTokenAddress(fromSwapCurrencyStatus.currency)
val isAllowedToSpend = maybeQuotes.fold(
ifRight = { quotes ->
quotes.allowanceContract?.let { allowanceContract ->
getAllowanceInfoUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
cryptoCurrency = fromSwapCurrencyStatus.currency,
spenderAddress = allowanceContract,
requiredAmount = amount.value,
).getOrNull() is AllowanceInfo.Enough
} != false
},
ifLeft = { false },
)
val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive &&
fromSwapCurrencyStatus.currency is CryptoCurrency.Token
val isAllowedToSpend = if (isYieldSwap) {
maybeQuotes.isRight() &&
fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isAllowedToSpend == true
} else {
maybeQuotes.fold(
ifRight = { quotes ->
quotes.allowanceContract?.let { allowanceContract ->
getAllowanceInfoUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
cryptoCurrency = fromSwapCurrencyStatus.currency,
spenderAddress = allowanceContract,
requiredAmount = amount.value,
).getOrNull() is AllowanceInfo.Enough
} != false
},
ifLeft = { false },
)
}
if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) {
allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress)
@ -308,6 +324,7 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
val isBalanceWithoutFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, null)
val quoteAllowanceContract = maybeQuotes.getOrNull()?.allowanceContract
return if (isAllowedToSpend && isBalanceWithoutFeeEnough) {
provider to loadDexSwapDataNoFee(
provider = provider,
@ -315,6 +332,7 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
amount = amount,
expressOperationType = expressOperationType,
quoteAllowanceContract = quoteAllowanceContract,
)
} else {
val quoteBalanceStatus = if (isBalanceWithoutFeeEnough) {
@ -377,6 +395,7 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
amount = amount,
expressOperationType = expressOperationType,
quoteAllowanceContract = maybeQuotes.getOrNull()?.allowanceContract,
)
} else {
provider to getQuotesState(
@ -635,28 +654,54 @@ internal class SwapInteractorImpl @Inject constructor(
swapFee: SwapFee,
): SwapTransactionState {
val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" }
val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" }
val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX
val dataToSign = dexTransaction.txData
val amountToSend = createNativeAmountForDex(txValue, fromSwapCurrencyStatus.currency.network)
val txData = createTransactionUseCase(
amount = amountToSend,
fee = swapFee.fee,
memo = null,
destination = swapData.transaction.txTo,
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = toSwapCurrencyStatus.currency.network,
txExtras = createDexTxExtras(
dataToSign,
fromSwapCurrencyStatus.currency.network,
swapFee.fee.getGasLimit(),
),
).getOrElse { error ->
val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive
val fromCurrency = fromSwapCurrencyStatus.currency
val txDataResult = if (isYieldSwap && fromCurrency is CryptoCurrency.Token) {
val spenderAddress = dexTransaction.allowanceContract
?: return SwapTransactionState.Error.UnknownError
createYieldSwapDexTransaction(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
swapData = swapData,
dexCallData = dataToSign,
amount = amountDecimal,
fee = swapFee.fee,
spenderAddress = spenderAddress,
)
} else {
val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" }
val amountToSend = createNativeAmountForDex(txValue, fromCurrency.network)
createTransactionUseCase(
amount = amountToSend,
fee = swapFee.fee,
memo = null,
destination = swapData.transaction.txTo,
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = fromCurrency.network,
txExtras = createDexTxExtras(
dataToSign,
fromCurrency.network,
swapFee.fee.getGasLimit(),
),
)
}
val txData = txDataResult.getOrElse { error ->
TangemLogger.e("Failed to create swap dex tx data", error)
return SwapTransactionState.Error.UnknownError
}
val payInAddress = if (isYieldSwap && fromCurrency is CryptoCurrency.Token) {
swapData.transaction.txTo
} else if (txData is TransactionData.Uncompiled) {
getPayoutAddress(txData)
} else {
swapData.transaction.txTo
}
return handleSwapResult(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
@ -664,7 +709,7 @@ internal class SwapInteractorImpl @Inject constructor(
swapData = swapData,
amount = amount,
txData = txData,
payInAddress = getPayoutAddress(txData),
payInAddress = payInAddress,
)
}
@ -1001,11 +1046,23 @@ internal class SwapInteractorImpl @Inject constructor(
val transaction = swapData?.transaction as? ExpressTransactionModel.DEX
?: return GetFeeError.UnknownError.left()
return dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
selectedToken = selectedFeeToken,
).fold(
val dexFeeResultEither = if (fromStatus.isYieldSwapActive && fromStatus.currency is CryptoCurrency.Token) {
val network = (fromStatus.currency as CryptoCurrency.Token).network
val yieldModuleAddress = yieldModuleAddressProvider.getOrFetch(fromStatus.userWalletId, network)
dexSwapFeeCalculator.calculateYield(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
yieldModuleAddress = yieldModuleAddress,
)
} else {
dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
selectedToken = selectedFeeToken,
)
}
return dexFeeResultEither.fold(
ifLeft = { error -> GetFeeError.DataError(error).left() },
ifRight = { dexFeeResult ->
val feeToken = selectedFeeToken
@ -1021,6 +1078,42 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
private suspend fun createYieldSwapDexTransaction(
fromSwapCurrencyStatus: SwapCurrencyStatus,
swapData: SwapDataModel,
dexCallData: String,
amount: BigDecimal,
fee: Fee,
spenderAddress: String,
): Either<Throwable, TransactionData> {
val fromCurrency = fromSwapCurrencyStatus.currency as CryptoCurrency.Token
val network = fromCurrency.network
val yieldModuleAddress = yieldModuleAddressProvider.getOrFetch(fromSwapCurrencyStatus.userWalletId, network)
?: return Either.Left(IllegalStateException("Yield module address is not available for ${network.id}"))
val wrappedCallData = dexSwapFeeCalculator.buildYieldSwapCallData(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
txTo = swapData.transaction.txTo,
dexCallData = dexCallData,
amount = amount,
spenderAddress = spenderAddress,
)
val txExtras = createTransactionExtrasUseCase(
callData = wrappedCallData,
network = network,
gasLimit = fee.getGasLimit()?.toBigInteger(),
).getOrNull() ?: error("Failed to create yield swap extras")
return createTransactionUseCase(
amount = createNativeAmountForDex("0", network),
fee = fee,
memo = null,
destination = yieldModuleAddress,
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = network,
txExtras = txExtras,
)
}
/**
* [REDACTED_TASK_KEY] CEX branch of [loadSwapFee]. Native-fallback behavior is preserved: when
* [selectedFeeToken] is null the gasless use case (invoked inside [CexSwapFeeCalculator])
@ -1501,6 +1594,7 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus: SwapCurrencyStatus,
amount: SwapAmount,
expressOperationType: ExpressOperationType,
quoteAllowanceContract: String? = null,
): SwapState {
val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress
val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty()
@ -1521,7 +1615,14 @@ internal class SwapInteractorImpl @Inject constructor(
toAddress = dexToAddress,
refundAddress = fromNetworkAddress?.defaultAddress?.value,
expressOperationType = expressOperationType,
).fold(
).map { swapData ->
val dexTx = swapData.transaction as? ExpressTransactionModel.DEX
if (dexTx != null && quoteAllowanceContract != null && dexTx.allowanceContract == null) {
swapData.copy(transaction = dexTx.copy(allowanceContract = quoteAllowanceContract))
} else {
swapData
}
}.fold(
ifRight = { swapData ->
val preparedSwapConfigState = PreparedSwapConfigState(
balanceStatus = SwapBalanceStatus.Pending,
@ -1640,17 +1741,31 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive && fromToken is CryptoCurrency.Token
val spenderAddress = if (isYieldSwap) {
yieldModuleAddressProvider.getOrFetch(fromSwapCurrencyStatus.userWalletId, fromToken.network)
?: run {
TangemLogger.e(
"Yield-swap approval skipped: yield-module address unresolved for " +
"walletId=${fromSwapCurrencyStatus.userWalletId} network=${fromToken.network.rawId}",
)
return quotesLoadedState.copy(permissionState = PermissionDataState.Empty)
}
} else {
requireNotNull(quoteModel.allowanceContract) { "spenderAddress cant be null" }
}
val allowanceInfo = getAllowanceInfoUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
cryptoCurrency = fromToken,
spenderAddress = requireNotNull(quoteModel.allowanceContract) { "spenderAddress cant be null" },
spenderAddress = spenderAddress,
requiredAmount = swapAmount.value,
).getOrNull()
return quotesLoadedState.copy(
permissionState = PermissionDataState.PermissionRequired(
isResetApproval = allowanceInfo is AllowanceInfo.ResetNeeded,
spenderAddress = quoteModel.allowanceContract,
spenderAddress = spenderAddress,
),
)
}

View file

@ -9,6 +9,7 @@ import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseC
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
import com.tangem.feature.swap.domain.*
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
import com.tangem.feature.swap.domain.api.SwapRepository
@ -75,6 +76,7 @@ internal class SwapDomainModule {
createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
walletManagersFacade: WalletManagersFacade,
@SwapDexGasLimit patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase,
): DexSwapFeeCalculator = DexSwapFeeCalculator(
getFeeUseCase = getFeeUseCase,
getEthSpecificFeeUseCase = getEthSpecificFeeUseCase,
@ -82,6 +84,7 @@ internal class SwapDomainModule {
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
walletManagersFacade = walletManagersFacade,
patchEthGasLimitForSwap = patchEthGasLimitForSwap,
wrapYieldSwapCallDataWithUpgradeUseCase = wrapYieldSwapCallDataWithUpgradeUseCase,
)
@Provides

View file

@ -7,8 +7,13 @@ import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySwapCallData
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.extensions.hexToBytes
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -19,12 +24,14 @@ import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal
import java.math.BigInteger
/**
* Calculates the on-chain transaction fee for a DEX swap.
@ -52,6 +59,7 @@ class DexSwapFeeCalculator(
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
private val walletManagersFacade: WalletManagersFacade,
private val patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
private val wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase,
) {
suspend fun calculate(
@ -115,6 +123,134 @@ class DexSwapFeeCalculator(
}
}
/**
* Yield-mode DEX fee path: routes the swap through the user's yield module proxy.
*
* Native fee is computed for a [TransactionData.Uncompiled] addressed to [yieldModuleAddress],
* carrying the wrapped call data produced by [buildYieldSwapCallData]. The 12% gas-limit bump
* is applied to match the non-yield DEX flow.
*
* Fallback to [GetEthSpecificFeeUseCase] (with the gas limit carried by the Express transaction
* model) is applied in two cases:
* - [yieldModuleAddress] is `null` yield module address could not be resolved upstream;
* - the fee estimation call throws `IllegalStateException` (e.g. payload too large).
*
* Yield-module errors ([YieldModuleUpgradeUnavailableException],
* [YieldModuleVersionIndeterminateException]) are mapped to [ExpressDataError.UnknownError]
* to keep the unified error surface a single type.
*/
suspend fun calculateYield(
fromSwapCurrencyStatus: SwapCurrencyStatus,
transaction: ExpressTransactionModel.DEX,
yieldModuleAddress: String?,
): Either<ExpressDataError, DexFeeResult> = either {
val fromCurrency = fromSwapCurrencyStatus.currency as? CryptoCurrency.Token
?: raise(ExpressDataError.UnknownError())
val network = fromCurrency.network
val nativeBalance = walletManagersFacade.getNativeTokenBalance(
userWalletId = fromSwapCurrencyStatus.userWalletId,
networkId = network.rawId,
derivationPath = network.derivationPath.value,
)
if (nativeBalance.signum() == 0) raise(ExpressDataError.UnknownError())
if (yieldModuleAddress == null) {
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
return@either ethSpecificFeeFallback(fromSwapCurrencyStatus, gasLimit).bind()
}
val spenderAddress = transaction.allowanceContract
?: raise(ExpressDataError.UnknownError())
val rawFee = try {
val wrappedCallData = buildYieldSwapCallData(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
txTo = transaction.txTo,
dexCallData = transaction.txData,
amount = transaction.fromAmount.value,
spenderAddress = spenderAddress,
)
val extras = createTransactionExtrasUseCase(
callData = wrappedCallData,
network = network,
).getOrNull() ?: raise(ExpressDataError.UnknownError())
val transactionData = TransactionData.Uncompiled(
amount = createNativeAmountForDex("0", network),
destinationAddress = yieldModuleAddress,
fee = null,
sourceAddress = transaction.txFrom,
extras = extras,
)
getFeeUseCase(
transactionData = transactionData,
network = network,
userWallet = fromSwapCurrencyStatus.userWallet,
).getOrNull() ?: raise(ExpressDataError.UnknownError())
} catch (_: YieldModuleUpgradeUnavailableException) {
raise(ExpressDataError.UnknownError())
} catch (_: YieldModuleVersionIndeterminateException) {
raise(ExpressDataError.UnknownError())
} catch (_: IllegalStateException) {
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
return@either ethSpecificFeeFallback(fromSwapCurrencyStatus, gasLimit).bind()
}
val patched = patchEthGasLimitForSwap(rawFee)
DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(patched),
otherNativeFee = BigDecimal.ZERO,
gas = transaction.gas,
)
}
/**
* Wraps a DEX call data into a yield-supply swap call data, ready to be sent through the
* user's yield module. Shared with [SwapInteractorImpl.createYieldSwapDexTransaction], which
* is why this helper is exposed at the calculator level rather than kept private.
*/
suspend fun buildYieldSwapCallData(
fromSwapCurrencyStatus: SwapCurrencyStatus,
txTo: String,
dexCallData: String,
amount: BigDecimal,
spenderAddress: String,
): SmartContractCallData {
val fromCurrency = fromSwapCurrencyStatus.currency as CryptoCurrency.Token
val amountInWei = amount.movePointRight(fromCurrency.decimals).toBigInteger()
val dexCallDataBytes = dexCallData.removePrefix("0x").hexToBytes()
val swapCallData = EthereumYieldSupplySwapCallData(
tokenIn = fromCurrency.contractAddress,
amountIn = amountInWei,
target = txTo,
spender = spenderAddress,
swapData = dexCallDataBytes,
)
return wrapYieldSwapCallDataWithUpgradeUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = fromCurrency.network,
callData = swapCallData,
)
}
private suspend fun ethSpecificFeeFallback(
fromSwapCurrencyStatus: SwapCurrencyStatus,
gasLimit: BigInteger,
): Either<ExpressDataError, DexFeeResult> = either {
val fee = getEthSpecificFeeUseCase(
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoCurrency = fromSwapCurrencyStatus.currency,
gasLimit = gasLimit,
).getOrNull() ?: raise(ExpressDataError.UnknownError())
val patched = patchEthGasLimitForSwap(fee)
DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(patched),
otherNativeFee = BigDecimal.ZERO,
gas = gasLimit,
)
}
@Suppress("CyclomaticComplexMethod")
private suspend fun getFeeDataForDexSwap(
fromSwapCurrencyStatus: SwapCurrencyStatus,

View file

@ -30,7 +30,7 @@ sealed class ExpressTransactionModel {
val txData: String,
val otherNativeFeeWei: BigDecimal?,
val gas: BigInteger?,
val allowanceContract: String?,
val allowanceContract: String? = null,
) : ExpressTransactionModel()
data class CEX(

View file

@ -21,6 +21,7 @@ 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.SwapDataModel
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.SwapState
import io.mockk.coEvery
import io.mockk.every
@ -872,6 +873,210 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
assertThat(result[cexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java)
}
}
@Nested
inner class YieldSwapApprovalPath {
private val yieldProxyAddress = "0xYieldModuleProxy"
private val yieldTokenContract = "0xTokenContract"
@BeforeEach
fun enableYieldSwap() {
every { swapFeatureToggles.isYieldSwapEnabled } returns true
coEvery {
yieldModuleAddressProvider.getOrFetch(any(), any())
} returns yieldProxyAddress
}
@Test
fun `should proceed to QuotesLoadedState when yield-supply is active and isAllowedToSpend is true`() = runTest {
// Given — yield active, approve to proxy in place → swap proceeds via loadDexSwapDataNoFee
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = yieldTokenContract,
isCoin = false,
amount = BigDecimal("10"),
yieldSupplyActive = true,
yieldSupplyAllowedToSpend = true,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
val swapData = buildSwapDataModelDex()
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(),
)
} returns quoteModel.right()
coEvery {
repository.getExchangeData(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(), toAddress = any(),
expressOperationType = any(), refundAddress = any(),
)
} returns swapData.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — proceeds (no PermissionRequired), permissionState is Empty
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.permissionState).isEqualTo(PermissionDataState.Empty)
}
@Test
fun `should request approval to yield-module proxy when isAllowedToSpend is false`() = runTest {
// Given — yield active, approve to proxy revoked → flow must surface PermissionRequired
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = yieldTokenContract,
isCoin = false,
amount = BigDecimal("10"),
yieldSupplyActive = true,
yieldSupplyAllowedToSpend = false,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouterShouldNotBeUsed")
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(),
)
} returns quoteModel.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — PermissionRequired with spender = yield-module proxy (not DEX router)
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.permissionState).isInstanceOf(PermissionDataState.PermissionRequired::class.java)
val required = loaded.permissionState as PermissionDataState.PermissionRequired
assertThat(required.spenderAddress).isEqualTo(yieldProxyAddress)
}
@Test
fun `should set isResetApproval=true when yield-token allowance requires reset before re-approval`() = runTest {
// Given — Tether-like token: any non-zero allowance must be reset to zero before re-approve.
// Yield approve to proxy was revoked → onchain allowance is partial → ResetNeeded.
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = yieldTokenContract,
isCoin = false,
amount = BigDecimal("10"),
yieldSupplyActive = true,
yieldSupplyAllowedToSpend = false,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouterIgnoredForYield")
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(),
)
} returns quoteModel.right()
// Override default Enough stub: simulate partial-allowance state for yield-proxy spender.
coEvery {
getAllowanceInfoUseCase.invoke(
userWalletId = any(),
cryptoCurrency = any(),
spenderAddress = yieldProxyAddress,
requiredAmount = any(),
)
} returns (
AllowanceInfo.ResetNeeded(
allowance = BigDecimal("0.5"),
requiredAmount = BigDecimal("1"),
) as AllowanceInfo
).right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — PermissionRequired with isResetApproval=true and spender = yield-module proxy
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.permissionState).isInstanceOf(PermissionDataState.PermissionRequired::class.java)
val required = loaded.permissionState as PermissionDataState.PermissionRequired
assertThat(required.spenderAddress).isEqualTo(yieldProxyAddress)
assertThat(required.isResetApproval).isTrue()
}
@Test
fun `should fallback to no-permission state when yield-module proxy address is unresolvable`() = runTest {
// Given — yield store returns null (e.g. network unreachable on first resolve)
coEvery { yieldModuleAddressProvider.getOrFetch(any(), any()) } returns null
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = yieldTokenContract,
isCoin = false,
amount = BigDecimal("10"),
yieldSupplyActive = true,
yieldSupplyAllowedToSpend = false,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouter")
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(),
)
} returns quoteModel.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — falls back to PermissionDataState.Empty (no approval UI shown to avoid bogus DEX-router approve)
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.permissionState).isEqualTo(PermissionDataState.Empty)
}
}
}
// region — test-local helpers

View file

@ -33,6 +33,7 @@ import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator
import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator
@ -41,6 +42,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.AmountFormatter
import com.tangem.feature.swap.domain.models.ui.SwapFee
import com.tangem.features.swap.SwapFeatureToggles
import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.mockk
@ -84,6 +86,8 @@ internal open class SwapInteractorImplTestBase {
protected val getSwapPairUseCase: GetSwapPairUseCase = mockk(relaxed = true)
protected val dexSwapFeeCalculator: DexSwapFeeCalculator = mockk(relaxed = true)
protected val cexSwapFeeCalculator: CexSwapFeeCalculator = mockk(relaxed = true)
protected val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
protected val yieldModuleAddressProvider: YieldModuleAddressProvider = mockk(relaxed = true)
// endregion
@ -115,6 +119,8 @@ internal open class SwapInteractorImplTestBase {
getSwapPairUseCase = getSwapPairUseCase,
dexSwapFeeCalculator = dexSwapFeeCalculator,
cexSwapFeeCalculator = cexSwapFeeCalculator,
swapFeatureToggles = swapFeatureToggles,
yieldModuleAddressProvider = yieldModuleAddressProvider,
)
}
@ -158,6 +164,7 @@ internal fun buildSwapCurrencyStatus(
decimals: Int = 18,
userWalletId: UserWalletId = UserWalletId(stringValue = "deadbeef"),
yieldSupplyActive: Boolean = false,
yieldSupplyAllowedToSpend: Boolean = true,
): SwapCurrencyStatus {
val networkId = mockk<Network.ID>(relaxed = true) {
every { rawId } returns Network.RawID(networkRawId)
@ -196,6 +203,7 @@ internal fun buildSwapCurrencyStatus(
val maybeYield: YieldSupplyStatus? = if (yieldSupplyActive) {
mockk<YieldSupplyStatus>(relaxed = true) {
every { isActive } returns true
every { isAllowedToSpend } returns yieldSupplyAllowedToSpend
}
} else {
null

View file

@ -20,6 +20,7 @@ import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
import com.tangem.feature.swap.domain.buildSwapCurrencyStatus
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
@ -59,6 +60,7 @@ internal class DexSwapFeeCalculatorTest {
private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true)
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true)
private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true)
private val wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase = mockk(relaxed = true)
private val dexBump = PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.DEX_PERCENTAGE)
@ -70,6 +72,7 @@ internal class DexSwapFeeCalculatorTest {
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
walletManagersFacade = walletManagersFacade,
patchEthGasLimitForSwap = dexBump,
wrapYieldSwapCallDataWithUpgradeUseCase = wrapYieldSwapCallDataWithUpgradeUseCase,
)
}

View file

@ -9,6 +9,10 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
featureTogglesManager: FeatureTogglesManager,
) : SwapFeatureToggles {
override val isYieldSwapEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED,
)
override val isSwapSwitchToTransferEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED,
)