Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-25 10:19:00 +01:00
commit e606dd474d
15 changed files with 1061 additions and 194 deletions

View file

@ -63,6 +63,7 @@ dependencies {
implementation(projects.features.swap.api)
implementation(projects.features.swap.domain.api)
implementation(projects.features.swap.domain.models)
implementation(projects.features.sendV2.api)
implementation(projects.libs.blockchainSdk)
/** Other Libraries **/

View file

@ -1,18 +1,10 @@
package com.tangem.feature.swap.domain.di
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.domain.transaction.usecase.CreateTransactionDataExtrasUseCase
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.feature.swap.domain.AllowPermissionsHandler
import com.tangem.feature.swap.domain.AllowPermissionsHandlerImpl
import com.tangem.feature.swap.domain.GetSwapUiModeUseCase
import com.tangem.feature.swap.domain.SetSwapUiModeUseCase
import com.tangem.feature.swap.domain.SwapFeedbackUseCase
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.SwapInteractorImpl
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
@ -23,9 +15,9 @@ import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator
import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator
import com.tangem.feature.swap.domain.fee.PatchEthGasLimitForSwap
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.feature.swap.domain.transfer.SwapTransferInteractor
import com.tangem.feature.swap.domain.transfer.SwapTransferInteractorImpl
import com.tangem.features.swap.SwapFeatureToggles
import dagger.Binds
import dagger.Module
import dagger.Provides

View file

@ -40,6 +40,11 @@ sealed interface SwapState {
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
val isAccountsMode: Boolean,
val isFeeCoverage: Boolean,
val sendingAmount: BigDecimal,
val currencyCheck: CryptoCurrencyCheck? = null,
val validationResult: Throwable? = null,
val minAdaValue: BigDecimal? = null,
) : SwapState
data class EmptyAmountState(

View file

@ -4,12 +4,14 @@ import arrow.core.Either
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.ui.SwapState
import java.math.BigDecimal
interface SwapTransferInteractor {
@ -17,6 +19,8 @@ interface SwapTransferInteractor {
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
feePaidCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
): SwapState
fun shouldTransferInsteadOfSwap(fromSwapCurrency: CryptoCurrency?, toSwapCurrency: CryptoCurrency?): Boolean
@ -36,7 +40,7 @@ interface SwapTransferInteractor {
suspend fun sendTransfer(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
sendingAmount: BigDecimal,
fee: Fee,
transactionFeeResult: TransactionFeeResult,
): Either<SendTransactionError, String>

View file

@ -1,6 +1,7 @@
package com.tangem.feature.swap.domain.transfer
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.left
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
@ -17,7 +18,11 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
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.tokens.GetCurrencyCheckUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.TransactionFeeExtended
@ -31,6 +36,8 @@ import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkAndCalculateSubtractedAmount
import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkFeeCoverage
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.extensions.orZero
import kotlinx.coroutines.flow.first
@ -48,12 +55,16 @@ class SwapTransferInteractorImpl @Inject constructor(
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase,
private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase,
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
) : SwapTransferInteractor {
override suspend fun updateTransfer(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
feePaidCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
): SwapState {
val fromToken = fromSwapCurrencyStatus.currency
val toToken = toSwapCurrencyStatus.currency
@ -63,6 +74,7 @@ class SwapTransferInteractorImpl @Inject constructor(
val fromTokenAmountValue = fromTokenAmount.parseBigDecimalOrNull() ?: return createEmptyAmountState(appCurrency)
val fromTokenAmountFiat = fromSwapCurrencyStatus.status.value.fiatRate.orZero() * fromTokenAmountValue
val fromTokenBalance = fromSwapCurrencyStatus.status.value.amount.orZero()
val userWallet = toSwapCurrencyStatus.userWallet
val fromTokenInfo = TokenSwapInfo(
tokenAmount = SwapAmount(fromTokenAmountValue, fromToken.decimals),
@ -75,17 +87,86 @@ class SwapTransferInteractorImpl @Inject constructor(
swapCurrencyStatus = toSwapCurrencyStatus,
amountFiat = fromTokenAmountFiat,
)
// Mirrors legacy manageWarnings in SwapInteractorImpl.applySwapFee: when the fee is paid in
// a token different from the from-token, the fee is deducted from a separate balance, so
// it must not be subtracted from the from-token balance here.
val feePaidCurrency = feePaidCurrencyStatus?.currency
val isFeeInOtherToken = feePaidCurrency is CryptoCurrency.Token && feePaidCurrency.id != fromToken.id
val warningsFee = if (isFeeInOtherToken) BigDecimal.ZERO else fee?.amount?.value.orZero()
val currencyCheck = getCurrencyCheckUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
currencyStatus = fromSwapCurrencyStatus.status,
feeCurrencyStatus = feePaidCurrencyStatus,
amount = fromTokenAmountValue,
fee = warningsFee,
feeCurrencyBalanceAfterTransaction = null,
)
val (isFeeCoverage, sendingAmount) = getCoverageState(
fromTokenInfo = fromTokenInfo,
userWallet = userWallet,
fee = fee,
currencyCheck = currencyCheck,
)
return SwapState.Transfer(
userWallet = toSwapCurrencyStatus.userWallet,
userWallet = userWallet,
fromTokenInfo = fromTokenInfo,
toTokenInfo = toTokenInfo,
isInsufficientBalance = fromTokenAmountValue > fromTokenBalance,
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
isAccountsMode = isAccountsMode,
isFeeCoverage = isFeeCoverage,
sendingAmount = sendingAmount,
currencyCheck = currencyCheck,
)
}
private suspend fun getCoverageState(
fromTokenInfo: TokenSwapInfo,
userWallet: UserWallet,
fee: Fee?,
currencyCheck: CryptoCurrencyCheck,
): Pair<Boolean, BigDecimal> {
val swapCurrencyStatus = fromTokenInfo.swapCurrencyStatus
val isAmountSubtractAvailable = isAmountSubtractAvailable(
userWalletId = userWallet.walletId,
currency = swapCurrencyStatus.currency,
fee = fee,
)
val balance = swapCurrencyStatus.status.value.amount ?: BigDecimal.ZERO
val reduceAmountBy = currencyCheck.existentialDeposit.orZero()
val amount = fromTokenInfo.tokenAmount
val feeValue = fee?.amount?.value.orZero()
val isFeeCoverage = checkFeeCoverage(
isSubtractAvailable = isAmountSubtractAvailable,
balance = balance,
amountValue = amount.value,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
val sendingAmount = checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = fromTokenInfo.swapCurrencyStatus.status,
amountValue = amount.value,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
return isFeeCoverage to sendingAmount
}
private suspend fun isAmountSubtractAvailable(
userWalletId: UserWalletId,
currency: CryptoCurrency,
fee: Fee?,
): Boolean {
val feeCurrencyId = currency.id
return isAmountSubtractAvailableUseCase(
userWalletId = userWalletId,
currency = currency,
maybeGaslessFee = fee?.let { feeCurrencyId to fee },
).getOrElse { false }
}
private fun createEmptyAmountState(appCurrency: AppCurrency): SwapState.EmptyAmountState {
return SwapState.EmptyAmountState(
zeroAmountEquivalent = stringReference(
@ -168,25 +249,26 @@ class SwapTransferInteractorImpl @Inject constructor(
override suspend fun sendTransfer(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
sendingAmount: BigDecimal,
fee: Fee,
transactionFeeResult: TransactionFeeResult,
): Either<SendTransactionError, String> {
val amount = fromTokenAmount.parseBigDecimalOrNull()?.takeIf { it.signum() > 0 }
?: return SendTransactionError.DataError("Can't parse fromTokenAmount: $fromTokenAmount").left()
val destination = toSwapCurrencyStatus.destinationAddress()
?: return SendTransactionError.DataError("Destination address is null").left()
val destination = toSwapCurrencyStatus.destinationAddress() ?: return getDataError(
message = "Destination address is null",
)
val userWallet = fromSwapCurrencyStatus.userWallet
val currency = fromSwapCurrencyStatus.currency
val txData = createTransferTransactionUseCase(
amount = amount.convertToSdkAmount(cryptoCurrencyStatus = fromSwapCurrencyStatus.status),
amount = sendingAmount.convertToSdkAmount(cryptoCurrencyStatus = fromSwapCurrencyStatus.status),
fee = fee,
memo = null,
destination = destination,
userWalletId = userWallet.walletId,
network = currency.network,
).getOrNull() ?: return SendTransactionError.DataError("Failed to build transfer transaction").left()
).getOrNull() ?: return getDataError(
message = "Failed to build transfer transaction",
)
return sendTransferForFeeType(
userWallet = fromSwapCurrencyStatus.userWallet,
@ -196,6 +278,10 @@ class SwapTransferInteractorImpl @Inject constructor(
)
}
private fun getDataError(message: String): Either<SendTransactionError.DataError, String> {
return SendTransactionError.DataError(message).left()
}
private suspend fun sendTransferForFeeType(
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,

View file

@ -238,7 +238,6 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — has a result entry for the DEX provider; type of state is decided by internal logic

View file

@ -17,6 +17,9 @@ 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.tokens.GetCurrencyCheckUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase
@ -51,6 +54,8 @@ internal class SwapTransferInteractorImplTest {
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk()
private val sendTransactionUseCase: SendTransactionUseCase = mockk()
private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk()
private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk()
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk()
private val sut = SwapTransferInteractorImpl(
swapFeatureToggles = swapFeatureToggles,
@ -62,6 +67,8 @@ internal class SwapTransferInteractorImplTest {
createTransferTransactionUseCase = createTransferTransactionUseCase,
sendTransactionUseCase = sendTransactionUseCase,
createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase,
getCurrencyCheckUseCase = getCurrencyCheckUseCase,
isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase,
)
@AfterEach
@ -90,6 +97,8 @@ internal class SwapTransferInteractorImplTest {
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "abc",
feePaidCurrencyStatus = null,
fee = null,
)
assertThat(result).isInstanceOf(SwapState.EmptyAmountState::class.java)
@ -103,12 +112,13 @@ internal class SwapTransferInteractorImplTest {
fun `GIVEN valid amount WHEN updateTransfer THEN return Transfer state with mirrored from-and-to swap info`() =
runTest {
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
val userWallet: UserWallet = mockk()
val userWallet: UserWallet = mockk(relaxed = true)
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
fiatRate = BigDecimal.TEN,
amount = BigDecimal("1.6"),
userWallet = userWallet,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
@ -118,11 +128,18 @@ internal class SwapTransferInteractorImplTest {
every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right())
every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(true)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true
val currencyCheck = buildCurrencyCheck()
coEvery { getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) } returns currencyCheck
coEvery {
isAmountSubtractAvailableUseCase(any(), any(), any())
} returns false.right()
val result = sut.updateTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1,5",
feePaidCurrencyStatus = null,
fee = null,
)
val expectedAmount = BigDecimal("1.5")
@ -143,6 +160,9 @@ internal class SwapTransferInteractorImplTest {
appCurrency = appCurrency,
isBalanceHidden = true,
isAccountsMode = true,
isFeeCoverage = false,
sendingAmount = expectedAmount,
currencyCheck = currencyCheck,
)
assertThat(result).isEqualTo(expected)
coVerify { isAccountsModeEnabledUseCase.invokeSync() }
@ -152,12 +172,13 @@ internal class SwapTransferInteractorImplTest {
@Test
fun `GIVEN insufficient amount WHEN updateTransfer THEN return state with insufficient amount`() = runTest {
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
val userWallet: UserWallet = mockk()
val userWallet: UserWallet = mockk(relaxed = true)
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
fiatRate = BigDecimal.TEN,
amount = BigDecimal("1.4"),
userWallet = userWallet,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
@ -167,11 +188,18 @@ internal class SwapTransferInteractorImplTest {
every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right())
every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(true)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true
val currencyCheck = buildCurrencyCheck()
coEvery { getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) } returns currencyCheck
coEvery {
isAmountSubtractAvailableUseCase(any(), any(), any())
} returns false.right()
val result = sut.updateTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1,5",
feePaidCurrencyStatus = null,
fee = null,
)
val expectedAmount = BigDecimal("1.5")
@ -192,12 +220,61 @@ internal class SwapTransferInteractorImplTest {
appCurrency = appCurrency,
isBalanceHidden = true,
isAccountsMode = true,
isFeeCoverage = false,
sendingAmount = expectedAmount,
currencyCheck = currencyCheck,
)
assertThat(result).isEqualTo(expected)
coVerify { isAccountsModeEnabledUseCase.invokeSync() }
verify { getBalanceHidingSettingsUseCase.isBalanceHidden() }
}
@Test
fun `GIVEN subtract available and fee fills the gap WHEN updateTransfer THEN isFeeCoverage is true and sendingAmount is reduced by fee`() =
runTest {
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
val userWallet: UserWallet = mockk(relaxed = true)
val balance = BigDecimal("1.5")
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
fiatRate = BigDecimal.TEN,
amount = balance,
userWallet = userWallet,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
userWallet = userWallet,
)
val feeValue = BigDecimal("0.2")
val fee: Fee = mockk(relaxed = true) {
every { amount.value } returns feeValue
}
every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right())
every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
coEvery {
getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any())
} returns buildCurrencyCheck()
coEvery {
isAmountSubtractAvailableUseCase(any(), any(), any())
} returns true.right()
// entered amount = full balance → balance < amount + fee, balance > fee, balance >= amount
// → isFeeCoverage = true, sendingAmount = balance - fee
val result = sut.updateTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = balance.toPlainString(),
feePaidCurrencyStatus = null,
fee = fee,
) as SwapState.Transfer
assertThat(result.isFeeCoverage).isTrue()
assertThat(result.sendingAmount).isEqualTo(balance - feeValue)
}
// endregion
// region loadFee
@ -310,31 +387,6 @@ internal class SwapTransferInteractorImplTest {
// region sendTransfer
@Test
fun `GIVEN unparsable amount WHEN sendTransfer THEN return DataError`() = runTest {
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "abc",
fee = mockk(),
transactionFeeResult = mockk(),
)
assertThat(result).isInstanceOf(arrow.core.Either.Left::class.java)
val error = (result as arrow.core.Either.Left).value
assertThat(error).isInstanceOf(SendTransactionError.DataError::class.java)
}
@Test
fun `GIVEN missing destination WHEN sendTransfer THEN return DataError`() = runTest {
val fromCurrencyStatus = buildCurrencyStatus(
@ -350,7 +402,7 @@ internal class SwapTransferInteractorImplTest {
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1.0",
sendingAmount = BigDecimal("1.0"),
fee = mockk(),
transactionFeeResult = mockk(),
)
@ -394,7 +446,7 @@ internal class SwapTransferInteractorImplTest {
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1.0",
sendingAmount = BigDecimal("1.0"),
fee = fee,
transactionFeeResult = transactionFeeResult,
)
@ -450,7 +502,7 @@ internal class SwapTransferInteractorImplTest {
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1.0",
sendingAmount = BigDecimal("1.0"),
fee = fee,
transactionFeeResult = transactionFeeResult,
)
@ -504,7 +556,7 @@ internal class SwapTransferInteractorImplTest {
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1.0",
sendingAmount = BigDecimal("1.0"),
fee = fee,
transactionFeeResult = transactionFeeResult,
)
@ -549,7 +601,7 @@ internal class SwapTransferInteractorImplTest {
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1.0",
sendingAmount = BigDecimal("1.0"),
fee = fee,
transactionFeeResult = mockk(),
)
@ -679,12 +731,26 @@ internal class SwapTransferInteractorImplTest {
}
}
private fun buildCurrencyCheck(
existentialDeposit: BigDecimal? = null,
dustValue: BigDecimal? = null,
reserveAmount: BigDecimal? = null,
): CryptoCurrencyCheck = CryptoCurrencyCheck(
dustValue = dustValue,
reserveAmount = reserveAmount,
minimumSendAmount = null,
existentialDeposit = existentialDeposit,
utxoAmountLimit = null,
isAccountFunded = true,
rentWarning = null,
)
private fun buildCurrencyStatus(
rawCurrencyId: CryptoCurrency.RawID?,
decimals: Int,
fiatRate: BigDecimal = BigDecimal.ZERO,
amount: BigDecimal = BigDecimal.ZERO,
userWallet: UserWallet = mockk(),
userWallet: UserWallet = mockk(relaxed = true),
destinationAddress: String? = null,
symbol: String = "ETH",
network: Network = mockk(),
@ -714,6 +780,7 @@ internal class SwapTransferInteractorImplTest {
return mockk {
every { this@mockk.currency } returns currency
every { this@mockk.userWallet } returns userWallet
every { this@mockk.userWalletId } answers { userWallet.walletId }
every { this@mockk.status } returns status
}
}
@ -722,7 +789,7 @@ internal class SwapTransferInteractorImplTest {
private fun buildTokenCurrencyStatus(
rawCurrencyId: CryptoCurrency.RawID?,
decimals: Int,
userWallet: UserWallet = mockk(),
userWallet: UserWallet = mockk(relaxed = true),
destinationAddress: String? = null,
symbol: String = "USDT",
network: Network = mockk(),
@ -769,7 +836,6 @@ internal class SwapTransferInteractorImplTest {
const val TX_HASH = "0xabc123"
const val FROM_DECIMALS = 18
const val TO_DECIMALS = 6
val USD_QUOTE: BigDecimal = BigDecimal("2000")
val FROM_RAW_CURRENCY_ID = CryptoCurrency.RawID(value = "eth")
val TO_RAW_CURRENCY_ID = CryptoCurrency.RawID(value = "matic")
}

View file

@ -153,10 +153,15 @@ internal class DefaultSwapComponent @AssistedInject constructor(
val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } }
val shouldHideBlock by remember {
derivedStateOf {
dataState.amount?.parseBigDecimalOrNull().isNullOrZero() ||
model.uiState.isInsufficientFunds ||
dataState.selectedProvider == null ||
dataState.getCurrentLoadedSwapState()?.permissionState !is PermissionDataState.Empty
val isAmountEmptyOrZero = dataState.amount?.parseBigDecimalOrNull().isNullOrZero()
val isInsufficientFunds = model.uiState.isInsufficientFunds
val isProviderMissing = dataState.selectedProvider == null
val loadedState = dataState.getCurrentLoadedSwapState()
val isPermissionNotReady = loadedState?.permissionState !is PermissionDataState.Empty
val isInTransferMode = dataState.currentTransferState != null
val isSwapNotReady = !isInTransferMode && (isProviderMissing || isPermissionNotReady)
isAmountEmptyOrZero || isInsufficientFunds || isSwapNotReady
}
}

View file

@ -10,6 +10,7 @@ import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
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.fromNetworkId
import com.tangem.common.routing.AppRoute
@ -205,6 +206,7 @@ internal class SwapModel @Inject constructor(
)
private val amountDebouncer = Debouncer()
private val transferModeDebouncer = Debouncer()
private val singleTaskScheduler = SingleTaskScheduler<Map<SwapProvider, SwapState>>()
private val performanceTracker = SwapQuotePerformanceTracker()
@ -595,6 +597,7 @@ internal class SwapModel @Inject constructor(
fromTokenAmount = lastAmount.value,
)
if (isUpdatedToTransferMode) return
dataState = dataState.copy(currentTransferState = null)
modelScope.launch {
uiState = stateBuilder.createInitialLoadingState(
uiStateHolder = uiState,
@ -682,13 +685,18 @@ internal class SwapModel @Inject constructor(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
forceUpdate: Boolean = true,
): Boolean {
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
fromSwapCurrencyStatus.currency,
toSwapCurrencyStatus.currency,
)
if (shouldTransferInsteadOfSwap) {
modelScope.launch {
transferModeDebouncer.debounce(
coroutineScope = modelScope,
waitMs = DEBOUNCE_AMOUNT_DELAY,
forceUpdate = forceUpdate,
) {
singleTaskScheduler.destroyTask()
swapPairsJobHolder.cancel()
updateTransferUIState(fromSwapCurrencyStatus, toSwapCurrencyStatus, fromTokenAmount)
@ -702,19 +710,28 @@ internal class SwapModel @Inject constructor(
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
) {
val feePaidCryptoCurrency = dataState.feePaidCryptoCurrency
val selectedFee = getSelectedSwapFee()?.fee
val swapState = swapTransferInteractor.updateTransfer(
fromSwapCurrencyStatus,
toSwapCurrencyStatus,
fromTokenAmount,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = fromTokenAmount,
feePaidCurrencyStatus = feePaidCryptoCurrency,
fee = selectedFee,
)
when (swapState) {
is SwapState.EmptyAmountState -> setupEmptyAmountUiState(swapState, fromSwapCurrencyStatus)
is SwapState.Transfer -> {
dataState = dataState.copy(amount = fromTokenAmount)
dataState = dataState.copy(
amount = fromTokenAmount,
currentTransferState = swapState,
)
uiState = swapTransferStateBuilder.createTransferState(
actions = actions,
transferState = swapState,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = feePaidCryptoCurrency,
fee = selectedFee,
)
feeSelectorRepository.state.value = FeeSelectorUM.Loading
feeSelectorReloadTrigger.triggerUpdate()
@ -723,11 +740,36 @@ internal class SwapModel @Inject constructor(
}
}
private fun refreshTransferUIStateAfterFeeUpdate() {
private fun refreshTransferUIStateAfterFeeUpdateIfNeeded(
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus? = null,
fee: Fee? = null,
) {
val from = dataState.fromSwapCurrencyStatus ?: return
val to = dataState.toSwapCurrencyStatus ?: return
if (!swapTransferInteractor.shouldTransferInsteadOfSwap(from.currency, to.currency)) return
// todo notification check should be triggered (will be implemented in [REDACTED_TASK_KEY])
val currentTransferState = dataState.currentTransferState ?: return
val amount = dataState.amount ?: return
modelScope.launch {
// The cached currentTransferState may have been built when the fee selector
// had not loaded yet (fee=null). Recompute it with the freshly-loaded fee so
// isFeeCoverage and sendingAmount reflect the actual fee, otherwise the fee
// coverage notification stays hidden on first Max click.
val refreshed = swapTransferInteractor.updateTransfer(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
fromTokenAmount = amount,
feePaidCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
) as? SwapState.Transfer ?: currentTransferState
dataState = dataState.copy(currentTransferState = refreshed)
uiState = swapTransferStateBuilder.updateTransferButtonEnableState(
transferState = refreshed,
actions = actions,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
)
}
}
private fun retrySwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) {
@ -1037,6 +1079,7 @@ internal class SwapModel @Inject constructor(
emptyAmountState = state,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
)
dataState = dataState.copy(amount = "0")
}
private fun setupErrorUiState(provider: SwapProvider, state: SwapState.SwapError) {
@ -1243,12 +1286,13 @@ internal class SwapModel @Inject constructor(
showAlert()
return
}
val transferState = dataState.currentTransferState ?: return
uiState = swapTransferStateBuilder.createTransferInProgressState(uiState)
modelScope.launch(dispatchers.main) {
swapTransferInteractor.sendTransfer(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
sendingAmount = transferState.sendingAmount,
fee = fee,
transactionFeeResult = requireNotNull(getSelectedSwapFee()?.transactionFeeResult) {
"It should be not null at this stage"
@ -1256,7 +1300,6 @@ internal class SwapModel @Inject constructor(
).fold(
ifLeft = { error ->
TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}")
refreshTransferUIStateAfterFeeUpdate()
showAlert()
},
ifRight = { txHash ->
@ -1437,6 +1480,7 @@ internal class SwapModel @Inject constructor(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
forceUpdate = forceQuotesUpdate,
)
if (isUpdatedToTransferMode) return@launch
if (toSwapCurrencyStatus.status.value.amount != null) {
@ -2138,21 +2182,22 @@ internal class SwapModel @Inject constructor(
override fun onResult(newState: FeeSelectorUM) {
state.value = newState
val quoteState = dataState.getCurrentLoadedSwapState() ?: return
if (newState is FeeSelectorUM.Error) {
TangemLogger.e("loadFee: ${newState.error}, isHidden = true")
refreshTransferUIStateAfterFeeUpdateIfNeeded()
uiState = stateBuilder.createFeeErrorState(
uiStateHolder = uiState,
quoteModel = quoteState,
quoteModel = dataState.getCurrentLoadedSwapState() ?: return,
feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
feeError = newState.error,
)
modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) }
refreshTransferUIStateAfterFeeUpdate()
return
}
refreshTransferUIStateAfterFeeUpdate()
refreshTransferUIStateAfterFeeUpdateIfNeeded(
feePaidCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
)
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
@ -2163,6 +2208,7 @@ internal class SwapModel @Inject constructor(
)
if (shouldTransferInsteadOfSwap) return
val quoteState = dataState.getCurrentLoadedSwapState() ?: return
val swapFee = getSelectedSwapFee() ?: return
modelScope.launch(dispatchers.default) {

View file

@ -20,6 +20,7 @@ data class SwapProcessDataState(
val selectedPairProviders: List<SwapProvider> = emptyList(),
val selectedProvider: SwapProvider? = null,
val lastLoadedSwapStates: Map<SwapProvider, SwapState> = emptyMap(),
val currentTransferState: SwapState.Transfer? = null,
// Amount from input
val amount: String? = null,

View file

@ -0,0 +1,175 @@
package com.tangem.feature.swap.ui.transfer
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold
import com.tangem.lib.crypto.BlockchainUtils.isTezos
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
import javax.inject.Inject
internal class SwapTransferNotificationsFactory @Inject constructor() {
fun getNotifications(
transferState: SwapState.Transfer,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
onReduceToAmount: (SwapAmount) -> Unit,
): ImmutableList<NotificationUM> {
return buildList {
maybeAddRentExemptionError(transferState)
maybeAddDomainWarnings(
state = transferState,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
fee = fee,
onReduceByAmount = onReduceByAmount,
onReduceToAmount = onReduceToAmount,
)
maybeAddNeedReserveToCreateAccountWarning(transferState)
}.toPersistentList()
}
private fun MutableList<NotificationUM>.maybeAddRentExemptionError(state: SwapState.Transfer) {
state.currencyCheck?.rentWarning?.let {
add(NotificationUM.Solana.RentInfo(it))
}
}
private fun MutableList<NotificationUM>.maybeAddDomainWarnings(
state: SwapState.Transfer,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
onReduceToAmount: (SwapAmount) -> Unit,
) {
val swapCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus
val amount = state.fromTokenInfo.tokenAmount
val balance = swapCurrencyStatus.status.value.amount ?: BigDecimal.ZERO
val feeValue = fee?.amount?.value.orZero()
val isCardano = BlockchainUtils.isCardano(swapCurrencyStatus.currency.network.rawId)
addExistentialWarningNotification(
existentialDeposit = state.currencyCheck?.existentialDeposit,
feeAmount = feeValue,
sendingAmount = amount.value,
cryptoCurrencyStatus = swapCurrencyStatus.status,
onReduceClick = { reduceBy, reduceByDiff, _ ->
onReduceByAmount(
amount.copy(value = amount.value.minus(reduceByDiff)),
reduceBy,
)
},
)
addValidateTransactionNotifications(
dustValue = state.currencyCheck?.dustValue.orZero(),
validationError = state.validationResult,
cryptoCurrency = swapCurrencyStatus.currency,
minAdaValue = state.minAdaValue,
onReduceClick = { reduceTo, _ ->
onReduceToAmount(amount.copy(value = reduceTo))
},
)
if (!isCardano) {
addDustWarningNotification(
dustValue = state.currencyCheck?.dustValue,
feeValue = feeValue,
sendingAmount = amount.value,
cryptoCurrencyStatus = swapCurrencyStatus.status,
feeCurrencyStatus = feeCryptoCurrencyStatus,
)
}
addReserveAmountErrorNotification(
reserveAmount = state.currencyCheck?.reserveAmount,
sendingAmount = amount.value,
cryptoCurrency = swapCurrencyStatus.currency,
feeCryptoCurrency = feeCryptoCurrencyStatus?.currency,
isAccountFunded = true,
)
addReduceAmountNotification(
cryptoCurrencyStatus = swapCurrencyStatus.status,
fromAmount = state.fromTokenInfo.tokenAmount,
balance = balance,
onReduceByAmount = onReduceByAmount,
)
addTransactionLimitErrorNotification(
currencyCheck = state.currencyCheck,
sendingAmount = amount.value,
cryptoCurrencyStatus = swapCurrencyStatus.status,
feeCurrencyStatus = feeCryptoCurrencyStatus,
feeValue = feeValue,
onReduceClick = { reduceTo, _ ->
onReduceToAmount(amount.copy(value = reduceTo))
},
)
maybeAddFeeCoverageNotification(state = state, amount = amount)
}
private fun MutableList<NotificationUM>.maybeAddFeeCoverageNotification(
state: SwapState.Transfer,
amount: SwapAmount,
) {
addFeeCoverageNotification(
isFeeCoverage = state.isFeeCoverage,
enteredAmountValue = amount.value,
sendingValue = state.sendingAmount,
appCurrency = state.appCurrency,
cryptoCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus.status,
)
}
private fun MutableList<NotificationUM>.maybeAddNeedReserveToCreateAccountWarning(state: SwapState.Transfer) {
val status = state.toTokenInfo.swapCurrencyStatus.status.value
if (status is CryptoCurrencyStatus.NoAccount) {
val amount = state.toTokenInfo.tokenAmount.value
val amountToCreateAccount = status.amountToCreateAccount
val currencyTo = state.toTokenInfo.swapCurrencyStatus.currency
if (amount < amountToCreateAccount) {
add(
SwapNotificationUM.Warning.NeedReserveToCreateAccount(
receiveAmount = status.amountToCreateAccount.parseBigDecimal(currencyTo.decimals),
receiveToken = currencyTo.symbol,
),
)
}
}
}
private fun MutableList<NotificationUM>.addReduceAmountNotification(
cryptoCurrencyStatus: CryptoCurrencyStatus,
fromAmount: SwapAmount,
balance: BigDecimal,
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
) {
val isTezos = isTezos(cryptoCurrencyStatus.currency.network.rawId)
val threshold = getTezosThreshold()
val isTotalBalance = fromAmount.value >= balance && balance > threshold
if (isTezos && isTotalBalance) {
add(
SwapNotificationUM.Warning.ReduceAmount(
currencyName = cryptoCurrencyStatus.currency.name,
amount = threshold.toPlainString(),
onConfirmClick = {
val patchedAmount = fromAmount.copy(
value = fromAmount.value - threshold,
)
onReduceByAmount(patchedAmount, threshold)
},
),
)
}
}
}

View file

@ -1,12 +1,13 @@
package com.tangem.feature.swap.ui.transfer
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
@ -24,13 +25,16 @@ import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.model.SwapProcessDataState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.SwapButton.Mode
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.utils.formatToUIRepresentation
import com.tangem.utils.StringsSigns.DASH_SIGN
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
import javax.inject.Inject
internal class SwapTransferStateBuilder @Inject constructor() {
internal class SwapTransferStateBuilder @Inject constructor(
private val notificationsFactory: SwapTransferNotificationsFactory,
) {
private val iconConverter by lazy(::CryptoCurrencyToIconStateConverter)
@ -38,13 +42,24 @@ internal class SwapTransferStateBuilder @Inject constructor() {
actions: UiActions,
transferState: SwapState.Transfer,
uiStateHolder: SwapStateHolder,
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
): SwapStateHolder {
val fromTokenSwapInfo = transferState.fromTokenInfo
val toTokenSwapInfo = transferState.toTokenInfo
val isInsufficientBalance = transferState.isInsufficientBalance
val amountTextFieldValue = (uiStateHolder.sendCardData as? SwapCardState.SwapCardData)?.amountTextFieldValue
val notifications = notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
onReduceByAmount = actions.onReduceByAmount,
onReduceToAmount = actions.onReduceToAmount,
)
return uiStateHolder.copy(
sendCardData = createSendSwapCardState(
actions = actions,
amountTextFieldValue = amountTextFieldValue,
tokenSwapInfo = fromTokenSwapInfo,
appCurrency = transferState.appCurrency,
isAccountsMode = transferState.isAccountsMode,
@ -54,6 +69,7 @@ internal class SwapTransferStateBuilder @Inject constructor() {
),
receiveCardData = createSendSwapCardState(
actions = actions,
amountTextFieldValue = amountTextFieldValue,
tokenSwapInfo = toTokenSwapInfo,
appCurrency = transferState.appCurrency,
isAccountsMode = transferState.isAccountsMode,
@ -69,12 +85,14 @@ internal class SwapTransferStateBuilder @Inject constructor() {
onClick = actions.onTransferClick,
),
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
notifications = notifications,
)
}
@Suppress("LongParameterList")
private fun createSendSwapCardState(
actions: UiActions,
amountTextFieldValue: TextFieldValue?,
tokenSwapInfo: TokenSwapInfo,
appCurrency: AppCurrency,
isAccountsMode: Boolean,
@ -83,7 +101,6 @@ internal class SwapTransferStateBuilder @Inject constructor() {
isInsufficientBalance: Boolean,
): SwapCardState {
val swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus
val formattedSwapAmount = tokenSwapInfo.tokenAmount.formatToUIRepresentation()
return SwapCardState.SwapCardData(
type = createSendTransactionCardType(
@ -101,10 +118,7 @@ internal class SwapTransferStateBuilder @Inject constructor() {
appCurrency = appCurrency,
amount = tokenSwapInfo.amountFiat,
),
amountTextFieldValue = TextFieldValue(
text = formattedSwapAmount,
selection = TextRange(index = formattedSwapAmount.length),
),
amountTextFieldValue = amountTextFieldValue,
balance = swapCurrencyStatus.status.getFormattedAmount(),
isBalanceHidden = isBalanceHidden,
)
@ -190,6 +204,40 @@ internal class SwapTransferStateBuilder @Inject constructor() {
}
}
fun updateTransferButtonEnableState(
transferState: SwapState.Transfer,
actions: UiActions,
uiStateHolder: SwapStateHolder,
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
): SwapStateHolder {
val notifications = notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
onReduceByAmount = actions.onReduceByAmount,
onReduceToAmount = actions.onReduceToAmount,
)
return uiStateHolder.copy(
notifications = notifications,
swapButton = uiStateHolder.swapButton.copy(
isEnabled = getTransferButtonEnabled(notifications, fee),
),
)
}
private fun getTransferButtonEnabled(notifications: ImmutableList<NotificationUM>, fee: Fee?): Boolean {
return fee != null && notifications.none { notification ->
notification is SwapNotificationUM.Error || notification is NotificationUM.Error ||
notification is SwapNotificationUM.Warning.ExpressErrorWarning ||
notification is SwapNotificationUM.Warning.ExpressGeneralError ||
notification is SwapNotificationUM.Warning.NoAvailableTokensToSwap ||
notification is SwapNotificationUM.Warning.SwapNotSupported ||
notification is SwapNotificationUM.Warning.NeedReserveToCreateAccount ||
notification is SwapNotificationUM.Info.PermissionNeeded
}
}
fun createTransferInProgressState(uiState: SwapStateHolder): SwapStateHolder {
return uiState.copy(
swapButton = uiState.swapButton.copy(

View file

@ -0,0 +1,297 @@
package com.tangem.feature.swap.ui.transfer
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.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.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.models.states.SwapNotificationUM
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapTransferNotificationsFactoryTest {
private val sut = SwapTransferNotificationsFactory()
private val userWalletId = UserWalletId(stringValue = "deadbeef")
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
every { walletId } returns userWalletId
}
@Test
fun `GIVEN clean state WHEN getNotifications THEN list is empty`() = runTest {
val transferState = buildTransferState()
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
)
assertThat(result).isEmpty()
}
@Test
fun `GIVEN currencyCheck with rentWarning WHEN getNotifications THEN Solana RentInfo is added`() = runTest {
val rentWarning = CryptoCurrencyWarning.Rent(
rent = BigDecimal("0.01"),
exemptionAmount = BigDecimal("1.0"),
cryptoCurrency = buildCoin(),
)
val transferState = buildTransferState(
currencyCheck = buildCurrencyCheck(rentWarning = rentWarning),
)
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
)
assertThat(result.filterIsInstance<NotificationUM.Solana.RentInfo>()).hasSize(1)
}
@Test
fun `GIVEN existential deposit greater than diff WHEN getNotifications THEN ExistentialDeposit is added`() =
runTest {
val fromStatus = buildCoinStatus(balance = BigDecimal("1.0"))
val transferState = buildTransferState(
fromTokenInfo = buildTokenInfo(
swapCurrencyStatus = fromStatus,
amount = BigDecimal("0.5"),
),
currencyCheck = buildCurrencyCheck(existentialDeposit = BigDecimal("0.5")),
)
val fee: Fee = mockk(relaxed = true) {
every { amount.value } returns BigDecimal("0.4")
}
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = fee,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
)
assertThat(result.filterIsInstance<NotificationUM.Error.ExistentialDeposit>()).hasSize(1)
}
@Test
fun `GIVEN dust limit exceeded for coin WHEN getNotifications THEN MinimumAmountError is added`() = runTest {
val fromStatus = buildCoinStatus(balance = BigDecimal("1.0"))
val transferState = buildTransferState(
fromTokenInfo = buildTokenInfo(
swapCurrencyStatus = fromStatus,
amount = BigDecimal("0.0001"),
),
currencyCheck = buildCurrencyCheck(dustValue = BigDecimal("0.01")),
)
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
)
assertThat(result.filterIsInstance<NotificationUM.Error.MinimumAmountError>()).hasSize(1)
}
@Test
fun `GIVEN minAdaValue and no validationResult WHEN getNotifications THEN MinAdaValueCharged is added`() =
runTest {
val transferState = buildTransferState(
minAdaValue = BigDecimal("1500000"),
)
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
)
assertThat(result.filterIsInstance<NotificationUM.Cardano.MinAdaValueCharged>()).hasSize(1)
}
@Test
fun `GIVEN transferState with isFeeCoverage true WHEN getNotifications THEN FeeCoverage is added`() = runTest {
val fromStatus = buildCoinStatus(balance = BigDecimal("1.5"))
val transferState = buildTransferState(
fromTokenInfo = buildTokenInfo(
swapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
),
isFeeCoverage = true,
sendingAmount = BigDecimal("0.5"),
)
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
)
assertThat(result.filterIsInstance<NotificationUM.Warning.FeeCoverageNotification>()).hasSize(1)
}
@Test
fun `GIVEN toToken has NoAccount status with reserve gap WHEN getNotifications THEN NeedReserveToCreateAccount is added`() =
runTest {
val toStatus = buildNoAccountStatus(amountToCreateAccount = BigDecimal("2.0"))
val transferState = buildTransferState(
toTokenInfo = buildTokenInfo(
swapCurrencyStatus = toStatus,
amount = BigDecimal("0.5"),
),
)
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
)
val reserve = result.filterIsInstance<SwapNotificationUM.Warning.NeedReserveToCreateAccount>()
assertThat(reserve).hasSize(1)
assertThat(reserve.first().receiveToken).isEqualTo(toStatus.currency.symbol)
}
@Test
fun `GIVEN Tezos network with total balance amount WHEN getNotifications THEN ReduceAmount is added`() = runTest {
val fromStatus = buildCoinStatus(rawNetworkId = "tezos", balance = BigDecimal("1.0"))
val transferState = buildTransferState(
fromTokenInfo = buildTokenInfo(
swapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
),
)
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
)
assertThat(result.filterIsInstance<SwapNotificationUM.Warning.ReduceAmount>()).hasSize(1)
}
@Suppress("LongParameterList")
private fun buildTransferState(
fromTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()),
toTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()),
currencyCheck: CryptoCurrencyCheck? = null,
validationResult: Throwable? = null,
minAdaValue: BigDecimal? = null,
isFeeCoverage: Boolean = false,
sendingAmount: BigDecimal = fromTokenInfo.tokenAmount.value,
): SwapState.Transfer = SwapState.Transfer(
userWallet = coldWallet,
fromTokenInfo = fromTokenInfo,
toTokenInfo = toTokenInfo,
isInsufficientBalance = false,
appCurrency = AppCurrency.Default,
isBalanceHidden = false,
isAccountsMode = false,
isFeeCoverage = isFeeCoverage,
sendingAmount = sendingAmount,
currencyCheck = currencyCheck,
validationResult = validationResult,
minAdaValue = minAdaValue,
)
private fun buildTokenInfo(
swapCurrencyStatus: SwapCurrencyStatus,
amount: BigDecimal = BigDecimal("0.1"),
): TokenSwapInfo = TokenSwapInfo(
tokenAmount = SwapAmount(value = amount, decimals = swapCurrencyStatus.currency.decimals),
amountFiat = amount * BigDecimal("2000"),
swapCurrencyStatus = swapCurrencyStatus,
)
private fun buildCurrencyCheck(
existentialDeposit: BigDecimal? = null,
dustValue: BigDecimal? = null,
reserveAmount: BigDecimal? = null,
rentWarning: CryptoCurrencyWarning.Rent? = null,
): CryptoCurrencyCheck = CryptoCurrencyCheck(
dustValue = dustValue,
reserveAmount = reserveAmount,
minimumSendAmount = null,
existentialDeposit = existentialDeposit,
utxoAmountLimit = null,
isAccountFunded = true,
rentWarning = rentWarning,
)
private fun buildCoinStatus(
rawNetworkId: String = "ethereum",
balance: BigDecimal = BigDecimal("1.0"),
fiatRate: BigDecimal = BigDecimal("2000"),
): SwapCurrencyStatus {
val coin = buildCoin(rawNetworkId = rawNetworkId)
val statusValue: CryptoCurrencyStatus.Loaded = mockk(relaxed = true) {
every { amount } returns balance
every { this@mockk.fiatRate } returns fiatRate
every { fiatAmount } returns balance.multiply(fiatRate)
}
return SwapCurrencyStatus(
userWallet = coldWallet,
status = CryptoCurrencyStatus(currency = coin, value = statusValue),
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
)
}
private fun buildNoAccountStatus(amountToCreateAccount: BigDecimal): SwapCurrencyStatus {
val coin = buildCoin()
val statusValue: CryptoCurrencyStatus.NoAccount = mockk(relaxed = true) {
every { this@mockk.amountToCreateAccount } returns amountToCreateAccount
}
return SwapCurrencyStatus(
userWallet = coldWallet,
status = CryptoCurrencyStatus(currency = coin, value = statusValue),
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
)
}
private fun buildCoin(rawNetworkId: String = "ethereum"): CryptoCurrency.Coin {
return mockk(relaxed = true) {
every { id } returns mockk(relaxed = true)
every { network } returns mockk(relaxed = true) {
every { rawId } returns rawNetworkId
every { name } returns "Test Network"
}
every { name } returns "Test Coin"
every { symbol } returns "TST"
every { decimals } returns 18
}
}
}

View file

@ -3,19 +3,20 @@ package com.tangem.feature.swap.ui.transfer
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
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.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.feature.swap.buildSwapCurrencyStatus
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.PriceImpact
@ -25,9 +26,12 @@ import com.tangem.feature.swap.model.SwapProcessDataState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.utils.formatToUIRepresentation
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@ -36,7 +40,18 @@ import java.math.BigDecimal
internal class SwapTransferStateBuilderTest {
private val actions: UiActions = mockk(relaxed = true)
private val sut = SwapTransferStateBuilder()
private val notificationsFactory: SwapTransferNotificationsFactory = mockk(relaxed = true) {
coEvery {
getNotifications(
transferState = any(),
feeCryptoCurrencyStatus = any(),
fee = any(),
onReduceByAmount = any(),
onReduceToAmount = any(),
)
} returns persistentListOf()
}
private val sut = SwapTransferStateBuilder(notificationsFactory = notificationsFactory)
private val userWalletId = UserWalletId(stringValue = "deadbeef")
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
@ -47,121 +62,193 @@ internal class SwapTransferStateBuilderTest {
private val iconConverter = CryptoCurrencyToIconStateConverter()
private val fromIcon = iconConverter.convert(fromCurrencyStatus.status)
private val toIcon = iconConverter.convert(toCurrencyStatus.status)
private val initialAmountTextFieldValue = TextFieldValue(
text = "0.5",
selection = TextRange(index = 3),
)
@Test
fun `GIVEN accounts mode enabled WHEN createTransferState THEN cards expose Account titles for from and to`() {
val transferState = buildTransferState(
fromAmount = BigDecimal("1.5"),
toAmount = BigDecimal("1.5"),
isAccountsMode = true,
)
fun `GIVEN accounts mode enabled WHEN createTransferState THEN cards expose Account titles for from and to`() =
runTest {
val transferState = buildTransferState(
fromAmount = BigDecimal("1.5"),
toAmount = BigDecimal("1.5"),
isAccountsMode = true,
)
val uiState = baseStateHolder()
val result = sut.createTransferState(actions, transferState, baseStateHolder())
val result = sut.createTransferState(
actions = actions,
transferState = transferState,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
)
val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio
val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
val expectedAccountName = portfolioAccount.accountName.toUM().value
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_from_account_title),
name = expectedAccountName,
icon = expectedAccountIcon,
),
)
assertThat(receiveType.accountTitleUM).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_to_account_title),
name = expectedAccountName,
icon = expectedAccountIcon,
),
)
assertSharedCardShape(
result = result,
transferState = transferState,
)
}
val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio
val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
val expectedAccountName = portfolioAccount.accountName.toUM().value
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_from_account_title),
name = expectedAccountName,
icon = expectedAccountIcon,
),
)
assertThat(receiveType.accountTitleUM).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_to_account_title),
name = expectedAccountName,
icon = expectedAccountIcon,
),
)
assertSharedCardShape(
result = result,
transferState = transferState,
)
coVerify(exactly = 1) {
notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
)
}
}
@Test
fun `GIVEN accounts mode disabled WHEN createTransferState THEN cards fall back to Text titles for from and to`() {
val transferState = buildTransferState(
fromAmount = BigDecimal("2"),
toAmount = BigDecimal("2"),
isAccountsMode = false,
)
fun `GIVEN accounts mode disabled WHEN createTransferState THEN cards fall back to Text titles for from and to`() =
runTest {
val transferState = buildTransferState(
fromAmount = BigDecimal("2"),
toAmount = BigDecimal("2"),
isAccountsMode = false,
)
val uiState = baseStateHolder()
val result = sut.createTransferState(actions, transferState, baseStateHolder())
val result = sut.createTransferState(
actions = actions,
transferState = transferState,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
)
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)),
)
assertThat(receiveType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)),
)
assertSharedCardShape(
result = result,
transferState = transferState,
)
}
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)),
)
assertThat(receiveType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)),
)
assertSharedCardShape(
result = result,
transferState = transferState,
)
coVerify(exactly = 1) {
notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
)
}
}
@Test
fun `GIVEN insufficient balance and accounts mode disabled WHEN createTransferState THEN from card shows insufficient funds title and error and swap is disabled`() {
val transferState = buildTransferState(
fromAmount = BigDecimal("10"),
toAmount = BigDecimal("10"),
isAccountsMode = false,
isInsufficientBalance = true,
)
fun `GIVEN insufficient balance and accounts mode disabled WHEN createTransferState THEN from card shows insufficient funds title and error and swap is disabled`() =
runTest {
val transferState = buildTransferState(
fromAmount = BigDecimal("10"),
toAmount = BigDecimal("10"),
isAccountsMode = false,
isInsufficientBalance = true,
)
val uiState = baseStateHolder()
val result = sut.createTransferState(actions, transferState, baseStateHolder())
val result = sut.createTransferState(
actions = actions,
transferState = transferState,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
)
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)),
)
assertThat(sendType.inputError).isEqualTo(TransactionCardType.InputError.InsufficientFunds)
assertThat(receiveType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)),
)
assertThat(result.isInsufficientFunds).isTrue()
assertThat(result.swapButton.isEnabled).isFalse()
assertThat(result.swapButton.mode).isEqualTo(SwapButton.Mode.TRANSFER)
}
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)),
)
assertThat(sendType.inputError).isEqualTo(TransactionCardType.InputError.InsufficientFunds)
assertThat(receiveType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)),
)
assertThat(result.isInsufficientFunds).isTrue()
assertThat(result.swapButton.isEnabled).isFalse()
assertThat(result.swapButton.mode).isEqualTo(SwapButton.Mode.TRANSFER)
coVerify(exactly = 1) {
notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
)
}
}
@Test
fun `GIVEN insufficient balance and accounts mode enabled WHEN createTransferState THEN from card overrides Account title with insufficient funds text`() {
val transferState = buildTransferState(
fromAmount = BigDecimal("10"),
toAmount = BigDecimal("10"),
isAccountsMode = true,
isInsufficientBalance = true,
)
fun `GIVEN insufficient balance and accounts mode enabled WHEN createTransferState THEN from card overrides Account title with insufficient funds text`() =
runTest {
val transferState = buildTransferState(
fromAmount = BigDecimal("10"),
toAmount = BigDecimal("10"),
isAccountsMode = true,
isInsufficientBalance = true,
)
val uiState = baseStateHolder()
val result = sut.createTransferState(actions, transferState, baseStateHolder())
val result = sut.createTransferState(
actions = actions,
transferState = transferState,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
)
val portfolioAccount = toCurrencyStatus.account as Account.CryptoPortfolio
val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
val expectedAccountName = portfolioAccount.accountName.toUM().value
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)),
)
assertThat(sendType.inputError).isEqualTo(TransactionCardType.InputError.InsufficientFunds)
assertThat(receiveType.accountTitleUM).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_to_account_title),
name = expectedAccountName,
icon = expectedAccountIcon,
),
)
assertThat(result.isInsufficientFunds).isTrue()
assertThat(result.swapButton.isEnabled).isFalse()
}
val portfolioAccount = toCurrencyStatus.account as Account.CryptoPortfolio
val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
val expectedAccountName = portfolioAccount.accountName.toUM().value
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)),
)
assertThat(sendType.inputError).isEqualTo(TransactionCardType.InputError.InsufficientFunds)
assertThat(receiveType.accountTitleUM).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_to_account_title),
name = expectedAccountName,
icon = expectedAccountIcon,
),
)
assertThat(result.isInsufficientFunds).isTrue()
assertThat(result.swapButton.isEnabled).isFalse()
coVerify(exactly = 1) {
notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
)
}
}
@Test
fun `GIVEN content uiState WHEN createTransferInProgressState THEN swap button is disabled in TRANSFER_PROGRESSING mode`() {
@ -181,6 +268,55 @@ internal class SwapTransferStateBuilderTest {
assertThat(result.swapButton.onClick).isEqualTo(initialButton.onClick)
}
@Test
fun `GIVEN no blocking notifications and non-null fee WHEN updateTransferButtonEnableState THEN swap button becomes enabled`() =
runTest {
val transferState = buildTransferState(
fromAmount = BigDecimal("1"),
toAmount = BigDecimal("1"),
isAccountsMode = false,
)
val fee: Fee = mockk(relaxed = true)
val uiState = baseStateHolder().copy(
swapButton = SwapButton(
walletInteractionIcon = null,
isEnabled = false,
mode = SwapButton.Mode.TRANSFER,
onClick = {},
),
)
coEvery {
notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = fee,
onReduceByAmount = any(),
onReduceToAmount = any(),
)
} returns persistentListOf()
val result = sut.updateTransferButtonEnableState(
transferState = transferState,
actions = actions,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = fee,
)
assertThat(result.swapButton.isEnabled).isTrue()
assertThat(result.swapButton.mode).isEqualTo(SwapButton.Mode.TRANSFER)
assertThat(result.notifications).isEmpty()
coVerify(exactly = 1) {
notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = fee,
onReduceByAmount = any(),
onReduceToAmount = any(),
)
}
}
@Test
fun `GIVEN dataState with from-to currencies WHEN createSuccessState THEN success holder is built in transfer mode with given fee and txUrl`() {
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
@ -242,14 +378,8 @@ internal class SwapTransferStateBuilderTest {
) {
val sendCard = result.sendCardData as SwapCardState.SwapCardData
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
val expectedFromText = transferState.fromTokenInfo.tokenAmount.formatToUIRepresentation()
val expectedToText = transferState.toTokenInfo.tokenAmount.formatToUIRepresentation()
assertThat(sendCard.amountTextFieldValue).isEqualTo(
TextFieldValue(text = expectedFromText, selection = TextRange(index = expectedFromText.length)),
)
assertThat(receiveCard.amountTextFieldValue).isEqualTo(
TextFieldValue(text = expectedToText, selection = TextRange(index = expectedToText.length)),
)
assertThat(sendCard.amountTextFieldValue).isEqualTo(initialAmountTextFieldValue)
assertThat(receiveCard.amountTextFieldValue).isEqualTo(initialAmountTextFieldValue)
assertThat(sendCard.currencyIconState).isEqualTo(fromIcon)
assertThat(receiveCard.currencyIconState).isEqualTo(toIcon)
assertThat(sendCard.isBalanceHidden).isEqualTo(transferState.isBalanceHidden)
@ -290,14 +420,26 @@ internal class SwapTransferStateBuilderTest {
appCurrency = AppCurrency.Default,
isBalanceHidden = false,
isAccountsMode = isAccountsMode,
isFeeCoverage = false,
sendingAmount = fromAmount,
)
}
private fun baseStateHolder(): SwapStateHolder = SwapStateHolder(
sendCardData = SwapCardState.Loading(
type = TransactionCardType.ReadOnly(
sendCardData = SwapCardState.SwapCardData(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)),
isEnabled = true,
),
currencyIconState = fromIcon,
tokenSymbol = stringReference(""),
amountEquivalent = TextReference.EMPTY,
amountTextFieldValue = initialAmountTextFieldValue,
balance = "",
isBalanceHidden = false,
),
receiveCardData = SwapCardState.Loading(
type = TransactionCardType.ReadOnly(