diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index 95d56c4314..9712084d11 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -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 **/ diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt index 4d915395ad..d4c16b5b71 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt @@ -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 diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index dd33112779..861066f760 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -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( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt index bd53ada8ec..68ceccef30 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt @@ -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 diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index 9b26409e31..00ce438d27 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -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 { + 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 { - 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 { + return SendTransactionError.DataError(message).left() + } + private suspend fun sendTransferForFeeType( userWallet: UserWallet, cryptoCurrencyStatus: CryptoCurrencyStatus, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt index f2f4017539..0c0243150f 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt @@ -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 diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index 5252bd405d..c49a417619 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -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") } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 52c1cd0ccd..3e10710467 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -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 } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 216c311590..36d51df8be 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -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>() 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) { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt index 84f8f76b2d..44368feeef 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt @@ -20,6 +20,7 @@ data class SwapProcessDataState( val selectedPairProviders: List = emptyList(), val selectedProvider: SwapProvider? = null, val lastLoadedSwapStates: Map = emptyMap(), + val currentTransferState: SwapState.Transfer? = null, // Amount from input val amount: String? = null, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt new file mode 100644 index 0000000000..7468e1af03 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt @@ -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 { + return buildList { + maybeAddRentExemptionError(transferState) + maybeAddDomainWarnings( + state = transferState, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fee = fee, + onReduceByAmount = onReduceByAmount, + onReduceToAmount = onReduceToAmount, + ) + maybeAddNeedReserveToCreateAccountWarning(transferState) + }.toPersistentList() + } + + private fun MutableList.maybeAddRentExemptionError(state: SwapState.Transfer) { + state.currencyCheck?.rentWarning?.let { + add(NotificationUM.Solana.RentInfo(it)) + } + } + + private fun MutableList.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.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.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.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) + }, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index 5665834d36..451601f4d6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -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, 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( diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt new file mode 100644 index 0000000000..ab218123aa --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt @@ -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()).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()).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()).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()).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()).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() + 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()).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 + } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt index c9165e2f2a..14bcd8fdea 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -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(