Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-14 14:22:23 +05:00
parent 49bef29d23
commit b646b4f010
4 changed files with 0 additions and 1955 deletions

View file

@ -1,745 +0,0 @@
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.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
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.
*/
/**
* [REDACTED_TASK_KEY] Phase 4 `findBestQuote` no longer loads fees. The legacy `loadDexSwapData` is gone,
* replaced by `loadDexSwapDataNoFee` (no fee calls inside). Fee-loading characterization that was
* previously exercised via `findBestQuote` is now covered by:
* - `DexSwapFeeCalculatorTest` for the raw fee strategy (EVM, Solana, fallback, size guard)
* - `SwapInteractorImplLoadSwapFeeTest` for the unified entry point through `loadSwapFee`
* - `SwapInteractorImplApplySwapFeeTest` for fee state patching semantics
*
* This class is kept in source for reference and disabled. Phase 5 removes it.
*/
@Disabled(
"[REDACTED_TASK_KEY] Phase 4: findBestQuote no longer loads fees. " +
"See DexSwapFeeCalculatorTest, SwapInteractorImplLoadSwapFeeTest, SwapInteractorImplApplySwapFeeTest.",
)
@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<Set<CryptoCurrency.RawID>>().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<TransactionExtras>(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<TransactionData>()
coEvery {
getFeeUseCase.invoke(
userWallet = any(),
network = any(),
transactionData = capture(capturedTxData),
)
} returns mockk<TransactionFee.Single>(relaxed = true).right()
// When
sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// 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,
)
// 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<TransactionFee.Choosable>(relaxed = true).right()
// When
sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// 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<Throwable, TransactionExtras>; 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<TransactionFee.Choosable>(relaxed = true).right()
// When
sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// 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<TransactionFee.Choosable>(relaxed = true).right()
// When
sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// 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<String>(), 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<TransactionData>()
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,
)
// 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<String>(), any()) } returns oversizedBytes
mockkObject(SolanaTransactionHelper)
every { SolanaTransactionHelper.removeSignaturesPlaceholders(any()) } returns oversizedBytes
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val coldWallet = mockk<UserWallet.Cold>(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,
)
// 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
}

View file

@ -1,359 +0,0 @@
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.Disabled
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.
*/
/**
* [REDACTED_TASK_KEY] Phase 4 bridge `otherNativeFee` no longer flows through `findBestQuote` (fees aren't
* computed during quotes). The bridge-fee balance check is now exercised by
* `SwapInteractorImplApplySwapFeeTest` where `SwapFee.otherNativeFee` feeds the recomputed
* `feeToCheck = swapFee.fee + otherNativeFee`. The raw propagation from
* `ExpressTransactionModel.DEX.otherNativeFeeWei` is covered by `DexSwapFeeCalculatorTest`.
*
* This class is kept in source for reference and disabled. Phase 5 removes it.
*/
@Disabled(
"[REDACTED_TASK_KEY] Phase 4: otherNativeFee no longer flows through findBestQuote. " +
"See SwapInteractorImplApplySwapFeeTest and DexSwapFeeCalculatorTest.",
)
@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<Set<CryptoCurrency.RawID>>().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<TransactionExtras>(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,
)
// 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,
)
// 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,
)
// 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
}

View file

@ -1,376 +0,0 @@
package com.tangem.feature.swap
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.core.ui.extensions.stringReference
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.*
import com.tangem.feature.swap.domain.models.ui.*
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<Boolean> = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = 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,
shouldShowAbMenu = false,
)
}
@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 = 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
}

View file

@ -1,475 +0,0 @@
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<CryptoCurrency.Coin>(relaxed = true) {
every { network } returns ethNetworkMock
every { symbol } returns "ETH"
every { decimals } returns 18
every { name } returns "Ethereum"
}
private val differentFeeCurrency: CryptoCurrency = mockk<CryptoCurrency.Token>(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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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<NetworkAddress>(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
}