Updated on 2026-08-14
This commit is contained in:
parent
675d13ab6f
commit
742795d2d9
10 changed files with 205 additions and 62 deletions
|
|
@ -102,7 +102,7 @@ fun ClickableWarningCard(
|
|||
* >Figma component</a>
|
||||
*/
|
||||
@Composable
|
||||
fun RefreshableWaringCard(
|
||||
fun RefreshableWarningCard(
|
||||
title: String,
|
||||
description: String,
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
|
|
@ -193,7 +193,7 @@ private fun WarningsPreview() {
|
|||
onClick = {},
|
||||
)
|
||||
SpacerH32()
|
||||
RefreshableWaringCard(
|
||||
RefreshableWarningCard(
|
||||
title = "Exchange rate has expired",
|
||||
description = "To access all the networks, you need to scan the card.",
|
||||
onClick = {},
|
||||
|
|
|
|||
|
|
@ -7,4 +7,6 @@ sealed class Warning {
|
|||
data class ExistentialDepositWarning(val existentialDeposit: BigDecimal) : Warning()
|
||||
|
||||
data class MinAmountWarning(val dustValue: BigDecimal) : Warning()
|
||||
|
||||
data class ReduceAmountWarning(val tezosFeeThreshold: BigDecimal) : Warning()
|
||||
}
|
||||
|
|
@ -79,8 +79,6 @@ interface SwapInteractor {
|
|||
*/
|
||||
fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount
|
||||
|
||||
fun isAvailableToSwap(networkId: String): Boolean
|
||||
|
||||
fun getSelectedWallet(): UserWallet?
|
||||
|
||||
suspend fun selectInitialCurrencyToSwap(
|
||||
|
|
|
|||
|
|
@ -197,7 +197,6 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
return repository.getPairs(initialCurrency, currenciesList)
|
||||
}
|
||||
|
||||
@Deprecated("used in old swap mechanism")
|
||||
override suspend fun givePermissionToSwap(networkId: String, permissionOptions: PermissionOptions): TxState {
|
||||
val derivationPath = permissionOptions.fromToken.network.derivationPath.value
|
||||
val dataToSign = if (permissionOptions.approveType == SwapApproveType.UNLIMITED) {
|
||||
|
|
@ -241,7 +240,6 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
@Deprecated("used in old swap mechanism")
|
||||
override suspend fun findBestQuote(
|
||||
fromToken: CryptoCurrencyStatus,
|
||||
toToken: CryptoCurrencyStatus,
|
||||
|
|
@ -376,6 +374,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
val warnings = mutableListOf<Warning>()
|
||||
manageExistentialDepositWarning(warnings, userWalletId, amount, fromToken)
|
||||
manageDustWarning(warnings, feeState, userWalletId, fromTokenStatus, amount)
|
||||
manageReduceAmountWarning(warnings, fromTokenStatus, amount)
|
||||
return warnings
|
||||
}
|
||||
|
||||
|
|
@ -424,6 +423,17 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun manageReduceAmountWarning(
|
||||
warnings: MutableList<Warning>,
|
||||
fromTokenStatus: CryptoCurrencyStatus,
|
||||
amount: SwapAmount,
|
||||
) {
|
||||
val isTezos = fromTokenStatus.currency.network.id.value == Blockchain.Tezos.id
|
||||
if (isTezos && amount.value == fromTokenStatus.value.amount) {
|
||||
warnings.add(Warning.ReduceAmountWarning(TEZOS_FEE_THRESHOLD))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onSwap(
|
||||
swapProvider: SwapProvider,
|
||||
swapData: SwapDataModel?,
|
||||
|
|
@ -729,16 +739,10 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
@Deprecated("used in old swap mechanism")
|
||||
override fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount {
|
||||
return SwapAmount(token.value.amount ?: BigDecimal.ZERO, token.currency.decimals)
|
||||
}
|
||||
|
||||
@Deprecated("used in old swap mechanism")
|
||||
override fun isAvailableToSwap(networkId: String): Boolean {
|
||||
return ONE_INCH_SUPPORTED_NETWORKS.contains(networkId)
|
||||
}
|
||||
|
||||
override suspend fun selectInitialCurrencyToSwap(
|
||||
initialCryptoCurrency: CryptoCurrency,
|
||||
state: TokensDataStateExpress,
|
||||
|
|
@ -1575,16 +1579,6 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
private const val INCREASE_GAS_LIMIT_BY = 112 // 12%
|
||||
private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5%
|
||||
private const val INFINITY_SYMBOL = "∞"
|
||||
|
||||
private val ONE_INCH_SUPPORTED_NETWORKS = listOf(
|
||||
"ethereum",
|
||||
"binance-smart-chain",
|
||||
"polygon-pos",
|
||||
"optimistic-ethereum",
|
||||
"arbitrum-one",
|
||||
"xdai",
|
||||
"avalanche",
|
||||
"fantom",
|
||||
)
|
||||
private val TEZOS_FEE_THRESHOLD = BigDecimal("0.01")
|
||||
}
|
||||
}
|
||||
|
|
@ -67,4 +67,5 @@ dependencies {
|
|||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ data class SwapStateHolder(
|
|||
val alert: SwapWarning.GenericWarning? = null,
|
||||
val changeCardsButtonState: ChangeCardsButtonState = ChangeCardsButtonState.ENABLED,
|
||||
val providerState: ProviderState,
|
||||
val reduceAmountIgnore: Boolean, // ignore warning about reducing XTZ amount by 0.01
|
||||
|
||||
val fee: FeeItemState = FeeItemState.Empty,
|
||||
val permissionState: SwapPermissionState = SwapPermissionState.Empty,
|
||||
|
|
@ -115,6 +116,8 @@ sealed interface SwapWarning {
|
|||
data class GeneralWarning(val notificationConfig: NotificationConfig) : SwapWarning
|
||||
data class GeneralInformational(val notificationConfig: NotificationConfig) : SwapWarning
|
||||
data class TransactionInProgressWarning(val title: TextReference, val description: TextReference) : SwapWarning
|
||||
data class NeedReserveToCreateAccount(val notificationConfig: NotificationConfig) : SwapWarning
|
||||
data class ReduceAmount(val notificationConfig: NotificationConfig) : SwapWarning
|
||||
}
|
||||
|
||||
enum class GenericWarningType {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.ui.TxFee
|
||||
|
||||
data class UiActions(
|
||||
|
|
@ -13,6 +14,8 @@ data class UiActions(
|
|||
val onChangeCardsClicked: () -> Unit,
|
||||
val onBackClicked: () -> Unit,
|
||||
val onMaxAmountSelected: () -> Unit,
|
||||
val onReduceAmount: (SwapAmount) -> Unit,
|
||||
val onReduceAmountIgnoreClick: () -> Unit,
|
||||
val openPermissionBottomSheet: () -> Unit,
|
||||
val onChangeApproveType: (ApproveType) -> Unit,
|
||||
// region new actions
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ internal class StateBuilder(
|
|||
onShowPermissionBottomSheet = actions.openPermissionBottomSheet,
|
||||
providerState = ProviderState.Empty(),
|
||||
shouldShowMaxAmount = false,
|
||||
reduceAmountIgnore = false,
|
||||
priceImpact = PriceImpact.Empty(),
|
||||
)
|
||||
}
|
||||
|
|
@ -213,7 +214,11 @@ internal class StateBuilder(
|
|||
): SwapStateHolder {
|
||||
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
val warnings = getWarningsForSuccessState(quoteModel, fromToken)
|
||||
val warnings = getWarningsForSuccessState(
|
||||
quoteModel = quoteModel,
|
||||
fromToken = fromToken,
|
||||
ignoreAmountReduce = uiStateHolder.reduceAmountIgnore,
|
||||
)
|
||||
val feeState = createFeeState(quoteModel.txFee, selectedFeeType)
|
||||
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
|
||||
val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus
|
||||
|
|
@ -311,36 +316,23 @@ internal class StateBuilder(
|
|||
private fun getWarningsForSuccessState(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
fromToken: CryptoCurrency,
|
||||
ignoreAmountReduce: Boolean,
|
||||
): List<SwapWarning> {
|
||||
val warnings = mutableListOf<SwapWarning>()
|
||||
addDomainWarnings(quoteModel, warnings)
|
||||
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
|
||||
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
|
||||
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
|
||||
) {
|
||||
warnings.add(
|
||||
SwapWarning.PermissionNeeded(
|
||||
createPermissionNotificationConfig(fromToken.symbol),
|
||||
),
|
||||
)
|
||||
}
|
||||
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
|
||||
is IncludeFeeInAmount.Included ->
|
||||
warnings.add(
|
||||
SwapWarning.GeneralWarning(
|
||||
createNetworkFeeCoverageNotificationConfig(),
|
||||
),
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
addUnableCoverFeeWarning(quoteModel, fromToken, warnings)
|
||||
// check isBalanceEnough, but for dex includeFeeInAmount always Excluded
|
||||
if (!quoteModel.preparedSwapConfigState.isBalanceEnough &&
|
||||
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
|
||||
) {
|
||||
warnings.add(SwapWarning.InsufficientFunds)
|
||||
}
|
||||
maybeAddDomainWarnings(quoteModel, warnings, ignoreAmountReduce)
|
||||
maybeAddNeedReserveToCreateAccountWarning(quoteModel, warnings)
|
||||
maybeAddPermissionNeededWarning(quoteModel, warnings, fromToken)
|
||||
maybeAddNetworkFeeCoverageWarning(quoteModel, warnings)
|
||||
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, warnings)
|
||||
maybeAddInsufficientFundsWarning(quoteModel, warnings)
|
||||
maybeAddTransactionInProgressWarning(quoteModel, warnings)
|
||||
return warnings
|
||||
}
|
||||
|
||||
private fun maybeAddTransactionInProgressWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
warnings: MutableList<SwapWarning>,
|
||||
) {
|
||||
if (quoteModel.permissionState is PermissionDataState.PermissionLoading) {
|
||||
warnings.add(
|
||||
SwapWarning.TransactionInProgressWarning(
|
||||
|
|
@ -361,10 +353,13 @@ internal class StateBuilder(
|
|||
),
|
||||
)
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
private fun addDomainWarnings(quoteModel: SwapState.QuotesLoadedState, warnings: MutableList<SwapWarning>) {
|
||||
private fun maybeAddDomainWarnings(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
warnings: MutableList<SwapWarning>,
|
||||
ignoreAmountReduce: Boolean,
|
||||
) {
|
||||
quoteModel.warnings.forEach {
|
||||
when (it) {
|
||||
is Warning.ExistentialDepositWarning -> {
|
||||
|
|
@ -401,11 +396,84 @@ internal class StateBuilder(
|
|||
),
|
||||
)
|
||||
}
|
||||
is Warning.ReduceAmountWarning -> {
|
||||
if (!ignoreAmountReduce) {
|
||||
warnings.add(
|
||||
SwapWarning.ReduceAmount(
|
||||
notificationConfig = createReduceAmountNotificationConfig(
|
||||
amount = it.tezosFeeThreshold.toPlainString(),
|
||||
onConfirmClick = {
|
||||
val fromAmount = quoteModel.fromTokenInfo.tokenAmount
|
||||
val patchedAmount = fromAmount.copy(
|
||||
value = fromAmount.value - it.tezosFeeThreshold,
|
||||
)
|
||||
actions.onReduceAmount(patchedAmount)
|
||||
},
|
||||
onDismissClick = actions.onReduceAmountIgnoreClick,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addUnableCoverFeeWarning(
|
||||
private fun maybeAddNeedReserveToCreateAccountWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
warnings: MutableList<SwapWarning>,
|
||||
) {
|
||||
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
|
||||
if (status is CryptoCurrencyStatus.NoAccount) {
|
||||
val amount = quoteModel.toTokenInfo.tokenAmount.value
|
||||
val amountToCreateAccount = status.amountToCreateAccount
|
||||
|
||||
if (amount < amountToCreateAccount) {
|
||||
warnings.add(
|
||||
SwapWarning.NeedReserveToCreateAccount(
|
||||
notificationConfig = createActivateAccountNotificationConfig(
|
||||
status.amountToCreateAccount,
|
||||
quoteModel.toTokenInfo.cryptoCurrencyStatus.currency.name,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeAddPermissionNeededWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
warnings: MutableList<SwapWarning>,
|
||||
fromToken: CryptoCurrency,
|
||||
) {
|
||||
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
|
||||
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
|
||||
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
|
||||
) {
|
||||
warnings.add(
|
||||
SwapWarning.PermissionNeeded(
|
||||
createPermissionNotificationConfig(fromToken.symbol),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeAddNetworkFeeCoverageWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
warnings: MutableList<SwapWarning>,
|
||||
) {
|
||||
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
|
||||
is IncludeFeeInAmount.Included ->
|
||||
warnings.add(
|
||||
SwapWarning.GeneralWarning(
|
||||
createNetworkFeeCoverageNotificationConfig(),
|
||||
),
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeAddUnableCoverFeeWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
fromToken: CryptoCurrency,
|
||||
warnings: MutableList<SwapWarning>,
|
||||
|
|
@ -428,7 +496,29 @@ internal class StateBuilder(
|
|||
}
|
||||
}
|
||||
|
||||
private fun maybeAddInsufficientFundsWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
warnings: MutableList<SwapWarning>,
|
||||
) {
|
||||
// check isBalanceEnough, but for dex includeFeeInAmount always Excluded
|
||||
if (!quoteModel.preparedSwapConfigState.isBalanceEnough &&
|
||||
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
|
||||
) {
|
||||
warnings.add(SwapWarning.InsufficientFunds)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSwapButtonEnabled(quoteModel: SwapState.QuotesLoadedState): Boolean {
|
||||
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
|
||||
if (status is CryptoCurrencyStatus.NoAccount) {
|
||||
val amount = quoteModel.toTokenInfo.tokenAmount.value
|
||||
val amountToCreateAccount = status.amountToCreateAccount
|
||||
|
||||
if (amount < amountToCreateAccount) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
val preparedSwapConfigState = quoteModel.preparedSwapConfigState
|
||||
// check has has outgoing transaction
|
||||
if (preparedSwapConfigState.hasOutgoingTransaction) return false
|
||||
|
|
@ -1212,6 +1302,35 @@ internal class StateBuilder(
|
|||
)
|
||||
}
|
||||
|
||||
private fun createActivateAccountNotificationConfig(amount: BigDecimal, token: String): NotificationConfig {
|
||||
return NotificationConfig(
|
||||
title = resourceReference(
|
||||
id = R.string.send_notification_invalid_reserve_amount_title,
|
||||
formatArgs = wrappedList("$amount $token"),
|
||||
),
|
||||
subtitle = resourceReference(R.string.send_notification_invalid_reserve_amount_text),
|
||||
iconResId = R.drawable.img_attention_20,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createReduceAmountNotificationConfig(
|
||||
amount: String,
|
||||
onConfirmClick: () -> Unit,
|
||||
onDismissClick: () -> Unit,
|
||||
): NotificationConfig {
|
||||
return NotificationConfig(
|
||||
title = resourceReference(R.string.send_notification_high_fee_title),
|
||||
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(amount)),
|
||||
iconResId = R.drawable.img_attention_20,
|
||||
buttonsState = NotificationConfig.ButtonsState.PairButtonsConfig(
|
||||
primaryText = resourceReference(R.string.xtz_withdrawal_message_reduce, wrappedList(amount)),
|
||||
onPrimaryClick = onConfirmClick,
|
||||
secondaryText = resourceReference(R.string.xtz_withdrawal_message_ignore),
|
||||
onSecondaryClick = onDismissClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createUnableToCoverFeeNotificationConfig(
|
||||
fromToken: CryptoCurrency,
|
||||
feeCurrency: CryptoCurrency?,
|
||||
|
|
|
|||
|
|
@ -339,7 +339,7 @@ private fun SwapWarnings(warnings: List<SwapWarning>) {
|
|||
is SwapWarning.GenericWarning -> {
|
||||
val message = warning.message?.resolveReference()
|
||||
?: stringResource(id = R.string.common_unknown_error)
|
||||
RefreshableWaringCard(
|
||||
RefreshableWarningCard(
|
||||
title = stringResource(id = R.string.common_warning),
|
||||
description = message,
|
||||
onClick = warning.onClick,
|
||||
|
|
@ -372,6 +372,16 @@ private fun SwapWarnings(warnings: List<SwapWarning>) {
|
|||
iconTint = TangemTheme.colors.icon.accent,
|
||||
)
|
||||
}
|
||||
is SwapWarning.NeedReserveToCreateAccount -> {
|
||||
Notification(
|
||||
config = warning.notificationConfig,
|
||||
)
|
||||
}
|
||||
is SwapWarning.ReduceAmount -> {
|
||||
Notification(
|
||||
config = warning.notificationConfig,
|
||||
)
|
||||
}
|
||||
is SwapWarning.TransactionInProgressWarning -> {
|
||||
CardWithIcon(
|
||||
title = warning.title.resolveReference(),
|
||||
|
|
@ -494,6 +504,7 @@ private val state = SwapStateHolder(
|
|||
providerState = ProviderState.Loading(),
|
||||
priceImpact = PriceImpact.Empty(),
|
||||
shouldShowMaxAmount = true,
|
||||
reduceAmountIgnore = false,
|
||||
tosState = TosState(
|
||||
tosLink = LegalState(
|
||||
title = stringReference("Terms of Use"),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import com.tangem.feature.swap.domain.BlockchainInteractor
|
|||
import com.tangem.feature.swap.domain.SwapInteractor
|
||||
import com.tangem.feature.swap.domain.models.DataError
|
||||
import com.tangem.feature.swap.domain.models.ExpressException
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.formatToUIRepresentation
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
|
|
@ -810,6 +811,10 @@ internal class SwapViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun onReduceAmountClicked(newAmount: SwapAmount) {
|
||||
onAmountChanged(newAmount.formatToUIRepresentation())
|
||||
}
|
||||
|
||||
@Suppress("UnusedPrivateMember")
|
||||
private fun onAmountSelected(selected: Boolean) {
|
||||
if (selected) {
|
||||
|
|
@ -872,7 +877,14 @@ internal class SwapViewModel @Inject constructor(
|
|||
}
|
||||
onSearchEntered("")
|
||||
},
|
||||
onMaxAmountSelected = { onMaxAmountClicked() },
|
||||
onMaxAmountSelected = ::onMaxAmountClicked,
|
||||
onReduceAmount = ::onReduceAmountClicked,
|
||||
onReduceAmountIgnoreClick = {
|
||||
uiState = uiState.copy(
|
||||
reduceAmountIgnore = true,
|
||||
warnings = uiState.warnings.filter { it !is SwapWarning.ReduceAmount },
|
||||
)
|
||||
},
|
||||
openPermissionBottomSheet = {
|
||||
singleTaskScheduler.cancelTask()
|
||||
analyticsEventHandler.send(SwapEvents.ButtonGivePermissionClicked)
|
||||
|
|
@ -1151,11 +1163,11 @@ internal class SwapViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val loggingTag = "SwapViewModel"
|
||||
private const val INITIAL_AMOUNT = ""
|
||||
private const val UPDATE_DELAY = 10000L
|
||||
private const val DEBOUNCE_AMOUNT_DELAY = 1000L
|
||||
private const val UPDATE_BALANCE_DELAY_MILLIS = 11000L
|
||||
private companion object {
|
||||
const val loggingTag = "SwapViewModel"
|
||||
const val INITIAL_AMOUNT = ""
|
||||
const val UPDATE_DELAY = 10000L
|
||||
const val DEBOUNCE_AMOUNT_DELAY = 1000L
|
||||
const val UPDATE_BALANCE_DELAY_MILLIS = 11000L
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue