Updated on 2026-08-14
This commit is contained in:
parent
882a605a7c
commit
fa91188e72
10 changed files with 253 additions and 5 deletions
|
|
@ -166,5 +166,9 @@
|
|||
{
|
||||
"name": "AND_14829_WARNINGS_REFACTORING_ENABLED",
|
||||
"version": "undefined"
|
||||
},
|
||||
{
|
||||
"name": "AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED",
|
||||
"version": "6.0"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,4 +5,6 @@ import com.tangem.domain.staking.model.StakingIntegrationID
|
|||
interface StakingFeatureToggles {
|
||||
|
||||
fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean
|
||||
|
||||
fun isSolanaUnstakeValidationEnabled(): Boolean
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue