Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-29 15:20:33 +04:00
parent 78c4581372
commit 3069b8d32d
23 changed files with 287 additions and 20 deletions

View file

@ -235,6 +235,11 @@ sealed class NotificationUM(val config: NotificationConfig) {
subtitle = resourceReference(id = R.string.send_notification_fee_too_high_text, wrappedList(value)), subtitle = resourceReference(id = R.string.send_notification_fee_too_high_text, wrappedList(value)),
) )
data object HighNetworkFee : Warning(
title = resourceReference(id = R.string.high_fee_warning_title),
subtitle = resourceReference(id = R.string.high_fee_warning_description),
)
data class NetworkFeeUnreachable(val onRefresh: () -> Unit) : Warning( data class NetworkFeeUnreachable(val onRefresh: () -> Unit) : Warning(
title = resourceReference(R.string.send_fee_unreachable_error_title), title = resourceReference(R.string.send_fee_unreachable_error_title),
subtitle = resourceReference(R.string.send_fee_unreachable_error_text), subtitle = resourceReference(R.string.send_fee_unreachable_error_text),

View file

@ -179,6 +179,10 @@
"name": "TWI_1469_FOR_YOU_ENABLED", "name": "TWI_1469_FOR_YOU_ENABLED",
"version": "undefined" "version": "undefined"
}, },
{
"name": "TWI_1367_HIGH_FEE_WARNING_ENABLED",
"version": "undefined"
},
{ {
"name": "TWI_1638_VA_MVP0_ENABLED", "name": "TWI_1638_VA_MVP0_ENABLED",
"version": "6.1" "version": "6.1"

View file

@ -15,6 +15,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase
import com.tangem.domain.quotes.IsHighNetworkFeeUseCase
import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.quotes.multi.MultiQuoteUpdater import com.tangem.domain.quotes.multi.MultiQuoteUpdater
@ -79,4 +80,12 @@ internal object QuotesDataModule {
fun provideGetCurrencyUSDQuoteUseCase(quotesRepository: QuotesRepository): GetCurrencyUSDQuoteUseCase { fun provideGetCurrencyUSDQuoteUseCase(quotesRepository: QuotesRepository): GetCurrencyUSDQuoteUseCase {
return GetCurrencyUSDQuoteUseCase(quotesRepository) return GetCurrencyUSDQuoteUseCase(quotesRepository)
} }
@Singleton
@Provides
fun provideIsHighNetworkFeeUseCase(
getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase,
): IsHighNetworkFeeUseCase {
return IsHighNetworkFeeUseCase(getCurrencyUSDQuoteUseCase)
}
} }

View file

@ -6,4 +6,6 @@ plugins {
dependencies { dependencies {
api(projects.domain.core) api(projects.domain.core)
api(projects.domain.models) api(projects.domain.models)
testImplementation(projects.test.core)
} }

View file

@ -0,0 +1,27 @@
package com.tangem.domain.quotes
import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigDecimal
/**
* Checks whether a network fee is higher than a single hardcoded USD threshold, applied uniformly
* across all networks. The fee USD value is computed from the fee currency's USD quote
* ([GetCurrencyUSDQuoteUseCase]), independent of the user's selected app currency.
*
* Returns `false` when there is no USD quote or no raw currency id never warn without pricing data.
*/
class IsHighNetworkFeeUseCase(
private val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase,
) {
suspend operator fun invoke(feeCurrency: CryptoCurrency, feeAmount: BigDecimal): Boolean {
val rawCurrencyId = feeCurrency.id.rawCurrencyId ?: return false
val usdRate = getCurrencyUSDQuoteUseCase(rawCurrencyId) ?: return false
return feeAmount.multiply(usdRate) > HIGH_FEE_USD_THRESHOLD
}
private companion object {
val HIGH_FEE_USD_THRESHOLD = BigDecimal("10")
}
}

View file

@ -0,0 +1,87 @@
package com.tangem.domain.quotes
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class IsHighNetworkFeeUseCaseTest {
private val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase = mockk()
private val feeCurrency: CryptoCurrency = mockk()
private val rawCurrencyId = CryptoCurrency.RawID("bitcoin")
private val useCase = IsHighNetworkFeeUseCase(getCurrencyUSDQuoteUseCase)
@BeforeEach
fun setup() {
clearMocks(getCurrencyUSDQuoteUseCase, feeCurrency)
every { feeCurrency.id.rawCurrencyId } returns rawCurrencyId
}
@Test
fun `GIVEN fee usd value above threshold WHEN invoke THEN returns true`() = runTest {
// Arrange — 0.5 coin * 25 USD = 12.5 USD > 10
coEvery { getCurrencyUSDQuoteUseCase(rawCurrencyId) } returns BigDecimal("25")
// Act
val result = useCase(feeCurrency, BigDecimal("0.5"))
// Assert
assertThat(result).isTrue()
}
@Test
fun `GIVEN fee usd value below threshold WHEN invoke THEN returns false`() = runTest {
// Arrange — 0.2 coin * 25 USD = 5 USD < 10
coEvery { getCurrencyUSDQuoteUseCase(rawCurrencyId) } returns BigDecimal("25")
// Act
val result = useCase(feeCurrency, BigDecimal("0.2"))
// Assert
assertThat(result).isFalse()
}
@Test
fun `GIVEN fee usd value equal to threshold WHEN invoke THEN returns false`() = runTest {
// Arrange — 0.4 coin * 25 USD = 10 USD, not strictly above threshold
coEvery { getCurrencyUSDQuoteUseCase(rawCurrencyId) } returns BigDecimal("25")
// Act
val result = useCase(feeCurrency, BigDecimal("0.4"))
// Assert
assertThat(result).isFalse()
}
@Test
fun `GIVEN no usd quote WHEN invoke THEN returns false`() = runTest {
// Arrange
coEvery { getCurrencyUSDQuoteUseCase(rawCurrencyId) } returns null
// Act
val result = useCase(feeCurrency, BigDecimal("100"))
// Assert
assertThat(result).isFalse()
}
@Test
fun `GIVEN no raw currency id WHEN invoke THEN returns false`() = runTest {
// Arrange
every { feeCurrency.id.rawCurrencyId } returns null
// Act
val result = useCase(feeCurrency, BigDecimal("100"))
// Assert
assertThat(result).isFalse()
}
}

View file

@ -1,3 +1,5 @@
package com.tangem.features.send.api package com.tangem.features.send.api
interface SendFeatureToggles interface SendFeatureToggles {
val isHighFeeWarningEnabled: Boolean
}

View file

@ -1,6 +1,16 @@
package com.tangem.features.send package com.tangem.features.send
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.send.api.SendFeatureToggles import com.tangem.features.send.api.SendFeatureToggles
import javax.inject.Inject import javax.inject.Inject
internal class DefaultSendFeatureToggles @Inject constructor() : SendFeatureToggles internal class DefaultSendFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : SendFeatureToggles {
override val isHighFeeWarningEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.TWI_1367_HIGH_FEE_WARNING_ENABLED,
)
}

View file

@ -34,6 +34,7 @@ import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.quotes.IsHighNetworkFeeUseCase
import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase
import com.tangem.domain.settings.NeverShowTapHelpUseCase import com.tangem.domain.settings.NeverShowTapHelpUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
@ -43,6 +44,7 @@ import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.features.send.api.SendFeatureToggles
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger
@ -113,6 +115,8 @@ internal class SendConfirmModel @Inject constructor(
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val currenciesRepository: CurrenciesRepository, private val currenciesRepository: CurrenciesRepository,
private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase,
private val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase,
private val sendFeatureToggles: SendFeatureToggles,
sendBalanceUpdaterFactory: SendBalanceUpdater.Factory, sendBalanceUpdaterFactory: SendBalanceUpdater.Factory,
) : Model(), SendConfirmClickIntents, FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback { ) : Model(), SendConfirmClickIntents, FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback {
@ -503,6 +507,7 @@ internal class SendConfirmModel @Inject constructor(
private fun updateConfirmNotifications() { private fun updateConfirmNotifications() {
modelScope.launch { modelScope.launch {
val feeCryptoCurrencyStatus = getCurrencyStatusForFeePayment()
notificationsUpdateTrigger.triggerUpdate( notificationsUpdateTrigger.triggerUpdate(
data = NotificationData( data = NotificationData(
destinationAddress = confirmData.enteredDestination.orEmpty(), destinationAddress = confirmData.enteredDestination.orEmpty(),
@ -512,9 +517,10 @@ internal class SendConfirmModel @Inject constructor(
isIgnoreReduce = confirmData.isIgnoreReduce, isIgnoreReduce = confirmData.isIgnoreReduce,
fee = confirmData.fee, fee = confirmData.fee,
feeError = confirmData.feeError, feeError = confirmData.feeError,
feeCryptoCurrencyStatus = getCurrencyStatusForFeePayment(), feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
), ),
) )
val isHighNetworkFee = isHighNetworkFee(feeCryptoCurrencyStatus.currency)
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
confirmUM = SendConfirmationNotificationsTransformerV2( confirmUM = SendConfirmationNotificationsTransformerV2(
@ -524,12 +530,19 @@ internal class SendConfirmModel @Inject constructor(
cryptoCurrency = cryptoCurrencyStatus.currency, cryptoCurrency = cryptoCurrencyStatus.currency,
appCurrency = appCurrency, appCurrency = appCurrency,
analyticsCategoryName = params.analyticsCategoryName, analyticsCategoryName = params.analyticsCategoryName,
isHighNetworkFee = isHighNetworkFee,
).transform(uiState.value.confirmUM), ).transform(uiState.value.confirmUM),
) )
} }
} }
} }
private suspend fun isHighNetworkFee(feeCurrency: CryptoCurrency): Boolean {
if (!sendFeatureToggles.isHighFeeWarningEnabled) return false
val feeAmount = confirmData.fee?.amount?.value ?: return false
return isHighNetworkFeeUseCase(feeCurrency, feeAmount)
}
@Suppress("LongMethod") @Suppress("LongMethod")
private fun configConfirmNavigation() { private fun configConfirmNavigation() {
combine( combine(

View file

@ -29,6 +29,7 @@ internal class SendConfirmationNotificationsTransformerV2(
private val cryptoCurrency: CryptoCurrency, private val cryptoCurrency: CryptoCurrency,
private val appCurrency: AppCurrency, private val appCurrency: AppCurrency,
private val analyticsCategoryName: String, private val analyticsCategoryName: String,
private val isHighNetworkFee: Boolean = false,
) : Transformer<ConfirmUM> { ) : Transformer<ConfirmUM> {
override fun transform(prevState: ConfirmUM): ConfirmUM { override fun transform(prevState: ConfirmUM): ConfirmUM {
val state = prevState as? ConfirmUM.Content ?: return prevState val state = prevState as? ConfirmUM.Content ?: return prevState
@ -38,10 +39,17 @@ internal class SendConfirmationNotificationsTransformerV2(
notifications = buildList { notifications = buildList {
addTooHighNotification(feeSelectorUM) addTooHighNotification(feeSelectorUM)
addTooLowNotification(feeSelectorUM) addTooLowNotification(feeSelectorUM)
addHighNetworkFeeNotification()
}.toPersistentList(), }.toPersistentList(),
) )
} }
private fun MutableList<NotificationUM>.addHighNetworkFeeNotification() {
if (isHighNetworkFee) {
add(NotificationUM.Warning.HighNetworkFee)
}
}
private fun MutableList<NotificationUM>.addTooLowNotification(feeSelectorUM: FeeSelectorUM.Content) { private fun MutableList<NotificationUM>.addTooLowNotification(feeSelectorUM: FeeSelectorUM.Content) {
if (FeeCalculationUtils.checkIfCustomFeeTooLow(feeSelectorUM)) { if (FeeCalculationUtils.checkIfCustomFeeTooLow(feeSelectorUM)) {
add(NotificationUM.Warning.FeeTooLow) add(NotificationUM.Warning.FeeTooLow)

View file

@ -23,6 +23,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.quotes.IsHighNetworkFeeUseCase
import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase
@ -30,6 +31,7 @@ import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.SendComponent
import com.tangem.features.send.api.SendFeatureToggles
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener
@ -122,6 +124,8 @@ internal abstract class SendModelTestBase {
protected val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase = mockk(relaxed = true) protected val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase = mockk(relaxed = true)
protected val currenciesRepository: CurrenciesRepository = mockk(relaxed = true) protected val currenciesRepository: CurrenciesRepository = mockk(relaxed = true)
protected val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk(relaxed = true) protected val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk(relaxed = true)
protected val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase = mockk(relaxed = true)
protected val sendFeatureToggles: SendFeatureToggles = mockk(relaxed = true)
protected val sendAnalyticHelper: SendAnalyticHelper = mockk(relaxed = true) protected val sendAnalyticHelper: SendAnalyticHelper = mockk(relaxed = true)
protected val sendBalanceUpdaterFactory: SendBalanceUpdater.Factory = mockk(relaxed = true) protected val sendBalanceUpdaterFactory: SendBalanceUpdater.Factory = mockk(relaxed = true)
@ -233,6 +237,8 @@ internal abstract class SendModelTestBase {
manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase, manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase,
currenciesRepository = currenciesRepository, currenciesRepository = currenciesRepository,
createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase, createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase,
isHighNetworkFeeUseCase = isHighNetworkFeeUseCase,
sendFeatureToggles = sendFeatureToggles,
sendBalanceUpdaterFactory = sendBalanceUpdaterFactory, sendBalanceUpdaterFactory = sendBalanceUpdaterFactory,
) )
} }

View file

@ -76,6 +76,7 @@ class SendConfirmationNotificationsTransformerV2Test {
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency, appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName, analyticsCategoryName = analyticsCategoryName,
isHighNetworkFee = false,
) )
val initialState: ConfirmUM = ConfirmUM.Empty val initialState: ConfirmUM = ConfirmUM.Empty
@ -98,6 +99,7 @@ class SendConfirmationNotificationsTransformerV2Test {
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency, appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName, analyticsCategoryName = analyticsCategoryName,
isHighNetworkFee = false,
) )
val initialState = createTestConfirmUM() val initialState = createTestConfirmUM()
@ -120,6 +122,7 @@ class SendConfirmationNotificationsTransformerV2Test {
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency, appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName, analyticsCategoryName = analyticsCategoryName,
isHighNetworkFee = false,
) )
val initialState = createTestConfirmUM() val initialState = createTestConfirmUM()
@ -145,6 +148,7 @@ class SendConfirmationNotificationsTransformerV2Test {
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency, appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName, analyticsCategoryName = analyticsCategoryName,
isHighNetworkFee = false,
) )
val initialState = createTestConfirmUM() val initialState = createTestConfirmUM()
@ -158,6 +162,31 @@ class SendConfirmationNotificationsTransformerV2Test {
assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.TooHigh::class.java) assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.TooHigh::class.java)
} }
@Test
fun `GIVEN high network fee WHEN transform THEN returns state with high network fee notification`() = runTest {
// GIVEN
val feeSelectorUM = createNormalFeeSelectorUM()
val amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
isHighNetworkFee = true,
)
val initialState = createTestConfirmUM()
// WHEN
val result = transformer.transform(initialState)
// THEN
assertThat(result).isInstanceOf(ConfirmUM.Content::class.java)
val content = result as ConfirmUM.Content
assertThat(content.notifications).containsExactly(NotificationUM.Warning.HighNetworkFee)
}
@Test @Test
fun `GIVEN fee too low WHEN transform THEN returns state with too low notification`() = runTest { fun `GIVEN fee too low WHEN transform THEN returns state with too low notification`() = runTest {
// GIVEN // GIVEN
@ -170,6 +199,7 @@ class SendConfirmationNotificationsTransformerV2Test {
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency, appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName, analyticsCategoryName = analyticsCategoryName,
isHighNetworkFee = false,
) )
val initialState = createTestConfirmUM() val initialState = createTestConfirmUM()
@ -196,6 +226,7 @@ class SendConfirmationNotificationsTransformerV2Test {
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency, appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName, analyticsCategoryName = analyticsCategoryName,
isHighNetworkFee = false,
) )
val initialState = createTestConfirmUM() val initialState = createTestConfirmUM()

