From fa91188e722c6b683678cbea98aedbeaa8e4d364 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 15:44:05 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 4 + .../toggles/DefaultStakingFeatureToggles.kt | 6 + .../DefaultStakingFeatureTogglesTest.kt | 26 +++ .../staking/toggles/StakingFeatureToggles.kt | 2 + .../impl/presentation/model/StakingModel.kt | 7 + .../amount/AmountChangeStateTransformer.kt | 2 + .../amount/AmountMaxValueStateTransformer.kt | 2 + .../AmountRequirementStateTransformer.kt | 41 ++++- .../model/StakingModelTestBase.kt | 5 + .../AmountRequirementStateTransformerTest.kt | 163 ++++++++++++++++++ 10 files changed, 253 insertions(+), 5 deletions(-) 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 dec9126e3d..b1ff87ea9d 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 @@ -166,5 +166,9 @@ { "name": "AND_14829_WARNINGS_REFACTORING_ENABLED", "version": "undefined" + }, + { + "name": "AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED", + "version": "6.0" } ] 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 6f62c8bc06..bc6f6e44a5 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 @@ -14,6 +14,12 @@ internal class DefaultStakingFeatureToggles( return featureTogglesManager.isFeatureEnabled(toggle) } + override fun isSolanaUnstakeValidationEnabled(): Boolean { + return featureTogglesManager.isFeatureEnabled( + FeatureToggles.AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED, + ) + } + private fun StakingIntegrationID.getFeatureToggle(): FeatureToggles? = when (this) { is StakingIntegrationID.P2PEthPool -> FeatureToggles.STAKING_ETH_ENABLED is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle() 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 ca6766c8c8..73586fe538 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 @@ -58,4 +58,30 @@ internal class DefaultStakingFeatureTogglesTest { verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) } } + + @Test + fun `isSolanaUnstakeValidationEnabled returns true when toggle enabled`() { + every { + featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED) + } returns true + + assertThat(toggles.isSolanaUnstakeValidationEnabled()).isTrue() + + verify(exactly = 1) { + featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED) + } + } + + @Test + fun `isSolanaUnstakeValidationEnabled returns false when toggle disabled`() { + every { + featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED) + } returns false + + assertThat(toggles.isSolanaUnstakeValidationEnabled()).isFalse() + + verify(exactly = 1) { + featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED) + } + } } \ 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 80761562fc..8da36f24fb 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 @@ -5,4 +5,6 @@ import com.tangem.domain.staking.model.StakingIntegrationID interface StakingFeatureToggles { fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean + + fun isSolanaUnstakeValidationEnabled(): Boolean } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index e808f05372..e03f0537dc 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -60,6 +60,7 @@ import com.tangem.domain.staking.model.stakekit.action.StakingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.tokens.* import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.* @@ -157,6 +158,7 @@ internal class StakingModel @Inject constructor( private val innerRouter: InnerStakingRouter, private val messageSender: UiMessageSender, private val giveApprovalFeatureToggles: GiveApprovalFeatureToggles, + private val stakingFeatureToggles: StakingFeatureToggles, appRouter: AppRouter, ) : Model(), StakingClickIntents { @@ -382,6 +384,8 @@ internal class StakingModel @Inject constructor( minimumTransactionAmount = minimumTransactionAmount, actionType = uiState.value.actionType, integration = integration, + isSolanaUnstakeValidationEnabled = stakingFeatureToggles + .isSolanaUnstakeValidationEnabled(), ) addAll( @@ -644,6 +648,7 @@ internal class StakingModel @Inject constructor( minimumTransactionAmount = minimumTransactionAmount, value = value, integration = integration, + isSolanaUnstakeValidationEnabled = stakingFeatureToggles.isSolanaUnstakeValidationEnabled(), ), ) checkSumLimitExceeded() @@ -685,6 +690,7 @@ internal class StakingModel @Inject constructor( minimumTransactionAmount = minimumTransactionAmount, actionType = uiState.value.actionType, integration = integration, + isSolanaUnstakeValidationEnabled = stakingFeatureToggles.isSolanaUnstakeValidationEnabled(), ), ) checkSumLimitExceeded() @@ -1472,6 +1478,7 @@ internal class StakingModel @Inject constructor( value = amountValue, minimumTransactionAmount = minimumTransactionAmount, integration = integration, + isSolanaUnstakeValidationEnabled = stakingFeatureToggles.isSolanaUnstakeValidationEnabled(), ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt index 9c9c0d1595..7dd219e1b3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt @@ -14,6 +14,7 @@ internal class AmountChangeStateTransformer( private val minimumTransactionAmount: EnterAmountBoundary?, private val value: String, private val integration: StakingIntegration, + private val isSolanaUnstakeValidationEnabled: Boolean, ) : Transformer { private val maxEnterAmountConverter = MaxEnterAmountConverter() @@ -43,6 +44,7 @@ internal class AmountChangeStateTransformer( maxAmount = maxEnterAmount, integration = integration, actionType = prevState.actionType, + isSolanaUnstakeValidationEnabled = isSolanaUnstakeValidationEnabled, ).transform(updatedAmountState), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt index 94476c6be9..0e250e8b3c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt @@ -14,6 +14,7 @@ internal class AmountMaxValueStateTransformer( private val minimumTransactionAmount: EnterAmountBoundary?, private val actionType: StakingActionCommonType, private val integration: StakingIntegration, + private val isSolanaUnstakeValidationEnabled: Boolean, ) : Transformer { private val maxEnterAmountConverter = MaxEnterAmountConverter() @@ -40,6 +41,7 @@ internal class AmountMaxValueStateTransformer( cryptoCurrencyStatus = cryptoCurrencyStatus, integration = integration, actionType = prevState.actionType, + isSolanaUnstakeValidationEnabled = isSolanaUnstakeValidationEnabled, ).transform(updatedAmountState), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt index 05a795e7cf..8874243fb5 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt @@ -16,6 +16,7 @@ import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.common.StakingAmountRequirement import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.R +import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.lib.crypto.BlockchainUtils.isTron import com.tangem.utils.extensions.isPositive import com.tangem.utils.isNullOrZero @@ -28,6 +29,7 @@ internal class AmountRequirementStateTransformer( private val maxAmount: EnterAmountBoundary, private val integration: StakingIntegration, private val actionType: StakingActionCommonType, + private val isSolanaUnstakeValidationEnabled: Boolean = false, ) : Transformer { override fun transform(prevState: AmountState): AmountState { return if (prevState is AmountState.Data) { @@ -103,11 +105,17 @@ internal class AmountRequirementStateTransformer( ) } is StakingActionCommonType.Exit -> { - integration.exitArgs?.amountRequirement?.getError( - amount = amountDecimal, - minErrorRes = R.string.staking_unstake_amount_requirement_error, - maxErrorRes = R.string.staking_max_amount_requirement_error, - ) + if (isSolanaUnstakeValidationEnabled && + isSolana(cryptoCurrencyStatus.currency.network.rawId) + ) { + getSolanaUnstakeError(amount = amountDecimal, staked = maxAmount.amount) + } else { + integration.exitArgs?.amountRequirement?.getError( + amount = amountDecimal, + minErrorRes = R.string.staking_unstake_amount_requirement_error, + maxErrorRes = R.string.staking_max_amount_requirement_error, + ) + } } else -> null } @@ -124,6 +132,29 @@ internal class AmountRequirementStateTransformer( return isEnterOrExit && isTron && !isIntegerOnly } + private fun getSolanaUnstakeError(amount: BigDecimal, staked: BigDecimal?): TextReference? { + val minimum = integration.exitMinimumAmount?.takeIf { it.isPositive() } + ?: integration.enterMinimumAmount + if (minimum == null || staked == null) return null + + // Full unstake is always allowed regardless of minimum delegation. + if (amount.compareTo(staked) == 0) return null + + if (amount < minimum) { + val formatted = minimum.format { crypto(cryptoCurrencyStatus.currency) } + return resourceReference( + R.string.staking_unstake_amount_requirement_error, + wrappedList(formatted), + ) + } + + if (staked - amount < minimum) { + return resourceReference(R.string.staking_notification_low_staked_balance_text) + } + + return null + } + private fun StakingAmountRequirement.getError( amount: BigDecimal, @StringRes minErrorRes: Int, diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt index dccea3b0b6..e5e657a1fe 100644 --- a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt @@ -29,6 +29,7 @@ import com.tangem.domain.staking.* import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.tokens.* import com.tangem.domain.transaction.usecase.* import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -115,6 +116,9 @@ internal abstract class StakingModelTestBase { protected val innerRouter: InnerStakingRouter = mockk() protected val messageSender: UiMessageSender = mockk() protected val giveApprovalFeatureToggles: GiveApprovalFeatureToggles = mockk() + protected val stakingFeatureToggles: StakingFeatureToggles = mockk { + every { isSolanaUnstakeValidationEnabled() } returns false + } @BeforeEach fun setUp() { @@ -208,6 +212,7 @@ internal abstract class StakingModelTestBase { innerRouter = innerRouter, messageSender = messageSender, giveApprovalFeatureToggles = giveApprovalFeatureToggles, + stakingFeatureToggles = stakingFeatureToggles, appRouter = appRouter, ) } diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt index b470fd8de2..10b2ec7971 100644 --- a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt @@ -70,6 +70,30 @@ internal class AmountRequirementStateTransformerTest { ) } + private fun solanaCryptoStatus(): CryptoCurrencyStatus = mockk(relaxed = true) { + every { currency.network.rawId } returns "solana" + } + + private fun solanaExitIntegration(exitMin: BigDecimal?, enterMin: BigDecimal? = null): StakingIntegration = + mockk { + every { exitMinimumAmount } returns exitMin + every { enterMinimumAmount } returns enterMin + every { exitArgs } returns null + } + + private fun solanaTransformer( + staked: BigDecimal, + exitMin: BigDecimal?, + enterMin: BigDecimal? = null, + enabled: Boolean = true, + ) = AmountRequirementStateTransformer( + cryptoCurrencyStatus = solanaCryptoStatus(), + maxAmount = EnterAmountBoundary(amount = staked, fiatAmount = null, fiatRate = null), + integration = solanaExitIntegration(exitMin = exitMin, enterMin = enterMin), + actionType = StakingActionCommonType.Exit(partiallyUnstakeDisabled = false), + isSolanaUnstakeValidationEnabled = enabled, + ) + @Test fun `WHEN amount exceeds positive maximum THEN max amount error string is used`() { val transformer = AmountRequirementStateTransformer( @@ -149,4 +173,143 @@ internal class AmountRequirementStateTransformerTest { assertThat((result.amountTextField.error as TextReference.Res).id) .isEqualTo(R.string.staking_max_amount_requirement_error) } + + @Test + fun `WHEN Solana full unstake THEN no error`() { + val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(BigDecimal("5"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isFalse() + assertThat(result.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `WHEN Solana full unstake of small stake below minimum THEN no error`() { + val small = BigDecimal("0.098090754") + val transformer = solanaTransformer(staked = small, exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(small)) as AmountState.Data + + assertThat(result.amountTextField.isError).isFalse() + assertThat(result.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `WHEN Solana partial unstake below minimum THEN unstake min error and button disabled`() { + val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat(result.isPrimaryButtonEnabled).isFalse() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_unstake_amount_requirement_error) + } + + @Test + fun `WHEN Solana partial unstake leaving remainder below minimum THEN low staked balance error and button disabled`() { + val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(BigDecimal("4.5"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat(result.isPrimaryButtonEnabled).isFalse() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_notification_low_staked_balance_text) + } + + @Test + fun `WHEN Solana partial unstake violating both minimums THEN unstake min error takes priority`() { + val transformer = solanaTransformer(staked = BigDecimal("1.5"), exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(BigDecimal("0.7"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_unstake_amount_requirement_error) + } + + @Test + fun `WHEN Solana partial unstake with both parts above minimum THEN no error`() { + val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(BigDecimal("1.5"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isFalse() + assertThat(result.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `WHEN Solana amount exactly at minimum THEN no error`() { + val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(BigDecimal("1"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isFalse() + assertThat(result.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `WHEN Solana remainder exactly at minimum THEN no error`() { + val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1")) + + val result = transformer.transform(amountState(BigDecimal("4"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isFalse() + } + + @Test + fun `WHEN Solana exit minimum is zero THEN falls back to enter minimum`() { + val transformer = solanaTransformer( + staked = BigDecimal("5"), + exitMin = BigDecimal.ZERO, + enterMin = BigDecimal("1"), + ) + + val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_unstake_amount_requirement_error) + } + + @Test + fun `WHEN Solana both minimums null THEN partial unstake allowed`() { + val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = null, enterMin = null) + + val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isFalse() + assertThat(result.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `WHEN Solana validation disabled THEN partial unstake below minimum allowed`() { + val transformer = solanaTransformer( + staked = BigDecimal("5"), + exitMin = BigDecimal("1"), + enabled = false, + ) + + val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isFalse() + } + + @Test + fun `WHEN validation enabled but currency is not Solana THEN Solana rule does not apply`() { + val transformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, // relaxed mock: network.rawId is not "solana" + maxAmount = EnterAmountBoundary(amount = BigDecimal("5"), fiatAmount = null, fiatRate = null), + integration = exitIntegrationWith(minimum = null, maximum = null), + actionType = StakingActionCommonType.Exit(partiallyUnstakeDisabled = false), + isSolanaUnstakeValidationEnabled = true, + ) + + val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data + + // Non-Solana: falls through to legacy exitArgs path (minimum null → no error), NOT the Solana remainder rule. + assertThat(result.amountTextField.isError).isFalse() + } } \ No newline at end of file