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 da5b4c29e8..b598c30326 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 @@ -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.events.StakingAlertUM 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.StakingFeeLoader 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 getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, + private val getEffectiveStakingFee: GetEffectiveStakingFee, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase, private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase, @@ -945,9 +947,16 @@ internal class StakingModel @Inject constructor( null } + val effectiveFee = getEffectiveStakingFee( + stakeKitFee = fee, + amount = amount, + userWallet = userWallet, + feeCurrencyStatus = feeStatus, + ) + val balanceAfterTransaction = calculateBalanceAfterTransaction( amount = amount.orZero(), - fee = fee.orZero(), + fee = effectiveFee.orZero(), reduceAmountBy = confirmationState?.reduceAmountBy.orZero(), actionType = value.actionType, ) @@ -956,9 +965,12 @@ internal class StakingModel @Inject constructor( currencyStatus = cryptoCurrencyStatus, feeCurrencyStatus = feeCryptoCurrencyStatus, amount = amount, - fee = fee, + fee = effectiveFee, feeCurrencyBalanceAfterTransaction = balanceAfterTransaction, ) + + val hasRentWarning = currencyStatus.rentWarning != null + stateController.update( AddStakingNotificationsTransformer( cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, @@ -968,8 +980,8 @@ internal class StakingModel @Inject constructor( currencyWarning = currencyWarning, currencyCheck = currencyStatus, isSubtractAvailable = isAmountSubtractAvailable, - feeError = feeError, - stakingError = stakingError, + feeError = if (hasRentWarning) null else feeError, + stakingError = if (hasRentWarning) null else stakingError, integration = integration, ), ) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/GetEffectiveStakingFee.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/GetEffectiveStakingFee.kt new file mode 100644 index 0000000000..3607ca37bb --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/GetEffectiveStakingFee.kt @@ -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 + } +} \ No newline at end of file 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 92689585c7..dccea3b0b6 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 @@ -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.StakingStep 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.StakingOperationsFactory import com.tangem.utils.coroutines.AppCoroutineScope @@ -92,6 +93,7 @@ internal abstract class StakingModelTestBase { protected val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk() protected val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk() protected val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk() + protected val getEffectiveStakingFee: GetEffectiveStakingFee = mockk() protected val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk() protected val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase = mockk() private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase = mockk() @@ -145,6 +147,9 @@ internal abstract class StakingModelTestBase { coEvery { isAmountSubtractAvailableUseCase(testUserWalletId, any()) } returns Either.Right(false) + coEvery { + getEffectiveStakingFee(any(), any(), any(), any()) + } answers { firstArg() } every { getActionsUseCase(testUserWalletId, any()) } returns emptyFlow() every { getBalanceHidingSettingsUseCase() } returns emptyFlow() mockBalanceUpdater = mockk { @@ -178,6 +183,7 @@ internal abstract class StakingModelTestBase { saveBlockchainErrorUseCase = saveBlockchainErrorUseCase, getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase, getCurrencyCheckUseCase = getCurrencyCheckUseCase, + getEffectiveStakingFee = getEffectiveStakingFee, isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase, isAnyTokenStakedUseCase = isAnyTokenStakedUseCase, invalidatePendingTransactionsUseCase = invalidatePendingTransactionsUseCase, diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/helpers/GetEffectiveStakingFeeTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/helpers/GetEffectiveStakingFeeTest.kt new file mode 100644 index 0000000000..fac3a2a318 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/helpers/GetEffectiveStakingFeeTest.kt @@ -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) } + } +} \ No newline at end of file