Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-16 13:36:16 +02:00
parent a5488207ed
commit 2a41321e74
20 changed files with 440 additions and 9 deletions

View file

@ -183,6 +183,10 @@
"name": "TWI_1192_TANGEM_PAY_CASHBACK_ENABLED", "name": "TWI_1192_TANGEM_PAY_CASHBACK_ENABLED",
"version": "undefined" "version": "undefined"
}, },
{
"name": "AND_15231_STAKING_REGION_UNAVAILABLE_ENABLED",
"version": "6.1"
},
{ {
"name": "AND_16204_POLYMARKET_ENABLED", "name": "AND_16204_POLYMARKET_ENABLED",
"version": "undefined" "version": "undefined"

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.local.token
import com.tangem.datasource.local.datastore.RuntimeStateStore
import javax.inject.Inject
import javax.inject.Singleton
/**
* In-memory flag: the last P2P vaults request returned HTTP 451 (region unavailable).
* `true` staking is blocked in the region. Reset to `false` on a successful fetch.
*/
@Singleton
class P2PEthPoolRegionBlockedStore @Inject constructor() :
RuntimeStateStore<Boolean> by RuntimeStateStore(defaultValue = false)

View file

@ -1,12 +1,12 @@
package com.tangem.data.staking package com.tangem.data.staking
import arrow.core.Either import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.Raise import arrow.core.raise.Raise
import arrow.core.raise.either import arrow.core.raise.either
import arrow.core.raise.ensure import arrow.core.raise.ensure
import com.tangem.data.staking.converters.ethpool.* import com.tangem.data.staking.converters.ethpool.*
import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.ethpool.P2PEthPoolApi import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest
@ -14,6 +14,7 @@ import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolTransactionReq
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolTransactionResponse import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolTransactionResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.token.P2PEthPoolRegionBlockedStore
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.P2PVaultLimitsStore import com.tangem.datasource.local.token.P2PVaultLimitsStore
import com.tangem.domain.models.staking.P2PEthPoolStakingAccount import com.tangem.domain.models.staking.P2PEthPoolStakingAccount
@ -41,6 +42,7 @@ import kotlinx.coroutines.withContext
/** /**
* P2PEthPool staking repository implementation * P2PEthPool staking repository implementation
*/ */
@Suppress("LongParameterList")
internal class DefaultP2PEthPoolRepository( internal class DefaultP2PEthPoolRepository(
private val p2pEthPoolApi: P2PEthPoolApi, private val p2pEthPoolApi: P2PEthPoolApi,
private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore, private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
@ -48,6 +50,7 @@ internal class DefaultP2PEthPoolRepository(
private val tangemTechApi: TangemTechApi, private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
private val stakingFeatureToggles: StakingFeatureToggles, private val stakingFeatureToggles: StakingFeatureToggles,
private val p2pEthPoolRegionBlockedStore: P2PEthPoolRegionBlockedStore,
) : P2PEthPoolRepository { ) : P2PEthPoolRepository {
private val vaultConverter = P2PEthPoolVaultConverter private val vaultConverter = P2PEthPoolVaultConverter
@ -76,17 +79,31 @@ internal class DefaultP2PEthPoolRepository(
override suspend fun fetchVaults(network: P2PEthPoolNetwork) { override suspend fun fetchVaults(network: P2PEthPoolNetwork) {
val vaults = if (stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)) { val vaults = if (stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)) {
getVaults(network).getOrElse { error -> getVaults(network).fold(
TangemLogger.e("Error fetching P2PEthPool vaults: $error") ifLeft = { error ->
emptyList() TangemLogger.e("Error fetching P2PEthPool vaults: $error")
} p2pEthPoolRegionBlockedStore.store(error.isRegionBlocked())
emptyList()
},
ifRight = { fetched ->
p2pEthPoolRegionBlockedStore.store(false)
fetched
},
)
} else { } else {
p2pEthPoolRegionBlockedStore.store(false)
emptyList() emptyList()
} }
p2pEthPoolVaultsStore.store(vaults) p2pEthPoolVaultsStore.store(vaults)
} }
private fun StakingError.isRegionBlocked(): Boolean {
if (!stakingFeatureToggles.isRegionUnavailableHandlingEnabled()) return false
val httpException = (this as? StakingError.UnknownError)?.exception as? ApiResponseError.HttpException
return httpException?.code == ApiResponseError.HttpException.Code.UNAVAILABLE_FOR_LEGAL_REASONS
}
override suspend fun getVaults(network: P2PEthPoolNetwork): Either<StakingError, List<P2PEthPoolVault>> = either { override suspend fun getVaults(network: P2PEthPoolNetwork): Either<StakingError, List<P2PEthPoolVault>> = either {
withContext(dispatchers.io) { withContext(dispatchers.io) {
handleApiResponse(p2pEthPoolApi.getVaults(network.value)) { result -> handleApiResponse(p2pEthPoolApi.getVaults(network.value)) { result ->
@ -194,8 +211,10 @@ internal class DefaultP2PEthPoolRepository(
return combine( return combine(
getVaultsFlow().distinctUntilChanged(), getVaultsFlow().distinctUntilChanged(),
getVaultLimitsFlow().distinctUntilChanged(), getVaultLimitsFlow().distinctUntilChanged(),
) { vaults, limits -> p2pEthPoolRegionBlockedStore.get(),
) { vaults, limits, regionBlocked ->
when { when {
regionBlocked -> StakingAvailability.RegionUnavailable
vaults.isEmpty() -> StakingAvailability.TemporaryUnavailable vaults.isEmpty() -> StakingAvailability.TemporaryUnavailable
limits == null -> StakingAvailability.TemporaryUnavailable limits == null -> StakingAvailability.TemporaryUnavailable
else -> { else -> {
@ -211,6 +230,7 @@ internal class DefaultP2PEthPoolRepository(
} }
override suspend fun getStakingAvailabilitySync(): StakingAvailability { override suspend fun getStakingAvailabilitySync(): StakingAvailability {
if (p2pEthPoolRegionBlockedStore.getSyncOrNull() == true) return StakingAvailability.RegionUnavailable
val vaults = getVaultsSync() val vaults = getVaultsSync()
if (vaults.isEmpty()) return StakingAvailability.TemporaryUnavailable if (vaults.isEmpty()) return StakingAvailability.TemporaryUnavailable
val limits = getVaultLimitsSyncOrNull() ?: return StakingAvailability.TemporaryUnavailable val limits = getVaultLimitsSyncOrNull() ?: return StakingAvailability.TemporaryUnavailable

View file

@ -15,6 +15,7 @@ import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitEr
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.P2PEthPoolRegionBlockedStore
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.P2PVaultLimitsStore import com.tangem.datasource.local.token.P2PVaultLimitsStore
import com.tangem.datasource.local.token.StakingActionsStore import com.tangem.datasource.local.token.StakingActionsStore
@ -83,6 +84,7 @@ internal object StakingDataModule {
tangemTechApi: TangemTechApi, tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider, dispatchers: CoroutineDispatcherProvider,
stakingFeatureToggles: StakingFeatureToggles, stakingFeatureToggles: StakingFeatureToggles,
p2pEthPoolRegionBlockedStore: P2PEthPoolRegionBlockedStore,
): P2PEthPoolRepository { ): P2PEthPoolRepository {
return DefaultP2PEthPoolRepository( return DefaultP2PEthPoolRepository(
p2pEthPoolApi = p2pEthPoolApi, p2pEthPoolApi = p2pEthPoolApi,
@ -91,6 +93,7 @@ internal object StakingDataModule {
tangemTechApi = tangemTechApi, tangemTechApi = tangemTechApi,
dispatchers = dispatchers, dispatchers = dispatchers,
stakingFeatureToggles = stakingFeatureToggles, stakingFeatureToggles = stakingFeatureToggles,
p2pEthPoolRegionBlockedStore = p2pEthPoolRegionBlockedStore,
) )
} }

View file

@ -20,6 +20,12 @@ internal class DefaultStakingFeatureToggles(
) )
} }
override fun isRegionUnavailableHandlingEnabled(): Boolean {
return featureTogglesManager.isFeatureEnabled(
FeatureToggles.AND_15231_STAKING_REGION_UNAVAILABLE_ENABLED,
)
}
private fun StakingIntegrationID.getFeatureToggle(): FeatureToggles? = when (this) { private fun StakingIntegrationID.getFeatureToggle(): FeatureToggles? = when (this) {
is StakingIntegrationID.P2PEthPool -> null is StakingIntegrationID.P2PEthPool -> null
is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle() is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle()

View file

@ -1,8 +1,14 @@
package com.tangem.data.staking package com.tangem.data.staking
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.ethpool.P2PEthPoolApi import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolNetworkDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultsResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.token.P2PEthPoolRegionBlockedStore
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.P2PVaultLimitsStore import com.tangem.datasource.local.token.P2PVaultLimitsStore
import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingAvailability
@ -10,13 +16,16 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.ethpool.VaultLimitInfo import com.tangem.domain.staking.model.ethpool.VaultLimitInfo
import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal import java.math.BigDecimal
@ -29,6 +38,7 @@ internal class DefaultP2PEthPoolRepositoryAvailabilityTest {
private val limitsStore = mockk<P2PVaultLimitsStore>(relaxed = true) private val limitsStore = mockk<P2PVaultLimitsStore>(relaxed = true)
private val tangemTechApi = mockk<TangemTechApi>(relaxed = true) private val tangemTechApi = mockk<TangemTechApi>(relaxed = true)
private val featureToggles = mockk<StakingFeatureToggles>(relaxed = true) private val featureToggles = mockk<StakingFeatureToggles>(relaxed = true)
private val regionBlockedStore = mockk<P2PEthPoolRegionBlockedStore>(relaxed = true)
private val repository = DefaultP2PEthPoolRepository( private val repository = DefaultP2PEthPoolRepository(
p2pEthPoolApi = api, p2pEthPoolApi = api,
@ -37,8 +47,14 @@ internal class DefaultP2PEthPoolRepositoryAvailabilityTest {
tangemTechApi = tangemTechApi, tangemTechApi = tangemTechApi,
dispatchers = TestingCoroutineDispatcherProvider(), dispatchers = TestingCoroutineDispatcherProvider(),
stakingFeatureToggles = featureToggles, stakingFeatureToggles = featureToggles,
p2pEthPoolRegionBlockedStore = regionBlockedStore,
) )
@BeforeEach
fun resetMocks() {
clearMocks(api, vaultsStore, limitsStore, tangemTechApi, featureToggles, regionBlockedStore)
}
private fun buildVault(address: String, totalAssets: String) = P2PEthPoolVault( private fun buildVault(address: String, totalAssets: String) = P2PEthPoolVault(
vaultAddress = address, vaultAddress = address,
displayName = "Vault", displayName = "Vault",
@ -59,10 +75,22 @@ internal class DefaultP2PEthPoolRepositoryAvailabilityTest {
private fun limits(address: String, limit: String) = private fun limits(address: String, limit: String) =
mapOf(address.lowercase() to VaultLimitInfo(limit = BigDecimal(limit), coefficient = null)) mapOf(address.lowercase() to VaultLimitInfo(limit = BigDecimal(limit), coefficient = null))
@Suppress("UNCHECKED_CAST")
private fun regionBlockedErrorResponse(): ApiResponse<P2PEthPoolResponse<P2PEthPoolVaultsResponse>> {
return ApiResponse.Error(
cause = ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.UNAVAILABLE_FOR_LEGAL_REASONS,
message = "451",
errorBody = null,
),
) as ApiResponse<P2PEthPoolResponse<P2PEthPoolVaultsResponse>>
}
@Test @Test
fun `all vaults full - emits Full with option`() = runTest { fun `all vaults full - emits Full with option`() = runTest {
every { vaultsStore.get() } returns flowOf(listOf(buildVault("0xABC", totalAssets = "999.95"))) every { vaultsStore.get() } returns flowOf(listOf(buildVault("0xABC", totalAssets = "999.95")))
every { limitsStore.get() } returns MutableStateFlow(limits("0xABC", limit = "1000")) // remaining 0.05 <= 0.1 every { limitsStore.get() } returns MutableStateFlow(limits("0xABC", limit = "1000")) // remaining 0.05 <= 0.1
every { regionBlockedStore.get() } returns MutableStateFlow(false)
val result = repository.getStakingAvailability().first() val result = repository.getStakingAvailability().first()
@ -73,12 +101,89 @@ internal class DefaultP2PEthPoolRepositoryAvailabilityTest {
fun `capacity available - emits Available`() = runTest { fun `capacity available - emits Available`() = runTest {
every { vaultsStore.get() } returns flowOf(listOf(buildVault("0xABC", totalAssets = "100"))) every { vaultsStore.get() } returns flowOf(listOf(buildVault("0xABC", totalAssets = "100")))
every { limitsStore.get() } returns MutableStateFlow(limits("0xABC", limit = "1000")) // remaining 900 > 0.1 every { limitsStore.get() } returns MutableStateFlow(limits("0xABC", limit = "1000")) // remaining 900 > 0.1
every { regionBlockedStore.get() } returns MutableStateFlow(false)
val result = repository.getStakingAvailability().first() val result = repository.getStakingAvailability().first()
assertThat(result).isInstanceOf(StakingAvailability.Available::class.java) assertThat(result).isInstanceOf(StakingAvailability.Available::class.java)
} }
@Test
fun `GIVEN region blocked flag WHEN getStakingAvailability THEN emits RegionUnavailable`() = runTest {
// Arrange
every { vaultsStore.get() } returns flowOf(emptyList())
every { limitsStore.get() } returns MutableStateFlow(null)
every { regionBlockedStore.get() } returns MutableStateFlow(true)
// Act
val result = repository.getStakingAvailability().first()
// Assert
assertThat(result).isInstanceOf(StakingAvailability.RegionUnavailable::class.java)
}
@Test
fun `GIVEN region blocked flag WHEN getStakingAvailabilitySync THEN returns RegionUnavailable`() = runTest {
// Arrange
coEvery { regionBlockedStore.getSyncOrNull() } returns true
// Act
val result = repository.getStakingAvailabilitySync()
// Assert
assertThat(result).isInstanceOf(StakingAvailability.RegionUnavailable::class.java)
}
@Test
fun `GIVEN 451 AND toggle on WHEN fetchVaults THEN region flag set true`() = runTest {
// Arrange
every { featureToggles.isIntegrationEnabled(any()) } returns true
every { featureToggles.isRegionUnavailableHandlingEnabled() } returns true
coEvery { api.getVaults(any()) } returns regionBlockedErrorResponse()
// Act
repository.fetchVaults()
// Assert
coVerify { regionBlockedStore.store(true) }
coVerify { vaultsStore.store(emptyList()) }
}
@Test
fun `GIVEN 451 AND toggle off WHEN fetchVaults THEN region flag stays false`() = runTest {
// Arrange
every { featureToggles.isIntegrationEnabled(any()) } returns true
every { featureToggles.isRegionUnavailableHandlingEnabled() } returns false
coEvery { api.getVaults(any()) } returns regionBlockedErrorResponse()
// Act
repository.fetchVaults()
// Assert
coVerify { regionBlockedStore.store(false) }
}
@Test
fun `GIVEN successful fetch WHEN fetchVaults THEN region flag reset to false`() = runTest {
// Arrange
every { featureToggles.isIntegrationEnabled(any()) } returns true
coEvery { api.getVaults(any()) } returns ApiResponse.Success(
P2PEthPoolResponse(
error = null,
result = P2PEthPoolVaultsResponse(
network = P2PEthPoolNetworkDTO.MAINNET,
vaults = emptyList(),
),
),
)
// Act
repository.fetchVaults()
// Assert
coVerify { regionBlockedStore.store(false) }
}
@Test @Test
fun `sync - all vaults full - returns Full with option`() = runTest { fun `sync - all vaults full - returns Full with option`() = runTest {
coEvery { vaultsStore.getSync() } returns listOf(buildVault("0xABC", totalAssets = "999.95")) coEvery { vaultsStore.getSync() } returns listOf(buildVault("0xABC", totalAssets = "999.95"))

View file

@ -8,6 +8,7 @@ import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultDTO import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultsResponse import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultsResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.token.P2PEthPoolRegionBlockedStore
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.P2PVaultLimitsStore import com.tangem.datasource.local.token.P2PVaultLimitsStore
import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.StakingIntegrationID
@ -37,6 +38,7 @@ internal class P2PEthPoolVaultFilterTest {
private val featureToggles = mockk<StakingFeatureToggles> { private val featureToggles = mockk<StakingFeatureToggles> {
every { isIntegrationEnabled(StakingIntegrationID.P2PEthPool) } returns true every { isIntegrationEnabled(StakingIntegrationID.P2PEthPool) } returns true
} }
private val regionBlockedStore = mockk<P2PEthPoolRegionBlockedStore>(relaxed = true)
private val repository = DefaultP2PEthPoolRepository( private val repository = DefaultP2PEthPoolRepository(
p2pEthPoolApi = api, p2pEthPoolApi = api,
p2pEthPoolVaultsStore = store, p2pEthPoolVaultsStore = store,
@ -44,6 +46,7 @@ internal class P2PEthPoolVaultFilterTest {
tangemTechApi = tangemTechApi, tangemTechApi = tangemTechApi,
dispatchers = TestingCoroutineDispatcherProvider(), dispatchers = TestingCoroutineDispatcherProvider(),
stakingFeatureToggles = featureToggles, stakingFeatureToggles = featureToggles,
p2pEthPoolRegionBlockedStore = regionBlockedStore,
) )
private fun buildVaultDTO(address: String) = P2PEthPoolVaultDTO( private fun buildVaultDTO(address: String) = P2PEthPoolVaultDTO(

View file

@ -73,4 +73,32 @@ internal class DefaultStakingFeatureTogglesTest {
featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED) featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED)
} }
} }
@Test
fun `GIVEN toggle enabled WHEN isRegionUnavailableHandlingEnabled THEN returns true`() {
// Arrange
every {
featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15231_STAKING_REGION_UNAVAILABLE_ENABLED)
} returns true
// Act
val result = toggles.isRegionUnavailableHandlingEnabled()
// Assert
assertThat(result).isTrue()
}
@Test
fun `GIVEN toggle disabled WHEN isRegionUnavailableHandlingEnabled THEN returns false`() {
// Arrange
every {
featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15231_STAKING_REGION_UNAVAILABLE_ENABLED)
} returns false
// Act
val result = toggles.isRegionUnavailableHandlingEnabled()
// Assert
assertThat(result).isFalse()
}
} }

View file

@ -13,6 +13,12 @@ sealed class StakingAvailability {
data object Unavailable : StakingAvailability() data object Unavailable : StakingAvailability()
data object TemporaryUnavailable : StakingAvailability() data object TemporaryUnavailable : StakingAvailability()
/**
* The P2P API returned HTTP 451 staking is blocked in the user's region.
* New stakes are not offered; an existing stake is shown as a read-only block.
*/
data object RegionUnavailable : StakingAvailability()
} }
/** Staking option if the integration is known (Available or Full), else null. */ /** Staking option if the integration is known (Available or Full), else null. */
@ -22,5 +28,6 @@ val StakingAvailability.optionOrNull: StakingOption?
is StakingAvailability.Full -> option is StakingAvailability.Full -> option
StakingAvailability.Unavailable, StakingAvailability.Unavailable,
StakingAvailability.TemporaryUnavailable, StakingAvailability.TemporaryUnavailable,
StakingAvailability.RegionUnavailable,
-> null -> null
} }

View file

@ -7,4 +7,6 @@ interface StakingFeatureToggles {
fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean
fun isSolanaUnstakeValidationEnabled(): Boolean fun isSolanaUnstakeValidationEnabled(): Boolean
fun isRegionUnavailableHandlingEnabled(): Boolean
} }

View file

@ -182,6 +182,7 @@ internal open class BaseActionsFactory(
) )
is StakingAvailability.Full -> null is StakingAvailability.Full -> null
StakingAvailability.Unavailable -> null StakingAvailability.Unavailable -> null
StakingAvailability.RegionUnavailable -> null
} }
} }

View file

@ -95,14 +95,30 @@ internal class CommonActionsFactoryTest {
assertThat(buyAction.unavailabilityReason).isEqualTo(ScenarioUnavailabilityReason.None) assertThat(buyAction.unavailabilityReason).isEqualTo(ScenarioUnavailabilityReason.None)
} }
@Test
fun `GIVEN region unavailable WHEN create THEN no stake action offered`() = runTest {
// Arrange
every { cardTypesResolver.isStart2Coin() } returns false
// Act
val actions = createActions(stakingAvailability = StakingAvailability.RegionUnavailable)
// Assert
assertThat(actions.filterIsInstance<ActionState.Stake>()).isEmpty()
}
private suspend fun createBuyAction(): ActionState.Buy { private suspend fun createBuyAction(): ActionState.Buy {
val actions = factory.create( val actions = createActions(stakingAvailability = StakingAvailability.Unavailable)
return actions.filterIsInstance<ActionState.Buy>().single()
}
private suspend fun createActions(stakingAvailability: StakingAvailability): Set<ActionState> {
return factory.create(
userWallet = userWallet, userWallet = userWallet,
cryptoCurrencyStatus = cryptoCurrencyStatus, cryptoCurrencyStatus = cryptoCurrencyStatus,
stakingAvailability = StakingAvailability.Unavailable, stakingAvailability = stakingAvailability,
yieldSupplyAvailability = YieldSupplyAvailability.Unavailable, yieldSupplyAvailability = YieldSupplyAvailability.Unavailable,
shouldShowSwapStories = false, shouldShowSwapStories = false,
) )
return actions.filterIsInstance<ActionState.Buy>().single()
} }
} }

View file

@ -25,6 +25,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetails
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.ChooseAddressBottomSheetComponent import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.ChooseAddressBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.StakingRegionUnavailableBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.TransferBottomSheetComponent import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.TransferBottomSheetComponent
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.marketing.api.MarketingBannerComponent
@ -237,6 +238,9 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
stateFlow = model.transferUiState, stateFlow = model.transferUiState,
onDismiss = model.bottomSheetNavigation::dismiss, onDismiss = model.bottomSheetNavigation::dismiss,
) )
is TokenDetailsBottomSheetConfig.RegionUnavailable -> StakingRegionUnavailableBottomSheetComponent(
onDismiss = model.bottomSheetNavigation::dismiss,
)
} }
@AssistedFactory @AssistedFactory

View file

@ -78,6 +78,8 @@ interface TokenDetailsClickIntents {
fun onYieldInfoClick() fun onYieldInfoClick()
fun onStakingRegionUnavailableClick()
// region Clore migration // region Clore migration
// TODO: Remove after 2025-04-01 when Clore migration ends ([REDACTED_TASK_KEY]) // TODO: Remove after 2025-04-01 when Clore migration ends ([REDACTED_TASK_KEY])
@ -175,6 +177,8 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents {
override fun onYieldInfoClick() { /* no op */ } override fun onYieldInfoClick() { /* no op */ }
override fun onStakingRegionUnavailableClick() { /* no op */ }
override fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) { /* no op */ } override fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) { /* no op */ }
override fun onCopyAddress(): TextReference? { override fun onCopyAddress(): TextReference? {

View file

@ -72,6 +72,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.GetOfframpUrlUseCase import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.CheckOnrampAvailabilityUseCase import com.tangem.domain.onramp.CheckOnrampAvailabilityUseCase
import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.staking.FetchStakingOptionsUseCase
import com.tangem.domain.staking.GetStakingAvailabilityUseCase import com.tangem.domain.staking.GetStakingAvailabilityUseCase
import com.tangem.domain.staking.GetStakingEntryInfoUseCase import com.tangem.domain.staking.GetStakingEntryInfoUseCase
import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingAvailability
@ -143,6 +144,7 @@ internal class TokenDetailsModel @Inject constructor(
private val getExtendedPublicKeyForCurrencyUseCase: GetExtendedPublicKeyForCurrencyUseCase, private val getExtendedPublicKeyForCurrencyUseCase: GetExtendedPublicKeyForCurrencyUseCase,
private val getStakingEntryInfoUseCase: GetStakingEntryInfoUseCase, private val getStakingEntryInfoUseCase: GetStakingEntryInfoUseCase,
private val getStakingAvailabilityUseCase: GetStakingAvailabilityUseCase, private val getStakingAvailabilityUseCase: GetStakingAvailabilityUseCase,
private val fetchStakingOptionsUseCase: FetchStakingOptionsUseCase,
private val networkHasDerivationUseCase: NetworkHasDerivationUseCase, private val networkHasDerivationUseCase: NetworkHasDerivationUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase, private val isDemoCardUseCase: IsDemoCardUseCase,
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase, private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
@ -1037,6 +1039,7 @@ internal class TokenDetailsModel @Inject constructor(
updateTxHistory() updateTxHistory()
expressTransactionsEventListener.send(ExpressTransactionsEvent.Update) expressTransactionsEventListener.send(ExpressTransactionsEvent.Update)
}, },
async { fetchStakingOptionsUseCase() },
).awaitAll() ).awaitAll()
uiState.value = stateFactory.getRefreshedState() uiState.value = stateFactory.getRefreshedState()
redesignStateController.update { state -> redesignStateController.update { state ->
@ -1261,6 +1264,10 @@ internal class TokenDetailsModel @Inject constructor(
) )
} }
override fun onStakingRegionUnavailableClick() {
bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.RegionUnavailable)
}
private fun handleUnavailabilityReason(unavailabilityReason: ScenarioUnavailabilityReason): Boolean { private fun handleUnavailabilityReason(unavailabilityReason: ScenarioUnavailabilityReason): Boolean {
if (unavailabilityReason == ScenarioUnavailabilityReason.None) return false if (unavailabilityReason == ScenarioUnavailabilityReason.None) return false

View file

@ -40,4 +40,7 @@ sealed class TokenDetailsBottomSheetConfig : Route {
@Serializable @Serializable
data object Transfer : TokenDetailsBottomSheetConfig() data object Transfer : TokenDetailsBottomSheetConfig()
@Serializable
data object RegionUnavailable : TokenDetailsBottomSheetConfig()
} }

View file

@ -53,6 +53,7 @@ internal class TokenDetailsStakingInfoConverter(
) )
return when (stakingAvailability) { return when (stakingAvailability) {
StakingAvailability.TemporaryUnavailable -> StakingBlockUM.TemporaryUnavailable StakingAvailability.TemporaryUnavailable -> StakingBlockUM.TemporaryUnavailable
StakingAvailability.RegionUnavailable -> StakingBlockUM.TemporaryUnavailable
StakingAvailability.Unavailable -> null StakingAvailability.Unavailable -> null
is StakingAvailability.Full -> getStakedBlockOrNull(status) is StakingAvailability.Full -> getStakedBlockOrNull(status)
is StakingAvailability.Available -> getStakingInfoBlock( is StakingAvailability.Available -> getStakingInfoBlock(

View file

@ -44,6 +44,7 @@ internal class UpdateStakingNotificationTransformer(
private fun buildEarnBlock(isBalanceHidden: Boolean): EarnBlockUM? { private fun buildEarnBlock(isBalanceHidden: Boolean): EarnBlockUM? {
return when (val availability = stakingAvailability) { return when (val availability = stakingAvailability) {
StakingAvailability.TemporaryUnavailable -> buildTemporaryUnavailable() StakingAvailability.TemporaryUnavailable -> buildTemporaryUnavailable()
StakingAvailability.RegionUnavailable -> buildRegionUnavailableOrNull()
StakingAvailability.Unavailable -> null StakingAvailability.Unavailable -> null
is StakingAvailability.Full -> buildActiveBlockOrNull(isBalanceHidden) is StakingAvailability.Full -> buildActiveBlockOrNull(isBalanceHidden)
is StakingAvailability.Available -> getStakingInfoBlock(availability, isBalanceHidden) is StakingAvailability.Available -> getStakingInfoBlock(availability, isBalanceHidden)
@ -69,6 +70,31 @@ internal class UpdateStakingNotificationTransformer(
) )
} }
private fun buildRegionUnavailableOrNull(): EarnBlockUM? {
val status = cryptoCurrencyStatus
val stakingBalance = status.value.stakingBalance as? StakingBalance.Data
val stakingCryptoAmount = stakingBalance?.getTotalStakingBalance(status.currency.network.rawId)
val hasStake = !stakingCryptoAmount.isNullOrZero() || stakingBalance.hasPendingBalances()
if (!hasStake) return null
return EarnBlockUM.Content(
type = EarnBlockUM.Type.Staking,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Plain(iconRes = CoreUiR.drawable.ic_staking_disable_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_staking),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.staking_error_unavailable_region),
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
),
trailingUM = null,
onClick = clickIntents::onStakingRegionUnavailableClick,
)
}
private fun getStakingInfoBlock( private fun getStakingInfoBlock(
availability: StakingAvailability.Available, availability: StakingAvailability.Available,
isBalanceHidden: Boolean, isBalanceHidden: Boolean,

View file

@ -0,0 +1,41 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.tangem.core.ui.components.bottomsheets.message.*
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_error_28
import com.tangem.core.res.R as CoreResR
internal class StakingRegionUnavailableBottomSheetComponent(
private val onDismiss: () -> Unit,
) : ComposableBottomSheetComponent {
override fun dismiss() {
onDismiss()
}
@Composable
override fun BottomSheet() {
val state = remember {
messageBottomSheetUM {
infoBlock {
vector(imageVector = Icons.ic_error_28) {
type = MessageBottomSheetUM.Vector.Type.Attention
backgroundType = MessageBottomSheetUM.Vector.BackgroundType.Attention
}
title = resourceReference(CoreResR.string.common_staking)
body = resourceReference(CoreResR.string.staking_error_unavailable_region_description)
}
primaryButton {
text = resourceReference(CoreResR.string.common_close)
onClick { closeBs() }
}
onDismiss { dismiss() }
}
}
MessageBottomSheet(state = state, onDismissRequest = ::dismiss)
}
}

View file

@ -0,0 +1,137 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.earn.EarnBlockUM
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
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.staking.StakingBalance
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.*
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class UpdateStakingNotificationTransformerRegionTest {
private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true)
@Test
fun `GIVEN region unavailable AND staked WHEN transform THEN tappable region block`() {
// Arrange
val status = buildStatusWithStake(stakedAmount = BigDecimal("5"))
val transformer = UpdateStakingNotificationTransformer(
cryptoCurrencyStatus = status,
stakingAvailability = StakingAvailability.RegionUnavailable,
stakingEntryInfo = null,
appCurrency = AppCurrency.Default,
isBalanceHidden = false,
clickIntents = clickIntents,
)
// Act
val result = transformer.transform(initialState()).earnBlockState
// Assert
val content = result as EarnBlockUM.Content
assertThat(content.onClick).isNotNull()
assertThat(content.trailingUM).isNull()
assertThat(content.subtitleUM).isInstanceOf(EarnBlockUM.SubtitleUM.Text::class.java)
}
@Test
fun `GIVEN region unavailable AND not staked WHEN transform THEN no block`() {
// Arrange
val status = buildStatus()
val transformer = UpdateStakingNotificationTransformer(
cryptoCurrencyStatus = status,
stakingAvailability = StakingAvailability.RegionUnavailable,
stakingEntryInfo = null,
appCurrency = AppCurrency.Default,
isBalanceHidden = false,
clickIntents = clickIntents,
)
// Act
val result = transformer.transform(initialState()).earnBlockState
// Assert
assertThat(result).isNull()
}
private fun buildStatus(
networkRawId: String = "solana",
symbol: String = "SOL",
isCoin: Boolean = true,
stakingBalance: StakingBalance = mockk(relaxed = true),
): CryptoCurrencyStatus {
val network = mockk<Network>(relaxed = true) {
every { rawId } returns networkRawId
every { isTestnet } returns false
}
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { this@mockk.symbol } returns symbol
every { decimals } returns 9
every { this@mockk.network } returns network
every { id.isCoin } returns isCoin
}
val value = mockk<CryptoCurrencyStatus.Value>(relaxed = true) {
every { this@mockk.stakingBalance } returns stakingBalance
every { fiatRate } returns BigDecimal.ONE
every { yieldSupplyStatus } returns null
}
return CryptoCurrencyStatus(currency = currency, value = value)
}
private fun buildStatusWithStake(stakedAmount: BigDecimal): CryptoCurrencyStatus {
val network = mockk<Network>(relaxed = true) {
every { rawId } returns "ethereum"
every { isTestnet } returns false
}
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { symbol } returns "ETH"
every { decimals } returns 18
every { this@mockk.network } returns network
every { id.isCoin } returns true
}
val stakingBalance = mockk<StakingBalance.Data.P2PEthPool>(relaxed = true) {
every { totalStaked } returns stakedAmount
every { unstakingAmount } returns BigDecimal.ZERO
every { withdrawableAmount } returns BigDecimal.ZERO
every { totalRewards } returns BigDecimal.ZERO
}
val value = mockk<CryptoCurrencyStatus.Value>(relaxed = true) {
every { this@mockk.stakingBalance } returns stakingBalance
every { fiatRate } returns BigDecimal.ONE
every { yieldSupplyStatus } returns null
}
return CryptoCurrencyStatus(currency = currency, value = value)
}
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = "Ethereum"),
subtitle = stringReference("Ethereum network"),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = mockk<TokenDetailsBalanceBlockUM>(relaxed = true),
notifications = persistentListOf(),
earnBlockState = null,
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
}