From 04fdc401d599de64cec3b322399c78be6d4f251a Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 2 Jul 2026 15:26:40 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../notifications/model/NotificationsModel.kt | 34 +++-- .../transfer/SwapTransferInteractorImpl.kt | 25 +++- .../SwapTransferInteractorImplTest.kt | 138 ++++++++++++++++++ .../SwapTransferNotificationsFactory.kt | 9 +- 4 files changed, 184 insertions(+), 22 deletions(-) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt index 6f1349f9c3..60ed22fd79 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt @@ -149,8 +149,21 @@ internal class NotificationsModel @Inject constructor( feeValue = feeValue, reduceAmountBy = reduceAmountBy, ) + val feePaymentBalance = getCurrencyStatusForFeePayment().value.amount.orZero() + val isFeeCoverageForRent = checkFeeCoverage( + isSubtractAvailable = isAmountSubtractAvailable, + balance = feePaymentBalance, + amountValue = amountValue, + feeValue = feeValue.orZero(), + reduceAmountBy = reduceAmountBy, + ) + val sendingAmountForRentCheck = if (isFeeCoverageForRent) { + (amountValue - feeValue.orZero()).coerceAtLeast(BigDecimal.ZERO) + } else { + amountValue + } val feeCurrencyBalanceAfterTransaction = getFeeCurrencyBalanceAfterTx( - sendingAmount = sendingAmount, + sendingAmount = sendingAmountForRentCheck, feeValue = feeValue, ) val currencyCheck = getCurrencyCheckUseCase( @@ -226,14 +239,17 @@ internal class NotificationsModel @Inject constructor( } private fun getFeeCurrencyBalanceAfterTx(sendingAmount: BigDecimal, feeValue: BigDecimal?): BigDecimal? { - val sendingCurrencyBalance = cryptoCurrencyStatus.value as? CryptoCurrencyStatus.Loaded - val feeCurrencyBalance = feeCryptoCurrencyStatus.value as? CryptoCurrencyStatus.Loaded - if (feeCryptoCurrencyStatus.value !is CryptoCurrencyStatus.Loaded || feeValue == null) return null - return when { - feeCryptoCurrencyStatus == cryptoCurrencyStatus -> sendingCurrencyBalance?.let { - it.amount - sendingAmount - feeValue - } - else -> feeCurrencyBalance?.let { it.amount - feeValue } + val feeCurrencyBalance = feeCryptoCurrencyStatus.value as? CryptoCurrencyStatus.Loaded ?: return null + if (feeValue == null) return null + // Compare by currency id, not by data-class equality: the sending status and the fee status come from + // two independently-populated flows, so a native-coin send (fee paid in the coin being sent, e.g. SOL) + // yields two non-equal snapshots. Falling into the else branch there would skip subtracting the sending + // amount and hide the rent-exemption warning. + val isFeeInSendingCurrency = feeCryptoCurrencyStatus.currency.id == cryptoCurrencyStatus.currency.id + return if (isFeeInSendingCurrency) { + feeCurrencyBalance.amount - sendingAmount - feeValue + } else { + feeCurrencyBalance.amount - feeValue } } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index 0c1371e0ad..b1124566eb 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -108,6 +108,24 @@ class SwapTransferInteractorImpl @Inject constructor( val feePaidCurrency = feePaidCurrencyStatus?.currency val isFeeInOtherToken = feePaidCurrency is CryptoCurrency.Token && feePaidCurrency.id != fromToken.id val warningsFee = if (isFeeInOtherToken) BigDecimal.ZERO else fee?.amount?.value.orZero() + val isAmountSubtractAvailable = isAmountSubtractAvailable( + userWalletId = userWallet.walletId, + currency = fromTokenInfo.swapCurrencyStatus.currency, + fee = fee, + ) + val fromBalance = fromSwapCurrencyStatus.status.value.amount.orZero() + val isFeeCoverageForRent = checkFeeCoverage( + isSubtractAvailable = isAmountSubtractAvailable, + balance = fromBalance, + amountValue = fromTokenAmountValue, + feeValue = fee?.amount?.value.orZero(), + reduceAmountBy = BigDecimal.ZERO, + ) + val sendingAmountForRentCheck = if (isFeeCoverageForRent) { + (fromTokenAmountValue - fee?.amount?.value.orZero()).coerceAtLeast(BigDecimal.ZERO) + } else { + fromTokenAmountValue + } val currencyCheck = getCurrencyCheckUseCase( userWalletId = fromSwapCurrencyStatus.userWalletId, currencyStatus = fromSwapCurrencyStatus.status, @@ -117,16 +135,11 @@ class SwapTransferInteractorImpl @Inject constructor( feeCurrencyBalanceAfterTransaction = getFeeCurrencyBalanceAfterTx( fromSwapCurrencyStatus = fromSwapCurrencyStatus, feePaidCurrencyStatus = feePaidCurrencyStatus, - sendingAmount = fromTokenAmountValue, + sendingAmount = sendingAmountForRentCheck, feeValue = fee?.amount?.value, ), recipientAddress = toSwapCurrencyStatus.destinationAddress(), ) - val isAmountSubtractAvailable = isAmountSubtractAvailable( - userWalletId = userWallet.walletId, - currency = fromTokenInfo.swapCurrencyStatus.currency, - fee = fee, - ) val coverageState = getCoverageState( fromTokenInfo = fromTokenInfo, isAmountSubtractAvailable = isAmountSubtractAvailable, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index d5c42f5ef9..a8c7256646 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -724,6 +724,144 @@ internal class SwapTransferInteractorImplTest { assertThat(feeBalances.single()).isNull() } + @Test + fun `GIVEN subtract available and sub-max amount in coverage zone WHEN updateTransfer THEN feeCurrencyBalanceAfterTx surfaces the dust remainder`() = + runTest { + // Arrange: reproduces the reported Solana dust bug. Entered amount is below the balance but within + // one fee of it → fee coverage applies and the sent amount tracks the entered amount (entered - fee), + // leaving (balance - entered) on the account. That dust remainder must be surfaced so the rent + // warning can fire — it must NOT be clamped to (balance - fee), which would report a zero remainder. + val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") + val userWallet: UserWallet = mockk(relaxed = true) + val balance = BigDecimal("0.0534546") + val enteredAmount = BigDecimal("0.05332441") + val feeValue = BigDecimal("0.000205") + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + fiatRate = BigDecimal.TEN, + amount = balance, + userWallet = userWallet, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + userWallet = userWallet, + ) + val feePaidCurrencyStatus = buildFeeCurrencyStatus( + currency = fromCurrencyStatus.currency, + amount = balance, + ) + val fee: Fee = mockk(relaxed = true) { every { amount.value } returns feeValue } + stubBaseFlows(appCurrency) + coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns true.right() + coEvery { getBalanceNotEnoughForFeeWarningUseCase(any(), any(), any(), any()) } returns null.right() + val feeBalances = mutableListOf() + stubGetCurrencyCheckCapturingFeeBalance(feeBalances) + + // Act + sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = enteredAmount.toPlainString(), + feePaidCurrencyStatus = feePaidCurrencyStatus, + fee = fee, + ) + + // Assert: remainder is balance - entered (the dust), not zero. + assertThat(feeBalances.single()!!.compareTo(balance - enteredAmount)).isEqualTo(0) + } + + @Test + fun `GIVEN subtract available and max amount WHEN updateTransfer THEN feeCurrencyBalanceAfterTx is fee-adjusted remainder`() = + runTest { + // Arrange: Max send with fee coverage. The actual sent amount is entered - fee, so the true + // remainder is 0 (allowed) — the raw entered amount must not be subtracted on top of the fee. + val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") + val userWallet: UserWallet = mockk(relaxed = true) + val balance = BigDecimal("1.5") + val feeValue = BigDecimal("0.2") + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + fiatRate = BigDecimal.TEN, + amount = balance, + userWallet = userWallet, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + userWallet = userWallet, + ) + val feePaidCurrencyStatus = buildFeeCurrencyStatus( + currency = fromCurrencyStatus.currency, + amount = balance, + ) + val fee: Fee = mockk(relaxed = true) { every { amount.value } returns feeValue } + stubBaseFlows(appCurrency) + coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns true.right() + coEvery { getBalanceNotEnoughForFeeWarningUseCase(any(), any(), any(), any()) } returns null.right() + val feeBalances = mutableListOf() + stubGetCurrencyCheckCapturingFeeBalance(feeBalances) + + // Act + sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = balance.toPlainString(), + feePaidCurrencyStatus = feePaidCurrencyStatus, + fee = fee, + ) + + // Assert: fee-adjusted remainder is exactly zero, not -fee. + assertThat(feeBalances.single()!!.compareTo(BigDecimal.ZERO)).isEqualTo(0) + } + + @Test + fun `GIVEN subtract available and amount below coverage zone WHEN updateTransfer THEN feeCurrencyBalanceAfterTx is balance minus amount minus fee`() = + runTest { + // Arrange: amount well below balance → no fee coverage → the entered amount is used as-is. + val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") + val userWallet: UserWallet = mockk(relaxed = true) + val balance = BigDecimal("2.0") + val enteredAmount = BigDecimal("0.5") + val feeValue = BigDecimal("0.1") + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + fiatRate = BigDecimal.TEN, + amount = balance, + userWallet = userWallet, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + userWallet = userWallet, + ) + val feePaidCurrencyStatus = buildFeeCurrencyStatus( + currency = fromCurrencyStatus.currency, + amount = balance, + ) + val fee: Fee = mockk(relaxed = true) { every { amount.value } returns feeValue } + stubBaseFlows(appCurrency) + coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns true.right() + coEvery { getBalanceNotEnoughForFeeWarningUseCase(any(), any(), any(), any()) } returns null.right() + val feeBalances = mutableListOf() + stubGetCurrencyCheckCapturingFeeBalance(feeBalances) + + // Act + sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = enteredAmount.toPlainString(), + feePaidCurrencyStatus = feePaidCurrencyStatus, + fee = fee, + ) + + // Assert + assertThat(feeBalances.single()!!.compareTo(balance - enteredAmount - feeValue)).isEqualTo(0) + } + // endregion // region loadFee diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt index 7a631e308c..7201b3d59e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt @@ -7,6 +7,7 @@ import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalance import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addRentExemptionNotification import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications @@ -44,7 +45,7 @@ internal class SwapTransferNotificationsFactory @Inject constructor() { val feeContent = feeSelectorUM val getFeeError = (feeSelectorUM as? FeeSelectorUM.Error)?.error return buildList { - maybeAddRentExemptionError(transferState) + addRentExemptionNotification(transferState.currencyCheck?.rentWarning) maybeAddDomainWarnings( state = transferState, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, @@ -72,12 +73,6 @@ internal class SwapTransferNotificationsFactory @Inject constructor() { }.toPersistentList() } - private fun MutableList.maybeAddRentExemptionError(state: SwapState.Transfer) { - state.currencyCheck?.rentWarning?.let { - add(NotificationUM.Solana.RentInfo(it)) - } - } - private fun MutableList.maybeAddDomainWarnings( state: SwapState.Transfer, feeCryptoCurrencyStatus: CryptoCurrencyStatus?,