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

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

View file

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

View file

@ -18,6 +18,7 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.swap.SwapTransactionRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -49,6 +50,7 @@ internal object SwapDataModule {
dataSignatureVerifier: DataSignatureVerifier,
singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
singleQuoteStatusFetcher: SingleQuoteStatusFetcher,
featureTogglesManager: FeatureTogglesManager,
@NetworkMoshi moshi: Moshi,
): SwapRepositoryV2 {
return DefaultSwapRepositoryV2(
@ -60,6 +62,7 @@ internal object SwapDataModule {
moshi = moshi,
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
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.SwapStatus
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 io.mockk.*
import kotlinx.coroutines.test.runTest
@ -43,6 +45,9 @@ internal class DefaultSwapRepositoryV2Test {
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk()
private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher = mockk()
private val moshi: Moshi = Moshi.Builder().build()
private val featureTogglesManager: FeatureTogglesManager = mockk {
every { isFeatureEnabled(any()) } returns false
}
private val repository = DefaultSwapRepositoryV2(
tangemExpressApi = tangemExpressApi,
@ -52,6 +57,7 @@ internal class DefaultSwapRepositoryV2Test {
dataSignatureVerifier = dataSignatureVerifier,
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
singleQuoteStatusFetcher = singleQuoteStatusFetcher,
featureTogglesManager = featureTogglesManager,
moshi = moshi,
)
@ -64,7 +70,9 @@ internal class DefaultSwapRepositoryV2Test {
dataSignatureVerifier,
singleQuoteStatusSupplier,
singleQuoteStatusFetcher,
featureTogglesManager,
)
every { featureTogglesManager.isFeatureEnabled(any()) } returns false
}
// region getPairs(SwapCurrencyStatus, SwapCurrencyStatus)
@ -511,8 +519,9 @@ internal class DefaultSwapRepositoryV2Test {
// region filterYieldSupplyProvider
@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
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED) } returns false
val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin)
val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin)
val primarySwapCurrencyStatus = SwapCurrencyStatus(
@ -558,6 +567,54 @@ internal class DefaultSwapRepositoryV2Test {
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
// 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.domain.models.currency.CryptoCurrency
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.yield.supply.YieldSupplyStatus
import com.tangem.domain.utils.convertToSdkAmount
@ -127,6 +128,11 @@ internal class DefaultYieldSupplyTransactionRepository(
val amount = getEnterAmount(cryptoCurrency, yieldSupplyStatus)
val emptyContractAddress = existingYieldAddress == null || existingYieldAddress == EthereumUtils.ZERO_ADDRESS
val activeYieldContractAddress = if (emptyContractAddress) {
calculatedYieldContractAddress
} else {
existingYieldAddress
}
when {
yieldSupplyStatus == null || emptyContractAddress -> {
@ -143,7 +149,7 @@ internal class DefaultYieldSupplyTransactionRepository(
createInitTokenTransaction(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
yieldContractAddress = calculatedYieldContractAddress,
yieldContractAddress = activeYieldContractAddress,
amount = amount,
maxNetworkFee = maxNetworkFee,
),
@ -152,7 +158,7 @@ internal class DefaultYieldSupplyTransactionRepository(
createReactivateTokenTransaction(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
yieldContractAddress = calculatedYieldContractAddress,
yieldContractAddress = activeYieldContractAddress,
amount = amount,
maxNetworkFee = maxNetworkFee,
),
@ -166,7 +172,7 @@ internal class DefaultYieldSupplyTransactionRepository(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
callData = ApprovalERC20TokenCallData(
spenderAddress = calculatedYieldContractAddress,
spenderAddress = activeYieldContractAddress,
amount = null,
),
destinationAddress = cryptoCurrency.contractAddress,
@ -182,7 +188,7 @@ internal class DefaultYieldSupplyTransactionRepository(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
amount = amount,
yieldContractAddress = calculatedYieldContractAddress,
yieldContractAddress = activeYieldContractAddress,
),
)
}
@ -218,6 +224,20 @@ internal class DefaultYieldSupplyTransactionRepository(
}.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(
walletManager: WalletManager,
cryptoCurrency: CryptoCurrency.Token,

View file

@ -1,6 +1,7 @@
package com.tangem.data.yield.supply.di
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.DefaultYieldSupplyErrorResolver
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.YieldBoostStatusStore
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.YieldSupplyErrorResolver
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
@ -65,6 +67,18 @@ internal object YieldSupplyDataModule {
return DefaultYieldSupplyErrorResolver
}
@Provides
@Singleton
fun provideYieldModuleAddressProvider(
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
): YieldModuleAddressProvider {
return DefaultYieldModuleAddressProvider(
walletManagersFacade = walletManagersFacade,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideYieldPromoRepository(