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

@ -78,6 +78,16 @@ internal object YieldSupplyDomainModule {
) )
} }
@Provides
@Singleton
fun provideWrapYieldSwapCallDataWithUpgradeUseCase(
yieldSupplyTransactionRepository: YieldSupplyTransactionRepository,
): WrapYieldSwapCallDataWithUpgradeUseCase {
return WrapYieldSwapCallDataWithUpgradeUseCase(
yieldSupplyTransactionRepository = yieldSupplyTransactionRepository,
)
}
@Provides @Provides
@Singleton @Singleton
fun provideYieldSupplyGetProtocolBalanceUseCase( fun provideYieldSupplyGetProtocolBalanceUseCase(

View file

@ -55,6 +55,10 @@
"name": "WALLET_CONNECT_BITCOIN_ENABLED", "name": "WALLET_CONNECT_BITCOIN_ENABLED",
"version": "undefined" "version": "undefined"
}, },
{
"name": "TWI_1326_YIELD_MODE_SWAP_ENABLED",
"version": "undefined"
},
{ {
"name": "ADDRESS_SYNC_ENABLED", "name": "ADDRESS_SYNC_ENABLED",
"version": "undefined" "version": "undefined"

View file

@ -46,6 +46,9 @@ dependencies {
exclude(module = "joda-time") exclude(module = "joda-time")
} }
/** Core */
implementation(projects.core.configToggles)
/** Libs */ /** Libs */
implementation(projects.libs.blockchainSdk) implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto) implementation(projects.libs.crypto)

View file

