From fccbac44c27c0e725c845f2a265704dbdc8cc4ac Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 8 May 2026 00:44:19 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../SwapInteractorImplLoadFeeForDexTest.kt | 736 ++++++++++++++++++ .../SwapInteractorImplOtherNativeFeeTest.kt | 345 ++++++++ .../feature/swap/StateBuilderFeeStateTest.kt | 385 +++++++++ ...SwapNotificationsFactoryFeeWarningsTest.kt | 486 ++++++++++++ 4 files changed, 1952 insertions(+) create mode 100644 features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt create mode 100644 features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt create mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt create mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt new file mode 100644 index 0000000000..b981644028 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt @@ -0,0 +1,736 @@ +package com.tangem.feature.swap.domain + +import android.util.Base64 +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper +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.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.tokens.model.FeePaidCurrency +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.AllowanceInfo +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.SwapState +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 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 + +/** + * Characterization tests for the private fee-loading paths of [SwapInteractorImpl] reached by + * the DEX provider branch: + * + * - `loadFeeForDex` + * - `getFeeDataForDexSwap` (EVM) + * - `getFeeDataForSolanaDexSwap` (Solana) + * - the `patchTransactionFeeForSwap` 12% gas-limit bump applied on EVM DEX + * + * Driven through the public [SwapInteractorImpl.findBestQuote] entry point with carefully + * stubbed dependencies so the DEX-fee branch executes deterministically. + * + * [REDACTED_TASK_KEY] — these tests are intentionally pinned to the **current** behavior so that the + * upcoming refactor (extraction into `DexSwapFeeCalculator`) is provably equivalent. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val solanaNetwork = Blockchain.Solana.toNetworkId() + private val btcNetwork = Blockchain.Bitcoin.toNetworkId() + + @BeforeEach + fun setup() { + coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10") + coEvery { + getCurrencyCheckUseCase.invoke( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any(), + ) + } returns CryptoCurrencyCheck( + dustValue = null, + reserveAmount = null, + minimumSendAmount = null, + existentialDeposit = null, + utxoAmountLimit = null, + isAccountFunded = true, + rentWarning = null, + isMemoRequired = false, + ) + coEvery { + validateTransactionUseCase.invoke( + amount = any(), + fee = any(), + memo = any(), + destination = any(), + userWalletId = any(), + network = any(), + ) + } returns Unit.right() + coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } answers { + firstArg>().map { rawId -> + QuoteStatus( + rawCurrencyId = rawId, + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = BigDecimal.ONE, + fiatRateUSD = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + ), + ) + }.toSet() + } + coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right() + coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right() + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null + coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency() + every { allowPermissionsHandler.isAddressAllowanceInProgress(any()) } returns false + coEvery { + getAllowanceInfoUseCase.invoke( + userWalletId = any(), + cryptoCurrency = any(), + spenderAddress = any(), + requiredAmount = any(), + ) + } returns (AllowanceInfo.Enough(allowance = BigDecimal("1000")) as AllowanceInfo).right() + every { createTransactionExtrasUseCase.invoke(data = any(), network = any()) } returns + mockk(relaxed = true).right() + } + + @Test + fun `EVM DEX swap propagates extras destinationAddress sourceAddress and amount to getFeeUseCase`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex( + txValue = "1000000000000000", // 0.001 ETH + txTo = "0xRecipient", + txFrom = "0xSender", + txData = "0xPayload", + ) + + 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() + + val capturedTxData = slot() + coEvery { + getFeeUseCase.invoke( + userWallet = any(), + network = any(), + transactionData = capture(capturedTxData), + ) + } returns mockk(relaxed = true).right() + + // When + sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — captured TransactionData carries the values from ExpressTransactionModel.DEX + assertThat(capturedTxData.isCaptured).isTrue() + val uncompiled = capturedTxData.captured as TransactionData.Uncompiled + assertThat(uncompiled.destinationAddress).isEqualTo("0xRecipient") + assertThat(uncompiled.sourceAddress).isEqualTo("0xSender") + // amount.value is the txValue moved-point-left by native decimals (18 for ETH) → 0.001 + // Use compareTo-equivalence to ignore the BigDecimal scale (0.001 vs 0.001000000000000000). + assertThat(uncompiled.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.001")) + // extras came from createTransactionExtrasUseCase + assertThat(uncompiled.extras).isNotNull() + } + + @Test + fun `EVM DEX swap with native balance ZERO surfaces SwapError UnknownError`() = runTest { + // Given — zero native balance triggers the early-raise in getFeeDataForDexSwap + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex(txValue = "0") + + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO + + 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() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — native-balance == 0 raises ExpressDataError.UnknownError up to SwapError + val state = result[dexProvider] + assertThat(state).isInstanceOf(SwapState.SwapError::class.java) + val swapError = state as SwapState.SwapError + assertThat(swapError.error).isEqualTo(ExpressDataError.UnknownError) + // getFeeUseCase should NOT have been invoked because the balance check short-circuits first + coVerify(exactly = 0) { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } + } + + @Test + fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when txValue is null`() = runTest { + // Given — null txValue forces error("unable to get txValue") → IllegalStateException → fallback + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val gas = BigInteger.valueOf(150_000L) + val swapData = buildSwapDataModelDex(txValue = null, gas = gas) + + 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() + + coEvery { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = any(), + gasPrice = any(), + ) + } returns mockk(relaxed = true).right() + + // When + sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — fallback path is invoked with the gas from the express transaction model + coVerify(exactly = 1) { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = gas, + gasPrice = any(), + ) + } + } + + @Test + fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when createTransactionExtrasUseCase returns null`() = + runTest { + // Given — null extras → error("unable to create extras") → IllegalStateException → fallback + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val gas = BigInteger.valueOf(75_000L) + val swapData = buildSwapDataModelDex(txValue = "1000000000000000", gas = gas) + + 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() + + // Force createTransactionExtrasUseCase to return null → triggers the fallback path. + // The use case signature is Either; pass a Throwable Left. + 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() + + // When + sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + coVerify(exactly = 1) { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = gas, + gasPrice = any(), + ) + } + } + + @Test + fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when getFeeUseCase returns null`() = runTest { + // Given — getFeeUseCase Left → getOrNull() == null → error("unable to calculate fee") → fallback + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val gas = BigInteger.valueOf(50_000L) + val swapData = buildSwapDataModelDex(txValue = "1000000000000000", gas = gas) + + 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() + + coEvery { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } returns GetFeeError.UnknownError.left() + + coEvery { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = any(), + gasPrice = any(), + ) + } returns mockk(relaxed = true).right() + + // When + sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + coVerify(exactly = 1) { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = gas, + gasPrice = any(), + ) + } + } + + @Test + fun `Solana DEX uses TransactionData Compiled and skips the 12 percent gas patch`() = runTest { + // Given — Solana network forces the Compiled path. We capture the TransactionData and + // assert that the resulting fee value is the raw return of getFeeUseCase (no 1.12x scaling). + mockkStatic(Base64::class) + every { Base64.decode(any(), any()) } returns ByteArray(64) + mockkObject(SolanaTransactionHelper) + every { SolanaTransactionHelper.removeSignaturesPlaceholders(any()) } returns ByteArray(64) + + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = solanaNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex(txData = "U29sYW5h") + + 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() + + // Construct a deterministic Solana fee — Fee.Common with a known amount value. + val rawFeeAmount = BigDecimal("0.005000") + val rawFee: Fee = Fee.Common( + amount = Amount( + currencySymbol = "SOL", + value = rawFeeAmount, + decimals = 9, + ), + ) + val txFee = TransactionFee.Single(normal = rawFee) + val capturedTxData = slot() + coEvery { + getFeeUseCase.invoke( + userWallet = any(), + network = any(), + transactionData = capture(capturedTxData), + ) + } returns txFee.right() + + // When + sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — TransactionData passed to getFeeUseCase is Compiled (not Uncompiled) + assertThat(capturedTxData.isCaptured).isTrue() + assertThat(capturedTxData.captured).isInstanceOf(TransactionData.Compiled::class.java) + // No gas-patch is applied on the Solana path; the raw amount is preserved. + // Pinning behavior: Fee.Common is not a Fee.Ethereum, so increaseEthGasLimitInNeeded + // returns it unchanged → no 1.12x scaling. + assertThat(rawFee.amount.value).isEqualTo(rawFeeAmount) + } + + @Test + fun `Solana DEX size guard raises TooLargeSolanaTransactionError when formatted hash exceeds 1232 bytes on Cold wallet`() = + runTest { + // Given — formatted hash > 1232 bytes on a Cold wallet → SwapError(TooLargeSolanaTransactionError) + mockkStatic(Base64::class) + val oversizedBytes = ByteArray(1300) + every { Base64.decode(any(), any()) } returns oversizedBytes + mockkObject(SolanaTransactionHelper) + every { SolanaTransactionHelper.removeSignaturesPlaceholders(any()) } returns oversizedBytes + + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val coldWallet = mockk(relaxed = true) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = solanaNetwork, + isCoin = true, + amount = BigDecimal("10"), + ).let { status -> + SwapCurrencyStatus( + userWallet = coldWallet, + status = status.status, + account = status.account, + ) + } + val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex(txData = "very-long-base64-content==") + + 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() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + val state = result[dexProvider] + assertThat(state).isInstanceOf(SwapState.SwapError::class.java) + val swapError = state as SwapState.SwapError + assertThat(swapError.error).isEqualTo(ExpressDataError.TooLargeSolanaTransactionError) + } + + /** + * Surprising current behavior pinned here for the redesign: + * + * In `loadDexSwapData`, the local `txFeeState` produced by `loadFeeForDex(...).toTxFeeState(...)` + * is computed but NEVER PROPAGATED to `QuotesLoadedState.txFee`. The latter is sourced from + * the input `txFeeSealedState` parameter via `updateBalances`. This means the 12% gas patch + * is applied (the side-effect runs) but the patched value is then discarded for state + * purposes; only the side effects of `loadFeeForDex` (raising on Solana size limit, balance=0, + * etc.) survive. + * + * The 12% gas-patch math itself is fully covered by the planned Phase-2 PatchEthGasLimitForSwapTest; + * pinning it through the public API here would only assert the discarded result. + * + * [REDACTED_TASK_KEY] — flagged for Phase-2 author awareness; the refactor MUST decide whether to: + * (a) preserve the dead-store (unlikely), or + * (b) actually wire the loaded fee into the resulting state (the intended fix). + */ + + // region — local builders + + private fun buildSwapDataModelDex( + txData: String = "dGVzdA==", + txValue: String? = "0", + toAmount: BigDecimal = BigDecimal("0.5"), + otherNativeFeeWei: BigDecimal? = null, + gas: BigInteger = BigInteger.valueOf(21_000L), + txTo: String = "0xRecipient", + txFrom: String = "0xSender", + ): SwapDataModel = SwapDataModel( + toTokenAmount = SwapAmount(toAmount, 18), + transaction = ExpressTransactionModel.DEX( + fromAmount = SwapAmount(BigDecimal.ONE, 18), + toAmount = SwapAmount(toAmount, 18), + txValue = txValue, + txId = "tx-id-123", + txTo = txTo, + txExtraId = null, + txFrom = txFrom, + txData = txData, + otherNativeFeeWei = otherNativeFeeWei, + gas = gas, + ), + ) + + // endregion +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt new file mode 100644 index 0000000000..fd66c18baf --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt @@ -0,0 +1,345 @@ +package com.tangem.feature.swap.domain + +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.TransactionExtras +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.tokens.model.FeePaidCurrency +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.transaction.models.AllowanceInfo +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.domain.SwapFeeState +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TxFeeState +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal +import java.math.BigInteger + +/** + * Characterization tests for the bridge-fee field `otherNativeFeeWei` flowing into the + * resulting [TxFeeState]. + * + * Pinned behavior: + * - `otherNativeFee` (BigDecimal) = `transaction.otherNativeFeeWei` shifted left by native + * decimals (18 for ETH). + * - `feeIncludeOtherNativeFee` of the resulting `TxFee.Legacy` equals `feeValue + otherNativeFee`. + * - When `otherNativeFeeWei == null`, `feeIncludeOtherNativeFee == feeValue`. + * - The `feeToCheckFunds` (the value used by `getFeeState`) equals + * `feeByPriority + otherNativeFee`. We assert this indirectly: when the native balance is + * BETWEEN `feeByPriority` and `feeByPriority + otherNativeFee`, the resulting + * `SwapFeeState` is `NotEnough` (not `Enough`). + * + * [REDACTED_TASK_KEY] — these tests exist to guarantee that the upcoming refactor does not silently + * drop the bridge protocol fee for DEX_BRIDGE providers. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplOtherNativeFeeTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val btcNetwork = Blockchain.Bitcoin.toNetworkId() + + @BeforeEach + fun setup() { + coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin + // Default native balance is large; specific tests override. + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10") + coEvery { + getCurrencyCheckUseCase.invoke( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any(), + ) + } returns CryptoCurrencyCheck( + dustValue = null, + reserveAmount = null, + minimumSendAmount = null, + existentialDeposit = null, + utxoAmountLimit = null, + isAccountFunded = true, + rentWarning = null, + isMemoRequired = false, + ) + coEvery { + validateTransactionUseCase.invoke( + amount = any(), + fee = any(), + memo = any(), + destination = any(), + userWalletId = any(), + network = any(), + ) + } returns Unit.right() + coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } answers { + firstArg>().map { rawId -> + QuoteStatus( + rawCurrencyId = rawId, + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = BigDecimal.ONE, + fiatRateUSD = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + ), + ) + }.toSet() + } + coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right() + coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right() + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null + coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency() + every { allowPermissionsHandler.isAddressAllowanceInProgress(any()) } returns false + coEvery { + getAllowanceInfoUseCase.invoke( + userWalletId = any(), + cryptoCurrency = any(), + spenderAddress = any(), + requiredAmount = any(), + ) + } returns (AllowanceInfo.Enough(allowance = BigDecimal("1000")) as AllowanceInfo).right() + every { createTransactionExtrasUseCase.invoke(data = any(), network = any()) } returns + mockk(relaxed = true).right() + } + + /** + * Surprising current behavior (caught while writing this test): + * + * In `loadDexSwapData`, the local `txFeeState` produced by `loadFeeForDex(...).toTxFeeState(...)` + * is computed but **never used** for the resulting `QuotesLoadedState.txFee`. The actual + * `txFee` field of the resulting state is populated from the input `txFeeSealedState` + * parameter via `updateBalances` → which means the `feeIncludeOtherNativeFee` etc. on + * the returned state come from whatever the caller passes in, NOT from the loaded fee. + * + * What IS observable through the public API: + * - `feeByPriority + otherNativeFee` enters `feeToCheckFunds` and drives `feeState` + * (Enough vs NotEnough). This is verified in the two tests below. + * + * The "feeIncludeOtherNativeFee on the result.txFee" assertion is intentionally NOT + * pinned here — that field is sourced from the caller's `txFeeSealedState` and a refactor + * that fixes this dead-store will not break this test class. + * + * [REDACTED_TASK_KEY] — flagged for discussion before Phase 2. + */ + + @Test + fun `bridge provider with non-zero otherNativeFeeWei loads exchange data and reaches getFeeUseCase`() = runTest { + // Given — DEX_BRIDGE with otherNativeFeeWei = 5e15 wei = 0.005 ETH + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex( + txValue = "1000000000000000", + otherNativeFeeWei = BigDecimal("5000000000000000"), + ) + stubExchangeData(dexBridgeProvider, quoteModel, swapData) + + val rawFee: Fee = Fee.Common( + amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.001"), decimals = 18), + ) + coEvery { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } returns TransactionFee.Single(normal = rawFee).right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexBridgeProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — the bridge provider produces a QuotesLoadedState (no SwapError) + // and the swap data carries the otherNativeFeeWei. + val state = result[dexBridgeProvider] + assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) + val loaded = state as SwapState.QuotesLoadedState + val transaction = loaded.swapDataModel?.transaction as? ExpressTransactionModel.DEX + assertThat(transaction?.otherNativeFeeWei).isEqualTo(BigDecimal("5000000000000000")) + } + + @Test + fun `feeToCheckFunds includes otherNativeFee — NotEnough fires when balance covers fee but not fee plus otherNativeFee`() = + runTest { + // Given — DEX_BRIDGE swap, native balance = 0.002 ETH + // base fee = 0.001 ETH, otherNativeFee = 0.005 ETH → feeToCheck = 0.006 ETH > balance + // For a Coin swap the feeState branch checks: nativeBalance - spendAmount > fee + // We swap a Token (so fromToken != Coin) → branch becomes: nativeBalance > fee + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) + val fromTokenStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0xToken", + isCoin = false, + amount = BigDecimal("100"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex( + txValue = "1000000000000000", + otherNativeFeeWei = BigDecimal("5000000000000000"), // 0.005 ETH + ) + stubExchangeData(dexBridgeProvider, quoteModel, swapData) + + // Native balance: 0.002 ETH — enough for base fee (0.001) but NOT for combined (0.006). + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("0.002") + + val baseFeeValue = BigDecimal("0.001") + val rawFee: Fee = Fee.Common( + amount = Amount(currencySymbol = "ETH", value = baseFeeValue, decimals = 18), + ) + coEvery { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } returns TransactionFee.Single(normal = rawFee).right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromTokenStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexBridgeProvider), + amountToSwap = "10", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — feeToCheckFunds (0.006) > nativeBalance (0.002) → NotEnough + val state = result[dexBridgeProvider] + assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) + val loaded = state as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.feeState).isInstanceOf(SwapFeeState.NotEnough::class.java) + } + + @Test + fun `feeToCheckFunds excluding otherNativeFee would have been Enough — pinning the inclusion`() = runTest { + // Given — same shape as above but native balance = 0.002 ETH and otherNativeFee = 0 + // Verifies the contrapositive: with otherNativeFee == 0, balance covers the fee → Enough. + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) + val fromTokenStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0xToken", + isCoin = false, + amount = BigDecimal("100"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex( + txValue = "1000000000000000", + otherNativeFeeWei = null, + ) + stubExchangeData(dexBridgeProvider, quoteModel, swapData) + + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("0.002") + + val baseFeeValue = BigDecimal("0.001") + val rawFee: Fee = Fee.Common( + amount = Amount(currencySymbol = "ETH", value = baseFeeValue, decimals = 18), + ) + coEvery { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } returns TransactionFee.Single(normal = rawFee).right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromTokenStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexBridgeProvider), + amountToSwap = "10", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — without otherNativeFee, the same balance is now sufficient. + val state = result[dexBridgeProvider] + assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) + val loaded = state as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.feeState).isInstanceOf(SwapFeeState.Enough::class.java) + } + + // region — local helpers + + private fun stubExchangeData( + provider: com.tangem.feature.swap.domain.models.domain.SwapProvider, + quoteModel: com.tangem.feature.swap.domain.models.domain.QuoteModel, + swapData: SwapDataModel, + ) { + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = provider.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 = provider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + } + + private fun buildSwapDataModelDex( + txData: String = "dGVzdA==", + txValue: String? = "0", + toAmount: BigDecimal = BigDecimal("0.5"), + otherNativeFeeWei: BigDecimal? = null, + gas: BigInteger = BigInteger.valueOf(21_000L), + ): SwapDataModel = SwapDataModel( + toTokenAmount = SwapAmount(toAmount, 18), + transaction = ExpressTransactionModel.DEX( + fromAmount = SwapAmount(BigDecimal.ONE, 18), + toAmount = SwapAmount(toAmount, 18), + txValue = txValue, + txId = "tx-id-bridge", + txTo = "0xRecipient", + txExtraId = null, + txFrom = "0xSender", + txData = txData, + otherNativeFeeWei = otherNativeFeeWei, + gas = gas, + ), + ) + + // endregion +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt new file mode 100644 index 0000000000..8cd0721a51 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt @@ -0,0 +1,385 @@ +package com.tangem.feature.swap + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +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.IncludeFeeInAmount +import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState +import com.tangem.feature.swap.domain.models.domain.RateType +import com.tangem.feature.swap.domain.models.domain.SwapFeeState +import com.tangem.feature.swap.domain.models.domain.SwapProvider +import com.tangem.feature.swap.domain.models.ui.FeeType +import com.tangem.feature.swap.domain.models.ui.PermissionDataState +import com.tangem.feature.swap.domain.models.ui.PriceImpact +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.feature.swap.domain.models.ui.TxFee +import com.tangem.feature.swap.domain.models.ui.TxFeeState +import com.tangem.feature.swap.models.SwapStateHolder +import com.tangem.feature.swap.models.UiActions +import com.tangem.feature.swap.models.states.FeeItemState +import com.tangem.feature.swap.ui.StateBuilder +import com.tangem.utils.Provider +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Characterization tests for `StateBuilder.createFeeState` (private), exercised through the + * public `createQuotesLoadedState`. + * + * Pinned behavior: + * - `TxFeeState.Empty` → `FeeItemState.Empty` + * - `TxFeeState.SingleFeeState` → `FeeItemState.Content` with `isClickable = false` + * - `TxFeeState.MultipleFeeState` + `selectedFeeType = NORMAL` → uses normal fee values, isClickable = true + * - `TxFeeState.MultipleFeeState` + `selectedFeeType = PRIORITY` → uses priority fee values, isClickable = true + * - `hideFee = true` → always `FeeItemState.Empty` regardless of `txFee` + * - `feeCryptoFormattedWithNative` is what populates `FeeItemState.Content.amountCrypto`, + * NOT the plain `feeCryptoFormatted`. Same for fiat. (This pins the bridge-fee + * "display fee with native as workaround for okx" pathway.) + * + * [REDACTED_TASK_KEY] — these exist to guarantee the redesign's `FeeSelectorBlockComponent` carries + * the same display semantics across the cutover. + */ +internal class StateBuilderFeeStateTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isBalanceHiddenProvider: Provider = mockk() + private val appCurrencyProvider: Provider = mockk() + private val isAccountsModeProvider: Provider = mockk() + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + + private lateinit var sut: StateBuilder + + private val userWalletId = UserWalletId("aabbccdd") + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + @BeforeEach + fun setup() { + every { isBalanceHiddenProvider() } returns false + every { appCurrencyProvider() } returns AppCurrency.Default + every { isAccountsModeProvider() } returns false + every { isGaslessFeeSupportedForNetwork(any()) } returns false + + sut = StateBuilder( + actions = actions, + isBalanceHiddenProvider = isBalanceHiddenProvider, + appCurrencyProvider = appCurrencyProvider, + isAccountsModeProvider = isAccountsModeProvider, + isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, + ) + } + + @Test + fun `GIVEN TxFeeState Empty WHEN hideFee false THEN fee is FeeItemState Empty`() { + val baseState = buildReadyState() + val quoteModel = buildQuoteModel(txFeeState = TxFeeState.Empty) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = buildSwapProvider(), + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `GIVEN SingleFeeState WHEN hideFee false THEN fee Content is not clickable`() { + val baseState = buildReadyState() + val singleFee = buildLegacyFee( + feeType = FeeType.NORMAL, + cryptoFormatted = "0.001 ETH", + cryptoFormattedWithNative = "0.001 ETH", + fiatFormatted = "$2.00", + fiatFormattedWithNative = "$2.00", + ) + val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee)) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = buildSwapProvider(), + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + val feeContent = result.fee as FeeItemState.Content + assertThat(feeContent.isClickable).isFalse() + // Field source pinning: amountCrypto/fiatFormatted come from the *WithNative variants. + assertThat(feeContent.amountCrypto).isEqualTo("0.001 ETH") + assertThat(feeContent.amountFiatFormatted).isEqualTo("$2.00") + } + + @Test + fun `GIVEN MultipleFeeState WHEN selectedFeeType NORMAL THEN fee Content has normal fee values and is clickable`() { + val baseState = buildReadyState() + val normalFee = buildLegacyFee( + feeType = FeeType.NORMAL, + cryptoFormatted = "0.001 ETH", + cryptoFormattedWithNative = "0.001 ETH", + fiatFormatted = "$2.00", + fiatFormattedWithNative = "$2.00", + ) + val priorityFee = buildLegacyFee( + feeType = FeeType.PRIORITY, + cryptoFormatted = "0.005 ETH", + cryptoFormattedWithNative = "0.005 ETH", + fiatFormatted = "$10.00", + fiatFormattedWithNative = "$10.00", + ) + val quoteModel = buildQuoteModel( + txFeeState = TxFeeState.MultipleFeeState(normalFee = normalFee, priorityFee = priorityFee), + ) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = buildSwapProvider(), + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + val feeContent = result.fee as FeeItemState.Content + assertThat(feeContent.isClickable).isTrue() + assertThat(feeContent.feeType).isEqualTo(FeeType.NORMAL) + assertThat(feeContent.amountCrypto).isEqualTo("0.001 ETH") + assertThat(feeContent.amountFiatFormatted).isEqualTo("$2.00") + } + + @Test + fun `GIVEN MultipleFeeState WHEN selectedFeeType PRIORITY THEN fee Content has priority fee values and is clickable`() { + val baseState = buildReadyState() + val normalFee = buildLegacyFee( + feeType = FeeType.NORMAL, + cryptoFormatted = "0.001 ETH", + cryptoFormattedWithNative = "0.001 ETH", + fiatFormatted = "$2.00", + fiatFormattedWithNative = "$2.00", + ) + val priorityFee = buildLegacyFee( + feeType = FeeType.PRIORITY, + cryptoFormatted = "0.005 ETH", + cryptoFormattedWithNative = "0.005 ETH", + fiatFormatted = "$10.00", + fiatFormattedWithNative = "$10.00", + ) + val quoteModel = buildQuoteModel( + txFeeState = TxFeeState.MultipleFeeState(normalFee = normalFee, priorityFee = priorityFee), + ) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = buildSwapProvider(), + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.PRIORITY, + needApplyFCARestrictions = false, + hideFee = false, + ) + + val feeContent = result.fee as FeeItemState.Content + assertThat(feeContent.isClickable).isTrue() + assertThat(feeContent.feeType).isEqualTo(FeeType.PRIORITY) + assertThat(feeContent.amountCrypto).isEqualTo("0.005 ETH") + assertThat(feeContent.amountFiatFormatted).isEqualTo("$10.00") + } + + @Test + fun `GIVEN hideFee true WHEN any TxFeeState THEN fee is Empty`() { + val baseState = buildReadyState() + val singleFee = buildLegacyFee( + feeType = FeeType.NORMAL, + cryptoFormatted = "0.001 ETH", + cryptoFormattedWithNative = "0.001 ETH", + fiatFormatted = "$2.00", + fiatFormattedWithNative = "$2.00", + ) + val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee)) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = buildSwapProvider(), + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = true, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `GIVEN otherNativeFee greater than feeValue WHEN SingleFeeState THEN amountCrypto reflects the With-Native variant`() { + // Bridge fee scenario: the WithNative formatted strings differ from the plain ones. + // StateBuilder.createFeeState picks `feeCryptoFormattedWithNative` (and fiat) — pinning that. + val baseState = buildReadyState() + val singleFee = buildLegacyFee( + feeType = FeeType.NORMAL, + cryptoFormatted = "0.001 ETH", + cryptoFormattedWithNative = "0.006 ETH", // includes 0.005 bridge native fee + fiatFormatted = "$2.00", + fiatFormattedWithNative = "$12.00", + ) + val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee)) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = buildSwapProvider(), + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + val feeContent = result.fee as FeeItemState.Content + assertThat(feeContent.amountCrypto).isEqualTo("0.006 ETH") + assertThat(feeContent.amountFiatFormatted).isEqualTo("$12.00") + } + + @Test + fun `GIVEN otherNativeFee equal to feeValue WHEN SingleFeeState THEN amountCrypto equals plain feeCryptoFormatted`() { + // No bridge fee → WithNative strings happen to equal the plain strings. + val baseState = buildReadyState() + val singleFee = buildLegacyFee( + feeType = FeeType.NORMAL, + cryptoFormatted = "0.0007 ETH", + cryptoFormattedWithNative = "0.0007 ETH", + fiatFormatted = "$1.40", + fiatFormattedWithNative = "$1.40", + ) + val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee)) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = buildSwapProvider(), + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + val feeContent = result.fee as FeeItemState.Content + assertThat(feeContent.amountCrypto).isEqualTo("0.0007 ETH") + assertThat(feeContent.amountFiatFormatted).isEqualTo("$1.40") + } + + // region — local fixtures + + private fun buildReadyState(): SwapStateHolder { + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + return sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ), + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + } + + private fun buildQuoteModel( + txFeeState: TxFeeState, + ): SwapState.QuotesLoadedState { + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val fromInfo = TokenSwapInfo( + tokenAmount = SwapAmount(BigDecimal("1.0"), 18), + amountFiat = BigDecimal("100.00"), + swapCurrencyStatus = fromStatus, + ) + val toInfo = TokenSwapInfo( + tokenAmount = SwapAmount(BigDecimal("0.05"), 18), + amountFiat = BigDecimal("100.00"), + swapCurrencyStatus = toStatus, + ) + return SwapState.QuotesLoadedState( + fromTokenInfo = fromInfo, + toTokenInfo = toInfo, + priceImpact = PriceImpact.Empty, + preparedSwapConfigState = PreparedSwapConfigState( + isBalanceEnough = true, + feeState = SwapFeeState.Enough, + hasOutgoingTransaction = false, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + ), + permissionState = PermissionDataState.Empty, + txFee = txFeeState, + currencyCheck = null, + validationResult = null, + minAdaValue = null, + swapProvider = buildSwapProvider(), + ) + } + + private fun buildSwapProvider(): SwapProvider = SwapProvider( + providerId = "provider-id", + rateTypes = listOf(RateType.FLOAT), + name = "TestProvider", + type = ExchangeProviderType.DEX, + imageLarge = "https://example.com/icon.png", + termsOfUse = null, + privacyPolicy = null, + isRecommended = false, + slippage = null, + isExtraIdSupported = false, + ) + + private fun buildLegacyFee( + feeType: FeeType, + cryptoFormatted: String, + cryptoFormattedWithNative: String, + fiatFormatted: String, + fiatFormattedWithNative: String, + ): TxFee.Legacy { + val fee: Fee = mockk(relaxed = true) + return TxFee.Legacy( + feeValue = BigDecimal("0.001"), + feeFiatFormatted = fiatFormatted, + feeCryptoFormatted = cryptoFormatted, + feeIncludeOtherNativeFee = BigDecimal.ZERO, + feeFiatFormattedWithNative = fiatFormattedWithNative, + feeCryptoFormattedWithNative = cryptoFormattedWithNative, + cryptoSymbol = "ETH", + feeType = feeType, + fee = fee, + ) + } + + // endregion +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt new file mode 100644 index 0000000000..cb55c0b5a5 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt @@ -0,0 +1,486 @@ +package com.tangem.feature.swap + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +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.IncludeFeeInAmount +import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState +import com.tangem.feature.swap.domain.models.domain.RateType +import com.tangem.feature.swap.domain.models.domain.SwapFeeState +import com.tangem.feature.swap.domain.models.domain.SwapProvider +import com.tangem.feature.swap.domain.models.ui.FeeType +import com.tangem.feature.swap.domain.models.ui.PermissionDataState +import com.tangem.feature.swap.domain.models.ui.PriceImpact +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.feature.swap.domain.models.ui.TxFee +import com.tangem.feature.swap.domain.models.ui.TxFeeState +import com.tangem.feature.swap.model.SwapNotificationsFactory +import com.tangem.feature.swap.models.UiActions +import com.tangem.feature.swap.models.states.SwapNotificationUM +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Characterization tests for fee-related notifications produced by [SwapNotificationsFactory]. + * + * Pinned behavior: + * - [SwapNotificationUM.Error.UnableToCoverFeeWarning]: + * adds when `feeState = NotEnough` AND `isBalanceEnough = true` AND + * `permissionState != PermissionLoading` AND fee currency != fromCurrency, + * AND NOT (gasless network AND CEX provider). + * Suppressed for CEX-on-gasless-network. Re-added unconditionally when + * `includeFeeInAmount is BalanceNotEnough`. + * - [NotificationUM.Warning.FeeCoverageNotification]: triggers on + * `includeFeeInAmount is Included` AND a fee is selected AND no existential deposit. + * - [SwapNotificationUM.Info.PermissionNeeded]: triggers on `permissionState is PermissionRequired`. + * - [SwapNotificationUM.Error.TransactionInProgressWarning]: triggers on + * `hasOutgoingTransaction = true` (when `permissionState is not PermissionLoading`). + * - `hideFee = true` short-circuits `maybeAddUnableCoverFeeWarning` only. + * + * [REDACTED_TASK_KEY] — these guard the redesign that consolidates fee-state into `SwapFee` / + * `FeeBucket`. Phase 5 will rewrite the factory; these tests stay green throughout. + */ +internal class SwapNotificationsFactoryFeeWarningsTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + + private val sut: SwapNotificationsFactory by lazy { + SwapNotificationsFactory( + actions = actions, + isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, + ) + } + + private val ethNetworkMock: Network = mockk(relaxed = true) { + every { name } returns "Ethereum" + every { currencySymbol } returns "ETH" + every { rawId } returns "ethereum" + } + private val fromCurrency: CryptoCurrency = mockk(relaxed = true) { + every { network } returns ethNetworkMock + every { symbol } returns "ETH" + every { decimals } returns 18 + every { name } returns "Ethereum" + } + private val differentFeeCurrency: CryptoCurrency = mockk(relaxed = true) { + every { network } returns ethNetworkMock + every { symbol } returns "USDC" + every { decimals } returns 6 + every { name } returns "USD Coin" + } + + // ---------- UnableToCoverFeeWarning ---------- + + @Test + fun `UnableToCoverFeeWarning is added when feeState NotEnough and balance enough and not gasless and fee currency differs`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"), + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.DEX, + ) + val feeStatus = buildFeeStatus(differentFeeCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isTrue() + } + + @Test + fun `UnableToCoverFeeWarning is suppressed when gasless is available for CEX provider`() { + // Given — CEX + supported network → suppressed + every { isGaslessFeeSupportedForNetwork(any()) } returns true + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"), + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.CEX, + ) + val feeStatus = buildFeeStatus(differentFeeCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isFalse() + } + + @Test + fun `UnableToCoverFeeWarning is added even when gasless is available IF includeFeeInAmount is BalanceNotEnough`() { + // Given — CEX + supported network BUT BalanceNotEnough overrides the suppression. + every { isGaslessFeeSupportedForNetwork(any()) } returns true + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"), + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.BalanceNotEnough, + providerType = ExchangeProviderType.CEX, + ) + val feeStatus = buildFeeStatus(differentFeeCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isTrue() + } + + @Test + fun `UnableToCoverFeeWarning is not added when hideFee true`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"), + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.DEX, + ) + val feeStatus = buildFeeStatus(differentFeeCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = true, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isFalse() + } + + @Test + fun `UnableToCoverFeeWarning is not added when feeState is Enough`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.Enough, + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.DEX, + ) + val feeStatus = buildFeeStatus(differentFeeCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isFalse() + } + + // ---------- FeeCoverageNotification ---------- + + @Test + fun `FeeCoverageNotification is added when includeFeeInAmount is Included with a selected fee`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val singleFee = buildLegacyFee(FeeType.NORMAL) + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.Enough, + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Included(SwapAmount(BigDecimal("0.99"), 18)), + providerType = ExchangeProviderType.CEX, + txFeeState = TxFeeState.SingleFeeState(singleFee), + ) + val feeStatus = buildFeeStatus(fromCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is NotificationUM.Warning.FeeCoverageNotification }).isTrue() + } + + @Test + fun `FeeCoverageNotification is not added when includeFeeInAmount is Excluded`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val singleFee = buildLegacyFee(FeeType.NORMAL) + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.Enough, + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.CEX, + txFeeState = TxFeeState.SingleFeeState(singleFee), + ) + val feeStatus = buildFeeStatus(fromCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is NotificationUM.Warning.FeeCoverageNotification }).isFalse() + } + + // ---------- PermissionNeeded ---------- + + @Test + fun `PermissionNeeded is added when permissionState is PermissionRequired`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.Enough, + isBalanceEnough = true, + permissionState = PermissionDataState.PermissionRequired( + isResetApproval = false, + spenderAddress = "0xSpender", + ), + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.DEX, + ) + val feeStatus = buildFeeStatus(fromCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Info.PermissionNeeded }).isTrue() + } + + @Test + fun `PermissionNeeded is not added when permissionState is Empty`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.Enough, + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.DEX, + ) + val feeStatus = buildFeeStatus(fromCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Info.PermissionNeeded }).isFalse() + } + + // ---------- TransactionInProgressWarning / ApprovalInProgressWarning ---------- + + @Test + fun `ApprovalInProgressWarning is added when permissionState is PermissionLoading`() { + // Given — PermissionLoading short-circuits to ApprovalInProgressWarning (an Error subtype) + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.Enough, + isBalanceEnough = true, + permissionState = PermissionDataState.PermissionLoading, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.DEX, + hasOutgoingTransaction = false, + ) + val feeStatus = buildFeeStatus(fromCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Error.ApprovalInProgressWarning }).isTrue() + } + + @Test + fun `TransactionInProgressWarning is added when hasOutgoingTransaction true and permission not loading`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.Enough, + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.DEX, + hasOutgoingTransaction = true, + ) + val feeStatus = buildFeeStatus(fromCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Error.TransactionInProgressWarning }).isTrue() + } + + // region — local helpers + + @Suppress("LongParameterList") + private fun buildQuoteModel( + feeState: SwapFeeState = SwapFeeState.Enough, + isBalanceEnough: Boolean = true, + permissionState: PermissionDataState = PermissionDataState.Empty, + includeFeeInAmount: IncludeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType: ExchangeProviderType = ExchangeProviderType.DEX, + txFeeState: TxFeeState = TxFeeState.Empty, + hasOutgoingTransaction: Boolean = false, + ): SwapState.QuotesLoadedState { + val fromStatus = buildSwapCurrencyStatusForFromCurrency() + val toStatus = buildSwapCurrencyStatusForFromCurrency() + val fromInfo = TokenSwapInfo( + tokenAmount = SwapAmount(BigDecimal("1.0"), 18), + amountFiat = BigDecimal("100.00"), + swapCurrencyStatus = fromStatus, + ) + val toInfo = TokenSwapInfo( + tokenAmount = SwapAmount(BigDecimal("0.05"), 18), + amountFiat = BigDecimal("100.00"), + swapCurrencyStatus = toStatus, + ) + return SwapState.QuotesLoadedState( + fromTokenInfo = fromInfo, + toTokenInfo = toInfo, + priceImpact = PriceImpact.Empty, + preparedSwapConfigState = PreparedSwapConfigState( + isBalanceEnough = isBalanceEnough, + feeState = feeState, + hasOutgoingTransaction = hasOutgoingTransaction, + includeFeeInAmount = includeFeeInAmount, + ), + permissionState = permissionState, + txFee = txFeeState, + currencyCheck = null, + validationResult = null, + minAdaValue = null, + swapProvider = SwapProvider( + providerId = "p", + rateTypes = listOf(RateType.FLOAT), + name = "TestProvider", + type = providerType, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + isRecommended = false, + slippage = null, + isExtraIdSupported = false, + ), + ) + } + + private fun buildSwapCurrencyStatusForFromCurrency(): SwapCurrencyStatus { + val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) { + every { amount } returns BigDecimal("1.0") + every { fiatRate } returns BigDecimal("2000.00") + every { fiatAmount } returns BigDecimal("2000.00") + every { networkAddress } returns mockk(relaxed = true) + every { pendingTransactions } returns emptySet() + } + val cryptoCurrencyStatus = CryptoCurrencyStatus(currency = fromCurrency, value = statusValue) + val userWallet: UserWallet = mockk(relaxed = true) { + every { walletId } returns UserWalletId("aabbccdd") + } + val account = com.tangem.domain.models.account.Account.CryptoPortfolio.createMainAccount(userWallet.walletId) + return SwapCurrencyStatus( + userWallet = userWallet, + status = cryptoCurrencyStatus, + account = account, + ) + } + + private fun buildFeeStatus(currency: CryptoCurrency): CryptoCurrencyStatus { + val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) { + every { amount } returns BigDecimal("0.5") + } + return CryptoCurrencyStatus(currency = currency, value = statusValue) + } + + private fun buildLegacyFee(feeType: FeeType): TxFee.Legacy { + val fee: Fee = mockk(relaxed = true) + return TxFee.Legacy( + feeValue = BigDecimal("0.001"), + feeFiatFormatted = "$2.00", + feeCryptoFormatted = "0.001 ETH", + feeIncludeOtherNativeFee = BigDecimal.ZERO, + feeFiatFormattedWithNative = "$2.00", + feeCryptoFormattedWithNative = "0.001 ETH", + cryptoSymbol = "ETH", + feeType = feeType, + fee = fee, + ) + } + + // endregion +} \ No newline at end of file