diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/StatusCodeInterceptor.kt b/core/datasource/src/main/java/com/tangem/datasource/di/StatusCodeInterceptor.kt new file mode 100644 index 0000000000..7a6362efe3 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/StatusCodeInterceptor.kt @@ -0,0 +1,51 @@ +package com.tangem.datasource.di + +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import timber.log.Timber + +class StatusCodeInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val originalResponse = chain.proceed(chain.request()) + + if (shouldInterceptResponse(originalResponse)) { + Timber.e("StatusCodeInterceptor INTERCEPTED%s", originalResponse.request.url.toString()) + + val body = getBody().toResponseBody("application/json".toMediaTypeOrNull()) + val code = getCode() + + return originalResponse.newBuilder() + .code(code) + .body(body) + .build() + } + + return originalResponse + } + + private fun shouldInterceptResponse(response: Response): Boolean { + return response.request.url.toString().contains("exchange-quote") + // && response.request.url.toString().contains("changenow") + } + + private fun getCode(): Int { + return CODE_400 + } + + private fun getBody(): String { + return "\"error\": {\n" + + " \"code\": 2290,\n" + + " \"description\": \"Core: receivedDecimals is not equal to expressDecimals\",\n" + + " \"message\": \"Not valid\",\n" + + " \"receivedToDecimals\": 5,\n" + + " \"expressToDecimals\": 5\n" + + " }" + } + + companion object { + private const val CODE_400 = 400 + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 26d5b3cf08..02432a3d03 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -35,6 +35,8 @@ object PreferencesKeys { val SWAP_TRANSACTIONS_KEY by lazy { stringPreferencesKey(name = "swapTransactions") } + val SWAP_TRANSACTIONS_STATUSES_KEY by lazy { stringPreferencesKey(name = "swapTransactionsStatuses") } + val WALLETS_SCROLL_PREVIEW_KEY by lazy { booleanPreferencesKey(name = "walletsScrollPreview") } val SENT_ONE_TIME_EVENTS_KEY by lazy { stringPreferencesKey(name = "sentOneTimeEvents") } diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 744452a5fa..42f9f8b591 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -194,6 +194,7 @@ Мои токены У вас нет добавленных токенов. Добавьте токены для обмена Недоступен для обмена с %s + Кроме того, в курс обмена включена комиссия сети за отправку обмененных средств на ваш адрес Статус Провайдеры проводят транзакции, обеспечивая плавный и эффективный обмен токенами Выберите провайдера diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 55f40ad98b..1150c0349b 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -193,6 +193,7 @@ My tokens You haven\'t added any tokens yet. Add tokens via Market to swap Cannot be swapped for %s + Additionally, the network fee for sending the exchanged funds back to your address is included in the rate Status Providers facilitate transactions, ensuring smooth and efficient token swaps Choose provider diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt index 2998ea46b9..9fe2ba124a 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt @@ -88,7 +88,7 @@ class GetCurrencyWarningsUseCase( showSwapPromoTokenUseCase().conflate(), flowOf(marketCryptoCurrencyRepository.isExchangeable(userWalletId, currency)).conflate(), ) { shouldShowSwapPromo, isExchangeable -> - if (shouldShowSwapPromo && isExchangeable) { + if (shouldShowSwapPromo && isExchangeable && currencyStatus.value !is CryptoCurrencyStatus.Unreachable) { cryptoStatuses.fold( ifLeft = { null }, ifRight = { cryptoCurrencyStatuses -> diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt index 97b9a1e2b2..f617305f17 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt @@ -4,9 +4,11 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectList import com.tangem.datasource.local.preferences.utils.getObjectListSync +import com.tangem.datasource.local.preferences.utils.getObjectMap import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.swap.domain.SwapTransactionRepository +import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel import com.tangem.feature.swap.domain.models.domain.SavedLastSwappedCryptoCurrency import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel @@ -24,11 +26,11 @@ class DefaultSwapTransactionRepository( toCryptoCurrencyId: CryptoCurrency.ID, transaction: SavedSwapTransactionModel, ) { + transaction.status?.let { storeTransactionState(transaction.txId, it) } appPreferencesStore.editData { mutablePreferences -> val savedTransactions: List? = mutablePreferences.getObjectList( key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, ) - val tokenTransactions = savedTransactions ?.firstOrNull { it.checkId( @@ -62,14 +64,17 @@ class DefaultSwapTransactionRepository( } } - override fun getTransactions( + override suspend fun getTransactions( userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID, ): Flow?> { + val txStatuses = appPreferencesStore.getObjectMap( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + ) return appPreferencesStore.getObjectList( key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, ).map { savedTransactions -> - savedTransactions + val currencyTxs = savedTransactions ?.filter { it.userWalletId == userWalletId.stringValue && ( @@ -77,6 +82,14 @@ class DefaultSwapTransactionRepository( it.fromCryptoCurrencyId == cryptoCurrencyId.value ) } + + currencyTxs?.map { currencyTx -> + currencyTx.copy( + transactions = currencyTx.transactions.map { tx -> + tx.copy(status = txStatuses[tx.txId]) + }, + ) + } } } @@ -86,6 +99,7 @@ class DefaultSwapTransactionRepository( toCryptoCurrencyId: CryptoCurrency.ID, txId: String, ) { + clearTransactionsStatuses(txId = txId) appPreferencesStore.editData { mutablePreferences -> val savedList: List? = mutablePreferences.getObjectList( key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, @@ -130,6 +144,22 @@ class DefaultSwapTransactionRepository( } } + override suspend fun storeTransactionState(txId: String, status: ExchangeStatusModel) { + appPreferencesStore.editData { mutablePreferences -> + val savedMap = mutablePreferences.getObjectMap( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + ) + + val updatesMap = savedMap?.toMutableMap() ?: mutableMapOf() + updatesMap[txId] = status + + mutablePreferences.setObjectMap( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + value = updatesMap, + ) + } + } + override suspend fun getLastSwappedCryptoCurrencyId(userWalletId: UserWalletId): String? { val lastSwappedCurrencies = appPreferencesStore.getObjectListSync( key = PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY, @@ -194,4 +224,22 @@ class DefaultSwapTransactionRepository( }, ) } + + private suspend fun clearTransactionsStatuses(txId: String) { + appPreferencesStore.editData { mutablePreferences -> + val savedList = mutablePreferences.getObjectMap( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + ) + val editedList = savedList?.filterNot { it.key == txId } + + if (editedList.isNullOrEmpty()) { + mutablePreferences.remove(key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY) + } else { + mutablePreferences.setObjectMap( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + value = editedList, + ) + } + } + } } \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt index 2068fc982a..6ca55d7f04 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt @@ -39,7 +39,7 @@ internal class ErrorsDataConverter( receivedFromDecimals = requireNotNull(error.value?.receivedFromDecimals), expressFromDecimals = requireNotNull(error.value?.expressFromDecimals), ) - else -> DataError.UnknownError + else -> DataError.UnknownErrorWithCode(error.code) } } catch (e: Exception) { return DataError.UnknownError diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt index 2c3a3e6af6..829cb5e7e4 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt @@ -30,6 +30,8 @@ sealed class DataError { val expressFromDecimals: Int, ) : DataError() + data class UnknownErrorWithCode(override val code: Int) : DataError() + object UnknownError : DataError() { override val code: Int = -1 } diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 97c7c85fb3..6711df346c 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -37,6 +37,7 @@ sealed interface SwapState { data class SwapError( val fromTokenInfo: TokenSwapInfo, val error: DataError, + val includeFeeInAmount: IncludeFeeInAmount, ) : SwapState } diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt index 526b5b2a73..132327e4cb 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt @@ -11,6 +11,7 @@ sealed class TxState { val toAmountValue: BigDecimal? = null, val txAddress: String, val txExternalUrl: String? = null, + val txUrl: String? = null, val timestamp: Long, ) : TxState() 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 2fd8561359..34c9756d26 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 @@ -9,7 +9,6 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.model.Quote import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.tokens.utils.convertToAmount @@ -37,6 +36,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.firstOrNull import timber.log.Timber import java.math.BigDecimal +import java.math.BigInteger import java.math.RoundingMode import javax.inject.Inject @@ -54,6 +54,7 @@ internal class SwapInteractorImpl @Inject constructor( private val dispatcher: CoroutineDispatcherProvider, private val swapTransactionRepository: SwapTransactionRepository, private val initialToCurrencyResolver: InitialToCurrencyResolver, + private val blockchainInteractor: BlockchainInteractor, ) : SwapInteractor { private val estimateFeeUseCase by lazy(LazyThreadSafetyMode.NONE) { @@ -62,7 +63,7 @@ internal class SwapInteractorImpl @Inject constructor( private val swapCurrencyConverter = SwapCurrencyConverter() private val amountFormatter = AmountFormatter() - private var network: Network? = null + private val hundredPercent = BigInteger("100") override suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { val selectedWallet = getSelectedWalletSyncUseCase().fold( @@ -263,7 +264,6 @@ internal class SwapInteractorImpl @Inject constructor( provider = provider, amount = amount, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - selectedFee = selectedFee, ) } } @@ -340,7 +340,6 @@ internal class SwapInteractorImpl @Inject constructor( provider: SwapProvider, amount: SwapAmount, isBalanceWithoutFeeEnough: Boolean, - selectedFee: FeeType, ): Pair { return provider to loadCexQuoteData( exchangeProviderType = ExchangeProviderType.CEX, @@ -351,7 +350,6 @@ internal class SwapInteractorImpl @Inject constructor( isAllowedToSpend = true, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, provider = provider, - selectedFee = selectedFee, ) } @@ -411,7 +409,6 @@ internal class SwapInteractorImpl @Inject constructor( txFee = state.txFee, amount = amount, fromToken = fromToken.currency, - selectedFee = selectedFee, ) return state.copy( permissionState = PermissionDataState.Empty, @@ -539,6 +536,10 @@ internal class SwapInteractorImpl @Inject constructor( }, ifRight = { val timestamp = System.currentTimeMillis() + val txUrl = blockchainInteractor.getExplorerTransactionLink( + networkId = currencyToSend.currency.network.backendId, + txAddress = exchangeData.transaction.txTo, + ) storeSwapTransaction( currencyToSend = currencyToSend, currencyToGet = currencyToGet, @@ -546,6 +547,7 @@ internal class SwapInteractorImpl @Inject constructor( swapProvider = swapProvider, swapDataModel = exchangeData, timestamp = timestamp, + txUrl = txUrl, ) storeLastCryptoCurrencyId(currencyToGet.currency) TxState.TxSent( @@ -564,6 +566,7 @@ internal class SwapInteractorImpl @Inject constructor( derivationPath, ).orEmpty(), txExternalUrl = externalUrl, + txUrl = txUrl, timestamp = timestamp, ) }, @@ -580,10 +583,11 @@ internal class SwapInteractorImpl @Inject constructor( ) return if (fee.gasLimit != 0) { + val feeAmountWithDecimals = feeAmountValue.movePointRight(fee.decimals) Fee.Ethereum( amount = feeAmount, gasLimit = fee.gasLimit.toBigInteger(), - gasPrice = (feeAmountValue / fee.gasLimit.toBigDecimal()).toBigInteger(), + gasPrice = (feeAmountWithDecimals / fee.gasLimit.toBigDecimal()).toBigInteger(), ) } else { Fee.Common(feeAmount) @@ -597,6 +601,7 @@ internal class SwapInteractorImpl @Inject constructor( swapProvider: SwapProvider, swapDataModel: SwapDataModel, timestamp: Long, + txUrl: String, ) { swapTransactionRepository.storeTransaction( userWalletId = UserWalletId(userWalletManager.getWalletId()), @@ -608,6 +613,12 @@ internal class SwapInteractorImpl @Inject constructor( timestamp = timestamp, fromCryptoAmount = amount.value, toCryptoAmount = swapDataModel.toTokenAmount.value, + status = ExchangeStatusModel( + providerId = swapProvider.providerId, + status = ExchangeStatus.New, + txId = swapDataModel.transaction.txId, + txUrl = txUrl, + ), ), ) } @@ -709,7 +720,6 @@ internal class SwapInteractorImpl @Inject constructor( provider: SwapProvider, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, - selectedFee: FeeType, ): SwapState { val fromToken = fromTokenStatus.currency val toToken = toTokenStatus.currency @@ -725,7 +735,6 @@ internal class SwapInteractorImpl @Inject constructor( txFee = txFee, amount = amount, fromToken = fromToken, - selectedFee = selectedFee, ) val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) { includeFeeInAmount.amountSubtractFee @@ -824,7 +833,7 @@ internal class SwapInteractorImpl @Inject constructor( ?: BigDecimal.ZERO, cryptoCurrencyStatus = fromToken, ) - return SwapState.SwapError(fromTokenSwapInfo, error) + return SwapState.SwapError(fromTokenSwapInfo, error, includeFeeInAmount) }, ) } @@ -834,7 +843,6 @@ internal class SwapInteractorImpl @Inject constructor( txFee: TxFeeState, amount: SwapAmount, fromToken: CryptoCurrency, - selectedFee: FeeType, ): IncludeFeeInAmount { if (fromToken is CryptoCurrency.Token) { return IncludeFeeInAmount.Excluded @@ -851,11 +859,7 @@ internal class SwapInteractorImpl @Inject constructor( } val feeValue = when (txFee) { TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> if (selectedFee == FeeType.NORMAL) { - txFee.normalFee.feeValue - } else { - txFee.priorityFee.feeValue - } + is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue is TxFeeState.SingleFeeState -> txFee.fee.feeValue } @@ -965,6 +969,7 @@ internal class SwapInteractorImpl @Inject constructor( SwapState.SwapError( fromTokenSwapInfo, error, + IncludeFeeInAmount.Excluded, ) }, ) @@ -1191,8 +1196,10 @@ internal class SwapInteractorImpl @Inject constructor( val decimals = transactionManager.getNativeTokenDecimals(networkId) return when (this) { is TransactionFee.Choosable -> { - val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO - val feePriority = this.priority.amount.value ?: BigDecimal.ZERO + val normalFee = this.normal.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SEND) + val priorityFee = this.priority.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SEND) + val feeNormal = normalFee.amount.value ?: BigDecimal.ZERO + val feePriority = priorityFee.amount.value ?: BigDecimal.ZERO val normalFiatValue = getFormattedFiatFees(networkId, feeNormal)[0] val priorityFiatValue = getFormattedFiatFees(networkId, feePriority)[0] @@ -1207,7 +1214,7 @@ internal class SwapInteractorImpl @Inject constructor( TxFeeState.MultipleFeeState( normalFee = TxFee( feeValue = feeNormal, - gasLimit = this.normal.getGasLimit(), + gasLimit = normalFee.getGasLimit(), feeFiatFormatted = normalFiatValue, feeCryptoFormatted = normalCryptoFee, decimals = decimals, @@ -1216,7 +1223,7 @@ internal class SwapInteractorImpl @Inject constructor( ), priorityFee = TxFee( feeValue = feePriority, - gasLimit = this.priority.getGasLimit(), + gasLimit = priorityFee.getGasLimit(), feeFiatFormatted = priorityFiatValue, feeCryptoFormatted = priorityCryptoFee, decimals = decimals, @@ -1247,6 +1254,26 @@ internal class SwapInteractorImpl @Inject constructor( } } + /** + * Workaround to increase gas limit cause we calculate fee for random address + */ + private fun Fee.increaseGasLimitBy(percentage: Int): Fee { + if (this !is Fee.Ethereum) return this + val gasLimit = this.gasLimit + val increasedGasPrice = this.amount.value?.movePointRight(this.amount.decimals) + ?.divide(gasLimit.toBigDecimal(), RoundingMode.HALF_UP) + val increasedGasLimit = gasLimit + .multiply(percentage.toBigInteger()) + .divide(hundredPercent) + val increasedAmount = this.amount.copy( + value = increasedGasLimit.toBigDecimal().multiply(increasedGasPrice).movePointLeft(this.amount.decimals), + ) + return this.copy( + amount = increasedAmount, + gasLimit = increasedGasLimit, + ) + } + private fun hasOutgoingTransaction(cryptoCurrencyStatuses: CryptoCurrencyStatus): Boolean { return cryptoCurrencyStatuses.value.pendingTransactions.any { it.isOutgoing } } @@ -1377,6 +1404,7 @@ internal class SwapInteractorImpl @Inject constructor( companion object { @Suppress("UnusedPrivateMember") private const val INCREASE_GAS_LIMIT_BY = 112 // 12% + private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5% private const val INFINITY_SYMBOL = "∞" private val ONE_INCH_SUPPORTED_NETWORKS = listOf( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt index 2db58ee01d..e00dadb2a9 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt @@ -2,6 +2,7 @@ package com.tangem.feature.swap.domain import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel import kotlinx.coroutines.flow.Flow @@ -15,7 +16,7 @@ interface SwapTransactionRepository { transaction: SavedSwapTransactionModel, ) - fun getTransactions( + suspend fun getTransactions( userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID, ): Flow?> @@ -27,6 +28,8 @@ interface SwapTransactionRepository { txId: String, ) + suspend fun storeTransactionState(txId: String, status: ExchangeStatusModel) + suspend fun storeLastSwappedCryptoCurrencyId(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID) suspend fun getLastSwappedCryptoCurrencyId(userWalletId: UserWalletId): String? 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 d299f7824e..518d7c9fc8 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 @@ -42,6 +42,7 @@ class SwapDomainModule { walletManagersFacade: WalletManagersFacade, coroutineDispatcherProvider: CoroutineDispatcherProvider, initialToCurrencyResolver: InitialToCurrencyResolver, + blockchainInteractor: BlockchainInteractor, ): SwapInteractor { return SwapInteractorImpl( transactionManager = transactionManager, @@ -56,6 +57,7 @@ class SwapDomainModule { dispatcher = coroutineDispatcherProvider, swapTransactionRepository = swapTransactionRepository, initialToCurrencyResolver = initialToCurrencyResolver, + blockchainInteractor = blockchainInteractor, ) } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt index e01a8b8058..6ca10e195f 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt @@ -11,6 +11,7 @@ sealed class FeeItemState { val amountCrypto: String, val symbolCrypto: String, val amountFiatFormatted: String, + val explanation: TextReference?, val isClickable: Boolean, val onClick: () -> Unit, ) : FeeItemState() diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt index d0f47a62a2..3c3de0d27f 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt @@ -109,6 +109,7 @@ private fun ChooseFeeBottomSheetContent_Preview() { amountCrypto = "1000", symbolCrypto = "MATIC", amountFiatFormatted = "(10$)", + explanation = null, isClickable = false, onClick = {}, ), @@ -118,6 +119,7 @@ private fun ChooseFeeBottomSheetContent_Preview() { amountCrypto = "2000", symbolCrypto = "MATIC", amountFiatFormatted = "(10$)", + explanation = null, isClickable = false, onClick = {}, ), diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt index f379ae3511..f5385bf456 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt @@ -3,6 +3,8 @@ package com.tangem.feature.swap.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.material.Text +import androidx.compose.material3.Divider import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -24,12 +26,13 @@ fun FeeItemBlock(state: FeeItemState) { @Composable fun FeeItem(state: FeeItemState.Content) { - Box( + Column( modifier = Modifier .background( color = TangemTheme.colors.background.action, shape = TangemTheme.shapes.roundedCornersXMedium, ) + .padding(start = TangemTheme.dimens.spacing12) .clip(shape = TangemTheme.shapes.roundedCornersXMedium) .clickable( onClick = state.onClick, @@ -40,13 +43,32 @@ fun FeeItem(state: FeeItemState.Content) { val description = "${state.amountCrypto} ${state.symbolCrypto} (${state.amountFiatFormatted})" SimpleActionRow( modifier = Modifier.padding( - start = TangemTheme.dimens.spacing12, top = TangemTheme.dimens.spacing12, ), title = state.title.resolveReference(), description = description, isClickable = state.isClickable, ) + state.explanation?.let { + Divider( + color = TangemTheme.colors.stroke.primary, + thickness = TangemTheme.dimens.size0_5, + modifier = Modifier.padding( + top = TangemTheme.dimens.spacing10, + bottom = TangemTheme.dimens.spacing10, + end = TangemTheme.dimens.spacing2, + ), + ) + Text( + text = it.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding( + bottom = TangemTheme.dimens.spacing10, + end = TangemTheme.dimens.spacing16, + ), + ) + } } } @@ -59,6 +81,10 @@ private fun FeeItemPreview() { amountCrypto = "1000", symbolCrypto = "MATIC", amountFiatFormatted = "(1000$)", + explanation = stringReference( + "Additionally, the network fee for sending the exchanged funds back to your address is " + + "included in the rate", + ), isClickable = false, onClick = {}, ) 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 c74bd436f7..cbc20fe451 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 @@ -215,7 +215,7 @@ internal class StateBuilder( if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder val warnings = getWarningsForSuccessState(quoteModel, fromToken) - val feeState = createFeeState(quoteModel.txFee, selectedFeeType) + val feeState = createFeeState(quoteModel.txFee, selectedFeeType, swapProvider) val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus return uiStateHolder.copy( @@ -378,6 +378,7 @@ internal class StateBuilder( } private fun getSwapButtonEnabled(preparedSwapConfigState: PreparedSwapConfigState): Boolean { + if (preparedSwapConfigState.hasOutgoingTransaction) return false return when (preparedSwapConfigState.includeFeeInAmount) { IncludeFeeInAmount.BalanceNotEnough -> false IncludeFeeInAmount.Excluded -> @@ -394,12 +395,21 @@ internal class StateBuilder( swapProvider: SwapProvider, fromToken: TokenSwapInfo, toToken: CryptoCurrencyStatus?, + includeFeeInAmount: IncludeFeeInAmount, dataError: DataError, isReverseSwapPossible: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder - val warning = getWarningForError(dataError, fromToken.cryptoCurrencyStatus.currency) + val warnings = mutableListOf() + warnings.add(getWarningForError(dataError, fromToken.cryptoCurrencyStatus.currency)) + if (includeFeeInAmount is IncludeFeeInAmount.Included) { + warnings.add( + SwapWarning.GeneralWarning( + createNetworkFeeCoverageNotificationConfig(), + ), + ) + } val providerState = getProviderStateForError( swapProvider = swapProvider, fromToken = fromToken.cryptoCurrencyStatus.currency, @@ -437,7 +447,7 @@ internal class StateBuilder( amountEquivalent = getFormattedFiatAmount(fromToken.amountFiat), ), receiveCardData = receiveCardData, - warnings = listOf(warning), + warnings = warnings, permissionState = SwapPermissionState.Empty, fee = FeeItemState.Empty, swapButton = SwapButton( @@ -666,7 +676,7 @@ internal class StateBuilder( ) } - private fun createFeeState(txFeeState: TxFeeState, feeType: FeeType): FeeItemState { + private fun createFeeState(txFeeState: TxFeeState, feeType: FeeType, swapProvider: SwapProvider): FeeItemState { val isClickable: Boolean val fee = when (txFeeState) { TxFeeState.Empty -> return FeeItemState.Empty @@ -692,6 +702,11 @@ internal class StateBuilder( title = resourceReference(R.string.common_fee_label), amountCrypto = fee.feeCryptoFormatted, symbolCrypto = fee.cryptoSymbol, + explanation = if (swapProvider.type == ExchangeProviderType.CEX) { + resourceReference(R.string.express_cex_fee_explanation) + } else { + null + }, amountFiatFormatted = fee.feeFiatFormatted, isClickable = isClickable, onClick = actions.onClickFee, @@ -717,7 +732,6 @@ internal class StateBuilder( fun createSuccessState( uiState: SwapStateHolder, txState: TxState.TxSent, - txUrl: String, dataState: SwapProcessDataState, onExploreClick: () -> Unit, onStatusClick: () -> Unit, @@ -735,7 +749,7 @@ internal class StateBuilder( return uiState.copy( successState = SwapSuccessStateHolder( timestamp = txState.timestamp, - txUrl = txUrl, + txUrl = txState.txUrl.orEmpty(), providerName = stringReference(providerState.name), providerType = stringReference(providerState.type), showStatusButton = providerState.type == ExchangeProviderType.CEX.name, @@ -1002,6 +1016,7 @@ internal class StateBuilder( amountCrypto = this.normalFee.feeCryptoFormatted, symbolCrypto = this.normalFee.cryptoSymbol, amountFiatFormatted = this.normalFee.feeFiatFormatted, + explanation = null, isClickable = true, onClick = {}, ), @@ -1011,6 +1026,7 @@ internal class StateBuilder( amountCrypto = this.priorityFee.feeCryptoFormatted, symbolCrypto = this.priorityFee.cryptoSymbol, amountFiatFormatted = this.priorityFee.feeFiatFormatted, + explanation = null, isClickable = true, onClick = {}, ), @@ -1221,7 +1237,7 @@ internal class StateBuilder( } private fun SwapAmount.getFormattedCryptoAmount(token: CryptoCurrency): String { - return "${this.formatToUIRepresentation()} ${token.network.currencySymbol}" + return "${this.formatToUIRepresentation()} ${token.symbol}" } private fun BigDecimal.calculateRate(to: BigDecimal, decimals: Int): BigDecimal { 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 54a29db438..1e0b3f5944 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 @@ -467,13 +467,14 @@ private val state = SwapStateHolder( amountCrypto = "100", symbolCrypto = "1000", amountFiatFormatted = "(100)", + explanation = null, isClickable = true, onClick = {}, ), warnings = listOf( SwapWarning.PermissionNeeded( notificationConfig = NotificationConfig( - title = stringReference("Give Premission"), + title = stringReference("Give Permission"), subtitle = stringReference("To continue swapping you need to give permission to Tangem"), iconResId = R.drawable.ic_locked_24, ), 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 fe3d608266..a511e6ab43 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 @@ -344,6 +344,7 @@ internal class SwapViewModel @Inject constructor( fromToken = state.fromTokenInfo, toToken = dataState.toCryptoCurrency, dataError = state.error, + includeFeeInAmount = state.includeFeeInAmount, isReverseSwapPossible = isReverseSwapPossible(), ) sendErrorAnalyticsEvent(state.error, provider) @@ -447,19 +448,14 @@ internal class SwapViewModel @Inject constructor( }.onSuccess { when (it) { is TxState.TxSent -> { - val url = blockchainInteractor.getExplorerTransactionLink( - networkId = fromCurrency.currency.network.backendId, - txAddress = it.txAddress, - ) uiState = stateBuilder.createSuccessState( uiState = uiState, txState = it, dataState = dataState, - txUrl = url, onExploreClick = { - val txHash = it.txAddress - if (txHash.isNotEmpty()) { - swapRouter.openUrl(url) + val txUrl = it.txUrl + if (!txUrl.isNullOrBlank()) { + swapRouter.openUrl(txUrl) } analyticsEventHandler.send( event = SwapEvents.ButtonExplore(initialCryptoCurrency.symbol), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt index 7ef7622a82..4c78d11155 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt @@ -54,7 +54,7 @@ internal class ExchangeStatusFactory( ) } - operator fun invoke() = combine( + suspend operator fun invoke() = combine( flow = swapTransactionRepository.getTransactions(userWalletId, cryptoCurrency.id), flow2 = getWalletCryptoCurrencies().conflate(), ) { savedTransactions, cryptoCurrenciesStatusList -> @@ -95,9 +95,10 @@ internal class ExchangeStatusFactory( return swapRepository.getExchangeStatus(txId) .fold( ifLeft = { null }, - ifRight = { - sendStatusUpdateAnalytics(it) - it + ifRight = { statusModel -> + sendStatusUpdateAnalytics(statusModel) + swapTransactionRepository.storeTransactionState(txId, statusModel) + statusModel }, ) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt index 1e23353d4a..1c3f77a105 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt @@ -222,6 +222,7 @@ internal class TokenDetailsViewModel @Inject constructor( .distinctUntilChanged() .filterNot { it.isEmpty() } .onEach { swapTxs -> + updateSwapTx(swapTxs) swapTxStatusTaskScheduler.scheduleTask( viewModelScope, PeriodicTask( @@ -231,18 +232,8 @@ internal class TokenDetailsViewModel @Inject constructor( exchangeStatusFactory.updateSwapTxStatuses(swapTxs) } }, - onSuccess = { updatedTxs -> - val config = uiState.bottomSheetConfig - val exchangeBottomSheet = config?.content as? ExchangeStatusBottomSheetConfig - val currentTx = updatedTxs.firstOrNull { it.txId == exchangeBottomSheet?.value?.txId } - uiState = uiState.copy( - swapTxs = updatedTxs, - bottomSheetConfig = currentTx?.let( - stateFactory::updateStateWithExchangeStatusBottomSheet, - ) ?: config, - ) - }, - onError = {}, + onSuccess = ::updateSwapTx, + onError = { /* no-op */ }, ), ) } @@ -252,6 +243,18 @@ internal class TokenDetailsViewModel @Inject constructor( } } + private fun updateSwapTx(swapTxs: PersistentList) { + val config = uiState.bottomSheetConfig + val exchangeBottomSheet = config?.content as? ExchangeStatusBottomSheetConfig + val currentTx = swapTxs.firstOrNull { it.txId == exchangeBottomSheet?.value?.txId } + uiState = uiState.copy( + swapTxs = swapTxs, + bottomSheetConfig = currentTx?.let( + stateFactory::updateStateWithExchangeStatusBottomSheet, + ) ?: config, + ) + } + /** * @param refresh - invalidate cache and get data from remote * @param showItemsLoading - show loading items placeholder.