Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 15:44:05 +02:00
parent 882a605a7c
commit fa91188e72
10 changed files with 253 additions and 5 deletions

View file

@ -166,5 +166,9 @@
{
"name": "AND_14829_WARNINGS_REFACTORING_ENABLED",
"version": "undefined"
},
{
"name": "AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED",
"version": "6.0"
}
]

View file

@ -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()

View file

@ -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)
}
}
}

View file

@ -5,4 +5,6 @@ import com.tangem.domain.staking.model.StakingIntegrationID
interface StakingFeatureToggles {
fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean
fun isSolanaUnstakeValidationEnabled(): Boolean
}

View file

@ -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(),
),
)
}

View file

@ -14,6 +14,7 @@ internal class AmountChangeStateTransformer(
private val minimumTransactionAmount: EnterAmountBoundary?,
private val value: String,
private val integration: StakingIntegration,
private val isSolanaUnstakeValidationEnabled: Boolean,
) : Transformer<StakingUiState> {
private val maxEnterAmountConverter = MaxEnterAmountConverter()
@ -43,6 +44,7 @@ internal class AmountChangeStateTransformer(
maxAmount = maxEnterAmount,
integration = integration,
actionType = prevState.actionType,
isSolanaUnstakeValidationEnabled = isSolanaUnstakeValidationEnabled,
).transform(updatedAmountState),
)
}

View file

@ -14,6 +14,7 @@ internal class AmountMaxValueStateTransformer(
private val minimumTransactionAmount: EnterAmountBoundary?,
private val actionType: StakingActionCommonType,
private val integration: StakingIntegration,
private val isSolanaUnstakeValidationEnabled: Boolean,
) : Transformer<StakingUiState> {
private val maxEnterAmountConverter = MaxEnterAmountConverter()
@ -40,6 +41,7 @@ internal class AmountMaxValueStateTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
integration = integration,
actionType = prevState.actionType,
isSolanaUnstakeValidationEnabled = isSolanaUnstakeValidationEnabled,
).transform(updatedAmountState),
)
}

View file

@ -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<AmountState> {
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,

View file

@ -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,
)
}

View file

@ -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()
}
}