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

@ -1,12 +1,12 @@
package com.tangem.data.staking
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.Raise
import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.data.staking.converters.ethpool.*
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.ethpool.P2PEthPoolApi
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.P2PEthPoolTransactionResponse
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.P2PVaultLimitsStore
import com.tangem.domain.models.staking.P2PEthPoolStakingAccount
@ -41,6 +42,7 @@ import kotlinx.coroutines.withContext
/**
* P2PEthPool staking repository implementation
*/
@Suppress("LongParameterList")
internal class DefaultP2PEthPoolRepository(
private val p2pEthPoolApi: P2PEthPoolApi,
private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
@ -48,6 +50,7 @@ internal class DefaultP2PEthPoolRepository(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val stakingFeatureToggles: StakingFeatureToggles,
private val p2pEthPoolRegionBlockedStore: P2PEthPoolRegionBlockedStore,
) : P2PEthPoolRepository {
private val vaultConverter = P2PEthPoolVaultConverter
@ -76,17 +79,31 @@ internal class DefaultP2PEthPoolRepository(
override suspend fun fetchVaults(network: P2PEthPoolNetwork) {
val vaults = if (stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)) {
getVaults(network).getOrElse { error ->
TangemLogger.e("Error fetching P2PEthPool vaults: $error")
emptyList()
}
getVaults(network).fold(
ifLeft = { error ->
TangemLogger.e("Error fetching P2PEthPool vaults: $error")
p2pEthPoolRegionBlockedStore.store(error.isRegionBlocked())
emptyList()
},
ifRight = { fetched ->
p2pEthPoolRegionBlockedStore.store(false)
fetched
},
)
} else {
p2pEthPoolRegionBlockedStore.store(false)
emptyList()
}
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 {
withContext(dispatchers.io) {
handleApiResponse(p2pEthPoolApi.getVaults(network.value)) { result ->
@ -194,8 +211,10 @@ internal class DefaultP2PEthPoolRepository(
return combine(
getVaultsFlow().distinctUntilChanged(),
getVaultLimitsFlow().distinctUntilChanged(),
) { vaults, limits ->
p2pEthPoolRegionBlockedStore.get(),
) { vaults, limits, regionBlocked ->
when {
regionBlocked -> StakingAvailability.RegionUnavailable
vaults.isEmpty() -> StakingAvailability.TemporaryUnavailable
limits == null -> StakingAvailability.TemporaryUnavailable
else -> {
@ -211,6 +230,7 @@ internal class DefaultP2PEthPoolRepository(
}
override suspend fun getStakingAvailabilitySync(): StakingAvailability {
if (p2pEthPoolRegionBlockedStore.getSyncOrNull() == true) return StakingAvailability.RegionUnavailable
val vaults = getVaultsSync()
if (vaults.isEmpty()) 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.di.NetworkMoshi
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.P2PVaultLimitsStore
import com.tangem.datasource.local.token.StakingActionsStore
@ -83,6 +84,7 @@ internal object StakingDataModule {
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
stakingFeatureToggles: StakingFeatureToggles,
p2pEthPoolRegionBlockedStore: P2PEthPoolRegionBlockedStore,
): P2PEthPoolRepository {
return DefaultP2PEthPoolRepository(
p2pEthPoolApi = p2pEthPoolApi,
@ -91,6 +93,7 @@ internal object StakingDataModule {
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
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) {
is StakingIntegrationID.P2PEthPool -> null
is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle()

View file

@ -1,8 +1,14 @@
package com.tangem.data.staking
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.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.local.token.P2PEthPoolRegionBlockedStore
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.P2PVaultLimitsStore
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.toggles.StakingFeatureToggles
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@ -29,6 +38,7 @@ internal class DefaultP2PEthPoolRepositoryAvailabilityTest {
private val limitsStore = mockk<P2PVaultLimitsStore>(relaxed = true)
private val tangemTechApi = mockk<TangemTechApi>(relaxed = true)
private val featureToggles = mockk<StakingFeatureToggles>(relaxed = true)
private val regionBlockedStore = mockk<P2PEthPoolRegionBlockedStore>(relaxed = true)
private val repository = DefaultP2PEthPoolRepository(
p2pEthPoolApi = api,
@ -37,8 +47,14 @@ internal class DefaultP2PEthPoolRepositoryAvailabilityTest {
tangemTechApi = tangemTechApi,
dispatchers = TestingCoroutineDispatcherProvider(),
stakingFeatureToggles = featureToggles,
p2pEthPoolRegionBlockedStore = regionBlockedStore,
)
@BeforeEach
fun resetMocks() {
clearMocks(api, vaultsStore, limitsStore, tangemTechApi, featureToggles, regionBlockedStore)
}
private fun buildVault(address: String, totalAssets: String) = P2PEthPoolVault(
vaultAddress = address,
displayName = "Vault",
@ -59,10 +75,22 @@ internal class DefaultP2PEthPoolRepositoryAvailabilityTest {
private fun limits(address: String, limit: String) =
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
fun `all vaults full - emits Full with option`() = runTest {
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 { regionBlockedStore.get() } returns MutableStateFlow(false)
val result = repository.getStakingAvailability().first()
@ -73,12 +101,89 @@ internal class DefaultP2PEthPoolRepositoryAvailabilityTest {
fun `capacity available - emits Available`() = runTest {
every { vaultsStore.get() } returns flowOf(listOf(buildVault("0xABC", totalAssets = "100")))
every { limitsStore.get() } returns MutableStateFlow(limits("0xABC", limit = "1000")) // remaining 900 > 0.1
every { regionBlockedStore.get() } returns MutableStateFlow(false)
val result = repository.getStakingAvailability().first()
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
fun `sync - all vaults full - returns Full with option`() = runTest {
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.P2PEthPoolVaultsResponse
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.P2PVaultLimitsStore
import com.tangem.domain.staking.model.StakingIntegrationID
@ -37,6 +38,7 @@ internal class P2PEthPoolVaultFilterTest {
private val featureToggles = mockk<StakingFeatureToggles> {
every { isIntegrationEnabled(StakingIntegrationID.P2PEthPool) } returns true
}
private val regionBlockedStore = mockk<P2PEthPoolRegionBlockedStore>(relaxed = true)
private val repository = DefaultP2PEthPoolRepository(
p2pEthPoolApi = api,
p2pEthPoolVaultsStore = store,
@ -44,6 +46,7 @@ internal class P2PEthPoolVaultFilterTest {
tangemTechApi = tangemTechApi,
dispatchers = TestingCoroutineDispatcherProvider(),
stakingFeatureToggles = featureToggles,
p2pEthPoolRegionBlockedStore = regionBlockedStore,
)
private fun buildVaultDTO(address: String) = P2PEthPoolVaultDTO(

View file

@ -73,4 +73,32 @@ internal class DefaultStakingFeatureTogglesTest {
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()
}
}