diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt b/data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt index b1469917d7..1fc3f7c501 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt @@ -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) } } diff --git a/data/transaction/src/test/kotlin/com/tangem/data/transaction/error/DefaultFeeErrorResolverTest.kt b/data/transaction/src/test/kotlin/com/tangem/data/transaction/error/DefaultFeeErrorResolverTest.kt new file mode 100644 index 0000000000..a488e0b1a0 --- /dev/null +++ b/data/transaction/src/test/kotlin/com/tangem/data/transaction/error/DefaultFeeErrorResolverTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt index 3c8dfbdcfb..db3123dee5 100644 --- a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt +++ b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt @@ -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() } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/ErrorsMapper.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/ErrorsMapper.kt index db5b877d46..7c4621c57e 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/ErrorsMapper.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/ErrorsMapper.kt @@ -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) } } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt index e4f0234272..27e8209635 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt @@ -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 diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/error/ErrorsMapperTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/error/ErrorsMapperTest.kt new file mode 100644 index 0000000000..fda0f31c5e --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/error/ErrorsMapperTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/GetFeeUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/GetFeeUseCaseTest.kt new file mode 100644 index 0000000000..3d61651862 --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/GetFeeUseCaseTest.kt @@ -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(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" + } +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 01cd972d61..12309f2f38 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -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 + + 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 } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index a85cd3bf4d..f22074bb35 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -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(), + ) + + 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 { 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 { + ): Either { 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 = 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]. diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt index 183303e1e4..171921234c 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt @@ -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 = either { + permissionState: PermissionDataState = PermissionDataState.Empty, + ): Either = 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 = either { + ): Either = 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 = either { + ): Either = 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 = either { - val nativeBalance = walletManagersFacade.getNativeTokenBalance( - userWalletId = fromSwapCurrencyStatus.userWalletId, - networkId = fromSwapCurrencyStatus.currency.network.rawId, - derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value, - ) + permissionState: PermissionDataState, + ): Either = 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.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( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 54f1cf3973..f8692f938a 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -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, ) \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt index c36c3b6d0d..9f7b14673e 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt @@ -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(relaxed = true) - val gaslessToken = mockk(relaxed = true) { - every { id } returns gaslessTokenId - } - val gaslessTokenStatus = mockk(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(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(relaxed = true) + val gaslessToken = mockk(relaxed = true) { + every { id } returns gaslessTokenId } + val gaslessTokenStatus = mockk(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(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")) diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt index f608e724fa..87d2938747 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt @@ -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(relaxed = true) { every { value } returns feeValue } diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplBridgeReRouteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplBridgeReRouteTest.kt index bb0f71b3e6..405a193776 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplBridgeReRouteTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplBridgeReRouteTest.kt @@ -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, - provider: SwapProvider, - ) { + private fun assertManageCexPathTaken(result: Map, 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, - provider: SwapProvider, - ) { + private fun assertManageDexPathTaken(result: Map, provider: SwapProvider) { assertThat(result).hasSize(1) assertThat(result[provider]).isInstanceOf(SwapState.QuotesLoadedState::class.java) coVerify(atLeast = 1) { diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt index 4d3d80a5b4..de553ebb28 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt @@ -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 diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplIntegratedApprovalFallbackTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplIntegratedApprovalFallbackTest.kt new file mode 100644 index 0000000000..97f338060f --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplIntegratedApprovalFallbackTest.kt @@ -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(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() + 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() + 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() + 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() + 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) { + coEvery { + dexSwapFeeCalculator.calculate( + fromSwapCurrencyStatus = any(), + transaction = any(), + selectedToken = any(), + permissionState = capture(permissionSlot), + ) + } returns DexFeeResult( + transactionFee = TransactionFeeResult.Loaded( + TransactionFee.Single(normal = mockk(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" + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt index 732ae7d1f8..66736a8e47 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt @@ -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" + } } \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadIntegratedApprovalDataTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadIntegratedApprovalDataTest.kt new file mode 100644 index 0000000000..f2ec1512df --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadIntegratedApprovalDataTest.kt @@ -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(relaxed = true) + private val approvalFee = TransactionFee.Single(normal = mockk(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() + 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() + 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") + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt index e705f7c63e..ce0f27bb3f 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt @@ -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(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(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(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(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(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(relaxed = true) { - io.mockk.every { currency } returns mockk(relaxed = true) + every { currency } returns mockk(relaxed = true) } val extendedFee = mockk(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(relaxed = true) { - io.mockk.every { currency } returns mockk(relaxed = true) + every { currency } returns mockk(relaxed = true) } val rawFee = TransactionFee.Single(normal = mockk(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(relaxed = true) { - io.mockk.every { currency } returns mockk(relaxed = true) + every { currency } returns mockk(relaxed = true) } val rawFee = TransactionFee.Single(normal = mockk(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(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, + ) } \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt index e4444c20c3..6855f1cf9f 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt @@ -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>() + 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>() + 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), + ) + } } \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt index 80dbaf4494..94d013973d 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt @@ -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, ) } diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt index 846dc194ee..adf2316e02 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt @@ -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(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(relaxed = true) - val coinStatus = mockk(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(relaxed = true) + val coinStatus = mockk(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 { diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt index 3f5b481b94..9b07cca2b8 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt @@ -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(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(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(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) // ------------------------------------------------------------------------- diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index a50d993873..24a1cb2319 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -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) { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 0cd67e00e5..72ddde1399 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -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 { 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 { + 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 } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 836f97b026..86595797c2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -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) { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelCombineFeesTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelCombineFeesTest.kt new file mode 100644 index 0000000000..ec0dce4092 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelCombineFeesTest.kt @@ -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(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 { + 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 + } + + 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)) + } +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelHandleFeeErrorTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelHandleFeeErrorTest.kt new file mode 100644 index 0000000000..748075ce2b --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelHandleFeeErrorTest.kt @@ -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() + verify { analyticsEventHandler.send(capture(sentEvents)) } + val overrideEvent = sentEvents.filterIsInstance().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" + } +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt index d0193e97e8..1cb86959b5 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt @@ -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), ) } } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 3a35bff319..06ad851a5a 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -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 ^