Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-17 15:22:49 +02:00
parent a236e11593
commit 1e08fd51ee
4 changed files with 202 additions and 4 deletions

View file

@ -76,6 +76,7 @@ import com.tangem.features.staking.impl.presentation.state.*
import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType
import com.tangem.features.staking.impl.presentation.state.events.StakingAlertUM import com.tangem.features.staking.impl.presentation.state.events.StakingAlertUM
import com.tangem.features.staking.impl.presentation.state.events.StakingEventFactory import com.tangem.features.staking.impl.presentation.state.events.StakingEventFactory
import com.tangem.features.staking.impl.presentation.state.helpers.GetEffectiveStakingFee
import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater
import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeLoader import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeLoader
import com.tangem.features.staking.impl.presentation.state.helpers.StakingOperationsFactory import com.tangem.features.staking.impl.presentation.state.helpers.StakingOperationsFactory
@ -131,6 +132,7 @@ internal class StakingModel @Inject constructor(
private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase,
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase,
private val getEffectiveStakingFee: GetEffectiveStakingFee,
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase, private val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase,
private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase, private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase,
@ -945,9 +947,16 @@ internal class StakingModel @Inject constructor(
null null
} }
val effectiveFee = getEffectiveStakingFee(
stakeKitFee = fee,
amount = amount,
userWallet = userWallet,
feeCurrencyStatus = feeStatus,
)
val balanceAfterTransaction = calculateBalanceAfterTransaction( val balanceAfterTransaction = calculateBalanceAfterTransaction(
amount = amount.orZero(), amount = amount.orZero(),
fee = fee.orZero(), fee = effectiveFee.orZero(),
reduceAmountBy = confirmationState?.reduceAmountBy.orZero(), reduceAmountBy = confirmationState?.reduceAmountBy.orZero(),
actionType = value.actionType, actionType = value.actionType,
) )
@ -956,9 +965,12 @@ internal class StakingModel @Inject constructor(
currencyStatus = cryptoCurrencyStatus, currencyStatus = cryptoCurrencyStatus,
feeCurrencyStatus = feeCryptoCurrencyStatus, feeCurrencyStatus = feeCryptoCurrencyStatus,
amount = amount, amount = amount,
fee = fee, fee = effectiveFee,
feeCurrencyBalanceAfterTransaction = balanceAfterTransaction, feeCurrencyBalanceAfterTransaction = balanceAfterTransaction,
) )
val hasRentWarning = currencyStatus.rentWarning != null
stateController.update( stateController.update(
AddStakingNotificationsTransformer( AddStakingNotificationsTransformer(
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
@ -968,8 +980,8 @@ internal class StakingModel @Inject constructor(
currencyWarning = currencyWarning, currencyWarning = currencyWarning,
currencyCheck = currencyStatus, currencyCheck = currencyStatus,
isSubtractAvailable = isAmountSubtractAvailable, isSubtractAvailable = isAmountSubtractAvailable,
feeError = feeError, feeError = if (hasRentWarning) null else feeError,
stakingError = stakingError, stakingError = if (hasRentWarning) null else stakingError,
integration = integration, integration = integration,
), ),
) )

View file

@ -0,0 +1,41 @@
package com.tangem.features.staking.impl.presentation.state.helpers
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.extensions.orZero
import java.math.BigDecimal
import javax.inject.Inject
/**
* Returns the fee used by the rent-exemption check ([REDACTED_TASK_KEY]).
*
* When StakeKit provides a fee, returns it as-is. When the StakeKit fee is unavailable (`null`)
* which happens on Solana when the post-transaction balance would drop below the rent-exempt minimum
* and StakeKit fails its fee calculation falls back to a client-side Solana fee estimate so the
* rent-exemption check can still run independently of StakeKit. Returns `null` for non-Solana
* networks or when estimation fails (in those cases the rent check stays inert, preserving previous
* behavior).
*/
internal class GetEffectiveStakingFee @Inject constructor(
private val estimateFeeUseCase: EstimateFeeUseCase,
) {
suspend operator fun invoke(
stakeKitFee: BigDecimal?,
amount: BigDecimal?,
userWallet: UserWallet,
feeCurrencyStatus: CryptoCurrencyStatus?,
): BigDecimal? {
if (stakeKitFee != null) return stakeKitFee
if (feeCurrencyStatus == null) return null
if (!BlockchainUtils.isSolana(feeCurrencyStatus.currency.network.rawId)) return null
return estimateFeeUseCase(
amount = amount.orZero(),
userWallet = userWallet,
cryptoCurrencyStatus = feeCurrencyStatus,
).getOrNull()?.normal?.amount?.value
}
}

View file

@ -38,6 +38,7 @@ import com.tangem.features.staking.impl.navigation.InnerStakingRouter
import com.tangem.features.staking.impl.presentation.state.StakingStateController import com.tangem.features.staking.impl.presentation.state.StakingStateController
import com.tangem.features.staking.impl.presentation.state.StakingStep import com.tangem.features.staking.impl.presentation.state.StakingStep
import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.features.staking.impl.presentation.state.helpers.GetEffectiveStakingFee
import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater
import com.tangem.features.staking.impl.presentation.state.helpers.StakingOperationsFactory import com.tangem.features.staking.impl.presentation.state.helpers.StakingOperationsFactory
import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.AppCoroutineScope
@ -92,6 +93,7 @@ internal abstract class StakingModelTestBase {
protected val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk() protected val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk()
protected val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk() protected val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk()
protected val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk() protected val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk()
protected val getEffectiveStakingFee: GetEffectiveStakingFee = mockk()
protected val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk() protected val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk()
protected val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase = mockk() protected val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase = mockk()
private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase = mockk() private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase = mockk()
@ -145,6 +147,9 @@ internal abstract class StakingModelTestBase {
coEvery { coEvery {
isAmountSubtractAvailableUseCase(testUserWalletId, any()) isAmountSubtractAvailableUseCase(testUserWalletId, any())
} returns Either.Right(false) } returns Either.Right(false)
coEvery {
getEffectiveStakingFee(any(), any(), any(), any())
} answers { firstArg() }
every { getActionsUseCase(testUserWalletId, any()) } returns emptyFlow() every { getActionsUseCase(testUserWalletId, any()) } returns emptyFlow()
every { getBalanceHidingSettingsUseCase() } returns emptyFlow() every { getBalanceHidingSettingsUseCase() } returns emptyFlow()
mockBalanceUpdater = mockk { mockBalanceUpdater = mockk {
@ -178,6 +183,7 @@ internal abstract class StakingModelTestBase {
saveBlockchainErrorUseCase = saveBlockchainErrorUseCase, saveBlockchainErrorUseCase = saveBlockchainErrorUseCase,
getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase, getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase,
getCurrencyCheckUseCase = getCurrencyCheckUseCase, getCurrencyCheckUseCase = getCurrencyCheckUseCase,
getEffectiveStakingFee = getEffectiveStakingFee,
isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase, isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase,
isAnyTokenStakedUseCase = isAnyTokenStakedUseCase, isAnyTokenStakedUseCase = isAnyTokenStakedUseCase,
invalidatePendingTransactionsUseCase = invalidatePendingTransactionsUseCase, invalidatePendingTransactionsUseCase = invalidatePendingTransactionsUseCase,

View file

@ -0,0 +1,139 @@
package com.tangem.features.staking.impl.presentation.state.helpers
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class GetEffectiveStakingFeeTest {
private val estimateFeeUseCase: EstimateFeeUseCase = mockk()
private val userWallet: UserWallet = mockk(relaxed = true)
private val getEffectiveStakingFee = GetEffectiveStakingFee(estimateFeeUseCase)
private fun feeStatus(rawId: String): CryptoCurrencyStatus = mockk {
every { currency.network.rawId } returns rawId
}
@Test
fun `GIVEN stakekit fee present WHEN invoke THEN returns it without estimating`() = runTest {
// Act
val result = getEffectiveStakingFee(
stakeKitFee = BigDecimal("0.5"),
amount = BigDecimal.ONE,
userWallet = userWallet,
feeCurrencyStatus = feeStatus("solana"),
)
// Assert
assertThat(result).isEqualTo(BigDecimal("0.5"))
coVerify(exactly = 0) { estimateFeeUseCase(any(), any(), any()) }
}
@Test
fun `GIVEN null stakekit fee AND solana WHEN invoke THEN returns client estimate`() = runTest {
// Arrange
val feeCurrencyStatus = feeStatus("solana")
val estimated = BigDecimal("0.000005")
val txFee: TransactionFee = mockk {
every { normal.amount.value } returns estimated
}
coEvery { estimateFeeUseCase(any(), userWallet, feeCurrencyStatus) } returns txFee.right()
// Act
val result = getEffectiveStakingFee(
stakeKitFee = null,
amount = BigDecimal.ZERO,
userWallet = userWallet,
feeCurrencyStatus = feeCurrencyStatus,
)
// Assert
assertThat(result).isEqualTo(estimated)
coVerify(exactly = 1) { estimateFeeUseCase(BigDecimal.ZERO, userWallet, feeCurrencyStatus) }
}
@Test
fun `GIVEN null stakekit fee AND non-solana WHEN invoke THEN returns null without estimating`() = runTest {
// Act
val result = getEffectiveStakingFee(
stakeKitFee = null,
amount = BigDecimal.ZERO,
userWallet = userWallet,
feeCurrencyStatus = feeStatus("ethereum"),
)
// Assert
assertThat(result).isNull()
coVerify(exactly = 0) { estimateFeeUseCase(any(), any(), any()) }
}
@Test
fun `GIVEN null stakekit fee AND solana AND estimation fails WHEN invoke THEN returns null`() = runTest {
// Arrange
val feeCurrencyStatus = feeStatus("solana")
coEvery { estimateFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left()
// Act
val result = getEffectiveStakingFee(
stakeKitFee = null,
amount = BigDecimal.ZERO,
userWallet = userWallet,
feeCurrencyStatus = feeCurrencyStatus,
)
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN null fee currency status WHEN invoke THEN returns null`() = runTest {
// Act
val result = getEffectiveStakingFee(
stakeKitFee = null,
amount = BigDecimal.ZERO,
userWallet = userWallet,
feeCurrencyStatus = null,
)
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN null stakekit fee AND null amount AND solana WHEN invoke THEN estimates with zero amount`() = runTest {
// Arrange
val feeCurrencyStatus = feeStatus("solana")
val estimated = BigDecimal("0.000005")
val txFee: TransactionFee = mockk {
every { normal.amount.value } returns estimated
}
coEvery {
estimateFeeUseCase(BigDecimal.ZERO, userWallet, feeCurrencyStatus)
} returns txFee.right()
// Act
val result = getEffectiveStakingFee(
stakeKitFee = null,
amount = null,
userWallet = userWallet,
feeCurrencyStatus = feeCurrencyStatus,
)
// Assert
assertThat(result).isEqualTo(estimated)
coVerify(exactly = 1) { estimateFeeUseCase(BigDecimal.ZERO, userWallet, feeCurrencyStatus) }
}
}