Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-29 19:47:19 +01:00
parent e2d0973392
commit bb5d6848d9
10 changed files with 339 additions and 43 deletions

View file

@ -6,6 +6,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
@ -36,6 +37,7 @@ sealed interface SwapState {
val userWallet: UserWallet,
val fromTokenInfo: TokenSwapInfo,
val toTokenInfo: TokenSwapInfo,
val cryptoCurrencyWarning: CryptoCurrencyWarning?,
val isInsufficientBalance: Boolean,
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,

View file

@ -37,6 +37,7 @@ interface SwapTransferInteractor {
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: BigDecimal,
selectedToken: CryptoCurrencyStatus?,
): Either<GetFeeError, TransactionFeeExtended>
suspend fun sendTransfer(

View file

@ -22,9 +22,11 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
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.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.TransactionFeeExtended
@ -46,7 +48,7 @@ import kotlinx.coroutines.flow.first
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
class SwapTransferInteractorImpl @Inject constructor(
private val swapFeatureToggles: SwapFeatureToggles,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
@ -60,6 +62,7 @@ class SwapTransferInteractorImpl @Inject constructor(
private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase,
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
) : SwapTransferInteractor {
override suspend fun updateTransfer(
@ -110,10 +113,19 @@ class SwapTransferInteractorImpl @Inject constructor(
fee = fee,
currencyCheck = currencyCheck,
)
val cryptoCurrencyWarning = feePaidCurrencyStatus?.let { feeStatus ->
getCryptoCurrencyWarning(
feeValue = fee?.amount?.value.orZero(),
userWallet = userWallet,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
feeStatus = feeStatus,
)
}
return SwapState.Transfer(
userWallet = userWallet,
fromTokenInfo = fromTokenInfo,
toTokenInfo = toTokenInfo,
cryptoCurrencyWarning = cryptoCurrencyWarning,
isInsufficientBalance = fromTokenAmountValue > fromTokenBalance,
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
@ -124,6 +136,20 @@ class SwapTransferInteractorImpl @Inject constructor(
)
}
private suspend fun getCryptoCurrencyWarning(
feeValue: BigDecimal,
userWallet: UserWallet,
fromSwapCurrencyStatus: SwapCurrencyStatus,
feeStatus: CryptoCurrencyStatus,
): CryptoCurrencyWarning? {
return getBalanceNotEnoughForFeeWarningUseCase(
fee = feeValue,
userWalletId = userWallet.walletId,
tokenStatus = fromSwapCurrencyStatus.status,
feeStatus = feeStatus,
).getOrNull()
}
private suspend fun getCoverageState(
fromTokenInfo: TokenSwapInfo,
userWallet: UserWallet,
@ -210,12 +236,22 @@ class SwapTransferInteractorImpl @Inject constructor(
val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError(
message = "Destination address is null",
)
val userWallet = fromSwapCurrencyStatus.userWallet
val currency = fromSwapCurrencyStatus.currency
val transactionData = createTransferTransactionUseCase(
amount = fromTokenAmount.convertToSdkAmount(
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
),
memo = null,
destination = destination,
userWalletId = userWallet.walletId,
network = currency.network,
).getOrNull() ?: return feeDataError("Failed to build transfer transaction")
return getFeeUseCase(
amount = fromTokenAmount,
destination = destination,
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoCurrency = fromSwapCurrencyStatus.currency,
network = fromSwapCurrencyStatus.currency.network,
transactionData = transactionData,
)
}
@ -223,6 +259,7 @@ class SwapTransferInteractorImpl @Inject constructor(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: BigDecimal,
selectedToken: CryptoCurrencyStatus?,
): Either<GetFeeError, TransactionFeeExtended> {
val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError(
message = "Destination address is null",
@ -244,7 +281,13 @@ class SwapTransferInteractorImpl @Inject constructor(
userWallet = userWallet,
network = currency.network,
transactionData = transactionData,
)
).map { transactionFeeExtended ->
selectedToken ?: return@map transactionFeeExtended
val selectedTokenId = selectedToken.currency.id
transactionFeeExtended.copy(
feeTokenId = selectedTokenId,
)
}
}
override suspend fun sendTransfer(
@ -310,9 +353,9 @@ class SwapTransferInteractorImpl @Inject constructor(
transactionFeeResult: TransactionFeeResult,
txData: TransactionData,
): Either<SendTransactionError, String> {
val isToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token
val isGaslessToken = isToken && transactionFeeResult is TransactionFeeResult.LoadedExtended
return if (isGaslessToken) {
val isFeeInTokenCurrency = transactionFeeResult is TransactionFeeResult.LoadedExtended &&
transactionFeeResult.fee.transactionFee.normal is Fee.Ethereum.TokenCurrency
return if (isFeeInTokenCurrency) {
createAndSendGaslessTransactionUseCase(
transactionData = txData,
userWallet = userWallet,

View file

@ -19,6 +19,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
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
@ -59,6 +60,7 @@ internal class SwapTransferInteractorImplTest {
private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk()
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk()
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase = mockk()
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk(relaxed = true)
private val sut = SwapTransferInteractorImpl(
swapFeatureToggles = swapFeatureToggles,
@ -73,6 +75,7 @@ internal class SwapTransferInteractorImplTest {
getCurrencyCheckUseCase = getCurrencyCheckUseCase,
isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase,
tangemPayWithdrawUseCase = tangemPayWithdrawUseCase,
getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase,
)
@AfterEach
@ -170,6 +173,7 @@ internal class SwapTransferInteractorImplTest {
swapCurrencyStatus = toCurrencyStatus,
amountFiat = expectedFiat,
),
cryptoCurrencyWarning = null,
isInsufficientBalance = false,
appCurrency = appCurrency,
isBalanceHidden = true,
@ -240,6 +244,7 @@ internal class SwapTransferInteractorImplTest {
swapCurrencyStatus = toCurrencyStatus,
amountFiat = expectedFiat,
),
cryptoCurrencyWarning = null,
isInsufficientBalance = true,
appCurrency = appCurrency,
isBalanceHidden = true,
@ -313,24 +318,36 @@ internal class SwapTransferInteractorImplTest {
@Test
fun `GIVEN valid amount and destination WHEN loadFee THEN return TransactionFee from use case`() = runTest {
val userWallet: UserWallet = mockk()
val userWalletId: UserWalletId = mockk()
val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
val network: Network = mockk()
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
userWallet = userWallet,
network = network,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
val transactionData: TransactionData.Uncompiled = mockk()
val transactionFee: TransactionFee = mockk()
coEvery {
getFeeUseCase(
amount = BigDecimal("1.5"),
createTransferTransactionUseCase(
amount = any(),
memo = null,
destination = DESTINATION_ADDRESS,
userWalletId = userWalletId,
network = network,
)
} returns transactionData.right()
coEvery {
getFeeUseCase(
userWallet = userWallet,
cryptoCurrency = fromCurrencyStatus.currency,
network = network,
transactionData = transactionData,
)
} returns transactionFee.right()
@ -343,10 +360,9 @@ internal class SwapTransferInteractorImplTest {
assertThat(result).isEqualTo(transactionFee.right())
coVerify {
getFeeUseCase(
amount = BigDecimal("1.5"),
destination = DESTINATION_ADDRESS,
userWallet = userWallet,
cryptoCurrency = fromCurrencyStatus.currency,
network = network,
transactionData = transactionData,
)
}
}
@ -394,6 +410,7 @@ internal class SwapTransferInteractorImplTest {
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = BigDecimal("2.0"),
selectedToken = null,
)
assertThat(result).isEqualTo(feeExtended.right())
@ -493,7 +510,7 @@ internal class SwapTransferInteractorImplTest {
}
@Test
fun `GIVEN token and LoadedExtended fee WHEN sendTransfer THEN route via createAndSendGaslessTransactionUseCase`() =
fun `GIVEN LoadedExtended fee with TokenCurrency normal fee WHEN sendTransfer THEN route via createAndSendGaslessTransactionUseCase`() =
runTest {
val userWalletId: UserWalletId = mockk()
val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
@ -511,7 +528,11 @@ internal class SwapTransferInteractorImplTest {
)
val fee: Fee = mockk()
val txData: TransactionData.Uncompiled = mockk()
val transactionFeeExtended: TransactionFeeExtended = mockk()
val transactionFeeExtended: TransactionFeeExtended = mockk {
every { transactionFee } returns mockk {
every { normal } returns mockk<Fee.Ethereum.TokenCurrency>()
}
}
val transactionFeeResult = TransactionFeeResult.LoadedExtended(transactionFeeExtended)
coEvery {
createTransferTransactionUseCase(
@ -602,6 +623,62 @@ internal class SwapTransferInteractorImplTest {
}
}
@Test
fun `GIVEN LoadedExtended fee with non-TokenCurrency normal fee WHEN sendTransfer THEN fall back to sendTransactionUseCase`() =
runTest {
val userWalletId: UserWalletId = mockk()
val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
val network: Network = mockk()
val fromCurrencyStatus = buildTokenCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
userWallet = userWallet,
network = network,
)
val toCurrencyStatus = buildTokenCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
val fee: Fee = mockk()
val txData: TransactionData.Uncompiled = mockk()
val transactionFeeExtended: TransactionFeeExtended = mockk {
every { transactionFee } returns mockk {
every { normal } returns mockk<Fee.Common>()
}
}
val transactionFeeResult = TransactionFeeResult.LoadedExtended(transactionFeeExtended)
coEvery {
createTransferTransactionUseCase(
amount = any(),
fee = fee,
memo = null,
destination = DESTINATION_ADDRESS,
userWalletId = userWalletId,
network = network,
)
} returns txData.right()
coEvery {
sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network)
} returns TX_HASH.right()
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
sendingAmount = BigDecimal("1.0"),
fee = fee,
transactionFeeResult = transactionFeeResult,
)
assertThat(result).isEqualTo(TX_HASH.right())
coVerify {
sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network)
}
coVerify(exactly = 0) {
createAndSendGaslessTransactionUseCase(any(), any(), any())
}
}
@Test
fun `GIVEN createTransferTransactionUseCase fails WHEN sendTransfer THEN return DataError`() = runTest {
val userWalletId: UserWalletId = mockk()

View file

@ -1,11 +1,11 @@
package com.tangem.feature.swap.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER
import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_TOKEN
@ -13,6 +13,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_TOKEN
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
import com.tangem.core.analytics.models.getReferralParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.FeeBucket
@ -247,4 +248,46 @@ sealed class SwapEvents(
"Provider" to provider.name,
),
)
class TransferModeSwitched(
fromCurrency: CryptoCurrency?,
toCurrency: CryptoCurrency?,
) : SwapEvents(
event = "Transfer Mode Switched",
params = mapOf(
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
),
)
class ButtonTransferClicked(
fromCurrency: CryptoCurrency?,
toCurrency: CryptoCurrency?,
) : SwapEvents(
event = "Button - Transfer",
params = mapOf(
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
),
)
@Suppress("NullableToStringCall", "LongParameterList")
class TransferInProgressScreen(
fromCurrency: CryptoCurrency?,
toCurrency: CryptoCurrency?,
feeNetwork: Network,
) : SwapEvents(
event = "Transfer in Progress Screen Opened",
params = mapOf(
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
"Network fee" to feeNetwork.name,
),
), AppsFlyerIncludedEvent
}

View file

@ -601,7 +601,15 @@ internal class SwapModel @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
)
if (isUpdatedToTransferMode) return
if (isUpdatedToTransferMode) {
analyticsEventHandler.send(
event = SwapEvents.TransferModeSwitched(
fromCurrency = fromSwapCurrencyStatus.currency,
toCurrency = toSwapCurrencyStatus.currency,
),
)
return
}
dataState = dataState.copy(currentTransferState = null)
modelScope.launch {
uiState = stateBuilder.createInitialLoadingState(
@ -770,7 +778,10 @@ internal class SwapModel @Inject constructor(
feePaidCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
) as? SwapState.Transfer ?: currentTransferState
dataState = dataState.copy(currentTransferState = refreshed)
dataState = dataState.copy(
currentTransferState = refreshed,
feePaidCryptoCurrency = feePaidCryptoCurrencyStatus ?: dataState.feePaidCryptoCurrency,
)
uiState = swapTransferStateBuilder.updateTransferButtonEnableState(
dataState = dataState,
transferState = refreshed,
@ -1291,6 +1302,12 @@ internal class SwapModel @Inject constructor(
private fun onTransferClick() {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
analyticsEventHandler.send(
event = SwapEvents.ButtonTransferClicked(
fromCurrency = fromSwapCurrencyStatus?.currency,
toCurrency = toSwapCurrencyStatus?.currency,
),
)
val fee = (feeSelectorRepository.state.value as? FeeSelectorUM.Content)?.selectedFeeItem?.fee
if (fromSwapCurrencyStatus == null || toSwapCurrencyStatus == null) {
TangemLogger.e("onTransferClick: missing currency status, aborting")
@ -1332,6 +1349,7 @@ internal class SwapModel @Inject constructor(
TangemLogger.e(
messageString = "onTransferClick: withdrawTangemPay failed: ${error.getAnalyticsDescription()}",
)
startLoadingQuotesFromLastState()
showAlert()
}
.onRight { result ->
@ -1343,9 +1361,11 @@ internal class SwapModel @Inject constructor(
}
private fun updateTransferModeTangemPayState() {
sendTransferInProgressEvent()
uiState = swapTransferStateBuilder.createTangemPayWithdrawalSuccessState(
uiState = uiState,
dataState = dataState,
fee = getSelectedSwapFee()?.fee,
onExploreClick = {
val txUrl = uiState.successState?.txUrl.orEmpty()
if (txUrl.isNotEmpty()) {
@ -1373,6 +1393,7 @@ internal class SwapModel @Inject constructor(
).fold(
ifLeft = { error ->
TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}")
startLoadingQuotesFromLastState()
showAlert()
},
ifRight = { txHash ->
@ -1384,14 +1405,13 @@ internal class SwapModel @Inject constructor(
""
}
updateWalletBalance()
sendTransferInProgressEvent()
uiState = swapTransferStateBuilder.createSuccessState(
uiState = uiState,
dataState = dataState,
appCurrency = selectedAppCurrencyFlow.value,
isAccountsMode = isAccountsMode,
txUrl = txUrl,
timestamp = System.currentTimeMillis(),
fee = null,
fee = getSelectedSwapFee()?.fee,
onExplorerClick = {
if (txUrl.isNotEmpty()) {
urlOpener.openUrl(txUrl)
@ -1403,6 +1423,18 @@ internal class SwapModel @Inject constructor(
)
}
private fun sendTransferInProgressEvent() {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
analyticsEventHandler.send(
event = SwapEvents.TransferInProgressScreen(
fromCurrency = fromSwapCurrencyStatus?.currency,
toCurrency = toSwapCurrencyStatus?.currency,
feeNetwork = getFeeToken().network,
),
)
}
private suspend fun processTangemPayWithdrawal(
fromSwapCurrencyStatus: SwapCurrencyStatus,
swapTransactionState: SwapTransactionState.TangemPayWithdrawalData,
@ -2245,6 +2277,7 @@ internal class SwapModel @Inject constructor(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = amount,
selectedToken = selectedToken,
)
}
val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError)
@ -2300,10 +2333,6 @@ internal class SwapModel @Inject constructor(
modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) }
return
}
refreshTransferUIStateIfNeeded(
feePaidCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
)
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
@ -2312,7 +2341,13 @@ internal class SwapModel @Inject constructor(
fromSwapCurrencyStatus?.currency,
toSwapCurrencyStatus?.currency,
)
if (shouldTransferInsteadOfSwap) return
if (shouldTransferInsteadOfSwap) {
refreshTransferUIStateIfNeeded(
feePaidCryptoCurrencyStatus = getSelectedSwapFee()?.selectedFeeToken,
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
)
return
}
val quoteState = dataState.getCurrentLoadedSwapState() ?: return
val swapFee = getSelectedSwapFee() ?: return

View file

@ -3,12 +3,14 @@ package com.tangem.feature.swap.ui.transfer
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.SwapState
@ -24,12 +26,14 @@ import javax.inject.Inject
internal class SwapTransferNotificationsFactory @Inject constructor() {
@Suppress("LongParameterList")
fun getNotifications(
transferState: SwapState.Transfer,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
onReduceToAmount: (SwapAmount) -> Unit,
onBuyClick: (CryptoCurrency) -> Unit,
): ImmutableList<NotificationUM> {
return buildList {
maybeAddRentExemptionError(transferState)
@ -41,6 +45,7 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
onReduceToAmount = onReduceToAmount,
)
maybeAddNeedReserveToCreateAccountWarning(transferState)
maybeAddExceedsBalanceNotification(transferState, onBuyClick)
}.toPersistentList()
}
@ -172,4 +177,21 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
)
}
}
private fun MutableList<NotificationUM>.maybeAddExceedsBalanceNotification(
transferState: SwapState.Transfer,
onBuyClick: (CryptoCurrency) -> Unit,
) {
val cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status
addExceedsBalanceNotification(
cryptoCurrencyWarning = transferState.cryptoCurrencyWarning,
cryptoCurrencyStatus = cryptoCurrencyStatus,
shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(
networkId = cryptoCurrencyStatus.currency.network.rawId,
),
onClick = onBuyClick,
onAnalyticsEvent = {},
onResetAnalyticsEvent = {},
)
}
}

View file

@ -59,6 +59,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
onBuyClick = actions.openTokenDetailsScreen,
onReduceByAmount = actions.onReduceByAmount,
onReduceToAmount = actions.onReduceToAmount,
)
@ -227,6 +228,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
onBuyClick = actions.openTokenDetailsScreen,
onReduceByAmount = actions.onReduceByAmount,
onReduceToAmount = actions.onReduceToAmount,
)
@ -325,13 +327,12 @@ internal class SwapTransferStateBuilder @Inject constructor(
fun createSuccessState(
uiState: SwapStateHolder,
dataState: SwapProcessDataState,
appCurrency: AppCurrency,
isAccountsMode: Boolean,
fee: Fee?,
txUrl: String,
timestamp: Long,
fee: TextReference?,
onExplorerClick: () -> Unit,
): SwapStateHolder {
val transferState = requireNotNull(dataState.currentTransferState)
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus)
val amount = dataState.amount?.parseBigDecimalOrNull() ?: BigDecimal.ZERO
@ -341,11 +342,11 @@ internal class SwapTransferStateBuilder @Inject constructor(
val fromAmountText = amount.format { crypto(fromCurrency.symbol, fromCurrency.decimals) }
val toAmountText = amount.format { crypto(toCurrency.symbol, toCurrency.decimals) }
val fromFiatAmount = getFormattedFiatAmount(
appCurrency = appCurrency,
appCurrency = transferState.appCurrency,
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(amount),
)
val toFiatAmount = getFormattedFiatAmount(
appCurrency = appCurrency,
appCurrency = transferState.appCurrency,
amount = toSwapCurrencyStatus.status.value.fiatRate?.multiply(amount),
)
@ -359,15 +360,15 @@ internal class SwapTransferStateBuilder @Inject constructor(
isTransferMode = true,
providerIcon = "",
rate = TextReference.EMPTY,
fee = fee,
fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) },
fromTitle = getCardAccountTitle(
account = fromSwapCurrencyStatus.account,
isAccountsMode = isAccountsMode,
isAccountsMode = transferState.isAccountsMode,
isFromCard = true,
),
toTitle = getCardAccountTitle(
account = toSwapCurrencyStatus.account,
isAccountsMode = isAccountsMode,
isAccountsMode = transferState.isAccountsMode,
isFromCard = false,
),
fromTokenAmount = stringReference(fromAmountText),
@ -385,6 +386,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
fun createTangemPayWithdrawalSuccessState(
uiState: SwapStateHolder,
dataState: SwapProcessDataState,
fee: Fee?,
onExploreClick: () -> Unit,
): SwapStateHolder {
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
@ -407,7 +409,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
isTransferMode = true,
providerIcon = "",
rate = TextReference.EMPTY,
fee = null,
fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) },
fromTitle = getCardAccountTitle(
account = fromSwapCurrencyStatus.account,
isAccountsMode = transferState.isAccountsMode,
@ -429,4 +431,19 @@ internal class SwapTransferStateBuilder @Inject constructor(
),
)
}
private fun formatFeeForSuccess(transferState: SwapState.Transfer, fee: Fee): TextReference {
val feeAmount = fee.amount
val totalFeeValue = feeAmount.value ?: BigDecimal.ZERO
val cryptoFormatted = totalFeeValue.format {
crypto(symbol = feeAmount.currencySymbol, decimals = feeAmount.decimals)
}
val appCurrency = transferState.appCurrency
val swapCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus
val fiatRate = swapCurrencyStatus.status.value.fiatRate
val fiatFormatted = fiatRate?.multiply(totalFeeValue).format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}
return stringReference("$cryptoFormatted ($fiatFormatted)")
}
}

View file

@ -43,6 +43,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result).isEmpty()
@ -65,6 +66,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Solana.RentInfo>()).hasSize(1)
@ -91,6 +93,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = fee,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Error.ExistentialDeposit>()).hasSize(1)
@ -113,6 +116,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Error.MinimumAmountError>()).hasSize(1)
@ -131,6 +135,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Cardano.MinAdaValueCharged>()).hasSize(1)
@ -154,6 +159,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Warning.FeeCoverageNotification>()).hasSize(1)
@ -176,6 +182,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
val reserve = result.filterIsInstance<SwapNotificationUM.Warning.NeedReserveToCreateAccount>()
@ -199,15 +206,39 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<SwapNotificationUM.Warning.ReduceAmount>()).hasSize(1)
}
@Test
fun `GIVEN BalanceNotEnoughForFee warning WHEN getNotifications THEN TokenExceedsBalance is added`() = runTest {
val warning = CryptoCurrencyWarning.BalanceNotEnoughForFee(
tokenCurrency = buildCoin(),
coinCurrency = buildCoin(),
)
val transferState = buildTransferState(
cryptoCurrencyWarning = warning,
)
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Error.TokenExceedsBalance>()).hasSize(1)
}
@Suppress("LongParameterList")
private fun buildTransferState(
fromTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()),
toTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()),
cryptoCurrencyWarning: CryptoCurrencyWarning? = null,
currencyCheck: CryptoCurrencyCheck? = null,
validationResult: Throwable? = null,
minAdaValue: BigDecimal? = null,
@ -217,6 +248,7 @@ internal class SwapTransferNotificationsFactoryTest {
userWallet = coldWallet,
fromTokenInfo = fromTokenInfo,
toTokenInfo = toTokenInfo,
cryptoCurrencyWarning = cryptoCurrencyWarning,
isInsufficientBalance = false,
appCurrency = AppCurrency.Default,
isBalanceHidden = false,

View file

@ -56,6 +56,7 @@ internal class SwapTransferStateBuilderTest {
fee = any(),
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
} returns persistentListOf()
}
@ -129,6 +130,7 @@ internal class SwapTransferStateBuilderTest {
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -170,6 +172,7 @@ internal class SwapTransferStateBuilderTest {
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -212,6 +215,7 @@ internal class SwapTransferStateBuilderTest {
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -260,6 +264,7 @@ internal class SwapTransferStateBuilderTest {
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -307,6 +312,7 @@ internal class SwapTransferStateBuilderTest {
fee = fee,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
} returns persistentListOf()
@ -330,6 +336,7 @@ internal class SwapTransferStateBuilderTest {
fee = fee,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -468,25 +475,40 @@ internal class SwapTransferStateBuilderTest {
@Test
fun `GIVEN dataState with from-to currencies WHEN createSuccessState THEN success holder is built in transfer mode with given fee and txUrl`() {
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
val amount = BigDecimal("1.5")
val transferState = buildTransferState(
fromAmount = amount,
toAmount = amount,
isAccountsMode = true,
)
val dataState = SwapProcessDataState(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
amount = amount.toPlainString(),
currentTransferState = transferState,
)
val feeValue = BigDecimal("0.001")
val fee = Fee.Common(
amount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18),
)
val appCurrency = transferState.appCurrency
val expectedFee = stringReference(
"${feeValue.format { crypto(symbol = "ETH", decimals = 18) }} " +
"(${
fromCurrencyStatus.status.value.fiatRate!!.multiply(feeValue).format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}
})",
)
val fee: TextReference = stringReference("0.001 ETH")
val txUrl = "https://explorer.example/tx/0xabc"
val timestamp = 1_700_000_000_000L
val result = sut.createSuccessState(
uiState = baseStateHolder(),
dataState = dataState,
appCurrency = appCurrency,
isAccountsMode = true,
fee = fee,
txUrl = txUrl,
timestamp = timestamp,
fee = fee,
onExplorerClick = {},
)
@ -495,7 +517,7 @@ internal class SwapTransferStateBuilderTest {
assertThat(success.shouldShowStatusButton).isFalse()
assertThat(success.timestamp).isEqualTo(timestamp)
assertThat(success.txUrl).isEqualTo(txUrl)
assertThat(success.fee).isEqualTo(fee)
assertThat(success.fee).isEqualTo(expectedFee)
assertThat(success.providerName).isEqualTo(TextReference.EMPTY)
assertThat(success.providerType).isEqualTo(TextReference.EMPTY)
assertThat(success.providerIcon).isEmpty()
@ -613,6 +635,7 @@ internal class SwapTransferStateBuilderTest {
val result = sut.createTangemPayWithdrawalSuccessState(
uiState = baseStateHolder(),
dataState = dataState,
fee = null,
onExploreClick = onExploreClick,
)
val after = System.currentTimeMillis()
@ -707,6 +730,7 @@ internal class SwapTransferStateBuilderTest {
userWallet = coldWallet,
fromTokenInfo = fromInfo,
toTokenInfo = toInfo,
cryptoCurrencyWarning = null,
isInsufficientBalance = isInsufficientBalance,
appCurrency = AppCurrency.Default,
isBalanceHidden = false,