Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-08 15:45:10 +05:00
parent 1cf8a16fae
commit 24d7953389
30 changed files with 3096 additions and 494 deletions

View file

@ -4,6 +4,7 @@ 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.BLOCKCHAIN
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.Key.FEE_TOKEN
@ -12,6 +13,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
import com.tangem.core.analytics.models.getReferralParams
import com.tangem.domain.models.currency.CryptoCurrency
@ -340,6 +342,21 @@ sealed class SwapEvents(
"Network fee" to feeNetwork.name,
),
), AppsFlyerIncludedEvent
class ApproveGasOverrideError(
fromTokenSymbol: String,
fromTokenBlockchain: String,
rpcProvider: String,
error: String,
) : SwapEvents(
event = "Gas Estimation Override Error",
params = mapOf(
TOKEN_PARAM to fromTokenSymbol,
BLOCKCHAIN to fromTokenBlockchain,
"RPC Provider" to rpcProvider,
ERROR_MESSAGE to error,
),
)
}
private fun PredefinedPercentAmount.toAnalyticsValue(): String = when (this) {

View file

@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import arrow.core.Either
import arrow.core.flatMap
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
@ -1270,6 +1271,7 @@ internal class SwapModel @Inject constructor(
fee = swapFee,
expressOperationType = ExpressOperationType.SWAP,
isTangemPayWithdrawal = isTangemPayWithdrawal,
integratedApproval = lastLoadedQuotesState.integratedApprovalData,
)
}.onSuccess { swapTransactionState ->
when (swapTransactionState) {
@ -2302,12 +2304,13 @@ internal class SwapModel @Inject constructor(
val toSwapCurrencyStatus =
dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val amount = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError)
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
fromSwapCurrencyStatus.currency,
toSwapCurrencyStatus.currency,
)
if (shouldTransferInsteadOfSwap) {
return swapTransferInteractor.loadFee(
return if (shouldTransferInsteadOfSwap) {
swapTransferInteractor.loadFee(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = amount,
@ -2316,7 +2319,20 @@ internal class SwapModel @Inject constructor(
}.onRight {
TangemLogger.e("loadFee[transfer]: Fee loaded successfully")
}
} else {
loadSwapModeFee(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
amount = amount,
)
}
}
private suspend fun loadSwapModeFee(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
amount: BigDecimal,
): Either<GetFeeError, TransactionFee> {
val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError)
if (isPermissionNotificationShown()) {
@ -2331,8 +2347,12 @@ internal class SwapModel @Inject constructor(
}
ExchangeProviderType.CEX -> null
}
val integratedSettings = (quoteState.permissionState as? PermissionDataState.PermissionSettings)
?.takeIf { swapFeatureToggles.isSwapIntegratedApproveEnabled }
// Get swap tx fee
return swapInteractor.loadSwapFee(
provider = quoteState.swapProvider,
quotesLoadedState = quoteState,
fromStatus = fromSwapCurrencyStatus,
toStatus = toSwapCurrencyStatus,
amount = swapAmount,
@ -2346,6 +2366,19 @@ internal class SwapModel @Inject constructor(
}
}.onLeft {
TangemLogger.e("loadFee: Failed to load fee with error $it")
}.flatMap { swapTxFee ->
if (integratedSettings != null) {
// Get fee & tx data for integrated approval case
loadAndStoreIntegratedApproval(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
quoteState = quoteState,
permissionSettings = integratedSettings,
approvalAmount = amount,
swapTxFee = swapTxFee,
)
} else {
Either.Right(swapTxFee)
}
}
}
@ -2369,42 +2402,43 @@ internal class SwapModel @Inject constructor(
fromTokenAmount = amount,
selectedToken = selectedToken,
)
}
val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError)
} else {
val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError)
if (isPermissionNotificationShown()) {
return Either.Left(GetFeeError.UnknownError)
}
val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals)
// DEX path requires a SwapDataModel.
val swapDataForCall = when (quoteState.swapProvider.type) {
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
// TODO support gasless in DEX/DEX_BRIDGE
return Either.Left(GetFeeError.GaslessError.NetworkIsNotSupported)
if (isPermissionNotificationShown()) {
return Either.Left(GetFeeError.UnknownError)
}
ExchangeProviderType.CEX -> null
}
return swapInteractor.loadSwapFee(
provider = quoteState.swapProvider,
fromStatus = fromSwapCurrencyStatus,
toStatus = toSwapCurrencyStatus,
amount = swapAmount,
swapData = swapDataForCall,
selectedFeeToken = selectedToken,
isGasless = true,
).map { swapFee ->
// The fee selector block consumes TransactionFeeExtended; build one when
// `transactionFeeResult` is LoadedExtended, else wrap the native fee in a
// pass-through TransactionFeeExtended for compatibility with the block API.
when (val res = swapFee.transactionFeeResult) {
is TransactionFeeResult.LoadedExtended -> res.fee
is TransactionFeeResult.Loaded -> TransactionFeeExtended(
transactionFee = res.fee,
feeTokenId = swapFee.selectedFeeToken.currency.id,
)
val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals)
// DEX path requires a SwapDataModel.
val swapDataForCall = when (quoteState.swapProvider.type) {
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
// TODO support gasless in DEX/DEX_BRIDGE
return Either.Left(GetFeeError.GaslessError.NetworkIsNotSupported)
}
ExchangeProviderType.CEX -> null
}
return swapInteractor.loadSwapFee(
quotesLoadedState = quoteState,
fromStatus = fromSwapCurrencyStatus,
toStatus = toSwapCurrencyStatus,
amount = swapAmount,
swapData = swapDataForCall,
selectedFeeToken = selectedToken,
isGasless = true,
).map { swapFee ->
// The fee selector block consumes TransactionFeeExtended; build one when
// `transactionFeeResult` is LoadedExtended, else wrap the native fee in a
// pass-through TransactionFeeExtended for compatibility with the block API.
when (val res = swapFee.transactionFeeResult) {
is TransactionFeeResult.LoadedExtended -> res.fee
is TransactionFeeResult.Loaded -> TransactionFeeExtended(
transactionFee = res.fee,
feeTokenId = swapFee.selectedFeeToken.currency.id,
)
}
}
}
}
@ -2412,60 +2446,56 @@ internal class SwapModel @Inject constructor(
override fun onResult(newState: FeeSelectorUM) {
state.value = newState
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return
if (newState is FeeSelectorUM.Error) {
TangemLogger.e("loadFee: ${newState.error}, isHidden = true")
refreshTransferUIStateIfNeeded()
uiState = stateBuilder.createFeeErrorState(
uiStateHolder = uiState,
quoteModel = dataState.getCurrentLoadedSwapState() ?: return,
feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
feeError = newState.error,
handleFeeError(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
feeError = newState,
)
modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) }
return
}
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
// Transfer mode has its own fee pipeline and doesn't use swap quotes.
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
fromSwapCurrencyStatus?.currency,
toSwapCurrencyStatus?.currency,
)
if (shouldTransferInsteadOfSwap) {
refreshTransferUIStateIfNeeded(
feePaidCryptoCurrencyStatus = getSelectedSwapFee()?.selectedFeeToken,
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
} else {
// Transfer mode has its own fee pipeline and doesn't use swap quotes.
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
fromSwapCurrencyStatus.currency,
toSwapCurrencyStatus.currency,
)
return
}
val quoteState = dataState.getCurrentLoadedSwapState() ?: return
val swapFee = getSelectedSwapFee() ?: return
modelScope.launch(dispatchers.default) {
val patchedState = swapInteractor.applySwapFee(
state = quoteState,
fee = swapFee,
lastReducedBalanceBy = lastReducedBalanceBy.value,
)
val patchedStates = dataState.lastLoadedSwapStates.toMutableMap().apply {
put(quoteState.swapProvider, patchedState)
if (shouldTransferInsteadOfSwap) {
refreshTransferUIStateIfNeeded(
feePaidCryptoCurrencyStatus = getSelectedSwapFee()?.selectedFeeToken,
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
)
return
}
withContext(dispatchers.main) {
dataState = dataState.copy(
lastLoadedSwapStates = patchedStates,
feePaidCryptoCurrency = swapFee.selectedFeeToken,
)
// Refresh UI via the existing pipeline.
val updatedFromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return@withContext
val updatedToSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return@withContext
setupLoadedState(
provider = quoteState.swapProvider,
state = patchedState,
fromSwapCurrencyStatus = updatedFromSwapCurrencyStatus,
toSwapCurrencyStatus = updatedToSwapCurrencyStatus,
val quoteState = dataState.getCurrentLoadedSwapState() ?: return
val swapFee = getSelectedSwapFee() ?: return
modelScope.launch(dispatchers.default) {
val patchedState = swapInteractor.applySwapFee(
state = quoteState,
fee = swapFee,
lastReducedBalanceBy = lastReducedBalanceBy.value,
)
val patchedStates = dataState.lastLoadedSwapStates.toMutableMap().apply {
put(quoteState.swapProvider, patchedState)
}
withContext(dispatchers.main) {
dataState = dataState.copy(
lastLoadedSwapStates = patchedStates,
feePaidCryptoCurrency = swapFee.selectedFeeToken,
)
// Refresh UI via the existing pipeline.
val updatedFromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return@withContext
val updatedToSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return@withContext
setupLoadedState(
provider = quoteState.swapProvider,
state = patchedState,
fromSwapCurrencyStatus = updatedFromSwapCurrencyStatus,
toSwapCurrencyStatus = updatedToSwapCurrencyStatus,
)
}
}
}
}
@ -2481,7 +2511,139 @@ internal class SwapModel @Inject constructor(
private fun isPermissionNotificationShown(): Boolean {
val permissionState = dataState.getCurrentLoadedSwapState()?.permissionState
return permissionState != null && permissionState !is PermissionDataState.Empty
val isApprovalIntegrated = swapFeatureToggles.isSwapIntegratedApproveEnabled &&
permissionState is PermissionDataState.PermissionSettings
return permissionState != null && permissionState !is PermissionDataState.Empty && !isApprovalIntegrated
}
private fun handleFeeError(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
feeError: FeeSelectorUM.Error,
) {
val error = feeError.error
if (error is GetFeeError.EstimateOverrideError) {
analyticsEventHandler.send(
SwapEvents.ApproveGasOverrideError(
fromTokenSymbol = error.tokenSymbol,
fromTokenBlockchain = error.blockchain,
rpcProvider = error.rpcProvider,
error = error.error,
),
)
val (provider, swapState) = updateLoadedQuotes(
dataState.lastLoadedSwapStates.mapValues { (_, state) ->
if (state is SwapState.QuotesLoadedState) {
val permissionState = state.permissionState
if (permissionState is PermissionDataState.PermissionSettings) {
swapInteractor.integratedApprovalFallback(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
spenderAddress = permissionState.spenderAddress,
)
state.copy(
integratedApprovalData = null,
permissionState = PermissionDataState.PermissionRequired(
isResetApproval = false,
spenderAddress = permissionState.spenderAddress,
),
)
} else {
state
}
} else {
state
}
},
)
setupLoadedState(
provider = provider,
state = swapState,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
} else {
TangemLogger.e("loadFee: ${feeError.error}, isHidden = true")
refreshTransferUIStateIfNeeded()
uiState = stateBuilder.createFeeErrorState(
uiStateHolder = uiState,
quoteModel = dataState.getCurrentLoadedSwapState() ?: return,
feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
feeError = feeError.error,
)
modelScope.launch { forceUpdateState.emit(feeError.copy(isHidden = true)) }
}
}
}
/**
* Loads the approval transaction + its fee, stores both on the current
* [SwapState.QuotesLoadedState] as [IntegratedApprovalData], and returns the *combined*
* [TransactionFee] (approve + swap, per bucket) for the fee selector to render.
*
* The user sees a single fee number that already includes the approval cost. At submission
* time `onSwapClick` reads the stored [IntegratedApprovalData] back from
* `lastLoadedSwapStates` and sends both txs in a single DEFAULT-mode batch.
*/
private suspend fun loadAndStoreIntegratedApproval(
fromSwapCurrencyStatus: SwapCurrencyStatus,
quoteState: SwapState.QuotesLoadedState,
permissionSettings: PermissionDataState.PermissionSettings,
approvalAmount: BigDecimal,
swapTxFee: TransactionFee,
): Either<GetFeeError, TransactionFee> {
return swapInteractor.loadIntegratedApprovalData(
fromStatus = fromSwapCurrencyStatus,
spenderAddress = permissionSettings.spenderAddress,
approveType = permissionSettings.type,
approvalAmount = approvalAmount,
).onLeft {
TangemLogger.e("loadAndStoreIntegratedApproval: failed: $it")
}.map { integratedApprovalData ->
val selectedProvider = quoteState.swapProvider
val updatedState = quoteState.copy(integratedApprovalData = integratedApprovalData)
dataState = dataState.copy(
lastLoadedSwapStates = dataState.lastLoadedSwapStates.toMutableMap().apply {
put(selectedProvider, updatedState)
},
)
combineTransactionFees(integratedApprovalData.approvalFee, swapTxFee)
}
}
/**
* Per-bucket sum of two EVM [TransactionFee]s used to present the integrated
* approve+swap total to the user. Mirrors `GiveApprovalModel.estimateFeeForResetApproval`'s
* sum strategy (same gas-price, summed gas-limit). Non-EVM fees fall back to the swap fee
* alone since the integrated path is currently EVM-only (DEX, non-Solana).
*/
private fun combineTransactionFees(approvalFee: TransactionFee, swapFee: TransactionFee): TransactionFee {
return when {
approvalFee is TransactionFee.Choosable && swapFee is TransactionFee.Choosable ->
TransactionFee.Choosable(
minimum = sumEvmFees(approvalFee.minimum, swapFee.minimum),
normal = sumEvmFees(approvalFee.normal, swapFee.normal),
priority = sumEvmFees(approvalFee.priority, swapFee.priority),
)
else -> TransactionFee.Single(normal = sumEvmFees(approvalFee.normal, swapFee.normal))
}
}
/**
* Sums two [Fee.Ethereum] fees as approval + swap. Adds gas limits (same gas price) and
* recomputes the on-chain amount. For non-Ethereum fees returns [right] unchanged the
* integrated approve+swap path is EVM-only today.
*/
private fun sumEvmFees(left: Fee, right: Fee): Fee {
if (left !is Fee.Ethereum || right !is Fee.Ethereum) return right
val leftValue = left.amount.value ?: return right
val rightValue = right.amount.value ?: return right
val combinedValue = leftValue + rightValue
val combinedGasLimit = left.gasLimit + right.gasLimit
val combinedAmount = right.amount.copy(value = combinedValue)
return when (right) {
is Fee.Ethereum.EIP1559 -> right.copy(amount = combinedAmount, gasLimit = combinedGasLimit)
is Fee.Ethereum.Legacy -> right.copy(amount = combinedAmount, gasLimit = combinedGasLimit)
is Fee.Ethereum.TokenCurrency -> right
}
}

View file

@ -346,6 +346,15 @@ internal class SwapNotificationsFactory(
}
when (feeError) {
is GetFeeError.BlockchainErrors.TooLargeSolanaTransactionError -> {
add(
getWarningForError(
expressDataError = ExpressDataError.TooLargeSolanaTransactionError(),
fromToken = quoteModel.fromTokenInfo.swapCurrencyStatus.currency,
onRetryClick = actions.onRetryClick,
),
)
}
is GetFeeError.DataError -> {
val error = feeError.cause
if (error is ExpressDataError) {

View file

@ -0,0 +1,205 @@
package com.tangem.feature.swap.model
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.SwapState
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.math.BigInteger
/**
* Tests for [SwapModel]'s integrated approve+swap fee combination (private `combineTransactionFees`
* / `sumEvmFees`). These are pure functions, so they are exercised via reflection (the public
* fee-loading pipeline that calls them requires a large amount of async wiring; the plan permits
* reflection for pure private functions where the public path is brittle).
*
* Verifies:
* - Choosable + Choosable per-bucket [TransactionFee.Choosable] with summed amount + gasLimit.
* - Single involved (either side) [TransactionFee.Single] summing the `normal` fees.
* - Legacy EVM fees are summed too (amount + gasLimit).
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class SwapModelCombineFeesTest : SwapModelTestBase() {
private lateinit var model: SwapModel
@BeforeEach
fun setUp() {
setUpBase()
model = createModel()
}
@Test
fun `GIVEN Choosable plus Choosable THEN per-bucket sum of amount and gasLimit`() {
val approval = choosable(min = 1, normal = 2, priority = 3, gas = 21_000)
val swap = choosable(min = 10, normal = 20, priority = 30, gas = 50_000)
val result = combineTransactionFees(approval, swap)
assertThat(result).isInstanceOf(TransactionFee.Choosable::class.java)
val choosable = result as TransactionFee.Choosable
assertEip1559(choosable.minimum, expectedValue = 11, expectedGas = 71_000)
assertEip1559(choosable.normal, expectedValue = 22, expectedGas = 71_000)
assertEip1559(choosable.priority, expectedValue = 33, expectedGas = 71_000)
}
@Test
fun `GIVEN Single approval and Choosable swap THEN result is Single summing normals`() {
val approval = TransactionFee.Single(normal = eip1559(value = 2, gas = 21_000))
val swap = choosable(min = 10, normal = 20, priority = 30, gas = 50_000)
val result = combineTransactionFees(approval, swap)
assertThat(result).isInstanceOf(TransactionFee.Single::class.java)
assertEip1559((result as TransactionFee.Single).normal, expectedValue = 22, expectedGas = 71_000)
}
@Test
fun `GIVEN both Single THEN result is Single summing normals`() {
val approval = TransactionFee.Single(normal = eip1559(value = 5, gas = 21_000))
val swap = TransactionFee.Single(normal = eip1559(value = 7, gas = 30_000))
val result = combineTransactionFees(approval, swap)
assertThat(result).isInstanceOf(TransactionFee.Single::class.java)
assertEip1559((result as TransactionFee.Single).normal, expectedValue = 12, expectedGas = 51_000)
}
@Test
fun `GIVEN Legacy EVM fees THEN summed amount and gasLimit`() {
val approval = TransactionFee.Single(normal = legacy(value = 2, gas = 21_000))
val swap = TransactionFee.Single(normal = legacy(value = 20, gas = 50_000))
val result = combineTransactionFees(approval, swap)
val normal = (result as TransactionFee.Single).normal
assertThat(normal).isInstanceOf(Fee.Ethereum.Legacy::class.java)
val legacy = normal as Fee.Ethereum.Legacy
assertThat(legacy.amount.value).isEqualTo(BigDecimal(22))
assertThat(legacy.gasLimit).isEqualTo(BigInteger.valueOf(71_000))
}
@Test
fun `loadAndStoreIntegratedApproval stores IntegratedApprovalData on the quote state and returns combined fee`() =
runTest {
val provider = swapProvider()
val quoteState = quotesLoadedState(
provider = provider,
permissionState = permissionSettings(type = ApproveType.UNLIMITED, spender = "0xSpender"),
)
model.dataState = model.dataState.copy(
selectedProvider = provider,
lastLoadedSwapStates = mapOf(provider to quoteState),
)
val approvalData = IntegratedApprovalData(
approvalTransaction = mockk<TransactionData.Uncompiled>(relaxed = true),
approvalFee = TransactionFee.Single(normal = eip1559(value = 2, gas = 21_000)),
approveType = ApproveType.UNLIMITED,
)
coEvery {
swapInteractor.loadIntegratedApprovalData(
fromStatus = any(),
spenderAddress = any(),
approveType = any(),
approvalAmount = any(),
)
} returns approvalData.right()
val combined = loadAndStoreIntegratedApproval(
fromSwapCurrencyStatus = swapCurrencyStatus(),
quoteState = quoteState,
permissionSettings = permissionSettings(
type = ApproveType.UNLIMITED,
spender = "0xSpender",
),
approvalAmount = BigDecimal.ONE,
swapTxFee = TransactionFee.Single(normal = eip1559(value = 20, gas = 50_000)),
)
assertThat(combined.isRight()).isTrue()
combined.onRight { fee ->
assertEip1559((fee as TransactionFee.Single).normal, expectedValue = 22, expectedGas = 71_000)
}
// Stored on the current loaded state for later submission.
val stored = (model.dataState.lastLoadedSwapStates[provider] as SwapState.QuotesLoadedState)
.integratedApprovalData
assertThat(stored).isEqualTo(approvalData)
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
@Suppress("UNCHECKED_CAST")
private suspend fun loadAndStoreIntegratedApproval(
fromSwapCurrencyStatus: com.tangem.domain.swap.models.SwapCurrencyStatus,
quoteState: SwapState.QuotesLoadedState,
permissionSettings: PermissionDataState.PermissionSettings,
approvalAmount: BigDecimal,
swapTxFee: TransactionFee,
): arrow.core.Either<com.tangem.domain.transaction.error.GetFeeError, TransactionFee> {
val method = SwapModel::class.java.declaredMethods.first { it.name == "loadAndStoreIntegratedApproval" }
.apply { isAccessible = true }
return invokeSuspend(method, fromSwapCurrencyStatus, quoteState, permissionSettings, approvalAmount, swapTxFee)
as arrow.core.Either<com.tangem.domain.transaction.error.GetFeeError, TransactionFee>
}
private suspend fun invokeSuspend(method: java.lang.reflect.Method, vararg args: Any?): Any? =
kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn { cont ->
method.invoke(model, *args, cont)
}
private fun combineTransactionFees(approvalFee: TransactionFee, swapFee: TransactionFee): TransactionFee {
val method = SwapModel::class.java.getDeclaredMethod(
"combineTransactionFees",
TransactionFee::class.java,
TransactionFee::class.java,
).apply { isAccessible = true }
return method.invoke(model, approvalFee, swapFee) as TransactionFee
}
private fun choosable(min: Int, normal: Int, priority: Int, gas: Long): TransactionFee.Choosable =
TransactionFee.Choosable(
minimum = eip1559(value = min, gas = gas),
normal = eip1559(value = normal, gas = gas),
priority = eip1559(value = priority, gas = gas),
)
private fun eip1559(value: Int, gas: Long): Fee.Ethereum.EIP1559 = Fee.Ethereum.EIP1559(
amount = ethAmount(value),
gasLimit = BigInteger.valueOf(gas),
maxFeePerGas = BigInteger.ONE,
priorityFee = BigInteger.ONE,
)
private fun legacy(value: Int, gas: Long): Fee.Ethereum.Legacy = Fee.Ethereum.Legacy(
amount = ethAmount(value),
gasLimit = BigInteger.valueOf(gas),
gasPrice = BigInteger.ONE,
)
private fun ethAmount(value: Int): Amount = Amount(
currencySymbol = "ETH",
value = BigDecimal(value),
decimals = 18,
)
private fun assertEip1559(fee: Fee, expectedValue: Int, expectedGas: Long) {
assertThat(fee).isInstanceOf(Fee.Ethereum.EIP1559::class.java)
val eip = fee as Fee.Ethereum.EIP1559
assertThat(eip.amount.value).isEqualTo(BigDecimal(expectedValue))
assertThat(eip.gasLimit).isEqualTo(BigInteger.valueOf(expectedGas))
}
}

View file

@ -0,0 +1,160 @@
package com.tangem.feature.swap.model
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import io.mockk.coVerify
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Tests for [SwapModel]'s integrated-approval fallback trigger
* (`SwapModel.FeeSelectorRepository.onResult` private `handleFeeError`).
*
* Driven through the public `feeSelectorRepository.onResult(FeeSelectorUM.Error(...))` path:
*
* (a) `EstimateOverrideError` + `PermissionSettings` `swapInteractor.integratedApprovalFallback`
* is called once with the matching spender, and the loaded state is rewritten to
* `PermissionRequired(isResetApproval = false)` with `integratedApprovalData == null`.
* (b) `EstimateOverrideError` + non-`PermissionSettings` permission no fallback call, state
* left as-is (permission stays Empty).
* (c) non-`EstimateOverrideError` (plain fee error) no fallback call (plain fee-error path).
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class SwapModelHandleFeeErrorTest : SwapModelTestBase() {
@BeforeEach
fun setUp() {
setUpBase()
}
@Test
fun `GIVEN EstimateOverrideError and PermissionSettings THEN fallback is triggered and state becomes PermissionRequired`() =
runTest {
val provider = swapProvider()
val fromStatus = swapCurrencyStatus()
val toStatus = swapCurrencyStatus()
val model = createModel()
model.dataState = model.dataState.copy(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
selectedProvider = provider,
lastLoadedSwapStates = mapOf(
provider to quotesLoadedState(
provider = provider,
permissionState = permissionSettings(type = ApproveType.LIMITED, spender = SPENDER),
),
),
)
model.feeSelectorRepository.onResult(
FeeSelectorUM.Error(error = estimateOverrideError(), isHidden = false),
)
coVerify(exactly = 1) {
swapInteractor.integratedApprovalFallback(
fromSwapCurrencyStatus = fromStatus,
spenderAddress = SPENDER,
)
}
// The gas-override analytics event must be reported once, carrying the error fields.
verify(exactly = 1) {
analyticsEventHandler.send(ofType(SwapEvents.ApproveGasOverrideError::class))
}
val sentEvents = mutableListOf<AnalyticsEvent>()
verify { analyticsEventHandler.send(capture(sentEvents)) }
val overrideEvent = sentEvents.filterIsInstance<SwapEvents.ApproveGasOverrideError>().single()
assertThat(overrideEvent.params).isEqualTo(
mapOf(
"Token" to "USDT",
"Blockchain" to "ethereum",
"RPC Provider" to "infura",
"Error Message" to "execution reverted",
),
)
val updated = model.dataState.getCurrentLoadedSwapState()
val permission = updated?.permissionState as? PermissionDataState.PermissionRequired
assertThat(permission).isNotNull()
assertThat(permission!!.isResetApproval).isFalse()
assertThat(permission.spenderAddress).isEqualTo(SPENDER)
assertThat(updated.integratedApprovalData).isNull()
}
@Test
fun `GIVEN EstimateOverrideError and non-PermissionSettings THEN no fallback call`() = runTest {
val provider = swapProvider()
val fromStatus = swapCurrencyStatus()
val toStatus = swapCurrencyStatus()
val model = createModel()
model.dataState = model.dataState.copy(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
selectedProvider = provider,
lastLoadedSwapStates = mapOf(
provider to quotesLoadedState(provider = provider, permissionState = PermissionDataState.Empty),
),
)
model.feeSelectorRepository.onResult(
FeeSelectorUM.Error(error = estimateOverrideError(), isHidden = false),
)
coVerify(exactly = 0) {
swapInteractor.integratedApprovalFallback(fromSwapCurrencyStatus = any(), spenderAddress = any())
}
// Permission untouched.
assertThat(model.dataState.getCurrentLoadedSwapState()?.permissionState)
.isEqualTo(PermissionDataState.Empty)
}
@Test
fun `GIVEN non-EstimateOverrideError THEN no fallback call (plain fee-error path)`() = runTest {
val provider = swapProvider()
val fromStatus = swapCurrencyStatus()
val toStatus = swapCurrencyStatus()
val model = createModel()
// The plain path runs the model's StateBuilder/refresh; relaxed mocks cover it.
// We assert only the absence of the fallback call.
model.dataState = model.dataState.copy(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
selectedProvider = provider,
lastLoadedSwapStates = mapOf(
provider to quotesLoadedState(
provider = provider,
permissionState = permissionSettings(type = ApproveType.LIMITED, spender = SPENDER),
),
),
)
model.feeSelectorRepository.onResult(
FeeSelectorUM.Error(error = GetFeeError.UnknownError, isHidden = false),
)
coVerify(exactly = 0) {
swapInteractor.integratedApprovalFallback(fromSwapCurrencyStatus = any(), spenderAddress = any())
}
// The gas-override analytics event belongs only to the EstimateOverrideError branch.
verify(exactly = 0) {
analyticsEventHandler.send(ofType(SwapEvents.ApproveGasOverrideError::class))
}
}
private fun estimateOverrideError() = GetFeeError.EstimateOverrideError(
blockchain = "ethereum",
tokenSymbol = "USDT",
rpcProvider = "infura",
error = "execution reverted",
)
private companion object {
const val SPENDER = "0xSpender"
}
}

View file

@ -41,6 +41,7 @@ import com.tangem.feature.swap.domain.AllowPermissionsHandler
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.transfer.SwapTransferInteractor
@ -198,9 +199,15 @@ internal abstract class SwapModelTestBase {
protected fun quotesLoadedState(
provider: SwapProvider,
permissionState: PermissionDataState = PermissionDataState.Empty,
integratedApprovalData: IntegratedApprovalData? = null,
): SwapState.QuotesLoadedState = mockk(relaxed = true) {
every { swapProvider } returns provider
every { this@mockk.permissionState } returns permissionState
every { this@mockk.integratedApprovalData } returns integratedApprovalData
// Matcher for the copy(...) overload `handleFeeError` uses on the integrated-approval
// fallback path: it copies `integratedApprovalData` (→ null) and `permissionState`
// (→ PermissionRequired). Includes `integratedApprovalData` so MockK matches that call
// and the rebuilt mock reflects the new permissionState / integratedApprovalData.
every {
copy(
fromTokenInfo = any(),
@ -213,11 +220,17 @@ internal abstract class SwapModelTestBase {
validationResult = any(),
minAdaValue = any(),
swapProvider = any(),
integratedApprovalData = any(),
)
} answers {
// `copy` arg indices follow the QuotesLoadedState primary-constructor order:
// 0 fromTokenInfo, 1 toTokenInfo, 2 swapProvider, 3 priceImpact,
// 4 preparedSwapConfigState, 5 permissionState, 6 swapDataModel,
// 7 integratedApprovalData, 8 currencyCheck, 9 validationResult, 10 minAdaValue.
quotesLoadedState(
provider = provider,
permissionState = arg(4),
permissionState = arg(5),
integratedApprovalData = arg(7),
)
}
}