Updated on 2026-08-14
This commit is contained in:
parent
8a8f657d03
commit
fccbac44c2
4 changed files with 1952 additions and 0 deletions
|
|
@ -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<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,
|
||||
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<TransactionFee.Choosable>(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<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,
|
||||
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<TransactionFee.Choosable>(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<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,
|
||||
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<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,
|
||||
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
|
||||
}
|
||||
|
|
@ -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<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,
|
||||
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
|
||||
}
|
||||
|
|
@ -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<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,
|
||||
)
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
|
|
@ -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<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,
|
||||
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<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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue