Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-27 19:43:31 +05:00
parent a2b99aa465
commit 9be9f8f3e7
6 changed files with 774 additions and 33 deletions

View file

@ -27,6 +27,7 @@ import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.express.models.*
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -1372,37 +1373,46 @@ internal class SwapInteractorImpl @Inject constructor(
feeValue: BigDecimal,
selectedFeeToken: CryptoCurrencyStatus? = null,
): IncludeFeeInAmountInternal {
val isFeeInSameCurrencyToken = selectedFeeToken != null &&
fromSwapCurrencyStatus.currency.id == selectedFeeToken.currency.id &&
selectedFeeToken.currency is CryptoCurrency.Token
return if (isFeeInSameCurrencyToken) {
// we have a token selected for fee payment the same as sending token
val fromBalance = fromSwapCurrencyStatus.status.value.amount
val reducedBalance = fromBalance?.minus(reduceBalanceBy).orZero()
when {
amount.value > reducedBalance -> IncludeFeeInAmountInternal.BalanceNotEnough
amount.value + feeValue <= reducedBalance -> IncludeFeeInAmountInternal.Excluded
else -> {
if (feeValue < amount.value) {
IncludeFeeInAmountInternal.Included(
amountSubtractFee = SwapAmount(
value = reducedBalance - feeValue,
decimals = fromSwapCurrencyStatus.currency.decimals,
),
)
} else {
IncludeFeeInAmountInternal.Excluded
}
}
return if (fromSwapCurrencyStatus.account is Account.Payment) {
val fromBalance = fromSwapCurrencyStatus.status.value.amount.orZero()
if (amount.value > fromBalance) {
IncludeFeeInAmountInternal.BalanceNotEnough
} else {
IncludeFeeInAmountInternal.Excluded
}
} else {
getIncludeFeeInAmountForNative(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
feeValue = feeValue,
)
val isFeeInSameCurrencyToken = selectedFeeToken != null &&
fromSwapCurrencyStatus.currency.id == selectedFeeToken.currency.id &&
selectedFeeToken.currency is CryptoCurrency.Token
if (isFeeInSameCurrencyToken) {
// we have a token selected for fee payment the same as sending token
val fromBalance = fromSwapCurrencyStatus.status.value.amount
val reducedBalance = fromBalance?.minus(reduceBalanceBy).orZero()
when {
amount.value > reducedBalance -> IncludeFeeInAmountInternal.BalanceNotEnough
amount.value + feeValue <= reducedBalance -> IncludeFeeInAmountInternal.Excluded
else -> {
if (feeValue < amount.value) {
IncludeFeeInAmountInternal.Included(
amountSubtractFee = SwapAmount(
value = reducedBalance - feeValue,
decimals = fromSwapCurrencyStatus.currency.decimals,
),
)
} else {
IncludeFeeInAmountInternal.Excluded
}
}
}
} else {
getIncludeFeeInAmountForNative(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
feeValue = feeValue,
)
}
}
}

View file

@ -0,0 +1,336 @@
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.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
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.PreparedSwapConfigState
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
import com.tangem.feature.swap.domain.models.ui.*
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.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
* Tests for the Tangem Pay early-exit branch in [SwapInteractorImpl].
*
* When the from-currency belongs to a [Account.Payment] account the fee must
* never be included in the swap amount ([IncludeFeeInAmountInternal.Excluded]).
*
* The private [SwapInteractorImpl.getIncludeFeeInAmountInternal] function is exercised
* through the public [SwapInteractorImpl.applySwapFee] entry point (CEX provider path),
* which calls [computeBalanceStatus] [getIncludeFeeInAmountInternal].
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@DisplayName("SwapInteractorImpl — Tangem Pay (Payment account) fee-inclusion behaviour")
internal class SwapInteractorImplTangemPayTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val userWalletId = UserWalletId(stringValue = "deadbeef")
private val lastReducedBalanceBy = BigDecimal.ZERO
@BeforeEach
fun setup() {
// Shared stubs required by computeBalanceStatus / manageWarnings / manageTransactionValidationWarnings
coEvery {
getCurrencyCheckUseCase.invoke(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = any(),
)
} returns buildCurrencyCheck()
coEvery {
validateTransactionUseCase.invoke(
amount = any(),
fee = any(),
memo = any(),
destination = any(),
userWalletId = any(),
network = any(),
)
} returns Unit.right()
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right()
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
}
// -------------------------------------------------------------------------
// Payment account — fee always Excluded
// -------------------------------------------------------------------------
@Nested
@DisplayName("Payment account (Tangem Pay withdrawal)")
inner class PaymentAccountBranch {
@Test
@DisplayName("should produce Sufficient and not FeeAdjustedAmount when Payment account token amount within balance")
fun `should produce Sufficient when Payment account token swap and amount within balance`() = runTest {
// Token swap: balance=1, amount=0.95, fee=0.1 (amount + fee > balance).
// On a CryptoPortfolio account with a same-currency token fee this triggers FeeAdjustedAmount.
// On a Payment account the early-exit returns Excluded, so computeBalanceStatus falls through
// to isBalanceEnough (token: checks balance >= amount only → true) → Sufficient.
val state = buildCexQuotesLoadedState(
fromAmount = SwapAmount(BigDecimal("0.95"), 18),
isCoin = false,
fromBalance = BigDecimal("1"),
account = Account.Payment(userWalletId),
)
val swapFee = buildTestSwapFee(feeValue = BigDecimal("0.001"))
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
assertThat(patched.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
@Test
@DisplayName("should produce Sufficient even when from-token and fee-token ids match (same-currency token path bypassed)")
fun `should produce Sufficient when Payment account and same-currency token fee selected`() = runTest {
// With a CryptoPortfolio account this scenario (same token for fee and swap) would trigger
// the IncludeFeeInAmountInternal.Included / BalanceNotEnough paths.
// Payment account must short-circuit before reaching that logic.
val state = buildCexQuotesLoadedState(
fromAmount = SwapAmount(BigDecimal("1"), 18),
isCoin = false,
fromBalance = BigDecimal("1"),
account = Account.Payment(userWalletId),
)
// Build a fee token that shares the same currency id as the from-token — triggers same-currency path
// on non-Payment accounts.
val fromCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus.status
val swapFee = buildTestSwapFeeWithToken(
feeValue = BigDecimal("0.5"),
selectedFeeToken = fromCurrencyStatus,
)
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
// Must be Sufficient, not FeeAdjustedAmount or InsufficientFee
assertThat(patched.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
@Test
@DisplayName("should produce InsufficientAmount when Payment account and amount exceeds balance")
fun `should produce InsufficientAmount when Payment account and amount exceeds from-balance`() = runTest {
// Even on a Payment account the basic amount-vs-balance check must still apply.
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
val state = buildCexQuotesLoadedState(
fromAmount = SwapAmount(BigDecimal("5"), 18),
isCoin = true,
fromBalance = BigDecimal("1"),
account = Account.Payment(userWalletId),
)
val swapFee = buildTestSwapFee(feeValue = BigDecimal("0.001"))
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
assertThat(patched.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientAmount::class.java)
}
}
// -------------------------------------------------------------------------
// Non-Payment account — existing native-fee logic preserved
// -------------------------------------------------------------------------
@Nested
@DisplayName("CryptoPortfolio account (existing behaviour preserved)")
inner class CryptoPortfolioAccountBranch {
@Test
@DisplayName("should produce Sufficient when CryptoPortfolio account and native balance covers fee")
fun `should produce Sufficient when CryptoPortfolio account and native balance covers fee`() = runTest {
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
val state = buildCexQuotesLoadedState(
fromAmount = SwapAmount(BigDecimal("1"), 18),
isCoin = true,
fromBalance = BigDecimal("10"),
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
)
val swapFee = buildTestSwapFee(feeValue = BigDecimal("0.001"))
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
assertThat(patched.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
@Test
@DisplayName("should produce InsufficientFee when CryptoPortfolio account and native balance below fee")
fun `should produce InsufficientFee when CryptoPortfolio and native balance below fee`() = runTest {
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("0.0001")
val state = buildCexQuotesLoadedState(
fromAmount = SwapAmount(BigDecimal("1"), 18),
isCoin = false, // token → fee paid from native
fromBalance = BigDecimal("10"),
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
)
val swapFee = buildTestSwapFee(feeValue = BigDecimal("0.01"))
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
assertThat(patched.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
}
@Test
@DisplayName("should produce FeeAdjustedAmount when CryptoPortfolio account, same-currency token fee, and amount fills balance")
fun `should produce FeeAdjustedAmount when CryptoPortfolio and same-currency token fee squeezes amount`() =
runTest {
// same-token fee path: amount fills the balance but amount + fee > balance → FeeAdjustedAmount
val fromBalance = BigDecimal("1")
val feeValue = BigDecimal("0.1")
val amount = BigDecimal("0.95") // 0.95 + 0.1 = 1.05 > 1 → triggers Included
val state = buildCexQuotesLoadedState(
fromAmount = SwapAmount(amount, 18),
isCoin = false,
fromBalance = fromBalance,
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
)
val fromCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus.status
val swapFee = buildTestSwapFeeWithToken(
feeValue = feeValue,
selectedFeeToken = fromCurrencyStatus,
)
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
assertThat(patched.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.FeeAdjustedAmount::class.java)
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun buildCurrencyCheck(): CryptoCurrencyCheck = CryptoCurrencyCheck(
dustValue = null,
reserveAmount = null,
minimumSendAmount = null,
existentialDeposit = null,
utxoAmountLimit = null,
isAccountFunded = true,
rentWarning = null,
isMemoRequired = false,
)
/**
* Builds a [SwapState.QuotesLoadedState] with a CEX provider so that [applySwapFee] routes
* through [computeBalanceStatus] [getIncludeFeeInAmountInternal].
*
* The [account] parameter is the real domain [Account] instance to put on [SwapCurrencyStatus].
*/
private fun buildCexQuotesLoadedState(
fromAmount: SwapAmount,
isCoin: Boolean,
fromBalance: BigDecimal,
account: Account,
): SwapState.QuotesLoadedState {
val from = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = isCoin,
amount = fromBalance,
).copy(account = account)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
return SwapState.QuotesLoadedState(
fromTokenInfo = TokenSwapInfo(
tokenAmount = fromAmount,
swapCurrencyStatus = from,
amountFiat = BigDecimal.ZERO,
),
toTokenInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal("0.5"), 18),
swapCurrencyStatus = to,
amountFiat = BigDecimal.ZERO,
),
priceImpact = PriceImpact.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
balanceStatus = SwapBalanceStatus.Pending,
hasOutgoingTransaction = false,
),
permissionState = PermissionDataState.Empty,
swapDataModel = null,
currencyCheck = null,
validationResult = null,
minAdaValue = null,
swapProvider = buildSwapProvider(ExchangeProviderType.CEX),
)
}
private fun buildTestSwapFee(
feeValue: BigDecimal,
otherNativeFee: BigDecimal = BigDecimal.ZERO,
): SwapFee {
val feeAmount = mockk<Amount>(relaxed = true) {
every { value } returns feeValue
}
val fee = mockk<Fee.Common>(relaxed = true) {
every { this@mockk.amount } returns feeAmount
}
val feeTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns buildCoinCurrency()
}
return SwapFee(
fee = fee,
transactionFeeResult = TransactionFeeResult.Loaded(mockk<TransactionFee.Single>(relaxed = true)),
selectedFeeToken = feeTokenStatus,
otherNativeFee = otherNativeFee,
feeBucket = FeeBucket.MARKET,
)
}
/**
* Builds a [SwapFee] whose [SwapFee.selectedFeeToken] is the given [CryptoCurrencyStatus].
* This triggers the same-currency-token path in [getIncludeFeeInAmountInternal] for
* [Account.CryptoPortfolio] accounts.
*/
private fun buildTestSwapFeeWithToken(
feeValue: BigDecimal,
selectedFeeToken: CryptoCurrencyStatus,
): SwapFee {
val feeAmount = mockk<Amount>(relaxed = true) {
every { value } returns feeValue
}
val fee = mockk<Fee.Common>(relaxed = true) {
every { this@mockk.amount } returns feeAmount
}
return SwapFee(
fee = fee,
transactionFeeResult = TransactionFeeResult.Loaded(mockk<TransactionFee.Single>(relaxed = true)),
selectedFeeToken = selectedFeeToken,
otherNativeFee = BigDecimal.ZERO,
feeBucket = FeeBucket.MARKET,
)
}
}

View file

@ -160,8 +160,9 @@ internal class DefaultSwapComponent @AssistedInject constructor(
val isPermissionNotReady = loadedState?.permissionState !is PermissionDataState.Empty
val isInTransferMode = dataState.currentTransferState != null
val isSwapNotReady = !isInTransferMode && (isProviderMissing || isPermissionNotReady)
val isTangemPayWithdrawal = model.isTangemPayWithdrawal()
isAmountEmptyOrZero || isInsufficientFunds || isSwapNotReady
isAmountEmptyOrZero || isInsufficientFunds || isSwapNotReady || isTangemPayWithdrawal
}
}

View file

@ -1924,7 +1924,7 @@ internal class SwapModel @Inject constructor(
)
}
private fun isTangemPayWithdrawal(): Boolean {
fun isTangemPayWithdrawal(): Boolean {
return tangemPayInput?.isWithdrawal == true || dataState.fromSwapCurrencyStatus?.account is Account.Payment
}

View file

@ -497,6 +497,7 @@ internal class StateBuilder(
}
}
val priceImpact = quoteModel.priceImpact
val isTangemPayWithdrawal = fromSwapCurrencyStatus.account is Account.Payment
return uiStateHolder.copy(
sendCardData = SwapCardState.SwapCardData(
type = sendInput,
@ -550,7 +551,12 @@ internal class StateBuilder(
),
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
isEnabled = getSwapButtonEnabled(notifications, priceImpact, swapFee),
isEnabled = getSwapButtonEnabled(
notifications = notifications,
priceImpact = priceImpact,
swapFee = swapFee,
isTangemPayWithdrawal = isTangemPayWithdrawal,
),
isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet,
onClick = actions.onSwapClick,
),
@ -631,8 +637,10 @@ internal class StateBuilder(
notifications: ImmutableList<NotificationUM>,
priceImpact: PriceImpact,
swapFee: SwapFee?,
isTangemPayWithdrawal: Boolean,
): Boolean {
return swapFee != null && notifications.none { notification ->
val isSwapTxReady = isTangemPayWithdrawal || swapFee != null
return isSwapTxReady && notifications.none { notification ->
notification is SwapNotificationUM.Error || notification is NotificationUM.Error ||
notification is SwapNotificationUM.Warning.ExpressErrorWarning ||
notification is SwapNotificationUM.Warning.ExpressGeneralError ||

View file

@ -0,0 +1,386 @@
package com.tangem.feature.swap
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.common.routing.AppRouter
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
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.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
* Tests for the [StateBuilder.getSwapButtonEnabled] path as exposed via
* [StateBuilder.createQuotesLoadedState].
*
* The change under test:
* val isSwapTxReady = isTangemPayWithdrawal || swapFee != null
*
* Truth table asserted here:
* | isTangemPay | swapFee | blocking notification | expected isEnabled |
* |-------------|---------|----------------------|--------------------|
* | true | null | none | true |
* | true | null | present | false |
* | false | null | none | false |
* | false | non-null| none | true |
* | false | non-null| present | false |
*/
@DisplayName("StateBuilder — swap button enabled logic (isTangemPayWithdrawal gate)")
internal class StateBuilderSwapButtonTest {
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 val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
private val appRouter: AppRouter = mockk()
private lateinit var sut: StateBuilder
private val userWalletId = UserWalletId(stringValue = "deadbeef")
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
sut = StateBuilder(
actions = actions,
isBalanceHiddenProvider = isBalanceHiddenProvider,
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
swapFeatureToggles = swapFeatureToggles,
appRouter = appRouter,
)
}
@Nested
@DisplayName("Tangem Pay withdrawal (Payment account)")
inner class `Tangem Pay withdrawal` {
@Test
@DisplayName("should enable swap button when Payment account, swapFee is null, and no blocking notifications")
fun `should enable swap button when Payment account and swapFee null and no blocking notifications`() {
val paymentAccount = Account.Payment(userWalletId)
val state = buildQuotesLoadedStateFor(
account = paymentAccount,
hasOutgoingTransaction = false,
permissionState = PermissionDataState.Empty,
)
val baseHolder = buildInputtableHolder()
val result = sut.createQuotesLoadedState(
uiStateHolder = baseHolder,
quoteModel = state,
feeCryptoCurrencyStatus = null,
swapProvider = buildProvider(ExchangeProviderType.CEX),
bestRatedProviderId = "p",
isNeedBestRateBadge = false,
needApplyFCARestrictions = false,
swapFee = null,
feeError = null,
)
assertThat(result.swapButton.isEnabled).isTrue()
}
@Test
@DisplayName("should disable swap button when Payment account, swapFee is null, but a blocking notification is present")
fun `should disable swap button when Payment account and swapFee null and blocking notification`() {
val paymentAccount = Account.Payment(userWalletId)
val state = buildQuotesLoadedStateFor(
account = paymentAccount,
// hasOutgoingTransaction=true produces a SwapNotificationUM.Error which blocks the button
hasOutgoingTransaction = true,
permissionState = PermissionDataState.Empty,
)
val baseHolder = buildInputtableHolder()
val result = sut.createQuotesLoadedState(
uiStateHolder = baseHolder,
quoteModel = state,
feeCryptoCurrencyStatus = null,
swapProvider = buildProvider(ExchangeProviderType.CEX),
bestRatedProviderId = "p",
isNeedBestRateBadge = false,
needApplyFCARestrictions = false,
swapFee = null,
feeError = null,
)
assertThat(result.swapButton.isEnabled).isFalse()
}
}
@Nested
@DisplayName("Non-Pay account (CryptoPortfolio)")
inner class `Non-Pay account` {
@Test
@DisplayName("should disable swap button when CryptoPortfolio account and swapFee is null")
fun `should disable swap button when CryptoPortfolio account and swapFee null`() {
val cryptoAccount = Account.CryptoPortfolio.createMainAccount(userWalletId)
val state = buildQuotesLoadedStateFor(
account = cryptoAccount,
hasOutgoingTransaction = false,
permissionState = PermissionDataState.Empty,
)
val baseHolder = buildInputtableHolder()
val result = sut.createQuotesLoadedState(
uiStateHolder = baseHolder,
quoteModel = state,
feeCryptoCurrencyStatus = null,
swapProvider = buildProvider(ExchangeProviderType.CEX),
bestRatedProviderId = "p",
isNeedBestRateBadge = false,
needApplyFCARestrictions = false,
swapFee = null,
feeError = null,
)
assertThat(result.swapButton.isEnabled).isFalse()
}
@Test
@DisplayName("should enable swap button when CryptoPortfolio account, swapFee is non-null, and no blocking notifications")
fun `should enable swap button when CryptoPortfolio account and swapFee non-null and no blocking notifications`() {
val cryptoAccount = Account.CryptoPortfolio.createMainAccount(userWalletId)
val state = buildQuotesLoadedStateFor(
account = cryptoAccount,
hasOutgoingTransaction = false,
permissionState = PermissionDataState.Empty,
)
val baseHolder = buildInputtableHolder()
val result = sut.createQuotesLoadedState(
uiStateHolder = baseHolder,
quoteModel = state,
feeCryptoCurrencyStatus = null,
swapProvider = buildProvider(ExchangeProviderType.CEX),
bestRatedProviderId = "p",
isNeedBestRateBadge = false,
needApplyFCARestrictions = false,
swapFee = buildSwapFee(),
feeError = null,
)
assertThat(result.swapButton.isEnabled).isTrue()
}
@Test
@DisplayName("should disable swap button when CryptoPortfolio account, swapFee is non-null, but a blocking notification is present")
fun `should disable swap button when CryptoPortfolio account and swapFee non-null and blocking notification`() {
val cryptoAccount = Account.CryptoPortfolio.createMainAccount(userWalletId)
val state = buildQuotesLoadedStateFor(
account = cryptoAccount,
// PermissionRequired triggers SwapNotificationUM.Info.PermissionNeeded — in the blocking list
hasOutgoingTransaction = false,
permissionState = PermissionDataState.PermissionRequired(
isResetApproval = false,
spenderAddress = "0xspender",
),
)
val baseHolder = buildInputtableHolder()
val result = sut.createQuotesLoadedState(
uiStateHolder = baseHolder,
quoteModel = state,
feeCryptoCurrencyStatus = null,
swapProvider = buildProvider(ExchangeProviderType.CEX),
bestRatedProviderId = "p",
isNeedBestRateBadge = false,
needApplyFCARestrictions = false,
swapFee = buildSwapFee(),
feeError = null,
)
assertThat(result.swapButton.isEnabled).isFalse()
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Builds a [SwapStateHolder] whose send/receive cards are [SwapCardState.SwapCardData] with
* [TransactionCardType.Inputtable] type required by [StateBuilder.createQuotesLoadedState].
*/
private fun buildInputtableHolder(): SwapStateHolder {
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val emptyAmountState = SwapState.EmptyAmountState(stringReference("$0.00"))
val loading = sut.createInitialLoadingState()
return sut.createInitialReadyState(
uiStateHolder = loading,
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
}
/**
* Builds a minimal [SwapState.QuotesLoadedState] with the given [account] on the from-currency
* and configurable notification triggers.
*
* @param hasOutgoingTransaction when true, [SwapNotificationsFactory] adds a
* [SwapNotificationUM.Error.TransactionInProgressWarning] a blocking Error notification.
* @param permissionState when [PermissionDataState.PermissionRequired], adds a
* [SwapNotificationUM.Info.PermissionNeeded] also in the blocking list.
*/
private fun buildQuotesLoadedStateFor(
account: Account,
hasOutgoingTransaction: Boolean,
permissionState: PermissionDataState,
): SwapState.QuotesLoadedState {
val networkRawId = Blockchain.Ethereum.toNetworkId()
val networkId = mockk<Network.ID>(relaxed = true) {
every { rawId } returns Network.RawID(networkRawId)
}
val network = mockk<Network>(relaxed = true) {
every { rawId } returns networkRawId
every { id } returns networkId
every { currencySymbol } returns "ETH"
every { name } returns "Ethereum"
}
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { this@mockk.network } returns network
every { this@mockk.symbol } returns "ETH"
every { this@mockk.decimals } returns 18
}
val networkAddress = mockk<NetworkAddress>(relaxed = true) {
every { defaultAddress } returns NetworkAddress.Address(
value = "0xTest",
type = NetworkAddress.Address.Type.Primary,
)
}
val statusValue = mockk<CryptoCurrencyStatus.Loaded>(relaxed = true) {
every { amount } returns BigDecimal("1")
every { this@mockk.networkAddress } returns networkAddress
every { pendingTransactions } returns emptySet()
}
val cryptoCurrencyStatus = CryptoCurrencyStatus(currency = currency, value = statusValue)
val userWallet = mockk<UserWallet>(relaxed = true) {
every { walletId } returns userWalletId
}
val fromSwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = cryptoCurrencyStatus,
account = account,
)
val toSwapCurrencyStatus = buildSwapCurrencyStatusWithCryptoPortfolio(coldWallet)
return SwapState.QuotesLoadedState(
fromTokenInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal("0.5"), 18),
swapCurrencyStatus = fromSwapCurrencyStatus,
amountFiat = BigDecimal.ZERO,
),
toTokenInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal("0.5"), 18),
swapCurrencyStatus = toSwapCurrencyStatus,
amountFiat = BigDecimal.ZERO,
),
priceImpact = PriceImpact.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
balanceStatus = SwapBalanceStatus.Sufficient,
hasOutgoingTransaction = hasOutgoingTransaction,
),
permissionState = permissionState,
swapDataModel = null,
currencyCheck = null,
validationResult = null,
minAdaValue = null,
swapProvider = buildProvider(ExchangeProviderType.CEX),
)
}
private fun buildSwapCurrencyStatusWithCryptoPortfolio(userWallet: UserWallet): SwapCurrencyStatus {
val walletId = userWallet.walletId
val account = Account.CryptoPortfolio.createMainAccount(walletId)
val currency: CryptoCurrency = mockk(relaxed = true) {
every { symbol } returns "BTC"
every { decimals } returns 8
every { network } returns mockk(relaxed = true) {
every { id } returns mockk(relaxed = true)
every { name } returns "Bitcoin"
every { currencySymbol } returns "BTC"
}
}
val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) {
every { amount } returns BigDecimal("1.0")
}
val cryptoCurrencyStatus = CryptoCurrencyStatus(currency = currency, value = statusValue)
return SwapCurrencyStatus(
userWallet = userWallet,
status = cryptoCurrencyStatus,
account = account,
)
}
private fun buildProvider(type: ExchangeProviderType): SwapProvider = SwapProvider(
providerId = "p",
rateTypes = listOf(RateType.FLOAT),
name = "Provider",
type = type,
imageLarge = "",
termsOfUse = null,
privacyPolicy = null,
isRecommended = false,
slippage = null,
isExtraIdSupported = false,
)
private fun buildSwapFee(): SwapFee {
val amount = mockk<Amount>(relaxed = true) {
every { value } returns BigDecimal("0.001")
}
val fee = mockk<Fee.Common>(relaxed = true) {
every { this@mockk.amount } returns amount
}
val feeTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true)
return SwapFee(
fee = fee,
transactionFeeResult = TransactionFeeResult.Loaded(mockk<TransactionFee.Single>(relaxed = true)),
selectedFeeToken = feeTokenStatus,
otherNativeFee = BigDecimal.ZERO,
feeBucket = FeeBucket.MARKET,
)
}
}