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

@ -16,6 +16,14 @@ internal class DefaultFeeErrorResolver : FeeErrorResolver {
is BlockchainSdkError.Sui.OneSuiRequired -> {
GetFeeError.BlockchainErrors.SuiOneCoinRequired
}
is BlockchainSdkError.Ethereum.EstimateOverrideError -> {
GetFeeError.EstimateOverrideError(
blockchain = throwable.blockchain,
tokenSymbol = throwable.tokenSymbol,
rpcProvider = throwable.rpcProvider,
error = throwable.underlyingError,
)
}
else -> GetFeeError.DataError(throwable)
}
}

View file

@ -0,0 +1,71 @@
package com.tangem.data.transaction.error
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.domain.transaction.error.GetFeeError
import org.junit.jupiter.api.Test
/**
* Tests for [DefaultFeeErrorResolver] the [Throwable] -> [GetFeeError] resolver. Mirrors the
* mapping matrix of `ErrorsMapper.mapToFeeError` but driven through `resolve(throwable)`.
*
* Focuses on the [REDACTED_TASK_KEY] addition: [BlockchainSdkError.Ethereum.EstimateOverrideError] must be
* resolved to [GetFeeError.EstimateOverrideError] field-by-field; representative other chains map
* to their dedicated [GetFeeError.BlockchainErrors]; everything else falls through to
* [GetFeeError.DataError].
*/
internal class DefaultFeeErrorResolverTest {
private val resolver = DefaultFeeErrorResolver()
@Test
fun `GIVEN EstimateOverrideError THEN resolves to GetFeeError EstimateOverrideError field by field`() {
val sdkError = BlockchainSdkError.Ethereum.EstimateOverrideError(
blockchain = "ethereum",
tokenSymbol = "USDT",
rpcProvider = "infura",
underlyingError = "execution reverted",
)
val result = resolver.resolve(sdkError)
assertThat(result).isInstanceOf(GetFeeError.EstimateOverrideError::class.java)
val mapped = result as GetFeeError.EstimateOverrideError
assertThat(mapped.blockchain).isEqualTo("ethereum")
assertThat(mapped.tokenSymbol).isEqualTo("USDT")
assertThat(mapped.rpcProvider).isEqualTo("infura")
assertThat(mapped.error).isEqualTo("execution reverted")
}
@Test
fun `GIVEN TronActivationError THEN resolves to TronActivationError`() {
// AccountActivationError is a class taking an int code, not an object.
val result = resolver.resolve(BlockchainSdkError.Tron.AccountActivationError(code = 0))
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.TronActivationError)
}
@Test
fun `GIVEN KaspaZeroUtxoError THEN resolves to KaspaZeroUtxo`() {
val result = resolver.resolve(BlockchainSdkError.Kaspa.ZeroUtxoError)
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.KaspaZeroUtxo)
}
@Test
fun `GIVEN SuiOneSuiRequired THEN resolves to SuiOneCoinRequired`() {
val result = resolver.resolve(BlockchainSdkError.Sui.OneSuiRequired)
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.SuiOneCoinRequired)
}
@Test
fun `GIVEN unknown error THEN resolves to DataError preserving the throwable`() {
val sdkError = BlockchainSdkError.CustomError("boom")
val result = resolver.resolve(sdkError)
assertThat(result).isInstanceOf(GetFeeError.DataError::class.java)
assertThat((result as GetFeeError.DataError).cause).isEqualTo(sdkError)
}
}

View file

@ -8,6 +8,7 @@ sealed class GetFeeError {
data object TronActivationError : BlockchainErrors()
data object KaspaZeroUtxo : BlockchainErrors()
data object SuiOneCoinRequired : BlockchainErrors()
data object TooLargeSolanaTransactionError : BlockchainErrors()
}
/**
@ -19,4 +20,15 @@ sealed class GetFeeError {
data object NotEnoughFunds : GaslessError()
data class DataError(val cause: Throwable?) : GaslessError()
}
/**
* Error for gas estimation with state override for ethereum like networks.
* Specifically overriding approval slot.
*/
data class EstimateOverrideError(
val blockchain: String,
val tokenSymbol: String,
val rpcProvider: String,
val error: String,
) : GetFeeError()
}

View file

