Updated on 2026-08-14
This commit is contained in:
parent
1cf8a16fae
commit
24d7953389
30 changed files with 3096 additions and 494 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue