Updated on 2026-08-14
This commit is contained in:
parent
7f2e5650e2
commit
16c7de9054
27 changed files with 796 additions and 70 deletions
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.features.swap
|
||||
|
||||
interface SwapFeatureToggles {
|
||||
val isYieldSwapEnabled: Boolean
|
||||
val isSwapSwitchToTransferEnabled: Boolean
|
||||
val isSwapIntegratedApproveEnabled: Boolean
|
||||
val isSwapAbEnabled: Boolean
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue