Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-03 13:40:51 +01:00
commit ba572cf71f
4 changed files with 184 additions and 22 deletions

View file

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

View file

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

View file

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

View file

@ -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<NotificationUM>.maybeAddRentExemptionError(state: SwapState.Transfer) {
state.currencyCheck?.rentWarning?.let {
add(NotificationUM.Solana.RentInfo(it))
}
}
private fun MutableList<NotificationUM>.maybeAddDomainWarnings(
state: SwapState.Transfer,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,