@ -6,6 +6,8 @@ import arrow.core.toOption
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.data.common.api.safeApiCall import com.tangem.data.common.api.safeApiCall
import com.tangem.data.swap.converter.SwapDataConverter import com.tangem.data.swap.converter.SwapDataConverter
import com.tangem.data.swap.converter.SwapStatusConverter import com.tangem.data.swap.converter.SwapStatusConverter
@ -57,6 +59,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
private val dataSignatureVerifier: DataSignatureVerifier, private val dataSignatureVerifier: DataSignatureVerifier,
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier, private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher, private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher,
private val featureTogglesManager: FeatureTogglesManager,
@NetworkMoshi moshi: Moshi, @NetworkMoshi moshi: Moshi,
) : SwapRepositoryV2 { ) : SwapRepositoryV2 {
@ -544,17 +547,22 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString() return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString()
} }
private fun List<ExpressProvider>.filterYieldSupplyProvider(cryptoCurrencyStatus: CryptoCurrencyStatus?) = private fun List<ExpressProvider>.filterYieldSupplyProvider(
cryptoCurrencyStatus: CryptoCurrencyStatus?,
): List<ExpressProvider> {
return if (featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED)) {
this
} else {
filter { provider -> filter { provider ->
// !!!WARNING!!! Filter out dex provider if yield supply is active if (cryptoCurrencyStatus?.value?.yieldSupplyStatus?.isActive == true) {
val yieldSupplyStatus = cryptoCurrencyStatus?.value?.yieldSupplyStatus
if (yieldSupplyStatus != null && yieldSupplyStatus.isActive) {
provider.type == ExpressProviderType.CEX provider.type == ExpressProviderType.CEX
} else { } else {
true true
} }
} }
} }
}
}
private val MEMO_RESTRICTED_NETWORKS = setOf( private val MEMO_RESTRICTED_NETWORKS = setOf(
Blockchain.InternetComputer.toNetworkId(), Blockchain.InternetComputer.toNetworkId(),

View file

@ -18,6 +18,7 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.swap.SwapErrorResolver import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2 import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.swap.SwapTransactionRepository import com.tangem.domain.swap.SwapTransactionRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module import dagger.Module
@ -49,6 +50,7 @@ internal object SwapDataModule {
dataSignatureVerifier: DataSignatureVerifier, dataSignatureVerifier: DataSignatureVerifier,
singleQuoteStatusSupplier: SingleQuoteStatusSupplier, singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
singleQuoteStatusFetcher: SingleQuoteStatusFetcher, singleQuoteStatusFetcher: SingleQuoteStatusFetcher,
featureTogglesManager: FeatureTogglesManager,
@NetworkMoshi moshi: Moshi, @NetworkMoshi moshi: Moshi,
): SwapRepositoryV2 { ): SwapRepositoryV2 {
return DefaultSwapRepositoryV2( return DefaultSwapRepositoryV2(
@ -60,6 +62,7 @@ internal object SwapDataModule {
moshi = moshi, moshi = moshi,
singleQuoteStatusSupplier = singleQuoteStatusSupplier, singleQuoteStatusSupplier = singleQuoteStatusSupplier,
singleQuoteStatusFetcher = singleQuoteStatusFetcher, singleQuoteStatusFetcher = singleQuoteStatusFetcher,
featureTogglesManager = featureTogglesManager,
) )
} }

View file

@ -24,6 +24,8 @@ import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.swap.models.SwapStatus import com.tangem.domain.swap.models.SwapStatus
import com.tangem.domain.swap.models.SwapTxType import com.tangem.domain.swap.models.SwapTxType
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.* import io.mockk.*
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -43,6 +45,9 @@ internal class DefaultSwapRepositoryV2Test {
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk() private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk()
private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher = mockk() private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher = mockk()
private val moshi: Moshi = Moshi.Builder().build() private val moshi: Moshi = Moshi.Builder().build()
private val featureTogglesManager: FeatureTogglesManager = mockk {
every { isFeatureEnabled(any()) } returns false
}
private val repository = DefaultSwapRepositoryV2( private val repository = DefaultSwapRepositoryV2(
tangemExpressApi = tangemExpressApi, tangemExpressApi = tangemExpressApi,
@ -52,6 +57,7 @@ internal class DefaultSwapRepositoryV2Test {
dataSignatureVerifier = dataSignatureVerifier, dataSignatureVerifier = dataSignatureVerifier,
singleQuoteStatusSupplier = singleQuoteStatusSupplier, singleQuoteStatusSupplier = singleQuoteStatusSupplier,
singleQuoteStatusFetcher = singleQuoteStatusFetcher, singleQuoteStatusFetcher = singleQuoteStatusFetcher,
featureTogglesManager = featureTogglesManager,
moshi = moshi, moshi = moshi,
) )
@ -64,7 +70,9 @@ internal class DefaultSwapRepositoryV2Test {
dataSignatureVerifier, dataSignatureVerifier,
singleQuoteStatusSupplier, singleQuoteStatusSupplier,
singleQuoteStatusFetcher, singleQuoteStatusFetcher,
featureTogglesManager,
) )
every { featureTogglesManager.isFeatureEnabled(any()) } returns false
} }
// region getPairs(SwapCurrencyStatus, SwapCurrencyStatus) // region getPairs(SwapCurrencyStatus, SwapCurrencyStatus)
@ -511,8 +519,9 @@ internal class DefaultSwapRepositoryV2Test {
// region filterYieldSupplyProvider // region filterYieldSupplyProvider
@Test @Test
fun `getPairs filters out DEX providers when yield supply is active`() = runTest { fun `getPairs filters out DEX providers when yield supply is active and flag is off`() = runTest {
// Arrange // Arrange
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED) } returns false
val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin) val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin)
val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin)
val primarySwapCurrencyStatus = SwapCurrencyStatus( val primarySwapCurrencyStatus = SwapCurrencyStatus(
@ -558,6 +567,54 @@ internal class DefaultSwapRepositoryV2Test {
assertThat(providers.first().type).isEqualTo(ExpressProviderType.CEX) assertThat(providers.first().type).isEqualTo(ExpressProviderType.CEX)
} }
@Test
fun `getPairs keeps DEX providers when yield supply is active and flag is on`() = runTest {
// Arrange
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED) } returns true
val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin)
val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin)
val primarySwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = primaryStatus,
account = mockk(),
)
val secondarySwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = secondaryStatus,
account = mockk(),
)
val swapPair = SwapPair(
from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID),
to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID),
providers = listOf(
SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)),
SwapPairProvider(providerId = CEX_PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)),
),
)
coEvery {
tangemExpressApi.getPairs(any(), any(), any())
} returns ApiResponse.Success(listOf(swapPair))
coEvery {
expressRepository.getProviders(any(), any())
} returns listOf(dexProvider, cexProvider)
// Act
val result = repository.getPairs(
primarySwapCurrencyStatus = primarySwapCurrencyStatus,
secondarySwapCurrencyStatus = secondarySwapCurrencyStatus,
filterProviderTypes = emptyList(),
swapTxType = SwapTxType.Swap,
)
// Assert — both providers should remain
assertThat(result).hasSize(2)
val providers = result.first().providers
assertThat(providers).hasSize(2)
}
// endregion // endregion
// region getSwapData // region getSwapData

View file

@ -0,0 +1,54 @@
package com.tangem.data.yield.supply
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
internal class DefaultYieldModuleAddressProvider(
private val walletManagersFacade: WalletManagersFacade,
private val dispatchers: CoroutineDispatcherProvider,
) : YieldModuleAddressProvider {
private data class Key(val userWalletId: UserWalletId, val networkRawId: String)
private val cache = ConcurrentHashMap<Key, String>()
private val mutex = Mutex()
override suspend fun getOrFetch(userWalletId: UserWalletId, network: Network): String? {
val key = Key(userWalletId, network.rawId)
cache[key]?.let { return it }
return withContext(dispatchers.io) {
mutex.withLock {
cache[key]?.let { return@withLock it }
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = network.toBlockchain(),
derivationPath = network.derivationPath.value,
) ?: error("Wallet manager not found for $network")
// SDK returns ZERO_ADDRESS on internal failure (e.g. RPC error). Treat that as
// "unavailable" so callers are forced by the type system to fall back instead
// of using it as a destination.
val address = walletManager.getYieldModuleAddress()
.takeIf { it != EthereumUtils.ZERO_ADDRESS }
if (address != null) cache[key] = address
address
}
}
}
override fun invalidate(userWalletId: UserWalletId?) {
if (userWalletId == null) {
cache.clear()
} else {
cache.keys.removeAll { it.userWalletId == userWalletId }
}
}
}

