From 259953954a0601dd6bf03281f5ef6222f5354930 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 3 Jun 2026 18:47:59 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../ui/tokens/TokenItemStateConverter.kt | 14 +- .../staking/DefaultP2PEthPoolRepository.kt | 4 +- ...ultP2PEthPoolRepositoryAvailabilityTest.kt | 120 ++++++++++++++++++ .../staking/model/P2PEthPoolIntegration.kt | 2 +- .../staking/model/StakingAvailability.kt | 18 ++- .../model/P2PEthPoolIntegrationTest.kt | 45 +++---- .../tokens/actions/BaseActionsFactory.kt | 1 + .../deeplink/DefaultStakingDeepLinkHandler.kt | 5 +- .../tokendetails/model/TokenDetailsModel.kt | 3 +- .../TokenDetailsStakingInfoConverter.kt | 34 +++++ .../UpdateStakingNotificationTransformer.kt | 22 ++++ ...pdateStakingNotificationTransformerTest.kt | 62 ++++++++- .../converter/EarnApyConverter.kt | 14 +- 13 files changed, 303 insertions(+), 41 deletions(-) create mode 100644 data/staking/src/test/kotlin/com/tangem/data/staking/DefaultP2PEthPoolRepositoryAvailabilityTest.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt index b350ff74b9..68aec719b3 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt @@ -25,6 +25,7 @@ import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingOption +import com.tangem.domain.staking.model.optionOrNull import com.tangem.domain.staking.model.common.RewardInfo import com.tangem.domain.staking.model.common.RewardType import com.tangem.lib.crypto.BlockchainUtils @@ -244,14 +245,21 @@ class TokenItemStateConverter( currencyStatus: CryptoCurrencyStatus, stakingApyMap: Map, ): StakingLocalInfo { - val stakingAvailability = stakingApyMap[currencyStatus.currency] as? StakingAvailability.Available + val availability = stakingApyMap[currencyStatus.currency] + val option = availability?.optionOrNull ?: return StakingLocalInfo(rate = null, isActive = false, rewardType = null) val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool + val isActive = stakeKitBalance != null || p2pEthPoolBalance != null - val rateInfo = when (val stakingOptions = stakingAvailability.option) { + // Full = no free capacity: show the badge only for tokens that already have a stake. + if (availability is StakingAvailability.Full && !isActive) { + return StakingLocalInfo(rate = null, isActive = false, rewardType = null) + } + + val rateInfo = when (val stakingOptions = option) { is StakingOption.P2PEthPool -> { RewardInfo( rate = stakingOptions.apy, @@ -282,7 +290,7 @@ class TokenItemStateConverter( return StakingLocalInfo( rate = rateInfo?.rate, - isActive = stakeKitBalance != null || p2pEthPoolBalance != null, + isActive = isActive, rewardType = rateInfo?.type, ) } 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 37502feb87..5bbadc6b84 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 @@ -201,7 +201,7 @@ internal class DefaultP2PEthPoolRepository( else -> { val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) if (integration.areAllTargetsFull) { - StakingAvailability.Unavailable + StakingAvailability.Full(StakingOption.P2PEthPool(vaults)) } else { StakingAvailability.Available(StakingOption.P2PEthPool(vaults)) } @@ -216,7 +216,7 @@ internal class DefaultP2PEthPoolRepository( val limits = getVaultLimitsSyncOrNull() ?: return StakingAvailability.TemporaryUnavailable val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) return if (integration.areAllTargetsFull) { - StakingAvailability.Unavailable + StakingAvailability.Full(StakingOption.P2PEthPool(vaults)) } else { StakingAvailability.Available(StakingOption.P2PEthPool(vaults)) } 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 new file mode 100644 index 0000000000..314965ca01 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/DefaultP2PEthPoolRepositoryAvailabilityTest.kt @@ -0,0 +1,120 @@ +package com.tangem.data.staking + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.ethpool.P2PEthPoolApi +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.token.P2PEthPoolVaultsStore +import com.tangem.datasource.local.token.P2PVaultLimitsStore +import com.tangem.domain.staking.model.StakingAvailability +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.coEvery +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.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultP2PEthPoolRepositoryAvailabilityTest { + + private val api = mockk(relaxed = true) + private val vaultsStore = mockk(relaxed = true) + private val limitsStore = mockk(relaxed = true) + private val tangemTechApi = mockk(relaxed = true) + private val featureToggles = mockk(relaxed = true) + + private val repository = DefaultP2PEthPoolRepository( + p2pEthPoolApi = api, + p2pEthPoolVaultsStore = vaultsStore, + p2pVaultLimitsStore = limitsStore, + tangemTechApi = tangemTechApi, + dispatchers = TestingCoroutineDispatcherProvider(), + stakingFeatureToggles = featureToggles, + ) + + private fun buildVault(address: String, totalAssets: String) = P2PEthPoolVault( + vaultAddress = address, + displayName = "Vault", + apy = BigDecimal("4.5"), + baseApy = BigDecimal("4.0"), + capacity = BigDecimal("1000"), + totalAssets = BigDecimal(totalAssets), + feePercent = BigDecimal("10"), + isPrivate = false, + isGenesis = false, + isSmoothingPool = true, + isErc20 = false, + tokenName = null, + tokenSymbol = null, + createdAt = 0L, + ) + + private fun limits(address: String, limit: String) = + mapOf(address.lowercase() to VaultLimitInfo(limit = BigDecimal(limit), coefficient = null)) + + @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 + + val result = repository.getStakingAvailability().first() + + assertThat(result).isInstanceOf(StakingAvailability.Full::class.java) + } + + @Test + 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 + + val result = repository.getStakingAvailability().first() + + assertThat(result).isInstanceOf(StakingAvailability.Available::class.java) + } + + @Test + fun `sync - all vaults full - returns Full with option`() = runTest { + coEvery { vaultsStore.getSync() } returns listOf(buildVault("0xABC", totalAssets = "999.95")) + coEvery { limitsStore.getSyncOrNull() } returns limits("0xABC", limit = "1000") // remaining 0.05 <= 0.1 + + val result = repository.getStakingAvailabilitySync() + + assertThat(result).isInstanceOf(StakingAvailability.Full::class.java) + } + + @Test + fun `sync - capacity available - returns Available`() = runTest { + coEvery { vaultsStore.getSync() } returns listOf(buildVault("0xABC", totalAssets = "100")) + coEvery { limitsStore.getSyncOrNull() } returns limits("0xABC", limit = "1000") // remaining 900 > 0.1 + + val result = repository.getStakingAvailabilitySync() + + assertThat(result).isInstanceOf(StakingAvailability.Available::class.java) + } + + @Test + fun `sync - empty vaults - returns TemporaryUnavailable`() = runTest { + coEvery { vaultsStore.getSync() } returns emptyList() + + val result = repository.getStakingAvailabilitySync() + + assertThat(result).isInstanceOf(StakingAvailability.TemporaryUnavailable::class.java) + } + + @Test + fun `sync - limits not loaded - returns TemporaryUnavailable`() = runTest { + coEvery { vaultsStore.getSync() } returns listOf(buildVault("0xABC", totalAssets = "100")) + coEvery { limitsStore.getSyncOrNull() } returns null + + val result = repository.getStakingAvailabilitySync() + + assertThat(result).isInstanceOf(StakingAvailability.TemporaryUnavailable::class.java) + } +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt index a1baf275df..77a4f09876 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt @@ -108,7 +108,7 @@ class P2PEthPoolIntegration( private const val MAX_AMOUNT_SCALE = 1 private val DEFAULT_MINIMUM_STAKE = BigDecimal("0.01") private val DEFAULT_MINIMUM_UNSTAKE = BigDecimal("0.01") - private val AVAILABILITY_THRESHOLD = BigDecimal("2") + private val AVAILABILITY_THRESHOLD = BigDecimal("0.1") private const val TERMS_OF_SERVICE_URL = "https://www.p2p.org/terms-of-use" private const val PRIVACY_POLICY_URL = "https://www.p2p.org/privacy-policy" 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 b8afc62152..6b0a5c1ffc 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 @@ -4,7 +4,23 @@ sealed class StakingAvailability { data class Available(val option: StakingOption) : StakingAvailability() + /** + * Integration exists and APY is known, but there is no free capacity (all vaults full). + * Existing stakes stay visible; new stakes are not offered. P2P ETH only. + */ + data class Full(val option: StakingOption) : StakingAvailability() + data object Unavailable : StakingAvailability() data object TemporaryUnavailable : StakingAvailability() -} \ No newline at end of file +} + +/** Staking option if the integration is known (Available or Full), else null. */ +val StakingAvailability.optionOrNull: StakingOption? + get() = when (this) { + is StakingAvailability.Available -> option + is StakingAvailability.Full -> option + StakingAvailability.Unavailable, + StakingAvailability.TemporaryUnavailable, + -> null + } \ No newline at end of file diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/model/P2PEthPoolIntegrationTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/model/P2PEthPoolIntegrationTest.kt index 2d32510e90..2bc2331aff 100644 --- a/domain/staking/src/test/kotlin/com/tangem/domain/staking/model/P2PEthPoolIntegrationTest.kt +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/model/P2PEthPoolIntegrationTest.kt @@ -67,24 +67,6 @@ internal class P2PEthPoolIntegrationTest { assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isNull() } - @Test - fun `vault with exactly 2 ETH remaining - not available, max is null`() { - val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "48")) - val limits = buildLimits("0xABC" to BigDecimal("50")) - val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) - - assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isNull() - } - - @Test - fun `vault with less than 2 ETH remaining - not available, max is null`() { - val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "48.5")) - val limits = buildLimits("0xABC" to BigDecimal("50")) - val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) - - assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isNull() - } - @Test fun `multiple available vaults - uses minimum remaining space`() { val vault1 = buildVault("0xA", capacity = "100", totalAssets = "10") @@ -99,27 +81,36 @@ internal class P2PEthPoolIntegrationTest { @Nested inner class Availability { @Test - fun `all vaults full - areAllTargetsFull is true`() { + fun `vault absent from limits - areAllTargetsFull is true`() { val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "48")) - val limits = buildLimits("0xABC" to BigDecimal("50")) + val limits = emptyMap() val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) assertThat(integration.areAllTargetsFull).isTrue() } @Test - fun `vault with remaining between 0_1 and 2 ETH - also considered full`() { - val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "48.5")) - val limits = buildLimits("0xABC" to BigDecimal("50")) + fun `vault with exactly 0_1 ETH remaining - not available`() { + val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "49.9")) + val limits = buildLimits("0xABC" to BigDecimal("50")) // remaining = 0.1 val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) assertThat(integration.areAllTargetsFull).isTrue() } + @Test + fun `vault with remaining just above 0_1 ETH - available (regression [REDACTED_TASK_KEY])`() { + val vaults = listOf(buildVault("0xABC", capacity = "400", totalAssets = "321.895202388313423922")) + val limits = buildLimits("0xABC" to BigDecimal("322.1")) // remaining ≈ 0.2048 > 0.1 + val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) + + assertThat(integration.areAllTargetsFull).isFalse() + } + @Test fun `at least one vault available - areAllTargetsFull is false`() { - val vault1 = buildVault("0xA", capacity = "100", totalAssets = "48.5") // full (1.5 remaining < 2) - val vault2 = buildVault("0xB", capacity = "100", totalAssets = "10") // available (40 remaining > 2) + val vault1 = buildVault("0xA", capacity = "100", totalAssets = "49.95") // full (0.05 remaining < 0.1) + val vault2 = buildVault("0xB", capacity = "100", totalAssets = "10") // available (40 remaining > 0.1) val limits = buildLimits("0xa" to BigDecimal("50"), "0xb" to BigDecimal("50")) val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, listOf(vault1, vault2), limits) @@ -128,8 +119,8 @@ internal class P2PEthPoolIntegrationTest { @Test fun `preferred targets only contains available vaults`() { - val vault1 = buildVault("0xA", capacity = "100", totalAssets = "48.5") // full - val vault2 = buildVault("0xB", capacity = "100", totalAssets = "10") // available + val vault1 = buildVault("0xA", capacity = "100", totalAssets = "49.95") // full (0.05 remaining ≤ 0.1) + val vault2 = buildVault("0xB", capacity = "100", totalAssets = "10") // available (40 remaining > 0.1) val limits = buildLimits("0xa" to BigDecimal("50"), "0xb" to BigDecimal("50")) val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, listOf(vault1, vault2), limits) 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 8e58cc711c..f023d7fea9 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 @@ -184,6 +184,7 @@ internal open class BaseActionsFactory( unavailabilityReason = ScenarioUnavailabilityReason.StakingUnavailable(currency.name), option = null, ) + is StakingAvailability.Full -> null StakingAvailability.Unavailable -> null } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt index 648df734f4..01f549c2f7 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt @@ -8,6 +8,7 @@ import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.GetStakingAvailabilityUseCase import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.optionOrNull import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase @@ -70,12 +71,12 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( return@launch } - val availability = getStakingAvailabilityUseCase.invokeSync( + val availability: StakingAvailability? = getStakingAvailabilityUseCase.invokeSync( userWalletId = selectedUserWalletId, cryptoCurrency = cryptoCurrency, ).getOrNull() - val option = (availability as? StakingAvailability.Available)?.option + val option = availability?.optionOrNull if (option == null) { TangemLogger.e("Staking is unavailable for ${cryptoCurrency.name}") return@launch 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 1ee997f1dc..2a2ba67098 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 @@ -68,6 +68,7 @@ import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.staking.GetStakingAvailabilityUseCase import com.tangem.domain.staking.GetStakingEntryInfoUseCase import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.optionOrNull import com.tangem.domain.tokens.* import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.TokenActionsState @@ -1148,7 +1149,7 @@ internal class TokenDetailsModel @Inject constructor( modelScope.launch { getStakingAvailabilityUseCase.invokeSync(userWalletId, cryptoCurrency) .onRight { availability -> - val option = (availability as? StakingAvailability.Available)?.option + val option = availability.optionOrNull if (option != null) { router.openStaking( userWalletId = userWalletId, 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 61415883f5..845d22c4e1 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 @@ -52,6 +52,7 @@ internal class TokenDetailsStakingInfoConverter( return when (stakingAvailability) { StakingAvailability.TemporaryUnavailable -> StakingBlockUM.TemporaryUnavailable StakingAvailability.Unavailable -> null + is StakingAvailability.Full -> getStakedBlockOrNull(status) is StakingAvailability.Available -> getStakingInfoBlock(status, state) } } @@ -107,6 +108,39 @@ internal class TokenDetailsStakingInfoConverter( } } + private fun getStakedBlockOrNull(status: CryptoCurrencyStatus): StakingBlockUM? { + val stakingBalance = status.value.stakingBalance as? StakingBalance.Data + val stakingCryptoAmount = stakingBalance?.getTotalStakingBalance(status.currency.network.rawId) + val hasPendingBalances = when (stakingBalance) { + is StakingBalance.Data.StakeKit -> stakingBalance.balance.items.isNotEmpty() + is StakingBalance.Data.P2PEthPool -> !stakingBalance.unstakingAmount.isNullOrZero() + null -> false + } + return when { + !stakingCryptoAmount.isNullOrZero() -> getStakedBlockWithFiatAmount( + status = status, + stakingAmount = stakingCryptoAmount, + rewardAmount = when (stakingBalance) { + is StakingBalance.Data.StakeKit -> stakingBalance.getRewardStakingBalance() + is StakingBalance.Data.P2PEthPool -> stakingBalance.totalRewards + else -> BigDecimal.ZERO + }, + ) + // Pending-only path: reachable for StakeKit (pending items are not part of the total); + // for P2PEthPool the unstaking amount is already included in getTotalStakingBalance above. + hasPendingBalances -> getStakedBlockWithFiatAmount( + status = status, + stakingAmount = when (stakingBalance) { + is StakingBalance.Data.StakeKit -> stakingBalance.balance.items.sumOf { it.amount } + is StakingBalance.Data.P2PEthPool -> stakingBalance.unstakingAmount + null -> BigDecimal.ZERO + }, + rewardAmount = null, + ) + else -> null + } + } + private fun isStakingButtonEnabled(status: CryptoCurrencyStatus): Boolean { return status.value is CryptoCurrencyStatus.Loaded || status.value is CryptoCurrencyStatus.NoQuote || 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 35258d56cd..2933e23648 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 @@ -50,6 +50,7 @@ internal class UpdateStakingNotificationTransformer( return when (val availability = stakingAvailability) { StakingAvailability.TemporaryUnavailable -> buildTemporaryUnavailable() StakingAvailability.Unavailable -> null + is StakingAvailability.Full -> buildActiveBlockOrNull(isBalanceHidden) is StakingAvailability.Available -> getStakingInfoBlock(availability, isBalanceHidden) } } @@ -106,6 +107,27 @@ internal class UpdateStakingNotificationTransformer( } } + private fun buildActiveBlockOrNull(isBalanceHidden: Boolean): EarnBlockUM? { + val status = cryptoCurrencyStatus + val stakingBalance = status.value.stakingBalance as? StakingBalance.Data + val stakingCryptoAmount = stakingBalance?.getTotalStakingBalance(status.currency.network.rawId) + return when { + !stakingCryptoAmount.isNullOrZero() -> buildActiveBlock( + stakingAmount = stakingCryptoAmount, + rewardAmount = stakingBalance.getRewardAmount(), + isBalanceHidden = isBalanceHidden, + ) + // Pending-only path: reachable for StakeKit (pending items are not part of the total); + // for P2PEthPool the unstaking amount is already included in getTotalStakingBalance above. + stakingBalance.hasPendingBalances() -> buildActiveBlock( + stakingAmount = stakingBalance.getPendingAmount(), + rewardAmount = null, + isBalanceHidden = isBalanceHidden, + ) + else -> null + } + } + private fun isStakingButtonEnabled(status: CryptoCurrencyStatus): Boolean { return status.value is CryptoCurrencyStatus.Loaded || status.value is CryptoCurrencyStatus.NoQuote || diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt index 31b1d632f2..e994349df1 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt @@ -84,11 +84,39 @@ class UpdateStakingNotificationTransformerTest { assertThat(content.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java) } + @Test + fun `GIVEN Full AND no active stake WHEN transform THEN earnBlockState is null`() { + val transformer = createTransformer( + availability = fullOption(BigDecimal("4.2")), + entryInfo = null, + ) + + val result = transformer.transform(initialState()) + + assertThat(result.earnBlockState).isNull() + } + + @Test + fun `GIVEN Full AND active stake WHEN transform THEN active balance block`() { + val transformer = createTransformer( + availability = fullOption(BigDecimal("4.2")), + entryInfo = null, + status = buildStatusWithStake(stakedAmount = BigDecimal("5")), + ) + + val result = transformer.transform(initialState()) + + assertThat(result.earnBlockState).isInstanceOf(EarnBlockUM.Content::class.java) + val content = result.earnBlockState as EarnBlockUM.Content + assertThat(content.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Balance::class.java) + } + private fun createTransformer( availability: StakingAvailability, entryInfo: StakingEntryInfo?, + status: CryptoCurrencyStatus = buildStatus(), ) = UpdateStakingNotificationTransformer( - cryptoCurrencyStatus = buildStatus(), + cryptoCurrencyStatus = status, stakingAvailability = availability, stakingEntryInfo = entryInfo, appCurrency = AppCurrency.Default, @@ -122,6 +150,38 @@ class UpdateStakingNotificationTransformerTest { return StakingAvailability.Available(option = option) } + private fun fullOption(apy: BigDecimal): StakingAvailability.Full { + val option = mockk(relaxed = true) { + every { this@mockk.apy } returns apy + } + return StakingAvailability.Full(option = option) + } + + private fun buildStatusWithStake(stakedAmount: BigDecimal): CryptoCurrencyStatus { + val network = mockk(relaxed = true) { + every { rawId } returns "solana" + every { isTestnet } returns false + } + val currency = mockk(relaxed = true) { + every { symbol } returns "SOL" + every { decimals } returns 9 + 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 = "Solana"), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/EarnApyConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/EarnApyConverter.kt index 050519fc12..c7826b72c9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/EarnApyConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/EarnApyConverter.kt @@ -12,6 +12,7 @@ import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingOption +import com.tangem.domain.staking.model.optionOrNull import com.tangem.domain.staking.model.common.RewardInfo import com.tangem.domain.staking.model.common.RewardType import com.tangem.lib.crypto.BlockchainUtils @@ -77,14 +78,21 @@ internal class EarnApyConverter( currencyStatus: CryptoCurrencyStatus, stakingApyMap: Map, ): StakingLocalInfo { - val stakingAvailability = stakingApyMap[currencyStatus.currency] as? StakingAvailability.Available + val availability = stakingApyMap[currencyStatus.currency] + val option = availability?.optionOrNull ?: return StakingLocalInfo(rate = null, isActive = false, rewardType = null) val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool + val isActive = stakeKitBalance != null || p2pEthPoolBalance != null - val rateInfo = when (val stakingOptions = stakingAvailability.option) { + // Full = no free capacity: show the badge only for tokens that already have a stake. + if (availability is StakingAvailability.Full && !isActive) { + return StakingLocalInfo(rate = null, isActive = false, rewardType = null) + } + + val rateInfo = when (val stakingOptions = option) { is StakingOption.P2PEthPool -> { RewardInfo( rate = stakingOptions.apy, @@ -115,7 +123,7 @@ internal class EarnApyConverter( return StakingLocalInfo( rate = rateInfo?.rate, - isActive = stakeKitBalance != null || p2pEthPoolBalance != null, + isActive = isActive, rewardType = rateInfo?.type, ) }