Updated on 2026-08-14
This commit is contained in:
parent
a5488207ed
commit
2a41321e74
20 changed files with 440 additions and 9 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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? {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,4 +40,7 @@ sealed class TokenDetailsBottomSheetConfig : Route {
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Transfer : TokenDetailsBottomSheetConfig()
|
data object Transfer : TokenDetailsBottomSheetConfig()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object RegionUnavailable : TokenDetailsBottomSheetConfig()
|
||||||
}
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue