Updated on 2026-08-14
This commit is contained in:
parent
5402221995
commit
04fdc401d5
4 changed files with 184 additions and 22 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue