diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt index b6f073b245..757011d676 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt @@ -323,7 +323,9 @@ class DetailsMiddleware { enableAccessCodesSaving: Boolean, ): CompletionResult { val userWallet = scanResponse?.let { UserWalletBuilder(it).build() } - ?: return CompletionResult.Failure(TangemSdkError.ExceptionError(IllegalStateException())) + ?: return CompletionResult.Failure( + TangemSdkError.ExceptionError(IllegalStateException("scanResponse is null")), + ) return userWalletsListManager.save(userWallet) .flatMap { diff --git a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt index b0889ba759..b45c36ecb9 100644 --- a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt +++ b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt @@ -32,10 +32,8 @@ class TransactionManagerImpl( private val appStateHolder: AppStateHolder, ) : TransactionManager { - override suspend fun sendTransaction( + override suspend fun sendApproveTransaction( networkId: String, - amountToSend: BigDecimal, - currencyToSend: Currency, feeAmount: BigDecimal, estimatedGas: Int, destinationAddress: String, @@ -43,8 +41,58 @@ class TransactionManagerImpl( ): SendTxResult { val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } val walletManager = getActualWalletManager(blockchain) - val amount = createAmount(amountToSend, currencyToSend, blockchain) + walletManager.update() + val amount = Amount(value = BigDecimal.ZERO, blockchain = blockchain) + return sendTransactionInternal( + walletManager = walletManager, + amount = amount, + blockchain = blockchain, + feeAmount = feeAmount, + estimatedGas = estimatedGas, + destinationAddress = destinationAddress, + dataToSign = dataToSign, + ) + } + override suspend fun sendTransaction( + networkId: String, + amountToSend: BigDecimal, + feeAmount: BigDecimal, + estimatedGas: Int, + destinationAddress: String, + dataToSign: String, + isSwap: Boolean, + currencyToSend: Currency, + ): SendTxResult { + val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } + val walletManager = getActualWalletManager(blockchain) + walletManager.update() + val amount = if (isSwap) { + createAmountForSwap(amountToSend, currencyToSend, blockchain) + } else { + createAmount(amountToSend, currencyToSend, blockchain) + } + return sendTransactionInternal( + walletManager = walletManager, + amount = amount, + blockchain = blockchain, + feeAmount = feeAmount, + estimatedGas = estimatedGas, + destinationAddress = destinationAddress, + dataToSign = dataToSign, + ) + } + + @Suppress("LongParameterList") + private suspend fun sendTransactionInternal( + walletManager: WalletManager, + amount: Amount, + blockchain: Blockchain, + feeAmount: BigDecimal, + estimatedGas: Int, + destinationAddress: String, + dataToSign: String, + ): SendTxResult { val txData = walletManager.createTransaction( amount = amount, fee = Amount(value = feeAmount, blockchain = blockchain), @@ -66,6 +114,11 @@ class TransactionManagerImpl( return Blockchain.fromNetworkId(networkId)?.decimals() ?: error("blockchain not found") } + override suspend fun updateWalletManager(networkId: String) { + val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } + getActualWalletManager(blockchain).update() + } + override fun calculateFee(networkId: String, gasPrice: String, estimatedGas: Int): BigDecimal { val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } val gasPriceValue = requireNotNull(gasPrice.toLongOrNull()) { "gasprice should be Long" } @@ -119,7 +172,7 @@ class TransactionManagerImpl( return TangemSigner( card = actualCard, tangemSdk = tangemSdk, - initialMessage = Message("test transaction title", "test description"), + initialMessage = Message(), ) { signResponse -> store.dispatch( GlobalAction.UpdateWalletSignedHashes( @@ -179,6 +232,29 @@ class TransactionManagerImpl( } } + private fun createAmountForSwap( + amount: BigDecimal, + currency: Currency?, + blockchain: Blockchain, + ): Amount { + return when (currency) { + is Currency.NativeToken, + null, + -> { + Amount(value = amount, blockchain = blockchain) + } + is Currency.NonNativeToken -> { + // 1. when creates swap amount for NonNativeToken, amount should be ZERO + // 2. Amount has .Coin type, as workaround to use destinationAddress in bsdk, not contractAddress + Amount( + currencySymbol = currency.symbol, + value = BigDecimal.ZERO, + decimals = currency.decimalCount, + ) + } + } + } + private fun convertNonNativeToken(token: Currency.NonNativeToken): Token { return Token( name = token.name, diff --git a/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt b/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt index 77eb280ccc..a44ccfc2de 100644 --- a/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt +++ b/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt @@ -1,5 +1,6 @@ package com.tangem.tap.proxy +import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.DerivationParams import com.tangem.blockchain.common.DerivationStyle @@ -119,6 +120,25 @@ class UserWalletManagerImpl( }.toMap() } + override fun getNativeTokenBalance(networkId: String): ProxyAmount? { + val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } + val walletManager = getActualWalletManager(blockchain) + return walletManager.wallet.amounts.firstNotNullOfOrNull { + it.takeIf { it.key is AmountType.Coin } + }?.value?.let { + ProxyAmount( + it.currencySymbol, + it.value ?: BigDecimal.ZERO, + it.decimals, + ) + } + } + + override fun getNetworkCurrency(networkId: String): String { + val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } + return blockchain.currency + } + override fun getUserAppCurrency(): ProxyFiatCurrency { val appCurrency = appStateHolder.appFiatCurrency return ProxyFiatCurrency( diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/OneInchApisModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/OneInchApisModule.kt index fa6cf8a14d..379f312d25 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/OneInchApisModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/OneInchApisModule.kt @@ -24,6 +24,14 @@ class OneInchApisModule { apiFactory.putApi(ETH_NETWORK, createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_ETH_PATH, moshi)) apiFactory.putApi(BSC_NETWORK, createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_BSC_PATH, moshi)) apiFactory.putApi(POLYGON_NETWORK, createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_POLYGON_PATH, moshi)) + apiFactory.putApi(OPTIMISM_NETWORK, createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_OPTIMISM_PATH, moshi)) + apiFactory.putApi(ARBITRUM_NETWORK, createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_ARBITRUM_PATH, moshi)) + apiFactory.putApi(GNOSIS_NETWORK, createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_GNOSIS_PATH, moshi)) + apiFactory.putApi( + AVALANCHE_NETWORK, + createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_AVALANCHE_PATH, moshi), + ) + apiFactory.putApi(FANTOM_NETWORK, createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_FANTOM_PATH, moshi)) return apiFactory } @@ -47,9 +55,19 @@ class OneInchApisModule { private const val ONE_INCH_ETH_PATH = "1/" private const val ONE_INCH_BSC_PATH = "56/" private const val ONE_INCH_POLYGON_PATH = "137/" + private const val ONE_INCH_OPTIMISM_PATH = "10/" + private const val ONE_INCH_ARBITRUM_PATH = "42161/" + private const val ONE_INCH_GNOSIS_PATH = "100/" + private const val ONE_INCH_AVALANCHE_PATH = "43114/" + private const val ONE_INCH_FANTOM_PATH = "250/" private const val ETH_NETWORK = "ethereum" private const val BSC_NETWORK = "binance-smart-chain" private const val POLYGON_NETWORK = "polygon-pos" + private const val OPTIMISM_NETWORK = "optimistic-ethereum" + private const val ARBITRUM_NETWORK = "arbitrum-one" + private const val GNOSIS_NETWORK = "xdai" + private const val AVALANCHE_NETWORK = "avalanche" + private const val FANTOM_NETWORK = "fantom" } } \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/FormatExtensions.kt b/core/utils/src/main/java/com/tangem/utils/FormatExtensions.kt index fef3fe945a..1e96e28844 100644 --- a/core/utils/src/main/java/com/tangem/utils/FormatExtensions.kt +++ b/core/utils/src/main/java/com/tangem/utils/FormatExtensions.kt @@ -23,6 +23,25 @@ fun BigDecimal.toFormattedString( return df.format(this) } +@Suppress("MagicNumber") +fun BigDecimal.toFormattedCurrencyString( + decimals: Int, + currency: String, + roundingMode: RoundingMode = RoundingMode.DOWN, + limitNumberOfDecimals: Boolean = true, +): String { + val decimalsForRounding = if (limitNumberOfDecimals) { + if (decimals > 8) 8 else decimals + } else { + decimals + } + val formattedAmount = this.toFormattedString( + decimals = decimalsForRounding, + roundingMode = roundingMode, + ) + return "$formattedAmount $currency" +} + fun BigDecimal.toFiatString( rateValue: BigDecimal, fiatCurrencyName: String, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index edef9f475c..31feba33de 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -1,37 +1,94 @@ package com.tangem.feature.swap.domain -import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.cache.ExchangeCurrencies +import com.tangem.feature.swap.domain.models.domain.ApproveModel import com.tangem.feature.swap.domain.models.domain.Currency +import com.tangem.feature.swap.domain.models.domain.SwapDataModel import com.tangem.feature.swap.domain.models.ui.FoundTokensState import com.tangem.feature.swap.domain.models.ui.SwapState import com.tangem.feature.swap.domain.models.ui.TokensDataState +import com.tangem.feature.swap.domain.models.ui.TxState interface SwapInteractor { + /** + * Init tokens to swap, load tokens list available to swap for given network + * + * @param initialCurrency currency which to swap or receive + * @return [TokensDataState] that contains info about all available to swap tokens for networkId + * and preselected tokens which initially select to swap + */ suspend fun initTokensToSwap(initialCurrency: Currency): TokensDataState - suspend fun onSearchToken(searchQuery: String): FoundTokensState - - fun getExchangeCurrencies(): ExchangeCurrencies? + /** + * On search token, locally search tokens in previously loaded list to swap + * searching in names and symbols + * + * @param networkId networkId for tokens + * @param searchQuery string query for search + * @return [FoundTokensState] that contains list of tokens matching condition query + */ + suspend fun onSearchToken(networkId: String, searchQuery: String): FoundTokensState + /** + * Find specific token by id, null if not found + * + * @param id token id + * @return [Currency] or null + */ fun findTokenById(id: String): Currency? /** - * Give permission to swap + * Gives permission to swap, this starts scan card process + * + * @param networkId network in which selected token + * @param estimatedGas estimated gas for transaction + * @param transactionData tx data to give approve, it loaded from 1inch in findBestQuote if needed + * @param forTokenContractAddress token contract address for which needs permission */ @Throws(IllegalStateException::class) - suspend fun givePermissionToSwap() + suspend fun givePermissionToSwap( + networkId: String, + estimatedGas: Int, + transactionData: ApproveModel, + forTokenContractAddress: String, + ): TxState + /** + * Find best quote for given tokens to swap + * under the hood calls different methods to receive data, depends on permission for given token + * + * @param networkId network for tokens + * @param fromToken [Currency] from which want to swap + * @param toToken [Currency] that receive after swap + * @param amountToSwap amount you want to swap + * @return + */ @Throws(IllegalStateException::class) suspend fun findBestQuote( + networkId: String, fromToken: Currency, toToken: Currency, - amount: SwapAmount, + amountToSwap: String, ): SwapState + /** + * Starts swap transaction, perform sign transaction + * + * @param networkId network for tokens + * @param swapData tx data to swap, contains data to sign + * @param currencyToSend [Currency] + * @param currencyToGet [Currency] + * @param amountToSwap amount to swap + * @return [TxState] + */ @Throws(IllegalStateException::class) - suspend fun onSwap(): SwapState + suspend fun onSwap( + networkId: String, + swapData: SwapDataModel, + currencyToSend: Currency, + currencyToGet: Currency, + amountToSwap: String, + ): TxState fun getTokenDecimals(token: Currency): Int } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 11c1e149ed..7ddef7045b 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -4,24 +4,28 @@ import com.tangem.feature.swap.domain.cache.SwapDataCache import com.tangem.feature.swap.domain.converters.CryptoCurrencyConverter import com.tangem.feature.swap.domain.models.DataError import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.cache.ExchangeCurrencies import com.tangem.feature.swap.domain.models.domain.ApproveModel import com.tangem.feature.swap.domain.models.domain.Currency +import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState +import com.tangem.feature.swap.domain.models.domain.SwapDataModel import com.tangem.feature.swap.domain.models.toStringWithRightOffset +import com.tangem.feature.swap.domain.models.ui.AmountFormatter import com.tangem.feature.swap.domain.models.ui.FoundTokensState import com.tangem.feature.swap.domain.models.ui.PermissionDataState import com.tangem.feature.swap.domain.models.ui.PreselectTokens +import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData import com.tangem.feature.swap.domain.models.ui.SwapState import com.tangem.feature.swap.domain.models.ui.TokenBalanceData +import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo import com.tangem.feature.swap.domain.models.ui.TokenWithBalance import com.tangem.feature.swap.domain.models.ui.TokensDataState +import com.tangem.feature.swap.domain.models.ui.TxState import com.tangem.lib.crypto.TransactionManager import com.tangem.lib.crypto.UserWalletManager import com.tangem.lib.crypto.models.ProxyAmount import com.tangem.lib.crypto.models.ProxyFiatCurrency import com.tangem.lib.crypto.models.transactions.SendTxResult import com.tangem.utils.toFiatString -import com.tangem.utils.toFormattedString import java.math.BigDecimal import javax.inject.Inject @@ -35,10 +39,10 @@ internal class SwapInteractorImpl @Inject constructor( ) : SwapInteractor { private val cryptoCurrencyConverter = CryptoCurrencyConverter() + private val amountFormatter = AmountFormatter() override suspend fun initTokensToSwap(initialCurrency: Currency): TokensDataState { val networkId = initialCurrency.networkId - cache.cacheNetworkId(networkId) val availableTokens = cache.getAvailableTokens(networkId) val allLoadedTokens = availableTokens.ifEmpty { val tokens = repository.getExchangeableTokens(networkId) @@ -77,8 +81,7 @@ internal class SwapInteractorImpl @Inject constructor( ) } - override suspend fun onSearchToken(searchQuery: String): FoundTokensState { - val networkId = requireNotNull(cache.getNetworkId()) { "networkId is null" } + override suspend fun onSearchToken(networkId: String, searchQuery: String): FoundTokensState { val searchQueryLowerCase = searchQuery.lowercase() val tokensInWallet = cache.getInWalletTokens() .filter { @@ -99,67 +102,67 @@ internal class SwapInteractorImpl @Inject constructor( ) } - override fun getExchangeCurrencies(): ExchangeCurrencies? { - return cache.getExchangeCurrencies() - } - override fun findTokenById(id: String): Currency? { val tokensInWallet = cache.getInWalletTokens() val loadedTokens = cache.getLoadedTokens() return tokensInWallet.firstOrNull { it.id == id } ?: loadedTokens.firstOrNull { it.id == id } } - override suspend fun givePermissionToSwap() { - cache.getNetworkId()?.let { networkId -> - val currencyToSend = - requireNotNull(cache.getExchangeCurrencies()?.fromCurrency) { "currency is not selected" } - if (currencyToSend is Currency.NonNativeToken) { - val estimatedGas = - increaseByPercents( - TWENTY_FIVE_PERCENTS, - requireNotNull(cache.getLastQuote()?.estimatedGas) { - "estimatedGas not found call findBestQuote" - }, - ) - val transactionData = requireNotNull(cache.getApproveTransactionData()) { - "getApproveTransactionData not found, call findQuotes" - } - val gasPrice = transactionData.gasPrice.toBigDecimalOrNull() ?: error("cannot parse gasPrice") - val fee = transactionManager.calculateFee(networkId, gasPrice.toPlainString(), estimatedGas) - val result = transactionManager.sendTransaction( - networkId = networkId, - amountToSend = BigDecimal.ZERO, - currencyToSend = cryptoCurrencyConverter.convert(currencyToSend), - feeAmount = fee, - estimatedGas = estimatedGas, - destinationAddress = transactionData.toAddress, - dataToSign = transactionData.data, - ) - when (result) { - SendTxResult.Success -> { - allowPermissionsHandler.addAddressToInProgress(currencyToSend.contractAddress) - } - SendTxResult.UserCancelledError -> TODO() - is SendTxResult.BlockchainSdkError -> TODO() - is SendTxResult.TangemSdkError -> TODO() - is SendTxResult.UnknownError -> TODO() - } + override suspend fun givePermissionToSwap( + networkId: String, + estimatedGas: Int, + transactionData: ApproveModel, + forTokenContractAddress: String, + ): TxState { + val increasedEstimatedGas = increaseByPercents(TWENTY_FIVE_PERCENTS, estimatedGas) + val gasPrice = transactionData.gasPrice.toBigDecimalOrNull() ?: error("cannot parse gasPrice") + val fee = transactionManager.calculateFee(networkId, gasPrice.toPlainString(), increasedEstimatedGas) + val result = transactionManager.sendApproveTransaction( + networkId = networkId, + feeAmount = fee, + estimatedGas = increasedEstimatedGas, + destinationAddress = transactionData.toAddress, + dataToSign = transactionData.data, + ) + return when (result) { + SendTxResult.Success -> { + allowPermissionsHandler.addAddressToInProgress(forTokenContractAddress) + TxState.TxSent() } + SendTxResult.UserCancelledError -> TxState.UserCancelled + is SendTxResult.BlockchainSdkError -> TxState.BlockchainError + is SendTxResult.TangemSdkError -> TxState.TangemSdkError + is SendTxResult.UnknownError -> TxState.UnknownError } } - override suspend fun findBestQuote(fromToken: Currency, toToken: Currency, amount: SwapAmount): SwapState { - val networkId = requireNotNull(cache.getNetworkId()) { "no networkId found, please call initTokens first" } + override suspend fun findBestQuote( + networkId: String, + fromToken: Currency, + toToken: Currency, + amountToSwap: String, + ): SwapState { + val amountDecimal = amountToSwap.toBigDecimalOrNull() + if (amountDecimal == null || amountDecimal.compareTo(BigDecimal.ZERO) == 0) { + return createEmptyAmountState(networkId, fromToken, toToken) + } + val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken)) val fromTokenAddress = getTokenAddress(fromToken) val toTokenAddress = getTokenAddress(toToken) val isAllowedToSpend = checkAllowance(networkId, fromTokenAddress) - val isNotZeroBalance = isNotZeroBalance(fromToken, networkId) - cache.cacheExchangeCurrencies(fromToken, toToken) - cache.cacheAmountToSwap(amount) - return if (isAllowedToSpend && isNotZeroBalance) { - if (allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) { - allowPermissionsHandler.removeAddressFromProgress(toTokenAddress) - } + val fee = getAndUpdateFee(networkId, fromToken) + val isBalanceEnough = isBalanceEnough(fromToken, networkId, amount, fee) + val isFeeEnough = checkFeeIsEnough(fee, amount, networkId) + if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) { + allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress) + transactionManager.updateWalletManager(networkId) + } + val preparedSwapConfigState = PreparedSwapConfigState( + isAllowedToSpend = isAllowedToSpend, + isBalanceEnough = isBalanceEnough, + isFeeEnough = isFeeEnough, + ) + return if (isAllowedToSpend && isBalanceEnough && isFeeEnough) { loadSwapData( networkId = networkId, fromTokenAddress = fromTokenAddress, @@ -167,6 +170,7 @@ internal class SwapInteractorImpl @Inject constructor( fromToken = fromToken, toToken = toToken, amount = amount, + preparedSwapConfigState = preparedSwapConfigState, ) } else { loadQuoteData( @@ -176,16 +180,19 @@ internal class SwapInteractorImpl @Inject constructor( amount = amount, fromToken = fromToken, toToken = toToken, - isAllowedToSpend = isAllowedToSpend, + preparedSwapConfigState = preparedSwapConfigState, ) } } - override suspend fun onSwap(): SwapState { - val swapData = requireNotNull(cache.getLastSwapData()) { "swap data is not ready" } - val networkId = requireNotNull(cache.getNetworkId()) { "no networkId found, please call getTokensToSwap first" } - val currencyToSend = requireNotNull(cache.getExchangeCurrencies()?.fromCurrency) { "currency is not selected" } - val amountToSwap = requireNotNull(cache.getAmountToSwap()) { "" } + override suspend fun onSwap( + networkId: String, + swapData: SwapDataModel, + currencyToSend: Currency, + currencyToGet: Currency, + amountToSwap: String, + ): TxState { + val amount = requireNotNull(amountToSwap.toBigDecimalOrNull()) { "wrong amount format, use only digits" } val estimatedGas = increaseByPercents(TWENTY_FIVE_PERCENTS, swapData.transaction.gas.toIntOrNull() ?: DEFAULT_GAS) val fee = transactionManager.calculateFee( @@ -195,22 +202,27 @@ internal class SwapInteractorImpl @Inject constructor( ) val result = transactionManager.sendTransaction( networkId = networkId, - amountToSend = amountToSwap.value, + amountToSend = amount, currencyToSend = cryptoCurrencyConverter.convert(currencyToSend), feeAmount = fee, estimatedGas = estimatedGas, destinationAddress = swapData.transaction.toWalletAddress, dataToSign = swapData.transaction.data, + isSwap = true, ) - when (result) { + return when (result) { SendTxResult.Success -> { + userWalletManager.addToken(cryptoCurrencyConverter.convert(currencyToGet)) + TxState.TxSent( + fromAmount = amountFormatter.formatSwapAmountToUI(swapData.fromTokenAmount, currencyToSend.symbol), + toAmount = amountFormatter.formatSwapAmountToUI(swapData.toTokenAmount, currencyToSend.symbol), + ) } - SendTxResult.UserCancelledError -> TODO() - is SendTxResult.BlockchainSdkError -> TODO() - is SendTxResult.TangemSdkError -> TODO() - is SendTxResult.UnknownError -> TODO() + SendTxResult.UserCancelledError -> TxState.UserCancelled + is SendTxResult.BlockchainSdkError -> TxState.BlockchainError + is SendTxResult.TangemSdkError -> TxState.TangemSdkError + is SendTxResult.UnknownError -> TxState.UnknownError } - return SwapState.SwapError(DataError.UNKNOWN_ERROR) } override fun getTokenDecimals(token: Currency): Int { @@ -252,7 +264,7 @@ internal class SwapInteractorImpl @Inject constructor( TokenWithBalance( token = it, tokenBalanceData = TokenBalanceData( - amount = balance?.let { b -> b.value.toFormattedString(b.decimals) }, + amount = balance?.let { amount -> amountFormatter.formatProxyAmountToUI(amount, "") }, amountEquivalent = balance?.value?.toFiatString( rates[it.id]?.toBigDecimal() ?: BigDecimal.ZERO, appCurrency.symbol, @@ -262,13 +274,50 @@ internal class SwapInteractorImpl @Inject constructor( } } + private suspend fun getAndUpdateFee(networkId: String, fromToken: Currency): BigDecimal? { + val lastFee = cache.getLastFeeForNetwork(networkId) + if (lastFee == null) { + if (userWalletManager.getNativeTokenBalance(networkId)?.value?.compareTo(BigDecimal.ZERO) == 0) { + return null + } + val transactionData = repository.dataToApprove(networkId, getTokenAddress(fromToken)) + val fee = transactionManager.getFee( + networkId, + BigDecimal.ZERO, + cryptoCurrencyConverter.convert(fromToken), + transactionData.toAddress, + ).value + cache.cacheLastFeeForNetwork(fee, networkId) + return fee + } + return lastFee + } + private suspend fun checkAllowance(networkId: String, fromTokenAddress: String): Boolean { val allowance = repository.checkTokensSpendAllowance( networkId = networkId, tokenAddress = fromTokenAddress, walletAddress = userWalletManager.getWalletAddress(networkId), ) - return allowance.error == DataError.NO_ERROR && allowance.dataModel != ZERO_BALANCE + return allowance.error == DataError.NoError && allowance.dataModel != ZERO_BALANCE + } + + private fun createEmptyAmountState( + networkId: String, + fromToken: Currency, + toToken: Currency, + ): SwapState { + val tokensBalance = userWalletManager.getCurrentWalletTokensBalance(networkId) + val fromTokenBalance = tokensBalance[fromToken.symbol]?.let { + SwapAmount(it.value, it.decimals) + } + val toTokenBalance = tokensBalance[toToken.symbol]?.let { + SwapAmount(it.value, it.decimals) + } + return SwapState.EmptyAmountState( + fromTokenWalletBalance = fromTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") } ?: "", + toTokenWalletBalance = toTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") } ?: "", + ) } /** @@ -282,7 +331,7 @@ internal class SwapInteractorImpl @Inject constructor( amount: SwapAmount, fromToken: Currency, toToken: Currency, - isAllowedToSpend: Boolean, + preparedSwapConfigState: PreparedSwapConfigState, ): SwapState { repository.findBestQuote( networkId = networkId, @@ -292,9 +341,7 @@ internal class SwapInteractorImpl @Inject constructor( ).let { quotes -> val quoteDataModel = quotes.dataModel if (quoteDataModel != null) { - cache.cacheQuoteData(quoteModel = quoteDataModel) val transactionData = repository.dataToApprove(networkId, getTokenAddress(fromToken)) - cache.cacheApproveTransactionData(transactionData) val swapState = updateBalances( networkId = networkId, fromToken = fromToken, @@ -306,7 +353,8 @@ internal class SwapInteractorImpl @Inject constructor( estimatedGas = quoteDataModel.estimatedGas, gasPrice = transactionData.gasPrice, ), - isAllowedToSpend = isAllowedToSpend, + preparedSwapConfigState = preparedSwapConfigState, + swapDataModel = null, ) return updatePermissionState( networkId = networkId, @@ -332,6 +380,7 @@ internal class SwapInteractorImpl @Inject constructor( fromToken: Currency, toToken: Currency, amount: SwapAmount, + preparedSwapConfigState: PreparedSwapConfigState, ): SwapState { repository.prepareSwapTransaction( networkId = networkId, @@ -343,7 +392,6 @@ internal class SwapInteractorImpl @Inject constructor( ).let { val swapData = it.dataModel if (swapData != null) { - cache.cacheSwapData(swapData) val swapState = updateBalances( networkId = networkId, fromToken = fromToken, @@ -355,7 +403,8 @@ internal class SwapInteractorImpl @Inject constructor( estimatedGas = swapData.transaction.gas.toIntOrNull() ?: DEFAULT_GAS, gasPrice = swapData.transaction.gasPrice, ), - isAllowedToSpend = true, + preparedSwapConfigState = preparedSwapConfigState, + swapDataModel = swapData, ) return swapState.copy( permissionState = PermissionDataState.Empty, @@ -374,34 +423,45 @@ internal class SwapInteractorImpl @Inject constructor( fromTokenAmount: SwapAmount, toTokenAmount: SwapAmount, fee: BigDecimal, - isAllowedToSpend: Boolean, + preparedSwapConfigState: PreparedSwapConfigState, + swapDataModel: SwapDataModel?, ): SwapState.QuotesLoadedState { val appCurrency = userWalletManager.getUserAppCurrency() val rates = repository.getRates(appCurrency.code, listOf(fromToken.id, toToken.id)) val tokensBalance = userWalletManager.getCurrentWalletTokensBalance(networkId) val fromTokenBalance = tokensBalance[fromToken.symbol]?.let { - it.value.toFormattedString(it.decimals) + amountFormatter.formatProxyAmountToUI(it, "") } val toTokenBalance = tokensBalance[toToken.symbol]?.let { - it.value.toFormattedString(it.decimals) + amountFormatter.formatProxyAmountToUI(it, "") } return SwapState.QuotesLoadedState( - fromTokenAmount = fromTokenAmount, - toTokenAmount = toTokenAmount, - fromTokenAddress = getTokenAddress(fromToken), - toTokenAddress = getTokenAddress(toToken), - fee = "${fee.toPlainString()} ${userWalletManager.getCurrencyByNetworkId(networkId)}", - isAllowedToSpend = isAllowedToSpend, - fromTokenWalletBalance = fromTokenBalance ?: ZERO_BALANCE, - fromTokenFiatBalance = fromTokenAmount.value.toFiatString( - rates[fromToken.id]?.toBigDecimal() ?: BigDecimal.ZERO, - appCurrency.symbol, + fromTokenInfo = TokenSwapInfo( + tokenAmount = fromTokenAmount, + tokenId = fromToken.id, + tokenWalletBalance = fromTokenBalance ?: ZERO_BALANCE, + tokenFiatBalance = fromTokenAmount.value.toFiatString( + rates[fromToken.id]?.toBigDecimal() ?: BigDecimal.ZERO, + appCurrency.symbol, + ), ), - toTokenWalletBalance = toTokenBalance ?: ZERO_BALANCE, - toTokenFiatBalance = fromTokenAmount.value.toFiatString( - rates[fromToken.id]?.toBigDecimal() ?: BigDecimal.ZERO, - appCurrency.symbol, + toTokenInfo = TokenSwapInfo( + tokenAmount = toTokenAmount, + tokenId = toToken.id, + tokenWalletBalance = toTokenBalance ?: ZERO_BALANCE, + tokenFiatBalance = toTokenAmount.value.toFiatString( + rates[toToken.id]?.toBigDecimal() ?: BigDecimal.ZERO, + appCurrency.symbol, + ), ), + fee = amountFormatter.formatBigDecimalAmountToUI( + amount = fee, + decimals = transactionManager.getNativeTokenDecimals(networkId), + currency = userWalletManager.getCurrencyByNetworkId(networkId), + ), + networkCurrency = userWalletManager.getNetworkCurrency(networkId), + preparedSwapConfigState = preparedSwapConfigState, + swapDataModel = swapDataModel, ) } @@ -412,7 +472,7 @@ internal class SwapInteractorImpl @Inject constructor( estimatedGas: Int, transactionData: ApproveModel, ): SwapState.QuotesLoadedState { - if (allowPermissionsHandler.isAddressAllowanceInProgress(fromToken.networkId)) { + if (allowPermissionsHandler.isAddressAllowanceInProgress(getTokenAddress(fromToken))) { return quotesLoadedState.copy( permissionState = PermissionDataState.PermissionLoading, ) @@ -420,7 +480,7 @@ internal class SwapInteractorImpl @Inject constructor( return quotesLoadedState.copy( permissionState = PermissionDataState.PermissionReadyForRequest( currency = userWalletManager.getCurrencyByNetworkId(networkId), - amount = "infinite", // FIXME + amount = "∞", walletAddress = getWalletAddress(networkId), spenderAddress = transactionData.toAddress, fee = transactionManager.calculateFee( @@ -428,14 +488,18 @@ internal class SwapInteractorImpl @Inject constructor( gasPrice = transactionData.gasPrice, estimatedGas = estimatedGas, ).toPlainString(), + requestApproveData = RequestApproveStateData( + estimatedGas = estimatedGas, + approveModel = transactionData, + ), ), ) } - private fun isNotZeroBalance(fromToken: Currency, networkId: String): Boolean { + private fun isBalanceEnough(fromToken: Currency, networkId: String, amount: SwapAmount, fee: BigDecimal?): Boolean { /** to compare [BigDecimal] use only comparator */ return (userWalletManager.getCurrentWalletTokensBalance(networkId)[fromToken.symbol]?.value - ?: BigDecimal.ZERO).compareTo(BigDecimal.ZERO) != 0 + ?: BigDecimal.ZERO) > amount.value.plus(fee ?: BigDecimal.ZERO) } private fun getWalletAddress(networkId: String): String { @@ -445,7 +509,7 @@ internal class SwapInteractorImpl @Inject constructor( private fun getTokenAddress(currency: Currency): String { return when (currency) { is Currency.NativeToken -> { - DEFAULT_BLOCKCHAIN_ADDRESS + DEFAULT_BLOCKCHAIN_INCH_ADDRESS } is Currency.NonNativeToken -> { currency.contractAddress @@ -453,6 +517,20 @@ internal class SwapInteractorImpl @Inject constructor( } } + private fun checkFeeIsEnough(fee: BigDecimal?, spendAmount: SwapAmount, networkId: String): Boolean { + if (fee == null) { + return false + } + userWalletManager.getNativeTokenBalance(networkId)?.let { balance -> + return (balance.value.minus(spendAmount.value) > fee.multiply( + BigDecimal.valueOf( + INCREASE_FEE_TO_CHECK_ENOUGH_PERCENT, + ), + )) + } + return false + } + @Suppress("MagicNumber") private fun increaseByPercents(percents: Int, value: Int): Int { return value * (percents / 100 + 1) @@ -462,7 +540,8 @@ internal class SwapInteractorImpl @Inject constructor( private const val DEFAULT_SLIPPAGE = 2 private const val ZERO_BALANCE = "0" private const val DEFAULT_GAS = 300000 - private const val DEFAULT_BLOCKCHAIN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" + private const val DEFAULT_BLOCKCHAIN_INCH_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" private const val TWENTY_FIVE_PERCENTS = 25 + private const val INCREASE_FEE_TO_CHECK_ENOUGH_PERCENT = 1.5 } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCache.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCache.kt index a415b03edc..20cba789db 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCache.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCache.kt @@ -1,30 +1,16 @@ package com.tangem.feature.swap.domain.cache -import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.cache.ExchangeCurrencies -import com.tangem.feature.swap.domain.models.domain.ApproveModel import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.feature.swap.domain.models.domain.QuoteModel -import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import java.math.BigDecimal interface SwapDataCache { - fun cacheNetworkId(networkId: String) - fun cacheQuoteData(quoteModel: QuoteModel) - fun cacheSwapData(swapDataModel: SwapDataModel) - fun cacheAmountToSwap(amount: SwapAmount) - fun cacheApproveTransactionData(approve: ApproveModel) fun cacheAvailableToSwapTokens(networkId: String, tokens: List) fun cacheInWalletTokens(tokens: List) fun cacheLoadedTokens(tokens: List) - fun cacheExchangeCurrencies(fromToken: Currency, toToken: Currency) + fun cacheLastFeeForNetwork(fee: BigDecimal, networkId: String) fun getAvailableTokens(networkId: String): List - fun getApproveTransactionData(): ApproveModel? - fun getLastQuote(): QuoteModel? - fun getLastSwapData(): SwapDataModel? - fun getNetworkId(): String? - fun getAmountToSwap(): SwapAmount? - fun getExchangeCurrencies(): ExchangeCurrencies? fun getInWalletTokens(): List fun getLoadedTokens(): List + fun getLastFeeForNetwork(networkId: String): BigDecimal? } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCacheImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCacheImpl.kt index 582eb1ff73..bf4ec2aef7 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCacheImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCacheImpl.kt @@ -1,34 +1,17 @@ package com.tangem.feature.swap.domain.cache -import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.cache.ExchangeCurrencies -import com.tangem.feature.swap.domain.models.cache.SwapDataHolder -import com.tangem.feature.swap.domain.models.domain.ApproveModel import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.feature.swap.domain.models.domain.QuoteModel -import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import java.math.BigDecimal class SwapDataCacheImpl : SwapDataCache { - private var lastDataForSwap: SwapDataHolder = SwapDataHolder() private val availableTokensForNetwork: MutableMap> = mutableMapOf() + private val feesForNetworks: MutableMap = mutableMapOf() private val lastInWalletTokens = mutableListOf() private val lastLoadedTokens = mutableListOf() - override fun cacheQuoteData( - quoteModel: QuoteModel, - ) { - lastDataForSwap = lastDataForSwap.copy(quoteModel = quoteModel) - } - - override fun cacheExchangeCurrencies(fromToken: Currency, toToken: Currency) { - lastDataForSwap = - lastDataForSwap.copy( - exchangeCurrencies = ExchangeCurrencies( - fromCurrency = fromToken, - toCurrency = toToken, - ), - ) + override fun cacheLastFeeForNetwork(fee: BigDecimal, networkId: String) { + feesForNetworks[networkId] = fee } override fun cacheInWalletTokens(tokens: List) { @@ -49,51 +32,15 @@ class SwapDataCacheImpl : SwapDataCache { return lastLoadedTokens } - override fun cacheSwapData(swapDataModel: SwapDataModel) { - lastDataForSwap = lastDataForSwap.copy(swapModel = swapDataModel) - } - - override fun getLastSwapData(): SwapDataModel? { - return lastDataForSwap.swapModel - } - - override fun cacheApproveTransactionData(approve: ApproveModel) { - lastDataForSwap = lastDataForSwap.copy(approveTxModel = approve) - } - - override fun getExchangeCurrencies(): ExchangeCurrencies? { - return lastDataForSwap.exchangeCurrencies - } - override fun cacheAvailableToSwapTokens(networkId: String, tokens: List) { availableTokensForNetwork[networkId] = tokens } - override fun cacheNetworkId(networkId: String) { - lastDataForSwap = lastDataForSwap.copy(networkId = networkId) - } - - override fun cacheAmountToSwap(amount: SwapAmount) { - lastDataForSwap = lastDataForSwap.copy(amountToSwap = amount) - } - - override fun getNetworkId(): String? { - return lastDataForSwap.networkId + override fun getLastFeeForNetwork(networkId: String): BigDecimal? { + return feesForNetworks[networkId] } override fun getAvailableTokens(networkId: String): List { return availableTokensForNetwork.getOrElse(networkId) { emptyList() } } - - override fun getApproveTransactionData(): ApproveModel? { - return lastDataForSwap.approveTxModel - } - - override fun getLastQuote(): QuoteModel? { - return lastDataForSwap.quoteModel - } - - override fun getAmountToSwap(): SwapAmount? { - return lastDataForSwap.amountToSwap - } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt index def35afd55..fee4466f54 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt @@ -1,21 +1,18 @@ package com.tangem.feature.swap.domain.models -enum class DataError { - NO_ERROR, - UNKNOWN_ERROR, - INSUFFICIENT_LIQUIDITY, - CANNOT_SYNC +sealed class DataError { + object NoError : DataError() + data class UnknownError(val message: String) : DataError() + object InsufficientLiquidity : DataError() } fun mapErrors(error: String?): DataError { return if (error == null) { - DataError.UNKNOWN_ERROR - } else when { - error == INSUFFICIENT_LIQUIDITY_ERROR -> DataError.INSUFFICIENT_LIQUIDITY - error.startsWith(CANNOT_SYNC_ERROR) -> DataError.CANNOT_SYNC - else -> DataError.UNKNOWN_ERROR + DataError.NoError + } else when (error) { + INSUFFICIENT_LIQUIDITY_ERROR -> DataError.InsufficientLiquidity + else -> DataError.UnknownError(error) } } -private const val INSUFFICIENT_LIQUIDITY_ERROR = "insufficient liquidity" -private const val CANNOT_SYNC_ERROR = "cannot sync" \ No newline at end of file +private const val INSUFFICIENT_LIQUIDITY_ERROR = "insufficient liquidity" \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/data/AggregatedSwapDataModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/data/AggregatedSwapDataModel.kt index 2a7eefc5b3..317b0da94f 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/data/AggregatedSwapDataModel.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/data/AggregatedSwapDataModel.kt @@ -11,5 +11,5 @@ import com.tangem.feature.swap.domain.models.DataError */ data class AggregatedSwapDataModel( val dataModel: T?, - val error: DataError = DataError.NO_ERROR, + val error: DataError = DataError.NoError, ) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt new file mode 100644 index 0000000000..29515eef16 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt @@ -0,0 +1,7 @@ +package com.tangem.feature.swap.domain.models.domain + +data class PreparedSwapConfigState( + val isAllowedToSpend: Boolean, + val isBalanceEnough: Boolean, + val isFeeEnough: Boolean, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt new file mode 100644 index 0000000000..26eff81e56 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt @@ -0,0 +1,43 @@ +package com.tangem.feature.swap.domain.models.ui + +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.lib.crypto.models.ProxyAmount +import com.tangem.utils.toFormattedCurrencyString +import java.math.BigDecimal + +class AmountFormatter { + + /** + * Use to convert crypto amount [SwapAmount] to UI representation + * + * @param swapAmount [SwapAmount] + * @param currency currency symbol + * @return formatted [String] + */ + fun formatSwapAmountToUI(swapAmount: SwapAmount, currency: String): String { + return swapAmount.value.toFormattedCurrencyString(swapAmount.decimals, currency) + } + + /** + * Use to convert crypto amount [ProxyAmount] to UI representation + * + * @param proxyAmount [ProxyAmount] + * @param currency currency symbol + * @return formatted [String] + */ + fun formatProxyAmountToUI(proxyAmount: ProxyAmount, currency: String): String { + return proxyAmount.value.toFormattedCurrencyString(proxyAmount.decimals, currency) + } + + /** + * Use to convert ONLY crypto amount [BigDecimal] to UI representation + * + * @param amount + * @param decimals + * @param currency + * @return formatted [String] + */ + fun formatBigDecimalAmountToUI(amount: BigDecimal, decimals: Int, currency: String): String { + return amount.toFormattedCurrencyString(decimals, currency) + } +} \ No newline at end of file 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 77f86cbf47..3b7f728f9c 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 @@ -2,26 +2,28 @@ package com.tangem.feature.swap.domain.models.ui import com.tangem.feature.swap.domain.models.DataError import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ApproveModel +import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState +import com.tangem.feature.swap.domain.models.domain.SwapDataModel sealed interface SwapState { data class QuotesLoadedState( - val fromTokenAmount: SwapAmount, - val toTokenAmount: SwapAmount, - val fromTokenAddress: String, - val toTokenAddress: String, - val fromTokenWalletBalance: String, - val fromTokenFiatBalance: String, - val toTokenWalletBalance: String, - val toTokenFiatBalance: String, + val fromTokenInfo: TokenSwapInfo, + val toTokenInfo: TokenSwapInfo, val fee: String, - val isAllowedToSpend: Boolean = false, + val networkCurrency: String, + val preparedSwapConfigState: PreparedSwapConfigState, val permissionState: PermissionDataState = PermissionDataState.Empty, + val swapDataModel: SwapDataModel? = null, ) : SwapState - data class SwapError( - val errorType: DataError, + data class EmptyAmountState( + val fromTokenWalletBalance: String, + val toTokenWalletBalance: String, ) : SwapState + + data class SwapError(val error: DataError) : SwapState } sealed class PermissionDataState { @@ -32,6 +34,7 @@ sealed class PermissionDataState { val walletAddress: String, val spenderAddress: String, val fee: String, + val requestApproveData: RequestApproveStateData, ) : PermissionDataState() object PermissionFailed : PermissionDataState() @@ -39,4 +42,16 @@ sealed class PermissionDataState { object PermissionLoading : PermissionDataState() object Empty : PermissionDataState() -} \ No newline at end of file +} + +data class TokenSwapInfo( + val tokenAmount: SwapAmount, + val tokenId: String, + val tokenWalletBalance: String, + val tokenFiatBalance: String, +) + +data class RequestApproveStateData( + val estimatedGas: Int, + val approveModel: ApproveModel, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt new file mode 100644 index 0000000000..5da6797569 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt @@ -0,0 +1,10 @@ +package com.tangem.feature.swap.domain.models.ui + +sealed class TxState { + + data class TxSent(val fromAmount: String? = null, val toAmount: String? = null) : TxState() + object UserCancelled : TxState() + object BlockchainError : TxState() + object TangemSdkError : TxState() + object UnknownError : TxState() +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index aedfd886dd..139a7c50de 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -30,6 +30,7 @@ data class SwapCardData( val amount: String?, val amountEquivalent: String?, val tokenIconUrl: String, + val tokenId: String, val tokenCurrency: String, val balance: String, @DrawableRes val networkIconRes: Int? = null, @@ -68,7 +69,7 @@ sealed interface TransactionCardType { sealed interface SwapWarning { data class PermissionNeeded(val tokenCurrency: String) : SwapWarning - data class InsufficientFunds(val tokenCurrency: String) : SwapWarning + object InsufficientFunds : SwapWarning data class GenericWarning(val message: String?, val onClick: () -> Unit) : SwapWarning // data class RateExpired(val onClick: () -> Unit) : SwapWarning // object HighPriceImpact : SwapWarning diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 4d90a1c669..d288e6e4e7 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -1,11 +1,13 @@ package com.tangem.feature.swap.ui import com.tangem.feature.swap.converters.TokensDataConverter +import com.tangem.feature.swap.domain.models.DataError import com.tangem.feature.swap.domain.models.domain.Currency import com.tangem.feature.swap.domain.models.formatToUIRepresentation import com.tangem.feature.swap.domain.models.ui.FoundTokensState import com.tangem.feature.swap.domain.models.ui.PermissionDataState import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TxState import com.tangem.feature.swap.models.ApprovePermissionButton import com.tangem.feature.swap.models.CancelPermissionButton import com.tangem.feature.swap.models.FeeState @@ -13,6 +15,7 @@ import com.tangem.feature.swap.models.SwapButton import com.tangem.feature.swap.models.SwapCardData import com.tangem.feature.swap.models.SwapPermissionState import com.tangem.feature.swap.models.SwapStateHolder +import com.tangem.feature.swap.models.SwapSuccessStateHolder import com.tangem.feature.swap.models.SwapWarning import com.tangem.feature.swap.models.TransactionCardType import com.tangem.feature.swap.models.UiActions @@ -24,14 +27,15 @@ class StateBuilder(val actions: UiActions) { private val tokensDataConverter = TokensDataConverter(actions.onSearchEntered, actions.onTokenSelected) - fun createInitialLoadingState(networkCurrency: String): SwapStateHolder { + fun createInitialLoadingState(initialCurrency: Currency): SwapStateHolder { return SwapStateHolder( sendCardData = SwapCardData( type = TransactionCardType.SendCard(actions.onAmountChanged), amount = null, amountEquivalent = null, - tokenIconUrl = "", - tokenCurrency = "", + tokenIconUrl = initialCurrency.logoUrl, + tokenCurrency = initialCurrency.symbol, + tokenId = initialCurrency.id, canSelectAnotherToken = false, balance = "", ), @@ -43,9 +47,10 @@ class StateBuilder(val actions: UiActions) { tokenCurrency = "", canSelectAnotherToken = false, balance = "", + tokenId = "", ), fee = FeeState.Loading, - networkCurrency = networkCurrency, + networkCurrency = initialCurrency.symbol, swapButton = SwapButton(enabled = false, loading = true, onClick = {}), onRefresh = {}, onBackClicked = actions.onBackClicked, @@ -66,6 +71,7 @@ class StateBuilder(val actions: UiActions) { amountEquivalent = uiStateHolder.sendCardData.amountEquivalent, tokenIconUrl = fromToken.logoUrl, tokenCurrency = fromToken.symbol, + tokenId = fromToken.id, canSelectAnotherToken = mainTokenId != fromToken.id, balance = "", ), @@ -75,6 +81,7 @@ class StateBuilder(val actions: UiActions) { amountEquivalent = null, tokenIconUrl = toToken.logoUrl, tokenCurrency = toToken.symbol, + tokenId = toToken.id, canSelectAnotherToken = mainTokenId != toToken.id, balance = "", ), @@ -89,40 +96,87 @@ class StateBuilder(val actions: UiActions) { quoteModel: SwapState.QuotesLoadedState, fromToken: Currency, ): SwapStateHolder { + val warnings = mutableListOf() + if (!quoteModel.preparedSwapConfigState.isAllowedToSpend && quoteModel.preparedSwapConfigState.isFeeEnough) { + warnings.add(SwapWarning.PermissionNeeded(fromToken.symbol)) + } + if (!quoteModel.preparedSwapConfigState.isBalanceEnough) { + warnings.add(SwapWarning.InsufficientFunds) + } + val feeState = if (quoteModel.preparedSwapConfigState.isFeeEnough) { + FeeState.Loaded(quoteModel.fee) + } else { + FeeState.NotEnoughFundsWarning(quoteModel.fee) + } return uiStateHolder.copy( sendCardData = SwapCardData( type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard), - amount = quoteModel.fromTokenAmount.formatToUIRepresentation(), - amountEquivalent = quoteModel.fromTokenFiatBalance, + amount = quoteModel.fromTokenInfo.tokenAmount.formatToUIRepresentation(), + amountEquivalent = quoteModel.fromTokenInfo.tokenFiatBalance, tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, + tokenId = quoteModel.fromTokenInfo.tokenId, tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, - balance = quoteModel.fromTokenWalletBalance, + balance = quoteModel.fromTokenInfo.tokenWalletBalance, ), receiveCardData = SwapCardData( type = TransactionCardType.ReceiveCard(), - amount = quoteModel.toTokenAmount.formatToUIRepresentation(), - amountEquivalent = quoteModel.toTokenFiatBalance, + amount = quoteModel.toTokenInfo.tokenAmount.formatToUIRepresentation(), + amountEquivalent = quoteModel.toTokenInfo.tokenFiatBalance, tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, + tokenId = quoteModel.toTokenInfo.tokenId, tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, - balance = quoteModel.toTokenWalletBalance, + balance = quoteModel.toTokenInfo.tokenWalletBalance, ), - warnings = if (!quoteModel.isAllowedToSpend) { - listOf(SwapWarning.PermissionNeeded(fromToken.symbol)) - } else { - emptyList() - }, + networkCurrency = quoteModel.networkCurrency, + warnings = warnings, permissionState = convertPermissionState(quoteModel.permissionState, actions.onGivePermissionClick), - fee = FeeState.Loaded(quoteModel.fee), + fee = feeState, swapButton = SwapButton( - enabled = quoteModel.isAllowedToSpend, + enabled = quoteModel.preparedSwapConfigState.isAllowedToSpend + && quoteModel.preparedSwapConfigState.isBalanceEnough + && quoteModel.preparedSwapConfigState.isFeeEnough, loading = false, onClick = actions.onSwapClick, ), ) } + fun createQuotesEmptyAmountState( + uiStateHolder: SwapStateHolder, + emptyAmountState: SwapState.EmptyAmountState, + ): SwapStateHolder { + return uiStateHolder.copy( + sendCardData = SwapCardData( + type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard), + amount = uiStateHolder.sendCardData.amount, + amountEquivalent = uiStateHolder.sendCardData.amountEquivalent, + tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, + tokenId = uiStateHolder.sendCardData.tokenId, + tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, + canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, + balance = emptyAmountState.fromTokenWalletBalance, + ), + receiveCardData = SwapCardData( + type = TransactionCardType.ReceiveCard(), + amount = "0", + amountEquivalent = uiStateHolder.receiveCardData.amountEquivalent, + tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, + tokenId = uiStateHolder.receiveCardData.tokenId, + tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, + canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, + balance = emptyAmountState.toTokenWalletBalance, + ), + fee = FeeState.Empty, + swapButton = SwapButton( + enabled = false, + loading = false, + onClick = { }, + ), + ) + } + fun createSwapInProgressState(uiState: SwapStateHolder): SwapStateHolder { return uiState.copy( swapButton = uiState.swapButton.copy( @@ -164,12 +218,78 @@ class StateBuilder(val actions: UiActions) { } } - fun updateSwapAmount(uiState: SwapStateHolder, amount: String, amountEquivalent: String): SwapStateHolder { + fun updateSwapAmount(uiState: SwapStateHolder, amount: String): SwapStateHolder { return uiState.copy( sendCardData = uiState.sendCardData.copy( amount = amount, - amountEquivalent = amountEquivalent, ), ) } + + fun loadingPermissionState(uiState: SwapStateHolder): SwapStateHolder { + return uiState.copy( + permissionState = SwapPermissionState.InProgress, + warnings = uiState.warnings.filterNot { it is SwapWarning.PermissionNeeded }, + ) + } + + fun createSuccessState(uiState: SwapStateHolder, txState: TxState.TxSent): SwapStateHolder { + return uiState.copy( + successState = SwapSuccessStateHolder( + fromTokenAmount = txState.fromAmount ?: "", + toTokenAmount = txState.toAmount ?: "", + onSecondaryButtonClick = {}, + ), + ) + } + + fun createSwapErrorTransaction(uiState: SwapStateHolder, onAlertClick: () -> Unit): SwapStateHolder { + return uiState.copy( + swapButton = uiState.swapButton.copy( + enabled = true, + loading = false, + ), + alert = SwapWarning.GenericWarning( + message = null, + onClick = onAlertClick, + ), + ) + } + + fun addWarning(uiState: SwapStateHolder, message: String?): SwapStateHolder { + return if (message != null) { + val renewWarnings = uiState.warnings.filterNot { it is SwapWarning.GenericWarning }.toMutableList() + renewWarnings.add(SwapWarning.GenericWarning(message) {}) + uiState.copy( + warnings = renewWarnings, + ) + } else { + uiState + } + } + + fun mapError(uiState: SwapStateHolder, error: DataError): SwapStateHolder { + return when (error) { + //todo use if needed later + // DataError.InsufficientLiquidity -> TODO() + // DataError.NoError -> TODO() + is DataError.UnknownError -> addWarning(uiState, error.message) + else -> addWarning(uiState, null) + } + } + + fun addAlert(uiState: SwapStateHolder, onClick: () -> Unit): SwapStateHolder { + return uiState.copy( + alert = SwapWarning.GenericWarning( + message = null, + onClick = onClick, + ), + ) + } + + fun clearAlert(uiState: SwapStateHolder): SwapStateHolder { + return uiState.copy( + alert = null, + ) + } } \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 6660e36e01..615e893712 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -38,6 +38,7 @@ import com.tangem.core.ui.components.SmallInfoCard import com.tangem.core.ui.components.SmallInfoCardWithWarning import com.tangem.core.ui.components.SpacerH16 import com.tangem.core.ui.components.keyboardAsState +import com.tangem.core.ui.extensions.getActiveIconRes import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.swap.models.FeeState import com.tangem.feature.swap.models.SwapButton @@ -161,6 +162,7 @@ private fun MainInfo(state: SwapStateHolder) { tokenIconUrl = state.sendCardData.tokenIconUrl, tokenCurrency = state.sendCardData.tokenCurrency, networkIconRes = state.sendCardData.networkIconRes, + iconPlaceholder = getActiveIconRes(state.sendCardData.tokenId), onChangeTokenClick = if (state.sendCardData.canSelectAnotherToken) state.onSelectTokenClick else null, ) SpacerH16() @@ -172,6 +174,7 @@ private fun MainInfo(state: SwapStateHolder) { tokenIconUrl = state.receiveCardData.tokenIconUrl, tokenCurrency = state.receiveCardData.tokenCurrency, networkIconRes = state.receiveCardData.networkIconRes, + iconPlaceholder = getActiveIconRes(state.receiveCardData.tokenId), onChangeTokenClick = if (state.receiveCardData.canSelectAnotherToken) { state.onSelectTokenClick } else { @@ -228,7 +231,7 @@ private fun FeeItem(feeState: FeeState, currency: String) { SmallInfoCardWithWarning( startText = titleString, endText = feeState.fee, - warningText = stringResource(id = R.string.token_details_send_blocked_fee_format, currency), + warningText = stringResource(id = R.string.token_details_send_blocked_fee_format, currency, currency), ) } is FeeState.Empty -> {} @@ -298,6 +301,7 @@ private val sendCard = SwapCardData( networkIconRes = R.drawable.img_polygon_22, canSelectAnotherToken = false, balance = "123", + tokenId = "", ) private val receiveCard = SwapCardData( @@ -309,6 +313,7 @@ private val receiveCard = SwapCardData( networkIconRes = R.drawable.img_polygon_22, canSelectAnotherToken = true, balance = "33333", + tokenId = "", ) private val state = SwapStateHolder( diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index 878165b841..c62ebacd9c 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -74,6 +74,7 @@ fun TransactionCard( amountEquivalent: String?, tokenIconUrl: String, tokenCurrency: String, + @DrawableRes iconPlaceholder: Int? = null, @DrawableRes networkIconRes: Int? = null, onChangeTokenClick: (() -> Unit)? = null, ) { @@ -104,6 +105,7 @@ fun TransactionCard( tokenIconUrl = tokenIconUrl, tokenCurrency = tokenCurrency, networkIconRes = networkIconRes, + iconPlaceholder = iconPlaceholder, ) } @@ -328,6 +330,7 @@ private fun AutoSizeTextField(amount: String, onAmountChange: (String) -> Unit) fun Token( tokenIconUrl: String, tokenCurrency: String, + @DrawableRes iconPlaceholder: Int? = null, @DrawableRes networkIconRes: Int? = null, ) { Column( @@ -353,6 +356,8 @@ fun Token( model = ImageRequest.Builder(LocalContext.current) .data(tokenIconUrl) .crossfade(true) + .error(iconPlaceholder ?: 0) + .placeholder(iconPlaceholder ?: 0) .build(), loading = { TokenImageShimmer(modifier = tokenImageModifier) }, // error = { CurrencyPlaceholderIcon(modifier = tokenImageModifier, tokenCurrency) }, diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/PeriodicTask.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/PeriodicTask.kt index 138ce3c20c..e1343a73cd 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/PeriodicTask.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/PeriodicTask.kt @@ -5,9 +5,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean -class PeriodicTask( +class PeriodicTask( private val delay: Long, - private val task: suspend () -> Unit, + private val task: suspend () -> Result, + private val onSuccess: (T) -> Unit, + private val onError: (Throwable) -> Unit, ) { private var isActive: AtomicBoolean = AtomicBoolean(false) @@ -16,6 +18,18 @@ class PeriodicTask( isActive.set(true) while (isActive.get()) { task.invoke() + .onSuccess { + if (!isActive.get()) { + return@onSuccess + } + onSuccess.invoke(it) + } + .onFailure { + if (!isActive.get()) { + return@onFailure + } + onError.invoke(it) + } delay(delay) } } @@ -25,11 +39,11 @@ class PeriodicTask( } } -class SingleTaskScheduler { +class SingleTaskScheduler { - private var lastTask: PeriodicTask? = null + private var lastTask: PeriodicTask? = null - fun scheduleTask(scope: CoroutineScope, task: PeriodicTask) { + fun scheduleTask(scope: CoroutineScope, task: PeriodicTask) { lastTask?.cancel() lastTask = task scope.launch { diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapProcessDataState.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapProcessDataState.kt new file mode 100644 index 0000000000..4fe4e4bfb8 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapProcessDataState.kt @@ -0,0 +1,15 @@ +package com.tangem.feature.swap.viewmodels + +import com.tangem.feature.swap.domain.models.domain.ApproveModel +import com.tangem.feature.swap.domain.models.domain.Currency +import com.tangem.feature.swap.domain.models.domain.SwapDataModel + +data class SwapProcessDataState( + val networkId: String, + val fromCurrency: Currency? = null, + val toCurrency: Currency? = null, + val amount: String? = null, + val estimatedGas: Int? = null, + val approveModel: ApproveModel? = null, + val swapModel: SwapDataModel? = null, +) \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt index 764a1d9a85..d4ceed6688 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt @@ -8,10 +8,12 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.tangem.feature.swap.domain.SwapInteractor -import com.tangem.feature.swap.domain.models.createFromAmountWithoutOffset import com.tangem.feature.swap.domain.models.domain.Currency +import com.tangem.feature.swap.domain.models.domain.SwapDataModel import com.tangem.feature.swap.domain.models.ui.FoundTokensState +import com.tangem.feature.swap.domain.models.ui.PermissionDataState import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TxState import com.tangem.feature.swap.models.SwapStateHolder import com.tangem.feature.swap.models.UiActions import com.tangem.feature.swap.presentation.SwapFragment @@ -35,18 +37,19 @@ internal class SwapViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val stateBuilder = StateBuilder( - actions = createUiActions(), - ) - - private val amountDebouncer = Debouncer() - private val singleTaskScheduler = SingleTaskScheduler() private val currency = Json.decodeFromString( savedStateHandle[SwapFragment.CURRENCY_BUNDLE_KEY] ?: error("no expected parameter Currency found"), ) - var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState(currency.symbol)) + private val stateBuilder = StateBuilder( + actions = createUiActions(), + ) + private val amountDebouncer = Debouncer() + private val singleTaskScheduler = SingleTaskScheduler() + + private var dataState by mutableStateOf(SwapProcessDataState(networkId = currency.networkId)) + var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState(currency)) private set // shows currency order (direct - swap initial to selected, reversed = selected to initial) @@ -60,6 +63,11 @@ internal class SwapViewModel @Inject constructor( initTokens(currency) } + override fun onCleared() { + singleTaskScheduler.cancelTask() + super.onCleared() + } + fun setRouter(router: SwapRouter) { swapRouter = router uiState = uiState.copy( @@ -75,6 +83,10 @@ internal class SwapViewModel @Inject constructor( swapInteractor.initTokensToSwap(currency) } .onSuccess { state -> + dataState = dataState.copy( + fromCurrency = state.preselectTokens.fromToken, + toCurrency = state.preselectTokens.toToken, + ) updateTokensState(dataState = state.foundTokensState) startLoadingQuotes( fromToken = state.preselectTokens.fromToken, @@ -97,71 +109,101 @@ internal class SwapViewModel @Inject constructor( uiState = stateBuilder.createQuotesLoadingState(uiState, fromToken, toToken, currency.id) singleTaskScheduler.scheduleTask( viewModelScope, - PeriodicTask( - delay = UPDATE_DELAY, - ) { - loadQuotesInternal( - fromToken = fromToken, - toToken = toToken, - amount = amount, - ) + loadQuotesTask( + fromToken = fromToken, + toToken = toToken, + amount = amount, + ), + ) + } + + private fun loadQuotesTask( + fromToken: Currency, + toToken: Currency, + amount: String, + ): PeriodicTask { + return PeriodicTask( + UPDATE_DELAY, + task = { + runCatching(dispatchers.io) { + dataState = dataState.copy( + amount = amount, + swapModel = null, + estimatedGas = null, + approveModel = null, + ) + swapInteractor.findBestQuote( + networkId = dataState.networkId, + fromToken = fromToken, + toToken = toToken, + amountToSwap = amount, + ) + } + }, + onSuccess = { swapState -> + when (swapState) { + is SwapState.QuotesLoadedState -> { + fillDataState(swapState.permissionState, swapState.swapDataModel) + uiState = stateBuilder.createQuotesLoadedState( + uiStateHolder = uiState, + quoteModel = swapState, + fromToken = fromToken, + ) + } + is SwapState.EmptyAmountState -> { + uiState = stateBuilder.createQuotesEmptyAmountState( + uiStateHolder = uiState, + emptyAmountState = swapState, + ) + } + is SwapState.SwapError -> { + uiState = stateBuilder.mapError(uiState, swapState.error) + } + } + }, + onError = { + uiState = stateBuilder.addWarning(uiState, it.message) }, ) } - private fun loadQuotesInternal(fromToken: Currency, toToken: Currency, amount: String) { - viewModelScope.launch(dispatchers.main) { - runCatching(dispatchers.io) { - swapInteractor.findBestQuote( - fromToken = fromToken, - toToken = toToken, - amount = createFromAmountWithoutOffset( - amountWithoutOffset = amount, - decimals = swapInteractor.getTokenDecimals(fromToken), - ), - ) - } - .onSuccess { swapState -> - when (swapState) { - is SwapState.QuotesLoadedState -> { - uiState = stateBuilder.createQuotesLoadedState( - uiStateHolder = uiState, - quoteModel = swapState, - fromToken = fromToken, - ) - } - is SwapState.SwapError -> { - } - } - } - .onFailure { } + private fun fillDataState(permissionState: PermissionDataState, swapDataModel: SwapDataModel?) { + dataState = if (permissionState is PermissionDataState.PermissionReadyForRequest) { + dataState.copy( + estimatedGas = permissionState.requestApproveData.estimatedGas, + approveModel = permissionState.requestApproveData.approveModel, + ) + } else { + dataState.copy( + swapModel = swapDataModel, + ) } } - private fun createUiActions(): UiActions { - return UiActions( - onSearchEntered = { onSearchEntered(it) }, - onTokenSelected = { onTokenSelect(it) }, - onAmountChanged = { onAmountChanged(it) }, - onSwapClick = { onSwapClick() }, - onGivePermissionClick = { givePermissionsToSwap() }, - onChangeCardsClicked = { onChangeCardsClicked() }, - onBackClicked = { onSearchEntered("") }, - ) - } - private fun onSwapClick() { singleTaskScheduler.cancelTask() uiState = stateBuilder.createSwapInProgressState(uiState) viewModelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { - swapInteractor.onSwap() + swapInteractor.onSwap( + networkId = dataState.networkId, + swapData = dataState.swapModel!!, + currencyToSend = dataState.fromCurrency!!, + currencyToGet = dataState.toCurrency!!, + amountToSwap = dataState.amount!!, + ) } .onSuccess { when (it) { - is SwapState.SwapError -> { + is TxState.TxSent -> { + uiState = stateBuilder.createSuccessState(uiState, it) + swapRouter.openScreen(SwapScreen.Success) + } + else -> { + uiState = stateBuilder.createSwapErrorTransaction(uiState) { + uiState = stateBuilder.clearAlert(uiState) + } } - else -> {} } } .onFailure { } @@ -171,17 +213,38 @@ internal class SwapViewModel @Inject constructor( private fun givePermissionsToSwap() { viewModelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { - swapInteractor.givePermissionToSwap() + swapInteractor.givePermissionToSwap( + networkId = dataState.networkId, + estimatedGas = dataState.estimatedGas!!, + transactionData = dataState.approveModel!!, + forTokenContractAddress = (dataState.fromCurrency as? Currency.NonNativeToken)?.contractAddress + ?: "", + ) } - .onSuccess { } - .onFailure { } + .onSuccess { + when (it) { + is TxState.TxSent -> { + uiState = stateBuilder.loadingPermissionState(uiState) + } + else -> { + uiState = stateBuilder.addAlert(uiState) { + uiState = stateBuilder.clearAlert(uiState) + } + } + } + } + .onFailure { + uiState = stateBuilder.addAlert(uiState) { + uiState = stateBuilder.clearAlert(uiState) + } + } } } private fun onSearchEntered(searchQuery: String) { viewModelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { - swapInteractor.onSearchToken(searchQuery) + swapInteractor.onSearchToken(dataState.networkId, searchQuery) } .onSuccess { updateTokensState(it) @@ -202,42 +265,55 @@ internal class SwapViewModel @Inject constructor( fromToken = currency toToken = foundToken } - swapRouter.openScreen(SwapScreen.Main) + dataState = dataState.copy( + fromCurrency = fromToken, + toCurrency = toToken, + ) startLoadingQuotes(fromToken, toToken, lastAmount.value) + swapRouter.openScreen(SwapScreen.Main) } } private fun onChangeCardsClicked() { - val currencies = swapInteractor.getExchangeCurrencies() - val newFromToken = currencies?.toCurrency - val newToToken = currencies?.fromCurrency + val newFromToken = dataState.toCurrency + val newToToken = dataState.fromCurrency if (newFromToken != null && newToToken != null) { + dataState = dataState.copy( + fromCurrency = newFromToken, + toCurrency = newToToken, + ) isOrderReversed = !isOrderReversed startLoadingQuotes(newFromToken, newToToken, lastAmount.value) } } private fun onAmountChanged(value: String) { - uiState = stateBuilder.updateSwapAmount(uiState, value, value) + uiState = stateBuilder.updateSwapAmount(uiState, value) lastAmount.value = value amountDebouncer.debounce(DEBOUNCE_AMOUNT_DELAY, viewModelScope) { - val currencies = swapInteractor.getExchangeCurrencies() - val fromToken = currencies?.fromCurrency - val toToken = currencies?.toCurrency + val fromToken = dataState.fromCurrency + val toToken = dataState.toCurrency if (fromToken != null && toToken != null) { startLoadingQuotes(fromToken, toToken, lastAmount.value) } } } - override fun onCleared() { - singleTaskScheduler.cancelTask() - super.onCleared() + private fun createUiActions(): UiActions { + return UiActions( + onSearchEntered = { onSearchEntered(it) }, + onTokenSelected = { onTokenSelect(it) }, + onAmountChanged = { onAmountChanged(it) }, + onSwapClick = { onSwapClick() }, + onGivePermissionClick = { givePermissionsToSwap() }, + onChangeCardsClicked = { onChangeCardsClicked() }, + onBackClicked = { onSearchEntered("") }, + ) } companion object { - private const val INITIAL_AMOUNT = "1" - private const val UPDATE_DELAY = 10000L + private const val INITIAL_AMOUNT = "" + private const val UPDATE_DELAY = 60000L private const val DEBOUNCE_AMOUNT_DELAY = 1000L } } \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt index 712634f1fb..8c1a7e6dd1 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt @@ -7,16 +7,26 @@ import java.math.BigDecimal interface TransactionManager { + @Throws(IllegalStateException::class) + suspend fun sendApproveTransaction( + networkId: String, + feeAmount: BigDecimal, + estimatedGas: Int, + destinationAddress: String, + dataToSign: String, + ): SendTxResult + @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun sendTransaction( networkId: String, amountToSend: BigDecimal, - currencyToSend: Currency, feeAmount: BigDecimal, estimatedGas: Int, destinationAddress: String, dataToSign: String, + isSwap: Boolean, + currencyToSend: Currency, ): SendTxResult @Throws(IllegalStateException::class) @@ -30,5 +40,8 @@ interface TransactionManager { @Throws(IllegalStateException::class) fun getNativeTokenDecimals(networkId: String): Int + @Throws(IllegalStateException::class) + suspend fun updateWalletManager(networkId: String) + fun calculateFee(networkId: String, gasPrice: String, estimatedGas: Int): BigDecimal } \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/UserWalletManager.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/UserWalletManager.kt index 9d5651b23b..4fb0a3b584 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/UserWalletManager.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/UserWalletManager.kt @@ -49,6 +49,10 @@ interface UserWalletManager { @Throws(IllegalStateException::class) fun getCurrentWalletTokensBalance(networkId: String): Map + fun getNativeTokenBalance(networkId: String): ProxyAmount? + + fun getNetworkCurrency(networkId: String): String + /** * Returns selected app currency */