diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 2360da168b..7a08104191 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -183,6 +183,10 @@ "name": "TWI_1192_TANGEM_PAY_CASHBACK_ENABLED", "version": "undefined" }, + { + "name": "AND_15231_STAKING_REGION_UNAVAILABLE_ENABLED", + "version": "6.1" + }, { "name": "AND_16204_POLYMARKET_ENABLED", "version": "undefined" diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/P2PEthPoolRegionBlockedStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/P2PEthPoolRegionBlockedStore.kt new file mode 100644 index 0000000000..7e762dd88a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/P2PEthPoolRegionBlockedStore.kt @@ -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 by RuntimeStateStore(defaultValue = false) \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt index 5bbadc6b84..6c5ae425ea 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -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> = 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 diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt index 44bd4e1dc0..3c1011c8b9 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -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, ) } diff --git a/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt b/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt index a2b194b0f8..4a03e81627 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt @@ -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() diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/DefaultP2PEthPoolRepositoryAvailabilityTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/DefaultP2PEthPoolRepositoryAvailabilityTest.kt index 314965ca01..e36c5c9877 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/DefaultP2PEthPoolRepositoryAvailabilityTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/DefaultP2PEthPoolRepositoryAvailabilityTest.kt @@ -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(relaxed = true) private val tangemTechApi = mockk(relaxed = true) private val featureToggles = mockk(relaxed = true) + private val regionBlockedStore = mockk(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> { + return ApiResponse.Error( + cause = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.UNAVAILABLE_FOR_LEGAL_REASONS, + message = "451", + errorBody = null, + ), + ) as ApiResponse> + } + @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")) diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/P2PEthPoolVaultFilterTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/P2PEthPoolVaultFilterTest.kt index e1c7c97324..f4da1cec84 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/P2PEthPoolVaultFilterTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/P2PEthPoolVaultFilterTest.kt @@ -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 { every { isIntegrationEnabled(StakingIntegrationID.P2PEthPool) } returns true } + private val regionBlockedStore = mockk(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( diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt index c6f96ecc7b..f5bfab97a7 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt @@ -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() + } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingAvailability.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingAvailability.kt index 6b0a5c1ffc..90243a1951 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingAvailability.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingAvailability.kt @@ -13,6 +13,12 @@ sealed class StakingAvailability { data object Unavailable : 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. */ @@ -22,5 +28,6 @@ val StakingAvailability.optionOrNull: StakingOption? is StakingAvailability.Full -> option StakingAvailability.Unavailable, StakingAvailability.TemporaryUnavailable, + StakingAvailability.RegionUnavailable, -> null } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt b/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt index 8da36f24fb..545fea00cf 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt @@ -7,4 +7,6 @@ interface StakingFeatureToggles { fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean fun isSolanaUnstakeValidationEnabled(): Boolean + + fun isRegionUnavailableHandlingEnabled(): Boolean } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt index 0e1e27ace2..3e2dd30cc6 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt @@ -182,6 +182,7 @@ internal open class BaseActionsFactory( ) is StakingAvailability.Full -> null StakingAvailability.Unavailable -> null + StakingAvailability.RegionUnavailable -> null } } diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactoryTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactoryTest.kt index ba34737ad5..7994a49813 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactoryTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactoryTest.kt @@ -95,14 +95,30 @@ internal class CommonActionsFactoryTest { 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()).isEmpty() + } + private suspend fun createBuyAction(): ActionState.Buy { - val actions = factory.create( + val actions = createActions(stakingAvailability = StakingAvailability.Unavailable) + return actions.filterIsInstance().single() + } + + private suspend fun createActions(stakingAvailability: StakingAvailability): Set { + return factory.create( userWallet = userWallet, cryptoCurrencyStatus = cryptoCurrencyStatus, - stakingAvailability = StakingAvailability.Unavailable, + stakingAvailability = stakingAvailability, yieldSupplyAvailability = YieldSupplyAvailability.Unavailable, shouldShowSwapStories = false, ) - return actions.filterIsInstance().single() } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index 6b28aba27f..0b5fd6272a 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -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.CloreMigrationBottomSheetComponent 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.features.commonfeatures.api.managefunds.ManageFundsComponent import com.tangem.features.marketing.api.MarketingBannerComponent @@ -237,6 +238,9 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( stateFlow = model.transferUiState, onDismiss = model.bottomSheetNavigation::dismiss, ) + is TokenDetailsBottomSheetConfig.RegionUnavailable -> StakingRegionUnavailableBottomSheetComponent( + onDismiss = model.bottomSheetNavigation::dismiss, + ) } @AssistedFactory diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index fff85000be..81e52f101e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt @@ -78,6 +78,8 @@ interface TokenDetailsClickIntents { fun onYieldInfoClick() + fun onStakingRegionUnavailableClick() + // region Clore migration // 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 onStakingRegionUnavailableClick() { /* no op */ } + override fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) { /* no op */ } override fun onCopyAddress(): TextReference? { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 89f796e859..e4a4198826 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -72,6 +72,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.offramp.GetOfframpUrlUseCase import com.tangem.domain.onramp.CheckOnrampAvailabilityUseCase import com.tangem.domain.onramp.model.OnrampSource +import com.tangem.domain.staking.FetchStakingOptionsUseCase import com.tangem.domain.staking.GetStakingAvailabilityUseCase import com.tangem.domain.staking.GetStakingEntryInfoUseCase import com.tangem.domain.staking.model.StakingAvailability @@ -143,6 +144,7 @@ internal class TokenDetailsModel @Inject constructor( private val getExtendedPublicKeyForCurrencyUseCase: GetExtendedPublicKeyForCurrencyUseCase, private val getStakingEntryInfoUseCase: GetStakingEntryInfoUseCase, private val getStakingAvailabilityUseCase: GetStakingAvailabilityUseCase, + private val fetchStakingOptionsUseCase: FetchStakingOptionsUseCase, private val networkHasDerivationUseCase: NetworkHasDerivationUseCase, private val isDemoCardUseCase: IsDemoCardUseCase, private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase, @@ -1037,6 +1039,7 @@ internal class TokenDetailsModel @Inject constructor( updateTxHistory() expressTransactionsEventListener.send(ExpressTransactionsEvent.Update) }, + async { fetchStakingOptionsUseCase() }, ).awaitAll() uiState.value = stateFactory.getRefreshedState() 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 { if (unavailabilityReason == ScenarioUnavailabilityReason.None) return false diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt index 031ba31949..cce5cbb7d6 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt @@ -40,4 +40,7 @@ sealed class TokenDetailsBottomSheetConfig : Route { @Serializable data object Transfer : TokenDetailsBottomSheetConfig() + + @Serializable + data object RegionUnavailable : TokenDetailsBottomSheetConfig() } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt index 5c6019e55d..2fabb7fc2f 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt @@ -53,6 +53,7 @@ internal class TokenDetailsStakingInfoConverter( ) return when (stakingAvailability) { StakingAvailability.TemporaryUnavailable -> StakingBlockUM.TemporaryUnavailable + StakingAvailability.RegionUnavailable -> StakingBlockUM.TemporaryUnavailable StakingAvailability.Unavailable -> null is StakingAvailability.Full -> getStakedBlockOrNull(status) is StakingAvailability.Available -> getStakingInfoBlock( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt index 0dd4747ebd..641cf69ce4 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt @@ -44,6 +44,7 @@ internal class UpdateStakingNotificationTransformer( private fun buildEarnBlock(isBalanceHidden: Boolean): EarnBlockUM? { return when (val availability = stakingAvailability) { StakingAvailability.TemporaryUnavailable -> buildTemporaryUnavailable() + StakingAvailability.RegionUnavailable -> buildRegionUnavailableOrNull() StakingAvailability.Unavailable -> null is StakingAvailability.Full -> buildActiveBlockOrNull(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( availability: StakingAvailability.Available, isBalanceHidden: Boolean, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/StakingRegionUnavailableBottomSheetComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/StakingRegionUnavailableBottomSheetComponent.kt new file mode 100644 index 0000000000..c08d52388f --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/StakingRegionUnavailableBottomSheetComponent.kt @@ -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) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerRegionTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerRegionTest.kt new file mode 100644 index 0000000000..05d667d891 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerRegionTest.kt @@ -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(relaxed = true) { + every { rawId } returns networkRawId + every { isTestnet } returns false + } + val currency = mockk(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(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(relaxed = true) { + every { rawId } returns "ethereum" + every { isTestnet } returns false + } + val currency = mockk(relaxed = true) { + every { symbol } returns "ETH" + every { decimals } returns 18 + every { this@mockk.network } returns network + every { id.isCoin } returns true + } + val stakingBalance = mockk(relaxed = true) { + every { totalStaked } returns stakedAmount + every { unstakingAmount } returns BigDecimal.ZERO + every { withdrawableAmount } returns BigDecimal.ZERO + every { totalRewards } returns BigDecimal.ZERO + } + val value = mockk(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(relaxed = true), + notifications = persistentListOf(), + earnBlockState = null, + marketPriceBlockState = mockk(relaxed = true), + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + addFundsUM = AddFundsUM.Loading, + transferUM = TransferUM.Loading, + zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading, + ) +} \ No newline at end of file