View file

@ -11,6 +11,7 @@ import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFact
import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.yield.supply.YieldSupplyStatus import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.utils.convertToSdkAmount
@ -127,6 +128,11 @@ internal class DefaultYieldSupplyTransactionRepository(
val amount = getEnterAmount(cryptoCurrency, yieldSupplyStatus) val amount = getEnterAmount(cryptoCurrency, yieldSupplyStatus)
val emptyContractAddress = existingYieldAddress == null || existingYieldAddress == EthereumUtils.ZERO_ADDRESS val emptyContractAddress = existingYieldAddress == null || existingYieldAddress == EthereumUtils.ZERO_ADDRESS
val activeYieldContractAddress = if (emptyContractAddress) {
calculatedYieldContractAddress
} else {
existingYieldAddress
}
when { when {
yieldSupplyStatus == null || emptyContractAddress -> { yieldSupplyStatus == null || emptyContractAddress -> {
@ -143,7 +149,7 @@ internal class DefaultYieldSupplyTransactionRepository(
createInitTokenTransaction( createInitTokenTransaction(
walletManager = walletManager, walletManager = walletManager,
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
yieldContractAddress = calculatedYieldContractAddress, yieldContractAddress = activeYieldContractAddress,
amount = amount, amount = amount,
maxNetworkFee = maxNetworkFee, maxNetworkFee = maxNetworkFee,
), ),
@ -152,7 +158,7 @@ internal class DefaultYieldSupplyTransactionRepository(
createReactivateTokenTransaction( createReactivateTokenTransaction(
walletManager = walletManager, walletManager = walletManager,
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
yieldContractAddress = calculatedYieldContractAddress, yieldContractAddress = activeYieldContractAddress,
amount = amount, amount = amount,
maxNetworkFee = maxNetworkFee, maxNetworkFee = maxNetworkFee,
), ),
@ -166,7 +172,7 @@ internal class DefaultYieldSupplyTransactionRepository(
walletManager = walletManager, walletManager = walletManager,
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
callData = ApprovalERC20TokenCallData( callData = ApprovalERC20TokenCallData(
spenderAddress = calculatedYieldContractAddress, spenderAddress = activeYieldContractAddress,
amount = null, amount = null,
), ),
destinationAddress = cryptoCurrency.contractAddress, destinationAddress = cryptoCurrency.contractAddress,
@ -182,7 +188,7 @@ internal class DefaultYieldSupplyTransactionRepository(
walletManager = walletManager, walletManager = walletManager,
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
amount = amount, amount = amount,
yieldContractAddress = calculatedYieldContractAddress, yieldContractAddress = activeYieldContractAddress,
), ),
) )
} }
@ -218,6 +224,20 @@ internal class DefaultYieldSupplyTransactionRepository(
}.onFailure { TangemLogger.e("Error", it) }.getOrThrow() }.onFailure { TangemLogger.e("Error", it) }.getOrThrow()
} }
override suspend fun wrapYieldSwapCallDataWithUpgradeIfNeeded(
userWalletId: UserWalletId,
network: Network,
callData: SmartContractCallData,
): SmartContractCallData = withContext(dispatchers.io) {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = network.toBlockchain(),
derivationPath = network.derivationPath.value,
) ?: error("Wallet manager not found for $network")
val versionStatus = walletManager.checkModuleVersionStatus()
YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, callData)
}
private suspend fun getYieldTokenStatus( private suspend fun getYieldTokenStatus(
walletManager: WalletManager, walletManager: WalletManager,
cryptoCurrency: CryptoCurrency.Token, cryptoCurrency: CryptoCurrency.Token,

View file

@ -1,6 +1,7 @@
package com.tangem.data.yield.supply.di package com.tangem.data.yield.supply.di
import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.data.yield.supply.DefaultYieldModuleAddressProvider
import com.tangem.data.yield.supply.DefaultYieldSupplyRepository import com.tangem.data.yield.supply.DefaultYieldSupplyRepository
import com.tangem.data.yield.supply.DefaultYieldSupplyErrorResolver import com.tangem.data.yield.supply.DefaultYieldSupplyErrorResolver
import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository
@ -12,6 +13,7 @@ import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.YieldSupplyErrorResolver import com.tangem.domain.yield.supply.YieldSupplyErrorResolver
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
@ -65,6 +67,18 @@ internal object YieldSupplyDataModule {
return DefaultYieldSupplyErrorResolver return DefaultYieldSupplyErrorResolver
} }
@Provides
@Singleton
fun provideYieldModuleAddressProvider(
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
): YieldModuleAddressProvider {
return DefaultYieldModuleAddressProvider(
walletManagersFacade = walletManagersFacade,
dispatchers = dispatchers,
)
}
@Provides @Provides
@Singleton @Singleton
fun provideYieldPromoRepository( fun provideYieldPromoRepository(

View file

@ -30,4 +30,6 @@ data class SwapCurrencyStatus(
get() = status.currency get() = status.currency
val userWalletId: UserWalletId val userWalletId: UserWalletId
get() = userWallet.walletId get() = userWallet.walletId
val isYieldSupplyActive: Boolean
get() = status.value.yieldSupplyStatus?.isActive == true
} }

View file

@ -46,7 +46,7 @@ class GetEthSpecificFeeUseCase(
val minimalFee = getEthLegacyFee( val minimalFee = getEthLegacyFee(
gasPrice = gasPriceResult, gasPrice = gasPriceResult,
gasLimit = gasLimit, gasLimit = gasLimit,
decimals = cryptoCurrency.decimals, decimals = blockchain.decimals(),
blockchain = blockchain, blockchain = blockchain,
) )
@ -54,7 +54,7 @@ class GetEthSpecificFeeUseCase(
val normalFee = getEthLegacyFee( val normalFee = getEthLegacyFee(
gasPrice = normalGasPrice, gasPrice = normalGasPrice,
gasLimit = gasLimit, gasLimit = gasLimit,
decimals = cryptoCurrency.decimals, decimals = blockchain.decimals(),
blockchain = blockchain, blockchain = blockchain,
) )
@ -64,7 +64,7 @@ class GetEthSpecificFeeUseCase(
val priorityFee = getEthLegacyFee( val priorityFee = getEthLegacyFee(
gasPrice = priorityGasPrice, gasPrice = priorityGasPrice,
gasLimit = gasLimit, gasLimit = gasLimit,
decimals = cryptoCurrency.decimals, decimals = blockchain.decimals(),
blockchain = blockchain, blockchain = blockchain,
) )

View file

@ -0,0 +1,25 @@
package com.tangem.domain.yield.supply
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
/**
* Resolves the yield-module proxy address for a `(wallet, network)` pair and caches the result.
*
* The address is derived from on-chain state (factory contract + user's wallet) and is stable
* for the lifetime of the wallet, so caching the result avoids redundant blockchain calls.
*
* Call [invalidate] when the wallet's yield-module state may have changed (e.g. after a
* successful upgrade or removal of yield-supply).
*/
interface YieldModuleAddressProvider {
/**
* Returns the yield-module proxy address, or `null` if the address is currently unavailable
* (e.g. RPC failure inside the SDK).
*/
suspend fun getOrFetch(userWalletId: UserWalletId, network: Network): String?
/** Drops cached entries for [userWalletId], or the entire cache when [userWalletId] is `null`. */
fun invalidate(userWalletId: UserWalletId? = null)
}

View file

@ -1,9 +1,11 @@
package com.tangem.domain.yield.supply package com.tangem.domain.yield.supply
import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigDecimal import java.math.BigDecimal
@ -24,4 +26,14 @@ interface YieldSupplyTransactionRepository {
suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String? suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String?
suspend fun getEffectiveProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal? suspend fun getEffectiveProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal?
/**
* Checks the version status of the user's yield-module contract and wraps [callData] with an
* upgrade transaction if the deployed version is out of date.
*/
suspend fun wrapYieldSwapCallDataWithUpgradeIfNeeded(
userWalletId: UserWalletId,
network: Network,
callData: SmartContractCallData,
): SmartContractCallData
} }

View file

@ -0,0 +1,25 @@
package com.tangem.domain.yield.supply.usecase
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
/**
* Wraps a yield-swap call data with a yield-module upgrade transaction when the user's deployed
* yield-module contract version is out of date.
*/
class WrapYieldSwapCallDataWithUpgradeUseCase(
private val yieldSupplyTransactionRepository: YieldSupplyTransactionRepository,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
network: Network,
callData: SmartContractCallData,
): SmartContractCallData = yieldSupplyTransactionRepository.wrapYieldSwapCallDataWithUpgradeIfNeeded(
userWalletId = userWalletId,
network = network,
callData = callData,
)
}

View file

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

View file

@ -51,8 +51,10 @@ dependencies {
implementation(projects.domain.visa) implementation(projects.domain.visa)
implementation(projects.domain.visa.models) implementation(projects.domain.visa.models)
implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding)
implementation(projects.domain.yieldSupply)
/** Core modules */ /** Core modules */
implementation(projects.core.configToggles)
implementation(projects.core.utils) implementation(projects.core.utils)
implementation(projects.core.ui) implementation(projects.core.ui)
implementation(projects.core.datasource) 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.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.walletmanager.WalletManagersFacade 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.api.SwapRepository
import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator
import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator 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.domain.*
import com.tangem.feature.swap.domain.models.toStringWithRightOffset import com.tangem.feature.swap.domain.models.toStringWithRightOffset
import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.extensions.orZero import com.tangem.utils.extensions.orZero
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
@ -99,12 +101,17 @@ internal class SwapInteractorImpl @Inject constructor(
private val getSwapPairUseCase: GetSwapPairUseCase, private val getSwapPairUseCase: GetSwapPairUseCase,
private val dexSwapFeeCalculator: DexSwapFeeCalculator, private val dexSwapFeeCalculator: DexSwapFeeCalculator,
private val cexSwapFeeCalculator: CexSwapFeeCalculator, private val cexSwapFeeCalculator: CexSwapFeeCalculator,
private val swapFeatureToggles: SwapFeatureToggles,
private val yieldModuleAddressProvider: YieldModuleAddressProvider,
) : SwapInteractor { ) : SwapInteractor {
private val getSelectedAppCurrencyUseCase by lazy(LazyThreadSafetyMode.NONE) { private val getSelectedAppCurrencyUseCase by lazy(LazyThreadSafetyMode.NONE) {
GetSelectedAppCurrencyUseCase(appCurrencyRepository) GetSelectedAppCurrencyUseCase(appCurrencyRepository)
} }
private val SwapCurrencyStatus.isYieldSwapActive: Boolean
get() = swapFeatureToggles.isYieldSwapEnabled && isYieldSupplyActive
override suspend fun getPair( override suspend fun getPair(
fromSwapCurrencyStatus: SwapCurrencyStatus, fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus,
@ -254,7 +261,9 @@ internal class SwapInteractorImpl @Inject constructor(
reduceBalanceBy: BigDecimal, reduceBalanceBy: BigDecimal,
expressOperationType: ExpressOperationType, expressOperationType: ExpressOperationType,
): Pair<SwapProvider, SwapState> { ): Pair<SwapProvider, SwapState> {
if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true) { if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true &&
!swapFeatureToggles.isYieldSwapEnabled
) {
return provider to produceDexSwapDataError( return provider to produceDexSwapDataError(
error = ExpressDataError.DexActiveSupplyError(), error = ExpressDataError.DexActiveSupplyError(),
fromSwapCurrencyStatus = fromSwapCurrencyStatus, fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -286,7 +295,13 @@ internal class SwapInteractorImpl @Inject constructor(
} }
val fromTokenAddress = getTokenAddress(fromSwapCurrencyStatus.currency) val fromTokenAddress = getTokenAddress(fromSwapCurrencyStatus.currency)
val isAllowedToSpend = maybeQuotes.fold( 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 -> ifRight = { quotes ->
quotes.allowanceContract?.let { allowanceContract -> quotes.allowanceContract?.let { allowanceContract ->
getAllowanceInfoUseCase( getAllowanceInfoUseCase(
@ -299,6 +314,7 @@ internal class SwapInteractorImpl @Inject constructor(
}, },
ifLeft = { false }, ifLeft = { false },
) )
}
if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) { if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) {
allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress) allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress)
@ -308,6 +324,7 @@ internal class SwapInteractorImpl @Inject constructor(
) )
} }
val isBalanceWithoutFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, null) val isBalanceWithoutFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, null)
val quoteAllowanceContract = maybeQuotes.getOrNull()?.allowanceContract
return if (isAllowedToSpend && isBalanceWithoutFeeEnough) { return if (isAllowedToSpend && isBalanceWithoutFeeEnough) {
provider to loadDexSwapDataNoFee( provider to loadDexSwapDataNoFee(
provider = provider, provider = provider,
@ -315,6 +332,7 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus,
amount = amount, amount = amount,
expressOperationType = expressOperationType, expressOperationType = expressOperationType,
quoteAllowanceContract = quoteAllowanceContract,
) )
} else { } else {
val quoteBalanceStatus = if (isBalanceWithoutFeeEnough) { val quoteBalanceStatus = if (isBalanceWithoutFeeEnough) {
@ -377,6 +395,7 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus,
amount = amount, amount = amount,
expressOperationType = expressOperationType, expressOperationType = expressOperationType,
quoteAllowanceContract = maybeQuotes.getOrNull()?.allowanceContract,
) )
} else { } else {
provider to getQuotesState( provider to getQuotesState(
@ -635,28 +654,54 @@ internal class SwapInteractorImpl @Inject constructor(
swapFee: SwapFee, swapFee: SwapFee,
): SwapTransactionState { ): SwapTransactionState {
val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } 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 amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX
val dataToSign = dexTransaction.txData val dataToSign = dexTransaction.txData
val amountToSend = createNativeAmountForDex(txValue, fromSwapCurrencyStatus.currency.network) val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive
val txData = createTransactionUseCase( 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, amount = amountToSend,
fee = swapFee.fee, fee = swapFee.fee,
memo = null, memo = null,
destination = swapData.transaction.txTo, destination = swapData.transaction.txTo,
userWalletId = fromSwapCurrencyStatus.userWalletId, userWalletId = fromSwapCurrencyStatus.userWalletId,
network = toSwapCurrencyStatus.currency.network, network = fromCurrency.network,
txExtras = createDexTxExtras( txExtras = createDexTxExtras(
dataToSign, dataToSign,
fromSwapCurrencyStatus.currency.network, fromCurrency.network,
swapFee.fee.getGasLimit(), swapFee.fee.getGasLimit(),
), ),
).getOrElse { error -> )
}
val txData = txDataResult.getOrElse { error ->
TangemLogger.e("Failed to create swap dex tx data", error) TangemLogger.e("Failed to create swap dex tx data", error)
return SwapTransactionState.Error.UnknownError 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( return handleSwapResult(
fromSwapCurrencyStatus = fromSwapCurrencyStatus, fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus,
@ -664,7 +709,7 @@ internal class SwapInteractorImpl @Inject constructor(
swapData = swapData, swapData = swapData,
amount = amount, amount = amount,
txData = txData, txData = txData,
payInAddress = getPayoutAddress(txData), payInAddress = payInAddress,
) )
} }
@ -1001,11 +1046,23 @@ internal class SwapInteractorImpl @Inject constructor(
val transaction = swapData?.transaction as? ExpressTransactionModel.DEX val transaction = swapData?.transaction as? ExpressTransactionModel.DEX
?: return GetFeeError.UnknownError.left() ?: return GetFeeError.UnknownError.left()
return dexSwapFeeCalculator.calculate( 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, fromSwapCurrencyStatus = fromStatus,
transaction = transaction, transaction = transaction,
selectedToken = selectedFeeToken, selectedToken = selectedFeeToken,
).fold( )
}
return dexFeeResultEither.fold(
ifLeft = { error -> GetFeeError.DataError(error).left() }, ifLeft = { error -> GetFeeError.DataError(error).left() },
ifRight = { dexFeeResult -> ifRight = { dexFeeResult ->
val feeToken = selectedFeeToken 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 * [REDACTED_TASK_KEY] CEX branch of [loadSwapFee]. Native-fallback behavior is preserved: when
* [selectedFeeToken] is null the gasless use case (invoked inside [CexSwapFeeCalculator]) * [selectedFeeToken] is null the gasless use case (invoked inside [CexSwapFeeCalculator])
@ -1501,6 +1594,7 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus,
amount: SwapAmount, amount: SwapAmount,
expressOperationType: ExpressOperationType, expressOperationType: ExpressOperationType,
quoteAllowanceContract: String? = null,
): SwapState { ): SwapState {
val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress
val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty()
@ -1521,7 +1615,14 @@ internal class SwapInteractorImpl @Inject constructor(
toAddress = dexToAddress, toAddress = dexToAddress,
refundAddress = fromNetworkAddress?.defaultAddress?.value, refundAddress = fromNetworkAddress?.defaultAddress?.value,
expressOperationType = expressOperationType, 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 -> ifRight = { swapData ->
val preparedSwapConfigState = PreparedSwapConfigState( val preparedSwapConfigState = PreparedSwapConfigState(
balanceStatus = SwapBalanceStatus.Pending, 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( val allowanceInfo = getAllowanceInfoUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId, userWalletId = fromSwapCurrencyStatus.userWalletId,
cryptoCurrency = fromToken, cryptoCurrency = fromToken,
spenderAddress = requireNotNull(quoteModel.allowanceContract) { "spenderAddress cant be null" }, spenderAddress = spenderAddress,
requiredAmount = swapAmount.value, requiredAmount = swapAmount.value,
).getOrNull() ).getOrNull()
return quotesLoadedState.copy( return quotesLoadedState.copy(
permissionState = PermissionDataState.PermissionRequired( permissionState = PermissionDataState.PermissionRequired(
isResetApproval = allowanceInfo is AllowanceInfo.ResetNeeded, 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.EstimateFeeForTokenUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade 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.*
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.api.SwapRepository
@ -75,6 +76,7 @@ internal class SwapDomainModule {
createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase, createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
walletManagersFacade: WalletManagersFacade, walletManagersFacade: WalletManagersFacade,
@SwapDexGasLimit patchEthGasLimitForSwap: PatchEthGasLimitForSwap, @SwapDexGasLimit patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase,
): DexSwapFeeCalculator = DexSwapFeeCalculator( ): DexSwapFeeCalculator = DexSwapFeeCalculator(
getFeeUseCase = getFeeUseCase, getFeeUseCase = getFeeUseCase,
getEthSpecificFeeUseCase = getEthSpecificFeeUseCase, getEthSpecificFeeUseCase = getEthSpecificFeeUseCase,
@ -82,6 +84,7 @@ internal class SwapDomainModule {
createTransactionExtrasUseCase = createTransactionExtrasUseCase, createTransactionExtrasUseCase = createTransactionExtrasUseCase,
walletManagersFacade = walletManagersFacade, walletManagersFacade = walletManagersFacade,
patchEthGasLimitForSwap = patchEthGasLimitForSwap, patchEthGasLimitForSwap = patchEthGasLimitForSwap,
wrapYieldSwapCallDataWithUpgradeUseCase = wrapYieldSwapCallDataWithUpgradeUseCase,
) )
@Provides @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.Amount
import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.TransactionFee 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.blockchainsdk.utils.fromNetworkId
import com.tangem.common.extensions.hexToBytes
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network 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.GetFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade 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.ExpressDataError
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel 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.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal import java.math.BigDecimal
import java.math.BigInteger
/** /**
* Calculates the on-chain transaction fee for a DEX swap. * Calculates the on-chain transaction fee for a DEX swap.
@ -52,6 +59,7 @@ class DexSwapFeeCalculator(
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase, private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
private val walletManagersFacade: WalletManagersFacade, private val walletManagersFacade: WalletManagersFacade,
private val patchEthGasLimitForSwap: PatchEthGasLimitForSwap, private val patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
private val wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase,
) { ) {
suspend fun calculate( 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") @Suppress("CyclomaticComplexMethod")
private suspend fun getFeeDataForDexSwap( private suspend fun getFeeDataForDexSwap(
fromSwapCurrencyStatus: SwapCurrencyStatus, fromSwapCurrencyStatus: SwapCurrencyStatus,

View file

@ -30,7 +30,7 @@ sealed class ExpressTransactionModel {
val txData: String, val txData: String,
val otherNativeFeeWei: BigDecimal?, val otherNativeFeeWei: BigDecimal?,
val gas: BigInteger?, val gas: BigInteger?,
val allowanceContract: String?, val allowanceContract: String? = null,
) : ExpressTransactionModel() ) : ExpressTransactionModel()
data class CEX( 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.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel 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.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.SwapState import com.tangem.feature.swap.domain.models.ui.SwapState
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
@ -872,6 +873,210 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
assertThat(result[cexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java) 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 // 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.*
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade 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.api.SwapRepository
import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator
import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator 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.domain.*
import com.tangem.feature.swap.domain.models.ui.AmountFormatter import com.tangem.feature.swap.domain.models.ui.AmountFormatter
import com.tangem.feature.swap.domain.models.ui.SwapFee import com.tangem.feature.swap.domain.models.ui.SwapFee
import com.tangem.features.swap.SwapFeatureToggles
import io.mockk.clearAllMocks import io.mockk.clearAllMocks
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -84,6 +86,8 @@ internal open class SwapInteractorImplTestBase {
protected val getSwapPairUseCase: GetSwapPairUseCase = mockk(relaxed = true) protected val getSwapPairUseCase: GetSwapPairUseCase = mockk(relaxed = true)
protected val dexSwapFeeCalculator: DexSwapFeeCalculator = mockk(relaxed = true) protected val dexSwapFeeCalculator: DexSwapFeeCalculator = mockk(relaxed = true)
protected val cexSwapFeeCalculator: CexSwapFeeCalculator = 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 // endregion
@ -115,6 +119,8 @@ internal open class SwapInteractorImplTestBase {
getSwapPairUseCase = getSwapPairUseCase, getSwapPairUseCase = getSwapPairUseCase,
dexSwapFeeCalculator = dexSwapFeeCalculator, dexSwapFeeCalculator = dexSwapFeeCalculator,
cexSwapFeeCalculator = cexSwapFeeCalculator, cexSwapFeeCalculator = cexSwapFeeCalculator,
swapFeatureToggles = swapFeatureToggles,
yieldModuleAddressProvider = yieldModuleAddressProvider,
) )
} }
@ -158,6 +164,7 @@ internal fun buildSwapCurrencyStatus(
decimals: Int = 18, decimals: Int = 18,
userWalletId: UserWalletId = UserWalletId(stringValue = "deadbeef"), userWalletId: UserWalletId = UserWalletId(stringValue = "deadbeef"),
yieldSupplyActive: Boolean = false, yieldSupplyActive: Boolean = false,
yieldSupplyAllowedToSpend: Boolean = true,
): SwapCurrencyStatus { ): SwapCurrencyStatus {
val networkId = mockk<Network.ID>(relaxed = true) { val networkId = mockk<Network.ID>(relaxed = true) {
every { rawId } returns Network.RawID(networkRawId) every { rawId } returns Network.RawID(networkRawId)
@ -196,6 +203,7 @@ internal fun buildSwapCurrencyStatus(
val maybeYield: YieldSupplyStatus? = if (yieldSupplyActive) { val maybeYield: YieldSupplyStatus? = if (yieldSupplyActive) {
mockk<YieldSupplyStatus>(relaxed = true) { mockk<YieldSupplyStatus>(relaxed = true) {
every { isActive } returns true every { isActive } returns true
every { isAllowedToSpend } returns yieldSupplyAllowedToSpend
} }
} else { } else {
null 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.GetFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade 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.buildSwapCurrencyStatus
import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.SwapAmount
@ -59,6 +60,7 @@ internal class DexSwapFeeCalculatorTest {
private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true) private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true)
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true) private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true)
private val walletManagersFacade: WalletManagersFacade = 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) private val dexBump = PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.DEX_PERCENTAGE)
@ -70,6 +72,7 @@ internal class DexSwapFeeCalculatorTest {
createTransactionExtrasUseCase = createTransactionExtrasUseCase, createTransactionExtrasUseCase = createTransactionExtrasUseCase,
walletManagersFacade = walletManagersFacade, walletManagersFacade = walletManagersFacade,
patchEthGasLimitForSwap = dexBump, patchEthGasLimitForSwap = dexBump,
wrapYieldSwapCallDataWithUpgradeUseCase = wrapYieldSwapCallDataWithUpgradeUseCase,
) )
} }

View file

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

View file

@ -5,7 +5,7 @@
# https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico # https://github.com/tangem/vico
tangemBlockchainSdk = "develop-1527" tangemBlockchainSdk = "develop-1532"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-620" tangemCardSdk = "develop-620"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^

View file

@ -16,6 +16,7 @@ import javax.inject.Inject
* @property accountCreator account creator * @property accountCreator account creator
* @property blockchainDataStorage blockchain data storage * @property blockchainDataStorage blockchain data storage
* @property blockchainSDKLogger blockchain SDK logger * @property blockchainSDKLogger blockchain SDK logger
* @property featureToggleValues blockchain feature toggle values
* *
[REDACTED_AUTHOR] [REDACTED_AUTHOR]
*/ */
@ -23,9 +24,7 @@ internal class WalletManagerFactoryCreator @Inject constructor(
private val accountCreator: AccountCreator, private val accountCreator: AccountCreator,
private val blockchainDataStorage: BlockchainDataStorage, private val blockchainDataStorage: BlockchainDataStorage,
private val blockchainSDKLogger: BlockchainSDKLogger, private val blockchainSDKLogger: BlockchainSDKLogger,
private val isSolanaTxHistoryEnabled: Boolean, private val featureToggleValues: FeatureToggleValues,
private val isSolanaScaledUiAmountEnabled: Boolean,
private val isHederaErc20Enabled: Boolean,
) { ) {
fun create(config: BlockchainSdkConfig, blockchainProviderTypes: BlockchainProviderTypes): WalletManagerFactory { fun create(config: BlockchainSdkConfig, blockchainProviderTypes: BlockchainProviderTypes): WalletManagerFactory {
@ -37,13 +36,21 @@ internal class WalletManagerFactoryCreator @Inject constructor(
accountCreator = accountCreator, accountCreator = accountCreator,
featureToggles = BlockchainFeatureToggles( featureToggles = BlockchainFeatureToggles(
isYieldSupplyEnabled = true, isYieldSupplyEnabled = true,
isYieldModeSwapEnabled = featureToggleValues.isYieldModeSwapEnabled,
isPendingTransactionsEnabled = true, isPendingTransactionsEnabled = true,
isSolanaTxHistoryEnabled = isSolanaTxHistoryEnabled, isSolanaTxHistoryEnabled = featureToggleValues.isSolanaTxHistoryEnabled,
isSolanaScaledUiAmountEnabled = isSolanaScaledUiAmountEnabled, isSolanaScaledUiAmountEnabled = featureToggleValues.isSolanaScaledUiAmountEnabled,
isHederaErc20Enabled = isHederaErc20Enabled, isHederaErc20Enabled = featureToggleValues.isHederaErc20Enabled,
), ),
blockchainDataStorage = blockchainDataStorage, blockchainDataStorage = blockchainDataStorage,
loggers = listOf(blockchainSDKLogger), loggers = listOf(blockchainSDKLogger),
) )
} }
data class FeatureToggleValues(
val isSolanaTxHistoryEnabled: Boolean,
val isSolanaScaledUiAmountEnabled: Boolean,
val isYieldModeSwapEnabled: Boolean,
val isHederaErc20Enabled: Boolean,
)
} }

View file

@ -97,15 +97,20 @@ internal object BlockchainSDKFactoryModule {
accountCreator = DefaultAccountCreator(tangemTechApi), accountCreator = DefaultAccountCreator(tangemTechApi),
blockchainDataStorage = DefaultBlockchainDataStorage(appPreferencesStore), blockchainDataStorage = DefaultBlockchainDataStorage(appPreferencesStore),
blockchainSDKLogger = blockchainSDKLogger, blockchainSDKLogger = blockchainSDKLogger,
featureToggleValues = WalletManagerFactoryCreator.FeatureToggleValues(
isSolanaTxHistoryEnabled = featureTogglesManager.isFeatureEnabled( isSolanaTxHistoryEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.SOLANA_TX_HISTORY_ENABLED, FeatureToggles.SOLANA_TX_HISTORY_ENABLED,
), ),
isSolanaScaledUiAmountEnabled = featureTogglesManager.isFeatureEnabled( isSolanaScaledUiAmountEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.SOLANA_SCALED_UI_AMOUNT_ENABLED, FeatureToggles.SOLANA_SCALED_UI_AMOUNT_ENABLED,
), ),
isYieldModeSwapEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED,
),
isHederaErc20Enabled = featureTogglesManager.isFeatureEnabled( isHederaErc20Enabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.HEDERA_ERC20_ENABLED, FeatureToggles.HEDERA_ERC20_ENABLED,
), ),
),
) )
} }
} }