@ -9,7 +9,7 @@ import com.tangem.domain.transaction.error.SendTransactionError.Companion.USER_C
import com.tangem.sdk.extensions.localizedDescriptionRes
fun Result.Failure.mapToFeeError(): GetFeeError {
return when (this.error) {
return when (val gasError = error) {
is BlockchainSdkError.Tron.AccountActivationError -> {
GetFeeError.BlockchainErrors.TronActivationError
}
@ -19,7 +19,15 @@ fun Result.Failure.mapToFeeError(): GetFeeError {
is BlockchainSdkError.Sui.OneSuiRequired -> {
GetFeeError.BlockchainErrors.SuiOneCoinRequired
}
else -> GetFeeError.DataError(this.error)
is BlockchainSdkError.Ethereum.EstimateOverrideError -> {
GetFeeError.EstimateOverrideError(
blockchain = gasError.blockchain,
tokenSymbol = gasError.tokenSymbol,
rpcProvider = gasError.rpcProvider,
error = gasError.underlyingError,
)
}
else -> GetFeeError.DataError(error)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.transaction.usecase
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Token
@ -27,7 +28,13 @@ class GetFeeUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
) {
suspend operator fun invoke(userWallet: UserWallet, network: Network, transactionData: TransactionData) = either {
suspend operator fun invoke(
userWallet: UserWallet,
network: Network,
transactionData: TransactionData,
spenderAddress: String? = null,
isSimulateEstimation: Boolean = false,
) = either {
catch(
block = {
val transactionSender = if (userWallet is UserWallet.Cold &&
@ -40,8 +47,17 @@ class GetFeeUseCase(
network = network,
)
}
val result = transactionSender?.getFee(transactionData = transactionData)
?: error("Fee is null")
val isEthereumWalletManager = transactionSender is EthereumWalletManager
val result = if (isSimulateEstimation && spenderAddress != null && isEthereumWalletManager) {
transactionSender.estimateFeeWithOverride(
transactionData = transactionData,
spenderAddress = spenderAddress,
isSimulate = true,
)
} else {
transactionSender?.getFee(transactionData = transactionData)
?: error("Fee is null")
}
val maybeFee = when (result) {
is Result.Success -> result.data

View file

@ -0,0 +1,66 @@
package com.tangem.domain.transaction.error
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.extensions.Result
import org.junit.Test
/**
* Tests for [mapToFeeError] the [Result.Failure] -> [GetFeeError] mapper. Focuses on the
* [REDACTED_TASK_KEY] addition: [BlockchainSdkError.Ethereum.EstimateOverrideError] must be mapped to
* [GetFeeError.EstimateOverrideError] field-by-field; all other errors fall through to
* [GetFeeError.DataError].
*/
internal class ErrorsMapperTest {
@Test
fun `GIVEN EstimateOverrideError THEN maps to GetFeeError EstimateOverrideError field by field`() {
val sdkError = BlockchainSdkError.Ethereum.EstimateOverrideError(
blockchain = "ethereum",
tokenSymbol = "USDT",
rpcProvider = "infura",
underlyingError = "execution reverted",
)
val result = Result.Failure(sdkError).mapToFeeError()
assertThat(result).isInstanceOf(GetFeeError.EstimateOverrideError::class.java)
val mapped = result as GetFeeError.EstimateOverrideError
assertThat(mapped.blockchain).isEqualTo("ethereum")
assertThat(mapped.tokenSymbol).isEqualTo("USDT")
assertThat(mapped.rpcProvider).isEqualTo("infura")
assertThat(mapped.error).isEqualTo("execution reverted")
}
@Test
fun `GIVEN TronActivationError THEN maps to TronActivationError`() {
// AccountActivationError is a class taking an int code, not an object.
val result = Result.Failure(BlockchainSdkError.Tron.AccountActivationError(code = 0)).mapToFeeError()
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.TronActivationError)
}
@Test
fun `GIVEN KaspaZeroUtxoError THEN maps to KaspaZeroUtxo`() {
val result = Result.Failure(BlockchainSdkError.Kaspa.ZeroUtxoError).mapToFeeError()
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.KaspaZeroUtxo)
}
@Test
fun `GIVEN SuiOneSuiRequired THEN maps to SuiOneCoinRequired`() {
val result = Result.Failure(BlockchainSdkError.Sui.OneSuiRequired).mapToFeeError()
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.SuiOneCoinRequired)
}
@Test
fun `GIVEN unknown error THEN maps to DataError`() {
val sdkError = BlockchainSdkError.CustomError("boom")
val result = Result.Failure(sdkError).mapToFeeError()
assertThat(result).isInstanceOf(GetFeeError.DataError::class.java)
assertThat((result as GetFeeError.DataError).cause).isEqualTo(sdkError)
}
}

View file

@ -0,0 +1,206 @@
package com.tangem.domain.transaction.usecase
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.walletmanager.WalletManagersFacade
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
/**
* Tests for [GetFeeUseCase] the compiled-transaction overload that decides between the new
* simulated `estimateFee` path and the legacy `getFee` path.
*
* The simulated estimation is selected only when ALL of these hold:
* - [GetFeeUseCase.invoke] is called with `isSimulateEstimation = true`
* - `spenderAddress != null`
* - the resolved transaction sender is an [EthereumWalletManager]
*
* Any other combination falls back to the legacy `getFee(transactionData)`.
*/
internal class GetFeeUseCaseTest {
private val walletManagersFacade: WalletManagersFacade = mockk()
private val demoConfig: DemoConfig = mockk()
private val useCase = GetFeeUseCase(
walletManagersFacade = walletManagersFacade,
demoConfig = demoConfig,
)
private val network: Network = mockk(relaxed = true)
private val userWalletId = UserWalletId(stringValue = "deadbeef")
private val userWallet: UserWallet = mockk<UserWallet.Hot>(relaxed = true) {
every { walletId } returns userWalletId
}
private val transactionData: TransactionData = mockk(relaxed = true)
private val expectedFee: TransactionFee = mockk(relaxed = true)
private val ethereumWalletManager: EthereumWalletManager = mockk()
private val plainWalletManager: WalletManager = mockk()
@Before
fun setUp() {
every { demoConfig.isDemoCardId(any()) } returns false
}
@Test
fun `GIVEN simulate + spender + ethereum manager THEN estimateFee is used`() = runTest {
coEvery {
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
} returns ethereumWalletManager
coEvery {
ethereumWalletManager.estimateFeeWithOverride(
transactionData = transactionData,
spenderAddress = SPENDER,
isSimulate = true,
)
} returns Result.Success(expectedFee)
val result = useCase(
userWallet = userWallet,
network = network,
transactionData = transactionData,
spenderAddress = SPENDER,
isSimulateEstimation = true,
)
assertThat(result).isEqualTo(expectedFee.right())
coVerify(exactly = 1) {
ethereumWalletManager.estimateFeeWithOverride(
transactionData = transactionData,
spenderAddress = SPENDER,
isSimulate = true,
)
}
coVerify(exactly = 0) { ethereumWalletManager.getFee(transactionData = transactionData) }
}
@Test
fun `GIVEN simulate false THEN legacy getFee is used even for ethereum manager`() = runTest {
coEvery {
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
} returns ethereumWalletManager
coEvery { ethereumWalletManager.getFee(transactionData = transactionData) } returns
Result.Success(expectedFee)
val result = useCase(
userWallet = userWallet,
network = network,
transactionData = transactionData,
spenderAddress = SPENDER,
isSimulateEstimation = false,
)
assertThat(result).isEqualTo(expectedFee.right())
coVerify(exactly = 1) { ethereumWalletManager.getFee(transactionData = transactionData) }
coVerify(exactly = 0) {
ethereumWalletManager.estimateFeeWithOverride(
transactionData = any(),
spenderAddress = any(),
isSimulate = any(),
)
}
}
@Test
fun `GIVEN null spender THEN legacy getFee is used even when simulate true`() = runTest {
coEvery {
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
} returns ethereumWalletManager
coEvery { ethereumWalletManager.getFee(transactionData = transactionData) } returns
Result.Success(expectedFee)
val result = useCase(
userWallet = userWallet,
network = network,
transactionData = transactionData,
spenderAddress = null,
isSimulateEstimation = true,
)
assertThat(result).isEqualTo(expectedFee.right())
coVerify(exactly = 1) { ethereumWalletManager.getFee(transactionData = transactionData) }
coVerify(exactly = 0) {
ethereumWalletManager.estimateFeeWithOverride(
transactionData = any(),
spenderAddress = any(),
isSimulate = any(),
)
}
}
@Test
fun `GIVEN non-ethereum manager THEN legacy getFee is used even when simulate plus spender`() = runTest {
coEvery {
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
} returns plainWalletManager
coEvery { plainWalletManager.getFee(transactionData = transactionData) } returns
Result.Success(expectedFee)
val result = useCase(
userWallet = userWallet,
network = network,
transactionData = transactionData,
spenderAddress = SPENDER,
isSimulateEstimation = true,
)
assertThat(result).isEqualTo(expectedFee.right())
coVerify(exactly = 1) { plainWalletManager.getFee(transactionData = transactionData) }
}
@Test
fun `GIVEN getFee returns failure THEN error is mapped to GetFeeError`() = runTest {
val failure = Result.Failure(com.tangem.blockchain.common.BlockchainSdkError.CustomError("boom"))
coEvery {
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
} returns plainWalletManager
coEvery { plainWalletManager.getFee(transactionData = transactionData) } returns failure
val result = useCase(
userWallet = userWallet,
network = network,
transactionData = transactionData,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.DataError::class.java)
}
@Test
fun `GIVEN wallet manager is null THEN DataError is raised`() = runTest {
coEvery {
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
} returns null
val result = useCase(
userWallet = userWallet,
network = network,
transactionData = transactionData,
spenderAddress = null,
isSimulateEstimation = false,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.DataError::class.java)
}
private companion object {
const val SPENDER = "0xSpender"
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.feature.swap.domain
import arrow.core.Either
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -8,6 +9,7 @@ import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
import com.tangem.feature.swap.domain.models.ui.SwapFee
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.SwapTransactionState
@ -65,6 +67,7 @@ interface SwapInteractor {
fee: SwapFee?,
expressOperationType: ExpressOperationType,
isTangemPayWithdrawal: Boolean,
integratedApproval: IntegratedApprovalData? = null,
): SwapTransactionState
/**
@ -123,7 +126,7 @@ interface SwapInteractor {
*/
@Suppress("LongParameterList")
suspend fun loadSwapFee(
provider: SwapProvider,
quotesLoadedState: SwapState.QuotesLoadedState,
fromStatus: SwapCurrencyStatus,
toStatus: SwapCurrencyStatus,
amount: SwapAmount,
@ -131,4 +134,26 @@ interface SwapInteractor {
selectedFeeToken: CryptoCurrencyStatus?,
isGasless: Boolean,
): Either<GetFeeError, SwapFee>
fun integratedApprovalFallback(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String)
/**
* Builds the on-chain ERC-20 approval transaction for [fromStatus] / [spenderAddress]
* with an amount derived from [approveType] (null for `UNLIMITED`, the swap amount for
* `LIMITED`), loads its [com.tangem.blockchain.common.transaction.TransactionFee] and returns
* both as [IntegratedApprovalData].
*
* Used by the integrated approve+swap flow when
* `SwapFeatureToggles.isSwapIntegratedApproveEnabled` is ON and the quote requires an
* allowance bump.
*
* @param approvalAmount LIMITED-mode swap amount (the user-input amount). Used when
* [approveType] is `LIMITED`; ignored for `UNLIMITED`.
*/
suspend fun loadIntegratedApprovalData(
fromStatus: SwapCurrencyStatus,
spenderAddress: String,
approveType: ApproveType,
approvalAmount: BigDecimal,
): Either<GetFeeError, IntegratedApprovalData>
}

View file

@ -7,11 +7,9 @@ import arrow.core.left
import arrow.core.raise.either
import arrow.core.right
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionExtras
import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toBlockchain
@ -34,6 +32,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
@ -73,6 +72,8 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.Collections.newSetFromMap
import java.util.concurrent.ConcurrentHashMap
@Suppress("LargeClass", "LongParameterList")
internal class SwapInteractorImpl @Inject constructor(
@ -82,6 +83,8 @@ internal class SwapInteractorImpl @Inject constructor(
private val sendTransactionUseCase: SendTransactionUseCase,
private val createTransactionUseCase: CreateTransactionUseCase,
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val quotesRepository: QuotesRepository,
@ -113,6 +116,22 @@ internal class SwapInteractorImpl @Inject constructor(
private val SwapCurrencyStatus.isYieldSwapActive: Boolean
get() = swapFeatureToggles.isYieldSwapEnabled && isYieldSupplyActive
/**
* Set of integrated-approve contexts for which the simulated swap-fee estimation
* failed with [GetFeeError.EstimateOverrideError]. Once a context is recorded here, the
* integrated path is abandoned for the remainder of the session: the permission state is
* derived as [PermissionDataState.PermissionRequired] (legacy separate-approval flow).
* This survives the periodic quote-refresh task so the failing simulated estimation is not retried every cycle.
*/
private val integratedApprovalFallbackContexts = newSetFromMap(
ConcurrentHashMap<IntegratedApprovalFallbackKey, Boolean>(),
)
private fun hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String?) =
integratedApprovalFallbackContexts.contains(
IntegratedApprovalFallbackKey.of(fromSwapCurrencyStatus, spenderAddress),
)
override suspend fun getPair(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
@ -330,7 +349,9 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
val isBalanceWithoutFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, null)
val isIntegratedApproveActive = swapFeatureToggles.isSwapIntegratedApproveEnabled
val isIntegratedApproveActive = swapFeatureToggles.isSwapIntegratedApproveEnabled &&
!hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus, spenderAddress)
val isAllowanceSatisfied = if (isIntegratedApproveActive) {
allowanceInfo !is AllowanceInfo.ResetNeeded
} else {
@ -591,7 +612,7 @@ internal class SwapInteractorImpl @Inject constructor(
return result
}
@Suppress("NullableToStringCall")
@Suppress("NullableToStringCall", "LongParameterList")
override suspend fun onSwap(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
@ -602,6 +623,7 @@ internal class SwapInteractorImpl @Inject constructor(
fee: SwapFee?,
expressOperationType: ExpressOperationType,
isTangemPayWithdrawal: Boolean,
integratedApproval: IntegratedApprovalData?,
): SwapTransactionState {
TangemLogger.i(
"""
@ -663,12 +685,14 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
swapFee = fee,
amountToSwap = amountToSwap,
integratedApproval = integratedApproval,
)
}
}
}
}
@Suppress("LongParameterList")
private suspend fun onSwapDex(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
@ -676,6 +700,7 @@ internal class SwapInteractorImpl @Inject constructor(
swapData: SwapDataModel,
amountToSwap: String,
swapFee: SwapFee,
integratedApproval: IntegratedApprovalData?,
): SwapTransactionState {
val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" }
val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
@ -685,8 +710,7 @@ internal class SwapInteractorImpl @Inject constructor(
val fromCurrency = fromSwapCurrencyStatus.currency
val txDataResult = if (isYieldSwap && fromCurrency is CryptoCurrency.Token) {
val spenderAddress = dexTransaction.allowanceContract
?: return SwapTransactionState.Error.UnknownError
val spenderAddress = dexTransaction.allowanceContract ?: return SwapTransactionState.Error.UnknownError
createYieldSwapDexTransaction(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
swapData = swapData,
@ -726,17 +750,95 @@ internal class SwapInteractorImpl @Inject constructor(
swapData.transaction.txTo
}
return handleSwapResult(
return if (integratedApproval != null) {
// TODO YIELD payInAddress [REDACTED_TASK_KEY]
sendIntegratedApproveAndSwap(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
provider = provider,
swapData = swapData,
amount = amount,
swapTxData = txData,
swapFee = swapFee,
integratedApproval = integratedApproval,
)
} else {
handleSwapResult(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
provider = provider,
swapData = swapData,
amount = amount,
txData = txData,
payInAddress = payInAddress,
)
}
}
/**
* Integrated approve+swap submission. Selects the approval-fee bucket matching
* the user's swap-fee selection, attaches it to the prepared approval tx, and sends both
* transactions in a single [TransactionSender.MultipleTransactionSendMode.DEFAULT] batch.
*
* The success path is identical to the standalone swap path only the approval-side hash
* is dropped (the swap tx hash is what surfaces as the transaction result).
*/
@Suppress("LongParameterList")
private suspend fun sendIntegratedApproveAndSwap(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
provider: SwapProvider,
swapData: SwapDataModel,
amount: SwapAmount,
swapTxData: TransactionData.Uncompiled,
swapFee: SwapFee,
integratedApproval: IntegratedApprovalData,
): SwapTransactionState {
val approvalFee = selectFeeForBucket(integratedApproval.approvalFee, swapFee.feeBucket)
val approvalTx = integratedApproval.approvalTransaction.copy(fee = approvalFee)
val sendResult = sendTransactionUseCase(
txsData = listOf(approvalTx, swapTxData),
userWallet = fromSwapCurrencyStatus.userWallet,
network = fromSwapCurrencyStatus.currency.network,
sendMode = TransactionSender.MultipleTransactionSendMode.DEFAULT,
).fold(
ifLeft = { error -> return SwapTransactionState.Error.TransactionError(error) },
ifRight = { hashes -> hashes },
)
// The swap tx is the second (and last) hash; the approval hash is intentionally dropped.
val swapTxHash = sendResult.lastOrNull() ?: return SwapTransactionState.Error.UnknownError
return finalizeDexSwapSuccess(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
provider = provider,
swapData = swapData,
amount = amount,
txData = txData,
payInAddress = payInAddress,
txHash = swapTxHash,
payInAddress = getPayoutAddress(swapTxData),
)
}
/**
* [REDACTED_TASK_KEY] selects the approval [Fee] matching the user-picked [FeeBucket] tier. Mirrors
* the bucket-to-field mapping used by `GiveApprovalModel.sendApprovalTransaction`:
* - `SLOW` `Choosable.minimum` (fallback `Single.normal`)
* - `FAST` `Choosable.priority` (fallback `Single.normal`)
* - all other buckets `normal`
*/
private fun selectFeeForBucket(transactionFee: TransactionFee, bucket: FeeBucket): Fee {
return when (transactionFee) {
is TransactionFee.Choosable -> when (bucket) {
FeeBucket.SLOW -> transactionFee.minimum
FeeBucket.FAST -> transactionFee.priority
else -> transactionFee.normal
}
is TransactionFee.Single -> transactionFee.normal
}
}
/**
* Branch selection:
* - Gasless token path: `swapFee.transactionFeeResult is LoadedExtended && selectedFeeToken.currency is Token`
@ -937,46 +1039,72 @@ internal class SwapInteractorImpl @Inject constructor(
)
return result.fold(
ifRight = { txHash ->
val networkAddress = fromSwapCurrencyStatus.status.value.networkAddress
val fromAddress = networkAddress?.defaultAddress?.value.orEmpty()
repository.exchangeSent(
userWallet = fromSwapCurrencyStatus.userWallet,
txId = swapData.transaction.txId,
fromNetwork = fromSwapCurrencyStatus.currency.network.rawId,
fromAddress = fromAddress,
payInAddress = payInAddress,
txHash = txHash,
payInExtraId = swapData.transaction.txExtraId,
)
val timestamp = System.currentTimeMillis()
storeSwapTransaction(
finalizeDexSwapSuccess(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
provider = provider,
swapData = swapData,
amount = amount,
swapProvider = provider,
swapDataModel = swapData,
timestamp = timestamp,
)
storeLastCryptoCurrencyId(fromSwapCurrencyStatus)
SwapTransactionState.TxSent(
fromAmount = amountFormatter.formatSwapAmountToUI(
amount,
fromSwapCurrencyStatus.currency.symbol,
),
fromAmountValue = amount.value,
toAmount = amountFormatter.formatSwapAmountToUI(
swapData.toTokenAmount,
toSwapCurrencyStatus.currency.symbol,
),
toAmountValue = swapData.toTokenAmount.value,
txHash = txHash,
timestamp = timestamp,
payInAddress = payInAddress,
)
},
ifLeft = { SwapTransactionState.Error.TransactionError(it) },
)
}
/**
* Shared success path for DEX (single-tx and integrated approve+swap multi-tx). Notifies the
* exchange backend, stores the transaction locally for status tracking, records the last-used
* crypto currency id, and returns the [SwapTransactionState.TxSent] payload.
*/
@Suppress("LongParameterList")
private suspend fun finalizeDexSwapSuccess(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
provider: SwapProvider,
swapData: SwapDataModel,
amount: SwapAmount,
txHash: String,
payInAddress: String,
): SwapTransactionState.TxSent {
val networkAddress = fromSwapCurrencyStatus.status.value.networkAddress
val fromAddress = networkAddress?.defaultAddress?.value.orEmpty()
repository.exchangeSent(
userWallet = fromSwapCurrencyStatus.userWallet,
txId = swapData.transaction.txId,
fromNetwork = fromSwapCurrencyStatus.currency.network.rawId,
fromAddress = fromAddress,
payInAddress = payInAddress,
txHash = txHash,
payInExtraId = swapData.transaction.txExtraId,
)
val timestamp = System.currentTimeMillis()
storeSwapTransaction(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
amount = amount,
swapProvider = provider,
swapDataModel = swapData,
timestamp = timestamp,
)
storeLastCryptoCurrencyId(fromSwapCurrencyStatus)
return SwapTransactionState.TxSent(
fromAmount = amountFormatter.formatSwapAmountToUI(
amount,
fromSwapCurrencyStatus.currency.symbol,
),
fromAmountValue = amount.value,
toAmount = amountFormatter.formatSwapAmountToUI(
swapData.toTokenAmount,
toSwapCurrencyStatus.currency.symbol,
),
toAmountValue = swapData.toTokenAmount.value,
txHash = txHash,
timestamp = timestamp,
)
}
private fun createDexTxExtras(data: String, network: Network, gasLimit: Int?): TransactionExtras {
return createTransactionExtrasUseCase(
data = data,
@ -1029,7 +1157,7 @@ internal class SwapInteractorImpl @Inject constructor(
*/
@Suppress("LongParameterList")
override suspend fun loadSwapFee(
provider: SwapProvider,
quotesLoadedState: SwapState.QuotesLoadedState,
fromStatus: SwapCurrencyStatus,
toStatus: SwapCurrencyStatus,
amount: SwapAmount,
@ -1040,13 +1168,14 @@ internal class SwapInteractorImpl @Inject constructor(
if (amount.value.signum() == 0) {
raise(GetFeeError.UnknownError)
}
return when (provider.type) {
return when (quotesLoadedState.swapProvider.type) {
ExchangeProviderType.DEX,
ExchangeProviderType.DEX_BRIDGE,
-> loadDexSwapFee(
fromStatus = fromStatus,
swapData = swapData,
selectedFeeToken = selectedFeeToken,
permissionState = quotesLoadedState.permissionState,
)
ExchangeProviderType.CEX -> loadCexSwapFee(
fromStatus = fromStatus,
@ -1058,7 +1187,7 @@ internal class SwapInteractorImpl @Inject constructor(
}
/**
* [REDACTED_TASK_KEY] DEX branch of [loadSwapFee]. Pulls the cached `ExpressTransactionModel.DEX`
* DEX branch of [loadSwapFee]. Pulls the cached `ExpressTransactionModel.DEX`
* out of [swapData] and hands it to [DexSwapFeeCalculator]. Maps [ExpressDataError]
* `Left(GetFeeError.UnknownError)` to keep the unified surface a single error type, matching
* what the legacy `loadFeeForSwapTransaction` overload 2 does for DEX failures (line 1027 of
@ -1068,13 +1197,26 @@ internal class SwapInteractorImpl @Inject constructor(
fromStatus: SwapCurrencyStatus,
swapData: SwapDataModel?,
selectedFeeToken: CryptoCurrencyStatus?,
permissionState: PermissionDataState,
): Either<GetFeeError, SwapFee> {
val transaction = swapData?.transaction as? ExpressTransactionModel.DEX
?: return GetFeeError.UnknownError.left()
// If the integrated-approve simulation already failed for this context, skip the
// simulated estimation entirely and use the plain getFee path (legacy separate-approval flow).
val effectivePermissionState = if (
permissionState is PermissionDataState.PermissionSettings &&
hasIntegratedApprovalFallenBack(fromStatus, permissionState.spenderAddress)
) {
PermissionDataState.Empty
} else {
permissionState
}
val dexFeeResultEither = if (fromStatus.isYieldSwapActive && fromStatus.currency is CryptoCurrency.Token) {
val network = (fromStatus.currency as CryptoCurrency.Token).network
val yieldModuleAddress = yieldModuleAddressProvider.getOrFetch(fromStatus.userWalletId, network)
// TODO YIELD [REDACTED_TASK_KEY]
dexSwapFeeCalculator.calculateYield(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
@ -1085,11 +1227,12 @@ internal class SwapInteractorImpl @Inject constructor(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
selectedToken = selectedFeeToken,
permissionState = effectivePermissionState,
)
}
return dexFeeResultEither.fold(
ifLeft = { error -> GetFeeError.DataError(error).left() },
ifLeft = { error -> error.left() },
ifRight = { dexFeeResult ->
val feeToken = selectedFeeToken
?: resolveNativeFeeTokenStatus(fromStatus)
@ -1111,7 +1254,7 @@ internal class SwapInteractorImpl @Inject constructor(
amount: BigDecimal,
fee: Fee,
spenderAddress: String,
): Either<Throwable, TransactionData> {
): Either<Throwable, TransactionData.Uncompiled> {
val fromCurrency = fromSwapCurrencyStatus.currency as CryptoCurrency.Token
val network = fromCurrency.network
val yieldModuleAddress = yieldModuleAddressProvider.getOrFetch(fromSwapCurrencyStatus.userWalletId, network)
@ -1141,7 +1284,7 @@ internal class SwapInteractorImpl @Inject constructor(
}
/**
* [REDACTED_TASK_KEY] CEX branch of [loadSwapFee]. Native-fallback behavior is preserved: when
* CEX branch of [loadSwapFee]. Native-fallback behavior is preserved: when
* [selectedFeeToken] is null the gasless use case (invoked inside [CexSwapFeeCalculator])
* decides native vs token. The resulting `SwapFee.selectedFeeToken` is the explicit choice
* if provided, otherwise the native coin status of the from-token's network.
@ -1174,8 +1317,61 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
override fun integratedApprovalFallback(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String) {
integratedApprovalFallbackContexts.add(
element = IntegratedApprovalFallbackKey(
userWalletId = fromSwapCurrencyStatus.userWalletId,
fromCurrencyId = fromSwapCurrencyStatus.currency.id,
spenderAddress = spenderAddress,
),
)
}
/**
* [REDACTED_TASK_KEY] resolves the native-coin [CryptoCurrencyStatus] for the from-token's network.
* Builds the approval [TransactionData.Uncompiled] for the integrated
* approval + swap path and loads its [TransactionFee] via [getFeeUseCase]. The amount honors
* [ApproveType]: `UNLIMITED` null (unbounded allowance), `LIMITED` the swap amount.
*/
override suspend fun loadIntegratedApprovalData(
fromStatus: SwapCurrencyStatus,
spenderAddress: String,
approveType: ApproveType,
approvalAmount: BigDecimal,
): Either<GetFeeError, IntegratedApprovalData> = either {
val tokenCurrency = fromStatus.currency as? CryptoCurrency.Token
?: raise(GetFeeError.DataError(IllegalStateException("Integrated approval requires a Token from-currency")))
val amountForApprove: BigDecimal? = when (approveType) {
ApproveType.LIMITED -> approvalAmount
ApproveType.UNLIMITED -> null
}
val approvalTx = createApprovalTransactionUseCase(
userWalletId = fromStatus.userWalletId,
cryptoCurrencyStatus = fromStatus.status,
amount = amountForApprove,
contractAddress = tokenCurrency.contractAddress,
spenderAddress = spenderAddress,
).getOrElse { error ->
TangemLogger.e("loadIntegratedApprovalData: failed to create approval tx", error)
raise(GetFeeError.DataError(error))
}
val approvalFee = getFeeUseCase(
transactionData = approvalTx,
userWallet = fromStatus.userWallet,
network = fromStatus.currency.network,
).bind()
IntegratedApprovalData(
approvalTransaction = approvalTx,
approvalFee = approvalFee,
approveType = approveType,
)
}
/**
* Resolves the native-coin [CryptoCurrencyStatus] for the from-token's network.
* Used as the default `selectedFeeToken` of [SwapFee] when the caller did not provide an
* explicit choice. Mirrors how `SwapModel.updateFeePaidCryptoCurrencyFor` populates
* `dataState.feePaidCryptoCurrency`.
@ -1615,7 +1811,7 @@ internal class SwapInteractorImpl @Inject constructor(
* `applySwapFee` is called.
* - `currencyCheck`, `validationResult`, `minAdaValue` populated with `fee = 0` (re-derived once fee is known).
*/
@Suppress("LongMethod")
@Suppress("LongMethod", "LongParameterList")
private suspend fun loadDexSwapDataNoFee(
provider: SwapProvider,
fromSwapCurrencyStatus: SwapCurrencyStatus,
@ -1667,14 +1863,18 @@ internal class SwapInteractorImpl @Inject constructor(
provider = provider,
)
val isIntegratedApprovalNeeded = swapFeatureToggles.isSwapIntegratedApproveEnabled &&
allowanceInfo is AllowanceInfo.NotEnough
allowanceInfo is AllowanceInfo.NotEnough &&
!hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus, spenderAddress)
swapState.copy(
permissionState = if (isIntegratedApprovalNeeded) {
PermissionDataState.PermissionSettings(
type = ApproveType.LIMITED,
spenderAddress = spenderAddress.orEmpty(),
)
} else if (allowanceInfo is AllowanceInfo.NotEnough) {
} else if (
allowanceInfo is AllowanceInfo.NotEnough &&
hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus, spenderAddress)
) {
// Integrated estimation failed earlier this session — show the legacy
// separate-approval UI so the user approves before swapping.
PermissionDataState.PermissionRequired(
@ -1809,8 +2009,8 @@ internal class SwapInteractorImpl @Inject constructor(
).getOrNull() ?: return quotesLoadedState.copy(permissionState = PermissionDataState.Empty)
val isIntegratedApprovalNeeded = swapFeatureToggles.isSwapIntegratedApproveEnabled &&
allowanceInfo is AllowanceInfo.NotEnough
allowanceInfo is AllowanceInfo.NotEnough &&
!hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus, quoteModel.allowanceContract)
return quotesLoadedState.copy(
permissionState = if (isIntegratedApprovalNeeded) {
PermissionDataState.PermissionSettings(
@ -2153,6 +2353,28 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
/**
* Identity of an integrated-approve fee context, used to remember that the simulated
* swap-fee estimation failed (and hence the legacy separate-approval flow must be used). Keyed by
* wallet + from-currency + spender; intentionally amount-independent because the estimate-override
* failure is structural (the approval simply does not exist yet) and changing the amount cannot fix
* it so we must not retry the simulation on every amount change either.
*/
private data class IntegratedApprovalFallbackKey(
val userWalletId: UserWalletId,
val fromCurrencyId: CryptoCurrency.ID,
val spenderAddress: String?,
) {
companion object {
fun of(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String?): IntegratedApprovalFallbackKey =
IntegratedApprovalFallbackKey(
userWalletId = fromSwapCurrencyStatus.userWalletId,
fromCurrencyId = fromSwapCurrencyStatus.currency.id,
spenderAddress = spenderAddress,
)
}
}
/**
* [REDACTED_TASK_KEY] internal classifier replacing the deleted public `IncludeFeeInAmount` enum.
* Kept private to [SwapInteractorImpl]; consumers see only [SwapBalanceStatus].

View file

@ -2,6 +2,8 @@ package com.tangem.feature.swap.domain.fee
import android.util.Base64
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
import com.tangem.blockchain.common.Amount
@ -19,14 +21,16 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.CreateTransactionDataExtrasUseCase
import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.logging.TangemLogger
@ -66,7 +70,8 @@ class DexSwapFeeCalculator(
fromSwapCurrencyStatus: SwapCurrencyStatus,
transaction: ExpressTransactionModel.DEX,
selectedToken: CryptoCurrencyStatus? = null,
): Either<ExpressDataError, DexFeeResult> = either {
permissionState: PermissionDataState = PermissionDataState.Empty,
): Either<GetFeeError, DexFeeResult> = either {
val networkRawId = fromSwapCurrencyStatus.currency.network.rawId
val nativeCoinDecimals = Blockchain.fromNetworkId(networkRawId)?.decimals()
?: error("Blockchain not found")
@ -82,7 +87,7 @@ class DexSwapFeeCalculator(
if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES &&
fromSwapCurrencyStatus.userWallet is UserWallet.Cold
) {
raise(ExpressDataError.TooLargeSolanaTransactionError())
raise(GetFeeError.BlockchainErrors.TooLargeSolanaTransactionError)
}
val solanaFee = getFeeDataForSolanaDexSwap(
@ -99,6 +104,7 @@ class DexSwapFeeCalculator(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
transaction = transaction,
selectedToken = selectedToken,
permissionState = permissionState,
).bind()
// Apply the 12% bump on EVM, mirroring SwapInteractorImpl.loadFeeForDex.
// The original cast `(fee as TransactionFeeResult.Loaded)` only holds when
@ -143,9 +149,9 @@ class DexSwapFeeCalculator(
fromSwapCurrencyStatus: SwapCurrencyStatus,
transaction: ExpressTransactionModel.DEX,
yieldModuleAddress: String?,
): Either<ExpressDataError, DexFeeResult> = either {
): Either<GetFeeError, DexFeeResult> = either {
val fromCurrency = fromSwapCurrencyStatus.currency as? CryptoCurrency.Token
?: raise(ExpressDataError.UnknownError())
?: raise(GetFeeError.UnknownError)
val network = fromCurrency.network
val nativeBalance = walletManagersFacade.getNativeTokenBalance(
@ -153,15 +159,14 @@ class DexSwapFeeCalculator(
networkId = network.rawId,
derivationPath = network.derivationPath.value,
)
if (nativeBalance.signum() == 0) raise(ExpressDataError.UnknownError())
if (nativeBalance.signum() == 0) raise(GetFeeError.UnknownError)
if (yieldModuleAddress == null) {
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
val gasLimit = transaction.gas ?: raise(GetFeeError.UnknownError)
return@either ethSpecificFeeFallback(fromSwapCurrencyStatus, gasLimit).bind()
}
val spenderAddress = transaction.allowanceContract
?: raise(ExpressDataError.UnknownError())
val spenderAddress = transaction.allowanceContract ?: raise(GetFeeError.UnknownError)
val rawFee = try {
val wrappedCallData = buildYieldSwapCallData(
@ -174,7 +179,7 @@ class DexSwapFeeCalculator(
val extras = createTransactionExtrasUseCase(
callData = wrappedCallData,
network = network,
).getOrNull() ?: raise(ExpressDataError.UnknownError())
).getOrNull() ?: raise(GetFeeError.UnknownError)
val transactionData = TransactionData.Uncompiled(
amount = createNativeAmountForDex("0", network),
@ -187,13 +192,13 @@ class DexSwapFeeCalculator(
transactionData = transactionData,
network = network,
userWallet = fromSwapCurrencyStatus.userWallet,
).getOrNull() ?: raise(ExpressDataError.UnknownError())
).getOrNull() ?: raise(GetFeeError.UnknownError)
} catch (_: YieldModuleUpgradeUnavailableException) {
raise(ExpressDataError.UnknownError())
raise(GetFeeError.UnknownError)
} catch (_: YieldModuleVersionIndeterminateException) {
raise(ExpressDataError.UnknownError())
raise(GetFeeError.UnknownError)
} catch (_: IllegalStateException) {
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
val gasLimit = transaction.gas ?: raise(GetFeeError.UnknownError)
return@either ethSpecificFeeFallback(fromSwapCurrencyStatus, gasLimit).bind()
}
@ -237,12 +242,12 @@ class DexSwapFeeCalculator(
private suspend fun ethSpecificFeeFallback(
fromSwapCurrencyStatus: SwapCurrencyStatus,
gasLimit: BigInteger,
): Either<ExpressDataError, DexFeeResult> = either {
): Either<GetFeeError, DexFeeResult> = either {
val fee = getEthSpecificFeeUseCase(
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoCurrency = fromSwapCurrencyStatus.currency,
gasLimit = gasLimit,
).getOrNull() ?: raise(ExpressDataError.UnknownError())
).getOrNull() ?: raise(GetFeeError.UnknownError)
val patched = patchEthGasLimitForSwap(fee)
DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(patched),
@ -251,68 +256,114 @@ class DexSwapFeeCalculator(
)
}
@Suppress("CyclomaticComplexMethod")
@Suppress("LongMethod")
private suspend fun getFeeDataForDexSwap(
fromSwapCurrencyStatus: SwapCurrencyStatus,
transaction: ExpressTransactionModel.DEX,
selectedToken: CryptoCurrencyStatus?,
): Either<ExpressDataError, TransactionFeeResult> = either {
val nativeBalance = walletManagersFacade.getNativeTokenBalance(
userWalletId = fromSwapCurrencyStatus.userWalletId,
networkId = fromSwapCurrencyStatus.currency.network.rawId,
derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value,
)
permissionState: PermissionDataState,
): Either<GetFeeError, TransactionFeeResult> = either {
catch(
block = {
val nativeBalance = walletManagersFacade.getNativeTokenBalance(
userWalletId = fromSwapCurrencyStatus.userWalletId,
networkId = fromSwapCurrencyStatus.currency.network.rawId,
derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value,
)
// if native balance is zero - we can't calculate fee
if (nativeBalance.signum() == 0) {
raise(ExpressDataError.UnknownError())
}
// if native balance is zero - we can't calculate fee
if (nativeBalance.signum() == 0) {
raise(GetFeeError.UnknownError)
}
try {
val txAmountValue = transaction.txValue ?: error("unable to get txValue")
val amountToSend = createNativeAmountForDex(txAmountValue, fromSwapCurrencyStatus.currency.network)
val txAmountValue = transaction.txValue ?: error("unable to get txValue")
val amountToSend = if (permissionState is PermissionDataState.PermissionSettings) {
transaction.fromAmount.value.convertToSdkAmount(fromSwapCurrencyStatus.status)
} else {
createNativeAmountForDex(txAmountValue, fromSwapCurrencyStatus.currency.network)
}
// transaction.txValue is always native coin
if (nativeBalance < amountToSend.value) {
error("It's impossible to calculate fee for nativeBalance.value < amountToSend.value")
}
// transaction.txValue is always native coin
if (fromSwapCurrencyStatus.currency is CryptoCurrency.Coin && nativeBalance < amountToSend.value) {
error("It's impossible to calculate fee for nativeBalance.value < amountToSend.value")
}
val extras = createTransactionExtrasUseCase(
data = transaction.txData,
network = fromSwapCurrencyStatus.currency.network,
).getOrNull() ?: error("unable to create extras")
val transactionData = TransactionData.Uncompiled(
amount = amountToSend,
destinationAddress = transaction.txTo,
fee = null,
sourceAddress = transaction.txFrom,
extras = extras,
)
if (selectedToken != null && selectedToken.currency is CryptoCurrency.Token) {
getFeeForTokenUseCase(
transactionData = transactionData,
token = selectedToken.currency,
userWallet = fromSwapCurrencyStatus.userWallet,
).getOrNull()?.let { TransactionFeeResult.LoadedExtended(it) }
?: error("unable to calculate fee for token")
} else {
getFeeUseCase(
transactionData = transactionData,
val extras = createTransactionExtrasUseCase(
data = transaction.txData,
network = fromSwapCurrencyStatus.currency.network,
userWallet = fromSwapCurrencyStatus.userWallet,
).getOrNull()?.let { TransactionFeeResult.Loaded(it) } ?: error("unable to calculate fee")
}
} catch (_: IllegalStateException) {
// gas may be null — surface UnknownError so the provider becomes a SwapError.
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
getEthSpecificFeeUseCase(
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoCurrency = fromSwapCurrencyStatus.currency,
gasLimit = gasLimit,
).getOrNull()?.let { TransactionFeeResult.Loaded(it) }
?: raise(ExpressDataError.UnknownError())
).getOrNull() ?: error("unable to create extras")
val transactionData = TransactionData.Uncompiled(
amount = amountToSend,
destinationAddress = transaction.txTo,
fee = null,
sourceAddress = transaction.txFrom,
extras = extras,
)
if (selectedToken != null && selectedToken.currency is CryptoCurrency.Token) {
getFeeForTokenUseCase(
transactionData = transactionData,
token = selectedToken.currency,
userWallet = fromSwapCurrencyStatus.userWallet,
).fold(
// The token branch normally yields LoadedExtended, but when the use case fails
// we mirror the exception path and fall back to the eth-specific Loaded fee.
ifLeft = { left -> ethSpecificFeeFallbackOrRaise(fromSwapCurrencyStatus, transaction, left) },
ifRight = { feeExtended -> TransactionFeeResult.LoadedExtended(feeExtended) },
)
} else {
val isSimulateEstimation = permissionState is PermissionDataState.PermissionSettings
getFeeUseCase(
transactionData = transactionData,
network = fromSwapCurrencyStatus.currency.network,
userWallet = fromSwapCurrencyStatus.userWallet,
spenderAddress = (permissionState as? PermissionDataState.PermissionSettings)?.spenderAddress,
isSimulateEstimation = isSimulateEstimation,
).fold(
ifLeft = { left -> ethSpecificFeeFallbackOrRaise(fromSwapCurrencyStatus, transaction, left) },
ifRight = { fee -> TransactionFeeResult.Loaded(fee) },
)
}
},
catch = { error ->
ethSpecificFeeFallbackOrRaise(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
transaction = transaction,
// gas may be null — surface DataError so the provider becomes a SwapError.
gasNullError = GetFeeError.DataError(error),
)
},
)
}
/**
* Eth-specific fee fallback shared by the inner `catch`'s exception branch and the
* `Either.Left` branches of [getFeeForTokenUseCase]/[getFeeUseCase] in [getFeeDataForDexSwap].
*
* When [ExpressTransactionModel.DEX.gas] is `null` there is no gas limit to feed
* [GetEthSpecificFeeUseCase], so [gasNullError] is raised instead. For the exception path
* [gasNullError] wraps the thrown [Throwable] as [GetFeeError.DataError]; for the
* `Either.Left` path it is the original left [GetFeeError].
*
* Always returns a [TransactionFeeResult.Loaded] (never `LoadedExtended`), matching the
* legacy exception-catch behaviour.
*/
private suspend fun Raise<GetFeeError>.ethSpecificFeeFallbackOrRaise(
fromSwapCurrencyStatus: SwapCurrencyStatus,
transaction: ExpressTransactionModel.DEX,
gasNullError: GetFeeError,
): TransactionFeeResult {
if (gasNullError is GetFeeError.EstimateOverrideError) {
raise(gasNullError)
}
val gasLimit = transaction.gas ?: raise(gasNullError)
val fee = getEthSpecificFeeUseCase(
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoCurrency = fromSwapCurrencyStatus.currency,
gasLimit = gasLimit,
).bind()
return TransactionFeeResult.Loaded(fee)
}
private suspend fun getFeeDataForSolanaDexSwap(

View file

@ -1,6 +1,8 @@
package com.tangem.feature.swap.domain.models.ui
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
@ -19,19 +21,25 @@ import java.math.BigDecimal
sealed interface SwapState {
data class QuotesLoadedState(
// Quote info
val fromTokenInfo: TokenSwapInfo,
val toTokenInfo: TokenSwapInfo,
val swapProvider: SwapProvider,
// Quote UI state
val priceImpact: PriceImpact,
val preparedSwapConfigState: PreparedSwapConfigState = PreparedSwapConfigState(
balanceStatus = SwapBalanceStatus.Pending,
hasOutgoingTransaction = false,
),
val permissionState: PermissionDataState = PermissionDataState.Empty,
// Quote tx
val swapDataModel: SwapDataModel? = null,
val integratedApprovalData: IntegratedApprovalData? = null,
// Quote validation & checking
val currencyCheck: CryptoCurrencyCheck? = null,
val validationResult: Throwable? = null,
val minAdaValue: BigDecimal?,
val swapProvider: SwapProvider,
) : SwapState
data class Transfer(
@ -119,4 +127,25 @@ data class TokenSwapInfo(
val tokenAmount: SwapAmount,
val amountFiat: BigDecimal,
val swapCurrencyStatus: SwapCurrencyStatus,
)
/**
* Combined approval + swap data attached to a [SwapState.QuotesLoadedState] when the user must approve a token
* spend before swapping. Carries both the prepared approval transaction (built off the current
* `permissionState.type` / spender) and the approval fee [TransactionFee] so the user-selected
* fee bucket can be applied at submission time.
*
* The swap-tx data is not stored here it is rebuilt fresh from `swapDataModel` at submission
* time so any provider-side payload changes are picked up.
*
* @property approvalTransaction the unsigned ERC-20 approve transaction body, fee unset.
* @property approvalFee the loaded fee envelope (Choosable or Single) for the approval tx; used
* to pick min/normal/priority based on the user's [FeeBucket] selection.
* @property approveType the user-selected approval type (LIMITED vs UNLIMITED) the
* [approvalTransaction] was built for. Tracked so the model can detect a recalc-needed change.
*/
data class IntegratedApprovalData(
val approvalTransaction: TransactionData.Uncompiled,
val approvalFee: TransactionFee,
val approveType: ApproveType,
)

View file

@ -227,45 +227,44 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
* whose balance (5.0) comfortably exceeds the fee (0.001).
*/
@Test
fun `applySwapFee — FeePaidCurrency Token — sufficient gasless-token balance returns Sufficient`() =
runTest {
val gaslessTokenId = mockk<CryptoCurrency.ID>(relaxed = true)
val gaslessToken = mockk<CryptoCurrency.Token>(relaxed = true) {
every { id } returns gaslessTokenId
}
val gaslessTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns gaslessToken
every { value.amount } returns BigDecimal("5.0")
}
// FeePaidCurrency.Token with balance=5.0 > fee=0.001 → Enough
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Token(
tokenId = gaslessTokenId,
name = "GasToken",
symbol = "GAS",
contractAddress = "0xGasTokenAddress",
balance = BigDecimal("5.0"),
)
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
val state = buildQuotesLoadedStateWithTokenFrom(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
fromBalance = BigDecimal("10.0"),
fromTokenId = fromId,
)
// selectedFeeToken is the gasless token (different from fromToken)
val fee = buildSwapFeeWithExplicitToken(
feeValue = BigDecimal("0.001"),
tokenStatus = gaslessTokenStatus,
tokenId = gaslessTokenId,
)
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
fun `applySwapFee — FeePaidCurrency Token — sufficient gasless-token balance returns Sufficient`() = runTest {
val gaslessTokenId = mockk<CryptoCurrency.ID>(relaxed = true)
val gaslessToken = mockk<CryptoCurrency.Token>(relaxed = true) {
every { id } returns gaslessTokenId
}
val gaslessTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns gaslessToken
every { value.amount } returns BigDecimal("5.0")
}
// FeePaidCurrency.Token with balance=5.0 > fee=0.001 → Enough
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Token(
tokenId = gaslessTokenId,
name = "GasToken",
symbol = "GAS",
contractAddress = "0xGasTokenAddress",
balance = BigDecimal("5.0"),
)
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
val state = buildQuotesLoadedStateWithTokenFrom(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
fromBalance = BigDecimal("10.0"),
fromTokenId = fromId,
)
// selectedFeeToken is the gasless token (different from fromToken)
val fee = buildSwapFeeWithExplicitToken(
feeValue = BigDecimal("0.001"),
tokenStatus = gaslessTokenStatus,
tokenId = gaslessTokenId,
)
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
/**
* FeePaidCurrency.Token with insufficient token balance InsufficientFee.
@ -534,26 +533,25 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
* No amount+fee concern because the fee currency (ETH) != from-token (USDC).
*/
@Test
fun `applySwapFee DEX — Coin fee — from is Token — native balance covers fee returns Sufficient`() =
runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery {
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
} returns BigDecimal("0.5")
fun `applySwapFee DEX — Coin fee — from is Token — native balance covers fee returns Sufficient`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery {
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
} returns BigDecimal("0.5")
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("100.0"), 6), // 100 USDC
isCoin = false,
fromBalance = BigDecimal("200.0"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("100.0"), 6), // 100 USDC
isCoin = false,
fromBalance = BigDecimal("200.0"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
/**
* From-token is an ERC-20 Token, FeePaidCurrency.Coin.
@ -713,7 +711,7 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
providerType = ExchangeProviderType.CEX,
fromAmount = SwapAmount(BigDecimal("0.999"), 18),
isCoin = true,
fromBalance = BigDecimal("1.1"), // larger than amount+fee so isBalanceEnough passes
fromBalance = BigDecimal("1.1"), // larger than amount+fee so isBalanceEnough passes
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.005"))

View file

@ -234,10 +234,7 @@ internal class SwapInteractorImplApplySwapFeeTest : SwapInteractorImplTestBase()
)
}
private fun buildSwapFee(
feeValue: BigDecimal,
otherNativeFee: BigDecimal = BigDecimal.ZERO,
): SwapFee {
private fun buildSwapFee(feeValue: BigDecimal, otherNativeFee: BigDecimal = BigDecimal.ZERO): SwapFee {
val amount = mockk<Amount>(relaxed = true) {
every { value } returns feeValue
}

View file

@ -78,7 +78,12 @@ internal class SwapInteractorImplBridgeReRouteTest : SwapInteractorImplTestBase(
// we can validate routing by which side-effects ran (allowance + exchangeData for DEX,
// neither for CEX).
coEvery {
getAllowanceInfoUseCase.invoke(any(), any(), any(), any())
getAllowanceInfoUseCase.invoke(
userWalletId = any(),
cryptoCurrency = any(),
spenderAddress = any(),
requiredAmount = any(),
)
} returns (AllowanceInfo.Enough(allowance = BigDecimal("100")) as AllowanceInfo).right()
coEvery {
repository.getExchangeData(
@ -113,104 +118,109 @@ internal class SwapInteractorImplBridgeReRouteTest : SwapInteractorImplTestBase(
// -------------------------------------------------------------------------
@Test
fun `GIVEN DEX provider with quote txType SEND on EVM WHEN findBestQuote THEN routes to manageCex path`() = runTest {
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "dex-with-send")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = null, txType = ExpressTxType.SEND)
stubFindBestQuote(provider, quote)
fun `GIVEN DEX provider with quote txType SEND on EVM WHEN findBestQuote THEN routes to manageCex path`() =
runTest {
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "dex-with-send")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = null, txType = ExpressTxType.SEND)
stubFindBestQuote(provider, quote)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
assertManageCexPathTaken(result, provider)
}
assertManageCexPathTaken(result, provider)
}
@Test
fun `GIVEN DEX provider with quote txType SWAP on EVM WHEN findBestQuote THEN routes to manageDex path`() = runTest {
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "real-dex")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = ExpressTxType.SWAP)
stubFindBestQuote(provider, quote)
fun `GIVEN DEX provider with quote txType SWAP on EVM WHEN findBestQuote THEN routes to manageDex path`() =
runTest {
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "real-dex")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = ExpressTxType.SWAP)
stubFindBestQuote(provider, quote)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
assertManageDexPathTaken(result, provider)
}
assertManageDexPathTaken(result, provider)
}
@Test
fun `GIVEN DEX provider with quote txType null on EVM WHEN findBestQuote THEN routes to manageDex path`() = runTest {
// Legacy backend that hasn't started returning txType on quote yet.
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "legacy-dex")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = null)
stubFindBestQuote(provider, quote)
fun `GIVEN DEX provider with quote txType null on EVM WHEN findBestQuote THEN routes to manageDex path`() =
runTest {
// Legacy backend that hasn't started returning txType on quote yet.
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "legacy-dex")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = null)
stubFindBestQuote(provider, quote)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
assertManageDexPathTaken(result, provider)
}
assertManageDexPathTaken(result, provider)
}
// -------------------------------------------------------------------------
// DEX_BRIDGE provider on EVM
// -------------------------------------------------------------------------
@Test
fun `GIVEN DEX_BRIDGE provider with quote txType SEND WHEN findBestQuote THEN routes to manageCex path`() = runTest {
val provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, providerId = "bridge-send")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = null, txType = ExpressTxType.SEND)
stubFindBestQuote(provider, quote)
fun `GIVEN DEX_BRIDGE provider with quote txType SEND WHEN findBestQuote THEN routes to manageCex path`() =
runTest {
val provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, providerId = "bridge-send")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = null, txType = ExpressTxType.SEND)
stubFindBestQuote(provider, quote)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
assertManageCexPathTaken(result, provider)
}
assertManageCexPathTaken(result, provider)
}
@Test
fun `GIVEN DEX_BRIDGE provider with quote txType SWAP WHEN findBestQuote THEN routes to manageDex path`() = runTest {
val provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, providerId = "li-fi-like")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = ExpressTxType.SWAP)
stubFindBestQuote(provider, quote)
fun `GIVEN DEX_BRIDGE provider with quote txType SWAP WHEN findBestQuote THEN routes to manageDex path`() =
runTest {
val provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, providerId = "li-fi-like")
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = ExpressTxType.SWAP)
stubFindBestQuote(provider, quote)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(provider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
assertManageDexPathTaken(result, provider)
}
assertManageDexPathTaken(result, provider)
}
// -------------------------------------------------------------------------
// CEX provider — regression guard
@ -306,14 +316,18 @@ internal class SwapInteractorImplBridgeReRouteTest : SwapInteractorImplTestBase(
* loadDexSwapDataNoFee, only on the DEX path).
* - getAllowanceInfoUseCase NOT called (DEX-only artifact).
*/
private fun assertManageCexPathTaken(
result: Map<SwapProvider, SwapState>,
provider: SwapProvider,
) {
private fun assertManageCexPathTaken(result: Map<SwapProvider, SwapState>, provider: SwapProvider) {
assertThat(result).hasSize(1)
assertThat(result[provider]).isInstanceOf(SwapState.QuotesLoadedState::class.java)
coVerify(exactly = 0) { getAllowanceInfoUseCase.invoke(any(), any(), any(), any()) }
coVerify(exactly = 0) {
getAllowanceInfoUseCase.invoke(
userWalletId = any(),
cryptoCurrency = any(),
spenderAddress = any(),
requiredAmount = any(),
)
}
coVerify(exactly = 0) {
repository.getExchangeData(
userWallet = any(),
@ -340,10 +354,7 @@ internal class SwapInteractorImplBridgeReRouteTest : SwapInteractorImplTestBase(
* Right and balance is sufficient (the default setup ensures this). The presence of that
* call is therefore a reliable signal that the bridge re-route did NOT fire.
*/
private fun assertManageDexPathTaken(
result: Map<SwapProvider, SwapState>,
provider: SwapProvider,
) {
private fun assertManageDexPathTaken(result: Map<SwapProvider, SwapState>, provider: SwapProvider) {
assertThat(result).hasSize(1)
assertThat(result[provider]).isInstanceOf(SwapState.QuotesLoadedState::class.java)
coVerify(atLeast = 1) {

View file

@ -24,6 +24,7 @@ import com.tangem.feature.swap.domain.models.domain.SwapDataModel
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.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@ -1129,6 +1130,121 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
assertThat(loaded.permissionState).isEqualTo(PermissionDataState.Empty)
}
}
/**
* Regular (non-yield) DEX swap with the integrated-approve toggle ON: the
* `isAllowanceSatisfied` matrix in `manageDex`.
*
* - Integrated-active treats `NotEnough` as satisfied (only `ResetNeeded` blocks), so the flow
* proceeds to `loadDexSwapDataNoFee` `PermissionSettings` (bundled approve+swap).
* - `ResetNeeded` is NOT satisfied the flow does NOT proceed to exchange-data loading.
* - `Enough` proceeds with `permissionState = Empty` (nothing to approve).
*/
@Nested
inner class IntegratedApprovalActivationRegularSwap {
private val spender = "0xDexRouter"
private val tokenContract = "0xRegularToken"
@BeforeEach
fun enableIntegrated() {
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns true
}
@Test
fun `NotEnough allowance with integrated active proceeds to PermissionSettings`() = runTest {
stubAllowanceForSpender(
AllowanceInfo.NotEnough(allowance = BigDecimal.ZERO, requiredAmount = BigDecimal.ONE),
)
val dexProvider = stubTokenDexQuoteAndExchangeData()
val result = invokeRegularToken(dexProvider)
val loaded = result[dexProvider] as SwapState.QuotesLoadedState
assertThat(loaded.permissionState).isInstanceOf(PermissionDataState.PermissionSettings::class.java)
assertThat((loaded.permissionState as PermissionDataState.PermissionSettings).spenderAddress)
.isEqualTo(spender)
}
@Test
fun `ResetNeeded allowance with integrated active does NOT proceed to exchange data`() = runTest {
stubAllowanceForSpender(
AllowanceInfo.ResetNeeded(allowance = BigDecimal("0.5"), requiredAmount = BigDecimal.ONE),
)
val dexProvider = stubTokenDexQuoteAndExchangeData()
invokeRegularToken(dexProvider)
coVerify(exactly = 0) {
repository.getExchangeData(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
providerId = any(), rateType = any(), toAddress = any(),
expressOperationType = any(), refundAddress = any(),
)
}
}
@Test
fun `Enough allowance with integrated active proceeds with permission Empty`() = runTest {
stubAllowanceForSpender(AllowanceInfo.Enough(allowance = BigDecimal("100")))
val dexProvider = stubTokenDexQuoteAndExchangeData()
val result = invokeRegularToken(dexProvider)
val loaded = result[dexProvider] as SwapState.QuotesLoadedState
assertThat(loaded.permissionState).isEqualTo(PermissionDataState.Empty)
}
private fun stubAllowanceForSpender(info: AllowanceInfo) {
coEvery {
getAllowanceInfoUseCase.invoke(
userWalletId = any(), cryptoCurrency = any(),
spenderAddress = any(), requiredAmount = any(),
)
} returns info.right()
}
private fun stubTokenDexQuoteAndExchangeData(): com.tangem.feature.swap.domain.models.domain.SwapProvider {
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val quoteModel = buildQuoteModel(allowanceContract = spender)
val swapData = buildSwapDataModelDex()
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(),
)
} returns quoteModel.right()
coEvery {
repository.getExchangeData(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(), toAddress = any(),
expressOperationType = any(), refundAddress = any(),
)
} returns swapData.right()
return dexProvider
}
private suspend fun invokeRegularToken(
dexProvider: com.tangem.feature.swap.domain.models.domain.SwapProvider,
) = sut.findBestQuote(
fromSwapCurrencyStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = tokenContract,
isCoin = false,
amount = BigDecimal("10"),
),
toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork),
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
}
}
// region — test-local helpers

View file

@ -0,0 +1,226 @@
package com.tangem.feature.swap.domain
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.fee.DexFeeResult
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.math.BigInteger
/**
* Tests for the integrated-approval fallback context in [SwapInteractorImpl]
* ([SwapInteractorImpl.integratedApprovalFallback] / private `hasIntegratedApprovalFallenBack`).
*
* The fallback context is keyed by `(userWalletId, currency.id, spenderAddress)`. Once
* [SwapInteractorImpl.integratedApprovalFallback] records a context, a subsequent
* [SwapInteractorImpl.loadSwapFee] with a matching `PermissionSettings.spenderAddress` must
* downgrade the calculator's `permissionState` to [PermissionDataState.Empty] (legacy
* separate-approval flow). A DIFFERENT spender must NOT be downgraded.
*
* Observed through the public `loadSwapFee` path: the `DexSwapFeeCalculator` is mocked and its
* `permissionState` argument is captured.
*
* NOTE: the fallback key uses `currency.id`, which is a relaxed mock with reference equality.
* Each `buildSwapCurrencyStatus(...)` call produces a fresh `currency.id`, so the SAME
* `fromStatus` instance must be reused across the `integratedApprovalFallback` call and the
* `loadSwapFee` call for the key to match.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplIntegratedApprovalFallbackTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val nativeFeeTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true)
@BeforeEach
fun setup() {
coEvery {
getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any())
} returns nativeFeeTokenStatus.right()
}
@Test
fun `GIVEN fallback recorded for matching spender THEN loadSwapFee downgrades permissionState to Empty`() =
runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val permissionSlot = slot<PermissionDataState>()
stubCalculatorCapturing(permissionSlot)
sut.integratedApprovalFallback(fromSwapCurrencyStatus = fromStatus, spenderAddress = SPENDER)
sut.loadSwapFee(
quotesLoadedState = buildPermissionSettingsState(SPENDER),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = buildSwapData(),
selectedFeeToken = null,
isGasless = false
)
assertThat(permissionSlot.captured).isEqualTo(PermissionDataState.Empty)
}
@Test
fun `GIVEN no fallback recorded THEN loadSwapFee passes the original PermissionSettings`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val permissionSlot = slot<PermissionDataState>()
stubCalculatorCapturing(permissionSlot)
sut.loadSwapFee(
quotesLoadedState = buildPermissionSettingsState(SPENDER),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = buildSwapData(),
selectedFeeToken = null,
isGasless = false
)
assertThat(permissionSlot.captured).isInstanceOf(PermissionDataState.PermissionSettings::class.java)
assertThat((permissionSlot.captured as PermissionDataState.PermissionSettings).spenderAddress)
.isEqualTo(SPENDER)
}
@Test
fun `GIVEN fallback recorded for a different spender THEN loadSwapFee is NOT downgraded`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val permissionSlot = slot<PermissionDataState>()
stubCalculatorCapturing(permissionSlot)
// Record the fallback for a DIFFERENT spender than the one in the loaded state.
sut.integratedApprovalFallback(fromSwapCurrencyStatus = fromStatus, spenderAddress = OTHER_SPENDER)
sut.loadSwapFee(
quotesLoadedState = buildPermissionSettingsState(SPENDER),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = buildSwapData(),
selectedFeeToken = null,
isGasless = false
)
assertThat(permissionSlot.captured).isInstanceOf(PermissionDataState.PermissionSettings::class.java)
assertThat((permissionSlot.captured as PermissionDataState.PermissionSettings).spenderAddress)
.isEqualTo(SPENDER)
}
@Test
fun `GIVEN fallback recorded for a different from-currency THEN loadSwapFee is NOT downgraded`() = runTest {
// Fallback recorded for one currency instance, fee loaded for a different instance with
// the same spender → keys differ on currency.id → no downgrade.
val fallbackFromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
val feeFromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val permissionSlot = slot<PermissionDataState>()
stubCalculatorCapturing(permissionSlot)
sut.integratedApprovalFallback(fromSwapCurrencyStatus = fallbackFromStatus, spenderAddress = SPENDER)
sut.loadSwapFee(
quotesLoadedState = buildPermissionSettingsState(SPENDER),
fromStatus = feeFromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = buildSwapData(),
selectedFeeToken = null,
isGasless = false
)
assertThat(permissionSlot.captured).isInstanceOf(PermissionDataState.PermissionSettings::class.java)
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun stubCalculatorCapturing(permissionSlot: io.mockk.CapturingSlot<PermissionDataState>) {
coEvery {
dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = any(),
transaction = any(),
selectedToken = any(),
permissionState = capture(permissionSlot),
)
} returns DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(
TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true)),
),
otherNativeFee = BigDecimal.ZERO,
gas = BigInteger.valueOf(21_000L),
).right()
}
private fun buildPermissionSettingsState(spender: String): SwapState.QuotesLoadedState {
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
return SwapState.QuotesLoadedState(
fromTokenInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal.ONE, 18),
swapCurrencyStatus = from,
amountFiat = BigDecimal.ZERO,
),
toTokenInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal("0.5"), 18),
swapCurrencyStatus = to,
amountFiat = BigDecimal.ZERO,
),
priceImpact = PriceImpact.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
balanceStatus = SwapBalanceStatus.Pending,
hasOutgoingTransaction = false,
),
permissionState = PermissionDataState.PermissionSettings(
type = ApproveType.UNLIMITED,
spenderAddress = spender,
),
swapDataModel = null,
currencyCheck = null,
validationResult = null,
minAdaValue = null,
swapProvider = buildSwapProvider(ExchangeProviderType.DEX),
)
}
private fun buildSwapData(): SwapDataModel = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = ExpressTransactionModel.DEX(
fromAmount = SwapAmount(BigDecimal.ONE, 18),
toAmount = SwapAmount(BigDecimal("0.5"), 18),
txValue = "1000000000000000",
txId = "tx-id",
txTo = "0xTo",
txExtraId = null,
txFrom = "0xFrom",
txData = "dGVzdA==",
otherNativeFeeWei = null,
gas = BigInteger.valueOf(21_000L),
allowanceContract = null,
),
)
private companion object {
const val SPENDER = "0xSpender"
const val OTHER_SPENDER = "0xOtherSpender"
}
}

View file

@ -12,9 +12,11 @@ import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
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.coVerify
import io.mockk.every
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -76,7 +78,12 @@ internal class SwapInteractorImplLoadDexSwapDataNoFeeTest : SwapInteractorImplTe
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right()
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
coEvery {
getAllowanceInfoUseCase.invoke(any(), any(), any(), any())
getAllowanceInfoUseCase.invoke(
userWalletId = any(),
cryptoCurrency = any(),
spenderAddress = any(),
requiredAmount = any(),
)
} returns (AllowanceInfo.Enough(allowance = BigDecimal("100")) as AllowanceInfo).right()
}
@ -157,4 +164,131 @@ internal class SwapInteractorImplLoadDexSwapDataNoFeeTest : SwapInteractorImplTe
// Fee calculator must not be invoked during quote loading.
coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) }
}
// region permission-state selection on AllowanceInfo.NotEnough
@Test
fun `GIVEN NotEnough allowance AND integrated active THEN permissionState is PermissionSettings`() = runTest {
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns true
stubAllowance(AllowanceInfo.NotEnough(allowance = BigDecimal.ZERO, requiredAmount = BigDecimal.ONE))
val state = runFindBestQuoteForToken()
val permission = state.permissionState
assertThat(permission).isInstanceOf(PermissionDataState.PermissionSettings::class.java)
assertThat((permission as PermissionDataState.PermissionSettings).spenderAddress).isEqualTo(SPENDER)
}
@Test
fun `GIVEN Enough allowance THEN permissionState is Empty`() = runTest {
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns true
stubAllowance(AllowanceInfo.Enough(allowance = BigDecimal("100")))
val state = runFindBestQuoteForToken()
assertThat(state.permissionState).isEqualTo(PermissionDataState.Empty)
}
@Test
fun `GIVEN NotEnough allowance AND integrated toggle OFF THEN does not reach loadDexSwapDataNoFee`() = runTest {
// With the integrated toggle off, NotEnough is not allowance-satisfied (requires Enough),
// so manageDex does NOT enter loadDexSwapDataNoFee — getExchangeData is never called.
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns false
stubAllowance(AllowanceInfo.NotEnough(allowance = BigDecimal.ZERO, requiredAmount = BigDecimal.ONE))
runFindBestQuoteForTokenRaw()
coVerify(exactly = 0) {
repository.getExchangeData(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
providerId = any(), rateType = any(), toAddress = any(),
expressOperationType = any(), refundAddress = any(),
)
}
}
// endregion
private fun stubAllowance(info: AllowanceInfo) {
coEvery {
getAllowanceInfoUseCase.invoke(
userWalletId = any(),
cryptoCurrency = any(),
spenderAddress = any(),
requiredAmount = any(),
)
} returns info.right()
}
/** Runs findBestQuote for a token from-currency whose quote carries [SPENDER] as allowanceContract. */
private suspend fun runFindBestQuoteForToken(): SwapState.QuotesLoadedState {
val dexProvider = stubDexQuoteAndExchangeData()
val result = invokeFindBestQuote(dexProvider)
return result[dexProvider] as SwapState.QuotesLoadedState
}
private suspend fun runFindBestQuoteForTokenRaw() {
val dexProvider = stubDexQuoteAndExchangeData()
invokeFindBestQuote(dexProvider)
}
private fun stubDexQuoteAndExchangeData(): com.tangem.feature.swap.domain.models.domain.SwapProvider {
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val quoteModel = buildQuoteModel(allowanceContract = SPENDER)
val swapDataModel = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = ExpressTransactionModel.DEX(
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
toAmount = SwapAmount(BigDecimal("0.5"), 18),
txValue = "1000000000000000000",
txId = "tx-id",
txTo = "0xToAddress",
txExtraId = null,
txFrom = "0xFromAddress",
txData = "0xdata",
otherNativeFeeWei = null,
gas = BigInteger.valueOf(21_000L),
allowanceContract = SPENDER,
),
)
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(),
)
} returns quoteModel.right()
coEvery {
repository.getExchangeData(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(), toAddress = any(),
expressOperationType = any(), refundAddress = any(),
)
} returns swapDataModel.right()
return dexProvider
}
private suspend fun invokeFindBestQuote(
dexProvider: com.tangem.feature.swap.domain.models.domain.SwapProvider,
) = sut.findBestQuote(
fromSwapCurrencyStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = false,
contractAddress = "0xToken",
amount = BigDecimal("10"),
),
toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork),
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
private companion object {
const val SPENDER = "0xSpender"
}
}

View file

@ -0,0 +1,193 @@
package com.tangem.feature.swap.domain
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
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.domain.transaction.error.GetFeeError
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
* Tests for [SwapInteractorImpl.loadIntegratedApprovalData].
*
* Builds the ERC-20 approval transaction (via [createApprovalTransactionUseCase]) and loads its
* [TransactionFee] (via [getFeeUseCase]). Honors [ApproveType]:
* - `LIMITED` approval amount = the passed swap amount.
* - `UNLIMITED` approval amount = null (unbounded allowance).
*
* Error surfaces:
* - non-Token from-currency `Left(GetFeeError.DataError)`.
* - `createApprovalTransactionUseCase` Left (throwable) `Left(GetFeeError.DataError)`.
* - `getFeeUseCase` Left propagated as Left verbatim.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplLoadIntegratedApprovalDataTest : SwapInteractorImplTestBase() {
private val approvalTx = mockk<TransactionData.Uncompiled>(relaxed = true)
private val approvalFee = TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
@BeforeEach
fun setup() {
coEvery {
createApprovalTransactionUseCase.invoke(
cryptoCurrencyStatus = any(),
userWalletId = any(),
amount = any(),
contractAddress = any(),
spenderAddress = any(),
)
} returns approvalTx.right()
coEvery {
getFeeUseCase.invoke(
transactionData = any(),
userWallet = any(),
network = any(),
)
} returns approvalFee.right()
}
@Test
fun `GIVEN LIMITED THEN approval amount equals the swap amount`() = runTest {
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
val amountCaptures = mutableListOf<BigDecimal?>()
coEvery {
createApprovalTransactionUseCase.invoke(
cryptoCurrencyStatus = any(),
userWalletId = any(),
amount = captureNullable(amountCaptures),
contractAddress = any(),
spenderAddress = any(),
)
} returns approvalTx.right()
val result = sut.loadIntegratedApprovalData(
fromStatus = fromStatus,
spenderAddress = SPENDER,
approveType = ApproveType.LIMITED,
approvalAmount = SWAP_AMOUNT,
)
assertThat(result.isRight()).isTrue()
result.onRight { data ->
assertThat(data).isInstanceOf(IntegratedApprovalData::class.java)
assertThat(data.approveType).isEqualTo(ApproveType.LIMITED)
assertThat(data.approvalFee).isEqualTo(approvalFee)
assertThat(data.approvalTransaction).isEqualTo(approvalTx)
}
assertThat(amountCaptures.single()).isEqualTo(SWAP_AMOUNT)
}
@Test
fun `GIVEN UNLIMITED THEN approval amount is null`() = runTest {
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
val amountCaptures = mutableListOf<BigDecimal?>()
coEvery {
createApprovalTransactionUseCase.invoke(
cryptoCurrencyStatus = any(),
userWalletId = any(),
amount = captureNullable(amountCaptures),
contractAddress = any(),
spenderAddress = any(),
)
} returns approvalTx.right()
val result = sut.loadIntegratedApprovalData(
fromStatus = fromStatus,
spenderAddress = SPENDER,
approveType = ApproveType.UNLIMITED,
approvalAmount = SWAP_AMOUNT,
)
assertThat(result.isRight()).isTrue()
result.onRight { data -> assertThat(data.approveType).isEqualTo(ApproveType.UNLIMITED) }
assertThat(amountCaptures.single()).isNull()
}
@Test
fun `GIVEN non-Token from-currency THEN returns Left DataError`() = runTest {
val fromStatus = buildSwapCurrencyStatus(isCoin = true)
val result = sut.loadIntegratedApprovalData(
fromStatus = fromStatus,
spenderAddress = SPENDER,
approveType = ApproveType.LIMITED,
approvalAmount = SWAP_AMOUNT,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { error -> assertThat(error).isInstanceOf(GetFeeError.DataError::class.java) }
coVerify(exactly = 0) {
createApprovalTransactionUseCase.invoke(
cryptoCurrencyStatus = any(),
userWalletId = any(),
amount = any(),
contractAddress = any(),
spenderAddress = any(),
)
}
}
@Test
fun `GIVEN createApprovalTransaction Left THEN returns Left DataError`() = runTest {
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
coEvery {
createApprovalTransactionUseCase.invoke(
cryptoCurrencyStatus = any(),
userWalletId = any(),
amount = any(),
contractAddress = any(),
spenderAddress = any(),
)
} returns IllegalStateException("cannot build approval tx").left()
val result = sut.loadIntegratedApprovalData(
fromStatus = fromStatus,
spenderAddress = SPENDER,
approveType = ApproveType.LIMITED,
approvalAmount = SWAP_AMOUNT,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { error -> assertThat(error).isInstanceOf(GetFeeError.DataError::class.java) }
coVerify(exactly = 0) {
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
}
}
@Test
fun `GIVEN getFeeUseCase Left THEN propagates the Left error verbatim`() = runTest {
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
coEvery {
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
} returns GetFeeError.BlockchainErrors.TronActivationError.left()
val result = sut.loadIntegratedApprovalData(
fromStatus = fromStatus,
spenderAddress = SPENDER,
approveType = ApproveType.LIMITED,
approvalAmount = SWAP_AMOUNT,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isEqualTo(GetFeeError.BlockchainErrors.TronActivationError)
}
}
private companion object {
const val SPENDER = "0xSpender"
const val CONTRACT = "0xContract"
val SWAP_AMOUNT: BigDecimal = BigDecimal("12.34")
}
}

View file

@ -16,14 +16,12 @@ import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.feature.swap.domain.fee.CexFeeResult
import com.tangem.feature.swap.domain.fee.DexFeeResult
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.ui.FeeBucket
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
@ -81,7 +79,12 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
)
val rawFee = TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
coEvery {
dexSwapFeeCalculator.calculate(any(), any(), any())
dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = any(),
transaction = any(),
selectedToken = any(),
permissionState = any(),
)
} returns DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(rawFee),
otherNativeFee = BigDecimal.ZERO,
@ -89,7 +92,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX),
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
@ -106,7 +109,12 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(nativeFeeTokenStatus)
}
coVerify(exactly = 1) {
dexSwapFeeCalculator.calculate(fromStatus, transaction, null)
dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
selectedToken = null,
permissionState = any(),
)
}
}
@ -125,7 +133,12 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
),
)
coEvery {
dexSwapFeeCalculator.calculate(any(), any(), any())
dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = any(),
transaction = any(),
selectedToken = any(),
permissionState = any(),
)
} returns DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(solanaFee),
otherNativeFee = BigDecimal.ZERO,
@ -133,7 +146,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX),
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 9),
@ -159,7 +172,12 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
transaction = transaction,
)
coEvery {
dexSwapFeeCalculator.calculate(any(), any(), any())
dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = any(),
transaction = any(),
selectedToken = any(),
permissionState = any(),
)
} returns DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(
TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true)),
@ -169,7 +187,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE),
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX_BRIDGE),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
@ -190,7 +208,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX),
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
@ -203,7 +221,14 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) }
coVerify(exactly = 0) {
dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = any(),
transaction = any(),
selectedToken = any(),
permissionState = any(),
)
}
}
@Test
@ -212,7 +237,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE),
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX_BRIDGE),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
@ -228,35 +253,6 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
}
}
@Test
fun `DEX calculator Left ExpressDataError maps to Wrapped Left GetFeeError`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val swapData = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = buildDexTransaction(),
)
coEvery {
dexSwapFeeCalculator.calculate(any(), any(), any())
} returns ExpressDataError.UnknownError().left()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = swapData,
selectedFeeToken = null, isGasless = false,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.DataError::class.java)
assertThat((error as? GetFeeError.DataError)?.cause).isInstanceOf(ExpressDataError.UnknownError::class.java)
}
}
// -------------------------------------------------------------------------
// CEX branch
// -------------------------------------------------------------------------
@ -267,18 +263,24 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val extendedFee = mockk<TransactionFeeExtended>(relaxed = true) {
// Gasless picked native — feeTokenId points at the network's coin.
io.mockk.every { transactionFee } returns TransactionFee.Single(
every { transactionFee } returns TransactionFee.Single(
normal = mockk<Fee.Common>(relaxed = true),
)
}
coEvery {
cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any())
cexSwapFeeCalculator.calculate(
userWallet = any(),
fromSwapCurrencyStatus = any(),
amount = any(),
selectedFeeToken = any(),
isGasless = any(),
)
} returns CexFeeResult(
transactionFee = TransactionFeeResult.LoadedExtended(extendedFee),
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.CEX),
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.CEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
@ -300,8 +302,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
amount = BigDecimal.ONE,
selectedFeeToken = null,
isGasless = true,
)
)
}
}
@ -317,13 +318,19 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val extendedFee = mockk<TransactionFeeExtended>(relaxed = true)
coEvery {
cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any())
cexSwapFeeCalculator.calculate(
userWallet = any(),
fromSwapCurrencyStatus = any(),
amount = any(),
selectedFeeToken = any(),
isGasless = any(),
)
} returns CexFeeResult(
transactionFee = TransactionFeeResult.LoadedExtended(extendedFee),
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.CEX),
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.CEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
@ -344,17 +351,23 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val explicitTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
io.mockk.every { currency } returns mockk<CryptoCurrency.Token>(relaxed = true)
every { currency } returns mockk<CryptoCurrency.Token>(relaxed = true)
}
val extendedFee = mockk<TransactionFeeExtended>(relaxed = true)
coEvery {
cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any())
cexSwapFeeCalculator.calculate(
userWallet = any(),
fromSwapCurrencyStatus = any(),
amount = any(),
selectedFeeToken = any(),
isGasless = any(),
)
} returns CexFeeResult(
transactionFee = TransactionFeeResult.LoadedExtended(extendedFee),
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.CEX),
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.CEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
@ -383,17 +396,23 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val explicitNativeStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
io.mockk.every { currency } returns mockk<CryptoCurrency.Coin>(relaxed = true)
every { currency } returns mockk<CryptoCurrency.Coin>(relaxed = true)
}
val rawFee = TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
coEvery {
cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any())
cexSwapFeeCalculator.calculate(
userWallet = any(),
fromSwapCurrencyStatus = any(),
amount = any(),
selectedFeeToken = any(),
isGasless = any(),
)
} returns CexFeeResult(
transactionFee = TransactionFeeResult.Loaded(rawFee),
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.CEX),
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.CEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
@ -414,11 +433,17 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
coEvery {
cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any())
cexSwapFeeCalculator.calculate(
userWallet = any(),
fromSwapCurrencyStatus = any(),
amount = any(),
selectedFeeToken = any(),
isGasless = any(),
)
} returns GetFeeError.UnknownError.left()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.CEX),
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.CEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
@ -443,7 +468,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.CEX),
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.CEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ZERO, 18),
@ -456,7 +481,15 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
coVerify(exactly = 0) { cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any()) }
coVerify(exactly = 0) {
cexSwapFeeCalculator.calculate(
userWallet = any(),
fromSwapCurrencyStatus = any(),
amount = any(),
selectedFeeToken = any(),
isGasless = any(),
)
}
}
@Test
@ -469,7 +502,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
)
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX),
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ZERO, 18),
@ -483,7 +516,14 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) }
coVerify(exactly = 0) {
dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = any(),
transaction = any(),
selectedToken = any(),
permissionState = any(),
)
}
}
// -------------------------------------------------------------------------
@ -501,11 +541,16 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
transaction = transaction,
)
val explicitTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
io.mockk.every { currency } returns mockk<CryptoCurrency.Token>(relaxed = true)
every { currency } returns mockk<CryptoCurrency.Token>(relaxed = true)
}
val rawFee = TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
coEvery {
dexSwapFeeCalculator.calculate(any(), any(), any())
dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = any(),
transaction = any(),
selectedToken = any(),
permissionState = any(),
)
} returns DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(rawFee),
otherNativeFee = BigDecimal.ZERO,
@ -513,7 +558,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX),
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
@ -528,7 +573,12 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(explicitTokenStatus)
}
coVerify(exactly = 1) {
dexSwapFeeCalculator.calculate(fromStatus, transaction, explicitTokenStatus)
dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
selectedToken = explicitTokenStatus,
permissionState = any(),
)
}
}
@ -545,7 +595,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
* This exercises the `resolveNativeFeeTokenStatus` fallback path in loadDexSwapFee.
*/
@Test
fun `DEX with null selectedFeeToken resolveNativeFeeTokenStatus returns null when networkAddress is null`() =
fun `DEX with null selectedFeeToken - resolveNativeFeeTokenStatus returns null when networkAddress is null`() =
runTest {
// Primary resolve: getFeePaidCryptoCurrencyStatusSyncUseCase returns Right(null)
// → triggers the fallback block in resolveNativeFeeTokenStatus
@ -560,7 +610,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
networkRawId = ethNetwork,
isCoin = true,
)
io.mockk.every {
every {
fromStatusWithNullAddr.status.value.networkAddress
} returns null
@ -576,7 +626,12 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
// quotesRepository returns null → NoQuote path → networkAddress null → return@run null
coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } returns null
coEvery {
dexSwapFeeCalculator.calculate(any(), any(), any())
dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = any(),
transaction = any(),
selectedToken = any(),
permissionState = any(),
)
} returns DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(
TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true)),
@ -586,7 +641,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX),
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX),
fromStatus = fromStatusWithNullAddr,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
@ -606,19 +661,49 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
// Helpers
// -------------------------------------------------------------------------
private fun buildDexTransaction(
otherNativeFeeWei: BigDecimal? = null,
): ExpressTransactionModel.DEX = ExpressTransactionModel.DEX(
fromAmount = SwapAmount(BigDecimal.ONE, 18),
toAmount = SwapAmount(BigDecimal("0.5"), 18),
txValue = "1000000000000000",
txId = "tx-id",
txTo = "0xTo",
txExtraId = null,
txFrom = "0xFrom",
txData = "dGVzdA==",
otherNativeFeeWei = otherNativeFeeWei,
gas = BigInteger.valueOf(21_000L),
allowanceContract = null,
)
private fun buildQuotesLoadedState(
providerType: ExchangeProviderType,
permissionState: PermissionDataState = PermissionDataState.Empty,
): SwapState.QuotesLoadedState {
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
return SwapState.QuotesLoadedState(
fromTokenInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal.ONE, 18),
swapCurrencyStatus = from,
amountFiat = BigDecimal.ZERO,
),
toTokenInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal("0.5"), 18),
swapCurrencyStatus = to,
amountFiat = BigDecimal.ZERO,
),
priceImpact = PriceImpact.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
balanceStatus = SwapBalanceStatus.Pending,
hasOutgoingTransaction = false,
),
permissionState = permissionState,
swapDataModel = null,
currencyCheck = null,
validationResult = null,
minAdaValue = null,
swapProvider = buildSwapProvider(providerType),
)
}
private fun buildDexTransaction(otherNativeFeeWei: BigDecimal? = null): ExpressTransactionModel.DEX =
ExpressTransactionModel.DEX(
fromAmount = SwapAmount(BigDecimal.ONE, 18),
toAmount = SwapAmount(BigDecimal("0.5"), 18),
txValue = "1000000000000000",
txId = "tx-id",
txTo = "0xTo",
txExtraId = null,
txFrom = "0xFrom",
txData = "dGVzdA==",
otherNativeFeeWei = otherNativeFeeWei,
gas = BigInteger.valueOf(21_000L),
allowanceContract = null,
)
}

View file

@ -2,22 +2,35 @@ package com.tangem.feature.swap.domain
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionExtras
import com.tangem.blockchain.common.TransactionSender
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.ui.FeeBucket
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
import com.tangem.feature.swap.domain.models.ui.SwapFee
import com.tangem.feature.swap.domain.models.ui.SwapTransactionState
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -109,6 +122,170 @@ internal class SwapInteractorImplOnSwapTest : SwapInteractorImplTestBase() {
coVerifyGetExchangeData(times = 1)
}
// region integrated approve+swap (sendIntegratedApproveAndSwap)
@Test
fun `GIVEN integratedApproval WHEN onSwap THEN sends approve plus swap as one DEFAULT batch and swap hash is last`() =
runTest {
stubSwapTxCreated()
val txsSlot = slot<List<TransactionData>>()
coEvery {
sendTransactionUseCase(
txsData = capture(txsSlot),
userWallet = any(),
network = any(),
sendMode = TransactionSender.MultipleTransactionSendMode.DEFAULT,
)
} returns listOf(APPROVAL_HASH, SWAP_HASH).right()
val result = onSwapIntegrated(integratedApproval = integratedApproval(approvalFee = singleFee()))
// approval tx first, swap tx last → 2 txs in a single batch.
assertThat(txsSlot.captured).hasSize(2)
assertThat(txsSlot.captured.first()).isInstanceOf(TransactionData.Uncompiled::class.java)
// Success carries the LAST hash (the swap tx); approval hash is dropped.
assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java)
assertThat((result as SwapTransactionState.TxSent).txHash).isEqualTo(SWAP_HASH)
coVerify(exactly = 1) {
sendTransactionUseCase(
txsData = any(),
userWallet = any(),
network = any(),
sendMode = TransactionSender.MultipleTransactionSendMode.DEFAULT,
)
}
}
@Test
fun `GIVEN integratedApproval AND send fails WHEN onSwap THEN returns TransactionError`() = runTest {
stubSwapTxCreated()
coEvery {
sendTransactionUseCase(txsData = any(), userWallet = any(), network = any(), sendMode = any())
} returns SendTransactionError.UnknownError(Exception("boom")).left()
val result = onSwapIntegrated(integratedApproval = integratedApproval(approvalFee = singleFee()))
assertThat(result).isInstanceOf(SwapTransactionState.Error.TransactionError::class.java)
}
@Test
fun `GIVEN Choosable approval fee AND SLOW bucket THEN approval tx fee is the minimum`() = runTest {
assertApprovalFeeBucket(
approvalFee = choosableFee(),
bucket = FeeBucket.SLOW,
expectedFee = MIN_FEE,
)
}
@Test
fun `GIVEN Choosable approval fee AND FAST bucket THEN approval tx fee is the priority`() = runTest {
assertApprovalFeeBucket(
approvalFee = choosableFee(),
bucket = FeeBucket.FAST,
expectedFee = PRIORITY_FEE,
)
}
@Test
fun `GIVEN Choosable approval fee AND MARKET bucket THEN approval tx fee is the normal`() = runTest {
assertApprovalFeeBucket(
approvalFee = choosableFee(),
bucket = FeeBucket.MARKET,
expectedFee = NORMAL_FEE,
)
}
@Test
fun `GIVEN Single approval fee THEN approval tx fee is the normal regardless of bucket`() = runTest {
assertApprovalFeeBucket(
approvalFee = singleFee(),
bucket = FeeBucket.SLOW,
expectedFee = NORMAL_FEE,
)
}
private suspend fun assertApprovalFeeBucket(
approvalFee: TransactionFee,
bucket: FeeBucket,
expectedFee: Fee,
) {
stubSwapTxCreated()
val txsSlot = slot<List<TransactionData>>()
coEvery {
sendTransactionUseCase(txsData = capture(txsSlot), userWallet = any(), network = any(), sendMode = any())
} returns listOf(APPROVAL_HASH, SWAP_HASH).right()
onSwapIntegrated(
integratedApproval = integratedApproval(approvalFee = approvalFee),
swapFee = buildSwapFee(feeBucket = bucket),
)
val approvalTx = txsSlot.captured.first() as TransactionData.Uncompiled
assertThat(approvalTx.fee).isEqualTo(expectedFee)
}
private fun stubSwapTxCreated() {
// The swap tx must be a real Uncompiled so getPayoutAddress(swapTxData) resolves.
coEvery {
createTransactionUseCase(
amount = any(), fee = any(), memo = any(),
destination = any(), userWalletId = any(), network = any(), txExtras = any(),
)
} returns swapTxUncompiled().right()
}
private suspend fun onSwapIntegrated(
integratedApproval: IntegratedApprovalData,
swapFee: SwapFee = buildSwapFee(feeBucket = FeeBucket.MARKET),
): SwapTransactionState = sut.onSwap(
fromSwapCurrencyStatus = hotStatus(),
toSwapCurrencyStatus = hotStatus(),
swapProvider = buildSwapProvider(ExchangeProviderType.DEX),
swapData = dexSwapData(),
amountToSwap = "1.0",
balanceStatus = SwapBalanceStatus.Sufficient,
fee = swapFee,
expressOperationType = ExpressOperationType.SWAP,
isTangemPayWithdrawal = false,
integratedApproval = integratedApproval,
)
private fun integratedApproval(approvalFee: TransactionFee): IntegratedApprovalData = IntegratedApprovalData(
approvalTransaction = approvalTxUncompiled(),
approvalFee = approvalFee,
approveType = ApproveType.UNLIMITED,
)
private fun approvalTxUncompiled(): TransactionData.Uncompiled = TransactionData.Uncompiled(
amount = realAmount(),
fee = null,
sourceAddress = "0xFrom",
destinationAddress = "0xContract",
)
private fun swapTxUncompiled(): TransactionData.Uncompiled = TransactionData.Uncompiled(
amount = realAmount(),
fee = NORMAL_FEE,
sourceAddress = "0xFrom",
destinationAddress = "0xTo",
)
private fun realAmount(): Amount = Amount(
currencySymbol = "ETH",
value = BigDecimal.ONE,
decimals = 18,
)
private fun singleFee(): TransactionFee.Single = TransactionFee.Single(normal = NORMAL_FEE)
private fun choosableFee(): TransactionFee.Choosable = TransactionFee.Choosable(
minimum = MIN_FEE,
normal = NORMAL_FEE,
priority = PRIORITY_FEE,
)
// endregion
// region helpers
private suspend fun onSwap(provider: ExchangeProviderType, swapData: SwapDataModel?) {
@ -177,10 +354,28 @@ internal class SwapInteractorImplOnSwapTest : SwapInteractorImplTestBase() {
private fun coVerifyCreateTransaction(times: Int) = coVerify(exactly = times) {
createTransactionUseCase(
amount = any(), fee = any(), memo = any(),
destination = any(), userWalletId = any(), network = any(), txExtras = any(),
amount = any(),
fee = any(),
memo = any(),
destination = any(),
userWalletId = any(),
network = any(),
txExtras = any(),
)
}
// endregion
private companion object {
const val APPROVAL_HASH = "0xApprovalHash"
const val SWAP_HASH = "0xSwapHash"
val MIN_FEE: Fee = feeOf(BigDecimal("0.001"))
val NORMAL_FEE: Fee = feeOf(BigDecimal("0.002"))
val PRIORITY_FEE: Fee = feeOf(BigDecimal("0.003"))
private fun feeOf(value: BigDecimal): Fee = Fee.Common(
amount = Amount(currencySymbol = "ETH", value = value, decimals = 18),
)
}
}

View file

@ -88,6 +88,8 @@ internal open class SwapInteractorImplTestBase {
protected val cexSwapFeeCalculator: CexSwapFeeCalculator = mockk(relaxed = true)
protected val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
protected val yieldModuleAddressProvider: YieldModuleAddressProvider = mockk(relaxed = true)
protected val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase = mockk(relaxed = true)
protected val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
// endregion
@ -121,6 +123,8 @@ internal open class SwapInteractorImplTestBase {
cexSwapFeeCalculator = cexSwapFeeCalculator,
swapFeatureToggles = swapFeatureToggles,
yieldModuleAddressProvider = yieldModuleAddressProvider,
createApprovalTransactionUseCase = createApprovalTransactionUseCase,
getFeeUseCase = getFeeUseCase,
)
}

View file

@ -77,7 +77,12 @@ internal class CexSwapFeeCalculatorTest {
// None of the fee use cases were invoked
coVerify(exactly = 0) {
estimateFeeUseCase.invoke(any(), any(), any())
estimateFeeForTokenUseCase.invoke(any(), any(), any(), any())
estimateFeeForTokenUseCase.invoke(
userWallet = any(),
feeTokenCurrencyStatus = any(),
sendingTokenCurrencyStatus = any(),
amount = any(),
)
estimateFeeForGaslessTxUseCase.invoke(any(), any(), any())
}
}
@ -116,7 +121,12 @@ internal class CexSwapFeeCalculatorTest {
// Other use cases are NOT called.
coVerify(exactly = 0) {
estimateFeeUseCase.invoke(any(), any(), any())
estimateFeeForTokenUseCase.invoke(any(), any(), any(), any())
estimateFeeForTokenUseCase.invoke(
userWallet = any(),
feeTokenCurrencyStatus = any(),
sendingTokenCurrencyStatus = any(),
amount = any(),
)
}
}
@ -154,7 +164,12 @@ internal class CexSwapFeeCalculatorTest {
}
val expected = mockk<TransactionFeeExtended>(relaxed = true)
coEvery {
estimateFeeForTokenUseCase(any(), any(), any(), any())
estimateFeeForTokenUseCase(
userWallet = any(),
feeTokenCurrencyStatus = any(),
sendingTokenCurrencyStatus = any(),
amount = any(),
)
} returns expected.right()
val result = sut.calculate(
@ -228,39 +243,44 @@ internal class CexSwapFeeCalculatorTest {
)
}
coVerify(exactly = 0) {
estimateFeeForTokenUseCase.invoke(any(), any(), any(), any())
estimateFeeForTokenUseCase.invoke(
userWallet = any(),
feeTokenCurrencyStatus = any(),
sendingTokenCurrencyStatus = any(),
amount = any(),
)
estimateFeeForGaslessTxUseCase.invoke(any(), any(), any())
}
}
@Test
fun `GIVEN explicit native selectedFeeToken with non-Ethereum fee WHEN calculate THEN bump is a no-op`() =
runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
val coinStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns coinCurrency
}
val rawFee = Fee.Common(
amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8),
)
coEvery {
estimateFeeUseCase(any(), any(), any())
} returns TransactionFee.Single(normal = rawFee).right()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = coinStatus, isGasless = true,
)
result.onRight { cexResult ->
val loaded = cexResult.transactionFee as TransactionFeeResult.Loaded
val unchanged = (loaded.fee as TransactionFee.Single).normal as Fee.Common
assertThat(unchanged).isSameInstanceAs(rawFee)
}
fun `GIVEN explicit native selectedFeeToken with non-Ethereum fee WHEN calculate THEN bump is a no-op`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
val coinStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns coinCurrency
}
val rawFee = Fee.Common(
amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8),
)
coEvery {
estimateFeeUseCase(any(), any(), any())
} returns TransactionFee.Single(normal = rawFee).right()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = coinStatus,
isGasless = true,
)
result.onRight { cexResult ->
val loaded = cexResult.transactionFee as TransactionFeeResult.Loaded
val unchanged = (loaded.fee as TransactionFee.Single).normal as Fee.Common
assertThat(unchanged).isSameInstanceAs(rawFee)
}
}
@Test
fun `GIVEN native path returns Left WHEN calculate THEN error is propagated`() = runTest {

View file

@ -12,6 +12,7 @@ import com.tangem.blockchain.common.TransactionExtras
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
@ -19,21 +20,14 @@ import com.tangem.domain.transaction.usecase.CreateTransactionDataExtrasUseCase
import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
import com.tangem.feature.swap.domain.buildSwapCurrencyStatus
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import io.mockk.clearAllMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkAll
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
@ -137,7 +131,7 @@ internal class DexSwapFeeCalculatorTest {
val result = sut.calculate(fromStatus, transaction)
assertThat(result.isLeft()).isTrue()
result.onLeft { assertThat(it).isEqualTo(ExpressDataError.UnknownError()) }
result.onLeft { assertThat(it).isEqualTo(GetFeeError.UnknownError) }
// getFeeUseCase should not have been called because balance check short-circuits first.
// Use a more permissive verify to avoid clashing with the other overload signatures.
coVerify(exactly = 0) {
@ -256,7 +250,7 @@ internal class DexSwapFeeCalculatorTest {
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isEqualTo(ExpressDataError.UnknownError())
assertThat(error).isEqualTo(GetFeeError.UnknownError)
}
// Fallback use-case must NOT be invoked when gas is null — there's nothing to feed it.
coVerify(exactly = 0) {
@ -269,6 +263,221 @@ internal class DexSwapFeeCalculatorTest {
}
}
@Test
fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when getFeeForTokenUseCase returns Left`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val selectedToken = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xToken",
isCoin = false,
).status
val gas = BigInteger.valueOf(99_000L)
val transaction = buildDex(txValue = "1000000000000000", gas = gas)
coEvery {
getFeeForTokenUseCase.invoke(userWallet = any(), token = any(), transactionData = any())
} returns GetFeeError.UnknownError.left()
coEvery {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = any(),
gasPrice = any(),
)
} returns mockk<TransactionFee.Choosable>(relaxed = true).right()
val result = sut.calculate(fromStatus, transaction, selectedToken = selectedToken)
// The token branch normally yields LoadedExtended, but on Left we fall back to the
// eth-specific Loaded fee — mirroring the exception path.
assertThat(result.isRight()).isTrue()
result.onRight { dexFeeResult ->
assertThat(dexFeeResult.transactionFee).isInstanceOf(TransactionFeeResult.Loaded::class.java)
}
coVerify(exactly = 1) {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = gas,
gasPrice = any(),
)
}
}
@Test
fun `EVM DEX swap surfaces error when getFeeForTokenUseCase fails and transaction gas is null`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val selectedToken = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xToken",
isCoin = false,
).status
val transaction = buildDex(txValue = "1000000000000000", gas = null)
coEvery {
getFeeForTokenUseCase.invoke(userWallet = any(), token = any(), transactionData = any())
} returns GetFeeError.UnknownError.left()
val result = sut.calculate(fromStatus, transaction, selectedToken = selectedToken)
// gas is null → the original left error is surfaced, the fallback use case is not invoked.
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isEqualTo(GetFeeError.UnknownError)
}
coVerify(exactly = 0) {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = any(),
gasPrice = any(),
)
}
}
@Test
fun `EVM DEX swap raises DataError when exception path is hit and transaction gas is null`() = runTest {
// The exception (catch) branch wraps the thrown Throwable as GetFeeError.DataError when gas
// is null — distinct from the Either.Left branches, which surface the original Left error.
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
// txValue == null forces error("unable to get txValue") inside the catch block.
val transaction = buildDex(txValue = null, gas = null)
val result = sut.calculate(fromStatus, transaction)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.DataError::class.java)
}
coVerify(exactly = 0) {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = any(),
gasPrice = any(),
)
}
}
@Test
fun `EVM DEX swap propagates fallback error when getFeeUseCase Left and getEthSpecificFeeUseCase also Left`() =
runTest {
// Both the primary fee call and the eth-specific fallback fail. The fallback uses .bind(),
// so its Left error must be surfaced verbatim.
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val gas = BigInteger.valueOf(80_000L)
val transaction = buildDex(txValue = "1000000000000000", gas = gas)
coEvery {
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
} returns GetFeeError.UnknownError.left()
val fallbackError = GetFeeError.DataError(IllegalStateException("eth specific failed"))
coEvery {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = any(),
gasPrice = any(),
)
} returns fallbackError.left()
val result = sut.calculate(fromStatus, transaction)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isEqualTo(fallbackError)
}
coVerify(exactly = 1) {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = gas,
gasPrice = any(),
)
}
}
@Test
fun `EVM DEX swap token branch returns LoadedExtended on success and does not call fallback`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val selectedToken = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xToken",
isCoin = false,
).status
val transaction = buildDex(txValue = "1000000000000000")
coEvery {
getFeeForTokenUseCase.invoke(userWallet = any(), token = any(), transactionData = any())
} returns mockk<TransactionFeeExtended>(relaxed = true).right()
val result = sut.calculate(fromStatus, transaction, selectedToken = selectedToken)
assertThat(result.isRight()).isTrue()
result.onRight { dexFeeResult ->
assertThat(dexFeeResult.transactionFee).isInstanceOf(TransactionFeeResult.LoadedExtended::class.java)
assertThat(dexFeeResult.gas).isEqualTo(transaction.gas)
}
// On the happy token path neither the eth-specific fallback nor the native getFeeUseCase fires.
coVerify(exactly = 0) {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = any(),
gasPrice = any(),
)
}
}
@Test
fun `EVM DEX swap token branch falls back to getEthSpecificFeeUseCase when exception path is hit`() = runTest {
// selectedToken is a Token, but createTransactionExtrasUseCase fails before the token branch is
// reached, so the exception catch fires. With gas present the eth-specific fallback applies.
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val selectedToken = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xToken",
isCoin = false,
).status
val gas = BigInteger.valueOf(123_000L)
val transaction = buildDex(txValue = "1000000000000000", gas = gas)
every {
createTransactionExtrasUseCase.invoke(data = any(), network = any())
} returns IllegalStateException("forced fail").left()
coEvery {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = any(),
gasPrice = any(),
)
} returns mockk<TransactionFee.Choosable>(relaxed = true).right()
val result = sut.calculate(fromStatus, transaction, selectedToken = selectedToken)
assertThat(result.isRight()).isTrue()
result.onRight { dexFeeResult ->
// Fallback always yields Loaded, never LoadedExtended, even on the token branch.
assertThat(dexFeeResult.transactionFee).isInstanceOf(TransactionFeeResult.Loaded::class.java)
}
coVerify(exactly = 1) {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = gas,
gasPrice = any(),
)
}
// The token use case is never reached because extras creation throws first.
coVerify(exactly = 0) {
getFeeForTokenUseCase.invoke(userWallet = any(), token = any(), transactionData = any())
}
}
// -------------------------------------------------------------------------
// 12% gas patch — golden numbers
// -------------------------------------------------------------------------
@ -367,7 +576,7 @@ internal class DexSwapFeeCalculatorTest {
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isEqualTo(ExpressDataError.TooLargeSolanaTransactionError())
assertThat(error).isEqualTo(GetFeeError.BlockchainErrors.TooLargeSolanaTransactionError)
}
// No fee is computed when the size guard trips
coVerify(exactly = 0) {
@ -375,6 +584,140 @@ internal class DexSwapFeeCalculatorTest {
}
}
// -------------------------------------------------------------------------
// Integrated-approve simulated estimation override ([REDACTED_TASK_KEY])
//
// The end-to-end EstimateOverrideError → legacy-fallback recompute is exercised at the
// interactor level in
// [com.tangem.feature.swap.domain.SwapInteractorImplLoadSwapFeeTest] (which owns the
// session-fallback state machine). Here we only assert the calculator's branch selection:
// PermissionSettings → simulated estimation; Empty → plain getFee path.
// -------------------------------------------------------------------------
@Test
fun `EVM DEX swap with PermissionSettings uses the simulated estimation path`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val transaction = buildDex(txValue = "1000000000000000")
val permissionState = PermissionDataState.PermissionSettings(
type = ApproveType.LIMITED,
spenderAddress = "0xSpender",
)
coEvery {
getFeeUseCase.invoke(
userWallet = any(),
network = any(),
transactionData = any(),
spenderAddress = any(),
isSimulateEstimation = true,
)
} returns TransactionFee.Single(normal = ethLegacyFee()).right()
sut.calculate(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
permissionState = permissionState,
)
// PermissionSettings must drive the simulated estimation (isSimulateEstimation = true) with
// the spender carried through from the permission state.
coVerify(exactly = 1) {
getFeeUseCase.invoke(
userWallet = any(),
network = any(),
transactionData = any(),
spenderAddress = "0xSpender",
isSimulateEstimation = true,
)
}
}
@Test
fun `EVM DEX swap with Empty permission uses plain getFee path and does not simulate`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val transaction = buildDex(txValue = "1000000000000000")
coEvery {
getFeeUseCase.invoke(
userWallet = any(),
network = any(),
transactionData = any(),
spenderAddress = any(),
isSimulateEstimation = false,
)
} returns TransactionFee.Single(normal = ethLegacyFee()).right()
val result = sut.calculate(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
permissionState = PermissionDataState.Empty,
)
assertThat(result.isRight()).isTrue()
// The simulated estimation must not be used when there is no PermissionSettings context.
coVerify(exactly = 0) {
getFeeUseCase.invoke(
userWallet = any(),
network = any(),
transactionData = any(),
spenderAddress = any(),
isSimulateEstimation = true,
)
}
}
@Test
fun `EVM DEX swap raises EstimateOverrideError without eth-specific fallback even when gas is present`() =
runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
// gas is present — the legacy fallback would normally kick in for a plain Left,
// but an EstimateOverrideError must be raised verbatim so the model can trigger
// the integrated-approval fallback instead of silently using the eth-specific fee.
val transaction = buildDex(txValue = "1000000000000000", gas = BigInteger.valueOf(50_000L))
val permissionState = PermissionDataState.PermissionSettings(
type = ApproveType.LIMITED,
spenderAddress = "0xSpender",
)
val overrideError = GetFeeError.EstimateOverrideError(
blockchain = "ethereum",
tokenSymbol = "USDT",
rpcProvider = "infura",
error = "execution reverted",
)
coEvery {
getFeeUseCase.invoke(
userWallet = any(),
network = any(),
transactionData = any(),
spenderAddress = any(),
isSimulateEstimation = true,
)
} returns overrideError.left()
val result = sut.calculate(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
permissionState = permissionState,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isEqualTo(overrideError)
}
// The eth-specific fallback must NOT be invoked for EstimateOverrideError, even though
// gas is present — otherwise the model would never see the override and the
// integrated-approval fallback would not trigger.
coVerify(exactly = 0) {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = any(),
gasPrice = any(),
)
}
}
// -------------------------------------------------------------------------
// otherNativeFee propagation (bridge protocol fee)
// -------------------------------------------------------------------------

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),
)
}
}

View file

@ -5,7 +5,7 @@
# https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico
tangemBlockchainSdk = "develop-1544"
tangemBlockchainSdk = "develop-1555"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-620"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^