View file

@ -2,4 +2,5 @@ package com.tangem.features.swap.v2.api
interface SwapFeatureToggles { interface SwapFeatureToggles {
val isSwapProviderFilterEnabled: Boolean val isSwapProviderFilterEnabled: Boolean
val isHighFeeWarningEnabled: Boolean
} }

View file

@ -10,4 +10,7 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
) : SwapFeatureToggles { ) : SwapFeatureToggles {
override val isSwapProviderFilterEnabled: Boolean = override val isSwapProviderFilterEnabled: Boolean =
featureToggles.isFeatureEnabled(FeatureToggles.AND_15009_SWAP_PROVIDER_FILTER_ENABLED) featureToggles.isFeatureEnabled(FeatureToggles.AND_15009_SWAP_PROVIDER_FILTER_ENABLED)
override val isHighFeeWarningEnabled: Boolean =
featureToggles.isFeatureEnabled(FeatureToggles.TWI_1367_HIGH_FEE_WARNING_ENABLED)
} }

View file

@ -27,6 +27,7 @@ import com.tangem.domain.models.account.derivationIndex
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.quotes.IsHighNetworkFeeUseCase
import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase
import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapDirection.Companion.withSwapDirection import com.tangem.domain.swap.models.SwapDirection.Companion.withSwapDirection
@ -47,6 +48,7 @@ import com.tangem.features.send.api.subcomponents.destination.entity.Destination
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.swap.v2.api.SwapFeatureToggles
import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger
import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.SwapAmountReduceTrigger import com.tangem.features.swap.v2.impl.amount.SwapAmountReduceTrigger
@ -89,6 +91,8 @@ internal class SendWithSwapConfirmModel @Inject constructor(
private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase, private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase,
private val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase, private val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase,
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase,
private val swapFeatureToggles: SwapFeatureToggles,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val sendNotificationsUpdateTrigger: SendNotificationsUpdateTrigger, private val sendNotificationsUpdateTrigger: SendNotificationsUpdateTrigger,
private val swapNotificationsUpdateTrigger: SwapNotificationsUpdateTrigger, private val swapNotificationsUpdateTrigger: SwapNotificationsUpdateTrigger,
@ -467,12 +471,19 @@ internal class SendWithSwapConfirmModel @Inject constructor(
feeValue = confirmData.fee?.amount?.value, feeValue = confirmData.fee?.amount?.value,
), ),
) )
val isHighNetworkFee = isHighNetworkFee(feeCryptoCurrencyStatus.currency)
uiState.transformerUpdate( uiState.transformerUpdate(
SendWithSwapConfirmationNotificationsTransformer(), SendWithSwapConfirmationNotificationsTransformer(isHighNetworkFee = isHighNetworkFee),
) )
} }
} }
private suspend fun isHighNetworkFee(feeCurrency: CryptoCurrency): Boolean {
if (!swapFeatureToggles.isHighFeeWarningEnabled) return false
val feeAmount = confirmData.fee?.amount?.value ?: return false
return isHighNetworkFeeUseCase(feeCurrency, feeAmount)
}
private fun subscribeOnNotificationUpdates() { private fun subscribeOnNotificationUpdates() {
combine( combine(
flow = sendNotificationsUpdateListener.hasErrorFlow, flow = sendNotificationsUpdateListener.hasErrorFlow,

View file

@ -22,7 +22,9 @@ import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import com.tangem.utils.transformer.Transformer import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
internal class SendWithSwapConfirmationNotificationsTransformer : Transformer<SendWithSwapUM> { internal class SendWithSwapConfirmationNotificationsTransformer(
private val isHighNetworkFee: Boolean,
) : Transformer<SendWithSwapUM> {
override fun transform(prevState: SendWithSwapUM): SendWithSwapUM { override fun transform(prevState: SendWithSwapUM): SendWithSwapUM {
val confirmUM = prevState.confirmUM as? ConfirmUM.Content ?: return prevState val confirmUM = prevState.confirmUM as? ConfirmUM.Content ?: return prevState
val feeSelectorUM = prevState.feeSelectorUM as? FeeSelectorUM.Content ?: return prevState val feeSelectorUM = prevState.feeSelectorUM as? FeeSelectorUM.Content ?: return prevState
@ -34,11 +36,18 @@ internal class SendWithSwapConfirmationNotificationsTransformer : Transformer<Se
notifications = buildList { notifications = buildList {
addTooHighNotification(feeSelectorUM = feeSelectorUM) addTooHighNotification(feeSelectorUM = feeSelectorUM)
addTooLowNotification(feeSelectorUM = feeSelectorUM) addTooLowNotification(feeSelectorUM = feeSelectorUM)
addHighNetworkFeeNotification()
}.toPersistentList(), }.toPersistentList(),
), ),
) )
} }
private fun MutableList<NotificationUM>.addHighNetworkFeeNotification() {
if (isHighNetworkFee) {
add(NotificationUM.Warning.HighNetworkFee)
}
}
private fun MutableList<NotificationUM>.addTooLowNotification(feeSelectorUM: FeeSelectorUM.Content) { private fun MutableList<NotificationUM>.addTooLowNotification(feeSelectorUM: FeeSelectorUM.Content) {
if (checkIfCustomFeeTooLow(feeSelectorUM = feeSelectorUM)) { if (checkIfCustomFeeTooLow(feeSelectorUM = feeSelectorUM)) {
add(NotificationUM.Warning.FeeTooLow) add(NotificationUM.Warning.FeeTooLow)

View file

@ -10,4 +10,5 @@ interface SwapFeatureToggles {
val isSwapPredefinedButtonsEnabled: Boolean val isSwapPredefinedButtonsEnabled: Boolean
val isExpressShareButtonEnabled: Boolean val isExpressShareButtonEnabled: Boolean
val isSwapBestDexRateEnabled: Boolean val isSwapBestDexRateEnabled: Boolean
val isHighFeeWarningEnabled: Boolean
} }

View file

@ -52,4 +52,9 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
get() = featureTogglesManager.isFeatureEnabled( get() = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15715_SWAP_BEST_DEX_RATE_ENABLED, toggle = FeatureToggles.AND_15715_SWAP_BEST_DEX_RATE_ENABLED,
) && isSwapIntegratedApproveEnabled ) && isSwapIntegratedApproveEnabled
override val isHighFeeWarningEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.TWI_1367_HIGH_FEE_WARNING_ENABLED,
)
} }

View file

@ -65,6 +65,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.quotes.IsHighNetworkFeeUseCase
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
@ -173,6 +174,7 @@ internal class SwapModel @Inject constructor(
private val getSwapUiModeUseCase: GetSwapUiModeUseCase, private val getSwapUiModeUseCase: GetSwapUiModeUseCase,
private val setSwapUiModeUseCase: SetSwapUiModeUseCase, private val setSwapUiModeUseCase: SetSwapUiModeUseCase,
private val calculateAmountUseCase: CalculateAmountUseCase, private val calculateAmountUseCase: CalculateAmountUseCase,
private val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase,
) : Model() { ) : Model() {
private val params = paramsContainer.require<SwapComponent.Params>() private val params = paramsContainer.require<SwapComponent.Params>()
@ -1103,7 +1105,7 @@ internal class SwapModel @Inject constructor(
) )
} }
private fun setupLoadedState( private suspend fun setupLoadedState(
provider: SwapProvider, provider: SwapProvider,
state: SwapState, state: SwapState,
fromSwapCurrencyStatus: SwapCurrencyStatus, fromSwapCurrencyStatus: SwapCurrencyStatus,
@ -1127,7 +1129,7 @@ internal class SwapModel @Inject constructor(
} }
} }
private fun setupQuotesLoadedUiState(provider: SwapProvider, state: SwapState.QuotesLoadedState) { private suspend fun setupQuotesLoadedUiState(provider: SwapProvider, state: SwapState.QuotesLoadedState) {
val loadedStates = dataState.getLastLoadedSuccessStates() val loadedStates = dataState.getLastLoadedSuccessStates()
val additionalBadge = SwapProviderResolver.resolveBadge( val additionalBadge = SwapProviderResolver.resolveBadge(
provider = provider, provider = provider,
@ -1136,17 +1138,26 @@ internal class SwapModel @Inject constructor(
state = state, state = state,
isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled, isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled,
) )
val swapFee = getSelectedSwapFee()
uiState = stateBuilder.createQuotesLoadedState( uiState = stateBuilder.createQuotesLoadedState(
uiStateHolder = uiState, uiStateHolder = uiState,
quoteModel = state, quoteModel = state,
feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency, feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
swapProvider = provider, swapProvider = provider,
additionalBadge = additionalBadge, additionalBadge = additionalBadge,
swapFee = getSelectedSwapFee(), swapFee = swapFee,
feeError = feeSelectorRepository.state.value as? FeeSelectorUM.Error, feeError = feeSelectorRepository.state.value as? FeeSelectorUM.Error,
isHighNetworkFee = isHighNetworkFee(swapFee),
) )
} }
private suspend fun isHighNetworkFee(swapFee: SwapFee?): Boolean {
if (!swapFeatureToggles.isHighFeeWarningEnabled) return false
swapFee ?: return false
val feeAmount = swapFee.fee.amount.value ?: return false
return isHighNetworkFeeUseCase(swapFee.selectedFeeToken.currency, feeAmount)
}
private fun sendAnalyticsForNotifications( private fun sendAnalyticsForNotifications(
provider: SwapProvider, provider: SwapProvider,
fromToken: CryptoCurrencyStatus, fromToken: CryptoCurrencyStatus,
@ -2077,12 +2088,14 @@ internal class SwapModel @Inject constructor(
} }
analyticsEventHandler.send(SwapEvents.ProviderChosen(provider)) analyticsEventHandler.send(SwapEvents.ProviderChosen(provider))
uiState = stateBuilder.dismissBottomSheet(uiState) uiState = stateBuilder.dismissBottomSheet(uiState)
setupLoadedState( modelScope.launch {
provider = provider, setupLoadedState(
state = swapState, provider = provider,
fromSwapCurrencyStatus = fromSwapCurrencyStatus, state = swapState,
toSwapCurrencyStatus = toSwapCurrencyStatus, fromSwapCurrencyStatus = fromSwapCurrencyStatus,
) toSwapCurrencyStatus = toSwapCurrencyStatus,
)
}
} }
}, },
onProviderFilterSelect = { filterType -> onProviderFilterSelect = { filterType ->
@ -2823,12 +2836,14 @@ internal class SwapModel @Inject constructor(
} }
}, },
) )
setupLoadedState( modelScope.launch {
provider = provider, setupLoadedState(
state = swapState, provider = provider,
fromSwapCurrencyStatus = fromSwapCurrencyStatus, state = swapState,
toSwapCurrencyStatus = toSwapCurrencyStatus, fromSwapCurrencyStatus = fromSwapCurrencyStatus,
) toSwapCurrencyStatus = toSwapCurrencyStatus,
)
}
} else { } else {
TangemLogger.e("loadFee: ${feeError.error}, isHidden = true") TangemLogger.e("loadFee: ${feeError.error}, isHidden = true")
refreshTransferUIStateIfNeeded() refreshTransferUIStateIfNeeded()

View file

@ -124,6 +124,7 @@ internal class SwapNotificationsFactory(
swapFee: SwapFee?, swapFee: SwapFee?,
feeError: GetFeeError?, feeError: GetFeeError?,
appRouter: AppRouter, appRouter: AppRouter,
isHighNetworkFee: Boolean = false,
): ImmutableList<NotificationUM> { ): ImmutableList<NotificationUM> {
val warnings = buildList { val warnings = buildList {
maybeAddFeeErrorNotification(feeCryptoCurrencyStatus, quoteModel, feeError) maybeAddFeeErrorNotification(feeCryptoCurrencyStatus, quoteModel, feeError)
@ -135,10 +136,17 @@ internal class SwapNotificationsFactory(
maybeAddUnableCoverFeeWarning(quoteModel, feeCryptoCurrencyStatus, appRouter) maybeAddUnableCoverFeeWarning(quoteModel, feeCryptoCurrencyStatus, appRouter)
maybeAddTransactionInProgressWarning(quoteModel) maybeAddTransactionInProgressWarning(quoteModel)
maybeAddPriceImpactNotification(quoteModel.priceImpact) maybeAddPriceImpactNotification(quoteModel.priceImpact)
maybeAddHighNetworkFeeWarning(isHighNetworkFee)
} }
return warnings.toPersistentList() return warnings.toPersistentList()
} }
private fun MutableList<NotificationUM>.maybeAddHighNetworkFeeWarning(isHighNetworkFee: Boolean) {
if (isHighNetworkFee) {
add(NotificationUM.Warning.HighNetworkFee)
}
}
private fun MutableList<NotificationUM>.maybeAddRentExemptionError(quoteModel: SwapState.QuotesLoadedState) { private fun MutableList<NotificationUM>.maybeAddRentExemptionError(quoteModel: SwapState.QuotesLoadedState) {
quoteModel.currencyCheck?.rentWarning?.let { quoteModel.currencyCheck?.rentWarning?.let {
add(NotificationUM.Solana.RentInfo(it)) add(NotificationUM.Solana.RentInfo(it))

View file

@ -577,6 +577,7 @@ internal class StateBuilder(
additionalBadge: ProviderState.AdditionalBadge, additionalBadge: ProviderState.AdditionalBadge,
swapFee: SwapFee?, swapFee: SwapFee?,
feeError: FeeSelectorUM.Error?, feeError: FeeSelectorUM.Error?,
isHighNetworkFee: Boolean,
): SwapStateHolder { ): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
@ -590,6 +591,7 @@ internal class StateBuilder(
swapFee = swapFee, swapFee = swapFee,
feeError = feeError?.error, feeError = feeError?.error,
appRouter = appRouter, appRouter = appRouter,
isHighNetworkFee = isHighNetworkFee,
) )
val fromAccountTitleUM = when { val fromAccountTitleUM = when {

View file

@ -109,6 +109,7 @@ internal class StateBuilderSwapButtonTest {
additionalBadge = ProviderState.AdditionalBadge.Empty, additionalBadge = ProviderState.AdditionalBadge.Empty,
swapFee = null, swapFee = null,
feeError = null, feeError = null,
isHighNetworkFee = false,
) )
assertThat(result.swapButton.isEnabled).isTrue() assertThat(result.swapButton.isEnabled).isTrue()
@ -134,6 +135,7 @@ internal class StateBuilderSwapButtonTest {
additionalBadge = ProviderState.AdditionalBadge.Empty, additionalBadge = ProviderState.AdditionalBadge.Empty,
swapFee = null, swapFee = null,
feeError = null, feeError = null,
isHighNetworkFee = false,
) )
assertThat(result.swapButton.isEnabled).isFalse() assertThat(result.swapButton.isEnabled).isFalse()
@ -163,6 +165,7 @@ internal class StateBuilderSwapButtonTest {
additionalBadge = ProviderState.AdditionalBadge.Empty, additionalBadge = ProviderState.AdditionalBadge.Empty,
swapFee = null, swapFee = null,
feeError = null, feeError = null,
isHighNetworkFee = false,
) )
assertThat(result.swapButton.isEnabled).isFalse() assertThat(result.swapButton.isEnabled).isFalse()
@ -187,6 +190,7 @@ internal class StateBuilderSwapButtonTest {
additionalBadge = ProviderState.AdditionalBadge.Empty, additionalBadge = ProviderState.AdditionalBadge.Empty,
swapFee = buildSwapFee(), swapFee = buildSwapFee(),
feeError = null, feeError = null,
isHighNetworkFee = false,
) )
assertThat(result.swapButton.isEnabled).isTrue() assertThat(result.swapButton.isEnabled).isTrue()
@ -215,6 +219,7 @@ internal class StateBuilderSwapButtonTest {
additionalBadge = ProviderState.AdditionalBadge.Empty, additionalBadge = ProviderState.AdditionalBadge.Empty,
swapFee = buildSwapFee(), swapFee = buildSwapFee(),
feeError = null, feeError = null,
isHighNetworkFee = false,
) )
assertThat(result.swapButton.isEnabled).isFalse() assertThat(result.swapButton.isEnabled).isFalse()

View file

@ -26,6 +26,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.quotes.IsHighNetworkFeeUseCase
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.stories.ShouldShowStoriesUseCase import com.tangem.domain.stories.ShouldShowStoriesUseCase
@ -103,6 +104,7 @@ internal abstract class SwapModelTestBase {
protected val getSwapUiModeUseCase: GetSwapUiModeUseCase = mockk(relaxed = true) protected val getSwapUiModeUseCase: GetSwapUiModeUseCase = mockk(relaxed = true)
protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true) protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true)
protected val calculateAmountUseCase: CalculateAmountUseCase = mockk(relaxed = true) protected val calculateAmountUseCase: CalculateAmountUseCase = mockk(relaxed = true)
protected val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase = mockk(relaxed = true)
protected val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk(relaxed = true) protected val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk(relaxed = true)
protected val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true) protected val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true)
@ -175,6 +177,7 @@ internal abstract class SwapModelTestBase {
getSwapUiModeUseCase = getSwapUiModeUseCase, getSwapUiModeUseCase = getSwapUiModeUseCase,
setSwapUiModeUseCase = setSwapUiModeUseCase, setSwapUiModeUseCase = setSwapUiModeUseCase,
calculateAmountUseCase = calculateAmountUseCase, calculateAmountUseCase = calculateAmountUseCase,
isHighNetworkFeeUseCase = isHighNetworkFeeUseCase,
isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase, isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase,
sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase, sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase,
) )