Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-30 10:33:11 +01:00
parent 62d1577a00
commit 9b101c4238
11 changed files with 167 additions and 20 deletions

View file

@ -179,11 +179,15 @@ sealed class NotificationUM(val config: NotificationConfig) {
subtitle = resourceReference(id = R.string.send_notification_invalid_reserve_amount_text),
)
data class NetworkAccountNotFunded(val coinName: String) : Error(
data class NetworkAccountNotFunded(
val coinName: String,
val reserveAmount: String,
val reserveSymbol: String,
) : Error(
title = resourceReference(R.string.alert_failed_to_send_transaction_title),
subtitle = resourceReference(
id = R.string.no_account_generic,
formatArgs = wrappedList(coinName),
formatArgs = wrappedList(coinName, reserveAmount, reserveSymbol),
),
)
@ -191,6 +195,11 @@ sealed class NotificationUM(val config: NotificationConfig) {
title = resourceReference(id = R.string.send_validation_destination_tag_required_title),
subtitle = resourceReference(id = R.string.send_validation_destination_tag_required_description),
)
data object RequiredTrustline : Error(
title = resourceReference(id = R.string.common_error),
subtitle = resourceReference(id = R.string.no_trustline_xlm_asset),
)
}
open class Warning(

View file

@ -120,40 +120,66 @@ object NotificationsFactory {
}
}
@Suppress("LongParameterList")
fun MutableList<NotificationUM>.addReserveAmountErrorNotification(
reserveAmount: BigDecimal?,
sendingAmount: BigDecimal,
cryptoCurrency: CryptoCurrency,
feeCryptoCurrency: CryptoCurrency?,
isAccountFunded: Boolean,
hasRequiredTrustline: Boolean,
) {
val sendingCoinAmount = when (cryptoCurrency) {
is CryptoCurrency.Coin -> sendingAmount
is CryptoCurrency.Token -> BigDecimal.ZERO
}
if (feeCryptoCurrency == null && cryptoCurrency is CryptoCurrency.Token) {
when {
// No need to show reserve amount warning if fee currency is unknown for token transfer
return
} else if (!isAccountFunded && reserveAmount != null && reserveAmount > sendingCoinAmount) {
feeCryptoCurrency == null && cryptoCurrency is CryptoCurrency.Token -> Unit
// account not funded, sending coin amount < reserve (send coin with less amount OR send any token)
!isAccountFunded && reserveAmount != null && reserveAmount > sendingCoinAmount ->
addAccountNotFundedNotification(
reserveAmount = reserveAmount,
cryptoCurrency = cryptoCurrency,
feeCryptoCurrency = feeCryptoCurrency,
)
hasRequiredTrustline -> addTrustlineRequiredNotification()
}
}
if (cryptoCurrency is CryptoCurrency.Coin) {
private fun MutableList<NotificationUM>.addAccountNotFundedNotification(
reserveAmount: BigDecimal,
cryptoCurrency: CryptoCurrency,
feeCryptoCurrency: CryptoCurrency?,
) {
when (cryptoCurrency) {
// Try to send coin amount less than reserve amount (e.g. less than 1 XLM in Stellar)
add(
is CryptoCurrency.Coin -> add(
NotificationUM.Error.ReserveAmount(
reserveAmount.format {
crypto(feeCryptoCurrency ?: cryptoCurrency)
},
),
)
} else {
checkNotNull(feeCryptoCurrency)
// Try to send any token (e.g. USDC in Stellar, but account not funded -> user must send XLM at first)
add(NotificationUM.Error.NetworkAccountNotFunded(coinName = feeCryptoCurrency.name))
is CryptoCurrency.Token -> {
checkNotNull(feeCryptoCurrency)
add(
NotificationUM.Error.NetworkAccountNotFunded(
coinName = feeCryptoCurrency.name,
reserveAmount = reserveAmount.format {
crypto(symbol = "", decimals = feeCryptoCurrency.decimals)
},
reserveSymbol = feeCryptoCurrency.symbol,
),
)
}
}
// TODO: check the RECEIVER account trustline before sending
}
private fun MutableList<NotificationUM>.addTrustlineRequiredNotification() {
add(NotificationUM.Error.RequiredTrustline)
}
fun MutableList<NotificationUM>.addMinimumAmountErrorNotification(

View file

@ -25,14 +25,17 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.notifications.GetTronFeeNotificationShowCountUseCase
import com.tangem.domain.notifications.IncrementNotificationsShowCountUseCase
import com.tangem.domain.tokens.GetAssetRequirementsUseCase
import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase
import com.tangem.domain.tokens.GetCurrencyCheckUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.features.send.api.SendNotificationsComponent
@ -70,9 +73,11 @@ internal class NotificationsModel @Inject constructor(
private val validateTransactionUseCase: ValidateTransactionUseCase,
private val getTronFeeNotificationShowCountUseCase: GetTronFeeNotificationShowCountUseCase,
private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase,
private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase,
private val getAccountCurrencyByAddressUseCase: GetAccountCurrencyByAddressUseCase,
private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger,
private val notificationsUpdateListener: SendNotificationsUpdateListener,
private val analyticsEventHandler: AnalyticsEventHandler,
analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
private val params: SendNotificationsComponent.Params = paramsContainer.require()
@ -295,6 +300,7 @@ internal class NotificationsModel @Inject constructor(
cryptoCurrency = currency,
feeCryptoCurrency = feeCryptoCurrencyStatus.currency,
isAccountFunded = currencyCheck.isAccountFunded,
hasRequiredTrustline = recipientRequiresTrustline(notificationData.destinationAddress),
)
addMinimumAmountErrorNotification(
minimumSendAmount = currencyCheck.minimumSendAmount,
@ -303,6 +309,24 @@ internal class NotificationsModel @Inject constructor(
)
}
private suspend fun recipientRequiresTrustline(destinationAddress: String?): Boolean {
val recipientAccount = destinationAddress
?.let { getAccountCurrencyByAddressUseCase(it).getOrNull() }
?.account
?: return false
val recipientCurrency = recipientAccount.cryptoCurrencies
.firstOrNull { it.isSameTokenAs(currency) }
?: return false
return getAssetRequirementsUseCase(
userWalletId = recipientAccount.userWalletId,
currency = recipientCurrency,
).getOrNull() is AssetRequirementsCondition.RequiredTrustline
}
private fun CryptoCurrency.isSameTokenAs(other: CryptoCurrency): Boolean {
return id.rawNetworkId == other.id.rawNetworkId && id.contractAddress == other.id.contractAddress
}
private suspend fun MutableList<NotificationUM>.addWarningNotifications(
destinationAddress: String?,
memo: String?,

View file

@ -257,6 +257,7 @@ internal class AddStakingNotificationsTransformer(
cryptoCurrency = cryptoCurrency,
feeCryptoCurrency = feeCryptoCurrencyStatus?.currency,
isAccountFunded = false,
hasRequiredTrustline = false,
)
}

View file

@ -60,6 +60,7 @@ sealed interface SwapState {
val currencyCheck: CryptoCurrencyCheck? = null,
val validationResult: Throwable? = null,
val minAdaValue: BigDecimal? = null,
val hasRequiredTrustline: Boolean = false,
) : SwapState
data class EmptyAmountState(

View file

@ -24,6 +24,7 @@ import com.tangem.domain.notifications.IncrementNotificationsShowCountUseCase
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tokens.GetAssetRequirementsUseCase
import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase
import com.tangem.domain.tokens.GetCurrencyCheckUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
@ -31,6 +32,7 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
@ -66,8 +68,10 @@ class SwapTransferInteractorImpl @Inject constructor(
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
private val getTronFeeNotificationShowCountUseCase: GetTronFeeNotificationShowCountUseCase,
private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase,
private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase,
) : SwapTransferInteractor {
@Suppress("LongMethod")
override suspend fun updateTransfer(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
@ -109,6 +113,7 @@ class SwapTransferInteractorImpl @Inject constructor(
amount = fromTokenAmountValue,
fee = warningsFee,
feeCurrencyBalanceAfterTransaction = null,
recipientAddress = toSwapCurrencyStatus.destinationAddress(),
)
val isAmountSubtractAvailable = isAmountSubtractAvailable(
userWalletId = userWallet.walletId,
@ -130,6 +135,10 @@ class SwapTransferInteractorImpl @Inject constructor(
)
}
val tronFeeNotificationShowCount = getTronFeeNotificationShowCountUseCase()
val hasRequiredTrustline = getAssetRequirementsUseCase(
userWalletId = toSwapCurrencyStatus.userWalletId,
currency = toToken,
).getOrNull() is AssetRequirementsCondition.RequiredTrustline
return SwapState.Transfer(
userWallet = userWallet,
fromTokenInfo = fromTokenInfo,
@ -145,6 +154,7 @@ class SwapTransferInteractorImpl @Inject constructor(
isAmountSubtractAvailable = isAmountSubtractAvailable,
isSendingAmountLoading = coverageState.isSendingAmountLoading,
currencyCheck = currencyCheck,
hasRequiredTrustline = hasRequiredTrustline,
)
}

View file

@ -21,6 +21,7 @@ import com.tangem.domain.notifications.IncrementNotificationsShowCountUseCase
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tokens.GetAssetRequirementsUseCase
import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase
import com.tangem.domain.tokens.GetCurrencyCheckUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
@ -42,6 +43,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@ -65,6 +67,7 @@ internal class SwapTransferInteractorImplTest {
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk(relaxed = true)
private val getTronFeeNotificationShowCountUseCase: GetTronFeeNotificationShowCountUseCase = mockk(relaxed = true)
private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase = mockk(relaxed = true)
private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase = mockk()
private val sut = SwapTransferInteractorImpl(
swapFeatureToggles = swapFeatureToggles,
@ -82,8 +85,14 @@ internal class SwapTransferInteractorImplTest {
getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase,
getTronFeeNotificationShowCountUseCase = getTronFeeNotificationShowCountUseCase,
incrementNotificationsShowCountUseCase = incrementNotificationsShowCountUseCase,
getAssetRequirementsUseCase = getAssetRequirementsUseCase,
)
@BeforeEach
fun setup() {
coEvery { getAssetRequirementsUseCase(any(), any()) } returns null.right()
}
@AfterEach
fun tearDown() {
clearAllMocks()
@ -429,6 +438,68 @@ internal class SwapTransferInteractorImplTest {
assertThat(result.isSendingAmountLoading).isTrue()
}
@Test
fun `GIVEN destination address WHEN updateTransfer THEN currency check requested with recipient and isAccountFunded flows through`() =
runTest {
// Arrange
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
val userWallet: UserWallet = mockk(relaxed = true)
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
fiatRate = BigDecimal.TEN,
amount = BigDecimal("1.6"),
userWallet = userWallet,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
userWallet = userWallet,
destinationAddress = DESTINATION_ADDRESS,
)
every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right())
every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
// Stub matches only when the destination address is forwarded as recipientAddress; the
// returned check has isAccountFunded = true (buildCurrencyCheck default).
val fundedCheck = buildCurrencyCheck()
coEvery {
getCurrencyCheckUseCase(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = DESTINATION_ADDRESS,
)
} returns fundedCheck
coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns false.right()
// Act
val result = sut.updateTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1,5",
feePaidCurrencyStatus = null,
fee = null,
) as SwapState.Transfer
// Assert
assertThat(result.currencyCheck?.isAccountFunded).isTrue()
coVerify {
getCurrencyCheckUseCase(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = DESTINATION_ADDRESS,
)
}
}
// endregion
// region loadFee

View file

@ -332,6 +332,7 @@ sealed class SwapEvents(
fromCurrency: CryptoCurrency?,
toCurrency: CryptoCurrency?,
feeNetwork: Network,
isTangemPay: Boolean,
) : SwapEvents(
event = "Transfer in Progress Screen Opened",
params = mapOf(
@ -340,6 +341,7 @@ sealed class SwapEvents(
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
"Network fee" to feeNetwork.name,
"Pay Account" to isTangemPay.toString(),
),
), AppsFlyerIncludedEvent

View file

@ -1491,7 +1491,7 @@ internal class SwapModel @Inject constructor(
}
private fun updateTransferModeTangemPayState() {
sendTransferInProgressEvent()
sendTransferInProgressEvent(isTangemPay = true)
uiState = swapTransferStateBuilder.createTangemPayWithdrawalSuccessState(
uiState = uiState,
dataState = dataState,
@ -1541,7 +1541,7 @@ internal class SwapModel @Inject constructor(
""
}
updateWalletBalance()
sendTransferInProgressEvent()
sendTransferInProgressEvent(isTangemPay = false)
uiState = swapTransferStateBuilder.createSuccessState(
uiState = uiState,
dataState = dataState,
@ -1564,7 +1564,7 @@ internal class SwapModel @Inject constructor(
)
}
private fun sendTransferInProgressEvent() {
private fun sendTransferInProgressEvent(isTangemPay: Boolean) {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
analyticsEventHandler.send(
@ -1572,6 +1572,7 @@ internal class SwapModel @Inject constructor(
fromCurrency = fromSwapCurrencyStatus?.currency,
toCurrency = toSwapCurrencyStatus?.currency,
feeNetwork = getFeeToken().network,
isTangemPay = isTangemPay,
),
)
}

View file

@ -222,6 +222,7 @@ internal class SwapNotificationsFactory(
cryptoCurrency = swapCurrencyStatus.currency,
feeCryptoCurrency = feeCryptoCurrencyStatus?.currency,
isAccountFunded = true, // consider the account is funded on the provider side
hasRequiredTrustline = false,
)
addReduceAmountNotification(
cryptoCurrencyStatus = swapCurrencyStatus.status,

View file

@ -126,7 +126,8 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
sendingAmount = amount.value,
cryptoCurrency = swapCurrencyStatus.currency,
feeCryptoCurrency = feeCryptoCurrencyStatus?.currency,
isAccountFunded = true,
isAccountFunded = state.currencyCheck?.isAccountFunded == true,
hasRequiredTrustline = state.hasRequiredTrustline,
)
addReduceAmountNotification(
cryptoCurrencyStatus = swapCurrencyStatus.status,