Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-02 19:19:32 +04:00
parent a9fbfc8a53
commit 52a607c1f5
9 changed files with 153 additions and 40 deletions

View file

@ -2,6 +2,7 @@ package com.tangem.tap.di.domain
import com.tangem.domain.staking.* import com.tangem.domain.staking.*
import com.tangem.domain.staking.repositories.* import com.tangem.domain.staking.repositories.*
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.staking.single.SingleStakingBalanceFetcher import com.tangem.domain.staking.single.SingleStakingBalanceFetcher
import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
@ -226,8 +227,14 @@ internal object StakingDomainModule {
@Provides @Provides
@Singleton @Singleton
fun provideStakingIdFactory(walletManagersFacade: WalletManagersFacade): StakingIdFactory { fun provideStakingIdFactory(
return StakingIdFactory(walletManagersFacade = walletManagersFacade) walletManagersFacade: WalletManagersFacade,
stakingFeatureToggles: StakingFeatureToggles,
): StakingIdFactory {
return StakingIdFactory(
walletManagersFacade = walletManagersFacade,
stakingFeatureToggles = stakingFeatureToggles,
)
} }
@Provides @Provides

View file

@ -22,6 +22,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.domain.staking.repositories.P2PEthPoolRepository import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
@ -65,7 +66,7 @@ internal class DefaultP2PEthPoolRepository(
} }
override suspend fun fetchVaults(network: P2PEthPoolNetwork) { override suspend fun fetchVaults(network: P2PEthPoolNetwork) {
val vaults = if (stakingFeatureToggles.isEthStakingEnabled) { val vaults = if (stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)) {
getVaults(network).getOrElse { error -> getVaults(network).getOrElse { error ->
TangemLogger.e("Error fetching P2PEthPool vaults: $error") TangemLogger.e("Error fetching P2PEthPool vaults: $error")
emptyList() emptyList()

View file

@ -1,8 +1,6 @@
package com.tangem.data.staking package com.tangem.data.staking
import arrow.core.getOrElse import arrow.core.getOrElse
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.data.staking.store.StakeKitBalancesStore
import com.tangem.domain.card.common.TapWorkarounds.isWallet2 import com.tangem.domain.card.common.TapWorkarounds.isWallet2
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
@ -22,14 +20,13 @@ import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
internal class DefaultStakingRepository( internal class DefaultStakingRepository(
private val stakeKitRepository: StakeKitRepository, private val stakeKitRepository: StakeKitRepository,
private val p2pEthPoolRepository: P2PEthPoolRepository, private val p2pEthPoolRepository: P2PEthPoolRepository,
private val stakingBalanceStoreV2: StakeKitBalancesStore, private val stakeKitBalancesStore: StakeKitBalancesStore,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
private val getUserWalletUseCase: GetUserWalletUseCase, private val getUserWalletUseCase: GetUserWalletUseCase,
private val stakingFeatureToggles: StakingFeatureToggles, private val stakingFeatureToggles: StakingFeatureToggles,
@ -40,7 +37,8 @@ internal class DefaultStakingRepository(
cryptoCurrency: CryptoCurrency, cryptoCurrency: CryptoCurrency,
): Flow<StakingAvailability> { ): Flow<StakingAvailability> {
return channelFlow { return channelFlow {
if (!checkFeatureToggleEnabled(cryptoCurrency)) { val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
if (stakingIntegration == null || !stakingFeatureToggles.isIntegrationEnabled(stakingIntegration)) {
send(StakingAvailability.Unavailable) send(StakingAvailability.Unavailable)
return@channelFlow return@channelFlow
} }
@ -56,8 +54,6 @@ internal class DefaultStakingRepository(
return@channelFlow return@channelFlow
} }
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
val availabilityFlow = when (stakingIntegration) { val availabilityFlow = when (stakingIntegration) {
StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailability() StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailability()
is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailability( is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailability(
@ -65,7 +61,6 @@ internal class DefaultStakingRepository(
rawCurrencyId, rawCurrencyId,
cryptoCurrency.symbol, cryptoCurrency.symbol,
) )
null -> flowOf(StakingAvailability.Unavailable)
} }
availabilityFlow.collect { send(it) } availabilityFlow.collect { send(it) }
@ -76,20 +71,15 @@ internal class DefaultStakingRepository(
userWalletId: UserWalletId, userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency, cryptoCurrency: CryptoCurrency,
): StakingAvailability { ): StakingAvailability {
if (!checkFeatureToggleEnabled(cryptoCurrency)) { val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
return StakingAvailability.Unavailable ?.takeIf(stakingFeatureToggles::isIntegrationEnabled)
} ?: return StakingAvailability.Unavailable
if (checkForInvalidCardBatch(userWalletId, cryptoCurrency)) { if (checkForInvalidCardBatch(userWalletId, cryptoCurrency)) {
return StakingAvailability.Unavailable return StakingAvailability.Unavailable
} }
val rawCurrencyId = cryptoCurrency.id.rawCurrencyId val rawCurrencyId = cryptoCurrency.id.rawCurrencyId
if (rawCurrencyId == null) {
return StakingAvailability.Unavailable
}
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
?: return StakingAvailability.Unavailable ?: return StakingAvailability.Unavailable
return when (stakingIntegration) { return when (stakingIntegration) {
@ -104,7 +94,7 @@ internal class DefaultStakingRepository(
override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean { override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean {
return withContext(dispatchers.default) { return withContext(dispatchers.default) {
val balances = stakingBalanceStoreV2.getAllSyncOrNull(userWalletId) ?: return@withContext false val balances = stakeKitBalancesStore.getAllSyncOrNull(userWalletId) ?: return@withContext false
val hasDataStakingBalance by lazy { val hasDataStakingBalance by lazy {
balances.any { stakingBalance -> balances.any { stakingBalance ->
@ -116,18 +106,6 @@ internal class DefaultStakingRepository(
} }
} }
private fun checkFeatureToggleEnabled(cryptoCurrency: CryptoCurrency): Boolean {
return when (cryptoCurrency.network.id.toBlockchain()) {
Blockchain.Ethereum -> {
when (cryptoCurrency) {
is CryptoCurrency.Coin -> stakingFeatureToggles.isEthStakingEnabled
is CryptoCurrency.Token -> true
}
}
else -> true
}
}
private fun checkForInvalidCardBatch(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { private fun checkForInvalidCardBatch(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean {
val userWallet = getUserWalletUseCase(userWalletId).getOrElse { val userWallet = getUserWalletUseCase(userWalletId).getOrElse {
error("Failed to get user wallet") error("Failed to get user wallet")

View file

@ -65,7 +65,7 @@ internal object StakingDataModule {
return DefaultStakingRepository( return DefaultStakingRepository(
stakeKitRepository = stakeKitRepository, stakeKitRepository = stakeKitRepository,
p2pEthPoolRepository = p2pEthPoolRepository, p2pEthPoolRepository = p2pEthPoolRepository,
stakingBalanceStoreV2 = stakeKitBalancesStore, stakeKitBalancesStore = stakeKitBalancesStore,
dispatchers = dispatchers, dispatchers = dispatchers,
getUserWalletUseCase = getUserWalletUseCase, getUserWalletUseCase = getUserWalletUseCase,
stakingFeatureToggles = stakingFeatureToggles, stakingFeatureToggles = stakingFeatureToggles,

View file

@ -2,12 +2,35 @@ package com.tangem.data.staking.toggles
import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.staking.toggles.StakingFeatureToggles
internal class DefaultStakingFeatureToggles( internal class DefaultStakingFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager, private val featureTogglesManager: FeatureTogglesManager,
) : StakingFeatureToggles { ) : StakingFeatureToggles {
override val isEthStakingEnabled: Boolean override fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean {
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) val toggle = integrationId.getFeatureToggle() ?: return true
return featureTogglesManager.isFeatureEnabled(toggle)
}
private fun StakingIntegrationID.getFeatureToggle(): FeatureToggles? = when (this) {
is StakingIntegrationID.P2PEthPool -> FeatureToggles.STAKING_ETH_ENABLED
is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle()
}
private fun StakingIntegrationID.StakeKit.getStakeKitFeatureToggle(): FeatureToggles? = when (this) {
is StakingIntegrationID.StakeKit.Coin -> when (this) {
StakingIntegrationID.StakeKit.Coin.Ton,
StakingIntegrationID.StakeKit.Coin.Solana,
StakingIntegrationID.StakeKit.Coin.Cosmos,
StakingIntegrationID.StakeKit.Coin.Tron,
StakingIntegrationID.StakeKit.Coin.BSC,
StakingIntegrationID.StakeKit.Coin.Cardano,
-> null
}
is StakingIntegrationID.StakeKit.EthereumToken -> when (this) {
StakingIntegrationID.StakeKit.EthereumToken.Polygon -> null
}
}
} }

View file

@ -0,0 +1,61 @@
package com.tangem.data.staking.toggles
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.staking.model.StakingIntegrationID
import com.google.common.truth.Truth.assertThat
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultStakingFeatureTogglesTest {
private val featureTogglesManager: FeatureTogglesManager = mockk()
private val toggles = DefaultStakingFeatureToggles(featureTogglesManager = featureTogglesManager)
@BeforeEach
fun resetMocks() {
clearMocks(featureTogglesManager)
}
@Test
fun `P2PEthPool returns true when STAKING_ETH_ENABLED is enabled`() {
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns true
assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isTrue()
verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) }
}
@Test
fun `P2PEthPool returns false when STAKING_ETH_ENABLED is disabled`() {
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns false
assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isFalse()
verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) }
}
@Test
fun `existing StakeKit Coin integrations are always enabled`() {
StakingIntegrationID.StakeKit.Coin.entries.forEach { coin ->
assertThat(toggles.isIntegrationEnabled(coin)).isTrue()
}
verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) }
}
@Test
fun `existing StakeKit EthereumToken integrations are always enabled`() {
StakingIntegrationID.StakeKit.EthereumToken.entries.forEach { token ->
assertThat(toggles.isIntegrationEnabled(token)).isTrue()
}
verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) }
}
}

View file

@ -2,23 +2,27 @@ package com.tangem.domain.staking
import arrow.core.Either import arrow.core.Either
import arrow.core.raise.either import arrow.core.raise.either
import arrow.core.raise.ensure
import arrow.core.raise.ensureNotNull import arrow.core.raise.ensureNotNull
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network 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.staking.StakingID import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
/** /**
* Factory class for creating instances of [StakingID] * Factory class for creating instances of [StakingID]
* *
* @property walletManagersFacade wallet manager facade * @property walletManagersFacade wallet manager facade
* @property stakingFeatureToggles staking feature toggles
* *
[REDACTED_AUTHOR] [REDACTED_AUTHOR]
*/ */
class StakingIdFactory( class StakingIdFactory(
private val walletManagersFacade: WalletManagersFacade, private val walletManagersFacade: WalletManagersFacade,
private val stakingFeatureToggles: StakingFeatureToggles,
) { ) {
/** /**
@ -72,6 +76,8 @@ class StakingIdFactory(
ensureNotNull(integrationId) { Error.UnsupportedCurrency } ensureNotNull(integrationId) { Error.UnsupportedCurrency }
ensure(stakingFeatureToggles.isIntegrationEnabled(integrationId)) { Error.UnsupportedCurrency }
val address = defaultAddressProvider().takeUnless { it.isNullOrEmpty() } val address = defaultAddressProvider().takeUnless { it.isNullOrEmpty() }
ensureNotNull(address) { Error.UnableToGetAddress(integrationId = integrationId) } ensureNotNull(address) { Error.UnableToGetAddress(integrationId = integrationId) }

View file

@ -1,5 +1,8 @@
package com.tangem.domain.staking.toggles package com.tangem.domain.staking.toggles
import com.tangem.domain.staking.model.StakingIntegrationID
interface StakingFeatureToggles { interface StakingFeatureToggles {
val isEthStakingEnabled: Boolean
fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean
} }

View file

@ -10,11 +10,13 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.test.core.ProvideTestModels import com.tangem.test.core.ProvideTestModels
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
@ -30,11 +32,16 @@ import org.junit.jupiter.params.ParameterizedTest
internal class StakingIdFactoryTest { internal class StakingIdFactoryTest {
private val walletManagersFacade: WalletManagersFacade = mockk() private val walletManagersFacade: WalletManagersFacade = mockk()
private val factory = StakingIdFactory(walletManagersFacade = walletManagersFacade) private val stakingFeatureToggles: StakingFeatureToggles = mockk()
private val factory = StakingIdFactory(
walletManagersFacade = walletManagersFacade,
stakingFeatureToggles = stakingFeatureToggles,
)
@BeforeEach @BeforeEach
fun resetMocks() { fun resetMocks() {
clearMocks(walletManagersFacade) clearMocks(walletManagersFacade, stakingFeatureToggles)
every { stakingFeatureToggles.isIntegrationEnabled(any()) } returns true
} }
@Nested @Nested
@ -66,6 +73,33 @@ internal class StakingIdFactoryTest {
} }
} }
@Test
fun `create returns UnsupportedCurrency if integration is disabled by toggle`() = runTest {
// Arrange
val userWalletId = UserWalletId(stringValue = "011")
val currency = MockCryptoCurrencyFactory().createCoin(Blockchain.TON)
every {
stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.StakeKit.Coin.Ton)
} returns false
// Act
val actual = factory.create(
userWalletId = userWalletId,
currencyId = currency.id,
network = currency.network,
)
// Assert
val expected = StakingIdFactory.Error.UnsupportedCurrency
Truth.assertThat(actual.leftOrNull()).isEqualTo(expected)
coVerify(inverse = true) {
walletManagersFacade.getDefaultAddress(userWalletId = any(), network = any())
}
}
@Test @Test
fun `create returns UnableToGetAddress if address is null`() = runTest { fun `create returns UnableToGetAddress if address is null`() = runTest {
// Arrange // Arrange