diff --git a/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt index 8cf51ccfe1..4a30ff772b 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt @@ -49,6 +49,18 @@ internal object SwapDomainModule { ) } + @Provides + @Singleton + fun provideGetSwapPairUseCase( + swapRepositoryV2: SwapRepositoryV2, + swapErrorResolver: SwapErrorResolver, + ): GetSwapPairUseCase { + return GetSwapPairUseCase( + swapRepositoryV2 = swapRepositoryV2, + swapErrorResolver = swapErrorResolver, + ) + } + @Provides @Singleton fun provideSelectInitialPairUseCase( diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt index aed385bef1..e9bc94300b 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt @@ -85,11 +85,29 @@ internal class DefaultRampManager( cryptoCurrency: CryptoCurrency, ): ScenarioUnavailabilityReason { val availabilityState = runSuspendCatching { - getExchangeableState() + getExchangeableState( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) }.getOrNull() ?: ExpressAvailabilityState.Error return availabilityState.toReason(cryptoCurrency.name) } + override suspend fun availableForSwap( + userWalletId: UserWalletId, + cryptoCurrencies: List, + ): Map { + val availabilityStates = runSuspendCatching { + getExchangeableStates( + userWalletId = userWalletId, + cryptoCurrencies = cryptoCurrencies, + ) + }.getOrNull() ?: cryptoCurrencies.associateWith { ExpressAvailabilityState.Error } + return availabilityStates.mapValues { entry -> + entry.value.toReason(entry.key.name) + } + } + override fun getSellInitializationStatus(): Flow { return sellService.initializationStatus } @@ -141,9 +159,42 @@ internal class DefaultRampManager( } } - private fun getExchangeableState(): ExpressAvailabilityState { - // In task [REDACTED_TASK_KEY], removed all checks to make all tokens available - return ExpressAvailabilityState.Available + private suspend fun getExchangeableState( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): ExpressAvailabilityState { + val asset = expressServiceFetcher.getInitializationStatus(userWalletId).firstOrNull() + ?: return ExpressAvailabilityState.Loading + + return when (asset) { + is Lce.Error -> ExpressAvailabilityState.Error + is Lce.Loading -> ExpressAvailabilityState.Loading + is Lce.Content -> { + val foundAsset = asset.getOrNull()?.find { cryptoCurrency.findAssetPredicate(assetId = it.id) } + foundAsset?.isExchangeAvailable?.toSwapAvailabilityState() + ?: ExpressAvailabilityState.AssetNotFound + } + } + } + + private suspend fun getExchangeableStates( + userWalletId: UserWalletId, + cryptoCurrencies: List, + ): Map { + val asset = expressServiceFetcher.getInitializationStatus(userWalletId).firstOrNull() + ?: return cryptoCurrencies.associateWith { ExpressAvailabilityState.Loading } + + return when (asset) { + is Lce.Error -> cryptoCurrencies.associateWith { ExpressAvailabilityState.Error } + is Lce.Loading -> cryptoCurrencies.associateWith { ExpressAvailabilityState.Loading } + is Lce.Content -> { + val foundAsset = asset.getOrNull() + cryptoCurrencies.associateWith { cryptoCurrency -> + foundAsset?.find { cryptoCurrency.findAssetPredicate(assetId = it.id) }?.isExchangeAvailable + ?.toSwapAvailabilityState() ?: ExpressAvailabilityState.AssetNotFound + } + } + } } private suspend fun getOnrampAvailableState( @@ -177,6 +228,14 @@ internal class DefaultRampManager( } } + private fun Boolean.toSwapAvailabilityState(): ExpressAvailabilityState { + return if (this) { + ExpressAvailabilityState.Available + } else { + ExpressAvailabilityState.NotExchangeable + } + } + private fun Boolean.toOnrampAvailabilityState(): ExpressAvailabilityState { return if (this) { ExpressAvailabilityState.Available diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 743496098a..c5eac22cc5 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -304,11 +304,14 @@ internal class ChildFactory @Inject constructor( createComponentChild( context = context, params = SwapComponent.Params( - currencyFrom = route.currencyFrom, - currencyTo = route.currencyTo, + cryptoCurrency = route.cryptoCurrency, userWalletId = route.userWalletId, - isInitialReverseOrder = route.isInitialReverseOrder, screenSource = route.screenSource, + currencyPosition = when (route.currencyPosition) { + AppRoute.Swap.CurrencyPosition.FROM -> SwapComponent.Params.CurrencyPosition.FROM + AppRoute.Swap.CurrencyPosition.TO -> SwapComponent.Params.CurrencyPosition.TO + AppRoute.Swap.CurrencyPosition.ANY -> SwapComponent.Params.CurrencyPosition.ANY + }, tangemPayInput = route.tangemPayInput?.let { tangemPayInput -> SwapComponent.Params.TangemPayInput( cryptoAmount = tangemPayInput.cryptoAmount, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 165823622e..ce028bdc24 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -196,18 +196,15 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class Swap( - val currencyFrom: CryptoCurrency, - val currencyTo: CryptoCurrency? = null, val userWalletId: UserWalletId, - val isInitialReverseOrder: Boolean = false, + val cryptoCurrency: CryptoCurrency? = null, val screenSource: String, + val currencyPosition: CurrencyPosition = CurrencyPosition.ANY, val tangemPayInput: TangemPayInput? = null, ) : AppRoute( path = "/swap" + - "/${currencyFrom.id.value}" + - "/${currencyTo?.id?.value}" + - "/${userWalletId.stringValue}" + - "/$isInitialReverseOrder", + "/${cryptoCurrency?.id?.value}" + + "/${userWalletId.stringValue}", ) { @Serializable data class TangemPayInput( @@ -216,6 +213,13 @@ sealed class AppRoute(val path: String) : Route { val depositAddress: String, val isWithdrawal: Boolean, ) + + @Serializable + enum class CurrencyPosition { + FROM, + TO, + ANY, + } } @Serializable diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt index 90f8b3281b..e24e2ee1ed 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt @@ -118,9 +118,8 @@ class TokenActionsHandler @AssistedInject constructor( private fun onExchangeClick(cryptoCurrencyData: CryptoCurrencyData) { router.push( AppRoute.Swap( - currencyFrom = cryptoCurrencyData.status.currency, + cryptoCurrency = cryptoCurrencyData.status.currency, userWalletId = cryptoCurrencyData.userWallet.walletId, - isInitialReverseOrder = true, screenSource = AnalyticsParam.ScreensSources.Markets.value, ), ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt b/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt index 857855c019..da2ed27af0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt @@ -11,7 +11,7 @@ object ExpressUtils { private const val BATCH_ID_CHANGENOW = "BB000013" private const val BATCH_ID_PARTNER = "AF990015" - fun getRefCode(userWallet: UserWallet, appPreferencesStore: AppPreferencesStore): String? { + fun getRefCode(userWallet: UserWallet?, appPreferencesStore: AppPreferencesStore): String? { return when (userWallet) { is UserWallet.Cold -> { when { diff --git a/data/swap/build.gradle.kts b/data/swap/build.gradle.kts index 8b9fdaffe8..6541bb22cd 100644 --- a/data/swap/build.gradle.kts +++ b/data/swap/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.data.swap" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Core */ implementation(projects.core.datasource) @@ -56,4 +60,10 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Test */ + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(tangemDeps.card.core) + testImplementation(projects.common.test) + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt index eea56a309b..82196f8a4e 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt @@ -13,6 +13,7 @@ import com.tangem.data.swap.converter.TokenInfoConverter import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.express.models.request.ExchangeSentRequestBody +import com.tangem.datasource.api.express.models.request.LeastTokenInfo import com.tangem.datasource.api.express.models.request.PairsRequestBody import com.tangem.datasource.api.express.models.response.ExchangeDataResponseWithTxDetails import com.tangem.datasource.api.express.models.response.RateType @@ -34,7 +35,6 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusProducer import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.swap.SwapRepositoryV2 import com.tangem.domain.swap.models.* -import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger @@ -44,6 +44,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import java.io.IOException import java.math.BigDecimal +import java.math.RoundingMode import java.util.UUID import javax.inject.Inject @@ -64,6 +65,76 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( private val exchangeStatusConverter = SwapStatusConverter() private val txDetailsMoshiAdapter = moshi.adapter(TxDetails::class.java) + override suspend fun getPairs( + primarySwapCurrencyStatus: SwapCurrencyStatus, + secondarySwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + swapTxType: SwapTxType, + ): List = withContext(coroutineDispatcher.default) { + val primaryCurrency = primarySwapCurrencyStatus.currency + val secondaryCurrency = secondarySwapCurrencyStatus.currency + val primaryUserWallet = primarySwapCurrencyStatus.userWallet + val secondaryUserWallet = secondarySwapCurrencyStatus.userWallet + + val pairs = awaitAll( + // original pairs + async { + invokePairRequest( + userWallet = primaryUserWallet, + from = listOf(primaryCurrency), + to = listOf(secondaryCurrency), + ) + }, + // reversed pairs + async { + invokePairRequest( + userWallet = secondaryUserWallet, + from = listOf(secondaryCurrency), + to = listOf(primaryCurrency), + ) + }, + ).flatten() + + val expressProviders = expressRepository.getFilteredProviders( + userWallet = primaryUserWallet, + filterProviderTypes = filterProviderTypes, + swapTxType = swapTxType, + ).associateBy(ExpressProvider::providerId) + + fun checkPair(currency: CryptoCurrency, pair: LeastTokenInfo): Boolean { + return currency.getContractAddress() == pair.contractAddress && + currency.network.rawId == pair.network + } + + pairs.mapNotNull { pair -> + val fromCurrencyStatus = when { + checkPair(primaryCurrency, pair.from) -> primarySwapCurrencyStatus.status + checkPair(secondaryCurrency, pair.from) -> secondarySwapCurrencyStatus.status + else -> null + } + + val toCurrencyStatus = when { + checkPair(primaryCurrency, pair.to) -> primarySwapCurrencyStatus.status + checkPair(secondaryCurrency, pair.to) -> secondarySwapCurrencyStatus.status + else -> null + } + + val mappedProviders = pair.providers + .mapNotNull { it.withExpressProvider(expressProviders) } + .filterYieldSupplyProvider(fromCurrencyStatus) + + if (fromCurrencyStatus != null && toCurrencyStatus != null && mappedProviders.isNotEmpty()) { + SwapPairModel( + from = fromCurrencyStatus, + to = toCurrencyStatus, + providers = mappedProviders, + ) + } else { + null + } + } + } + override suspend fun getPairs( userWallet: UserWallet, initialCurrency: CryptoCurrency, @@ -185,16 +256,12 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( ): SwapQuoteModel = withContext(coroutineDispatcher.io) { val response = tangemExpressApi.getExchangeQuote( fromAmount = if (amountType == SwapAmountType.From) { - amount.movePointRight( - fromCryptoCurrency.decimals, - ).toString() + amount.toStringWithRightOffset(fromCryptoCurrency.decimals) } else { null }, toAmount = if (amountType == SwapAmountType.To) { - amount.movePointRight( - toCryptoCurrency.decimals, - ).toString() + amount.toStringWithRightOffset(toCryptoCurrency.decimals) } else { null }, @@ -229,7 +296,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( userWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, toCryptoCurrency: CryptoCurrency, - amount: String, + amount: BigDecimal, amountType: SwapAmountType, toAddress: String, toExtraId: String?, @@ -261,8 +328,16 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( toAddress = toAddress, fromDecimals = fromCurrency.decimals, toDecimals = toCryptoCurrency.decimals, - fromAmount = if (amountType == SwapAmountType.From) amount else null, - toAmount = if (amountType == SwapAmountType.To) amount else null, + fromAmount = if (amountType == SwapAmountType.From) { + amount.toStringWithRightOffset(fromCurrency.decimals) + } else { + null + }, + toAmount = if (amountType == SwapAmountType.To) { + amount.toStringWithRightOffset(toCryptoCurrency.decimals) + } else { + null + }, providerId = expressProvider.providerId, rateType = rateType.name.lowercase(), requestId = requestId, @@ -465,6 +540,10 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( } } + private fun BigDecimal.toStringWithRightOffset(decimals: Int): String { + return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString() + } + private fun List.filterYieldSupplyProvider(cryptoCurrencyStatus: CryptoCurrencyStatus?) = filter { provider -> // !!!WARNING!!! Filter out dex provider if yield supply is active diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt index 37c6ab2dfb..5816ec12b9 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt @@ -54,7 +54,8 @@ internal class DefaultSwapTransactionRepository( private val userTokensResponseFactory = UserTokensResponseFactory() override suspend fun storeTransaction( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, @@ -75,7 +76,10 @@ internal class DefaultSwapTransactionRepository( val tokenTransactions = savedTransactions ?.firstOrNull { swapTxList -> swapTxList.checkId( - checkUserWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, + fromAccountId = fromAccount?.accountId, + toAccountId = toAccount?.accountId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) @@ -89,7 +93,8 @@ internal class DefaultSwapTransactionRepository( mutablePreferences.setObject( key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, value = savedTransactions?.updateList( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -97,7 +102,8 @@ internal class DefaultSwapTransactionRepository( transactions = tokenTransactions, ) ?: listOf( listConverter.default( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -123,7 +129,7 @@ internal class DefaultSwapTransactionRepository( ) { savedTransactions, txStatuses, multiAccountList -> val currencyTxs = savedTransactions ?.filter { swapTxList -> - swapTxList.userWalletId == userWallet.walletId.stringValue && + swapTxList.fromUserWalletId == userWallet.walletId.stringValue && ( swapTxList.toCryptoCurrencyId == cryptoCurrencyId.value || swapTxList.fromCryptoCurrencyId == cryptoCurrencyId.value @@ -222,19 +228,27 @@ internal class DefaultSwapTransactionRepository( } } + @Suppress("LongParameterList") private fun SwapTransactionListDTO.checkId( - checkUserWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, + fromAccountId: AccountId?, + toAccountId: AccountId?, fromCurrencyId: CryptoCurrency.ID, toCurrencyId: CryptoCurrency.ID, ): Boolean { - return userWalletId == checkUserWalletId.stringValue && - toCryptoCurrencyId == toCurrencyId.value && - fromCryptoCurrencyId == fromCurrencyId.value + return this.fromUserWalletId == fromUserWalletId.stringValue && + this.toUserWalletId == toUserWalletId.stringValue && + this.fromTokensResponse?.accountId == fromAccountId?.value && + this.toTokensResponse?.accountId == toAccountId?.value && + fromCryptoCurrencyId == fromCurrencyId.value && + toCryptoCurrencyId == toCurrencyId.value } @Suppress("LongParameterList") private fun List.updateList( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, @@ -243,7 +257,8 @@ internal class DefaultSwapTransactionRepository( ): List { return addOrReplace( item = listConverter.default( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -252,7 +267,10 @@ internal class DefaultSwapTransactionRepository( ), predicate = { swapTxList -> swapTxList.checkId( - checkUserWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, + fromAccountId = fromAccount?.accountId, + toAccountId = toAccount?.accountId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) diff --git a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt index aa9d27b4b7..db7f9ebd06 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt @@ -31,7 +31,8 @@ internal class SavedSwapTransactionListConverter( } override fun convert(value: SwapTransactionListModel) = SwapTransactionListDTO( - userWalletId = value.userWalletId, + fromUserWalletId = value.fromUserWalletId, + toUserWalletId = value.toUserWalletId, fromCryptoCurrencyId = value.fromCryptoCurrencyId, toCryptoCurrencyId = value.toCryptoCurrencyId, fromTokensResponse = userTokensResponseFactory.createResponseToken( @@ -79,7 +80,8 @@ internal class SavedSwapTransactionListConverter( txStatuses = txStatuses, ) }, - userWalletId = value.userWalletId, + fromUserWalletId = value.fromUserWalletId, + toUserWalletId = value.toUserWalletId, fromCryptoCurrencyId = value.fromCryptoCurrencyId, toCryptoCurrencyId = value.toCryptoCurrencyId, fromCryptoCurrency = fromCryptoCurrency, @@ -98,14 +100,16 @@ internal class SavedSwapTransactionListConverter( @Suppress("LongParameterList") fun default( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, toAccount: Account?, tokenTransactions: List, ) = SwapTransactionListDTO( - userWalletId = userWalletId.stringValue, + fromUserWalletId = fromUserWalletId.stringValue, + toUserWalletId = toUserWalletId.stringValue, fromCryptoCurrencyId = fromCryptoCurrency.id.value, toCryptoCurrencyId = toCryptoCurrency.id.value, fromTokensResponse = userTokensResponseFactory.createResponseToken( diff --git a/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt b/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt index ab4738e367..d928a75938 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt @@ -9,7 +9,9 @@ import java.math.BigDecimal @JsonClass(generateAdapter = true) internal data class SwapTransactionListDTO( @Json(name = "userWalletId") - val userWalletId: String, + val fromUserWalletId: String, + @Json(name = "toUserWalletId") + val toUserWalletId: String = fromUserWalletId, @Json(name = "fromCryptoCurrencyId") val fromCryptoCurrencyId: String, @Json(name = "toCryptoCurrencyId") diff --git a/data/swap/src/test/kotlin/com/tangem/data/swap/DefaultSwapRepositoryV2Test.kt b/data/swap/src/test/kotlin/com/tangem/data/swap/DefaultSwapRepositoryV2Test.kt new file mode 100644 index 0000000000..92343937bb --- /dev/null +++ b/data/swap/src/test/kotlin/com/tangem/data/swap/DefaultSwapRepositoryV2Test.kt @@ -0,0 +1,709 @@ +package com.tangem.data.swap + +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.Moshi +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.express.TangemExpressApi +import com.tangem.datasource.api.express.models.request.LeastTokenInfo +import com.tangem.datasource.api.express.models.request.PairsRequestBody +import com.tangem.datasource.api.express.models.response.* +import com.tangem.datasource.crypto.DataSignatureVerifier +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.express.ExpressRepository +import com.tangem.domain.express.models.* +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher +import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier +import com.tangem.domain.swap.models.SwapAmountType +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.swap.models.SwapStatus +import com.tangem.domain.swap.models.SwapTxType +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.assertThrows +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultSwapRepositoryV2Test { + + private val tangemExpressApi: TangemExpressApi = mockk() + private val expressRepository: ExpressRepository = mockk() + private val appPreferencesStore: AppPreferencesStore = mockk(relaxed = true) + private val dataSignatureVerifier: DataSignatureVerifier = mockk() + private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk() + private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher = mockk() + private val moshi: Moshi = Moshi.Builder().build() + + private val repository = DefaultSwapRepositoryV2( + tangemExpressApi = tangemExpressApi, + expressRepository = expressRepository, + coroutineDispatcher = TestingCoroutineDispatcherProvider(), + appPreferencesStore = appPreferencesStore, + dataSignatureVerifier = dataSignatureVerifier, + singleQuoteStatusSupplier = singleQuoteStatusSupplier, + singleQuoteStatusFetcher = singleQuoteStatusFetcher, + moshi = moshi, + ) + + @BeforeEach + fun resetMocks() { + clearMocks( + tangemExpressApi, + expressRepository, + appPreferencesStore, + dataSignatureVerifier, + singleQuoteStatusSupplier, + singleQuoteStatusFetcher, + ) + } + + // region getPairs(SwapCurrencyStatus, SwapCurrencyStatus) + + @Test + fun `getPairs with SwapCurrencyStatus returns mapped pairs when providers match`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatus(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + val primarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = primaryStatus, + account = mockk(), + ) + val secondarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = secondaryStatus, + account = mockk(), + ) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf(SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT))), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert + assertThat(result).hasSize(2) + assertThat(result.first().from).isEqualTo(primaryStatus) + assertThat(result.first().to).isEqualTo(secondaryStatus) + assertThat(result.first().providers).hasSize(1) + assertThat(result.first().providers.first().providerId).isEqualTo(PROVIDER_ID) + } + + @Test + fun `getPairs with SwapCurrencyStatus returns empty when no providers match`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatus(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + val primarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = primaryStatus, + account = mockk(), + ) + val secondarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = secondaryStatus, + account = mockk(), + ) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf(SwapPairProvider(providerId = "unknown-provider", rateTypes = listOf(RateType.FLOAT))), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun `getPairs with SwapCurrencyStatus returns empty when API returns empty pairs`() = runTest { + // Arrange + val primarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = createCryptoCurrencyStatus(primaryCoin), + account = mockk(), + ) + val secondarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = createCryptoCurrencyStatus(secondaryCoin), + account = mockk(), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(emptyList()) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert + assertThat(result).isEmpty() + } + + // endregion + + // region getPairs(UserWallet, CryptoCurrency, List) + + @Test + fun `getPairs with currency status list returns mapped pairs`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatus(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf(SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT))), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + userWallet = userWallet, + initialCurrency = primaryCoin, + cryptoCurrencyStatusList = listOf(primaryStatus, secondaryStatus), + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert + assertThat(result).hasSize(2) + assertThat(result.first().from).isEqualTo(primaryStatus) + assertThat(result.first().to).isEqualTo(secondaryStatus) + } + + @Test + fun `getPairs with SendWithSwap only fetches forward pairs`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatus(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf(SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT))), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + userWallet = userWallet, + initialCurrency = primaryCoin, + cryptoCurrencyStatusList = listOf(primaryStatus, secondaryStatus), + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.SendWithSwap, + ) + + // Assert + assertThat(result).hasSize(1) + + // SendWithSwap should call getPairs only once (forward), not twice (forward + reverse) + coVerify(exactly = 1) { tangemExpressApi.getPairs(any(), any(), any()) } + } + + // endregion + + // region getSwapQuote + + @Test + fun `getSwapQuote returns correct quote model for fromAmount`() = runTest { + // Arrange + val quoteResponse = ExchangeQuoteResponse( + fromAmount = "1000000000000000000", + fromDecimals = 18, + toAmount = "100000000", + toDecimals = 8, + allowanceContract = "0xAllowance", + minAmount = BigDecimal.ONE, + quoteId = "quote-123", + ) + + coEvery { + tangemExpressApi.getExchangeQuote( + fromAmount = any(), + toAmount = any(), + fromNetwork = any(), + fromContractAddress = any(), + fromDecimals = any(), + toNetwork = any(), + toContractAddress = any(), + toDecimals = any(), + providerId = any(), + rateType = any(), + userWalletId = any(), + refCode = any(), + ) + } returns ApiResponse.Success(quoteResponse) + + // Act + val result = repository.getSwapQuote( + userWallet = userWallet, + fromCryptoCurrency = primaryCoin, + toCryptoCurrency = secondaryCoin, + amount = BigDecimal.ONE, + amountType = SwapAmountType.From, + provider = expressProvider, + rateType = ExpressRateType.Float, + ) + + // Assert + assertThat(result.provider).isEqualTo(expressProvider) + assertThat(result.toTokenAmount).isEqualTo(BigDecimal("1.00000000")) + assertThat(result.fromTokenAmount).isEqualTo(BigDecimal("1.000000000000000000")) + assertThat(result.allowanceContract).isEqualTo("0xAllowance") + assertThat(result.quoteId).isEqualTo("quote-123") + } + + @Test + fun `getSwapQuote sends fromAmount when amountType is From`() = runTest { + // Arrange + val quoteResponse = ExchangeQuoteResponse( + fromAmount = "1000000000000000000", + fromDecimals = 18, + toAmount = "100000000", + toDecimals = 8, + allowanceContract = null, + minAmount = BigDecimal.ONE, + quoteId = null, + ) + + coEvery { + tangemExpressApi.getExchangeQuote( + fromAmount = any(), + toAmount = any(), + fromNetwork = any(), + fromContractAddress = any(), + fromDecimals = any(), + toNetwork = any(), + toContractAddress = any(), + toDecimals = any(), + providerId = any(), + rateType = any(), + userWalletId = any(), + refCode = any(), + ) + } returns ApiResponse.Success(quoteResponse) + + // Act + repository.getSwapQuote( + userWallet = userWallet, + fromCryptoCurrency = primaryCoin, + toCryptoCurrency = secondaryCoin, + amount = BigDecimal("2.5"), + amountType = SwapAmountType.From, + provider = expressProvider, + rateType = ExpressRateType.Float, + ) + + // Assert — fromAmount is set, toAmount is null + coVerify { + tangemExpressApi.getExchangeQuote( + fromAmount = "2500000000000000000", + toAmount = null, + fromNetwork = ETH_BACKEND_ID, + fromContractAddress = "0", + fromDecimals = 18, + toNetwork = BTC_BACKEND_ID, + toContractAddress = "0", + toDecimals = 8, + providerId = PROVIDER_ID, + rateType = "float", + userWalletId = any(), + refCode = any(), + ) + } + } + + @Test + fun `getSwapQuote sends toAmount when amountType is To`() = runTest { + // Arrange + val quoteResponse = ExchangeQuoteResponse( + fromAmount = "1000000000000000000", + fromDecimals = 18, + toAmount = "100000000", + toDecimals = 8, + allowanceContract = null, + minAmount = BigDecimal.ONE, + quoteId = null, + ) + + coEvery { + tangemExpressApi.getExchangeQuote( + fromAmount = any(), + toAmount = any(), + fromNetwork = any(), + fromContractAddress = any(), + fromDecimals = any(), + toNetwork = any(), + toContractAddress = any(), + toDecimals = any(), + providerId = any(), + rateType = any(), + userWalletId = any(), + refCode = any(), + ) + } returns ApiResponse.Success(quoteResponse) + + // Act + repository.getSwapQuote( + userWallet = userWallet, + fromCryptoCurrency = primaryCoin, + toCryptoCurrency = secondaryCoin, + amount = BigDecimal("1.5"), + amountType = SwapAmountType.To, + provider = expressProvider, + rateType = ExpressRateType.Fixed, + ) + + // Assert — toAmount is set, fromAmount is null + coVerify { + tangemExpressApi.getExchangeQuote( + fromAmount = null, + toAmount = "150000000", + fromNetwork = ETH_BACKEND_ID, + fromContractAddress = "0", + fromDecimals = 18, + toNetwork = BTC_BACKEND_ID, + toContractAddress = "0", + toDecimals = 8, + providerId = PROVIDER_ID, + rateType = "fixed", + userWalletId = any(), + refCode = any(), + ) + } + } + + // endregion + + // region getExchangeStatus + + @Test + fun `getExchangeStatus returns converted status model`() = runTest { + // Arrange + val statusResponse = ExchangeStatusResponse( + providerId = PROVIDER_ID, + status = ExchangeStatus.Finished, + externalTxId = "ext-tx-1", + externalTxUrl = "https://example.com/tx/1", + error = null, + ) + + coEvery { + tangemExpressApi.getExchangeStatus(any(), any(), any()) + } returns ApiResponse.Success(statusResponse) + + // Act + val result = repository.getExchangeStatus(userWallet = userWallet, txId = "tx-123") + + // Assert + assertThat(result.providerId).isEqualTo(PROVIDER_ID) + assertThat(result.status).isEqualTo(SwapStatus.Finished) + assertThat(result.txId).isEqualTo("ext-tx-1") + assertThat(result.txExternalUrl).isEqualTo("https://example.com/tx/1") + } + + // endregion + + // region swapTransactionSent + + @Test + fun `swapTransactionSent calls exchangeSent API`() = runTest { + // Arrange + val fromStatus = createCryptoCurrencyStatus(primaryCoin) + + coEvery { + tangemExpressApi.exchangeSent(any(), any(), any()) + } returns ApiResponse.Success(ExchangeSentResponseBody(txId = "tx-1", status = "ok")) + + // Act + repository.swapTransactionSent( + userWallet = userWallet, + fromCryptoCurrencyStatus = fromStatus, + payInAddress = "0xPayIn", + txId = "tx-1", + txHash = "0xHash", + txExtraId = null, + ) + + // Assert + coVerify { + tangemExpressApi.exchangeSent( + userWalletId = any(), + refCode = any(), + body = match { body -> + body.txId == "tx-1" && + body.txHash == "0xHash" && + body.payinAddress == "0xPayIn" && + body.payinExtraId == null + }, + ) + } + } + + // endregion + + // region filterYieldSupplyProvider + + @Test + fun `getPairs filters out DEX providers when yield supply is active`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + val primarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = primaryStatus, + account = mockk(), + ) + val secondarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = secondaryStatus, + account = mockk(), + ) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf( + SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)), + SwapPairProvider(providerId = CEX_PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)), + ), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(dexProvider, cexProvider) + + // Act + val result = repository.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert — only CEX provider should remain + assertThat(result).hasSize(2) + val providers = result.first().providers + assertThat(providers).hasSize(1) + assertThat(providers.first().type).isEqualTo(ExpressProviderType.CEX) + } + + // endregion + + // region getSwapData + + @Test + fun `getSwapData throws InvalidSignatureError when signature verification fails`() = runTest { + // Arrange + val fromStatus = createCryptoCurrencyStatus(primaryCoin) + val exchangeDataResponse = ExchangeDataResponse( + fromAmount = "1000000000000000000", + fromDecimals = 18, + toAmount = "100000000", + toDecimals = 8, + txId = "tx-123", + txDetailsJson = "{}", + signature = "invalid-sig", + ) + + coEvery { + tangemExpressApi.getExchangeData( + fromContractAddress = any(), + toContractAddress = any(), + fromNetwork = any(), + toNetwork = any(), + fromAddress = any(), + toAddress = any(), + fromDecimals = any(), + toDecimals = any(), + fromAmount = any(), + toAmount = any(), + providerId = any(), + rateType = any(), + requestId = any(), + refundAddress = any(), + refundExtraId = any(), + userWalletId = any(), + partnerOperationType = any(), + refCode = any(), + toExtraId = any(), + quoteId = any(), + ) + } returns ApiResponse.Success(exchangeDataResponse) + + every { dataSignatureVerifier.verifySignature(any(), any()) } returns false + + // Act & Assert + assertThrows { + repository.getSwapData( + userWallet = userWallet, + fromCryptoCurrencyStatus = fromStatus, + toCryptoCurrency = secondaryCoin, + amount = BigDecimal.ONE, + amountType = SwapAmountType.From, + toAddress = "0xToAddress", + toExtraId = null, + expressProvider = cexProvider, + rateType = ExpressRateType.Float, + expressOperationType = ExpressOperationType.SWAP, + quoteId = null, + ) + } + } + + // endregion + + private companion object { + const val ETH_BACKEND_ID = "ethereum" + const val BTC_BACKEND_ID = "bitcoin" + const val PROVIDER_ID = "dex-provider-1" + const val CEX_PROVIDER_ID = "cex-provider-1" + + val userWallet: UserWallet = MockUserWalletFactory.create() + + val ethNetwork: Network = mockk(relaxed = true) { + every { rawId } returns ETH_BACKEND_ID + } + + val btcNetwork: Network = mockk(relaxed = true) { + every { rawId } returns BTC_BACKEND_ID + } + + val primaryCoin: CryptoCurrency.Coin = mockk(relaxed = true) { + every { network } returns ethNetwork + every { decimals } returns 18 + every { id } returns mockk(relaxed = true) { + every { rawCurrencyId } returns CryptoCurrency.RawID("ethereum") + } + } + + val secondaryCoin: CryptoCurrency.Coin = mockk(relaxed = true) { + every { network } returns btcNetwork + every { decimals } returns 8 + every { id } returns mockk(relaxed = true) { + every { rawCurrencyId } returns CryptoCurrency.RawID("bitcoin") + } + } + + val expressProvider = ExpressProvider( + providerId = PROVIDER_ID, + rateTypes = listOf(ExpressRateType.Float), + name = "Test DEX", + type = ExpressProviderType.DEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + val dexProvider = expressProvider + + val cexProvider = ExpressProvider( + providerId = CEX_PROVIDER_ID, + rateTypes = listOf(ExpressRateType.Float), + name = "Test CEX", + type = ExpressProviderType.CEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + fun createCryptoCurrencyStatus(currency: CryptoCurrency): CryptoCurrencyStatus { + val value: CryptoCurrencyStatus.Value = mockk(relaxed = true) { + every { yieldSupplyStatus } returns null + every { networkAddress } returns mockk(relaxed = true) { + every { defaultAddress } returns mockk(relaxed = true) { + every { value } returns "0xAddress" + } + } + } + return CryptoCurrencyStatus(currency = currency, value = value) + } + + fun createCryptoCurrencyStatusWithActiveYield(currency: CryptoCurrency): CryptoCurrencyStatus { + val yieldStatus: YieldSupplyStatus = mockk { + every { isActive } returns true + } + val value: CryptoCurrencyStatus.Value = mockk(relaxed = true) { + every { yieldSupplyStatus } returns yieldStatus + every { networkAddress } returns mockk(relaxed = true) { + every { defaultAddress } returns mockk(relaxed = true) { + every { value } returns "0xAddress" + } + } + } + return CryptoCurrencyStatus(currency = currency, value = value) + } + } +} \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt index 6d3effb0c0..ce4ddfd9dd 100644 --- a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt @@ -2,6 +2,7 @@ package com.tangem.domain.express.models import java.math.BigDecimal +@Suppress("MagicNumber") sealed class ExpressError : Throwable() { abstract val code: Int @@ -61,4 +62,12 @@ sealed class ExpressError : Throwable() { data object UnknownError : ExpressError() { override val code: Int = -1 } + + data class TooLargeSolanaTransactionError(override val code: Int = -2) : ExpressError() { + override val message: String = "tooLargeSolanaTransaction" + } + + data class DexActiveSupplyError(override val code: Int = -3) : ExpressError() { + override val message: String = "dexActiveSupplyError" + } } \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt index 2974c8a2db..74e05c2a7c 100644 --- a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt @@ -23,14 +23,18 @@ enum class ExpressProviderType(val typeName: String) { ONRAMP(typeName = "ONRAMP"), ; + fun shouldStoreSwapTransaction() = when (this) { + CEX, + DEX_BRIDGE, + DEX, + -> true + ONRAMP, + -> false + } + companion object { - fun ExpressProviderType.shouldStoreSwapTransaction() = when (this) { - CEX, - DEX_BRIDGE, - DEX, - -> true - ONRAMP, - -> false + fun getSwapProviderTypes(): List { + return listOf(CEX, DEX, DEX_BRIDGE) } } } \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt b/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt index 087b4312ed..cfcb5fc1d6 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt @@ -31,11 +31,22 @@ interface RampStateManager { sendUnavailabilityReason: ScenarioUnavailabilityReason?, ): Either + /** + * Check if [CryptoCurrency] is available for swap (express/assets request) + * + * @param userWalletId the ID of the user's wallet + * @param cryptoCurrency cryptocurrency + */ suspend fun availableForSwap( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): ScenarioUnavailabilityReason + suspend fun availableForSwap( + userWalletId: UserWalletId, + cryptoCurrencies: List, + ): Map + suspend fun fetchSellServiceData() fun getSellInitializationStatus(): Flow> diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencyStatus.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencyStatus.kt new file mode 100644 index 0000000000..4a0067251f --- /dev/null +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencyStatus.kt @@ -0,0 +1,33 @@ +package com.tangem.domain.swap.models + +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Represents the status of a cryptocurrency in the context of a swap operation. + * + * Combines the [UserWallet], [CryptoCurrencyStatus], and [Account] to provide + * all necessary information about a currency participating in a swap. + * + * @property userWallet the user wallet that owns the currency + * @property status the current status of the cryptocurrency, including balance and value state + * @property account the account within the wallet that holds the currency + * @property currency shortcut to the [CryptoCurrency] from [status] + * @property userWalletId shortcut to the wallet ID from [userWallet] + * @property isAvailableForSwap whether this currency can participate in a swap operation, + * determined by [RampStateManager][com.tangem.domain.exchange.RampStateManager] + */ +data class SwapCurrencyStatus( + val userWallet: UserWallet, + val status: CryptoCurrencyStatus, + val account: Account, + val isAvailableForSwap: Boolean = true, +) { + val currency: CryptoCurrency + get() = status.currency + val userWalletId: UserWalletId + get() = userWallet.walletId +} \ No newline at end of file diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt index 5c8e52d327..cbaf128a59 100644 --- a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt @@ -9,7 +9,8 @@ import java.math.BigDecimal * List of saved swap transactions */ data class SwapTransactionListModel( - val userWalletId: String, + val fromUserWalletId: String, + val toUserWalletId: String, val fromCryptoCurrencyId: String, val toCryptoCurrencyId: String, val fromCryptoCurrency: CryptoCurrency, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt b/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt index dacec2c058..f852d6c8cc 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt @@ -16,6 +16,21 @@ import java.math.BigDecimal @Suppress("LongParameterList") interface SwapRepositoryV2 { + /** + * Returns express swap pairs for a specific primary and secondary currency. + * + * @param primarySwapCurrencyStatus primary currency status participating in the swap + * @param secondarySwapCurrencyStatus secondary currency status participating in the swap + * @param filterProviderTypes filters only specified provider types, if empty returns providers as is + * @param swapTxType swap tx type + */ + suspend fun getPairs( + primarySwapCurrencyStatus: SwapCurrencyStatus, + secondarySwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + swapTxType: SwapTxType, + ): List + /** * Express swap pairs, both direct and reversed * @@ -84,7 +99,7 @@ interface SwapRepositoryV2 { userWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, toCryptoCurrency: CryptoCurrency, - amount: String, + amount: BigDecimal, amountType: SwapAmountType, toAddress: String, toExtraId: String?, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt b/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt index 3e22c172a7..4b450ef0b8 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt @@ -18,7 +18,8 @@ interface SwapTransactionRepository { /** * Store new swap transaction * - * @param userWalletId selected user wallet id + * @param fromUserWalletId wallet id swap from + * @param toUserWalletId wallet id swap to * @param fromCryptoCurrency currency swap from * @param toCryptoCurrency currency swap to * @param fromAccount account swap from @@ -27,7 +28,8 @@ interface SwapTransactionRepository { */ @Suppress("LongParameterList") suspend fun storeTransaction( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt index 2726e77e94..616afe33e8 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt @@ -12,6 +12,7 @@ import com.tangem.domain.swap.SwapErrorResolver import com.tangem.domain.swap.SwapRepositoryV2 import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapDataModel +import java.math.BigDecimal @Suppress("LongParameterList") class GetSwapDataUseCase( @@ -22,7 +23,7 @@ class GetSwapDataUseCase( suspend operator fun invoke( userWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, - amount: String, + amount: BigDecimal, amountType: SwapAmountType, toCryptoCurrency: CryptoCurrency, toAddress: String, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairUseCase.kt new file mode 100644 index 0000000000..101edf46e2 --- /dev/null +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairUseCase.kt @@ -0,0 +1,35 @@ +package com.tangem.domain.swap.usecase + +import arrow.core.Either +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.swap.SwapErrorResolver +import com.tangem.domain.swap.SwapRepositoryV2 +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.swap.models.SwapTxType + +/** + * Use case for retrieving swap pairs between a specific primary and secondary currency. + * + * Returns either a list of available swap pairs or a resolved swap error. + * + * @property swapRepositoryV2 repository providing swap pair data + * @property swapErrorResolver resolver that maps exceptions to domain swap errors + */ +class GetSwapPairUseCase( + private val swapRepositoryV2: SwapRepositoryV2, + private val swapErrorResolver: SwapErrorResolver, +) { + suspend operator fun invoke( + primarySwapCurrencyStatus: SwapCurrencyStatus, + secondarySwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + swapTxType: SwapTxType, + ) = Either.catch { + swapRepositoryV2.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = filterProviderTypes, + swapTxType = swapTxType, + ) + }.mapLeft(swapErrorResolver::resolve) +} \ No newline at end of file diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt index 70c9811d57..b7ed209339 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt @@ -2,7 +2,6 @@ package com.tangem.domain.swap.usecase import arrow.core.Either import com.tangem.domain.express.models.ExpressProvider -import com.tangem.domain.express.models.ExpressProviderType.Companion.shouldStoreSwapTransaction import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet @@ -19,7 +18,8 @@ class SwapTransactionSentUseCase( ) { suspend operator fun invoke( - userWallet: UserWallet, + fromUserWallet: UserWallet, + toUserWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, toCryptoCurrencyStatus: CryptoCurrencyStatus, fromAccount: Account?, @@ -33,7 +33,8 @@ class SwapTransactionSentUseCase( ) = Either.catch { if (provider.type.shouldStoreSwapTransaction()) { swapTransactionRepository.storeTransaction( - userWalletId = userWallet.walletId, + fromUserWalletId = fromUserWallet.walletId, + toUserWalletId = toUserWallet.walletId, fromCryptoCurrency = fromCryptoCurrencyStatus.currency, toCryptoCurrency = toCryptoCurrencyStatus.currency, fromAccount = fromAccount, @@ -58,11 +59,11 @@ class SwapTransactionSentUseCase( } swapTransactionRepository.storeLastSwappedCryptoCurrencyId( - userWalletId = userWallet.walletId, + userWalletId = fromUserWallet.walletId, cryptoCurrencyId = toCryptoCurrencyStatus.currency.id, ) swapRepositoryV2.swapTransactionSent( - userWallet = userWallet, + userWallet = fromUserWallet, fromCryptoCurrencyStatus = fromCryptoCurrencyStatus, payInAddress = payInAddress, txId = swapDataTransactionModel.txId, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt index a5ee00426b..73d73a599f 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt @@ -3,14 +3,11 @@ package com.tangem.domain.tokens.actions import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.TokenActionsState.ActionState -import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.yield.supply.models.YieldSupplyAvailability -import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -65,20 +62,6 @@ internal class CommonActionsFactory( getSendUnavailabilityReason(userWalletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus) } - val swapUnavailabilityReason = if (!cryptoCurrencyStatus.currency.isCustom && - cryptoCurrencyStatus.value !is CryptoCurrencyStatus.NoQuote - ) { - async { - getSwapUnavailabilityReason( - userWalletId = userWallet.walletId, - currencyStatus = cryptoCurrencyStatus, - requirementsDeferred = requirementsDeferred, - ) - } - } else { - null - } - val hideTokenUnavailabilityReason = getTokenHideUnavailabilityReason(userWallet) actionAvailabilityBuilder { @@ -111,7 +94,6 @@ internal class CommonActionsFactory( createSwapAction( userWallet = userWallet, cryptoCurrencyStatus = cryptoCurrencyStatus, - swapUnavailableReasonDeferred = swapUnavailabilityReason, shouldShowSwapStories = shouldShowSwapStories, ).addByReason() // endregion @@ -140,11 +122,9 @@ internal class CommonActionsFactory( } } - @Suppress("CanBeNonNullable") - private suspend fun createSwapAction( + private fun createSwapAction( userWallet: UserWallet, cryptoCurrencyStatus: CryptoCurrencyStatus, - swapUnavailableReasonDeferred: Deferred?, shouldShowSwapStories: Boolean, ): ActionState { val cryptoCurrency = cryptoCurrencyStatus.currency @@ -172,35 +152,11 @@ internal class CommonActionsFactory( ) } else -> { - val reason = requireNotNull(swapUnavailableReasonDeferred) { - "swapUnavailableReasonDeferred must not be null for available swap action" - }.await() - - return ActionState.Swap( - unavailabilityReason = reason, - shouldShowBadge = reason == ScenarioUnavailabilityReason.None && shouldShowSwapStories, + ActionState.Swap( + unavailabilityReason = ScenarioUnavailabilityReason.None, + shouldShowBadge = shouldShowSwapStories, ) } } } - - private suspend fun getSwapUnavailabilityReason( - userWalletId: UserWalletId, - currencyStatus: CryptoCurrencyStatus, - requirementsDeferred: Deferred?, - ): ScenarioUnavailabilityReason { - val swapUnavailabilityReason = rampStateManager - .availableForSwap(userWalletId = userWalletId, cryptoCurrency = currencyStatus.currency) - val shouldCheckAssetRequirements = - swapUnavailabilityReason == ScenarioUnavailabilityReason.None && requirementsDeferred != null - - val yieldSupplyStatus = currencyStatus.value.yieldSupplyStatus - val isUnavailableByYieldSupply = yieldSupplyStatus?.isAllowedToSpend == false && yieldSupplyStatus.isActive - - return when { - isUnavailableByYieldSupply -> ScenarioUnavailabilityReason.YieldSupplyApprovalRequired - shouldCheckAssetRequirements -> getReceiveScenario(requirementsDeferred.await()) - else -> swapUnavailabilityReason - } - } } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt index 4200d87d77..acc42bd153 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt @@ -9,13 +9,32 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapNotNull class GetPaymentAccountCryptoCurrencyStatusUseCase( private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, ) { - suspend operator fun invoke( + operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): Flow> { + return paymentAccountStatusSupplier(userWalletId).mapNotNull { accountStatus -> + val cryptoCurrencyStatus = when (val statusValue = accountStatus.value) { + is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus + else -> return@mapNotNull null + } + if (cryptoCurrencyStatus.currency == cryptoCurrency) { + accountStatus.account to cryptoCurrencyStatus + } else { + null + } + } + } + + suspend fun invokeSync( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): Option> { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt index 584aadf822..11a144fc73 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt @@ -2,10 +2,11 @@ package com.tangem.features.onramp.deeplink import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.utils.logging.TangemLogger import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import com.tangem.utils.logging.TangemLogger internal class DefaultSwapDeepLinkHandler @AssistedInject constructor( router: AppRouter, @@ -19,7 +20,12 @@ internal class DefaultSwapDeepLinkHandler @AssistedInject constructor( TangemLogger.e("Error on getting user wallet: $it") }, ifRight = { userWallet -> - router.push(AppRoute.SwapCrypto(userWallet.walletId)) + router.push( + AppRoute.Swap( + userWalletId = userWallet.walletId, + screenSource = AnalyticsParam.ScreensSources.Main.value, + ), + ) }, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt index cf05eb89cc..876a6aa1dc 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt @@ -114,8 +114,7 @@ internal class SwapSelectTokensModel @Inject constructor( router.push( route = AppRoute.Swap( - currencyFrom = requireNotNull(fromCurrencyStatus.value).currency, - currencyTo = status.currency, + cryptoCurrency = requireNotNull(fromCurrencyStatus.value).currency, userWalletId = params.userWalletId, screenSource = AnalyticsParam.ScreensSources.Main.value, ), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt index 884058c33b..b11f85dce7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt @@ -309,10 +309,7 @@ internal class OnrampTokenListModel @Inject constructor( ).isRight() } OnrampOperation.SWAP -> { - val isAvailable = rampStateManager.availableForSwap( - userWalletId = params.userWalletId, - cryptoCurrency = status.currency, - ).isAvailable() && !status.currency.isCustom + val isAvailable = !status.currency.isCustom val supplyStatus = status.value.yieldSupplyStatus val isUnavailableByYieldSupply = supplyStatus?.isAllowedToSpend == false && supplyStatus.isActive diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt index 21709e238a..7057fd13d9 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt @@ -20,13 +20,11 @@ import com.tangem.core.ui.extensions.WrappedList import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase -import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.send.v2.api.entity.PredefinedValues @@ -44,7 +42,6 @@ import com.tangem.utils.extensions.orZero import com.tangem.utils.isNullOrZero import com.tangem.utils.transformer.update import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.properties.Delegates @@ -61,7 +58,6 @@ internal class SendAmountModel @Inject constructor( private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val getUserWalletUseCase: GetUserWalletUseCase, - private val rampStateManager: RampStateManager, private val sendAmountAlertFactory: SendAmountAlertFactory, private val getWalletsUseCase: GetWalletsUseCase, ) : Model(), SendAmountClickIntents { @@ -73,7 +69,6 @@ internal class SendAmountModel @Inject constructor( private val _uiState = MutableStateFlow(params.state) val uiState = _uiState.asStateFlow() - private var isAvailableForSwap: Boolean = false val isSendWithSwapAvailable: StateFlow field = MutableStateFlow(false) @@ -87,12 +82,6 @@ internal class SendAmountModel @Inject constructor( private var maxAmountBoundary: EnterAmountBoundary by Delegates.notNull() init { - modelScope.launch { - isAvailableForSwap = rampStateManager.availableForSwap( - userWalletId = params.userWalletId, - cryptoCurrency = params.cryptoCurrency, - ) == ScenarioUnavailabilityReason.None - } configAmountNavigation() initAppCurrency() subscribeOnCryptoCurrencyStatusFlow() @@ -409,7 +398,7 @@ internal class SendAmountModel @Inject constructor( val isMultiCurrency = userWallet?.isMultiCurrency == true isSendWithSwapAvailable.update { - isAvailableForSwap && isMultiCurrency && !params.predefinedValues.isFromMainScreenQr + !params.cryptoCurrency.isCustom && isMultiCurrency && !params.predefinedValues.isFromMainScreenQr } } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt index 9f9d5ff8da..e35f01fcf3 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt @@ -26,12 +26,11 @@ import com.tangem.domain.utils.convertToSdkAmount import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils import com.tangem.features.swap.v2.impl.common.ConfirmData import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal -import java.math.RoundingMode @Suppress("LongParameterList") internal class SwapTransactionSender @AssistedInject constructor( @@ -91,7 +90,7 @@ internal class SwapTransactionSender @AssistedInject constructor( val feeValue = confirmData.fee?.amount?.value ?: return val destination = confirmData.enteredDestination ?: return - val (swapDataRequestAmount, swapDataRequestCurrency) = when (confirmData.amountType) { + val swapDataRequestAmount = when (confirmData.amountType) { SwapAmountType.From -> { val amountValue = confirmData.enteredFromAmount ?: return val subtracted = FeeCalculationUtils.checkAndCalculateSubtractedAmount( @@ -101,11 +100,11 @@ internal class SwapTransactionSender @AssistedInject constructor( feeValue = feeValue, reduceAmountBy = confirmData.reduceAmountBy, ) - subtracted to fromStatus + subtracted } SwapAmountType.To -> { val amountValue = confirmData.enteredToAmount ?: return - amountValue to toStatus + amountValue } } @@ -117,7 +116,7 @@ internal class SwapTransactionSender @AssistedInject constructor( val swapData = getSwapDataUseCase( userWallet = userWallet, fromCryptoCurrencyStatus = fromStatus, - amount = swapDataRequestAmount.toStringWithRightOffset(swapDataRequestCurrency.currency.decimals), + amount = swapDataRequestAmount, amountType = confirmData.amountType, toCryptoCurrency = toStatus.currency, toAddress = destination, @@ -208,7 +207,8 @@ internal class SwapTransactionSender @AssistedInject constructor( ifRight = { txHash -> val timestamp = System.currentTimeMillis() swapTransactionSentUseCase.invoke( - userWallet = userWallet, + fromUserWallet = userWallet, + toUserWallet = userWallet, fromCryptoCurrencyStatus = fromStatus, toCryptoCurrencyStatus = toStatus, fromAccount = fromAccount, @@ -225,10 +225,6 @@ internal class SwapTransactionSender @AssistedInject constructor( ) } - private fun BigDecimal.toStringWithRightOffset(decimals: Int): String { - return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString() - } - @AssistedFactory interface Factory { fun create(userWallet: UserWallet): SwapTransactionSender diff --git a/features/swap/CLAUDE.md b/features/swap/CLAUDE.md new file mode 100644 index 0000000000..c4f75d64f1 --- /dev/null +++ b/features/swap/CLAUDE.md @@ -0,0 +1,148 @@ +# Swap Feature + +Token-to-token exchange feature. Users select FROM and TO tokens, get quotes from providers (DEX/CEX), approve ERC-20 allowances if needed, and execute swaps. + +## Module Structure + +``` +features/swap/ + api/ — Public contracts (SwapComponent, SwapEntryComponent, SwapFeatureToggles) + impl/ — UI, model, navigation, DI, token selection subfeature + domain/ — Business logic (SwapInteractor) + domain models + api/ — Domain interfaces + models/ — Domain model types (SwapPair, SwapProvider, SwapState, etc.) + data/ — Repository implementations, Retrofit APIs, Moshi DTOs +``` + +**Package naming:** API = `com.tangem.features.swap`, Impl = `com.tangem.feature.swap` (singular `feature`, legacy inconsistency). + +## Key Components + +### SwapComponent (API) +Entry point. `Params` requires `currencyFrom`, `userWalletId`, `screenSource`. Optional: `currencyTo`, `isInitialReverseOrder`, `tangemPayInput`, `preselectedToToken`, `preselectedAccount`. + +### SwapEntryComponent (API) +Gateway component with sealed `Params`: `Story`, `Empty`, `Selected`, `Payment`. Routes to stories or directly to swap based on input type. See `entry/SwapEntryRoute.kt` for route definitions. + +### DefaultSwapComponent (impl) +Decompose component. Creates `SwapModel` via `getOrCreateModel(params)`. + +**Child navigation:** +- `childStack(SwapRoute)` for screen navigation — `SwapRoute.Main`, `SwapRoute.Success`, `SwapRoute.SelectToken(isFromDirection)` rendered via `Children` composable with fade animation +- `SlotNavigation` for approval bottom sheet (`GiveApprovalComponent`) +- `SlotNavigation` for fee selector block + +**Injected factories:** `SwapFeeSelectorBlockComponent.Factory`, `GiveApprovalComponent.Factory`, `ChooseTokenComponent.Factory`. + +### SwapModel (impl) +`@ModelScoped`, extends `Model()`. The central coordinator — ~1500 lines. + +**Key state:** +- `dataStateStateFlow: MutableStateFlow` — reactive domain data (from/to tokens, pairs, providers, amounts, fees) +- `uiState: SwapStateHolder by mutableStateOf()` — Compose UI state built by `StateBuilder` +- `feeSelectorRepository: FeeSelectorRepository` — fee state management +- `stackNavigation: StackNavigation` — stack navigation exposed from `SwapRouter` +- `approvalSlotNavigation: SlotNavigation` — approval bottom sheet + +**Navigation:** +- `SwapRouter` wraps `AppRouter` + `StackNavigation` for screen switching and back navigation +- `swapRouter.openScreen(SwapRoute.SelectToken(isFromDirection))` to push token selection +- `swapRouter.openScreen(SwapRoute.Success)` replaces current with success screen +- `swapRouter.back()` — pops local stack or exits swap via AppRouter + +**Initialization flow (init block):** +1. Subscribes to `chooseTokenBridge.onCurrencyChosen` → `onTokenSelect(result)` +2. Subscribes to `chooseTokenBridge.onClose` → pops slot navigation +3. Checks `ShouldShowStoriesUseCase` → pushes `AppRoute.Stories` if first-time swap +4. Resolves user country for FCA restrictions +5. Loads primary account status, initial currencies, and starts swap pair loading + +**Token selection flow:** +1. User taps FROM or TO card → `onSelectTokenClick(direction)` pushes `SwapRoute.SelectToken(isFromDirection)` to stack +2. Stack creates `ChooseTokenComponent` with appropriate bridge (FROM or TO) +3. `ChooseTokenBridge` communicates selection result via Channel +4. `onTokenSelect(result)` assigns selected token to FROM or TO based on `isFromDirection` + +**Swap execution flow:** +1. `onSwapClick()` — validates state, checks approval, initiates transaction +2. If approval needed → `approvalSlotNavigation.activate(Unit)` +3. On approval done → reloads quotes +4. On swap success → `swapRouter.openScreen(SwapRoute.Success)` + +### StateBuilder (impl) +Pure transformation class. Takes `UiActions` + providers, builds `SwapStateHolder` from `SwapProcessDataState`. + +Key methods: `createInitialLoadingState`, `createQuotesLoadedState`, `createSuccessState`, `loadingPermissionState`, `updateSwapAmount`, `addNotification`, `dismissBottomSheet`. + +### SwapRouter (impl) +Wraps `AppRouter` + `StackNavigation`. Handles `openScreen(SwapRoute)` to push/replace stack entries and `back()` with special logic: SelectToken pops local stack, Success exits to screen before SwapCrypto in app stack, Main pops AppRouter. `openTokenDetails()` navigates to `AppRoute.CurrencyDetails`. + +## Token Selection Subfeature (impl) + +Self-contained within `choosetoken/` package: +- `ChooseTokenComponent` — API with `Params(bridge, settings, analyticsPayload)` +- `ChooseTokenBridge` — Channel-based communication: `onCurrencyChosen`, `onClose`, `onTokenSelected` (legacy), `onNewTokenAdded` (legacy). Has `settingsStateFlow` for dynamic settings. +- `ChooseTokenComponent.Settings` — `SwapFrom` (no market block) vs `SwapTo` (with market block) +- `ChooseTokenResult` — Contains `CryptoCurrencyStatus`, `AccountStatus`, `UserWallet` +- `DefaultChooseTokenComponent` — Has its own `ChooseTokenModel` and optional `AddToPortfolioComponent` bottom sheet slot + +## Domain Layer + +### SwapInteractor +Central domain interface. Methods: +- `getPair(from, to, filterProviderTypes)` → `Either>` +- `findBestQuote(from, to, providers, amount, ...)` → `Map` +- `onSwap(from, to, provider, swapData, amount, fee, ...)` → `SwapTransactionState` +- `loadFeeForSwapTransaction(...)` → `Either` +- `getInitialCurrencyToSwap(accountStatusList, fromUserWallet, isReverse)` → `AccountCryptoCurrencyStatus?` +- `getTokenBalance(token)` → `SwapAmount` + +### Key Domain Models +- `SwapPairLeast` — from/to token info + providers list +- `SwapProvider` — providerId, name, type (DEX/CEX/DEX_BRIDGE), rates, slippage, TOS links +- `SwapState` — sealed: `QuotesLoadedState`, `SwapError`, `EmptyAmountState` +- `SwapCurrencyStatus` — wraps `CryptoCurrencyStatus` + `UserWallet` + `Account` +- `SwapAmount` — value + decimals pair +- `SwapDataModel` — quote result with transaction data + +## DI Modules + +| Module | Scope | Bindings | +|--------|-------|----------| +| `SwapFeatureModule` | Singleton | `SwapComponent.Factory`, `SwapFeatureToggles` | +| `SwapModelModule` | ModelComponent | `SwapModel` into model map | +| `SwapEntryModule` | Singleton + Model | `SwapEntryComponent.Factory`, `SwapEntryModel` | +| `ChooseTokenModule` | Singleton + Model | `ChooseTokenComponent.Factory`, `ChooseTokenBridge.Factory`, `ChooseTokenModel` | +| `SwapSingletonModule` | Singleton | `AmountFormatter` | + +## UI Layer + +- `SwapScreen` — main swap composable (send card, receive card, swap button, provider, notifications, fee) +- `SwapSuccessScreen` — post-swap success with transaction details +- `SwapScreenContent` — layout with `ConstraintLayout` for card positioning +- `TransactionCard` / `TransactionCardEmpty` — token cards with amount input +- Token cards pass `TokenSelectionDirection.FROM` / `.TO` to `onSelectTokenClick` + +## Navigation Summary + +``` +AppRouter (global) + └─ AppRoute.Swap → DefaultSwapComponent + ├─ childStack(SwapRoute) + │ ├─ SwapRoute.Main → SwapMainChild (renders SwapScreen) + │ ├─ SwapRoute.Success → SwapSuccessChild (renders SwapSuccessScreen) + │ └─ SwapRoute.SelectToken → ChooseTokenComponent (FROM or TO bridge) + ├─ SlotNavigation (Approval) + │ └─ GiveApprovalComponent (bottom sheet) + └─ SlotNavigation + └─ SwapFeeSelectorBlockComponent (inline fee block) +``` + +## Build Commands + +```bash +./gradlew :features:swap:impl:compileDebugKotlin +./gradlew :features:swap:api:compileDebugKotlin +./gradlew :features:swap:domain:compileDebugKotlin +./gradlew :features:swap:impl:detekt +``` \ No newline at end of file diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt index 2d0f54f7b2..6b8f708a35 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt @@ -9,11 +9,10 @@ import java.math.BigDecimal interface SwapComponent : ComposableContentComponent { data class Params( - val currencyFrom: CryptoCurrency, - val currencyTo: CryptoCurrency? = null, val userWalletId: UserWalletId, - val isInitialReverseOrder: Boolean = false, + val cryptoCurrency: CryptoCurrency? = null, val screenSource: String, + val currencyPosition: CurrencyPosition = CurrencyPosition.ANY, val tangemPayInput: TangemPayInput? = null, ) { data class TangemPayInput( @@ -22,6 +21,16 @@ interface SwapComponent : ComposableContentComponent { val depositAddress: String, val isWithdrawal: Boolean, ) + + /** Preferred position of the pre-selected currency on the swap screen. */ + enum class CurrencyPosition { + /** Force-place as the FROM (send) currency. */ + FROM, + /** Force-place as the TO (receive) currency. */ + TO, + /** Auto-determine position based on availability and balance. */ + ANY, + } } interface Factory : ComponentFactory diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index e27cb8705f..21cc38dfd0 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -23,6 +23,7 @@ import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.feature.swap.converters.* import com.tangem.feature.swap.domain.api.SwapRepository @@ -226,7 +227,8 @@ internal class DefaultSwapRepository( } override suspend fun getExchangeStatus( - userWallet: UserWallet, + userWallet: UserWallet?, + userWalletId: UserWalletId, txId: String, ): Either { return withContext(coroutineDispatcher.io) { @@ -236,7 +238,7 @@ internal class DefaultSwapRepository( exchangeStatusConverter.convert( tangemExpressApi .getExchangeStatus( - userWalletId = userWallet.walletId.stringValue, + userWalletId = userWalletId.stringValue, refCode = ExpressUtils.getRefCode( userWallet = userWallet, appPreferencesStore = appPreferencesStore, 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 1d102e458e..fe9a0e7d93 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 @@ -41,7 +41,8 @@ internal class DefaultSwapTransactionRepository( private val userTokensResponseFactory = UserTokensResponseFactory() override suspend fun storeTransaction( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, @@ -62,7 +63,8 @@ internal class DefaultSwapTransactionRepository( val tokenTransactions = savedTransactions ?.firstOrNull { savedTx -> savedTx.checkId( - checkUserWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) @@ -76,7 +78,8 @@ internal class DefaultSwapTransactionRepository( mutablePreferences.setObject( key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, value = savedTransactions?.updateList( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -84,7 +87,8 @@ internal class DefaultSwapTransactionRepository( transactions = tokenTransactions, ) ?: listOf( converter.default( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -111,13 +115,13 @@ internal class DefaultSwapTransactionRepository( ) { savedTransactions, txStatuses, accountList -> val currencyToTxs = savedTransactions?.filter { savedTx -> - val isUserWallet = savedTx.userWalletId == userWallet.walletId.stringValue + val isUserWallet = savedTx.toUserWalletId == userWallet.walletId.stringValue val isToCurrency = savedTx.toCryptoCurrencyId == cryptoCurrencyId.value isUserWallet && isToCurrency } val currencyFromTxs = savedTransactions?.filter { savedTx -> - val isUserWallet = savedTx.userWalletId == userWallet.walletId.stringValue + val isUserWallet = savedTx.fromUserWalletId == userWallet.walletId.stringValue val isFromCurrency = savedTx.fromCryptoCurrencyId == cryptoCurrencyId.value isUserWallet && isFromCurrency } @@ -227,18 +231,21 @@ internal class DefaultSwapTransactionRepository( } private fun SavedSwapTransactionListModelInner.checkId( - checkUserWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCurrencyId: CryptoCurrency.ID, toCurrencyId: CryptoCurrency.ID, ): Boolean { - return userWalletId == checkUserWalletId.stringValue && + return this.fromUserWalletId == fromUserWalletId.stringValue && + this.toUserWalletId == toUserWalletId.stringValue && toCryptoCurrencyId == toCurrencyId.value && fromCryptoCurrencyId == fromCurrencyId.value } @Suppress("LongParameterList") private fun List.updateList( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, @@ -247,7 +254,8 @@ internal class DefaultSwapTransactionRepository( ): List { return addOrReplace( item = converter.default( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -256,7 +264,8 @@ internal class DefaultSwapTransactionRepository( ), predicate = { savedTx -> savedTx.checkId( - checkUserWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt index dba1d47550..10b480b9a1 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt @@ -28,7 +28,8 @@ internal class SavedSwapTransactionListConverter( private val userTokensResponseFactory = UserTokensResponseFactory() override fun convert(value: SavedSwapTransactionListModel) = SavedSwapTransactionListModelInner( - userWalletId = value.userWalletId, + fromUserWalletId = value.fromUserWalletId, + toUserWalletId = value.toUserWalletId, fromCryptoCurrencyId = value.fromCryptoCurrencyId, toCryptoCurrencyId = value.toCryptoCurrencyId, fromTokensResponse = userTokensResponseFactory.createResponseToken( @@ -98,7 +99,8 @@ internal class SavedSwapTransactionListConverter( val statusWithRefundCurrency = status?.copy(refundCurrency = refundCurrency) tx.copy(status = statusWithRefundCurrency) }, - userWalletId = value.userWalletId, + fromUserWalletId = value.fromUserWalletId, + toUserWalletId = value.toUserWalletId, fromCryptoCurrencyId = value.fromCryptoCurrencyId, toCryptoCurrencyId = value.toCryptoCurrencyId, fromCryptoCurrency = fromCryptoCurrency, @@ -117,14 +119,16 @@ internal class SavedSwapTransactionListConverter( @Suppress("LongParameterList") fun default( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, toAccount: Account?, tokenTransactions: List, ) = SavedSwapTransactionListModelInner( - userWalletId = userWalletId.stringValue, + fromUserWalletId = fromUserWalletId.stringValue, + toUserWalletId = toUserWalletId.stringValue, fromCryptoCurrencyId = fromCryptoCurrency.id.value, toCryptoCurrencyId = toCryptoCurrency.id.value, fromTokensResponse = userTokensResponseFactory.createResponseToken( diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index b76d4c6e4c..5a567049e0 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -20,6 +20,8 @@ dependencies { kapt(deps.hilt.kapt) /** Domain */ + implementation(projects.domain.swap.models) + implementation(projects.domain.swap) implementation(projects.domain.appCurrency) implementation(projects.domain.appCurrency.models) implementation(projects.domain.card) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt deleted file mode 100644 index e8d4a35b72..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tangem.feature.swap.domain - -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency -import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress -import com.tangem.feature.swap.domain.models.ui.getGroupWithReverse -import com.tangem.utils.extensions.orZero - -internal class DefaultInitialToCurrencyResolver( - private val swapTransactionRepository: SwapTransactionRepository, -) : InitialToCurrencyResolver { - - override suspend fun tryGetFromCache( - userWallet: UserWallet, - initialCryptoCurrency: CryptoCurrency, - state: TokensDataStateExpress, - isReverseFromTo: Boolean, - ): AccountSwapCurrency? { - val id = swapTransactionRepository.getLastSwappedCryptoCurrencyId(userWallet.walletId) ?: return null - - return if (id != initialCryptoCurrency.id.value) { - val group = state.getGroupWithReverse(isReverseFromTo) - - group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) -> - currencyList.find { it.isAvailable && it.cryptoCurrencyStatus.currency.id.value == id } - } - } else { - null - } - } - - override fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency? { - val group = state.getGroupWithReverse(isReverseFromTo) - return group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) -> - currencyList.maxByOrNull { swapAccountCurrency -> - swapAccountCurrency.cryptoCurrencyStatus.value.fiatAmount - .takeIf { swapAccountCurrency.isAvailable } - .orZero() - } - } - } -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt deleted file mode 100644 index da6436ee57..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.feature.swap.domain - -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency -import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress - -interface InitialToCurrencyResolver { - - suspend fun tryGetFromCache( - userWallet: UserWallet, - initialCryptoCurrency: CryptoCurrency, - state: TokensDataStateExpress, - isReverseFromTo: Boolean, - ): AccountSwapCurrency? - - fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency? -} \ No newline at end of file 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 ae3c82c978..98d5c4d9e1 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 @@ -2,87 +2,58 @@ package com.tangem.feature.swap.domain import arrow.core.Either import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.express.models.ExpressError import com.tangem.domain.express.models.ExpressOperationType -import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount -import com.tangem.feature.swap.domain.models.domain.PermissionOptions -import com.tangem.feature.swap.domain.models.domain.SwapDataModel -import com.tangem.feature.swap.domain.models.domain.SwapProvider -import com.tangem.feature.swap.domain.models.ui.* +import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.SwapTransactionState +import com.tangem.feature.swap.domain.models.ui.TxFee import java.math.BigDecimal interface SwapInteractor { - suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress + suspend fun getPair( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + ): Either> - /** - * Gives permission to swap, this starts scan card process - * - * @param networkId network in which selected token - * @param permissionOptions data to give permissions - */ - @Throws(IllegalStateException::class) - suspend fun givePermissionToSwap(networkId: String, permissionOptions: PermissionOptions): SwapTransactionState + suspend fun findProvidersForPairWithCheck( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + pairs: List, + ): List + + fun findProvidersForPair( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + pairs: List, + ): List - /** - * Find best quote for given tokens to swap - * under the hood calls different methods to receive data, depends on permission for given token - * - * @param fromToken token from which want to swap - * @param fromAccount account from which swap will be made - * @param toToken token that receive after swap - * @param toAccount account to which receive token after swap - * @param providers list of providers to find quote - * @param amountToSwap amount you want to swap - * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) - * @param txFeeSealedState selected fee to swap - * @return - */ @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun findBestQuote( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, txFeeSealedState: TxFeeSealedState, ): Map - /** - * Starts swap transaction, perform sign transaction - * - * @param swapProvider swap provider to use - * @param swapData tx data to swap, contains data to sign - * @param currencyToSend crypto currency to send - * @param currencyToGet crypto currency to get - * @param fromAccount account from which swap will be made - * @param toAccount account to which receive token - * @param amountToSwap amount to swap - * @param includeFeeInAmount flag to include fee in amount - * @param fee for tx (can be null only for tangem pay withdrawal) - * @param expressOperationType type of express operation - - * @return [SwapTransactionState] - */ @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun onSwap( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, swapProvider: SwapProvider, swapData: SwapDataModel?, - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, amountToSwap: String, includeFeeInAmount: IncludeFeeInAmount, fee: TxFee?, @@ -90,14 +61,6 @@ interface SwapInteractor { isTangemPayWithdrawal: Boolean, ): SwapTransactionState - // suspend fun updateQuotesStateWithSelectedFee( - // state: SwapState.QuotesLoadedState, - // selectedFee: FeeType, - // fromToken: CryptoCurrencyStatus, - // amountToSwap: String, - // reduceBalanceBy: BigDecimal, - // ): SwapState.QuotesLoadedState - /** * Returns token in wallet balance * @@ -105,27 +68,12 @@ interface SwapInteractor { */ fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount - /** - * Returns initial currency to swap as AccountSwapCurrency - * - * @param initialCryptoCurrency initial currency selected to swap - * @param state current tokens data state - * @param isReverseFromTo flag indicating the direction of the swap - */ - suspend fun getInitialCurrencyToSwap( - initialCryptoCurrency: CryptoCurrency, - state: TokensDataStateExpress, - isReverseFromTo: Boolean, - ): AccountSwapCurrency? - - suspend fun getNativeToken(network: Network): CryptoCurrency + suspend fun getNativeToken(swapCurrencyStatus: SwapCurrencyStatus): CryptoCurrency @Suppress("LongParameterList") suspend fun storeSwapTransaction( - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, swapProvider: SwapProvider, swapDataModel: SwapDataModel, @@ -135,51 +83,19 @@ interface SwapInteractor { averageDuration: Int? = null, ) - /** - * Loads fee for swap transaction - * - * @param fromToken token from which want to swap - * @param fromAccount account from which swap will be made - * @param toToken token that receive after swap - * @param toAccount account to which receive token after swap - * @param amount amount you want to swap - * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) - * @param selectedFeeToken selected token to pay fee or null to pay fee with coin - */ - @Suppress("LongParameterList") suspend fun loadFeeForSwapTransaction( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, provider: SwapProvider, selectedFeeToken: CryptoCurrencyStatus?, ): Either - /** - * Loads fee for swap transaction - * - * @param fromToken token from which want to swap - * @param fromAccount account from which swap will be made - * @param toToken token that receive after swap - * @param toAccount account to which receive token after swap - * @param amount amount you want to swap - * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) - */ - @Suppress("LongParameterList") suspend fun loadFeeForSwapTransaction( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, provider: SwapProvider, ): Either - - interface Factory { - fun create(selectedWalletId: UserWalletId): SwapInteractor - } } \ 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 2a5a8f55ea..6079eb8646 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 @@ -19,8 +19,6 @@ import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer -import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -28,19 +26,18 @@ import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.exchange.RampStateManager -import com.tangem.domain.express.models.ExpressOperationType -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.express.models.* import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayWithdrawExchangeState import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.swap.models.SwapTxType +import com.tangem.domain.swap.usecase.GetSwapPairUseCase import com.tangem.domain.tokens.GetAssetRequirementsUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer @@ -59,7 +56,6 @@ import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount @@ -69,16 +65,17 @@ import com.tangem.feature.swap.domain.models.ui.* import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.* +import jakarta.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.supervisorScope import java.math.BigDecimal import java.math.BigInteger import java.math.RoundingMode @Suppress("LargeClass", "LongParameterList") -internal class SwapInteractorImpl @AssistedInject constructor( +internal class SwapInteractorImpl @Inject constructor( private val repository: SwapRepository, private val allowPermissionsHandler: AllowPermissionsHandler, private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, @@ -86,7 +83,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val createTransactionUseCase: CreateTransactionUseCase, private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase, - private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, private val isDemoCardUseCase: IsDemoCardUseCase, private val quotesRepository: QuotesRepository, private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, @@ -95,7 +91,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val appCurrencyRepository: AppCurrencyRepository, private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val initialToCurrencyResolver: InitialToCurrencyResolver, private val validateTransactionUseCase: ValidateTransactionUseCase, private val estimateFeeUseCase: EstimateFeeUseCase, private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase, @@ -104,16 +99,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, private val getFeeUseCase: GetFeeUseCase, private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase, - private val getUserWalletUseCase: GetUserWalletUseCase, private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase, private val amountFormatter: AmountFormatter, private val rampStateManager: RampStateManager, - private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val walletManagersFacade: WalletManagersFacade, private val getAllowanceInfoUseCase: GetAllowanceInfoUseCase, - @Assisted private val userWalletId: UserWalletId, + private val getSwapPairUseCase: GetSwapPairUseCase, ) : SwapInteractor { private val getSelectedAppCurrencyUseCase by lazy(LazyThreadSafetyMode.NONE) { @@ -122,211 +115,79 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val hundredPercent = BigInteger("100") - private val userWallet - get() = getUserWalletUseCase(userWalletId).getOrElse { - error("Failed to get user wallet") - } - - override suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { - return getAccountCurrencyTokensDataState(currency) - } - - private suspend fun getAccountCurrencyTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { - val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull( - SingleAccountStatusListProducer.Params(userWalletId), - )?.accountStatuses.orEmpty() - - val walletAccountCurrencyStatusesExceptInitial: Map> = - walletAccountCurrencyStatuses.mapNotNull { accountStatus -> - val filteredCurrencies = when (accountStatus) { - is AccountStatus.CryptoPortfolio -> accountStatus.flattenCurrencies().filterCurrencies(currency) - is AccountStatus.Payment -> getPaymentAccountCurrencies(accountStatus) + override suspend fun getPair( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + ): Either> { + return getSwapPairUseCase( + primarySwapCurrencyStatus = fromSwapCurrencyStatus, + secondarySwapCurrencyStatus = toSwapCurrencyStatus, + filterProviderTypes = filterProviderTypes.map { type -> + // Temporary solution until domain layer is migrated + when (type) { + ExchangeProviderType.DEX -> ExpressProviderType.DEX + ExchangeProviderType.CEX -> ExpressProviderType.CEX + ExchangeProviderType.DEX_BRIDGE -> ExpressProviderType.DEX_BRIDGE } - - if (filteredCurrencies.isNotEmpty()) { - accountStatus.account to filteredCurrencies - } else { - null - } - }.toMap() - - if (walletAccountCurrencyStatusesExceptInitial.isEmpty()) { - return TokensDataStateExpress.EMPTY - } - - val pairsLeast = getPairs( - userWallet = userWallet, - initialCurrency = LeastTokenInfo( - contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", - network = currency.network.rawId, - ), - currenciesList = walletAccountCurrencyStatusesExceptInitial.flatMap { accountStatus -> - accountStatus.value.map { it.currency } }, - ) - - return TokensDataStateExpress( - fromGroup = getToCurrenciesGroup( - currency = currency, - leastPairs = pairsLeast.pairs, - cryptoCurrenciesList = walletAccountCurrencyStatusesExceptInitial, - tokenInfoForFilter = { it.to }, - tokenInfoForAvailable = { it.from }, - ), - toGroup = getToCurrenciesGroup( - currency = currency, - leastPairs = pairsLeast.pairs, - cryptoCurrenciesList = walletAccountCurrencyStatusesExceptInitial, - tokenInfoForFilter = { it.from }, - tokenInfoForAvailable = { it.to }, - ), - allProviders = pairsLeast.allProviders, - ) - } - - private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List { - val currencyStatus = when (val statusValue = accountStatus.value) { - is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus - else -> return emptyList() - } - - return listOf(currencyStatus) - } - - private fun List.filterCurrencies(currency: CryptoCurrency) = this.filter { status -> - val isDifferentCurrency = status.currency.network.rawId != currency.network.rawId || - status.currency.getContractAddress() != currency.getContractAddress() - - val hasValidStatus = - status.value is CryptoCurrencyStatus.Loaded || status.value is CryptoCurrencyStatus.NoAccount - val isNotCustomToken = !status.currency.isCustom - - hasValidStatus && isDifferentCurrency && isNotCustomToken - } - - private suspend fun getToCurrenciesGroup( - currency: CryptoCurrency, - leastPairs: List, - cryptoCurrenciesList: Map>, - tokenInfoForFilter: (SwapPairLeast) -> LeastTokenInfo, - tokenInfoForAvailable: (SwapPairLeast) -> LeastTokenInfo, - ): CurrenciesGroup { - val filteredPairs = leastPairs.filter { pair -> - tokenInfoForFilter(pair).contractAddress == currency.getContractAddress() && - tokenInfoForFilter(pair).network == currency.network.rawId - } - - val accountCurrencyList = cryptoCurrenciesList.map { (accountEntry, currencyStatusList) -> - AccountSwapAvailability( - account = accountEntry, - currencyList = currencyStatusList.map { currencyStatus -> - val providers = findProvidersForPair( - cryptoCurrencyStatuses = currencyStatus, - swapPairsLeastList = filteredPairs, - tokenInfoForAvailable = tokenInfoForAvailable, - ) - val isUnavailable = providers.isNullOrEmpty() - AccountSwapCurrency( - isAvailable = !isUnavailable, - account = accountEntry, - cryptoCurrencyStatus = currencyStatus, - providers = providers.orEmpty(), - ) - }, - ) - } - - return CurrenciesGroup( - available = emptyList(), - unavailable = emptyList(), - accountCurrencyList = accountCurrencyList, - isAfterSearch = false, - ) - } - - private suspend fun findProvidersForPair( - cryptoCurrencyStatuses: CryptoCurrencyStatus, - swapPairsLeastList: List, - tokenInfoForAvailable: (SwapPairLeast) -> LeastTokenInfo, - ): List? { - val requirements = getAssetRequirementsUseCase.invoke(userWalletId, cryptoCurrencyStatuses.currency).getOrNull() - val isAvailableForSwap = rampStateManager.checkAssetRequirements(requirements) - - return swapPairsLeastList.firstNotNullOfOrNull { pair -> - val listTokenInfo = tokenInfoForAvailable(pair) - if (cryptoCurrencyStatuses.currency.network.rawId == listTokenInfo.network && - cryptoCurrencyStatuses.currency.getContractAddress() == listTokenInfo.contractAddress && - isAvailableForSwap - ) { - pair.providers - } else { - null + swapTxType = SwapTxType.Swap, + ).map { pairs -> + pairs.map { pair -> + SwapPairLeast( + from = LeastTokenInfo( + contractAddress = pair.from.currency.getContractAddress(), + network = pair.from.currency.network.rawId, + ), + to = LeastTokenInfo( + contractAddress = pair.to.currency.getContractAddress(), + network = pair.to.currency.network.rawId, + ), + providers = pair.providers.map { provider -> + provider.toSwapProvider() + }, + ) } } } - private fun CryptoCurrency.getContractAddress(): String { - return when (this) { - is CryptoCurrency.Token -> this.contractAddress - is CryptoCurrency.Coin -> "0" - } + override fun findProvidersForPair( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + pairs: List, + ): List { + return pairs.firstOrNull { pair -> + pair.from.network == fromSwapCurrencyStatus.currency.network.rawId && + pair.from.contractAddress == fromSwapCurrencyStatus.currency.getContractAddress() && + pair.to.network == toSwapCurrencyStatus.currency.network.rawId + pair.to.contractAddress == toSwapCurrencyStatus.currency.getContractAddress() + }?.providers.orEmpty() } - private suspend fun getPairs( - userWallet: UserWallet, - initialCurrency: LeastTokenInfo, - currenciesList: List, - ): PairsWithProviders { - return repository.getPairs( - userWallet = userWallet, - initialCurrency = initialCurrency, - currencyList = currenciesList, + override suspend fun findProvidersForPairWithCheck( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + pairs: List, + ): List { + val requirements = getAssetRequirementsUseCase.invoke( + fromSwapCurrencyStatus.userWalletId, + fromSwapCurrencyStatus.currency, + ).getOrNull() + + if (!rampStateManager.checkAssetRequirements(requirements)) { + return emptyList() + } + + return findProvidersForPair( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + pairs = pairs, ) } - override suspend fun givePermissionToSwap( - networkId: String, - permissionOptions: PermissionOptions, - ): SwapTransactionState { - val amount = permissionOptions.approveData.fromTokenAmount.takeIf { - permissionOptions.approveType == SwapApproveType.LIMITED - } - - val approveTransaction = createApprovalTransactionUseCase( - fee = permissionOptions.txFee.fee, - userWalletId = userWalletId, - cryptoCurrencyStatus = permissionOptions.fromTokenStatus, - amount = amount?.value, - contractAddress = permissionOptions.forTokenContractAddress, - spenderAddress = permissionOptions.spenderAddress, - ).getOrElse { error -> - TangemLogger.e("Failed to create approveTransaction", error) - return SwapTransactionState.Error.UnknownError - } - - val result = sendTransactionUseCase( - txData = approveTransaction, - userWallet = userWallet, - network = permissionOptions.fromTokenStatus.currency.network, - ) - return result.fold( - ifRight = { hash -> - allowPermissionsHandler.addAddressToInProgress(permissionOptions.forTokenContractAddress) - SwapTransactionState.TxSent( - txHash = hash, - timestamp = System.currentTimeMillis(), - ) - }, - ifLeft = { SwapTransactionState.Error.TransactionError(it) }, - ) - } - - @Suppress("LongMethod") override suspend fun findBestQuote( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, @@ -335,10 +196,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( TangemLogger.i( """ Find the best quote - |- fromToken: $fromToken - |- fromAccount: $fromAccount - |- toToken: $toToken - |- toAccount: $toAccount + |- fromSwapCurrencyStatus: + |---- walletId: ${fromSwapCurrencyStatus.userWalletId} + |---- accountId: ${fromSwapCurrencyStatus.account.accountId} + |---- currencyId: ${fromSwapCurrencyStatus.currency.id} + |- toSwapCurrencyStatus: $toSwapCurrencyStatus + |---- walletId: ${toSwapCurrencyStatus.userWalletId} + |---- accountId: ${toSwapCurrencyStatus.account.accountId} + |---- currencyId: ${toSwapCurrencyStatus.currency.id} |- providers: $providers |- amountToSwap: $amountToSwap |- selectedFee: $txFeeSealedState @@ -349,74 +214,46 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (amountDecimal == null || amountDecimal.signum() == 0) { return providers.associateWith { createEmptyAmountState() } } - val amount = SwapAmount(amountDecimal, fromToken.currency.decimals) - val isBalanceWithoutFeeEnough = when (fromAccount) { - is Account.Payment -> true - else -> isBalanceEnough(fromToken, amount, null) - } - val networkId = fromToken.currency.network.rawId - + val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) + val isBalanceWithoutFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, null) return supervisorScope { providers.map { provider -> async { - try { - when (provider.type) { - ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - if (isSolana(networkId)) { - manageDexSolana( - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - provider = provider, - txFeeSealedState = txFeeSealedState, - amount = amount, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - expressOperationType = ExpressOperationType.SWAP, - ) - } else { - manageDex( - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - provider = provider, - txFeeSealedState = txFeeSealedState, - amount = amount, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - expressOperationType = ExpressOperationType.SWAP, - ) - } - } - ExchangeProviderType.CEX -> { - manageCex( - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + when (provider.type) { + ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { + if (isSolana(fromSwapCurrencyStatus.currency.network.rawId)) { + manageDexSolana( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, - amount = amount, - reduceBalanceBy = reduceBalanceBy, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, txFeeSealedState = txFeeSealedState, + amount = amount, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + expressOperationType = ExpressOperationType.SWAP, + ) + } else { + manageDex( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + provider = provider, + txFeeSealedState = txFeeSealedState, + amount = amount, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + expressOperationType = ExpressOperationType.SWAP, ) } } - } catch (e: Throwable) { - if (e is CancellationException) { - throw e + ExchangeProviderType.CEX -> { + manageCex( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + provider = provider, + amount = amount, + reduceBalanceBy = reduceBalanceBy, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + txFeeSealedState = txFeeSealedState, + ) } - TangemLogger.e("Failed to find quote for provider: ${provider.providerId}", e) - provider to createSwapErrorWith( - fromToken = fromToken, - fromAccount = fromAccount, - amount = amount, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - expressDataError = ExpressDataError.UnknownError, - ) } } }.awaitAll().toMap() @@ -425,46 +262,42 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongMethod") private suspend fun manageDex( - networkId: String, - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, isBalanceWithoutFeeEnough: Boolean, expressOperationType: ExpressOperationType, ): Pair { - if (fromToken.value.yieldSupplyStatus?.isActive == true) { + if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true) { return provider to produceDexSwapDataError( error = ExpressDataError.DexActiveSupplyError, - fromToken = fromToken, - fromAccount = fromAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, ) } val maybeQuotes = repository.findBestQuote( - userWallet = userWallet, - fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.rawId, - toContractAddress = toToken.currency.getContractAddress(), - toNetwork = toToken.currency.network.rawId, + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, - toDecimals = toToken.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = provider.providerId, rateType = RateType.FLOAT, ) - val fromTokenAddress = getTokenAddress(fromToken.currency) + val fromTokenAddress = getTokenAddress(fromSwapCurrencyStatus.currency) val isAllowedToSpend = maybeQuotes.fold( ifRight = { quotes -> quotes.allowanceContract?.let { allowanceContract -> getAllowanceInfoUseCase( - userWalletId = userWalletId, - cryptoCurrency = fromToken.currency, + userWalletId = fromSwapCurrencyStatus.userWalletId, + cryptoCurrency = fromSwapCurrencyStatus.currency, spenderAddress = allowanceContract, requiredAmount = amount.value, ).getOrNull() is AllowanceInfo.Enough @@ -475,16 +308,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) { allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress) - cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currency = fromToken.currency) + cryptoCurrencyBalanceFetcher( + userWalletId = fromSwapCurrencyStatus.userWalletId, + currency = fromSwapCurrencyStatus.currency, + ) } return if (isAllowedToSpend && isBalanceWithoutFeeEnough) { provider to loadDexSwapData( provider = provider, - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, txFeeSealedState = txFeeSealedState, expressOperationType = expressOperationType, @@ -494,11 +327,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, quoteDataModel = maybeQuotes, amount = amount, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, txFeeSealedState = txFeeSealedState, @@ -508,11 +338,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun manageDexSolana( - networkId: String, - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, @@ -520,14 +347,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( expressOperationType: ExpressOperationType, ): Pair { val maybeQuotes = repository.findBestQuote( - userWallet = userWallet, - fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.rawId, - toContractAddress = toToken.currency.getContractAddress(), - toNetwork = toToken.currency.network.rawId, + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, - toDecimals = toToken.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = provider.providerId, rateType = RateType.FLOAT, ) @@ -535,11 +362,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( return if (isBalanceWithoutFeeEnough && maybeQuotes.isRight()) { provider to loadDexSwapData( provider = provider, - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, txFeeSealedState = txFeeSealedState, expressOperationType = expressOperationType, @@ -549,11 +373,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, quoteDataModel = maybeQuotes, amount = amount, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = true, isBalanceWithoutFeeEnough = false, txFeeSealedState = txFeeSealedState, @@ -563,11 +384,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun manageCex( - networkId: String, - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, amount: SwapAmount, reduceBalanceBy: BigDecimal, @@ -575,13 +393,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( txFeeSealedState: TxFeeSealedState, ): Pair { return provider to loadCexQuoteData( - networkId = networkId, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromTokenStatus = fromToken, - fromAccount = fromAccount, - toTokenStatus = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = true, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, provider = provider, @@ -590,7 +405,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun manageWarnings( - fromTokenStatus: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, txFeeSealed: TxFeeSealedState?, includeFeeInAmount: IncludeFeeInAmount, @@ -614,7 +429,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( } ?: BigDecimal.ZERO val balanceAfterTransaction = getCoinBalanceAfterTransaction( - fromTokenStatus = fromTokenStatus, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, includeFeeInAmount = includeFeeInAmount, fee = fee, @@ -625,12 +440,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( amount } val feePaidCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = fromTokenStatus, + userWalletId = fromSwapCurrencyStatus.userWalletId, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ).getOrNull() val currencyCheck = getCurrencyCheckUseCase( - userWalletId = userWalletId, - currencyStatus = fromTokenStatus, + userWalletId = fromSwapCurrencyStatus.userWalletId, + currencyStatus = fromSwapCurrencyStatus.status, feeCurrencyStatus = feePaidCurrencyStatus, amount = amountToRequest.value, fee = fee, @@ -641,14 +456,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun getCoinBalanceAfterTransaction( - fromTokenStatus: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, includeFeeInAmount: IncludeFeeInAmount, fee: BigDecimal, ): BigDecimal? { - return when (fromTokenStatus.currency) { + return when (fromSwapCurrencyStatus.currency) { is CryptoCurrency.Coin -> { - val statusValue = fromTokenStatus.value as? CryptoCurrencyStatus.Loaded + val statusValue = fromSwapCurrencyStatus.status.value as? CryptoCurrencyStatus.Loaded when (includeFeeInAmount) { is IncludeFeeInAmount.Included -> { statusValue?.let { it.amount - includeFeeInAmount.amountSubtractFee.value - fee } @@ -660,15 +475,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } is CryptoCurrency.Token -> { - val feePaidCurrency = getFeePaidCurrency( - currency = fromTokenStatus.currency, - ) + val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus) when (feePaidCurrency) { FeePaidCurrency.Coin -> { val nativeBalance = walletManagersFacade.getNativeTokenBalance( - userWalletId = userWalletId, - networkId = fromTokenStatus.currency.network.rawId, - derivationPath = fromTokenStatus.currency.network.derivationPath.value, + userWalletId = fromSwapCurrencyStatus.userWalletId, + networkId = fromSwapCurrencyStatus.currency.network.rawId, + derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value, ) nativeBalance - fee @@ -680,12 +493,11 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun manageTransactionValidationWarnings( - fromToken: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, txFeeSealedState: TxFeeSealedState, - userWalletId: UserWalletId, ): Throwable? { - val currency = fromToken.currency + val currency = fromSwapCurrencyStatus.currency val blockchain = currency.network.toBlockchain() // Stellar validation removed because swap uses destination = "0" and throws an error if (blockchain == Blockchain.Stellar) { @@ -710,11 +522,11 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) val result = validateTransactionUseCase( - amount = amount.value.convertToSdkAmount(fromToken), + amount = amount.value.convertToSdkAmount(fromSwapCurrencyStatus.status), fee = fee, memo = null, - destination = getTokenAddress(fromToken.currency), - userWalletId = userWalletId, + destination = getTokenAddress(fromSwapCurrencyStatus.currency), + userWalletId = fromSwapCurrencyStatus.userWalletId, network = currency.network, ).leftOrNull() @@ -723,12 +535,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("NullableToStringCall") override suspend fun onSwap( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, swapProvider: SwapProvider, swapData: SwapDataModel?, - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, amountToSwap: String, includeFeeInAmount: IncludeFeeInAmount, fee: TxFee?, @@ -740,15 +550,21 @@ internal class SwapInteractorImpl @AssistedInject constructor( Swap |- swapProvider: $swapProvider |- swapData: $swapData - |- currencyToSend: $currencyToSend - |- currencyToGet: $currencyToGet + |- fromSwapCurrencyStatus: + |---- walletId: ${fromSwapCurrencyStatus.userWalletId} + |---- accountId: ${fromSwapCurrencyStatus.account.accountId} + |---- currencyId: ${fromSwapCurrencyStatus.currency.id} + |- toSwapCurrencyStatus: $toSwapCurrencyStatus + |---- walletId: ${toSwapCurrencyStatus.userWalletId} + |---- accountId: ${toSwapCurrencyStatus.account.accountId} + |---- currencyId: ${toSwapCurrencyStatus.currency.id} |- amountToSwap: $amountToSwap |- includeFeeInAmount: $includeFeeInAmount |- fee: $fee """.trimIndent(), ) - val userWallet = userWallet + val userWallet = fromSwapCurrencyStatus.userWallet if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) { return SwapTransactionState.DemoMode } @@ -756,17 +572,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( return when (swapProvider.type) { ExchangeProviderType.CEX -> { val amountDecimal = toBigDecimalOrNull(amountToSwap) - val amount = SwapAmount(requireNotNull(amountDecimal), currencyToSend.currency.decimals) + val amount = SwapAmount(requireNotNull(amountDecimal), fromSwapCurrencyStatus.currency.decimals) val amountToSwapWithFee = if (includeFeeInAmount is IncludeFeeInAmount.Included) { includeFeeInAmount.amountSubtractFee } else { amount } onSwapCex( - currencyToSend = currencyToSend, - currencyToGet = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amountToSwapWithFee, txFee = fee, swapProvider = swapProvider, @@ -775,15 +589,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - val networkId = currencyToSend.currency.network.rawId + val networkId = fromSwapCurrencyStatus.currency.network.rawId if (isSolana(networkId)) { onSwapSolanaDex( provider = swapProvider, swapData = requireNotNull(swapData), - currencyToSendStatus = currencyToSend, - currencyToGetStatus = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amountToSwap = amountToSwap, ) } else { @@ -791,10 +603,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( onSwapDex( provider = swapProvider, swapData = requireNotNull(swapData), - currencyToSendStatus = currencyToSend, - currencyToGetStatus = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, txFee = fee, amountToSwap = amountToSwap, ) @@ -804,41 +614,41 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun onSwapDex( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, swapData: SwapDataModel, - currencyToSendStatus: CryptoCurrencyStatus, - currencyToGetStatus: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, amountToSwap: String, txFee: TxFee, ): SwapTransactionState { val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" } - val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) + val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX val dataToSign = dexTransaction.txData - val amountToSend = createNativeAmountForDex(txValue, currencyToSendStatus.currency.network) + val amountToSend = createNativeAmountForDex(txValue, fromSwapCurrencyStatus.currency.network) val txData = createTransactionUseCase( amount = amountToSend, fee = txFee.fee, memo = null, destination = swapData.transaction.txTo, - userWalletId = userWalletId, - network = currencyToSendStatus.currency.network, - txExtras = createDexTxExtras(dataToSign, currencyToSendStatus.currency.network, txFee.fee.getGasLimit()), + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = toSwapCurrencyStatus.currency.network, + txExtras = createDexTxExtras( + dataToSign, + fromSwapCurrencyStatus.currency.network, + txFee.fee.getGasLimit(), + ), ).getOrElse { error -> TangemLogger.e("Failed to create swap dex tx data", error) return SwapTransactionState.Error.UnknownError } return handleSwapResult( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, swapData = swapData, - currencyToSendStatus = currencyToSendStatus, - currencyToGetStatus = currencyToGetStatus, - fromAccount = fromAccount, - toAccount = toAccount, amount = amount, txData = txData, payInAddress = getPayoutAddress(txData), @@ -848,26 +658,22 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun onSwapSolanaDex( provider: SwapProvider, swapData: SwapDataModel, - currencyToSendStatus: CryptoCurrencyStatus, - currencyToGetStatus: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amountToSwap: String, ): SwapTransactionState { val dexTransaction = swapData.transaction as? ExpressTransactionModel.DEX val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val txDataBase64 = requireNotNull(dexTransaction?.txData) { "txData is null" } - val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) + val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) val compiledTransaction = TransactionData.Compiled( value = TransactionData.Compiled.Data.Bytes(Base64.decode(txDataBase64, Base64.NO_WRAP)), ) return handleSwapResult( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, swapData = swapData, - currencyToSendStatus = currencyToSendStatus, - currencyToGetStatus = currencyToGetStatus, - fromAccount = fromAccount, - toAccount = toAccount, amount = amount, txData = compiledTransaction, payInAddress = swapData.transaction.txTo, @@ -875,29 +681,27 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun handleSwapResult( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, swapData: SwapDataModel, - currencyToSendStatus: CryptoCurrencyStatus, - currencyToGetStatus: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, amount: SwapAmount, txData: TransactionData, payInAddress: String, ): SwapTransactionState { val result = sendTransactionUseCase( txData = txData, - userWallet = userWallet, - network = currencyToSendStatus.currency.network, + userWallet = fromSwapCurrencyStatus.userWallet, + network = fromSwapCurrencyStatus.currency.network, ) return result.fold( ifRight = { txHash -> - val networkAddress = currencyToSendStatus.value.networkAddress + val networkAddress = fromSwapCurrencyStatus.status.value.networkAddress val fromAddress = networkAddress?.defaultAddress?.value.orEmpty() repository.exchangeSent( - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, txId = swapData.transaction.txId, - fromNetwork = currencyToSendStatus.currency.network.rawId, + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, fromAddress = fromAddress, payInAddress = payInAddress, txHash = txHash, @@ -905,25 +709,23 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) val timestamp = System.currentTimeMillis() storeSwapTransaction( - currencyToSend = currencyToSendStatus, - currencyToGet = currencyToGetStatus, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, swapProvider = provider, swapDataModel = swapData, timestamp = timestamp, ) - storeLastCryptoCurrencyId(currencyToGetStatus.currency) + storeLastCryptoCurrencyId(fromSwapCurrencyStatus) SwapTransactionState.TxSent( fromAmount = amountFormatter.formatSwapAmountToUI( amount, - currencyToSendStatus.currency.symbol, + fromSwapCurrencyStatus.currency.symbol, ), fromAmountValue = amount.value, toAmount = amountFormatter.formatSwapAmountToUI( swapData.toTokenAmount, - currencyToGetStatus.currency.symbol, + toSwapCurrencyStatus.currency.symbol, ), toAmountValue = swapData.toTokenAmount.value, txHash = txHash, @@ -944,35 +746,33 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongMethod", "CanBeNonNullable") private suspend fun onSwapCex( - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, txFee: TxFee?, swapProvider: SwapProvider, expressOperationType: ExpressOperationType, isTangemPayWithdrawal: Boolean, ): SwapTransactionState { - val fromNetworkAddress = currencyToSend.value.networkAddress + val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() - val toNetworkAddress = currencyToGet.value.networkAddress + val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress val toAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() val exchangeData = repository.getExchangeData( - userWallet = userWallet, - fromContractAddress = currencyToSend.currency.getContractAddress(), - fromNetwork = currencyToSend.currency.network.rawId, - toContractAddress = currencyToGet.currency.getContractAddress(), + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), fromAddress = fromAddress, - toNetwork = currencyToGet.currency.network.rawId, + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, - toDecimals = currencyToGet.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = swapProvider.providerId, rateType = RateType.FLOAT, expressOperationType = expressOperationType, toAddress = toAddress, - refundAddress = currencyToSend.value.networkAddress?.defaultAddress?.value, + refundAddress = fromNetworkAddress?.defaultAddress?.value, refundExtraId = null, // currently always null, ).getOrElse { error -> return SwapTransactionState.Error.ExpressError(error) } @@ -980,26 +780,23 @@ internal class SwapInteractorImpl @AssistedInject constructor( exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.Error.UnknownError if (isTangemPayWithdrawal) { - val networkAddress = currencyToSend.value.networkAddress return SwapTransactionState.TangemPayWithdrawalData( cryptoAmount = amount.value, - cryptoCurrencyId = requireNotNull(currencyToSend.currency.id.rawCurrencyId), + cryptoCurrencyId = requireNotNull(fromSwapCurrencyStatus.currency.id.rawCurrencyId), cexAddress = exchangeDataCex.txTo, fromAmount = amountFormatter.formatSwapAmountToUI( amount, - currencyToSend.currency.symbol, + fromSwapCurrencyStatus.currency.symbol, ), fromAmountValue = amount.value, toAmount = amountFormatter.formatSwapAmountToUI( exchangeData.toTokenAmount, - currencyToGet.currency.symbol, + toSwapCurrencyStatus.currency.symbol, ), toAmountValue = exchangeData.toTokenAmount.value, storeData = SwapTransactionState.TangemPayWithdrawalData.StoreTransactionData( - currencyToSend = currencyToSend, - currencyToGet = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, swapProvider = swapProvider, swapDataModel = exchangeData, @@ -1009,26 +806,26 @@ internal class SwapInteractorImpl @AssistedInject constructor( ), exchangeData = TangemPayWithdrawExchangeState( txId = exchangeDataCex.txId, - fromNetwork = currencyToSend.currency.network.rawId, - fromAddress = networkAddress?.defaultAddress?.value.orEmpty(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty(), payInAddress = exchangeData.transaction.txTo, payInExtraId = exchangeDataCex.txExtraId, ), ) } - val userWallet = userWallet + val userWallet = fromSwapCurrencyStatus.userWallet if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) { return SwapTransactionState.Error.UnknownError } val fee = requireNotNull(txFee) val txData = createTransferTransactionUseCase( - amount = amount.value.convertToSdkAmount(currencyToSend), + amount = amount.value.convertToSdkAmount(fromSwapCurrencyStatus.status), fee = fee.fee, memo = exchangeDataCex.txExtraId, destination = exchangeDataCex.txTo, - userWalletId = userWalletId, - network = currencyToSend.currency.network, + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = fromSwapCurrencyStatus.currency.network, ).getOrElse { error -> TangemLogger.e("Failed to create swap CEX tx data", error) return SwapTransactionState.Error.UnknownError @@ -1052,7 +849,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( sendTransactionUseCase( txData = txData, userWallet = userWallet, - network = currencyToSend.currency.network, + network = fromSwapCurrencyStatus.currency.network, ) } } @@ -1060,12 +857,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( sendTransactionUseCase( txData = txData, userWallet = userWallet, - network = currencyToSend.currency.network, + network = fromSwapCurrencyStatus.currency.network, ) } } - val cexNetworkAddress = currencyToSend.value.networkAddress + val cexNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val cexFromAddress = cexNetworkAddress?.defaultAddress?.value.orEmpty() return result.fold( ifLeft = { error -> SwapTransactionState.Error.TransactionError(error) }, @@ -1073,7 +870,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( repository.exchangeSent( userWallet = userWallet, txId = exchangeDataCex.txId, - fromNetwork = currencyToSend.currency.network.rawId, + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, fromAddress = cexFromAddress, payInAddress = getPayoutAddress(txData), txHash = txHash, @@ -1082,10 +879,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( val timestamp = System.currentTimeMillis() val txExternalUrl = exchangeDataCex.externalTxUrl storeSwapTransaction( - currencyToSend = currencyToSend, - currencyToGet = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, swapProvider = swapProvider, swapDataModel = exchangeData, @@ -1093,16 +888,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( txExternalUrl = txExternalUrl, txExternalId = exchangeDataCex.externalTxId, ) - storeLastCryptoCurrencyId(currencyToGet.currency) + storeLastCryptoCurrencyId(toSwapCurrencyStatus) SwapTransactionState.TxSent( fromAmount = amountFormatter.formatSwapAmountToUI( amount, - currencyToSend.currency.symbol, + fromSwapCurrencyStatus.currency.symbol, ), fromAmountValue = amount.value, toAmount = amountFormatter.formatSwapAmountToUI( exchangeData.toTokenAmount, - currencyToGet.currency.symbol, + toSwapCurrencyStatus.currency.symbol, ), toAmountValue = exchangeData.toTokenAmount.value, txHash = txHash, @@ -1114,10 +909,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } override suspend fun storeSwapTransaction( - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, swapProvider: SwapProvider, swapDataModel: SwapDataModel, @@ -1127,11 +920,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( averageDuration: Int?, ) { swapTransactionRepository.storeTransaction( - userWalletId = userWalletId, - fromCryptoCurrency = currencyToSend.currency, - toCryptoCurrency = currencyToGet.currency, - fromAccount = fromAccount, - toAccount = toAccount, + fromUserWalletId = fromSwapCurrencyStatus.userWalletId, + toUserWalletId = toSwapCurrencyStatus.userWalletId, + fromCryptoCurrency = fromSwapCurrencyStatus.currency, + toCryptoCurrency = toSwapCurrencyStatus.currency, + fromAccount = fromSwapCurrencyStatus.account, + toAccount = toSwapCurrencyStatus.account, transaction = SavedSwapTransactionModel( txId = swapDataModel.transaction.txId, provider = swapProvider, @@ -1152,10 +946,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongParameterList") override suspend fun loadFeeForSwapTransaction( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, provider: SwapProvider, @@ -1173,16 +964,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( return if (selectedFeeToken != null) { estimateFeeForTokenUseCase( - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, feeTokenCurrencyStatus = selectedFeeToken, - sendingTokenCurrencyStatus = fromToken, + sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status, amount = amountDecimal, ) } else { estimateFeeForGaslessTxUseCase( amount = amountDecimal, - userWallet = userWallet, - sendingTokenCurrencyStatus = fromToken, + userWallet = fromSwapCurrencyStatus.userWallet, + sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status, ) } } @@ -1190,10 +981,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } override suspend fun loadFeeForSwapTransaction( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, provider: SwapProvider, @@ -1202,39 +991,36 @@ internal class SwapInteractorImpl @AssistedInject constructor( ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE, -> { - val fromNetworkAddress = fromToken.value.networkAddress + val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() - val toNetworkAddress = toToken.value.networkAddress + val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress val dexToAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() val amountBigDecimal = toBigDecimalOrNull(amount) if (amountBigDecimal == null || amountBigDecimal.signum() == 0) { raise(GetFeeError.UnknownError) } - val swapAmount = SwapAmount(amountBigDecimal, fromToken.currency.decimals) + val swapAmount = SwapAmount(amountBigDecimal, fromSwapCurrencyStatus.currency.decimals) repository.getExchangeData( - userWallet = userWallet, - fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.rawId, - toContractAddress = toToken.currency.getContractAddress(), + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), fromAddress = dexFromAddress, - toNetwork = toToken.currency.network.rawId, + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = swapAmount.toStringWithRightOffset(), fromDecimals = swapAmount.decimals, - toDecimals = toToken.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = provider.providerId, rateType = RateType.FLOAT, toAddress = dexToAddress, - refundAddress = fromToken.value.networkAddress?.defaultAddress?.value, + refundAddress = fromNetworkAddress?.defaultAddress?.value, expressOperationType = ExpressOperationType.SWAP, ).map { swapData -> - val networkId = fromToken.currency.network.rawId val transaction = swapData.transaction as ExpressTransactionModel.DEX - loadFeeForDex( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, transaction = transaction, - fromToken = fromToken, ).getOrElse { raise(GetFeeError.UnknownError) } }.mapLeft { GetFeeError.UnknownError @@ -1248,8 +1034,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( estimateFeeUseCase.invoke( amount = amountDecimal, - userWallet = userWallet, - cryptoCurrencyStatus = fromToken, + userWallet = fromSwapCurrencyStatus.userWallet, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ).map { it.patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_SEND) } @@ -1257,10 +1043,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } - private suspend fun storeLastCryptoCurrencyId(cryptoCurrency: CryptoCurrency) { + private suspend fun storeLastCryptoCurrencyId(swapCurrencyStatus: SwapCurrencyStatus) { swapTransactionRepository.storeLastSwappedCryptoCurrencyId( - userWalletId = userWalletId, - cryptoCurrencyId = cryptoCurrency.id, + userWalletId = swapCurrencyStatus.userWalletId, + cryptoCurrencyId = swapCurrencyStatus.currency.id, ) } @@ -1268,32 +1054,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( return SwapAmount(token.value.amount ?: BigDecimal.ZERO, token.currency.decimals) } - override suspend fun getInitialCurrencyToSwap( - initialCryptoCurrency: CryptoCurrency, - state: TokensDataStateExpress, - isReverseFromTo: Boolean, - ): AccountSwapCurrency? { - val group = state.getGroupWithReverse(isReverseFromTo) - return initialToCurrencyResolver.tryGetFromCache( - userWallet = userWallet, - initialCryptoCurrency = initialCryptoCurrency, - state = state, - isReverseFromTo = isReverseFromTo, - ) - ?: initialToCurrencyResolver.tryGetWithMaxAmount(state = state, isReverseFromTo = isReverseFromTo) - ?: group.accountCurrencyList.firstNotNullOfOrNull { accountSwapAvailability -> - accountSwapAvailability.currencyList.firstOrNull { accountSwapCurrency -> - accountSwapCurrency.isAvailable - } - } - } - - override suspend fun getNativeToken(network: Network): CryptoCurrency { + override suspend fun getNativeToken(swapCurrencyStatus: SwapCurrencyStatus): CryptoCurrency { + val network = swapCurrencyStatus.currency.network return multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + params = MultiWalletCryptoCurrenciesProducer.Params(swapCurrencyStatus.userWalletId), ) ?.filterIsInstance() - ?.firstOrNull { it.network.id == network.id && it.network.derivationPath == network.derivationPath } + ?.firstOrNull { nativeCoin -> + nativeCoin.network.id == network.id && + nativeCoin.network.derivationPath == network.derivationPath + } ?: currenciesRepository.createCoinCurrency(network) } @@ -1316,32 +1086,28 @@ internal class SwapInteractorImpl @AssistedInject constructor( */ @Suppress("LongParameterList") private suspend fun loadCexQuoteData( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, reduceBalanceBy: BigDecimal, - fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account?, - toTokenStatus: CryptoCurrencyStatus, - toAccount: Account?, provider: SwapProvider, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, txFeeSealedState: TxFeeSealedState, ): SwapState { - val fromToken = fromTokenStatus.currency - val toToken = toTokenStatus.currency + val fromToken = fromSwapCurrencyStatus.currency + val toToken = toSwapCurrencyStatus.currency return coroutineScope { val txFeeSealedStateUpdated = updateTxFeeStateIfNeededForCEX( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, txFeeSealedState = txFeeSealedState, amount = amount, - fromTokenStatus = fromTokenStatus, ) val includeFeeInAmount = getIncludeFeeInAmount( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromToken = fromTokenStatus, txFeeSealedState = txFeeSealedStateUpdated, ) @@ -1352,7 +1118,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( } val quotes = repository.findBestQuote( - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, fromContractAddress = fromToken.getContractAddress(), fromNetwork = fromToken.network.rawId, toContractAddress = toToken.getContractAddress(), @@ -1368,11 +1134,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, quoteDataModel = quotes, amount = amount, - fromToken = fromTokenStatus, - fromAccount = fromAccount, - toToken = toTokenStatus, - toAccount = toAccount, - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, txFeeSealedState = txFeeSealedState, @@ -1382,9 +1145,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun updateTxFeeStateIfNeededForCEX( + fromSwapCurrencyStatus: SwapCurrencyStatus, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, - fromTokenStatus: CryptoCurrencyStatus, ): TxFeeSealedState { return when (txFeeSealedState) { is TxFeeSealedState.Component -> txFeeSealedState @@ -1392,10 +1155,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (txFeeSealedState.txFeeState is TxFeeState.Empty) { val txFeeResult = estimateFeeUseCase( amount = amount.value, - userWallet = userWallet, - cryptoCurrencyStatus = fromTokenStatus, + userWallet = fromSwapCurrencyStatus.userWallet, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ) - val txFee = getFeeForCex(txFeeResult, fromTokenStatus) + val txFee = getFeeForCex(txFeeResult, fromSwapCurrencyStatus) TxFeeSealedState.Legacy( txFeeState = txFee, @@ -1413,11 +1176,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider: SwapProvider, quoteDataModel: Either, amount: SwapAmount, - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, txFeeSealedState: TxFeeSealedState, @@ -1426,10 +1186,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( return quoteDataModel.fold( ifRight = { quoteModel -> val swapState = updateBalances( - fromTokenStatus = fromToken, - fromAccount = fromAccount, - toTokenStatus = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, fromTokenAmount = amount, toTokenAmount = quoteModel.toTokenAmount, swapData = null, @@ -1437,16 +1195,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, ).copy( currencyCheck = manageWarnings( - fromTokenStatus = fromToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, txFeeSealed = txFeeSealedState, includeFeeInAmount = includeFeeInAmount, ), validationResult = manageTransactionValidationWarnings( - fromToken = fromToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, txFeeSealedState = txFeeSealedState, - userWalletId = userWalletId, ), minAdaValue = when (txFeeSealedState) { is TxFeeSealedState.Component -> { @@ -1467,18 +1224,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( when (provider.type) { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { val state = updatePermissionState( - networkId = networkId, - fromTokenStatus = fromToken, - fromAccount = fromAccount, - swapAmount = amount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, quotesLoadedState = swapState, isAllowedToSpend = isAllowedToSpend, - spenderAddress = quoteModel.allowanceContract, + swapAmount = amount, + quoteModel = quoteModel, ) if (state !is SwapState.QuotesLoadedState) return state state.copy( preparedSwapConfigState = state.preparedSwapConfigState.copy( - isAllowedToSpend = isAllowedToSpend, isBalanceEnough = isBalanceWithoutFeeEnough, ), ) @@ -1496,18 +1250,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( } val feeState = getFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, fee = fee, spendAmount = amount, - networkId = networkId, - fromTokenStatus = fromToken, ) swapState.copy( permissionState = PermissionDataState.Empty, preparedSwapConfigState = PreparedSwapConfigState( feeState = feeState, - isAllowedToSpend = isAllowedToSpend, isBalanceEnough = isBalanceWithoutFeeEnough, - hasOutgoingTransaction = hasOutgoingTransaction(fromToken), + hasOutgoingTransaction = hasOutgoingTransaction(fromSwapCurrencyStatus.status), includeFeeInAmount = includeFeeInAmount, ), ) @@ -1516,8 +1268,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( }, ifLeft = { error -> createSwapErrorWith( - fromToken = fromToken, - fromAccount = fromAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, includeFeeInAmount = includeFeeInAmount, expressDataError = error, @@ -1527,46 +1278,41 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun createSwapErrorWith( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, ): SwapState.SwapError { - val rates = getQuotes(fromToken.currency.id) + val rates = getQuotes(fromSwapCurrencyStatus.currency.id) val fromTokenSwapInfo = TokenSwapInfo( + swapCurrencyStatus = fromSwapCurrencyStatus, tokenAmount = amount, - amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value) - ?: BigDecimal.ZERO, - cryptoCurrencyStatus = fromToken, - account = fromAccount, + amountFiat = rates[fromSwapCurrencyStatus.currency.id]?.fiatRate?.multiply(amount.value) ?: BigDecimal.ZERO, ) return SwapState.SwapError(fromTokenSwapInfo, expressDataError, includeFeeInAmount) } @Suppress("CyclomaticComplexMethod", "NestedBlockDepth", "CastNullableToNonNullableType") private suspend fun getIncludeFeeInAmount( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, reduceBalanceBy: BigDecimal, - fromToken: CryptoCurrencyStatus, txFeeSealedState: TxFeeSealedState, ): IncludeFeeInAmount { return when (txFeeSealedState) { is TxFeeSealedState.Component -> { - if (fromToken.currency.id == txFeeSealedState.txFee.selectedToken?.currency?.id) { + if (fromSwapCurrencyStatus.currency.id == txFeeSealedState.txFee.selectedToken?.currency?.id) { val fee = txFeeSealedState.txFee.fee.amount.value ?: BigDecimal.ZERO if (txFeeSealedState.txFee.selectedToken.currency is CryptoCurrency.Coin) { getIncludeFeeInAmountForNative( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromToken = fromToken.currency, feeValue = fee, ) } else { // we have a token selected for fee payment the same as sending token - val reducedBalance = fromToken.value.amount as BigDecimal - reduceBalanceBy + val reducedBalance = fromSwapCurrencyStatus.status.value.amount as BigDecimal - reduceBalanceBy when { amount.value > reducedBalance -> IncludeFeeInAmount.BalanceNotEnough amount.value + fee <= reducedBalance -> IncludeFeeInAmount.Excluded @@ -1575,7 +1321,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( IncludeFeeInAmount.Included( amountSubtractFee = SwapAmount( value = reducedBalance - fee, - decimals = fromToken.currency.decimals, + decimals = fromSwapCurrencyStatus.currency.decimals, ), ) } else { @@ -1587,10 +1333,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( } else { val fee = txFeeSealedState.txFee.fee.amount.value ?: BigDecimal.ZERO getIncludeFeeInAmountForNative( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromToken = fromToken.currency, feeValue = fee, ) } @@ -1604,10 +1349,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( is TxFeeState.SingleFeeState -> txFee.fee.feeIncludeOtherNativeFee } getIncludeFeeInAmountForNative( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromToken = fromToken.currency, feeValue = feeValue, ) } @@ -1615,15 +1359,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun getIncludeFeeInAmountForNative( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, reduceBalanceBy: BigDecimal, - fromToken: CryptoCurrency, feeValue: BigDecimal, ): IncludeFeeInAmount { - val feePaidCurrency = getFeePaidCurrency( - currency = fromToken, - ) + val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus) return when (feePaidCurrency) { is FeePaidCurrency.Token -> { @@ -1634,31 +1375,30 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } else -> getIncludeFeeAmountForCoinFee( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, feeValue = feeValue, - fromToken = fromToken, ) } } private suspend fun getIncludeFeeAmountForCoinFee( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, reduceBalanceBy: BigDecimal, feeValue: BigDecimal, - fromToken: CryptoCurrency, ): IncludeFeeInAmount { + val networkId = fromSwapCurrencyStatus.currency.network.rawId val tokenForFeeBalance = walletManagersFacade.getNativeTokenBalance( - userWalletId = userWalletId, + userWalletId = fromSwapCurrencyStatus.userWalletId, networkId = networkId, - derivationPath = fromToken.network.derivationPath.value, + derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value, ) val reducedBalance = tokenForFeeBalance - reduceBalanceBy val amountWithFee = amount.value + feeValue return when { - fromToken is CryptoCurrency.Token -> { + fromSwapCurrencyStatus.currency is CryptoCurrency.Token -> { if (feeValue > reducedBalance || reducedBalance.signum() == 0) { IncludeFeeInAmount.BalanceNotEnough } else { @@ -1673,8 +1413,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } else -> { if (feeValue < amount.value) { - val nativeCoinDecimals = Blockchain.fromNetworkId(networkId)?.decimals() - ?: error("Blockchain not found") + val nativeCoinDecimals = + Blockchain.fromNetworkId(networkId)?.decimals() ?: error("Blockchain not found") IncludeFeeInAmount.Included( amountSubtractFee = SwapAmount( reducedBalance - feeValue, @@ -1688,14 +1428,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } - private suspend fun getFormattedFiatFees(fromToken: CryptoCurrency, vararg fees: BigDecimal): List { + private suspend fun getFormattedFiatFees( + fromSwapCurrencyStatus: SwapCurrencyStatus, + vararg fees: BigDecimal, + ): List { val appCurrency = getSelectedAppCurrencyUseCase.unwrap() - val feePaidCurrency = getFeePaidCurrency( - currency = fromToken, - ) + val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus) val feeCurrencyId: CryptoCurrency.ID = when (feePaidCurrency) { is FeePaidCurrency.Token -> feePaidCurrency.tokenId - else -> getNativeToken(network = fromToken.network).id + else -> getNativeToken(fromSwapCurrencyStatus).id } val rates = getQuotes(feeCurrencyId) return rates[feeCurrencyId]?.let { rate -> @@ -1716,55 +1457,49 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongParameterList", "LongMethod") private suspend fun loadDexSwapData( provider: SwapProvider, - networkId: String, - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, txFeeSealedState: TxFeeSealedState, expressOperationType: ExpressOperationType, ): SwapState { - val fromNetworkAddress = fromToken.value.networkAddress + val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() - val toNetworkAddress = toToken.value.networkAddress + val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress val dexToAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() + val networkId = fromSwapCurrencyStatus.currency.network.rawId return repository.getExchangeData( - userWallet = userWallet, - fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.rawId, - toContractAddress = toToken.currency.getContractAddress(), + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), fromAddress = dexFromAddress, - toNetwork = toToken.currency.network.rawId, + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, - toDecimals = toToken.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = provider.providerId, rateType = RateType.FLOAT, toAddress = dexToAddress, - refundAddress = fromToken.value.networkAddress?.defaultAddress?.value, + refundAddress = fromNetworkAddress?.defaultAddress?.value, expressOperationType = expressOperationType, ).fold( ifRight = { swapData -> val transaction = swapData.transaction as ExpressTransactionModel.DEX - val nativeCoinDecimals = Blockchain.fromNetworkId(networkId)?.decimals() - ?: error("Blockchain not found") - val otherNativeFee = transaction.otherNativeFeeWei - ?.movePointLeft(nativeCoinDecimals) - ?: BigDecimal.ZERO + val nativeCoinDecimals = + Blockchain.fromNetworkId(networkId)?.decimals() ?: error("Blockchain not found") + val otherNativeFee = transaction.otherNativeFeeWei?.movePointLeft(nativeCoinDecimals) ?: BigDecimal.ZERO val txFeeState = loadFeeForDex( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, transaction = transaction, - fromToken = fromToken, ).getOrElse { error -> return@fold produceDexSwapDataError( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, error = error, - fromToken = fromToken, - fromAccount = fromAccount, amount = amount, ) - }.toTxFeeState(fromToken.currency, otherNativeFee) + }.toTxFeeState(fromSwapCurrencyStatus, otherNativeFee) val includeFeeInAmount = IncludeFeeInAmount.Excluded // exclude for dex val feeByPriority = when (txFeeSealedState) { @@ -1776,25 +1511,21 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } val feeToCheckFunds = feeByPriority + (otherNativeFee ?: BigDecimal.ZERO) - val isBalanceIncludeFeeEnough = isBalanceEnough(fromToken, amount, feeToCheckFunds) + val isBalanceIncludeFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, feeToCheckFunds) val feeState = getFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, fee = feeToCheckFunds, spendAmount = amount, - networkId = networkId, - fromTokenStatus = fromToken, ) val preparedSwapConfigState = PreparedSwapConfigState( - isAllowedToSpend = true, isBalanceEnough = isBalanceIncludeFeeEnough, feeState = feeState, - hasOutgoingTransaction = hasOutgoingTransaction(fromToken), + hasOutgoingTransaction = hasOutgoingTransaction(fromSwapCurrencyStatus.status), includeFeeInAmount = includeFeeInAmount, ) val swapState = updateBalances( - fromTokenStatus = fromToken, - fromAccount = fromAccount, - toTokenStatus = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, fromTokenAmount = amount, toTokenAmount = swapData.toTokenAmount, swapData = swapData, @@ -1804,25 +1535,23 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapState.copy( permissionState = PermissionDataState.Empty, currencyCheck = manageWarnings( - fromTokenStatus = fromToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, txFeeSealed = txFeeSealedState, includeFeeInAmount = includeFeeInAmount, ), validationResult = manageTransactionValidationWarnings( - fromToken = fromToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, txFeeSealedState = txFeeSealedState, - userWalletId = userWalletId, ), preparedSwapConfigState = preparedSwapConfigState, ) }, ifLeft = { error -> produceDexSwapDataError( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, error = error, - fromToken = fromToken, - fromAccount = fromAccount, amount = amount, ) }, @@ -1830,48 +1559,44 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun loadFeeForDex( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, transaction: ExpressTransactionModel.DEX, - fromToken: CryptoCurrencyStatus, ): Either = either { - if (isSolana(networkId)) { + if (isSolana(fromSwapCurrencyStatus.currency.network.rawId)) { val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP) val formattedHash = getFormattedHash(transactionBytes) - if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES && userWallet is UserWallet.Cold) { + if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES && + fromSwapCurrencyStatus.userWallet is UserWallet.Cold + ) { raise(ExpressDataError.TooLargeSolanaTransactionError) } getFeeDataForSolanaDexSwap( - network = fromToken.currency.network, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, transactionBytes = transactionBytes, ) } else { getFeeDataForDexSwap( - network = fromToken.currency.network, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, transaction = transaction, - fromToken = fromToken.currency, ).map { fee -> - (fee as TransactionFeeResult.Loaded).fee - .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) + (fee as TransactionFeeResult.Loaded).fee.patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) }.bind() } } private suspend fun produceDexSwapDataError( + fromSwapCurrencyStatus: SwapCurrencyStatus, error: ExpressDataError, - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, amount: SwapAmount, ): SwapState.SwapError { - val rates = getQuotes(fromToken.currency.id) + val rates = getQuotes(fromSwapCurrencyStatus.currency.id) val fromTokenSwapInfo = TokenSwapInfo( + swapCurrencyStatus = fromSwapCurrencyStatus, tokenAmount = amount, - amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value) - ?: BigDecimal.ZERO, - cryptoCurrencyStatus = fromToken, - account = fromAccount, + amountFiat = rates[fromSwapCurrencyStatus.currency.id]?.fiatRate?.multiply(amount.value) ?: BigDecimal.ZERO, ) return SwapState.SwapError( fromTokenSwapInfo, @@ -1882,15 +1607,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("CyclomaticComplexMethod") private suspend fun getFeeDataForDexSwap( - network: Network, + fromSwapCurrencyStatus: SwapCurrencyStatus, transaction: ExpressTransactionModel.DEX, - fromToken: CryptoCurrency, selectedToken: CryptoCurrencyStatus? = null, ): Either = either { val nativeBalance = walletManagersFacade.getNativeTokenBalance( - userWalletId = userWalletId, - networkId = network.rawId, - derivationPath = fromToken.network.derivationPath.value, + userWalletId = fromSwapCurrencyStatus.userWalletId, + networkId = fromSwapCurrencyStatus.currency.network.rawId, + derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value, ) // if native balance is zero - we can't calculate fee @@ -1900,7 +1624,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( try { val txAmountValue = transaction.txValue ?: error("unable to get txValue") - val amountToSend = createNativeAmountForDex(txAmountValue, fromToken.network) + val amountToSend = createNativeAmountForDex(txAmountValue, fromSwapCurrencyStatus.currency.network) // transaction.txValue is always native coin if (nativeBalance < amountToSend.value) { @@ -1909,7 +1633,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( val extras = createTransactionExtrasUseCase( data = transaction.txData, - network = network, + network = fromSwapCurrencyStatus.currency.network, ).getOrNull() ?: error("unable to create extras") val transactionData = TransactionData.Uncompiled( @@ -1923,68 +1647,65 @@ internal class SwapInteractorImpl @AssistedInject constructor( getFeeForTokenUseCase( transactionData = transactionData, token = selectedToken.currency, - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, ).getOrNull()?.let { TransactionFeeResult.LoadedExtended(it) } ?: error("unable to calculate fee for token") } else { getFeeUseCase( transactionData = transactionData, - network = network, - userWallet = userWallet, + network = fromSwapCurrencyStatus.currency.network, + userWallet = fromSwapCurrencyStatus.userWallet, ).getOrNull()?.let { TransactionFeeResult.Loaded(it) } ?: error("unable to calculate fee") } } catch (_: IllegalStateException) { getEthSpecificFeeUseCase( - userWallet = userWallet, - cryptoCurrency = fromToken, + userWallet = fromSwapCurrencyStatus.userWallet, + cryptoCurrency = fromSwapCurrencyStatus.currency, gasLimit = transaction.gas, ).getOrNull()?.let { TransactionFeeResult.Loaded(it) } ?: error("can't get fee for getEthSpecificFeeUseCase") } } - private suspend fun getFeeDataForSolanaDexSwap(network: Network, transactionBytes: ByteArray): TransactionFee { + private suspend fun getFeeDataForSolanaDexSwap( + fromSwapCurrencyStatus: SwapCurrencyStatus, + transactionBytes: ByteArray, + ): TransactionFee { val transactionData = TransactionData.Compiled( value = TransactionData.Compiled.Data.Bytes(transactionBytes), ) return getFeeUseCase( transactionData = transactionData, - network = network, - userWallet = userWallet, + network = fromSwapCurrencyStatus.currency.network, + userWallet = fromSwapCurrencyStatus.userWallet, ).getOrNull() ?: error("unable to calculate fee") } @Suppress("LongParameterList", "MaxChainedCallsOnSameLine") private suspend fun updateBalances( provider: SwapProvider, - fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account?, - toTokenStatus: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, fromTokenAmount: SwapAmount, toTokenAmount: SwapAmount, swapData: SwapDataModel?, txFeeSealedState: TxFeeSealedState, ): SwapState.QuotesLoadedState { - val fromToken = fromTokenStatus.currency - val toToken = toTokenStatus.currency - val nativeToken = getNativeToken(fromToken.network) + val fromToken = fromSwapCurrencyStatus.currency + val toToken = toSwapCurrencyStatus.currency + val nativeToken = getNativeToken(fromSwapCurrencyStatus) val rates = getQuotes(fromToken.id, toToken.id, nativeToken.id) return SwapState.QuotesLoadedState( fromTokenInfo = TokenSwapInfo( tokenAmount = fromTokenAmount, - account = fromAccount, - cryptoCurrencyStatus = fromTokenStatus, - amountFiat = rates[fromToken.id]?.fiatRate?.multiply(fromTokenAmount.value) - ?: BigDecimal.ZERO, + swapCurrencyStatus = fromSwapCurrencyStatus, + amountFiat = rates[fromToken.id]?.fiatRate?.multiply(fromTokenAmount.value) ?: BigDecimal.ZERO, ), toTokenInfo = TokenSwapInfo( tokenAmount = toTokenAmount, - cryptoCurrencyStatus = toTokenStatus, - account = toAccount, - amountFiat = rates[toToken.id]?.fiatRate?.multiply(toTokenAmount.value) - ?: BigDecimal.ZERO, + swapCurrencyStatus = toSwapCurrencyStatus, + amountFiat = rates[toToken.id]?.fiatRate?.multiply(toTokenAmount.value) ?: BigDecimal.ZERO, ), priceImpact = calculatePriceImpact( fromTokenAmount = fromTokenAmount.value, @@ -1998,9 +1719,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( is TxFeeSealedState.Component -> { when (txFeeSealedState.txFee.transactionFeeResult) { is TransactionFeeResult.Loaded -> - txFeeSealedState.txFee.transactionFeeResult.fee.toTxFeeState(fromToken, null) + txFeeSealedState.txFee.transactionFeeResult.fee.toTxFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + otherNativeFee = null, + ) is TransactionFeeResult.LoadedExtended -> - txFeeSealedState.txFee.transactionFeeResult.fee.transactionFee.toTxFeeState(fromToken, null) + txFeeSealedState.txFee.transactionFeeResult.fee.transactionFee.toTxFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + otherNativeFee = null, + ) } } is TxFeeSealedState.Legacy -> txFeeSealedState.txFeeState @@ -2011,36 +1738,31 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun getFeeForCex( txFeeResult: Either?, - fromToken: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, ): TxFeeState { return txFeeResult?.fold( ifLeft = { TxFeeState.Empty }, ifRight = { txFee -> - txFee - .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_SEND) - .toTxFeeState(fromToken.currency, null) + txFee.patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_SEND).toTxFeeState(fromSwapCurrencyStatus, null) }, ) ?: TxFeeState.Empty } - @Suppress("LongParameterList", "LongMethod", "CanBeNonNullable") private suspend fun updatePermissionState( - networkId: String, - fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, swapAmount: SwapAmount, quotesLoadedState: SwapState.QuotesLoadedState, - spenderAddress: String?, + quoteModel: QuoteModel, isAllowedToSpend: Boolean, ): SwapState { - val fromToken = fromTokenStatus.currency + val fromToken = fromSwapCurrencyStatus.currency if (isAllowedToSpend) { return quotesLoadedState.copy( permissionState = PermissionDataState.Empty, ) } // if token balance ZERO not show permission state to avoid user to spend money for fee - val isTokenZeroBalance = getTokenBalance(fromTokenStatus).value.signum() == 0 + val isTokenZeroBalance = getTokenBalance(fromSwapCurrencyStatus.status).value.signum() == 0 if (isTokenZeroBalance) { return quotesLoadedState.copy( permissionState = PermissionDataState.Empty, @@ -2051,84 +1773,25 @@ internal class SwapInteractorImpl @AssistedInject constructor( permissionState = PermissionDataState.PermissionLoading, ) } - // setting up amount for approve with given amount for swap [SwapApproveType.Limited] - requireNotNull( - fromTokenStatus.value.networkAddress?.defaultAddress?.value, - ) { "networkAddress cannot be null" } val allowanceInfo = getAllowanceInfoUseCase( - userWalletId = userWalletId, + userWalletId = fromSwapCurrencyStatus.userWalletId, cryptoCurrency = fromToken, - spenderAddress = requireNotNull(spenderAddress) { "spenderAddress cannot be null" }, + spenderAddress = requireNotNull(quoteModel.allowanceContract) { "spenderAddress cant be null" }, requiredAmount = swapAmount.value, ).getOrNull() - val amount = if (allowanceInfo is AllowanceInfo.ResetNeeded) { - BigDecimal.ZERO - } else { - swapAmount.value - } - - val approveTransaction = createApprovalTransactionUseCase( - cryptoCurrencyStatus = fromTokenStatus, - userWalletId = userWalletId, - amount = amount, - contractAddress = fromToken.getContractAddress(), - spenderAddress = spenderAddress, - ).getOrElse { error -> - TangemLogger.e("Failed to create approveTransaction", error) - return createSwapErrorWith( - fromToken = fromTokenStatus, - fromAccount = fromAccount, - amount = swapAmount, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - expressDataError = ExpressDataError.UnknownError, - ) - } - - val feeData = getFeeUseCase( - transactionData = approveTransaction, - network = fromToken.network, - userWallet = userWallet, - ).getOrNull() ?: error("unable to calculate fee") - - val feeState = feeData - .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) - .toTxFeeState(fromToken, null) - - val fee = when (feeState) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> feeState.normalFee.fee.amount.value - is TxFeeState.SingleFeeState -> feeState.fee.fee.amount.value - } - val swapFeeState = getFeeState( - fee = fee, - spendAmount = SwapAmount.zeroSwapAmount(), - networkId = networkId, - fromTokenStatus = fromTokenStatus, - ) return quotesLoadedState.copy( - permissionState = PermissionDataState.PermissionReadyForRequest( - currency = fromToken.symbol, - amount = INFINITY_SYMBOL, - walletAddress = getWalletAddress(fromToken.network), - spenderAddress = getTokenAddress(fromToken), + permissionState = PermissionDataState.PermissionRequired( isResetApproval = allowanceInfo is AllowanceInfo.ResetNeeded, - requestApproveData = RequestApproveStateData( - fee = feeState, - fromTokenAmount = swapAmount, - spenderAddress = spenderAddress, - ), - ), - preparedSwapConfigState = quotesLoadedState.preparedSwapConfigState.copy( - feeState = swapFeeState, + spenderAddress = quoteModel.allowanceContract, ), ) } @Suppress("LongMethod") private suspend fun TransactionFee.toTxFeeState( - fromToken: CryptoCurrency, + fromSwapCurrencyStatus: SwapCurrencyStatus, otherNativeFee: BigDecimal?, ): TxFeeState { val otherNativeFeeValue = otherNativeFee ?: BigDecimal.ZERO @@ -2136,8 +1799,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( is TransactionFee.Choosable -> { val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO val feePriority = this.priority.amount.value ?: BigDecimal.ZERO - val normalFiatValue = getFormattedFiatFees(fromToken, feeNormal)[0] - val priorityFiatValue = getFormattedFiatFees(fromToken, feePriority)[0] + val normalFiatValue = getFormattedFiatFees(fromSwapCurrencyStatus, feeNormal)[0] + val priorityFiatValue = getFormattedFiatFees(fromSwapCurrencyStatus, feePriority)[0] val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = feeNormal, @@ -2151,8 +1814,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( // region otherNativeFee val normalFeeWithOtherNative = feeNormal + otherNativeFeeValue val priorityFeeWithOtherNative = feePriority + otherNativeFeeValue - val normalFiatValueWithNative = getFormattedFiatFees(fromToken, normalFeeWithOtherNative)[0] - val priorityFiatValueWithNative = getFormattedFiatFees(fromToken, priorityFeeWithOtherNative)[0] + val normalFiatValueWithNative = + getFormattedFiatFees(fromSwapCurrencyStatus, normalFeeWithOtherNative)[0] + val priorityFiatValueWithNative = + getFormattedFiatFees(fromSwapCurrencyStatus, priorityFeeWithOtherNative)[0] val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI( amount = normalFeeWithOtherNative, @@ -2190,14 +1855,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( } is TransactionFee.Single -> { val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO - val normalFiatValue = getFormattedFiatFees(fromToken, feeNormal)[0] + val normalFiatValue = getFormattedFiatFees(fromSwapCurrencyStatus, feeNormal)[0] val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = feeNormal, decimals = this.normal.amount.decimals, ) // region otherNativeFee val normalFeeWithOtherNative = feeNormal + otherNativeFeeValue - val normalFiatValueWithNative = getFormattedFiatFees(fromToken, normalFeeWithOtherNative)[0] + val normalFiatValueWithNative = + getFormattedFiatFees(fromSwapCurrencyStatus, normalFeeWithOtherNative)[0] val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI( amount = normalFeeWithOtherNative, @@ -2285,9 +1951,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( 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 increasedGasLimit = gasLimit.multiply(percentage.toBigInteger()).divide(hundredPercent) val increasedAmount = this.amount.copy( value = increasedGasLimit.toBigDecimal().multiply(increasedGasPrice).movePointLeft(this.amount.decimals), ) @@ -2324,18 +1988,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun isBalanceEnough( - fromToken: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, fee: BigDecimal?, ): Boolean { - val tokenBalance = getTokenBalance(fromToken).value - val feePaidCurrency = getFeePaidCurrency( - currency = fromToken.currency, - ) + val tokenBalance = getTokenBalance(fromSwapCurrencyStatus.status).value + val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus) return when (feePaidCurrency) { is FeePaidCurrency.Token -> tokenBalance >= amount.value else -> { - if (fromToken.currency is CryptoCurrency.Token) { + if (fromSwapCurrencyStatus.currency is CryptoCurrency.Token) { tokenBalance >= amount.value } else { tokenBalance >= amount.value.plus(fee ?: BigDecimal.ZERO) @@ -2344,18 +2006,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } - private suspend fun getFeePaidCurrency(currency: CryptoCurrency): FeePaidCurrency { + private suspend fun getFeePaidCurrency(swapCurrencyStatus: SwapCurrencyStatus): FeePaidCurrency { return currenciesRepository.getFeePaidCurrency( - userWalletId = userWalletId, - network = currency.network, + userWalletId = swapCurrencyStatus.userWalletId, + network = swapCurrencyStatus.currency.network, ) } - private suspend fun getWalletAddress(network: Network): String { - return walletManagersFacade.getDefaultAddress(userWalletId, network) - ?: error("Address not found for network: ${network.id}") - } - private fun getTokenAddress(currency: CryptoCurrency): String { return when (currency) { is CryptoCurrency.Coin -> { @@ -2373,25 +2030,24 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongMethod", "CyclomaticComplexMethod") private suspend fun getFeeState( + fromSwapCurrencyStatus: SwapCurrencyStatus, fee: BigDecimal?, spendAmount: SwapAmount, - networkId: String, - fromTokenStatus: CryptoCurrencyStatus, ): SwapFeeState { if (fee == null) { return SwapFeeState.NotEnough() } - + val fromCurrency = fromSwapCurrencyStatus.currency val percentsToFeeIncrease = BigDecimal.ONE - return when (val feePaidCurrency = getFeePaidCurrency(fromTokenStatus.currency)) { + return when (val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus)) { FeePaidCurrency.Coin -> { val nativeTokenBalance = walletManagersFacade.getNativeTokenBalance( - userWalletId = userWalletId, - networkId = networkId, - derivationPath = fromTokenStatus.currency.network.derivationPath.value, + userWalletId = fromSwapCurrencyStatus.userWalletId, + networkId = fromCurrency.network.rawId, + derivationPath = fromCurrency.network.derivationPath.value, ) - val balanceToCheck = when (fromTokenStatus.currency) { + val balanceToCheck = when (fromCurrency) { is CryptoCurrency.Token -> nativeTokenBalance is CryptoCurrency.Coin -> { // need to check balance minus amount only if amount to swap in native token @@ -2401,23 +2057,21 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (balanceToCheck > fee.multiply(percentsToFeeIncrease)) { SwapFeeState.Enough } else { - val nativeToken = getNativeToken(fromTokenStatus.currency.network) + val nativeToken = getNativeToken(fromSwapCurrencyStatus) SwapFeeState.NotEnough( - feeCurrency = nativeToken, currencyName = nativeToken.network.name, currencySymbol = nativeToken.symbol, ) } } FeePaidCurrency.SameCurrency -> { - val balance = fromTokenStatus.value.amount ?: return SwapFeeState.NotEnough() + val balance = fromSwapCurrencyStatus.status.value.amount ?: return SwapFeeState.NotEnough() if (balance.minus(spendAmount.value) > fee.multiply(percentsToFeeIncrease)) { SwapFeeState.Enough } else { SwapFeeState.NotEnough( - feeCurrency = fromTokenStatus.currency, - currencyName = fromTokenStatus.currency.name, - currencySymbol = fromTokenStatus.currency.symbol, + currencyName = fromCurrency.name, + currencySymbol = fromCurrency.symbol, ) } } @@ -2425,20 +2079,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (feePaidCurrency.balance > fee.multiply(percentsToFeeIncrease)) { SwapFeeState.Enough } else { - val tokens = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - - val token = tokens - .filterIsInstance() - .find { cryptoToken -> - cryptoToken.contractAddress.equals(feePaidCurrency.contractAddress, ignoreCase = true) && - cryptoToken.network.derivationPath == fromTokenStatus.currency.network.derivationPath - } - SwapFeeState.NotEnough( - feeCurrency = token, currencyName = feePaidCurrency.name, currencySymbol = feePaidCurrency.symbol, ) @@ -2447,8 +2088,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( is FeePaidCurrency.FeeResource -> { val isFeeResourceEnough = currencyChecksRepository.checkIfFeeResourceEnough( amount = spendAmount.value, - userWalletId = userWalletId, - network = fromTokenStatus.currency.network, + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = fromCurrency.network, ) if (isFeeResourceEnough) { @@ -2510,14 +2151,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( val set = ids.mapNotNullTo(destination = hashSetOf(), transform = CryptoCurrency.ID::rawCurrencyId) .getQuotesOrEmpty() - return ids - .mapNotNull { id -> - val found = set.find { it.rawCurrencyId == id.rawCurrencyId && it.value is QuoteStatus.Data } - ?: return@mapNotNull null + return ids.mapNotNull { id -> + val found = set.find { it.rawCurrencyId == id.rawCurrencyId && it.value is QuoteStatus.Data } + ?: return@mapNotNull null - id to found.value as QuoteStatus.Data - } - .toMap() + id to found.value as QuoteStatus.Data + }.toMap() } private suspend fun Set.getQuotesOrEmpty(): Set { @@ -2570,6 +2209,40 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } + // region temporary. will be removed + private fun CryptoCurrency.getContractAddress(): String { + return when (this) { + is CryptoCurrency.Token -> this.contractAddress + is CryptoCurrency.Coin -> "0" + } + } + + private fun ExpressProvider.toSwapProvider(): SwapProvider { + return SwapProvider( + providerId = providerId, + rateTypes = rateTypes.map { rateType -> + when (rateType) { + ExpressRateType.Float -> RateType.FLOAT + ExpressRateType.Fixed -> RateType.FIXED + } + }, + name = name, + type = when (type) { + ExpressProviderType.DEX -> ExchangeProviderType.DEX + ExpressProviderType.CEX -> ExchangeProviderType.CEX + ExpressProviderType.DEX_BRIDGE -> ExchangeProviderType.DEX_BRIDGE + ExpressProviderType.ONRAMP -> error("Invalid provider type") + }, + imageLarge = imageLarge, + termsOfUse = termsOfUse, + privacyPolicy = privacyPolicy, + isRecommended = isRecommended, + slippage = slippage, + isExtraIdSupported = isExtraIdSupported, + ) + } + // endregion + companion object { private const val INCREASE_GAS_LIMIT_FOR_DEX = 112 // 12% private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5% @@ -2578,12 +2251,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val PRICE_IMPACT_AMOUNT_LOW_THRESHOLD = 100_000.toBigDecimal() // in USD private val PRICE_IMPACT_LOW_THRESHOLD = 0.1.toBigDecimal() // 10% private val PRICE_IMPACT_HIGH_THRESHOLD = 0.5.toBigDecimal() // 50% - private const val INFINITY_SYMBOL = "∞" - } - - @AssistedFactory - interface Factory : SwapInteractor.Factory { - override fun create(selectedWalletId: UserWalletId): SwapInteractorImpl } } 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 0720aa7abf..03b73c515b 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 @@ -14,7 +14,8 @@ interface SwapTransactionRepository { @Suppress("LongParameterList") suspend fun storeTransaction( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt index c197aac5a9..9a38c5448b 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt @@ -4,6 +4,7 @@ import arrow.core.Either import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.domain.* @@ -23,7 +24,11 @@ interface SwapRepository { isIgnoreExpress: Boolean = false, ): PairsWithProviders - suspend fun getExchangeStatus(userWallet: UserWallet, txId: String): Either + suspend fun getExchangeStatus( + userWallet: UserWallet?, + userWalletId: UserWalletId, + txId: String, + ): Either @Suppress("LongParameterList") suspend fun findBestQuote( 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 26b53125e0..c753a3381d 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt @@ -1,6 +1,10 @@ package com.tangem.feature.swap.domain.di -import com.tangem.feature.swap.domain.* +import com.tangem.feature.swap.domain.AllowPermissionsHandler +import com.tangem.feature.swap.domain.AllowPermissionsHandlerImpl +import com.tangem.feature.swap.domain.SwapInteractor +import com.tangem.feature.swap.domain.SwapInteractorImpl +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -16,20 +20,13 @@ internal class SwapDomainModule { fun provideAllowPermissionsHandler(): AllowPermissionsHandler { return AllowPermissionsHandlerImpl() } +} - @Provides - @Singleton - fun provideSwapInteractorFactory(factory: SwapInteractorImpl.Factory): SwapInteractor.Factory { - return factory - } +@Module +@InstallIn(SingletonComponent::class) +internal interface SwapDomainBindModule { - @Provides + @Binds @Singleton - fun provideInitialToCurrencyResolver( - swapTransactionRepository: SwapTransactionRepository, - ): InitialToCurrencyResolver { - return DefaultInitialToCurrencyResolver( - swapTransactionRepository = swapTransactionRepository, - ) - } + fun provideSwapInteractor(swapInteractor: SwapInteractorImpl): SwapInteractor } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt deleted file mode 100644 index 383d8fe28f..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.feature.swap.domain.models.domain - -data class NetworkInfo( - val name: String, - val blockchainId: String, -) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt deleted file mode 100644 index e07d257628..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.feature.swap.domain.models.domain - -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData -import com.tangem.feature.swap.domain.models.ui.TxFee - -/** - * Permission options - * - * @param approveData tx data to give approve, it loaded from 1inch in findBestQuote if needed - * @param forTokenContractAddress token contract address for which needs permission - * @param fromTokenStatus which token will be swapping - * @param approveType unlimited or tx amount approve - * @param txFee fee for tx - */ -data class PermissionOptions( - val approveData: RequestApproveStateData, - val forTokenContractAddress: String, - val fromTokenStatus: CryptoCurrencyStatus, - val spenderAddress: String, - val approveType: SwapApproveType, - val txFee: TxFee, -) \ 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 index ec5293ae96..8f1eab0d73 100644 --- 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 @@ -5,12 +5,10 @@ import com.tangem.feature.swap.domain.models.SwapAmount /** * Prepared swap config state that contains flags to determine * - * @property isAllowedToSpend shows is token allowed to spend * @property isBalanceEnough shows is balance of token enough */ // todo Refactor this state data class PreparedSwapConfigState( - val isAllowedToSpend: Boolean, val isBalanceEnough: Boolean, val feeState: SwapFeeState, val hasOutgoingTransaction: Boolean, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt index 31236c6313..1480a38558 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt @@ -8,7 +8,8 @@ import com.tangem.domain.models.currency.CryptoCurrency import java.math.BigDecimal data class SavedSwapTransactionListModel( - val userWalletId: String, + val fromUserWalletId: String, + val toUserWalletId: String, val fromCryptoCurrencyId: String, val toCryptoCurrencyId: String, val fromCryptoCurrency: CryptoCurrency, @@ -25,7 +26,9 @@ data class SavedSwapTransactionListModel( @JsonClass(generateAdapter = true) data class SavedSwapTransactionListModelInner( @Json(name = "userWalletId") - val userWalletId: String, + val fromUserWalletId: String, + @Json(name = "toUserWalletId") + val toUserWalletId: String = fromUserWalletId, @Json(name = "fromCryptoCurrencyId") val fromCryptoCurrencyId: String, @Json(name = "toCryptoCurrencyId") diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt deleted file mode 100644 index 21ded70713..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.feature.swap.domain.models.domain - -enum class SwapApproveType { - LIMITED, UNLIMITED -} - -fun SwapApproveType.getNameForAnalytics(): String { - return when (this) { - SwapApproveType.LIMITED -> "Transaction" - SwapApproveType.UNLIMITED -> "Unlimited" - } -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt index e4a002e630..b4ceb30a64 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt @@ -1,11 +1,8 @@ package com.tangem.feature.swap.domain.models.domain -import com.tangem.domain.models.currency.CryptoCurrency - sealed class SwapFeeState { data object Enough : SwapFeeState() data class NotEnough( - val feeCurrency: CryptoCurrency? = null, val currencyName: String? = null, val currencySymbol: String? = null, ) : SwapFeeState() diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt index f65dd60a98..9c1d643ad4 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt @@ -59,11 +59,21 @@ data class SwapProvider( @JsonClass(generateAdapter = false) enum class ExchangeProviderType(val providerName: String) { - @Json(name = "DEX") DEX("DEX"), + @Json(name = "DEX") + DEX("DEX"), - @Json(name = "CEX") CEX("CEX"), + @Json(name = "CEX") + CEX("CEX"), - @Json(name = "DEX_BRIDGE") DEX_BRIDGE("DEX/Bridge"), + @Json(name = "DEX_BRIDGE") + DEX_BRIDGE("DEX/Bridge"), + ; + + companion object { + fun getSwapProviderTypes(): List { + return listOf(CEX, DEX, DEX_BRIDGE) + } + } } /** @@ -73,7 +83,9 @@ enum class ExchangeProviderType(val providerName: String) { */ @JsonClass(generateAdapter = false) enum class RateType { - @Json(name = "FLOAT") FLOAT, + @Json(name = "FLOAT") + FLOAT, - @Json(name = "FIXED") FIXED, + @Json(name = "FIXED") + FIXED, } \ 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 d538156362..562869ee5a 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 @@ -3,8 +3,8 @@ package com.tangem.feature.swap.domain.models.ui import androidx.compose.runtime.Immutable import com.tangem.blockchain.common.transaction.Fee import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.models.ExpressDataError @@ -23,7 +23,6 @@ sealed interface SwapState { val toTokenInfo: TokenSwapInfo, val priceImpact: PriceImpact, val preparedSwapConfigState: PreparedSwapConfigState = PreparedSwapConfigState( - isAllowedToSpend = false, isBalanceEnough = false, feeState = SwapFeeState.NotEnough(), hasOutgoingTransaction = false, @@ -81,17 +80,11 @@ data class PriceImpact( sealed class PermissionDataState { - data class PermissionReadyForRequest( - val currency: String, - val amount: String, - val walletAddress: String, - val spenderAddress: String, - val requestApproveData: RequestApproveStateData, + data class PermissionRequired( val isResetApproval: Boolean, + val spenderAddress: String, ) : PermissionDataState() - object PermissionFailed : PermissionDataState() - object PermissionLoading : PermissionDataState() object Empty : PermissionDataState() @@ -100,8 +93,7 @@ sealed class PermissionDataState { data class TokenSwapInfo( val tokenAmount: SwapAmount, val amountFiat: BigDecimal, - val cryptoCurrencyStatus: CryptoCurrencyStatus, - val account: Account?, + val swapCurrencyStatus: SwapCurrencyStatus, ) data class RequestApproveStateData( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt index b1380f6a3a..8f5d9f4fbc 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt @@ -1,9 +1,8 @@ package com.tangem.feature.swap.domain.models.ui -import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.pay.TangemPayWithdrawExchangeState +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount @@ -36,10 +35,8 @@ sealed class SwapTransactionState { ) : SwapTransactionState() { data class StoreTransactionData( - val currencyToSend: CryptoCurrencyStatus, - val currencyToGet: CryptoCurrencyStatus, - val fromAccount: Account?, - val toAccount: Account?, + val fromSwapCurrencyStatus: SwapCurrencyStatus, + val toSwapCurrencyStatus: SwapCurrencyStatus, val amount: SwapAmount, val swapProvider: SwapProvider, val swapDataModel: SwapDataModel, diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index ca51e0e384..6c456d8519 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.feature.swap.presentation" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Api */ implementation(projects.features.commonFeatures.api) @@ -59,6 +63,8 @@ dependencies { implementation(projects.domain.account.status) implementation(projects.domain.visa) implementation(projects.domain.markets) + implementation(projects.domain.swap) + implementation(projects.domain.swap.models) /** Feature modules */ implementation(projects.features.swap.domain) @@ -106,4 +112,8 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Test */ + testImplementation(projects.test.core) + testRuntimeOnly(deps.test.junit5.engine) } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 09d6310350..6f961c4f36 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -1,30 +1,37 @@ package com.tangem.feature.swap -import androidx.compose.animation.Crossfade import androidx.compose.foundation.background import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.fade +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop import com.arkivanov.essenty.lifecycle.subscribe -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.R +import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.isHotWallet import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.model.SwapModel -import com.tangem.feature.swap.router.SwapNavScreen +import com.tangem.feature.swap.models.SwapPermissionUM +import com.tangem.feature.swap.router.SwapRoute import com.tangem.feature.swap.ui.SwapScreen import com.tangem.feature.swap.ui.SwapSuccessScreen import com.tangem.features.approval.api.GiveApprovalComponent @@ -46,17 +53,24 @@ internal class DefaultSwapComponent @AssistedInject constructor( private val chooseTokenComponentFactory: ChooseTokenComponent.Factory, ) : SwapComponent, AppComponentContext by appComponentContext { - private val model: SwapModel = getOrCreateModel(params) + private val stackNavigation = StackNavigation() + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) - // todo swap create InnerRouter - private val chooseTokenComponent by lazy { - chooseTokenComponentFactory.create( - context = child("chooseTokenComponent"), - params = ChooseTokenComponent.Params( - bridge = model.chooseTokenBridge, - ), - ) - } + private val model: SwapModel = getOrCreateModel(params, router = innerRouter) + + private val childStack = childStack( + key = STACK_KEY, + source = stackNavigation, + serializer = null, + initialConfiguration = SwapRoute.Main, + handleBackButton = true, + childFactory = { route, factoryContext -> + createChild(route, childByContext(factoryContext)) + }, + ) private val approvalSlot = childSlot( key = APPROVAL_SLOT_KEY, @@ -81,7 +95,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( } private val slotNavigation = SlotNavigation() - private val childSlot = childSlot( + private val feeSelectorSlot = childSlot( source = slotNavigation, serializer = null, key = FEE_SELECTOR_SLOT_KEY, @@ -112,6 +126,19 @@ internal class DefaultSwapComponent @AssistedInject constructor( ) } + private fun createChild(route: SwapRoute, factoryContext: AppComponentContext): ComposableContentComponent = + when (route) { + is SwapRoute.Main -> SwapMainChild() + is SwapRoute.Success -> SwapSuccessChild() + is SwapRoute.SelectToken -> { + val bridge = if (route.isFromDirection) model.chooseFromTokenBridge else model.chooseToTokenBridge + chooseTokenComponentFactory.create( + context = factoryContext, + params = ChooseTokenComponent.Params(bridge = bridge), + ) + } + } + data class FeeSelectorConfig( val sendingCurrencyStatus: CryptoCurrencyStatus, val feeCurrencyStatus: CryptoCurrencyStatus, @@ -121,7 +148,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val dataState by model.dataStateStateFlow.collectAsStateWithLifecycle() - val fromCryptoCurrency by remember { derivedStateOf { dataState.fromCryptoCurrency } } + val fromCryptoCurrency by remember { derivedStateOf { dataState.fromSwapCurrencyStatus?.status } } val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } } val shouldHideBlock by remember { derivedStateOf { toBigDecimalOrZero(dataState.amount).isZero() || model.uiState.isInsufficientFunds } @@ -158,67 +185,78 @@ internal class DefaultSwapComponent @AssistedInject constructor( ) } - val feeSelectorChildStackState by childSlot.subscribeAsState() - val feeSelectorBlockComponent = feeSelectorChildStackState.child?.instance + val stackState by childStack.subscribeAsState() - Crossfade( + Children( + stack = stackState, modifier = Modifier.background(TangemTheme.colors.background.secondary), - targetState = model.currentScreen, - label = "", - ) { screen -> - when (screen) { - SwapNavScreen.Main -> SwapScreen( - stateHolder = model.uiState, - feeSelectorBlockComponent = feeSelectorBlockComponent, - ) - SwapNavScreen.Success -> { - val successState = model.uiState.successState - val feeSelectorState by model.feeSelectorRepository.state.collectAsStateWithLifecycle() - if (successState != null) { - SwapSuccessScreen( - state = successState, - feeSelectorUM = feeSelectorState, - onBack = model.uiState.onBackClicked, - ) - } else { - SwapScreen( - stateHolder = model.uiState, - feeSelectorBlockComponent = feeSelectorBlockComponent, - ) - } - } - SwapNavScreen.SelectToken -> chooseTokenComponent.Content(Modifier) - } + animation = stackAnimation { fade() }, + ) { child -> + child.instance.Content(Modifier) } val approvalSlotState by approvalSlot.subscribeAsState() approvalSlotState.child?.instance?.BottomSheet() } - fun getApprovalParams(): GiveApprovalComponent.Params? { - val permissionState = model.uiState.permissionState as? GiveTxPermissionState.ReadyForRequest - ?: return null - val fromCryptoCurrency = model.dataState.fromCryptoCurrency ?: return null + private inner class SwapMainChild : ComposableContentComponent { + @Composable + override fun Content(modifier: Modifier) { + val feeSelectorChildState by feeSelectorSlot.subscribeAsState() + val feeSelectorBlockComponent = feeSelectorChildState.child?.instance + SwapScreen( + stateHolder = model.uiState, + feeSelectorBlockComponent = feeSelectorBlockComponent, + ) + } + } + + private inner class SwapSuccessChild : ComposableContentComponent { + @Composable + override fun Content(modifier: Modifier) { + val successState = model.uiState.successState + val feeSelectorState by model.feeSelectorRepository.state.collectAsStateWithLifecycle() + if (successState != null) { + SwapSuccessScreen( + state = successState, + feeSelectorUM = feeSelectorState, + onBack = router::pop, + ) + } else { + val feeSelectorChildState by feeSelectorSlot.subscribeAsState() + val feeSelectorBlockComponent = feeSelectorChildState.child?.instance + SwapScreen( + stateHolder = model.uiState, + feeSelectorBlockComponent = feeSelectorBlockComponent, + ) + } + } + } + + private fun getApprovalParams(): GiveApprovalComponent.Params? { + val permissionState = model.uiState.permissionUM as? SwapPermissionUM.PermissionRequired ?: return null + val fromSwapCurrencyStatus = model.dataState.fromSwapCurrencyStatus ?: return null val feeCryptoCurrency = model.dataState.feePaidCryptoCurrency ?: return null val providerName = model.dataState.selectedProvider?.name.orEmpty() + val isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet return GiveApprovalComponent.Params( userWalletId = params.userWalletId, - cryptoCurrencyStatus = fromCryptoCurrency, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, feeCryptoCurrencyStatus = feeCryptoCurrency, amount = model.dataState.amount.orEmpty(), - spenderAddress = requireNotNull(model.dataState.approveDataModel).spenderAddress, + spenderAddress = permissionState.spenderAddress, amountFooter = if (permissionState.isResetApproval) { resourceReference(R.string.update_approval_permission_subtitle) } else { resourceReference( id = R.string.give_permission_swap_subtitle, - formatArgs = wrappedList(providerName, permissionState.currency), + formatArgs = wrappedList(providerName, fromSwapCurrencyStatus.currency.symbol), ) }, feeFooter = resourceReference(R.string.swap_give_permission_fee_footer), isResetApproval = permissionState.isResetApproval, - isHoldToConfirm = model.isHoldToConfirmEnabled, + isHoldToConfirm = isHoldToConfirm, callback = model.approvalCallback, ) } @@ -227,13 +265,24 @@ internal class DefaultSwapComponent @AssistedInject constructor( return bigDecimalString?.replace(",", ".")?.toBigDecimalOrNull() ?: BigDecimal.ZERO } + private fun onChildBack() { + val isEmptyStack = childStack.value.backStack.isEmpty() + val isSuccess = model.uiState.successState != null + + val isPopSend = isEmptyStack || isSuccess + when { + isPopSend -> router.pop() + else -> stackNavigation.pop() + } + } + @AssistedFactory interface Factory : SwapComponent.Factory { override fun create(context: AppComponentContext, params: SwapComponent.Params): DefaultSwapComponent } private companion object { - const val BOTTOM_SHEET_SLOT_KEY = "bottomSheetSlot" + const val STACK_KEY = "swapStack" const val FEE_SELECTOR_SLOT_KEY = "feeSelectorSlot" const val APPROVAL_SLOT_KEY = "approvalSlot" } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index e0fccb451d..12d6bb07b8 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -1,6 +1,5 @@ package com.tangem.feature.swap.analytics -import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO @@ -38,11 +37,6 @@ sealed class SwapEvents( class SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked") - class ChooseTokenScreenOpened(val hasAvailableTokens: Boolean) : SwapEvents( - event = "Choose Token Screen Opened", - params = mapOf("Available tokens" to if (hasAvailableTokens) "Yes" else "No"), - ) - class ChooseTokenScreenResult( val isTokenChosen: Boolean, val token: String? = null, @@ -72,23 +66,6 @@ sealed class SwapEvents( ), ) - class ButtonPermissionApproveClicked( - val sendToken: String, - val receiveToken: String, - val approveType: ApproveType, - val provider: SwapProvider, - ) : SwapEvents( - event = "Button - Permission Approve", - params = mapOf( - "Send Token" to sendToken, - "Receive Token" to receiveToken, - "Type" to if (approveType == ApproveType.LIMITED) "Current Transaction" else "Unlimited", - "Provider" to provider.name, - ), - ) - - class ButtonPermissionCancelClicked : SwapEvents(event = "Button - Permission Cancel") - class ButtonSwipeClicked : SwapEvents(event = "Button - Swipe") @Suppress("NullableToStringCall", "LongParameterList") diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt index 41c4423225..4ac81de2fe 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt @@ -39,7 +39,7 @@ interface ChooseTokenBridge : ChooseTokenBridgeLegacy, ChooseTokenBridgeInternal companion object { val SwapFrom = Settings( title = resourceReference(R.string.swapping_from_title), - isShowMarketBlock = false, + isShowMarketBlock = true, isShowPaymentAccount = true, ) val SwapTo = Settings( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt index 4b97a57ebe..750f2de85c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt @@ -13,6 +13,7 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel +import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenScreen import com.tangem.feature.swap.models.AddToPortfolioRoute import com.tangem.feature.swap.ui.SwapSelectTokenScreen import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent @@ -42,10 +43,13 @@ internal class DefaultChooseTokenComponent @AssistedInject constructor( val bottomSheet by bottomSheetSlot.subscribeAsState() stateOld?.let { stateHolder -> SwapSelectTokenScreen(state = stateHolder, onBack = { model.onBackClicked() }) + bottomSheet.child?.instance?.BottomSheet() + // if old shown we should not show new screen + return } - // todo swap uncomment - // val state by model.state.collectAsStateWithLifecycle() - // ChooseTokenScreen(state = state) + + val state by model.state.collectAsStateWithLifecycle() + ChooseTokenScreen(state = state) bottomSheet.child?.instance?.BottomSheet() } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt new file mode 100644 index 0000000000..a245cc7a9f --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt @@ -0,0 +1,225 @@ +package com.tangem.feature.swap.model + +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.swap.SwapComponent.Params.CurrencyPosition +import com.tangem.utils.extensions.orZero +import com.tangem.utils.isNullOrZero +import javax.inject.Inject + +/** + * Resolves the initial FROM and TO currencies when the swap screen opens. + * + * Selection rules when [initialCryptoCurrency][CryptoCurrency] is provided: + * - [CurrencyPosition.FROM] — places the currency as FROM, TO is null. + * - [CurrencyPosition.TO] — places the currency as TO, FROM is null. + * - [CurrencyPosition.ANY] — auto-places based on availability and balance: + * - available with balance → FROM. + * - available without balance or unavailable without balance → TO, + * and the best candidate from the SAME account as the initial currency is selected as FROM + * (the search is scoped to that account only, not the whole portfolio). + * - unavailable with balance → FROM. + * + * When no initial currency is provided, selects the best token from crypto portfolio accounts: + * 1. If available tokens with balance exist — the available token with the highest fiat balance. + * 2. If available tokens exist but none have balance — the first token from the first account. + * 3. If no available tokens exist but tokens with balance exist — the token with the highest fiat balance. + * 4. If no tokens have balance — the first token from the first account. + */ +internal class InitialCurrenciesResolver @Inject constructor( + private val getUserWalletUseCase: GetUserWalletUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val rampStateManager: RampStateManager, +) { + + /** + * Resolves the initial FROM/TO currency pair for the swap screen. + * + * @param userWalletId the wallet to resolve currencies for + * @param initialCryptoCurrency pre-selected currency, or null to auto-select + * @param swapCurrencyPosition preferred position for the initial currency + * @return pair of (from, to) [SwapCurrencyStatus]; either or both may be null + */ + suspend operator fun invoke( + userWalletId: UserWalletId, + initialCryptoCurrency: CryptoCurrency?, + swapCurrencyPosition: CurrencyPosition, + isPaymentAccount: Boolean, + ): Pair { + val walletAccountList = getWalletAccountCurrencyStatusList(userWalletId) + val cryptoPortfolioAccounts = walletAccountList.filterKeys { accountStatus -> + accountStatus is AccountStatus.CryptoPortfolio + }.mapKeys { (key, _) -> key as AccountStatus.CryptoPortfolio } + val cryptoPaymentAccounts = walletAccountList.filterKeys { accountStatus -> + accountStatus is AccountStatus.Payment + } + + val cryptoCurrencyList = cryptoPortfolioAccounts.values.flatten() + + return if (initialCryptoCurrency != null) { + val selectedSwapCurrencyStatus = if (isPaymentAccount) { + cryptoPaymentAccounts + } else { + cryptoPortfolioAccounts + }.firstNotNullOfOrNull { (_, currencyList) -> + currencyList.firstOrNull { currencyStatus -> + currencyStatus.currency.id == initialCryptoCurrency.id + } + } + + if (selectedSwapCurrencyStatus == null) { + null to null + } else { + placeSelectedCurrency( + selectedSwapCurrencyStatus = selectedSwapCurrencyStatus, + swapCurrencyPosition = swapCurrencyPosition, + cryptoPortfolioAccountsMap = cryptoPortfolioAccounts, + ) + } + } else { + selectCryptoCurrency( + cryptoPortfolioAccountsMap = cryptoPortfolioAccounts, + cryptoCurrencyList = cryptoCurrencyList, + ) to null + } + } + + /** + * Builds a map of [AccountStatus] to their [SwapCurrencyStatus] lists, + * enriching each currency with its swap availability from [RampStateManager]. + */ + private suspend fun getWalletAccountCurrencyStatusList( + userWalletId: UserWalletId, + ): Map> { + val userWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return emptyMap() + + val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull( + SingleAccountStatusListProducer.Params(userWalletId), + )?.accountStatuses.orEmpty() + + return walletAccountCurrencyStatuses.associateWith { accountStatus -> + val currencyStatuses = when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.flattenCurrencies() + is AccountStatus.Payment -> getPaymentAccountCurrencies(accountStatus) + } + val availabilityStates = rampStateManager.availableForSwap( + userWalletId, + currencyStatuses.map { it.currency }, + ) + currencyStatuses.map { cryptoCurrencyStatus -> + SwapCurrencyStatus( + userWallet = userWallet, + account = accountStatus.account, + status = cryptoCurrencyStatus, + isAvailableForSwap = availabilityStates[cryptoCurrencyStatus.currency] == + ScenarioUnavailabilityReason.None, + ) + } + } + } + + private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List { + val paymentCryptoCurrencyStatus = when (val statusValue = accountStatus.value) { + is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus + else -> null + } + + return listOfNotNull(paymentCryptoCurrencyStatus) + } + + /** + * Places the [selectedSwapCurrencyStatus] into the FROM or TO slot based on [swapCurrencyPosition]. + * + * For [CurrencyPosition.ANY], the position is determined by availability and balance: + * currencies that are available with balance go to FROM; otherwise, the selected currency + * goes to TO and a best-candidate FROM is resolved via [selectCryptoCurrency] — scoped to the + * SAME account that the selected currency belongs to, so we never pull a FROM candidate from a + * different account in the portfolio. + */ + private fun placeSelectedCurrency( + selectedSwapCurrencyStatus: SwapCurrencyStatus, + swapCurrencyPosition: CurrencyPosition, + cryptoPortfolioAccountsMap: Map>, + ): Pair { + return when (swapCurrencyPosition) { + CurrencyPosition.FROM -> { + selectedSwapCurrencyStatus to null + } + CurrencyPosition.TO -> { + null to selectedSwapCurrencyStatus + } + CurrencyPosition.ANY -> { + val isAvailable = selectedSwapCurrencyStatus.isAvailableForSwap + val hasBalance = !selectedSwapCurrencyStatus.status.value.fiatAmount.isNullOrZero() + if (isAvailable && hasBalance) { + selectedSwapCurrencyStatus to null + } else if (isAvailable || !hasBalance) { + val selectedCurrency = selectedSwapCurrencyStatus.currency + val selectedAccountId = selectedSwapCurrencyStatus.account.accountId + val sameAccountEntry = cryptoPortfolioAccountsMap.entries + .firstOrNull { (accountStatus, _) -> accountStatus.account.accountId == selectedAccountId } + + if (sameAccountEntry == null) { + null to selectedSwapCurrencyStatus + } else { + val scopedList = sameAccountEntry.value + .filterNot { it.currency.isSameTokenAs(selectedCurrency) } + selectCryptoCurrency( + cryptoPortfolioAccountsMap = mapOf(sameAccountEntry.key to scopedList), + cryptoCurrencyList = scopedList, + ) to selectedSwapCurrencyStatus + } + } else { + selectedSwapCurrencyStatus to null + } + } + } + } + + /** + * Checks whether two currencies refer to the same asset on the same network, regardless of the + * owning account. Two instances of the same token in different accounts have distinct + * [CryptoCurrency.ID] values (their derivation path differs), so id equality is not sufficient + * to detect duplicates when auto-picking a FROM candidate. + */ + private fun CryptoCurrency.isSameTokenAs(other: CryptoCurrency): Boolean { + return id.rawNetworkId == other.id.rawNetworkId && + id.contractAddress == other.id.contractAddress + } + + /** + * Selects the best token from the crypto portfolio when no initial currency is specified. + * + * Prioritizes available-for-swap tokens. Among the candidates, picks the one with the highest + * [fiatAmount][CryptoCurrencyStatus.Value.fiatAmount]. Falls back to the first token from the + * first account if no candidate has a positive balance. + */ + private fun selectCryptoCurrency( + cryptoPortfolioAccountsMap: Map>, + cryptoCurrencyList: List, + ): SwapCurrencyStatus? { + return if (cryptoCurrencyList.isEmpty()) { + null + } else { + val hasAvailable = cryptoCurrencyList.any { it.isAvailableForSwap } + val candidates = if (hasAvailable) { + cryptoCurrencyList.filter { it.isAvailableForSwap } + } else { + cryptoCurrencyList + } + candidates + .filter { !it.status.value.fiatAmount.isNullOrZero() } + .maxByOrNull { it.status.value.fiatAmount.orZero() } + ?: cryptoPortfolioAccountsMap.entries.firstOrNull()?.value?.firstOrNull() + } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 074fe771f4..ed470d4a60 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -12,30 +12,28 @@ import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter -import com.tangem.common.ui.bottomsheet.permission.state.ApproveType -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState.InProgress.getApproveTypeOrNull import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.ScreensSources import com.tangem.core.analytics.models.Basic -import com.tangem.core.analytics.models.event.SwapAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.R import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.utils.InputNumberFormatter import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.datasource.local.appsflyer.AppsFlyerStore -import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase -import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency @@ -47,12 +45,12 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.derivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.promo.ShouldShowStoriesUseCase @@ -60,7 +58,7 @@ import com.tangem.domain.promo.models.StoryContentIds import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions -import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayWithdrawUseCase import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase @@ -69,10 +67,10 @@ import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase -import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.analytics.SwapEvents import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge +import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter import com.tangem.feature.swap.domain.AllowPermissionsHandler @@ -80,29 +78,33 @@ import com.tangem.feature.swap.domain.SwapInteractor import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.TxFeeSealedState import com.tangem.feature.swap.domain.models.ExpressDataError -import com.tangem.feature.swap.domain.models.ExpressException import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.* -import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.models.SwapAlertUM +import com.tangem.feature.swap.models.SwapStateHolder +import com.tangem.feature.swap.models.TokenSelectionDirection +import com.tangem.feature.swap.models.UiActions import com.tangem.feature.swap.models.states.SwapNotificationUM -import com.tangem.feature.swap.router.SwapNavScreen -import com.tangem.feature.swap.router.SwapRouter +import com.tangem.feature.swap.router.SwapRoute import com.tangem.feature.swap.ui.StateBuilder import com.tangem.feature.swap.utils.formatToUIRepresentation import com.tangem.feature.swap.utils.getContractAddress import com.tangem.features.approval.api.GiveApprovalComponent -import com.tangem.features.approval.api.GiveApprovalFeatureToggles import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.swap.SwapComponent import com.tangem.utils.Provider -import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP import com.tangem.utils.coroutines.* import com.tangem.utils.isNullOrZero import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.* +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.math.BigDecimal import java.math.RoundingMode import java.text.DecimalFormat @@ -112,72 +114,62 @@ import javax.inject.Inject typealias SuccessLoadedSwapData = Map -@OptIn(ExperimentalCoroutinesApi::class) @Suppress("LongParameterList", "LargeClass") @Stable @ModelScoped internal class SwapModel @Inject constructor( paramsContainer: ParamsContainer, + getUserCountryUseCase: GetUserCountryUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + chooseTokenBridgeFactory: ChooseTokenBridge.Factory, + private val router: Router, + private val appRouter: AppRouter, override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsErrorEventHandler: AnalyticsErrorHandler, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, - private val getUserWalletUseCase: GetUserWalletUseCase, private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val shouldShowStoriesUseCase: ShouldShowStoriesUseCase, - getUserCountryUseCase: GetUserCountryUseCase, - getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, - swapInteractorFactory: SwapInteractor.Factory, - private val urlOpener: UrlOpener, - router: AppRouter, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val swapInteractor: SwapInteractor, + private val urlOpener: UrlOpener, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, - private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, - private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase, + private val getPaymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase, private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase, private val appsFlyerStore: AppsFlyerStore, private val messageSender: UiMessageSender, - private val paymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase, + private val initialCurrenciesResolver: InitialCurrenciesResolver, private val allowPermissionsHandler: AllowPermissionsHandler, - chooseTokenBridgeFactory: ChooseTokenBridge.Factory, - giveApprovalFeatureToggles: GiveApprovalFeatureToggles, ) : Model() { private val params = paramsContainer.require() - private val initialCurrencyFrom = params.currencyFrom - private val initialCurrencyTo = params.currencyTo - private val userWalletId = params.userWalletId - private val isInitiallyReversed = params.isInitialReverseOrder + private val initialCryptoCurrency = params.cryptoCurrency private val tangemPayInput = params.tangemPayInput - private val userWallet by lazy { - requireNotNull( - getUserWalletUseCase(userWalletId).getOrNull(), - ) { "No wallet found for id: $userWalletId" } - } - private val swapInteractor = swapInteractorFactory.create(userWalletId) - - val isHoldToConfirmEnabled: Boolean = userWallet.isHotWallet - - private lateinit var initialFromStatus: CryptoCurrencyStatus - private var initialToStatus: CryptoCurrencyStatus? = null - private var isBalanceHidden = true + private var isAccountsMode: Boolean = false private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() - val chooseTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create( + val chooseFromTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create( + modelScope = modelScope, + settings = ChooseTokenBridge.Settings.SwapFrom, + analyticsPayload = setOf( + ChooseTokenAnalyticsPayload.ScreensSources(ScreensSources.Swap.value), + ), + ) + val chooseToTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create( modelScope = modelScope, settings = ChooseTokenBridge.Settings.SwapTo, analyticsPayload = setOf( @@ -186,7 +178,6 @@ internal class SwapModel @Inject constructor( ) private val stateBuilder = StateBuilder( - userWalletProvider = Provider { userWallet }, actions = createUiActions(), isBalanceHiddenProvider = Provider { isBalanceHidden }, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), @@ -194,11 +185,11 @@ internal class SwapModel @Inject constructor( iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, ) - private val inputNumberFormatter = - InputNumberFormatter( - NumberFormat.getInstance(Locale.getDefault()) as? DecimalFormat - ?: error("NumberFormat is not DecimalFormat"), - ) + private val inputNumberFormatter = InputNumberFormatter( + NumberFormat.getInstance(Locale.getDefault()) as? DecimalFormat + ?: error("NumberFormat is not DecimalFormat"), + ) + private val amountDebouncer = Debouncer() private val singleTaskScheduler = SingleTaskScheduler>() @@ -209,37 +200,15 @@ internal class SwapModel @Inject constructor( dataStateStateFlow.value = value } - var uiState: SwapStateHolder by mutableStateOf( - stateBuilder.createInitialLoadingState( - initialCurrencyFrom = initialCurrencyFrom, - initialCurrencyTo = initialCurrencyTo, - fromNetworkInfo = initialCurrencyFrom.getNetworkInfo(), - ), - ) + var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState()) private set val feeSelectorRepository = FeeSelectorRepository() - // shows currency order (direct - swap initial to selected, reversed = selected to initial) - private val isOrderReversed = MutableStateFlow(value = params.isInitialReverseOrder) private val lastAmount = mutableStateOf(INITIAL_AMOUNT) private val lastReducedBalanceBy = mutableStateOf(BigDecimal.ZERO) - private val swapRouter: SwapRouter = SwapRouter(router = router) private var userCountry: UserCountry? = null - private var fromAccount: Account? = null - private var toAccount: Account? = null - private var fromAccountStatus: CryptoCurrencyStatus? = null - private var toAccountStatus: CryptoCurrencyStatus? = null - - /** - * If user came from Tangem Pay -> fromAccountCurrencyStatus == null - * If user didn't come from Tangem Pay -> fromAccountCurrencyStatus != null - * - * Remove when accounts are integrated into Tangem Pay - */ - private val canUseFromAccountCurrencyStatus = tangemPayInput == null - private val isUserResolvableError: (SwapState) -> Boolean = { swapState -> swapState is SwapState.SwapError && ( @@ -254,19 +223,13 @@ internal class SwapModel @Inject constructor( private var isAmountChangedByUser: Boolean = false private var lastPermissionNotificationTokens: Pair? = null - val currentScreen: SwapNavScreen - get() = swapRouter.currentScreen - val approvalSlotNavigation = SlotNavigation() - private val shouldUseGaslessApproval: Boolean = giveApprovalFeatureToggles.isGaslessApprovalEnabled val approvalCallback = object : GiveApprovalComponent.Callback { - override fun onApproveClick() { - sendPermissionApproveClickedEvent() - } + override fun onApproveClick() {} override fun onApproveDone() { - val fromContractAddress = dataState.fromCryptoCurrency?.currency?.getContractAddress() + val fromContractAddress = dataState.fromSwapCurrencyStatus?.currency?.getContractAddress() if (fromContractAddress != null) { allowPermissionsHandler.addAddressToInProgress(fromContractAddress) } @@ -284,37 +247,11 @@ internal class SwapModel @Inject constructor( override fun onCancelClick() { approvalSlotNavigation.dismiss() startLoadingQuotesFromLastState(isSilent = true) - analyticsEventHandler.send(SwapEvents.ButtonPermissionCancelClicked()) } } init { - chooseTokenBridge.searchQueryState - .onEach { query -> onSearchEntered(query.value) } - .launchIn(modelScope) - - chooseTokenBridge.onNewTokenAdded.receiveAsFlow() - .onEach { (addedToken, isSearched) -> - applyAddedToken(addedToken, isSearched.value) - } - .launchIn(modelScope) - - chooseTokenBridge.onTokenSelected.receiveAsFlow() - .onEach { result -> - onTokenSelect( - account = result.account, - cryptoCurrencyStatus = result.cryptoCurrencyStatus, - isSearched = result.isSearched, - ) - } - .launchIn(modelScope) - - chooseTokenBridge.onClose.receiveAsFlow() - .onEach { - analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false)) - swapRouter.back() - } - .launchIn(modelScope) + subscribeToTokenSelection() modelScope.launch { val storyId = StoryContentIds.STORY_FIRST_TIME_SWAP.id @@ -332,72 +269,20 @@ internal class SwapModel @Inject constructor( userCountry = getUserCountryUseCase.invokeSync().getOrNull() ?: UserCountry.Other(Locale.getDefault().country) - modelScope.launch(dispatchers.io) { - if (canUseFromAccountCurrencyStatus) { - isAccountsMode = isAccountsModeEnabledUseCase.invokeSync() - - val fromAccountStatus = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = userWalletId, - currency = initialCurrencyFrom, - ).getOrNull() - val fromPaymentAccountStatus = - paymentAccountCryptoCurrencyStatusUseCase(userWalletId, initialCurrencyFrom).getOrNull() - val toAccountStatus = initialCurrencyTo?.let { currencyTo -> - getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = userWalletId, - currency = currencyTo, - ).getOrNull() - } - val toPaymentAccountStatus = initialCurrencyTo?.let { currencyTo -> - paymentAccountCryptoCurrencyStatusUseCase(userWalletId, currencyTo).getOrNull() - } - val fromAccount = fromAccountStatus?.account ?: fromPaymentAccountStatus?.first - val fromStatus = fromAccountStatus?.status ?: fromPaymentAccountStatus?.second - - if (fromAccount != null && fromStatus != null) { - this@SwapModel.fromAccount = fromAccount - this@SwapModel.fromAccountStatus = fromStatus - this@SwapModel.toAccount = toAccountStatus?.account ?: toPaymentAccountStatus?.first - this@SwapModel.toAccountStatus = toAccountStatus?.status ?: toPaymentAccountStatus?.second - this@SwapModel.initialFromStatus = fromStatus - this@SwapModel.initialToStatus = toAccountStatus?.status ?: toPaymentAccountStatus?.second - initTokens(isInitiallyReversed) - } else { - showAlert() - swapRouter.back() - } - } else { - val fromStatus = getFromStatus() - val toStatus = initialCurrencyTo?.let { currencyTo -> - singleAccountStatusListSupplier.getSyncOrNull(params.userWalletId) - .getCryptoCurrencyStatus(currencyTo) - .getOrNull() - } - - if (fromStatus == null) { - showAlert() - swapRouter.back() - } else { - initialFromStatus = fromStatus - initialToStatus = toStatus - initTokens(isInitiallyReversed) - } - } - } + initTokens() + // TODO swap analytics analyticsEventHandler.send( SwapEvents.SwapScreenOpened( - token = initialCurrencyFrom.symbol, - blockchain = initialCurrencyFrom.network.name, + token = initialCryptoCurrency?.symbol.orEmpty(), + blockchain = initialCryptoCurrency?.network?.name.orEmpty(), ), ) - getBalanceHidingSettingsUseCase() - .onEach { settings -> - isBalanceHidden = settings.isBalanceHidden - uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden) - } - .launchIn(modelScope) + getBalanceHidingSettingsUseCase().onEach { settings -> + isBalanceHidden = settings.isBalanceHidden + uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden) + }.launchIn(modelScope) } fun onStart() { @@ -413,220 +298,298 @@ internal class SwapModel @Inject constructor( super.onDestroy() } - private fun sendSelectTokenScreenOpenedEvent() { - val isAnyAvailableTokensTo = dataState.tokensDataState?.toGroup?.available?.isNotEmpty() == true - val isAnyAvailableTokensFrom = dataState.tokensDataState?.fromGroup?.available?.isNotEmpty() == true - val isAnyAvailableAccountTokensTo = !dataState.tokensDataState?.toGroup?.accountCurrencyList.isNullOrEmpty() - val isAnyAvailableAccountTokensFrom = !dataState.tokensDataState?.fromGroup?.accountCurrencyList.isNullOrEmpty() - val isAnyAvailableTokens = isAnyAvailableTokensTo || isAnyAvailableTokensFrom || - isAnyAvailableAccountTokensTo || isAnyAvailableAccountTokensFrom - analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(hasAvailableTokens = isAnyAvailableTokens)) + private fun subscribeToTokenSelection() { + chooseFromTokenBridge.onCurrencyChosen.receiveAsFlow() + .onEach { result -> + onTokenSelect(result, isFromDirection = true) + } + .launchIn(modelScope) + + chooseFromTokenBridge.onClose.receiveAsFlow() + .onEach { + analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false)) + router.pop() + } + .launchIn(modelScope) + + chooseToTokenBridge.onCurrencyChosen.receiveAsFlow() + .onEach { result -> + onTokenSelect(result, isFromDirection = false) + } + .launchIn(modelScope) + + chooseToTokenBridge.onClose.receiveAsFlow() + .onEach { + analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false)) + router.pop() + } + .launchIn(modelScope) + } + + private fun initTokens() { + modelScope.launch(dispatchers.default) { + isAccountsMode = isAccountsModeEnabledUseCase.invokeSync() + + val (fromSwapCurrencyStatus, toSwapCurrencyStatus) = initialCurrenciesResolver( + userWalletId = params.userWalletId, + initialCryptoCurrency = initialCryptoCurrency, + swapCurrencyPosition = params.currencyPosition, + isPaymentAccount = params.tangemPayInput != null, + ) + + dataState = dataState.copy( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + + selectWalletInSelector( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + filterTokensFromSelector() + + if (fromSwapCurrencyStatus != null) { + updateFeePaidCryptoCurrencyFor(fromSwapCurrencyStatus) + subscribeToCoinBalanceUpdatesIfNeeded() + } + + uiState = stateBuilder.createInitialReadyState( + uiStateHolder = uiState, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, + ) + }, + ), + ), + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + + // Check swap availability if there is pair + if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null) { + initSwapPairs( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } + } } @Suppress("LongMethod") - private fun initTokens(isReverseFromTo: Boolean) { - modelScope.launch(dispatchers.main) { - runCatching(dispatchers.io) { - swapInteractor.getTokensDataState(initialCurrencyFrom) - }.onSuccess { state -> - updateTokensState(state) + private suspend fun onTokenSelect(result: ChooseTokenResult, isFromDirection: Boolean) { + val selectedUserWallet = result.wallet + val selectedCurrencyStatus = result.currency + val selectedAccount = result.account.account - val (selectedCurrency, selectedAccount) = run { - var selectedAccountCurrency = toAccountStatus + val (fromSwapCurrencyStatus, toSwapCurrencyStatus) = if (isFromDirection) { + SwapCurrencyStatus( + userWallet = selectedUserWallet, + status = selectedCurrencyStatus, + account = selectedAccount, + ) to dataState.toSwapCurrencyStatus + } else { + dataState.fromSwapCurrencyStatus to SwapCurrencyStatus( + userWallet = selectedUserWallet, + status = selectedCurrencyStatus, + account = selectedAccount, + ) + } - if (selectedAccountCurrency == null) { - val amountSwapCurrency = swapInteractor.getInitialCurrencyToSwap( - initialCryptoCurrency = initialCurrencyFrom, - state = state, - isReverseFromTo = isReverseFromTo, + if (dataState.fromSwapCurrencyStatus != null) { + isAmountChangedByUser = true + } + + // Check whether pair was already selected + if (fromSwapCurrencyStatus == dataState.fromSwapCurrencyStatus && + toSwapCurrencyStatus == dataState.toSwapCurrencyStatus + ) { + startLoadingQuotesFromLastState(true) + return + } + + dataState = if (isFromDirection) { + // Reset amount if from token is changed + lastAmount.value = INITIAL_AMOUNT + lastReducedBalanceBy.value = BigDecimal.ZERO + SwapProcessDataState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } else { + dataState.copy( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } + filterTokensFromSelector() + uiState = stateBuilder.updateCurrenciesState( + uiStateHolder = uiState, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, ) + }, + ), + ), + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + shouldResetAmount = isFromDirection, + ) - if (amountSwapCurrency != null) { - selectedAccountCurrency = amountSwapCurrency.cryptoCurrencyStatus - } - } + router.pop() - selectedAccountCurrency to toAccount - } + if (isFromDirection && fromSwapCurrencyStatus != null) { + updateFeePaidCryptoCurrencyFor(fromSwapCurrencyStatus) + } - val isApplied = applyInitialTokenChoice( - state = state, - selectedCurrency = selectedCurrency, - selectedAccount = selectedAccount, - isReverseFromTo = isReverseFromTo, + subscribeToCoinBalanceUpdatesIfNeeded() + + // Check swap availability if there is pair + if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null) { + initSwapPairs( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } + } + + private fun onChangeCardsClicked() { + modelScope.launch { + singleTaskScheduler.cancelTask() + + val newFromSwapCurrencyStatus = dataState.toSwapCurrencyStatus + val newToSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + + isAmountChangedByUser = true + + lastAmount.value = INITIAL_AMOUNT + lastReducedBalanceBy.value = BigDecimal.ZERO + + dataState = SwapProcessDataState( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + pairs = dataState.pairs, + selectedPairProviders = dataState.selectedPairProviders, + ) + filterTokensFromSelector() + uiState = stateBuilder.updateCurrenciesState( + uiStateHolder = uiState, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, + ) + }, + ), + ), + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + shouldResetAmount = true, + ) + + if (newFromSwapCurrencyStatus != null && newToSwapCurrencyStatus != null) { + updateFeePaidCryptoCurrencyFor(newFromSwapCurrencyStatus) + val toProvidersList = swapInteractor.findProvidersForPairWithCheck( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + pairs = dataState.pairs, ) - - // assume that fromCryptoCurrency selected according reverse flag, - // so update fee paid currency according to it - val fromCryptoCurrency = dataState.fromCryptoCurrency - - if (isApplied && fromCryptoCurrency != null) { - TangemLogger.i( - "updateFeePaidCryptoCurrencyFor: id = ${fromCryptoCurrency.currency.id}, " + - "isReverseFromTo: $isReverseFromTo", + if (toProvidersList.isEmpty()) { + handleSwapNotSupported( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, ) - updateFeePaidCryptoCurrencyFor(fromCryptoCurrency) } else { - TangemLogger.e("updateFeePaidCryptoCurrencyFor failed: fromCryptoCurrency is null") - } - - subscribeToCoinBalanceUpdatesIfNeeded() - }.onFailure { error -> - TangemLogger.e("Error", error) - - applyInitialTokenChoice( - state = TokensDataStateExpress.EMPTY, - selectedCurrency = null, - selectedAccount = null, - isReverseFromTo = isReverseFromTo, - ) - - uiState = stateBuilder.createInitialErrorState( - uiState, - (error as? ExpressException)?.expressDataError?.code ?: ExpressDataError.UnknownError.code, - ) { - uiState = stateBuilder.createInitialLoadingState( - initialCurrencyFrom = initialCurrencyFrom, - initialCurrencyTo = initialCurrencyTo, - fromNetworkInfo = initialCurrencyFrom.getNetworkInfo(), + startLoadingQuotes( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + amount = lastAmount.value, + reduceBalanceBy = lastReducedBalanceBy.value, + toProvidersList = toProvidersList, ) - initTokens(isReverseFromTo) } } } } - private suspend fun applyAddedToken(addedToken: CryptoCurrency, isSearched: Boolean) { - analyticsEventHandler.send( - SwapEvents.ChooseTokenScreenResult(isTokenChosen = true, token = addedToken.symbol), - ) - analyticsEventHandler.send( - SwapAnalyticsEvent.TokenSelected( - token = addedToken.symbol, - source = ScreensSources.Markets, - isSearched = isSearched, - ), - ) - val status = getAccountCurrencyStatusUseCase.invoke(userWalletId, addedToken) - // todo swap are sure?? about status.value is CryptoCurrencyStatus.Loaded - .firstOrNull { it.status.value is CryptoCurrencyStatus.Loaded } - ?: return - val (selectedAccount, selectedCurrency) = status - - runCatching(dispatchers.io) { - swapInteractor.getTokensDataState(initialCurrencyFrom) - }.onSuccess { state -> - updateTokensState(state) - - applyInitialTokenChoice( - state = state, - selectedCurrency = selectedCurrency, - selectedAccount = selectedAccount, - isReverseFromTo = isOrderReversed.value, + private fun initSwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) { + modelScope.launch { + swapInteractor.getPair( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + filterProviderTypes = if (tangemPayInput?.isWithdrawal == true) { + listOf(ExchangeProviderType.CEX) + } else { + ExchangeProviderType.getSwapProviderTypes() + }, + ).fold( + ifLeft = { error -> + handleSwapNotSupported( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + TangemLogger.e("Error getting swap pair", error) + }, + ifRight = { pairs -> + val providerList = swapInteractor.findProvidersForPairWithCheck( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + pairs = pairs, + ) + if (providerList.isEmpty()) { + handleSwapNotSupported( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } else { + dataState = dataState.copy( + pairs = pairs, + selectedPairProviders = providerList, + ) + startLoadingQuotes( + amount = lastAmount.value, + reduceBalanceBy = lastReducedBalanceBy.value, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + toProvidersList = providerList, + ) + } + }, ) - - subscribeToCoinBalanceUpdatesIfNeeded() - - swapRouter.back() - }.onFailure { error -> - TangemLogger.e("Error", error) } } + @Suppress("UnusedPrivateMember") private fun subscribeToCoinBalanceUpdatesIfNeeded() { - (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + if (fromSwapCurrencyStatus != null) { subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = coin, + swapCurrencyStatus = fromSwapCurrencyStatus, isFromCurrency = true, ) } - (dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> + if (toSwapCurrencyStatus != null) { subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = coin, + swapCurrencyStatus = toSwapCurrencyStatus, isFromCurrency = false, ) } } - /** - * returns true if tokens are selected and dataState is updated, - * false if selected token is null and alert is shown with error message - */ - private fun applyInitialTokenChoice( - state: TokensDataStateExpress, - selectedCurrency: CryptoCurrencyStatus?, - selectedAccount: Account?, - isReverseFromTo: Boolean, - ): Boolean { - // exceptional case - if (selectedCurrency == null) { - TangemLogger.e("No available tokens to swap for ${initialCurrencyFrom.symbol}") - analyticsEventHandler.send(SwapEvents.NoticeNoAvailableTokensToSwap()) - uiState = stateBuilder.createNoAvailableTokensToSwapState( - uiStateHolder = uiState, - fromToken = initialFromStatus, - ) - return false - } - isOrderReversed.value = isReverseFromTo - val (fromCurrencyStatus, toCurrencyStatus) = if (isOrderReversed.value) { - selectedCurrency to initialFromStatus - } else { - initialFromStatus to selectedCurrency - } - val (fromAccount, toAccount) = if (canUseFromAccountCurrencyStatus) { - if (isOrderReversed.value) { - selectedAccount to fromAccount - } else { - fromAccount to selectedAccount - } - } else { - null to null - } - dataState = dataState.copy( - fromCryptoCurrency = fromCurrencyStatus, - fromAccount = fromAccount, - toCryptoCurrency = toCurrencyStatus, - toAccount = toAccount, - tokensDataState = state, - ) - - if (handleSwapNotSupported( - state = state, - fromToken = fromCurrencyStatus, - toToken = toCurrencyStatus, - fromAccount = fromAccount, - toAccount = toAccount, - ) - ) { - return true - } - - startLoadingQuotes( - fromToken = fromCurrencyStatus, - fromAccount = fromAccount, - toToken = toCurrencyStatus, - toAccount = toAccount, - amount = lastAmount.value, - reduceBalanceBy = lastReducedBalanceBy.value, - toProvidersList = findSwapProviders(fromCurrencyStatus, toCurrencyStatus), - ) - return true - } - - private fun updateTokensState(tokenDataState: TokensDataStateExpress) { - val tokensDataState = if (isOrderReversed.value) tokenDataState.fromGroup else tokenDataState.toGroup - chooseTokenBridge.updateCurrenciesGroup(tokensDataState) - } - private fun startLoadingQuotes( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, @@ -634,24 +597,20 @@ internal class SwapModel @Inject constructor( updateFeeBlock: Boolean = true, ) { singleTaskScheduler.cancelTask() + if (amount.isBlank()) return if (!isSilent) { uiState = stateBuilder.createQuotesLoadingState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, uiStateHolder = uiState, - fromToken = fromToken.currency, - toToken = toToken.currency, - fromAccount = fromAccount, - toAccount = toAccount, - mainTokenId = initialCurrencyFrom.id.value, ) feeSelectorRepository.state.value = FeeSelectorUM.Loading } singleTaskScheduler.scheduleTask( modelScope, loadQuotesTask( - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, toProvidersList = toProvidersList, @@ -661,45 +620,45 @@ internal class SwapModel @Inject constructor( } private fun startLoadingQuotesFromLastState(isSilent: Boolean = false, updateFeeBlock: Boolean = true) { - val fromCurrency = dataState.fromCryptoCurrency - val toCurrency = dataState.toCryptoCurrency + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus val amount = dataState.amount - if (fromCurrency != null && toCurrency != null && amount != null) { + if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null && amount != null) { startLoadingQuotes( - fromToken = fromCurrency, - fromAccount = dataState.fromAccount, - toToken = toCurrency, - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, isSilent = isSilent, reduceBalanceBy = dataState.reduceBalanceBy, - toProvidersList = findSwapProviders(fromCurrency, toCurrency), + toProvidersList = dataState.selectedPairProviders, updateFeeBlock = updateFeeBlock, ) } } - private suspend fun updateFeePaidCryptoCurrencyFor(fromToken: CryptoCurrencyStatus) { - dataState = dataState.copy( - feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = fromToken, - ) - .onLeft { TangemLogger.e("Unable to get fee paid crypto currency status for ${fromToken.currency.id}") } - .onRight { currencyStatus -> - if (currencyStatus == null) { - TangemLogger.e("Fee paid crypto currency status is null for ${fromToken.currency.id}") - } + private suspend fun updateFeePaidCryptoCurrencyFor(fromSwapCurrencyStatus: SwapCurrencyStatus) { + val fromCryptoCurrency = fromSwapCurrencyStatus.currency + val feePaidCryptoCurrency = if (fromSwapCurrencyStatus.account is Account.Payment) { + fromSwapCurrencyStatus.status + } else { + getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = fromSwapCurrencyStatus.userWalletId, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, + ).onLeft { + TangemLogger.e("Unable to get fee paid crypto currency status for ${fromCryptoCurrency.id}") + }.onRight { currencyStatus -> + if (currencyStatus == null) { + TangemLogger.e("Fee paid crypto currency status is null for ${fromCryptoCurrency.id}") } - .getOrNull(), - ) + }.getOrNull() + } + + dataState = dataState.copy(feePaidCryptoCurrency = feePaidCryptoCurrency) } private fun loadQuotesTask( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, @@ -715,13 +674,10 @@ internal class SwapModel @Inject constructor( amount = amount, reduceBalanceBy = reduceBalanceBy, swapDataModel = null, - approveDataModel = null, ) swapInteractor.findBestQuote( - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, providers = toProvidersList, amountToSwap = amount, reduceBalanceBy = reduceBalanceBy, @@ -732,7 +688,12 @@ internal class SwapModel @Inject constructor( onSuccess = { providersState -> if (providersState.isNotEmpty()) { val (provider, state) = updateLoadedQuotes(providersState) - setupLoadedState(provider = provider, state = state, fromToken = fromToken, toToken = toToken) + setupLoadedState( + provider = provider, + state = state, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) val successStates = providersState.getLastLoadedSuccessStates() val pricesLowerBest = getPricesLowerBest(provider.providerId, successStates) uiState = stateBuilder.updateProvidersBottomSheetContent( @@ -747,7 +708,8 @@ internal class SwapModel @Inject constructor( shouldUpdateFeeBlock = true } } else { - feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) + feeSelectorRepository.state.value = + FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) TangemLogger.e("Accidentally empty quotes list") } }, @@ -762,17 +724,17 @@ internal class SwapModel @Inject constructor( private fun setupLoadedState( provider: SwapProvider, state: SwapState, - fromToken: CryptoCurrencyStatus, - toToken: CryptoCurrencyStatus?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, ) { when (state) { is SwapState.QuotesLoadedState -> { - setupQuotesLoadedUiState(provider, state, fromToken) - sendAnalyticsForNotifications(provider, fromToken, toToken) + setupQuotesLoadedUiState(provider, state) + sendAnalyticsForNotifications(provider, fromSwapCurrencyStatus.status, toSwapCurrencyStatus.status) updatePermissionNotificationState(state) } is SwapState.EmptyAmountState -> { - setupEmptyAmountUiState(state, fromToken) + setupEmptyAmountUiState(state, fromSwapCurrencyStatus) lastPermissionNotificationTokens = null } is SwapState.SwapError -> { @@ -782,24 +744,18 @@ internal class SwapModel @Inject constructor( } } - private fun setupQuotesLoadedUiState( - provider: SwapProvider, - state: SwapState.QuotesLoadedState, - fromToken: CryptoCurrencyStatus, - ) { - fillLoadedDataState(state, state.permissionState, state.swapDataModel) + private fun setupQuotesLoadedUiState(provider: SwapProvider, state: SwapState.QuotesLoadedState) { + fillLoadedDataState(state.permissionState, state.swapDataModel) val loadedStates = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates() val bestRatedProviderId = findBestQuoteProvider(loadedStates)?.providerId ?: provider.providerId uiState = stateBuilder.createQuotesLoadedState( uiStateHolder = uiState, quoteModel = state, - fromToken = fromToken.currency, feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency, swapProvider = provider, bestRatedProviderId = bestRatedProviderId, isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1, selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, - isReverseSwapPossible = isReverseSwapPossible(), needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), hideFee = isTangemPayWithdrawal(), ) @@ -813,7 +769,7 @@ internal class SwapModel @Inject constructor( if (uiState.notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }) { analyticsEventHandler.send( SwapEvents.NoticeNotEnoughFee( - token = initialCurrencyFrom.symbol, + token = fromToken.currency.symbol, blockchain = fromToken.currency.network.name, ), ) @@ -843,10 +799,10 @@ internal class SwapModel @Inject constructor( } private fun updatePermissionNotificationState(state: SwapState.QuotesLoadedState) { - val fromTokenId = state.fromTokenInfo.cryptoCurrencyStatus - .currency.id.value - val toTokenId = state.toTokenInfo.cryptoCurrencyStatus - .currency.id.value + val fromCryptoCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus + val toCryptoCurrencyStatus = state.toTokenInfo.swapCurrencyStatus + val fromTokenId = fromCryptoCurrencyStatus.currency.id.value + val toTokenId = toCryptoCurrencyStatus.currency.id.value val currentTokenPair = Pair(fromTokenId, toTokenId) when { @@ -860,15 +816,14 @@ internal class SwapModel @Inject constructor( } } - private fun setupEmptyAmountUiState(state: SwapState.EmptyAmountState, fromToken: CryptoCurrencyStatus) { - val toTokenStatus = dataState.toCryptoCurrency + private fun setupEmptyAmountUiState( + state: SwapState.EmptyAmountState, + fromSwapCurrencyStatus: SwapCurrencyStatus, + ) { uiState = stateBuilder.createQuotesEmptyAmountState( uiStateHolder = uiState, emptyAmountState = state, - fromTokenStatus = fromToken, - toTokenStatus = toTokenStatus, - isReverseSwapPossible = isReverseSwapPossible(), - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, ) } @@ -878,24 +833,25 @@ internal class SwapModel @Inject constructor( uiStateHolder = uiState, swapProvider = provider, fromToken = state.fromTokenInfo, - toToken = dataState.toCryptoCurrency, + toSwapCurrencyStatus = dataState.toSwapCurrencyStatus, expressDataError = state.error, includeFeeInAmount = state.includeFeeInAmount, - isReverseSwapPossible = isReverseSwapPossible(), needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), - toAccount = dataState.toAccount, ) sendErrorAnalyticsEvent(state.error, provider) } private fun sendErrorAnalyticsEvent(error: ExpressDataError, provider: SwapProvider) { - val receiveToken = dataState.toCryptoCurrency?.currency?.let { currency -> + val fromCryptoCurrency = dataState.fromSwapCurrencyStatus?.currency?.let { currency -> + "${currency.network.rawId}:${currency.symbol}" + } + val toCryptoCurrency = dataState.toSwapCurrencyStatus?.currency?.let { currency -> "${currency.network.rawId}:${currency.symbol}" } analyticsErrorEventHandler.sendErrorEvent( SwapEvents.NoticeProviderError( - sendToken = "${initialCurrencyFrom.network.rawId}:${initialCurrencyFrom.symbol}", - receiveToken = receiveToken.orEmpty(), + sendToken = fromCryptoCurrency.orEmpty(), + receiveToken = toCryptoCurrency.orEmpty(), provider = provider, errorCode = error.code, errorMessage = error.message, @@ -951,38 +907,16 @@ internal class SwapModel @Inject constructor( } } - private fun fillLoadedDataState( - state: SwapState.QuotesLoadedState, - permissionState: PermissionDataState, - swapDataModel: SwapDataModel?, - ) { - dataState = if (permissionState is PermissionDataState.PermissionReadyForRequest) { - dataState.copy(approveDataModel = permissionState.requestApproveData) + private fun fillLoadedDataState(permissionState: PermissionDataState, swapDataModel: SwapDataModel?) { + dataState = if (permissionState is PermissionDataState.PermissionRequired) { + dataState.copy() } else { dataState.copy( swapDataModel = swapDataModel, - selectedFee = updateOrSelectFee(state), ) } } - private fun updateOrSelectFee(state: SwapState.QuotesLoadedState): TxFee.Legacy? { - val selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL - return when (val txFee = state.txFee) { - TxFeeState.Empty -> null - is TxFeeState.MultipleFeeState -> { - if (selectedFeeType == FeeType.NORMAL) { - txFee.normalFee - } else { - txFee.priorityFee - } - } - is TxFeeState.SingleFeeState -> { - txFee.fee - } - } - } - @Suppress("LongMethod") private fun onSwapClick() { singleTaskScheduler.cancelTask() @@ -993,7 +927,8 @@ internal class SwapModel @Inject constructor( TangemLogger.e("Last loaded quotes state is null") return } - val fromCurrency = requireNotNull(dataState.fromCryptoCurrency) + val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) + val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus) val fee = getSelectedFee() val isTangemPayWithdrawal = isTangemPayWithdrawal() @@ -1009,13 +944,11 @@ internal class SwapModel @Inject constructor( modelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { swapInteractor.onSwap( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, swapProvider = provider, swapData = dataState.swapDataModel, - currencyToSend = fromCurrency, - currencyToGet = requireNotNull(dataState.toCryptoCurrency), amountToSwap = requireNotNull(dataState.amount), - fromAccount = dataState.fromAccount, - toAccount = dataState.toAccount, includeFeeInAmount = lastLoadedQuotesState.preparedSwapConfigState.includeFeeInAmount, fee = fee, expressOperationType = ExpressOperationType.SWAP, @@ -1030,12 +963,12 @@ internal class SwapModel @Inject constructor( return@onSuccess } sendSuccessSwapEvent( - fromCurrency.currency, + fromSwapCurrencyStatus.currency, (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, ) val url = getExplorerTransactionUrlUseCase( txHash = swapTransactionState.txHash, - currency = fromCurrency.currency, + currency = fromSwapCurrencyStatus.currency, ).getOrElse { TangemLogger.i("tx hash explore not supported") "" @@ -1052,7 +985,7 @@ internal class SwapModel @Inject constructor( urlOpener.openUrl(url) } analyticsEventHandler.send( - event = SwapEvents.ButtonExplore(initialCurrencyFrom.symbol), + event = SwapEvents.ButtonExplore(fromSwapCurrencyStatus.currency.symbol), ) }, onStatusClick = { @@ -1060,14 +993,14 @@ internal class SwapModel @Inject constructor( if (!txExternalUrl.isNullOrBlank()) { urlOpener.openUrl(txExternalUrl) analyticsEventHandler.send( - event = SwapEvents.ButtonStatus(initialCurrencyFrom.symbol), + event = SwapEvents.ButtonStatus(fromSwapCurrencyStatus.currency.symbol), ) } }, ) sendSuccessEvent() - swapRouter.openScreen(SwapNavScreen.Success) + router.replaceAll(SwapRoute.Success) } SwapTransactionState.DemoMode -> { showDemoModeAlert() @@ -1077,7 +1010,10 @@ internal class SwapModel @Inject constructor( showTransactionErrorAlert(swapTransactionState) } is SwapTransactionState.TangemPayWithdrawalData -> { - processTangemPayWithdrawal(swapTransactionState = swapTransactionState) + processTangemPayWithdrawal( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + swapTransactionState = swapTransactionState, + ) } } }.onFailure { error -> @@ -1088,9 +1024,12 @@ internal class SwapModel @Inject constructor( } } - private suspend fun processTangemPayWithdrawal(swapTransactionState: SwapTransactionState.TangemPayWithdrawalData) { + private suspend fun processTangemPayWithdrawal( + fromSwapCurrencyStatus: SwapCurrencyStatus, + swapTransactionState: SwapTransactionState.TangemPayWithdrawalData, + ) { tangemPayWithdrawUseCase( - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, cryptoAmount = swapTransactionState.cryptoAmount, cryptoCurrencyId = swapTransactionState.cryptoCurrencyId, receiverCexAddress = swapTransactionState.cexAddress, @@ -1108,10 +1047,8 @@ internal class SwapModel @Inject constructor( WithdrawalResult.Success -> { val txUrl = swapTransactionState.storeData.txExternalUrl swapInteractor.storeSwapTransaction( - currencyToSend = swapTransactionState.storeData.currencyToSend, - currencyToGet = swapTransactionState.storeData.currencyToGet, - fromAccount = swapTransactionState.storeData.fromAccount, - toAccount = swapTransactionState.storeData.toAccount, + fromSwapCurrencyStatus = swapTransactionState.storeData.fromSwapCurrencyStatus, + toSwapCurrencyStatus = swapTransactionState.storeData.toSwapCurrencyStatus, amount = swapTransactionState.storeData.amount, swapProvider = swapTransactionState.storeData.swapProvider, swapDataModel = swapTransactionState.storeData.swapDataModel, @@ -1127,7 +1064,7 @@ internal class SwapModel @Inject constructor( txUrl = txUrl.orEmpty(), onExploreClick = { if (txUrl != null) urlOpener.openUrl(txUrl) }, ) - swapRouter.openScreen(SwapNavScreen.Success) + router.replaceAll(SwapRoute.Success) } } } @@ -1136,19 +1073,19 @@ internal class SwapModel @Inject constructor( private suspend fun sendSuccessEvent() { val provider = dataState.selectedProvider ?: return val fee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL - val fromCurrency = dataState.fromCryptoCurrency?.currency ?: return - val toCurrency = dataState.toCryptoCurrency?.currency ?: return - val fromDerivationIndex = dataState.fromAccount?.derivationIndex?.value - val toDerivationIndex = dataState.toAccount?.derivationIndex?.value + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return + val fromDerivationIndex = fromSwapCurrencyStatus.account.derivationIndex?.value + val toDerivationIndex = toSwapCurrencyStatus.account.derivationIndex?.value analyticsEventHandler.send( SwapEvents.SwapInProgressScreen( provider = provider, commission = fee, - sendBlockchain = fromCurrency.network.name, - receiveBlockchain = toCurrency.network.name, - sendToken = fromCurrency.symbol, - receiveToken = toCurrency.symbol, + sendBlockchain = fromSwapCurrencyStatus.currency.network.name, + receiveBlockchain = toSwapCurrencyStatus.currency.network.name, + sendToken = fromSwapCurrencyStatus.currency.symbol, + receiveToken = toSwapCurrencyStatus.currency.symbol, feeToken = getFeeToken().symbol, fromDerivationIndex = fromDerivationIndex, toDerivationIndex = toDerivationIndex, @@ -1157,290 +1094,50 @@ internal class SwapModel @Inject constructor( ) } - @Suppress("LongMethod") - private fun givePermissionsToSwap() { - modelScope.launch(dispatchers.main) { - runSuspendCatching { - val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency) { - "dataState.fromCryptoCurrency might not be null" - } - val fromToken = fromCryptoCurrency.currency + private fun subscribeToCoinBalanceUpdates(swapCurrencyStatus: SwapCurrencyStatus, isFromCurrency: Boolean) { + val swapCurrency = swapCurrencyStatus.currency - val approveDataModel = requireNotNull(dataState.approveDataModel) { - "dataState.approveDataModel.spenderAddress shouldn't be null" - } - val approveType = - requireNotNull(uiState.permissionState.getApproveTypeOrNull()?.toDomainApproveType()) { - "uiState.permissionState should not be null" - } - val feeForPermission = when (val fee = approveDataModel.fee) { - TxFeeState.Empty -> { - showAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) - TangemLogger.e("Fee should not be Empty") - return@launch - } - is TxFeeState.MultipleFeeState -> fee.priorityFee - is TxFeeState.SingleFeeState -> fee.fee - } - runCatching(dispatchers.io) { - swapInteractor.givePermissionToSwap( - networkId = fromToken.network.rawId, - permissionOptions = PermissionOptions( - approveData = approveDataModel, - forTokenContractAddress = (fromToken as? CryptoCurrency.Token)?.contractAddress.orEmpty(), - fromTokenStatus = fromCryptoCurrency, - approveType = approveType, - txFee = feeForPermission, - spenderAddress = requireNotNull(dataState.approveDataModel).spenderAddress, - ), - ) - }.onSuccess { swapTransactionState -> - when (swapTransactionState) { - is SwapTransactionState.TxSent -> { - // TODO [REDACTED_TASK_KEY] gasless analytics - sendApproveSuccessEvent(fromToken, feeForPermission.feeType, approveType) - updateWalletBalance() - uiState = stateBuilder.loadingPermissionState(uiState) - uiState = stateBuilder.dismissBottomSheet(uiState) - startLoadingQuotesFromLastState(isSilent = true) - } - is SwapTransactionState.Error -> { - showTransactionErrorAlert(swapTransactionState) - } - SwapTransactionState.DemoMode -> { - showDemoModeAlert() - } - is SwapTransactionState.TangemPayWithdrawalData -> { - processTangemPayWithdrawal(swapTransactionState = swapTransactionState) - } - } - }.onFailure { showAlert() } - }.onFailure { error -> - TangemLogger.e(error.message.orEmpty()) - showAlert() - } - } - } + when (swapCurrencyStatus.account) { + is Account.CryptoPortfolio -> getAccountCurrencyStatusUseCase( + userWalletId = swapCurrencyStatus.userWalletId, + currency = swapCurrency, + ).map { (_, status) -> status } + is Account.Payment -> getPaymentAccountCryptoCurrencyStatusUseCase( + userWalletId = swapCurrencyStatus.userWalletId, + cryptoCurrency = swapCurrency, + ).map { (_, status) -> status } + }.distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes + .onEach { currencyStatus -> - private fun onSearchEntered(searchQuery: String) { - val tokenDataState = dataState.tokensDataState ?: return - val group = if (isOrderReversed.value) { - tokenDataState.fromGroup - } else { - tokenDataState.toGroup - } - - val available = group.available.filter { swapAvailability -> - swapAvailability.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) || - swapAvailability.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true) - } - val unavailable = group.unavailable.filter { swapAvailability -> - swapAvailability.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) || - swapAvailability.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true) - } - val accountCurrencyList = group.accountCurrencyList.mapNotNull { accountSwapAvailability -> - val filteredCurrencies = accountSwapAvailability.currencyList.filter { accountSwapCurrency -> - val currency = accountSwapCurrency.cryptoCurrencyStatus.currency - currency.name.contains(searchQuery, ignoreCase = true) || - currency.symbol.contains(searchQuery, ignoreCase = true) - } - - if (filteredCurrencies.isEmpty()) { - return@mapNotNull null - } - - accountSwapAvailability.copy( - currencyList = filteredCurrencies, - ) - } - - val filteredTokenDataState = if (isOrderReversed.value) { - tokenDataState.copy( - fromGroup = tokenDataState.fromGroup.copy( - available = available, - unavailable = unavailable, - accountCurrencyList = accountCurrencyList, - isAfterSearch = true, - ), - ) - } else { - tokenDataState.copy( - toGroup = tokenDataState.toGroup.copy( - available = available, - unavailable = unavailable, - accountCurrencyList = accountCurrencyList, - isAfterSearch = true, - ), - ) - } - updateTokensState(filteredTokenDataState) - } - - @Suppress("LongMethod") - private fun onTokenSelect(account: Account, cryptoCurrencyStatus: CryptoCurrencyStatus, isSearched: Boolean) { - val tokens = dataState.tokensDataState ?: return - val foundToken = cryptoCurrencyStatus - val foundAccount = account - - foundToken.currency.symbol.let { symbol -> - analyticsEventHandler.send( - SwapEvents.ChooseTokenScreenResult(isTokenChosen = true, token = symbol), - ) - - analyticsEventHandler.send( - SwapAnalyticsEvent.TokenSelected( - token = symbol, - source = ScreensSources.Portfolio, - isSearched = isSearched, - ), - ) - } - - val fromToken: CryptoCurrencyStatus - val fromAccount: Account? - val toToken: CryptoCurrencyStatus - val toAccount: Account? - if (isOrderReversed.value) { - fromToken = foundToken - fromAccount = foundAccount - toToken = initialFromStatus - toAccount = this.fromAccount - - val newToken = fromToken.currency as? CryptoCurrency.Coin - if (newToken != null) { - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = newToken, - isFromCurrency = true, - ) - } else { - fromTokenBalanceJobHolder.cancel() - } - } else { - fromToken = initialFromStatus - fromAccount = this.fromAccount - toToken = foundToken - toAccount = foundAccount - - val newToken = toToken.currency as? CryptoCurrency.Coin - if (newToken != null) { - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = newToken, - isFromCurrency = false, - ) - } else { - toTokenBalanceJobHolder.cancel() - } - } - - if (dataState.fromCryptoCurrency != null && dataState.tokensDataState != null) { - isAmountChangedByUser = true - } - - dataState = dataState.copy( - fromCryptoCurrency = fromToken, - fromAccount = fromAccount, - toCryptoCurrency = toToken, - toAccount = toAccount, - selectedProvider = null, - ) - swapRouter.openScreen(SwapNavScreen.Main) - if (handleSwapNotSupported( - state = tokens, - fromToken = fromToken, - toToken = toToken, - fromAccount = fromAccount, - toAccount = toAccount, - ) - ) { - return - } - modelScope.launch { - TangemLogger.i( - "updateFeePaidCryptoCurrencyFor: id = ${fromToken.currency.id}, " + - "isOrderReversed: ${isOrderReversed.value}", - ) - if ((uiState.sendCardData as? SwapCardState.SwapCardData)?.type is TransactionCardType.ReadOnly) { - uiState = stateBuilder.createInitialLoadingState( - initialCurrencyFrom = fromToken.currency, - initialCurrencyTo = toToken.currency, - fromNetworkInfo = fromToken.currency.getNetworkInfo(), - ) - } - updateFeePaidCryptoCurrencyFor(fromToken) - startLoadingQuotes( - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - amount = lastAmount.value, - reduceBalanceBy = lastReducedBalanceBy.value, - toProvidersList = findSwapProviders(fromToken, toToken), - ) - } - updateTokensState(tokens) - } - - @Suppress("LongMethod", "CyclomaticComplexMethod") - private fun subscribeToCoinBalanceUpdates( - userWalletId: UserWalletId, - coin: CryptoCurrency.Coin, - isFromCurrency: Boolean, - ) { - TangemLogger.d("Subscribe to ${coin.id} balance updates") - - getAccountCurrencyStatusUseCase( - userWalletId = userWalletId, - currency = coin, - ).distinctUntilChanged { old, new -> old.status.value.amount == new.status.value.amount } // Check only balance changes - .onEach { (account, currencyStatus) -> - TangemLogger.d("${coin.id} balance is ${currencyStatus.value.amount ?: "null"}") - - if (isFromCurrency) { - dataState = dataState.copy( - feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = currencyStatus, + when { + isFromCurrency && currencyStatus.currency.id == swapCurrency.id -> { + dataState = dataState.copy( + fromSwapCurrencyStatus = swapCurrencyStatus.copy(status = currencyStatus), ) - .onLeft { - TangemLogger.e( - "Coin balance: Unable to get fee paid crypto currency status for " + - "${currencyStatus.currency.id}", + } + !isFromCurrency && currencyStatus.currency.id == swapCurrency.id -> { + dataState = dataState.copy( + toSwapCurrencyStatus = swapCurrencyStatus.copy(status = currencyStatus), + ) + } + else -> Unit + } + + uiState = stateBuilder.updateCurrencyBalanceStatus( + uiState = uiState, + fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus, + toSwapCurrencyStatus = dataState.toSwapCurrencyStatus, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, ) - } - .onRight { status -> - if (status == null) { - TangemLogger.e( - "Coin balance: Fee paid crypto currency status is null " + - "for ${currencyStatus.currency.id}", - ) - } - } - .getOrNull() - ?: currencyStatus, - ) - } - - uiState = when { - isFromCurrency && currencyStatus.currency.id == dataState.fromCryptoCurrency?.currency?.id -> { - dataState = dataState.copy( - fromCryptoCurrency = currencyStatus, - fromAccount = account, - ) - stateBuilder.updateSendCurrencyBalance(uiState, currencyStatus) - } - !isFromCurrency && currencyStatus.currency.id == dataState.toCryptoCurrency?.currency?.id -> { - dataState = dataState.copy( - toCryptoCurrency = currencyStatus, - toAccount = account, - ) - stateBuilder.updateReceiveCurrencyBalance(uiState, currencyStatus) - } - else -> { - uiState - } - } + }, + ), + ), + ) startLoadingQuotesFromLastState(isSilent = true) } .flowOn(dispatchers.main) @@ -1448,74 +1145,20 @@ internal class SwapModel @Inject constructor( .saveIn(if (isFromCurrency) fromTokenBalanceJobHolder else toTokenBalanceJobHolder) } - private fun onChangeCardsClicked() { - modelScope.launch { - val newFromToken = dataState.toCryptoCurrency - val newFromAccount = dataState.toAccount - val newToToken = dataState.fromCryptoCurrency - val newToAccount = dataState.fromAccount - - if (newFromToken != null && newToToken != null) { - isAmountChangedByUser = true - - dataState = dataState.copy( - fromCryptoCurrency = newFromToken, - fromAccount = newFromAccount, - toCryptoCurrency = newToToken, - toAccount = newToAccount, - ) - isOrderReversed.value = !isOrderReversed.value - TangemLogger.i( - "updateFeePaidCryptoCurrencyFor: id = ${newFromToken.currency.id}, " + - "isOrderReversed: ${isOrderReversed.value}", - ) - updateFeePaidCryptoCurrencyFor(newFromToken) - dataState.tokensDataState?.let { tokensDataState -> - updateTokensState(tokensDataState) - } - - val minTxAmount = getMinimumTransactionAmountSyncUseCase( - userWalletId, - newFromToken, - ).getOrNull() - val decimals = newFromToken.currency.decimals - lastAmount.value = cutAmountWithDecimals(decimals, lastAmount.value) - lastReducedBalanceBy.value = BigDecimal.ZERO - uiState = stateBuilder.updateSwapAmount( - uiState = uiState, - amountFormatted = inputNumberFormatter.formatWithThousands(lastAmount.value, decimals), - amountRaw = lastAmount.value, - fromToken = newFromToken.currency, - minTxAmount = minTxAmount, - fromAccount = dataState.fromAccount, - ) - startLoadingQuotes( - fromToken = newFromToken, - fromAccount = newFromAccount, - toToken = newToToken, - toAccount = newToAccount, - amount = lastAmount.value, - reduceBalanceBy = lastReducedBalanceBy.value, - toProvidersList = findSwapProviders(newFromToken, newToToken), - ) - } - } - } - private fun onAmountChanged( value: String, forceQuotesUpdate: Boolean = false, reduceBalanceBy: BigDecimal = BigDecimal.ZERO, ) { modelScope.launch { - val fromToken = dataState.fromCryptoCurrency - val toToken = dataState.toCryptoCurrency - if (fromToken != null) { - val decimals = fromToken.currency.decimals + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + if (fromSwapCurrencyStatus != null) { + val decimals = fromSwapCurrencyStatus.currency.decimals val cutValue = cutAmountWithDecimals(decimals, value) val minTxAmount = getMinimumTransactionAmountSyncUseCase( - userWalletId, - fromToken, + userWalletId = fromSwapCurrencyStatus.userWalletId, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ).getOrNull() lastAmount.value = cutValue lastReducedBalanceBy.value = reduceBalanceBy @@ -1523,25 +1166,22 @@ internal class SwapModel @Inject constructor( uiState = uiState, amountFormatted = inputNumberFormatter.formatWithThousands(cutValue, decimals), amountRaw = lastAmount.value, - fromToken = fromToken.currency, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, minTxAmount = minTxAmount, - fromAccount = dataState.fromAccount, ) - if (toToken != null) { - if (toToken.value.amount != null) { + if (toSwapCurrencyStatus != null) { + if (toSwapCurrencyStatus.status.value.amount != null) { isAmountChangedByUser = true } amountDebouncer.debounce(modelScope, DEBOUNCE_AMOUNT_DELAY, forceUpdate = forceQuotesUpdate) { startLoadingQuotes( - fromToken = fromToken, - fromAccount = dataState.fromAccount, - toToken = toToken, - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = lastAmount.value, reduceBalanceBy = lastReducedBalanceBy.value, - toProvidersList = findSwapProviders(fromToken, toToken), + toProvidersList = dataState.selectedPairProviders, ) } } @@ -1550,8 +1190,8 @@ internal class SwapModel @Inject constructor( } private fun onMaxAmountClicked() { - dataState.fromCryptoCurrency?.let { fromCurrency -> - val balance = swapInteractor.getTokenBalance(fromCurrency) + dataState.fromSwapCurrencyStatus?.let { fromCurrency -> + val balance = swapInteractor.getTokenBalance(fromCurrency.status) onAmountChanged(balance.formatToUIRepresentation()) } } @@ -1603,9 +1243,11 @@ internal class SwapModel @Inject constructor( } private fun onTangemPaySupportClick(txId: String) { + val fromUserWalletId = dataState.fromSwapCurrencyStatus?.userWalletId ?: return + modelScope.launch { - val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch - val customerId = getTangemPayCustomerIdUseCase(userWallet.walletId).getOrNull().orEmpty() + val metaInfo = getWalletMetaInfoUseCase(fromUserWalletId).getOrNull() ?: return@launch + val customerId = getTangemPayCustomerIdUseCase(fromUserWalletId).getOrNull().orEmpty() val email = FeedbackEmailType.Visa.Withdrawal( walletMetaInfo = metaInfo, customerId = customerId, @@ -1671,8 +1313,8 @@ internal class SwapModel @Inject constructor( onAmountChanged = { onAmountChanged(it) }, onSwapClick = { onSwapClick() - val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol - val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol + val sendTokenSymbol = dataState.fromSwapCurrencyStatus?.currency?.symbol + val receiveTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol if (sendTokenSymbol != null && receiveTokenSymbol != null) { analyticsEventHandler.send( SwapEvents.ButtonSwapClicked( @@ -1682,10 +1324,6 @@ internal class SwapModel @Inject constructor( ) } }, - onGivePermissionClick = { - givePermissionsToSwap() - sendPermissionApproveClickedEvent() - }, onChangeCardsClicked = { onChangeCardsClicked() analyticsEventHandler.send(SwapEvents.ButtonSwipeClicked()) @@ -1695,9 +1333,8 @@ internal class SwapModel @Inject constructor( if (bottomSheet != null && bottomSheet.isShown) { uiState = stateBuilder.dismissBottomSheet(uiState) } else { - swapRouter.back() + router.pop() } - onSearchEntered("") }, onMaxAmountSelected = ::onMaxAmountClicked, onReduceToAmount = ::onReduceAmountClicked, @@ -1705,20 +1342,9 @@ internal class SwapModel @Inject constructor( openPermissionBottomSheet = { singleTaskScheduler.cancelTask() sendGivePermissionClickedEvent() - if (shouldUseGaslessApproval) { - approvalSlotNavigation.activate(Unit) - } else { - uiState = stateBuilder.showPermissionBottomSheet(uiState) { - startLoadingQuotesFromLastState(isSilent = true) - analyticsEventHandler.send(SwapEvents.ButtonPermissionCancelClicked()) - uiState = stateBuilder.dismissBottomSheet(uiState) - } - } + approvalSlotNavigation.activate(Unit) }, onAmountSelected = { onAmountSelected(it) }, - onChangeApproveType = { approveType -> - uiState = stateBuilder.updateApproveType(uiState, approveType) - }, onClickFee = { val selectedFee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL val txFeeState = @@ -1753,9 +1379,10 @@ internal class SwapModel @Inject constructor( onProviderSelect = { providerId -> val provider = findAndSelectProvider(providerId) val swapState = dataState.lastLoadedSwapStates[provider] - val fromToken = dataState.fromCryptoCurrency - val toToken = dataState.toCryptoCurrency - if (provider != null && swapState != null && fromToken != null) { + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + val isNotNullCurrency = fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null + if (provider != null && swapState != null && isNotNullCurrency) { modelScope.launch { feeSelectorRepository.state.value = FeeSelectorUM.Loading feeSelectorReloadTrigger.triggerUpdate() @@ -1765,40 +1392,78 @@ internal class SwapModel @Inject constructor( setupLoadedState( provider = provider, state = swapState, - fromToken = fromToken, - toToken = toToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, ) } }, - onBuyClick = { currency -> - swapRouter.openTokenDetails( - userWalletId = userWalletId, - currency = currency, + onBuyClick = { + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return@UiActions + val feePaidCryptoCurrency = dataState.feePaidCryptoCurrency ?: return@UiActions + val route = AppRoute.CurrencyDetails( + userWalletId = fromSwapCurrencyStatus.userWalletId, + currency = feePaidCryptoCurrency.currency, ) + + appRouter.push(route) }, onRetryClick = { startLoadingQuotesFromLastState() }, onReceiveCardWarningClick = { val selectedProvider = dataState.selectedProvider ?: return@UiActions - val currencySymbol = dataState.toCryptoCurrency?.currency?.symbol ?: return@UiActions + val currencySymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return@UiActions val isPriceImpact = uiState.priceImpact.type != PriceImpact.Type.NONE showSwapInfoAlert(isPriceImpact, currencySymbol, selectedProvider) }, onLinkClick = urlOpener::openUrl, - onSelectTokenClick = { - swapRouter.openScreen(SwapNavScreen.SelectToken) - sendSelectTokenScreenOpenedEvent() + onSelectTokenClick = { direction -> + singleTaskScheduler.cancelTask() // Need to stop auto quotes fetching + router.push( + SwapRoute.SelectToken(isFromDirection = direction == TokenSelectionDirection.FROM), + ) }, onSuccess = { - swapRouter.openScreen(SwapNavScreen.Success) - }, - onOpenLearnMoreAboutApproveClick = { - urlOpener.openUrl(RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP) + router.replaceAll(SwapRoute.Success) }, ) } + private fun selectWalletInSelector( + fromSwapCurrencyStatus: SwapCurrencyStatus?, + toSwapCurrencyStatus: SwapCurrencyStatus?, + ) { + if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus == null) { + chooseFromTokenBridge.selectWalletTab(fromSwapCurrencyStatus.userWalletId) + chooseToTokenBridge.selectWalletTab(fromSwapCurrencyStatus.userWalletId) + } else if (fromSwapCurrencyStatus == null && toSwapCurrencyStatus != null) { + chooseFromTokenBridge.selectWalletTab(toSwapCurrencyStatus.userWalletId) + chooseToTokenBridge.selectWalletTab(toSwapCurrencyStatus.userWalletId) + } else if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null) { + chooseFromTokenBridge.selectWalletTab(fromSwapCurrencyStatus.userWalletId) + chooseToTokenBridge.selectWalletTab(toSwapCurrencyStatus.userWalletId) + } + } + + private fun filterTokensFromSelector() { + val tokenFilter = { accountStatus: AccountStatus, currencyStatus: CryptoCurrencyStatus -> + if (currencyStatus.currency.isCustom) { + false + } else { + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + + (fromSwapCurrencyStatus?.account?.accountId != accountStatus.accountId || + fromSwapCurrencyStatus.currency.id != currencyStatus.currency.id) && + (toSwapCurrencyStatus?.account?.accountId != accountStatus.accountId || + toSwapCurrencyStatus.currency.id != currencyStatus.currency.id) + } + } + + chooseFromTokenBridge.tokenFilter.value = tokenFilter + chooseToTokenBridge.tokenFilter.value = tokenFilter + } + private fun sendSuccessSwapEvent(fromToken: CryptoCurrency, feeType: FeeType) { val event = AnalyticsParam.TxSentFrom.Swap( blockchain = fromToken.network.name, @@ -1815,7 +1480,7 @@ internal class SwapModel @Inject constructor( } private fun getFeeToken(): CryptoCurrency { - val fromToken = requireNotNull(dataState.fromCryptoCurrency) { + val fromToken = requireNotNull(dataState.fromSwapCurrencyStatus) { "fromCryptoCurrency should not be null" } return when (val fee = getSelectedFee()) { @@ -1826,23 +1491,6 @@ internal class SwapModel @Inject constructor( } } - private fun sendApproveSuccessEvent(fromToken: CryptoCurrency, feeType: FeeType, approveType: SwapApproveType) { - val feeToken = getFeeToken().symbol - val event = AnalyticsParam.TxSentFrom.Approve( - blockchain = fromToken.network.name, - token = fromToken.symbol, - feeType = AnalyticsParam.FeeType.fromString(feeType.getNameForAnalytics()), - permissionType = approveType.getNameForAnalytics(), - feeToken = feeToken, - ) - analyticsEventHandler.send( - Basic.TransactionSent( - sentFrom = event, - memoType = Basic.TransactionSent.MemoType.Null, - ), - ) - } - private fun findAndSelectProvider(providerId: String): SwapProvider? { val selectedProvider = dataState.lastLoadedSwapStates.keys.firstOrNull { it.providerId == providerId } if (selectedProvider != null) { @@ -1862,7 +1510,7 @@ internal class SwapModel @Inject constructor( if (!fromAmountFiat.isNullOrZero() && !toAmountFiat.isNullOrZero()) { fromAmountFiat.divide( toAmountFiat, - toTokenInfo.cryptoCurrencyStatus.currency.decimals, + toTokenInfo.swapCurrencyStatus.currency.decimals, RoundingMode.HALF_UP, ) } else { @@ -1901,95 +1549,34 @@ internal class SwapModel @Inject constructor( ) } - private fun findSwapProviders(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): List { - val groupToFind = if (isOrderReversed.value) { - dataState.tokensDataState?.fromGroup - } else { - dataState.tokensDataState?.toGroup - } ?: return emptyList() - - val idToFind = if (isOrderReversed.value) { - fromToken.currency.id.value - } else { - toToken.currency.id.value - } - - return groupToFind.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) -> - currencyList.find { accountSwapCurrency -> - idToFind == accountSwapCurrency.cryptoCurrencyStatus.currency.id.value && - accountSwapCurrency.isAvailable - } - }?.providers - ?.filterForTangemPayWithdrawal() - .orEmpty() - } - - /** - * @return true if swap is not supported and UI was updated to show error state - */ private fun handleSwapNotSupported( - state: TokensDataStateExpress, - fromToken: CryptoCurrencyStatus, - toToken: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, - ): Boolean { - val selectedCurrency = if (isOrderReversed.value) fromToken else toToken - if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed.value)) return false - + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + ) { + val fromCurrency = fromSwapCurrencyStatus.currency + val toCurrency = toSwapCurrencyStatus.currency analyticsEventHandler.send( SwapEvents.NoticeUnavailableToSwapPair( - sendToken = fromToken.currency.symbol, - receiveToken = toToken.currency.symbol, - sendBlockchain = fromToken.currency.network.name, - receiveBlockchain = toToken.currency.network.name, + sendToken = fromCurrency.symbol, + receiveToken = toCurrency.symbol, + sendBlockchain = fromCurrency.network.name, + receiveBlockchain = toCurrency.network.name, ), ) // Cancel periodic quote task if selected token is not supported singleTaskScheduler.cancelTask() - // Reset data state - dataState = SwapProcessDataState( - tokensDataState = dataState.tokensDataState, - ) + lastReducedBalanceBy.value = BigDecimal.ZERO lastAmount.value = INITIAL_AMOUNT uiState = stateBuilder.createSwapNotSupportedState( uiStateHolder = uiState, - fromToken = fromToken, - toToken = toToken, - fromAccount = fromAccount, - toAccount = toAccount, - mainTokenId = initialCurrencyFrom.id.value, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, ) - return true - } - - private fun isTokenAvailableForSwap( - state: TokensDataStateExpress, - selectedCurrency: CryptoCurrencyStatus, - isReverseFromTo: Boolean, - ): Boolean { - val group = if (isReverseFromTo) state.fromGroup else state.toGroup - val idToFind = selectedCurrency.currency.id.value - - return group.accountCurrencyList.any { (_, currencyList) -> - currencyList.any { accountSwapCurrency -> - idToFind == accountSwapCurrency.cryptoCurrencyStatus.currency.id.value && - accountSwapCurrency.isAvailable - } - } } private fun isTangemPayWithdrawal(): Boolean { - return tangemPayInput?.isWithdrawal == true || dataState.fromAccount is Account.Payment - } - - private fun List.filterForTangemPayWithdrawal(): List { - return if (isTangemPayWithdrawal()) { - filter { it.type == ExchangeProviderType.CEX } - } else { - this - } + return tangemPayInput?.isWithdrawal == true || dataState.fromSwapCurrencyStatus?.account is Account.Payment } private fun Map.getLastLoadedSuccessStates(): SuccessLoadedSwapData { @@ -2003,29 +1590,9 @@ internal class SwapModel @Inject constructor( } } - private fun isReverseSwapPossible(): Boolean { - if (tangemPayInput != null) return false - val from = dataState.fromCryptoCurrency ?: return false - val to = dataState.toCryptoCurrency ?: return false - - val currenciesGroup = if (isOrderReversed.value) { - dataState.tokensDataState?.toGroup - } else { - dataState.tokensDataState?.fromGroup - } ?: return false - - val chosen = if (isOrderReversed.value) from else to - - return currenciesGroup.accountCurrencyList.flatMap { accountSwapAvailability -> - accountSwapAvailability.currencyList.map { accountSwapCurrency -> - accountSwapCurrency.cryptoCurrencyStatus - } - }.map { currencyStatus -> currencyStatus.currency }.contains(chosen.currency) - } - private fun sendNoticePermissionNeededEvent() { - val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol ?: return - val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol ?: return + val sendTokenSymbol = dataState.fromSwapCurrencyStatus?.currency?.symbol ?: return + val receiveTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return val provider = dataState.selectedProvider ?: return analyticsEventHandler.send( SwapEvents.NoticePermissionNeeded( @@ -2037,8 +1604,8 @@ internal class SwapModel @Inject constructor( } private fun sendGivePermissionClickedEvent() { - val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol ?: return - val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol ?: return + val sendTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return + val receiveTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return val provider = dataState.selectedProvider ?: return analyticsEventHandler.send( SwapEvents.ButtonGivePermissionClicked( @@ -2049,27 +1616,14 @@ internal class SwapModel @Inject constructor( ) } - private fun sendPermissionApproveClickedEvent() { - val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol ?: return - val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol ?: return - val approveType = uiState.permissionState.getApproveTypeOrNull() ?: return - val provider = dataState.selectedProvider ?: return - - analyticsEventHandler.send( - SwapEvents.ButtonPermissionApproveClicked( - sendToken = sendTokenSymbol, - receiveToken = receiveTokenSymbol, - approveType = approveType, - provider = provider, - ), - ) - } - private fun updateWalletBalance() { - dataState.fromCryptoCurrency?.currency?.network?.let { network -> + dataState.fromSwapCurrencyStatus?.let { fromSwapCurrencyStatus -> modelScope.launch { withContext(NonCancellable) { - updateForBalance(userWalletId, network) + updateForBalance( + fromSwapCurrencyStatus.userWalletId, + fromSwapCurrencyStatus.currency.network, + ) } } } @@ -2083,13 +1637,6 @@ internal class SwapModel @Inject constructor( ) } - private fun ApproveType.toDomainApproveType(): SwapApproveType { - return when (this) { - ApproveType.LIMITED -> SwapApproveType.LIMITED - ApproveType.UNLIMITED -> SwapApproveType.UNLIMITED - } - } - private fun triggerPromoProviderEvent(recommendedProvider: SwapProvider?, bestQuotesProvider: SwapProvider?) { // for now send event only for changelly if (recommendedProvider == null || @@ -2116,15 +1663,17 @@ internal class SwapModel @Inject constructor( private fun onFailedTxEmailClick(errorMessage: String) { modelScope.launch { val transaction = dataState.swapDataModel?.transaction - val fromCurrencyStatus = dataState.fromCryptoCurrency ?: initialFromStatus - val network = fromCurrencyStatus.currency.network + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val fromCurrency = fromSwapCurrencyStatus?.currency ?: params.cryptoCurrency + val fromWalletId = fromSwapCurrencyStatus?.userWalletId ?: params.userWalletId + val network = fromCurrency?.network saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage, - networkId = network.id, + networkId = network?.id, destinationAddress = transaction?.txTo.orEmpty(), - tokenSymbol = fromCurrencyStatus.currency.symbol, + tokenSymbol = fromCurrency?.symbol.orEmpty(), amount = dataState.amount.orEmpty(), fee = when (val fee = getSelectedFee()) { is TxFee.FeeComponent -> fee.fee.amount.value?.toString() @@ -2134,7 +1683,7 @@ internal class SwapModel @Inject constructor( ), ) - val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId) + val metaInfo = getWalletMetaInfoUseCase(fromWalletId) .getOrElse { error("CardInfo must be not null") } val email = FeedbackEmailType.SwapProblem( @@ -2148,28 +1697,6 @@ internal class SwapModel @Inject constructor( } } - private fun CryptoCurrency.getNetworkInfo(): NetworkInfo { - return NetworkInfo( - name = this.network.name, - blockchainId = this.network.rawId, - ) - } - - private suspend fun getFromStatus(): CryptoCurrencyStatus? { - return if (tangemPayInput != null) { - getTangemPayCurrencyStatusUseCase( - currency = initialCurrencyFrom, - cryptoAmount = tangemPayInput.cryptoAmount, - fiatAmount = tangemPayInput.fiatAmount, - depositAddress = tangemPayInput.depositAddress, - ) - } else { - singleAccountStatusListSupplier.getSyncOrNull(params.userWalletId) - .getCryptoCurrencyStatus(currency = initialCurrencyFrom) - .getOrNull() - } - } - private fun getSelectedFeeState(): TxFeeSealedState { val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content @@ -2222,8 +1749,8 @@ internal class SwapModel @Inject constructor( override suspend fun loadFeeExtended( selectedToken: CryptoCurrencyStatus?, ): Either { - val fromToken = dataState.fromCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError) - val toToken = dataState.toCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError) + val fromSwapCurrencyStatus = + dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) val selectedProvider = dataStateStateFlow.first { it.selectedProvider != null }.selectedProvider!! if (selectedProvider.type != ExchangeProviderType.CEX) { @@ -2239,10 +1766,7 @@ internal class SwapModel @Inject constructor( } return swapInteractor.loadFeeForSwapTransaction( - fromToken = fromToken, - fromAccount = dataState.fromAccount, - toToken = toToken, - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, provider = selectedProvider, amount = lastAmount.value, reduceBalanceBy = lastReducedBalanceBy.value, @@ -2261,9 +1785,11 @@ internal class SwapModel @Inject constructor( return } + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + // If fee currency is same as from currency, we need to reload quotes to update fee info val isFeeCurrencySameAsFromCurrency = newState is FeeSelectorUM.Content && - dataState.fromCryptoCurrency?.currency?.id == newState.feeExtraInfo.feeCryptoCurrencyStatus.currency.id + fromSwapCurrencyStatus?.currency?.id == newState.feeExtraInfo.feeCryptoCurrencyStatus.currency.id // If fee currency is coin, we need to reload quotes to update fee related warnings (e.g. insufficient funds) val isCoinFeeSelected = newState is FeeSelectorUM.Content && @@ -2296,8 +1822,10 @@ internal class SwapModel @Inject constructor( override suspend fun loadFee(): Either { TangemLogger.e("loadFee: Start loading fee") - val fromToken = dataState.fromCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError) - val toToken = dataState.toCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError) + val fromSwapCurrencyStatus = + dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) + val toSwapCurrencyStatus = + dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) val selectedProvider = dataStateStateFlow.first { it.selectedProvider != null }.selectedProvider!! if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) { @@ -2311,20 +1839,16 @@ internal class SwapModel @Inject constructor( } return swapInteractor.loadFeeForSwapTransaction( - fromToken = fromToken, - fromAccount = dataState.fromAccount, - toToken = toToken, - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, provider = selectedProvider, amount = lastAmount.value, reduceBalanceBy = lastReducedBalanceBy.value, - ) - .onLeft { - TangemLogger.e("loadFee: Failed to load fee with error $it") - } - .onRight { - TangemLogger.e("loadFee: Fee loaded successfully") - } + ).onLeft { + TangemLogger.e("loadFee: Failed to load fee with error $it") + }.onRight { + TangemLogger.e("loadFee: Fee loaded successfully") + } } override fun choosingInProgress(updatedState: Boolean) { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 6586b11550..5406397bbd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -37,15 +37,6 @@ internal class SwapNotificationsFactory( private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) { - fun getInitialErrorStateNotifications(code: Int, onRefreshClick: () -> Unit): ImmutableList { - return persistentListOf( - SwapNotificationUM.Warning.ExpressGeneralError( - code = code, - onConfirmClick = onRefreshClick, - ), - ) - } - fun getGeneralErrorStateNotifications( message: TextReference?, onClick: () -> Unit, @@ -104,7 +95,6 @@ internal class SwapNotificationsFactory( @Suppress("LongParameterList") fun getConfirmationStateNotifications( quoteModel: SwapState.QuotesLoadedState, - fromToken: CryptoCurrency, feeCryptoCurrencyStatus: CryptoCurrencyStatus?, selectedFeeType: FeeType, providerName: String, @@ -114,9 +104,9 @@ internal class SwapNotificationsFactory( maybeAddRentExemptionError(quoteModel) maybeAddDomainWarnings(quoteModel, feeCryptoCurrencyStatus, selectedFeeType) maybeAddNeedReserveToCreateAccountWarning(quoteModel) - maybeAddPermissionNeededWarning(quoteModel, fromToken, providerName) + maybeAddPermissionNeededWarning(quoteModel, providerName) maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType) - maybeAddUnableCoverFeeWarning(quoteModel, fromToken, hideFee) + maybeAddUnableCoverFeeWarning(quoteModel, feeCryptoCurrencyStatus, hideFee) maybeAddTransactionInProgressWarning(quoteModel) maybeAddPriceImpactNotification(quoteModel.priceImpact) } @@ -135,7 +125,7 @@ internal class SwapNotificationsFactory( if (quoteModel.permissionState is PermissionDataState.PermissionLoading) { add(SwapNotificationUM.Error.ApprovalInProgressWarning) } else if (quoteModel.preparedSwapConfigState.hasOutgoingTransaction) { - val fromCurrency = quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency + val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency add( SwapNotificationUM.Error.TransactionInProgressWarning( currencySymbol = fromCurrency.network.currencySymbol, @@ -162,7 +152,7 @@ internal class SwapNotificationsFactory( feeCryptoCurrencyStatus: CryptoCurrencyStatus?, selectedFeeType: FeeType, ) { - val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus + val swapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus val includeFeeInAmount = quoteModel.preparedSwapConfigState.includeFeeInAmount val amount = quoteModel.fromTokenInfo.tokenAmount val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) { @@ -179,14 +169,14 @@ internal class SwapNotificationsFactory( } is TxFeeState.SingleFeeState -> feeState.fee } - val isCardano = BlockchainUtils.isCardano(fromCurrencyStatus.currency.network.rawId) + val isCardano = BlockchainUtils.isCardano(swapCurrencyStatus.currency.network.rawId) // blockchain specific addExistentialWarningNotification( existentialDeposit = quoteModel.currencyCheck?.existentialDeposit, feeAmount = fee?.fee?.amount?.value.orZero(), sendingAmount = amountToRequest.value, - cryptoCurrencyStatus = fromCurrencyStatus, + cryptoCurrencyStatus = swapCurrencyStatus.status, onReduceClick = { reduceBy, reduceByDiff, _ -> actions.onReduceByAmount( // use in swap notification amountToRequest because fee is already subtracted @@ -198,7 +188,7 @@ internal class SwapNotificationsFactory( addValidateTransactionNotifications( dustValue = quoteModel.currencyCheck?.dustValue.orZero(), validationError = quoteModel.validationResult, - cryptoCurrency = fromCurrencyStatus.currency, + cryptoCurrency = swapCurrencyStatus.currency, minAdaValue = quoteModel.minAdaValue, onReduceClick = { reduceTo, _ -> actions.onReduceToAmount(amount.copy(value = reduceTo)) @@ -209,26 +199,26 @@ internal class SwapNotificationsFactory( dustValue = quoteModel.currencyCheck?.dustValue, feeValue = fee?.fee?.amount?.value.orZero(), sendingAmount = amountToRequest.value, - cryptoCurrencyStatus = fromCurrencyStatus, + cryptoCurrencyStatus = swapCurrencyStatus.status, feeCurrencyStatus = feeCryptoCurrencyStatus, ) } addReserveAmountErrorNotification( reserveAmount = quoteModel.currencyCheck?.reserveAmount, sendingAmount = amountToRequest.value, - cryptoCurrency = fromCurrencyStatus.currency, + cryptoCurrency = swapCurrencyStatus.currency, feeCryptoCurrency = feeCryptoCurrencyStatus?.currency, isAccountFunded = true, // consider the account is funded on the provider side ) addReduceAmountNotification( - cryptoCurrencyStatus = fromCurrencyStatus, + cryptoCurrencyStatus = swapCurrencyStatus.status, fromAmount = quoteModel.fromTokenInfo.tokenAmount, onReduceByAmount = actions.onReduceByAmount, ) addTransactionLimitErrorNotification( currencyCheck = quoteModel.currencyCheck, sendingAmount = amountToRequest.value, - cryptoCurrencyStatus = fromCurrencyStatus, + cryptoCurrencyStatus = swapCurrencyStatus.status, feeCurrencyStatus = feeCryptoCurrencyStatus, feeValue = fee?.feeValue.orZero(), onReduceClick = { reduceTo, _ -> @@ -240,11 +230,11 @@ internal class SwapNotificationsFactory( private fun MutableList.maybeAddNeedReserveToCreateAccountWarning( quoteModel: SwapState.QuotesLoadedState, ) { - val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value + val status = quoteModel.toTokenInfo.swapCurrencyStatus.status.value if (status is CryptoCurrencyStatus.NoAccount) { val amount = quoteModel.toTokenInfo.tokenAmount.value val amountToCreateAccount = status.amountToCreateAccount - val currencyTo = quoteModel.toTokenInfo.cryptoCurrencyStatus.currency + val currencyTo = quoteModel.toTokenInfo.swapCurrencyStatus.currency if (amount < amountToCreateAccount) { add( SwapNotificationUM.Warning.NeedReserveToCreateAccount( @@ -258,17 +248,13 @@ internal class SwapNotificationsFactory( private fun MutableList.maybeAddPermissionNeededWarning( quoteModel: SwapState.QuotesLoadedState, - fromToken: CryptoCurrency, providerName: String, ) { - if (!quoteModel.preparedSwapConfigState.isAllowedToSpend && - quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough && - quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest - ) { + if (quoteModel.permissionState is PermissionDataState.PermissionRequired) { add( SwapNotificationUM.Info.PermissionNeeded( providerName = providerName, - fromTokenSymbol = fromToken.symbol, + fromTokenSymbol = quoteModel.fromTokenInfo.swapCurrencyStatus.currency.symbol, onApproveClick = actions.openPermissionBottomSheet, ), ) @@ -308,27 +294,28 @@ internal class SwapNotificationsFactory( private fun MutableList.maybeAddUnableCoverFeeWarning( quoteModel: SwapState.QuotesLoadedState, - fromToken: CryptoCurrency, + feeCryptoCurrencyStatus: CryptoCurrencyStatus?, hideFee: Boolean, ) { if (hideFee) return + val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough ?: return val shouldShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough && quoteModel.permissionState !is PermissionDataState.PermissionLoading && - feeEnoughState.feeCurrency != fromToken + feeCryptoCurrencyStatus?.currency != fromCurrency val isNotEnoughFee = quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough - val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromToken.network) && + val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromCurrency.network) && quoteModel.swapProvider.type == ExchangeProviderType.CEX if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) { add( SwapNotificationUM.Error.UnableToCoverFeeWarning( - fromToken = fromToken, - feeCurrency = feeEnoughState.feeCurrency, - currencyName = feeEnoughState.currencyName ?: fromToken.network.name, - currencySymbol = feeEnoughState.currencySymbol ?: fromToken.network.currencySymbol, + fromToken = fromCurrency, + feeCurrency = feeCryptoCurrencyStatus?.currency, + currencyName = feeEnoughState.currencyName ?: fromCurrency.network.name, + currencySymbol = feeEnoughState.currencySymbol ?: fromCurrency.network.currencySymbol, onConfirmClick = actions.onBuyClick, ), ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt index 1c031215dd..0f2e373166 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt @@ -1,10 +1,10 @@ package com.tangem.feature.swap.model -import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.SwapPairLeast import com.tangem.feature.swap.domain.models.domain.SwapProvider -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.TokensDataStateExpress import com.tangem.feature.swap.domain.models.ui.TxFee @@ -12,20 +12,24 @@ import java.math.BigDecimal data class SwapProcessDataState( // Initial network id - val fromCryptoCurrency: CryptoCurrencyStatus? = null, - val toCryptoCurrency: CryptoCurrencyStatus? = null, + val fromSwapCurrencyStatus: SwapCurrencyStatus? = null, + val toSwapCurrencyStatus: SwapCurrencyStatus? = null, + val feePaidCryptoCurrency: CryptoCurrencyStatus? = null, - val fromAccount: Account? = null, - val toAccount: Account? = null, + + // swap info + val pairs: List = emptyList(), + + val selectedPairProviders: List = emptyList(), + val selectedProvider: SwapProvider? = null, + val lastLoadedSwapStates: Map = emptyMap(), + // Amount from input val amount: String? = null, val reduceBalanceBy: BigDecimal = BigDecimal.ZERO, - val approveDataModel: RequestApproveStateData? = null, val swapDataModel: SwapDataModel? = null, val selectedFee: TxFee.Legacy? = null, val tokensDataState: TokensDataStateExpress? = null, - val selectedProvider: SwapProvider? = null, - val lastLoadedSwapStates: Map = emptyMap(), ) { fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 0afbd2da4b..f45012a0b1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -4,11 +4,10 @@ import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import androidx.compose.ui.text.input.TextFieldValue import com.tangem.common.ui.account.AccountTitleUM -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.ui.PriceImpact import com.tangem.feature.swap.models.states.FeeItemState import com.tangem.feature.swap.models.states.ProviderState @@ -18,14 +17,13 @@ import kotlinx.collections.immutable.persistentListOf internal data class SwapStateHolder( val sendCardData: SwapCardState, val receiveCardData: SwapCardState, - val blockchainId: String, // not the same as networkId, its local id in app val notifications: ImmutableList = persistentListOf(), val isInsufficientFunds: Boolean, val changeCardsButtonState: ChangeCardsButtonState, val providerState: ProviderState, val fee: FeeItemState = FeeItemState.Empty, - val permissionState: GiveTxPermissionState = GiveTxPermissionState.Empty, + val permissionUM: SwapPermissionUM = SwapPermissionUM.Empty, val priceImpact: PriceImpact, val successState: SwapSuccessStateHolder? = null, @@ -37,34 +35,35 @@ internal data class SwapStateHolder( val onRefresh: () -> Unit, val onBackClicked: () -> Unit, val onChangeCardsClicked: () -> Unit, - val onSelectTokenClick: (() -> Unit), + val onSelectTokenClick: ((TokenSelectionDirection) -> Unit), val onSuccess: (() -> Unit), val onMaxAmountSelected: (() -> Unit)? = null, val onShowPermissionBottomSheet: () -> Unit = {}, ) +@Immutable sealed class SwapCardState { + abstract val type: TransactionCardType + data class SwapCardData( - @DrawableRes val networkIconRes: Int?, - val type: TransactionCardType, + override val type: TransactionCardType, + val currencyIconState: CurrencyIconState, + val tokenSymbol: TextReference, val amountEquivalent: TextReference?, - val token: CryptoCurrencyStatus?, - val coinId: String?, val amountTextFieldValue: TextFieldValue?, - val tokenIconUrl: String?, - val tokenCurrency: String, val balance: String, val isBalanceHidden: Boolean, - val isNotNativeToken: Boolean, - val canSelectAnotherToken: Boolean = false, ) : SwapCardState() data class Empty( - val type: TransactionCardType, - val amountEquivalent: TextReference?, + override val type: TransactionCardType, + val amountEquivalent: TextReference, val amountTextFieldValue: TextFieldValue?, - val canSelectAnotherToken: Boolean = false, + ) : SwapCardState() + + data class Loading( + override val type: TransactionCardType, ) : SwapCardState() } @@ -79,21 +78,21 @@ data class SwapButton( @Immutable sealed interface TransactionCardType { - val accountTitleUM: AccountTitleUM? + val accountTitleUM: AccountTitleUM val inputError: InputError data class Inputtable( val onAmountChanged: ((String) -> Unit), val onFocusChanged: ((Boolean) -> Unit), override val inputError: InputError, - override val accountTitleUM: AccountTitleUM?, + override val accountTitleUM: AccountTitleUM, ) : TransactionCardType data class ReadOnly( val shouldShowWarning: Boolean = false, val onWarningClick: (() -> Unit)? = null, override val inputError: InputError = InputError.Empty, - override val accountTitleUM: AccountTitleUM? = null, + override val accountTitleUM: AccountTitleUM, ) : TransactionCardType sealed interface InputError { @@ -116,4 +115,14 @@ data class LegalState( enum class ChangeCardsButtonState { ENABLED, DISABLED, UPDATE_IN_PROGRESS +} + +sealed class SwapPermissionUM { + + data class PermissionRequired( + val isResetApproval: Boolean, + val spenderAddress: String, + ) : SwapPermissionUM() + + object Empty : SwapPermissionUM() } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/TokenSelectionDirection.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/TokenSelectionDirection.kt new file mode 100644 index 0000000000..67761cb85d --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/TokenSelectionDirection.kt @@ -0,0 +1,6 @@ +package com.tangem.feature.swap.models + +internal enum class TokenSelectionDirection { + FROM, + TO, +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt index f6bddc0a70..ac922cd6f9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -1,33 +1,28 @@ package com.tangem.feature.swap.models -import com.tangem.common.ui.bottomsheet.permission.state.ApproveType -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.ui.TxFee import java.math.BigDecimal -data class UiActions( +internal data class UiActions( val onAmountChanged: (String) -> Unit, val onAmountSelected: (Boolean) -> Unit, val onSwapClick: () -> Unit, - val onGivePermissionClick: () -> Unit, val onChangeCardsClicked: () -> Unit, val onBackClicked: () -> Unit, val onMaxAmountSelected: () -> Unit, val onReduceToAmount: (SwapAmount) -> Unit, val onReduceByAmount: (SwapAmount, reduceBy: BigDecimal) -> Unit, val openPermissionBottomSheet: () -> Unit, - val onChangeApproveType: (ApproveType) -> Unit, // region new actions val onRetryClick: () -> Unit, val onClickFee: () -> Unit, val onSelectFeeType: (TxFee.Legacy) -> Unit, val onProviderClick: (String) -> Unit, val onProviderSelect: (String) -> Unit, - val onBuyClick: (CryptoCurrency) -> Unit, - val onSelectTokenClick: () -> Unit, + val onBuyClick: () -> Unit, + val onSelectTokenClick: (TokenSelectionDirection) -> Unit, val onSuccess: () -> Unit, val onLinkClick: (String) -> Unit, val onReceiveCardWarningClick: () -> Unit, - val onOpenLearnMoreAboutApproveClick: () -> Unit, ) \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index d6c929a2f5..ff6f8b47ce 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -60,7 +60,7 @@ internal object SwapNotificationUM { val currencyName: String, val currencySymbol: String, val feeCurrency: CryptoCurrency?, - val onConfirmClick: (CryptoCurrency) -> Unit, + val onConfirmClick: () -> Unit, ) : Error( title = resourceReference( R.string.warning_express_not_enough_fee_for_token_tx_title, @@ -74,7 +74,7 @@ internal object SwapNotificationUM { buttonState = feeCurrency?.let { NotificationConfig.ButtonsState.SecondaryButtonConfig( text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)), - onClick = { onConfirmClick(it) }, + onClick = onConfirmClick, ) }, ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRoute.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRoute.kt new file mode 100644 index 0000000000..9826e46d2f --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRoute.kt @@ -0,0 +1,9 @@ +package com.tangem.feature.swap.router + +import com.tangem.core.decompose.navigation.Route + +internal sealed interface SwapRoute : Route { + data object Main : SwapRoute + data object Success : SwapRoute + data class SelectToken(val isFromDirection: Boolean) : SwapRoute +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt deleted file mode 100644 index a3a55feafd..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.tangem.feature.swap.router - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.tangem.common.routing.AppRoute -import com.tangem.common.routing.AppRouter -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWalletId - -internal class SwapRouter( - private val router: AppRouter, -) { - - var currentScreen by mutableStateOf(SwapNavScreen.Main) - private set - - fun openScreen(screen: SwapNavScreen) { - currentScreen = screen - } - - fun back() { - if (currentScreen == SwapNavScreen.SelectToken) { - currentScreen = SwapNavScreen.Main - } else { - val selectTokensIndex = router.stack.getSelectTokensRouteIndexOrNull() - - /* - * If select token screen is not in stack, then just pop to previous screen. - * Otherwise, pop to previous screen that was before select token screen. - */ - if (currentScreen == SwapNavScreen.Success && selectTokensIndex != null) { - // find previous screen that was before select token - val prevRoute = router.stack.getOrNull(index = selectTokensIndex - 1) - - if (prevRoute != null) { - router.popTo(prevRoute) - } else { - router.pop() - } - } else { - router.pop() - } - } - } - - fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) { - val route = AppRoute.CurrencyDetails( - userWalletId = userWalletId, - currency = currency, - ) - - if (route in router.stack) { - router.popTo(route) - } else { - router.pop { - router.push(route) - } - } - } - - private fun List.getSelectTokensRouteIndexOrNull(): Int? { - return this - .indexOfFirst { it::class == AppRoute.SwapCrypto::class } - .takeIf { it != -1 } - } -} - -enum class SwapNavScreen { - Main, Success, SelectToken -} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index bc7e3c6bb1..2c2551b3d5 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -7,12 +7,10 @@ import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.toUM -import com.tangem.common.ui.bottomsheet.permission.state.* +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter -import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.* import com.tangem.core.ui.res.TangemTheme @@ -20,14 +18,13 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isHotWallet +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount -import com.tangem.feature.swap.domain.models.domain.NetworkInfo import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.model.SwapNotificationsFactory @@ -40,87 +37,54 @@ import com.tangem.utils.Provider import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.StringsSigns.TILDE_SIGN +import com.tangem.utils.TangemBlogUrlBuilder.FEE_BLOG_LINK import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal import java.math.RoundingMode -import java.util.Locale import kotlin.math.min /** * State builder creates a specific states for SwapScreen */ -@Suppress("LargeClass", "TooManyFunctions", "LongParameterList") +@Suppress("LargeClass", "TooManyFunctions") internal class StateBuilder( - private val userWalletProvider: Provider, private val actions: UiActions, private val isBalanceHiddenProvider: Provider, private val appCurrencyProvider: Provider, private val isAccountsModeProvider: Provider, private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) { - - private val isHoldToConfirmEnabled: Boolean = userWalletProvider().isHotWallet - private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) { SwapNotificationsFactory(actions, iGaslessFeeSupportedForNetwork) } - fun createInitialLoadingState( - initialCurrencyFrom: CryptoCurrency, - initialCurrencyTo: CryptoCurrency?, - fromNetworkInfo: NetworkInfo, - ): SwapStateHolder { + fun createInitialLoadingState(): SwapStateHolder { return SwapStateHolder( - blockchainId = fromNetworkInfo.blockchainId, - sendCardData = SwapCardState.SwapCardData( - type = TransactionCardType.Inputtable( - onAmountChanged = actions.onAmountChanged, - onFocusChanged = actions.onAmountSelected, - inputError = TransactionCardType.InputError.Empty, - accountTitleUM = null, - ), - amountEquivalent = null, - amountTextFieldValue = null, - token = null, - tokenIconUrl = initialCurrencyFrom.iconUrl, - tokenCurrency = initialCurrencyFrom.symbol, - coinId = initialCurrencyFrom.network.rawId, - canSelectAnotherToken = false, - isNotNativeToken = initialCurrencyFrom is CryptoCurrency.Token, - balance = "", - networkIconRes = initialCurrencyFrom.network.iconResId, - isBalanceHidden = true, + sendCardData = getEmptyCardState( + isFromCard = true, + emptyAmountState = SwapState.EmptyAmountState(TextReference.EMPTY), ), - receiveCardData = SwapCardState.SwapCardData( - type = TransactionCardType.ReadOnly(), - amountEquivalent = null, - tokenIconUrl = initialCurrencyTo?.iconUrl, - tokenCurrency = initialCurrencyTo?.symbol.orEmpty(), - token = null, - amountTextFieldValue = null, - canSelectAnotherToken = false, - balance = "", - isNotNativeToken = initialCurrencyTo is CryptoCurrency.Token, - networkIconRes = initialCurrencyTo?.network?.iconResId, - coinId = initialCurrencyTo?.network?.rawId, - isBalanceHidden = true, + receiveCardData = getEmptyCardState( + isFromCard = false, + emptyAmountState = SwapState.EmptyAmountState(TextReference.EMPTY), ), fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = null, isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isInProgress = true, + isHoldToConfirm = false, onClick = {}, ), onRefresh = {}, onBackClicked = actions.onBackClicked, onChangeCardsClicked = actions.onChangeCardsClicked, onMaxAmountSelected = actions.onMaxAmountSelected, - changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, + changeCardsButtonState = ChangeCardsButtonState.DISABLED, onShowPermissionBottomSheet = actions.openPermissionBottomSheet, onSelectTokenClick = actions.onSelectTokenClick, onSuccess = actions.onSuccess, @@ -131,101 +95,214 @@ internal class StateBuilder( ) } - fun createNoAvailableTokensToSwapState( + fun createInitialReadyState( uiStateHolder: SwapStateHolder, - fromToken: CryptoCurrencyStatus, + emptyAmountState: SwapState.EmptyAmountState, + fromSwapCurrencyStatus: SwapCurrencyStatus?, + toSwapCurrencyStatus: SwapCurrencyStatus?, ): SwapStateHolder { - if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( - sendCardData = SwapCardState.SwapCardData( - type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable), - amountTextFieldValue = null, - amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - token = fromToken, - tokenIconUrl = fromToken.currency.iconUrl, - coinId = fromToken.currency.network.rawId, - isNotNativeToken = fromToken.currency is CryptoCurrency.Token, - tokenCurrency = fromToken.currency.symbol, - canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, - balance = fromToken.getFormattedAmount(isNeedSymbol = false), - networkIconRes = fromToken.currency.network.iconResId, - isBalanceHidden = isBalanceHiddenProvider(), + sendCardData = createCardState( + swapCurrencyStatus = fromSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = true, ), - receiveCardData = SwapCardState.Empty( - type = TransactionCardType.ReadOnly(), - amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - amountTextFieldValue = TextFieldValue( - text = "0", - ), - canSelectAnotherToken = true, + receiveCardData = createCardState( + swapCurrencyStatus = toSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = false, ), - notifications = notificationsFactory.getNotAvailableStateNotifications(fromToken.currency.name), + notifications = persistentListOf(), + isInsufficientFunds = false, fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true, onClick = { }, ), - changeCardsButtonState = ChangeCardsButtonState.DISABLED, + shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, + providerState = ProviderState.Empty(), priceImpact = PriceImpact.Empty, ) } + fun updateCurrenciesState( + uiStateHolder: SwapStateHolder, + emptyAmountState: SwapState.EmptyAmountState, + fromSwapCurrencyStatus: SwapCurrencyStatus?, + toSwapCurrencyStatus: SwapCurrencyStatus?, + shouldResetAmount: Boolean, + ): SwapStateHolder { + return uiStateHolder.copy( + sendCardData = uiStateHolder.sendCardData.updateCurrencyStatus( + swapCurrencyStatus = fromSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = true, + shouldResetAmount = shouldResetAmount, + ), + receiveCardData = uiStateHolder.receiveCardData.updateCurrencyStatus( + swapCurrencyStatus = toSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = false, + shouldResetAmount = shouldResetAmount, + ), + notifications = persistentListOf(), + isInsufficientFunds = false, + fee = FeeItemState.Empty, + swapButton = SwapButton( + walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon), + isEnabled = false, + isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true, + onClick = { }, + ), + shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, + providerState = ProviderState.Empty(), + priceImpact = PriceImpact.Empty, + ) + } + + private fun SwapCardState.updateCurrencyStatus( + swapCurrencyStatus: SwapCurrencyStatus?, + emptyAmountState: SwapState.EmptyAmountState, + shouldResetAmount: Boolean, + isFromCard: Boolean, + ): SwapCardState { + val cardType = if (isFromCard) { + TransactionCardType.Inputtable( + onAmountChanged = actions.onAmountChanged, + onFocusChanged = actions.onAmountSelected, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(swapCurrencyStatus?.account, true), + ) + } else { + TransactionCardType.ReadOnly( + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(swapCurrencyStatus?.account, false), + ) + } + return if (this !is SwapCardState.SwapCardData || swapCurrencyStatus == null) { + createCardState( + swapCurrencyStatus = swapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = isFromCard, + ) + } else if (shouldResetAmount) { + copy( + amountTextFieldValue = if (isFromCard) { + null + } else { + TextFieldValue("0".appendApproximateSign()) + }, + amountEquivalent = emptyAmountState.zeroAmountEquivalent, + currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status), + tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol), + balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + isBalanceHidden = isBalanceHiddenProvider(), + type = cardType, + ) + } else { + copy( + currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status), + tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol), + balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + isBalanceHidden = isBalanceHiddenProvider(), + type = cardType, + ) + } + } + + private fun createCardState( + swapCurrencyStatus: SwapCurrencyStatus?, + emptyAmountState: SwapState.EmptyAmountState, + isFromCard: Boolean, + ): SwapCardState { + return if (swapCurrencyStatus == null) { + getEmptyCardState(isFromCard = isFromCard, emptyAmountState = emptyAmountState) + } else { + SwapCardState.SwapCardData( + type = if (isFromCard) { + TransactionCardType.Inputtable( + onAmountChanged = actions.onAmountChanged, + onFocusChanged = actions.onAmountSelected, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, true), + ) + } else { + TransactionCardType.ReadOnly( + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, false), + ) + }, + amountTextFieldValue = if (isFromCard) { + null + } else { + TextFieldValue("0".appendApproximateSign()) + }, + amountEquivalent = emptyAmountState.zeroAmountEquivalent, + currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status), + tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol), + balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + isBalanceHidden = isBalanceHiddenProvider(), + ) + } + } + + private fun getEmptyCardState(isFromCard: Boolean, emptyAmountState: SwapState.EmptyAmountState) = + SwapCardState.Empty( + type = TransactionCardType.ReadOnly( + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = AccountTitleUM.Text( + title = resourceReference( + if (isFromCard) R.string.swapping_from_title else R.string.swapping_to_title, + ), + ), + ), + amountTextFieldValue = TextFieldValue(text = if (isFromCard) "0" else "0".appendApproximateSign()), + amountEquivalent = emptyAmountState.zeroAmountEquivalent, + ) + fun createSwapNotSupportedState( uiStateHolder: SwapStateHolder, - fromToken: CryptoCurrencyStatus, - toToken: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, - mainTokenId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, ): SwapStateHolder { - val canSelectSendToken = mainTokenId != fromToken.currency.id.value - val canSelectReceiveToken = mainTokenId != toToken.currency.id.value if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( sendCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReadOnly( - accountTitleUM = getFromCardAccountTitle(fromAccount), + accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), ), amountTextFieldValue = TextFieldValue( text = "0", ), amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - token = fromToken, - tokenIconUrl = fromToken.currency.iconUrl, - coinId = fromToken.currency.network.rawId, - isNotNativeToken = fromToken.currency is CryptoCurrency.Token, - tokenCurrency = fromToken.currency.symbol, - canSelectAnotherToken = canSelectSendToken, - balance = fromToken.getFormattedAmount(isNeedSymbol = false), - networkIconRes = fromToken.currency.network.iconResId, + currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), + tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol), + balance = fromSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReadOnly( - accountTitleUM = getToCardAccountTitle(toAccount), + accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), ), amountTextFieldValue = TextFieldValue( text = "0", ), amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - token = toToken, - tokenIconUrl = toToken.currency.iconUrl, - coinId = toToken.currency.network.rawId, - isNotNativeToken = toToken.currency is CryptoCurrency.Token, - tokenCurrency = toToken.currency.symbol, - canSelectAnotherToken = canSelectReceiveToken, - balance = toToken.getFormattedAmount(isNeedSymbol = false), - networkIconRes = toToken.currency.network.iconResId, + currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), + tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol), + balance = toSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), notifications = notificationsFactory.getSwapNotSupportedNotifications(), fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, onClick = { }, ), changeCardsButtonState = ChangeCardsButtonState.DISABLED, @@ -236,115 +313,76 @@ internal class StateBuilder( @Suppress("LongParameterList") fun createQuotesLoadingState( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, uiStateHolder: SwapStateHolder, - fromToken: CryptoCurrency, - toToken: CryptoCurrency, - mainTokenId: String, - fromAccount: Account?, - toAccount: Account?, ): SwapStateHolder { - val canSelectSendToken = mainTokenId != fromToken.id.value - val canSelectReceiveToken = mainTokenId != toToken.id.value + val fromCurrency = fromSwapCurrencyStatus.currency + val toCurrency = toSwapCurrencyStatus.currency if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder - val sendInputType = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable) - val sendInput = if (sendInputType.inputError !is TransactionCardType.InputError.Empty) { - sendInputType - } else { - sendInputType.copy( - inputError = TransactionCardType.InputError.Empty, - accountTitleUM = getFromCardAccountTitle(fromAccount), - ) - } return uiStateHolder.copy( - sendCardData = SwapCardState.SwapCardData( - type = sendInput, - amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, - amountEquivalent = null, - token = uiStateHolder.sendCardData.token, - tokenIconUrl = fromToken.iconUrl, - tokenCurrency = fromToken.symbol, - coinId = fromToken.network.rawId, - isNotNativeToken = fromToken is CryptoCurrency.Token, - canSelectAnotherToken = canSelectSendToken, - balance = if (!canSelectSendToken) uiStateHolder.sendCardData.balance else "", - networkIconRes = fromToken.network.iconResId, - isBalanceHidden = isBalanceHiddenProvider(), + sendCardData = uiStateHolder.sendCardData.copy( + type = TransactionCardType.Inputtable( + onAmountChanged = actions.onAmountChanged, + onFocusChanged = actions.onAmountSelected, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), + ), ), - receiveCardData = SwapCardState.SwapCardData( + receiveCardData = uiStateHolder.receiveCardData.copy( type = TransactionCardType.ReadOnly( - accountTitleUM = getToCardAccountTitle(toAccount), + accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), ), amountTextFieldValue = null, amountEquivalent = null, - token = uiStateHolder.receiveCardData.token, - tokenIconUrl = toToken.iconUrl, - tokenCurrency = toToken.symbol, - coinId = toToken.network.rawId, - isNotNativeToken = toToken is CryptoCurrency.Token, - canSelectAnotherToken = canSelectReceiveToken, - balance = if (!canSelectReceiveToken) uiStateHolder.receiveCardData.balance else "", - networkIconRes = toToken.network.iconResId, - isBalanceHidden = isBalanceHiddenProvider(), ), notifications = persistentListOf(), fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, onClick = {}, ), providerState = ProviderState.Loading(), - permissionState = uiStateHolder.permissionState, + permissionUM = uiStateHolder.permissionUM, changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, priceImpact = PriceImpact.Empty, - shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toToken), + shouldShowMaxAmount = shouldShowMaxAmount(fromCurrency, toCurrency), ) } - /** - * Create quotes loaded state - * - * @param uiStateHolder whole screen state - * @param quoteModel data model - * @param fromToken token data to swap - * @return updated whole screen state - */ @Suppress("LongMethod", "LongParameterList") fun createQuotesLoadedState( uiStateHolder: SwapStateHolder, quoteModel: SwapState.QuotesLoadedState, - fromToken: CryptoCurrency, feeCryptoCurrencyStatus: CryptoCurrencyStatus?, swapProvider: SwapProvider, bestRatedProviderId: String, isNeedBestRateBadge: Boolean, selectedFeeType: FeeType, - isReverseSwapPossible: Boolean, needApplyFCARestrictions: Boolean, hideFee: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder + val feeState = if (hideFee) FeeItemState.Empty else createFeeState(quoteModel.txFee, selectedFeeType) + val fromSwapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus + val toSwapCurrencyStatus = quoteModel.toTokenInfo.swapCurrencyStatus + val isInsufficientFunds = isInsufficientFundsCondition(quoteModel) + val notifications = notificationsFactory.getConfirmationStateNotifications( quoteModel = quoteModel, - fromToken = fromToken, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, selectedFeeType = selectedFeeType, providerName = swapProvider.name, hideFee = hideFee, ) - val feeState = if (hideFee) FeeItemState.Empty else createFeeState(quoteModel.txFee, selectedFeeType) - val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus - val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus - val isInsufficientFunds = isInsufficientFundsCondition(quoteModel) - val fromAccount = quoteModel.fromTokenInfo.account - val toAccount = quoteModel.toTokenInfo.account val fromAccountTitleUM = when { isInsufficientFunds -> AccountTitleUM.Text(TextReference.Res(R.string.swapping_insufficient_funds)) - else -> getFromCardAccountTitle(fromAccount) + else -> getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true) } val sendCardType = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable) val sendInput = when (sendCardType.inputError) { @@ -369,22 +407,17 @@ internal class StateBuilder( sendCardData = SwapCardState.SwapCardData( type = sendInput, amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, - amountEquivalent = getFormattedFiatAmount(quoteModel.fromTokenInfo.amountFiat), - token = fromCurrencyStatus, - tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, - coinId = fromCurrencyStatus.currency.network.rawId, - isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.sendCardData.networkIconRes, - balance = fromCurrencyStatus.getFormattedAmount(isNeedSymbol = false), + amountEquivalent = uiStateHolder.sendCardData.amountEquivalent, + currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), + tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol), + balance = fromSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReadOnly( shouldShowWarning = true, onWarningClick = actions.onReceiveCardWarningClick, - accountTitleUM = getToCardAccountTitle(toAccount), + accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), ), amountTextFieldValue = TextFieldValue( quoteModel.toTokenInfo.tokenAmount @@ -411,34 +444,24 @@ internal class StateBuilder( } else { getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat) }, - token = toCurrencyStatus, - tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = toCurrencyStatus.currency.network.rawId, - isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.receiveCardData.networkIconRes, - balance = toCurrencyStatus.getFormattedAmount(isNeedSymbol = false), + currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), + tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol), + balance = toSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), isInsufficientFunds = isInsufficientFundsCondition(quoteModel), notifications = notifications, - permissionState = convertPermissionState( - lastPermissionState = uiStateHolder.permissionState, + permissionUM = convertPermissionState( permissionDataState = quoteModel.permissionState, - providerName = swapProvider.name, - onGivePermissionClick = actions.onGivePermissionClick, - onChangeApproveType = actions.onChangeApproveType, - onOpenLearnMoreAboutApproveClick = actions.onOpenLearnMoreAboutApproveClick, ), fee = feeState, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), isEnabled = getSwapButtonEnabled(notifications, priceImpact), - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, onClick = actions.onSwapClick, ), - changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, providerState = swapProvider.convertToContentClickableProviderState( isBestRate = bestRatedProviderId == swapProvider.providerId && !priceImpact.shouldShowWarning(), fromTokenInfo = quoteModel.fromTokenInfo, @@ -451,12 +474,12 @@ internal class StateBuilder( ), priceImpact = priceImpact, tosState = createTosState(swapProvider), - shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrencyStatus.currency), + shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus.currency, toSwapCurrencyStatus.currency), ) } - private fun shouldShowMaxAmount(fromToken: CryptoCurrency, toCurrency: CryptoCurrency): Boolean { - return !(fromToken is CryptoCurrency.Coin && fromToken.network.id == toCurrency.network.id) + private fun shouldShowMaxAmount(fromToken: CryptoCurrency?, toCurrency: CryptoCurrency?): Boolean { + return !(fromToken is CryptoCurrency.Coin && fromToken.network.id == toCurrency?.network?.id) } private fun createTosState(swapProvider: SwapProvider): TosState { @@ -500,72 +523,65 @@ internal class StateBuilder( uiStateHolder: SwapStateHolder, swapProvider: SwapProvider, fromToken: TokenSwapInfo, - toToken: CryptoCurrencyStatus?, - toAccount: Account?, + toSwapCurrencyStatus: SwapCurrencyStatus?, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, - isReverseSwapPossible: Boolean, needApplyFCARestrictions: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder + val fromSwapCurrencyStatus = fromToken.swapCurrencyStatus + val notifications = notificationsFactory.getQuotesErrorStateNotifications( expressDataError = expressDataError, - fromToken = fromToken.cryptoCurrencyStatus.currency, + fromToken = fromSwapCurrencyStatus.currency, feeItem = uiStateHolder.fee, includeFeeInAmount = includeFeeInAmount, ) val providerState = getProviderStateForError( swapProvider = swapProvider, - fromToken = fromToken.cryptoCurrencyStatus.currency, + fromToken = fromSwapCurrencyStatus.currency, expressDataError = expressDataError, onProviderClick = actions.onProviderClick, selectionType = ProviderState.SelectionType.CLICK, needApplyFCARestrictions = needApplyFCARestrictions, ) - val type = TransactionCardType.ReadOnly(accountTitleUM = getToCardAccountTitle(toAccount)) - val receiveCardData = toToken?.let { + val type = TransactionCardType.ReadOnly( + accountTitleUM = getCardAccountTitle( + toSwapCurrencyStatus?.account, + isFromCard = false, + ), + ) + val receiveCardData = toSwapCurrencyStatus?.status?.let { toToken -> SwapCardState.SwapCardData( type = type, amountTextFieldValue = TextFieldValue( text = "0", ), amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - token = toToken, - tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = toToken.currency.network.rawId, - isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.receiveCardData.networkIconRes, + currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), + tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol), balance = toToken.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ) } ?: SwapCardState.Empty( type = type, amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - amountTextFieldValue = TextFieldValue( - text = "0", - ), - canSelectAnotherToken = true, + amountTextFieldValue = null, ) return uiStateHolder.copy( - sendCardData = uiStateHolder.sendCardData.copy( - amountEquivalent = getFormattedFiatAmount(fromToken.amountFiat), - balance = fromToken.cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false), - ), receiveCardData = receiveCardData, notifications = notifications, - permissionState = GiveTxPermissionState.Empty, + permissionUM = SwapPermissionUM.Empty, fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, onClick = actions.onSwapClick, ), - changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, providerState = providerState, priceImpact = PriceImpact.Empty, tosState = createTosState(swapProvider), @@ -612,58 +628,32 @@ internal class StateBuilder( } } - @Suppress("LongParameterList") fun createQuotesEmptyAmountState( uiStateHolder: SwapStateHolder, emptyAmountState: SwapState.EmptyAmountState, - fromTokenStatus: CryptoCurrencyStatus, - toTokenStatus: CryptoCurrencyStatus?, - toAccount: Account?, - isReverseSwapPossible: Boolean, + fromSwapCurrencyStatus: SwapCurrencyStatus?, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( - sendCardData = SwapCardState.SwapCardData( - type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable), + sendCardData = uiStateHolder.sendCardData.copy( amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, amountEquivalent = emptyAmountState.zeroAmountEquivalent, - token = uiStateHolder.sendCardData.token, - tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, - coinId = uiStateHolder.sendCardData.coinId, - isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.sendCardData.networkIconRes, - balance = fromTokenStatus.getFormattedAmount(isNeedSymbol = false), - isBalanceHidden = isBalanceHiddenProvider(), ), - receiveCardData = SwapCardState.SwapCardData( - type = TransactionCardType.ReadOnly( - accountTitleUM = getToCardAccountTitle(toAccount), - ), + receiveCardData = uiStateHolder.receiveCardData.copy( amountTextFieldValue = TextFieldValue("0"), amountEquivalent = emptyAmountState.zeroAmountEquivalent, - token = uiStateHolder.receiveCardData.token, - tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = uiStateHolder.receiveCardData.coinId, - isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.receiveCardData.networkIconRes, - balance = toTokenStatus?.getFormattedAmount(isNeedSymbol = false) ?: DASH_SIGN, - isBalanceHidden = isBalanceHiddenProvider(), ), notifications = persistentListOf(), isInsufficientFunds = false, fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true, onClick = { }, ), - changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, providerState = ProviderState.Empty(), priceImpact = PriceImpact.Empty, ) @@ -692,15 +682,14 @@ internal class StateBuilder( uiState: SwapStateHolder, amountFormatted: String, amountRaw: String, - fromToken: CryptoCurrency, - fromAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, minTxAmount: BigDecimal?, ): SwapStateHolder { if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState val amountToSend = amountRaw.toBigDecimalOrNull() val sendInput = if (minTxAmount != null && amountToSend != null && amountToSend < minTxAmount) { val minAmountFormatted = minTxAmount.format { - crypto(cryptoCurrency = fromToken, ignoreSymbolPosition = true) + crypto(cryptoCurrency = fromSwapCurrencyStatus.currency, ignoreSymbolPosition = true) } (uiState.sendCardData.type as? TransactionCardType.Inputtable)?.copy( inputError = TransactionCardType.InputError.WrongAmount, @@ -711,7 +700,7 @@ internal class StateBuilder( } else { (uiState.sendCardData.type as? TransactionCardType.Inputtable)?.copy( inputError = TransactionCardType.InputError.Empty, - accountTitleUM = getFromCardAccountTitle(fromAccount), + accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), ) ?: uiState.sendCardData.type } return uiState.copy( @@ -720,48 +709,46 @@ internal class StateBuilder( text = amountFormatted, selection = TextRange(amountFormatted.length), ), + amountEquivalent = getFormattedFiatAmount( + fromSwapCurrencyStatus.status.value.fiatRate?.let { fiatRate -> + amountToSend?.multiply(fiatRate) + }, + ), type = sendInput, ), ) } - fun updateSendCurrencyBalance( + fun updateCurrencyBalanceStatus( uiState: SwapStateHolder, - cryptoCurrencyStatus: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus?, + toSwapCurrencyStatus: SwapCurrencyStatus?, + emptyAmountState: SwapState.EmptyAmountState, ): SwapStateHolder { - if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState - return uiState.copy( - sendCardData = uiState.sendCardData.copy( - balance = cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false), - token = cryptoCurrencyStatus, + sendCardData = uiState.sendCardData.updateCurrencyStatus( + swapCurrencyStatus = fromSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = true, + shouldResetAmount = false, ), - ) - } - - fun updateReceiveCurrencyBalance( - uiState: SwapStateHolder, - cryptoCurrencyStatus: CryptoCurrencyStatus, - ): SwapStateHolder { - if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState - - return uiState.copy( - receiveCardData = uiState.receiveCardData.copy( - balance = cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false), - token = cryptoCurrencyStatus, + receiveCardData = uiState.receiveCardData.updateCurrencyStatus( + swapCurrencyStatus = toSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = false, + shouldResetAmount = false, ), ) } fun updateBalanceHiddenState(uiState: SwapStateHolder, isBalanceHidden: Boolean): SwapStateHolder { - if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState - if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState - val patchedSendCardData = uiState.sendCardData.copy( + val patchedSendCardData = (uiState.sendCardData as? SwapCardState.SwapCardData)?.copy( isBalanceHidden = isBalanceHidden, - ) - val patchedReceiveCardData = uiState.receiveCardData.copy( + ) ?: uiState.sendCardData + + val patchedReceiveCardData = (uiState.receiveCardData as? SwapCardState.SwapCardData)?.copy( isBalanceHidden = isBalanceHidden, - ) + ) ?: uiState.receiveCardData return uiState.copy( sendCardData = patchedSendCardData, @@ -769,32 +756,6 @@ internal class StateBuilder( ) } - fun updateApproveType(uiState: SwapStateHolder, approveType: ApproveType): SwapStateHolder { - val config = uiState.bottomSheetConfig?.content as? GiveTxPermissionBottomSheetConfig - val permissionState = (uiState.permissionState as? GiveTxPermissionState.ReadyForRequest)?.copy( - approveType = approveType, - ) ?: uiState.permissionState - return if (config != null) { - uiState.copy( - permissionState = permissionState, - bottomSheetConfig = uiState.bottomSheetConfig.copy( - content = config.copy( - data = config.data.copy(approveType = approveType), - ), - ), - ) - } else { - uiState - } - } - - fun createInitialErrorState(uiState: SwapStateHolder, code: Int, onRefreshClick: () -> Unit): SwapStateHolder { - return uiState.copy( - isInsufficientFunds = false, - notifications = notificationsFactory.getInitialErrorStateNotifications(code, onRefreshClick), - ) - } - private fun createFeeState(txFeeState: TxFeeState, feeType: FeeType): FeeItemState { val isClickable: Boolean val fee = when (txFeeState) { @@ -833,7 +794,6 @@ internal class StateBuilder( isEnabled = false, isInProgress = false, ), - permissionState = GiveTxPermissionState.InProgress, notifications = notificationsFactory.getApprovalInProgressStateNotification(uiState.notifications), ) } @@ -847,14 +807,14 @@ internal class StateBuilder( onStatusClick: () -> Unit, txUrl: String, ): SwapStateHolder { - val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency) - val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency) + val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) + val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus) val fromAmount = swapTransactionState.fromAmountValue ?: BigDecimal.ZERO val toAmount = swapTransactionState.toAmountValue ?: BigDecimal.ZERO val providerState = uiState.providerState as ProviderState.Content - val fromFiatAmount = getFormattedFiatAmount(fromCryptoCurrency.value.fiatRate?.multiply(fromAmount)) - val toFiatAmount = getFormattedFiatAmount(toCryptoCurrency.value.fiatRate?.multiply(toAmount)) + val fromFiatAmount = getFormattedFiatAmount(fromSwapCurrencyStatus.status.value.fiatRate?.multiply(fromAmount)) + val toFiatAmount = getFormattedFiatAmount(toSwapCurrencyStatus.status.value.fiatRate?.multiply(toAmount)) val shouldShowStatus = providerState.type == ExchangeProviderType.CEX.providerName return uiState.copy( @@ -869,14 +829,14 @@ internal class StateBuilder( fee = dataState.selectedFee?.let { fee -> stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})") }, - fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount), - toTitle = getToCardAccountTitle(toAccount = dataState.toAccount), + fromTitle = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), + toTitle = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()), toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()), fromTokenFiatAmount = fromFiatAmount, toTokenFiatAmount = toFiatAmount, - fromTokenIconState = iconStateConverter.convert(fromCryptoCurrency), - toTokenIconState = iconStateConverter.convert(toCryptoCurrency), + fromTokenIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), + toTokenIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), onExploreButtonClick = onExploreClick, onStatusButtonClick = onStatusClick, ), @@ -890,14 +850,14 @@ internal class StateBuilder( txUrl: String, onExploreClick: () -> Unit, ): SwapStateHolder { - val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency) - val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency) + val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) + val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus) val fromAmount = swapTransactionState.fromAmountValue ?: BigDecimal.ZERO val toAmount = swapTransactionState.toAmountValue ?: BigDecimal.ZERO val providerState = uiState.providerState as ProviderState.Content - val fromFiatAmount = getFormattedFiatAmount(fromCryptoCurrency.value.fiatRate?.multiply(fromAmount)) - val toFiatAmount = getFormattedFiatAmount(toCryptoCurrency.value.fiatRate?.multiply(toAmount)) + val fromFiatAmount = getFormattedFiatAmount(fromSwapCurrencyStatus.status.value.fiatRate?.multiply(fromAmount)) + val toFiatAmount = getFormattedFiatAmount(toSwapCurrencyStatus.status.value.fiatRate?.multiply(toAmount)) return uiState.copy( successState = SwapSuccessStateHolder( @@ -909,14 +869,14 @@ internal class StateBuilder( providerIcon = providerState.iconUrl, rate = providerState.subtitle, fee = TextReference.EMPTY, - fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount), - toTitle = getToCardAccountTitle(toAccount = dataState.toAccount), + fromTitle = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), + toTitle = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()), toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()), fromTokenFiatAmount = fromFiatAmount, toTokenFiatAmount = toFiatAmount, - fromTokenIconState = iconStateConverter.convert(fromCryptoCurrency), - toTokenIconState = iconStateConverter.convert(toCryptoCurrency), + fromTokenIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), + toTokenIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), onExploreButtonClick = onExploreClick, onStatusButtonClick = {}, ), @@ -933,74 +893,14 @@ internal class StateBuilder( } @Suppress("LongParameterList") - private fun convertPermissionState( - lastPermissionState: GiveTxPermissionState, - permissionDataState: PermissionDataState, - providerName: String, - onGivePermissionClick: () -> Unit, - onChangeApproveType: (ApproveType) -> Unit, - onOpenLearnMoreAboutApproveClick: () -> Unit, - ): GiveTxPermissionState { - val approveType = if (lastPermissionState is GiveTxPermissionState.ReadyForRequest) { - lastPermissionState.approveType - } else { - ApproveType.UNLIMITED - } + private fun convertPermissionState(permissionDataState: PermissionDataState): SwapPermissionUM { return when (permissionDataState) { - PermissionDataState.Empty -> GiveTxPermissionState.Empty - PermissionDataState.PermissionFailed -> GiveTxPermissionState.Empty - PermissionDataState.PermissionLoading -> GiveTxPermissionState.InProgress - is PermissionDataState.PermissionReadyForRequest -> { - val permissionFee = when (val fee = permissionDataState.requestApproveData.fee) { - TxFeeState.Empty -> error("Fee shouldn't be empty") - is TxFeeState.MultipleFeeState -> fee.priorityFee - is TxFeeState.SingleFeeState -> fee.fee - } - GiveTxPermissionState.ReadyForRequest( - currency = permissionDataState.currency, - amount = permissionDataState.amount, - approveType = approveType, - walletAddress = getShortAddressValue(permissionDataState.walletAddress), - spenderAddress = getShortAddressValue(permissionDataState.spenderAddress), - fee = TextReference.Str("${permissionFee.feeCryptoFormatted} (${permissionFee.feeFiatFormatted})"), - approveButton = ApprovePermissionButton( - isEnabled = true, - onClick = onGivePermissionClick, - ), - cancelButton = CancelPermissionButton( - enabled = true, - ), - onChangeApproveType = onChangeApproveType, - subtitle = resourceReference( - id = R.string.give_permission_swap_subtitle, - formatArgs = wrappedList(providerName, permissionDataState.currency), - ), - dialogText = resourceReference(R.string.swapping_approve_information_text), - footerText = resourceReference(R.string.swap_give_permission_fee_footer), - onOpenLearnMoreAboutApproveClick = onOpenLearnMoreAboutApproveClick, - isResetApproval = permissionDataState.isResetApproval, - ) - } - } - } - - fun showPermissionBottomSheet(uiState: SwapStateHolder, onDismiss: () -> Unit): SwapStateHolder { - val permissionState = uiState.permissionState - if (permissionState is GiveTxPermissionState.ReadyForRequest) { - val config = GiveTxPermissionBottomSheetConfig( - data = permissionState, - onCancel = onDismiss, - walletInteractionIcon = walletInterationIcon(userWalletProvider()), - ) - return uiState.copy( - bottomSheetConfig = TangemBottomSheetConfig( - isShown = true, - onDismissRequest = onDismiss, - content = config, - ), + is PermissionDataState.PermissionRequired -> SwapPermissionUM.PermissionRequired( + isResetApproval = permissionDataState.isResetApproval, + spenderAddress = permissionDataState.spenderAddress, ) + else -> SwapPermissionUM.Empty } - return uiState } fun dismissBottomSheet(uiState: SwapStateHolder): SwapStateHolder { @@ -1061,7 +961,7 @@ internal class StateBuilder( val tokenInfo = tokenSwapInfoForProviders[providerState.id] if (providerState is ProviderState.Content && tokenInfo != null) { val rateString = tokenInfo.tokenAmount - .getFormattedCryptoAmount(tokenInfo.cryptoCurrencyStatus.currency) + .getFormattedCryptoAmount(tokenInfo.swapCurrencyStatus.currency) providerState.copy( subtitle = stringReference(rateString), percentLowerThenBest = pricesLowerBest[providerState.id]?.let { percent -> @@ -1095,7 +995,7 @@ internal class StateBuilder( } actions.onSelectFeeType.invoke(selectedItem) }, - readMoreUrl = buildReadMoreUrl(), + readMoreUrl = FEE_BLOG_LINK, feeItems = txFeeState.toFeeItemState(), readMore = resourceReference(R.string.common_read_more), onReadMoreClick = actions.onLinkClick, @@ -1109,15 +1009,6 @@ internal class StateBuilder( ) } - @Deprecated("Use TangemBlockUrlBuilder instead") - private fun buildReadMoreUrl(): String { - return buildString { - append(FEE_READ_MORE_URL_FIRST_PART) - append(getLocaleName()) - append(FEE_READ_MORE_URL_SECOND_PART) - } - } - private fun TxFeeState.MultipleFeeState.toFeeItemState(): ImmutableList { return listOf( FeeItemState.Content( @@ -1160,7 +1051,7 @@ internal class StateBuilder( } is SwapState.SwapError -> getProviderStateForError( swapProvider = provider, - fromToken = state.fromTokenInfo.cryptoCurrencyStatus.currency, + fromToken = state.fromTokenInfo.swapCurrencyStatus.currency, expressDataError = state.error, onProviderClick = onProviderSelect, selectionType = ProviderState.SelectionType.SELECT, @@ -1169,16 +1060,6 @@ internal class StateBuilder( } } - private fun getShortAddressValue(fullAddress: String): String { - check(fullAddress.length > ADDRESS_MIN_LENGTH) { "Invalid address" } - val firstAddressPart = fullAddress.substring(startIndex = 0, endIndex = ADDRESS_FIRST_PART_LENGTH) - val secondAddressPart = fullAddress.substring( - startIndex = fullAddress.length - ADDRESS_SECOND_PART_LENGTH, - endIndex = fullAddress.length, - ) - return "$firstAddressPart...$secondAddressPart" - } - @Suppress("LongParameterList") private fun SwapProvider.convertToContentClickableProviderState( isBestRate: Boolean, @@ -1192,18 +1073,18 @@ internal class StateBuilder( ): ProviderState { val rate = toTokenInfo.tokenAmount.value.calculateRate( fromTokenInfo.tokenAmount.value, - toTokenInfo.cryptoCurrencyStatus.currency.decimals, + toTokenInfo.swapCurrencyStatus.currency.decimals, ) - val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol + val fromCurrencySymbol = fromTokenInfo.swapCurrencyStatus.currency.symbol val rateString = buildString { append(BigDecimal.ONE.format { crypto(symbol = fromCurrencySymbol, decimals = 0).anyDecimals() }) append(" ≈ ") - append(rate.format { crypto(toTokenInfo.cryptoCurrencyStatus.currency) }) + append(rate.format { crypto(toTokenInfo.swapCurrencyStatus.currency) }) } val additionalBadge = when { needApplyFCARestrictions && isFCARestrictedProvider() -> ProviderState.AdditionalBadge.FCAWarningList - permissionState is PermissionDataState.PermissionReadyForRequest -> + permissionState is PermissionDataState.PermissionRequired -> ProviderState.AdditionalBadge.PermissionRequired isRecommended -> ProviderState.AdditionalBadge.Recommended isNeedBestRateBadge && isBestRate && !needApplyFCARestrictions -> ProviderState.AdditionalBadge.BestTrade @@ -1232,11 +1113,11 @@ internal class StateBuilder( needApplyFCARestrictions: Boolean, ): ProviderState { val toTokenInfo = state.toTokenInfo - val rateString = toTokenInfo.tokenAmount.getFormattedCryptoAmount(toTokenInfo.cryptoCurrencyStatus.currency) + val rateString = toTokenInfo.tokenAmount.getFormattedCryptoAmount(toTokenInfo.swapCurrencyStatus.currency) val additionalBadge = when { needApplyFCARestrictions && isFCARestrictedProvider() -> ProviderState.AdditionalBadge.FCAWarningList - state.permissionState is PermissionDataState.PermissionReadyForRequest -> { + state.permissionState is PermissionDataState.PermissionRequired -> { ProviderState.AdditionalBadge.PermissionRequired } isRecommended -> ProviderState.AdditionalBadge.Recommended @@ -1286,8 +1167,8 @@ internal class StateBuilder( ) } - private fun CryptoCurrencyStatus.getFormattedAmount(isNeedSymbol: Boolean): String { - val amount = value.amount ?: return DASH_SIGN + private fun CryptoCurrencyStatus?.getFormattedAmount(isNeedSymbol: Boolean): String { + val amount = this?.value?.amount ?: return DASH_SIGN val symbol = if (isNeedSymbol) currency.symbol else "" return amount.format { crypto(symbol, currency.decimals) } } @@ -1314,14 +1195,6 @@ internal class StateBuilder( return this.divide(to, min(rateDecimals, MAX_DECIMALS_TO_SHOW), RoundingMode.HALF_UP) } - private fun getLocaleName(): String { - return if (Locale.getDefault().language == "ru") { - RU_LOCALE - } else { - EN_LOCALE - } - } - private fun String.appendApproximateSign(): String { return "$TILDE_SIGN $this" } @@ -1330,27 +1203,20 @@ internal class StateBuilder( return FCA_RESTRICTED_PROVIDER_IDS.contains(providerId) } - private fun getFromCardAccountTitle(fromAccount: Account?): AccountTitleUM { - return if (fromAccount != null && isAccountsModeProvider()) { - AccountTitleUM.Account( - prefixText = resourceReference(R.string.common_from), - name = fromAccount.accountName.toUM().value, - icon = fromAccount.toIconUM(), - ) + private fun getCardAccountTitle(account: Account?, isFromCard: Boolean): AccountTitleUM { + val (prefix, placeholder) = if (isFromCard) { + R.string.common_from to R.string.swapping_from_title } else { - AccountTitleUM.Text(resourceReference(R.string.swapping_from_title)) + R.string.common_to to R.string.swapping_to_title } - } - - private fun getToCardAccountTitle(toAccount: Account?): AccountTitleUM { - return if (toAccount != null && isAccountsModeProvider()) { + return if (account != null && isAccountsModeProvider()) { AccountTitleUM.Account( - prefixText = resourceReference(R.string.common_to), - name = toAccount.accountName.toUM().value, - icon = toAccount.toIconUM(), + prefixText = resourceReference(prefix), + name = account.accountName.toUM().value, + icon = account.toIconUM(), ) } else { - AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)) + AccountTitleUM.Text(resourceReference(placeholder)) } } @@ -1361,22 +1227,9 @@ internal class StateBuilder( } } - private fun getChangeCardsButtonState(isReverseSwapPossible: Boolean) = if (isReverseSwapPossible) { - ChangeCardsButtonState.ENABLED - } else { - ChangeCardsButtonState.DISABLED - } - private companion object { - private const val RU_LOCALE = "ru" - private const val EN_LOCALE = "en" - const val ADDRESS_MIN_LENGTH = 11 - const val ADDRESS_FIRST_PART_LENGTH = 7 - const val ADDRESS_SECOND_PART_LENGTH = 4 private const val MAX_DECIMALS_TO_SHOW = 8 private const val IF_ZERO_DECIMALS_TO_SHOW = 2 - private const val FEE_READ_MORE_URL_FIRST_PART = "https://tangem.com/" - private const val FEE_READ_MORE_URL_SECOND_PART = "/blog/post/what-is-a-transaction-fee-and-why-do-we-need-it/" private val FCA_RESTRICTED_PROVIDER_IDS = setOf( "changelly", diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt index 0a5f77d83d..593a6e4109 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt @@ -9,8 +9,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag -import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -44,7 +42,7 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: feeBlock = if (feeSelectorBlockComponent != null) { @Composable { modifier: Modifier -> feeSelectorBlockComponent.Content( - modifier = Modifier + modifier = modifier .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.action), ) @@ -61,7 +59,6 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: val config = stateHolder.bottomSheetConfig when (config.content) { - is GiveTxPermissionBottomSheetConfig -> GiveTxPermissionBottomSheet(config = config) is ChooseProviderBottomSheetConfig -> ChooseProviderBottomSheet(config = config) is ChooseFeeBottomSheetConfig -> ChooseFeeBottomSheet(config = config) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 6716c863b8..322b60f85a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -24,25 +24,20 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState -import com.tangem.common.ui.extensions.iconResId import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.* import com.tangem.core.ui.components.notifications.Notification -import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.SwapTokenScreenTestTags -import com.tangem.domain.models.network.Network import com.tangem.feature.swap.domain.models.ui.FeeType import com.tangem.feature.swap.domain.models.ui.PriceImpact import com.tangem.feature.swap.models.* @@ -50,6 +45,8 @@ import com.tangem.feature.swap.models.states.FeeItemState import com.tangem.feature.swap.models.states.ProviderState import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.feature.swap.presentation.R +import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview.receiveCard +import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview.sendCard import kotlinx.collections.immutable.persistentListOf @Suppress("LongMethod") @@ -132,22 +129,22 @@ private fun MainInfo(state: SwapStateHolder) { ) { val (topCard, bottomCard, button) = createRefs() val priceImpact = state.priceImpact - TransactionCardData( + TransactionCard( priceImpact = priceImpact, swapCardState = state.sendCardData, modifier = Modifier.constrainAs(topCard) { top.linkTo(parent.top) }, - onSelectTokenClick = state.onSelectTokenClick, + onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.FROM) }, ) val marginCard = TangemTheme.dimens.spacing12 - TransactionCardData( + TransactionCard( priceImpact = priceImpact, swapCardState = state.receiveCardData, modifier = Modifier.constrainAs(bottomCard) { top.linkTo(topCard.bottom, margin = marginCard) }, - onSelectTokenClick = state.onSelectTokenClick, + onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.TO) }, ) val marginButton = TangemTheme.dimens.spacing30 SwapButton( @@ -161,43 +158,6 @@ private fun MainInfo(state: SwapStateHolder) { } } -@Composable -private fun TransactionCardData( - priceImpact: PriceImpact, - swapCardState: SwapCardState, - onSelectTokenClick: (() -> Unit)?, - modifier: Modifier = Modifier, -) { - when (swapCardState) { - is SwapCardState.Empty -> { - TransactionCardEmpty( - type = swapCardState.type, - amountEquivalent = swapCardState.amountEquivalent, - textFieldValue = swapCardState.amountTextFieldValue, - onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null, - modifier = modifier, - ) - } - is SwapCardState.SwapCardData -> { - TransactionCard( - type = swapCardState.type, - balance = swapCardState.balance.orMaskWithStars(swapCardState.isBalanceHidden), - textFieldValue = swapCardState.amountTextFieldValue, - amountEquivalent = swapCardState.amountEquivalent, - tokenIconUrl = swapCardState.tokenIconUrl.orEmpty(), - tokenCurrency = swapCardState.tokenCurrency, - priceImpact = priceImpact, - networkIconRes = if (swapCardState.isNotNativeToken) swapCardState.networkIconRes else null, - iconPlaceholder = swapCardState.coinId?.let { - Network.RawID(it).iconResId - }, - onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null, - modifier = modifier, - ) - } - } -} - @Composable private fun ProviderTos(tosState: TosState, modifier: Modifier = Modifier) { val tos = tosState.tosLink @@ -410,41 +370,6 @@ private fun MainButton(state: SwapStateHolder) { // region preview -private val sendCard = SwapCardState.SwapCardData( - type = TransactionCardType.Inputtable( - onAmountChanged = {}, - onFocusChanged = {}, - inputError = TransactionCardType.InputError.Empty, - accountTitleUM = null, - ), - amountTextFieldValue = TextFieldValue(), - amountEquivalent = stringReference("1 000 000"), - tokenIconUrl = "", - tokenCurrency = "DAI", - isNotNativeToken = true, - canSelectAnotherToken = false, - balance = "123", - coinId = "", - token = null, - networkIconRes = R.drawable.img_polygon_22, - isBalanceHidden = false, -) - -private val receiveCard = SwapCardState.SwapCardData( - type = TransactionCardType.ReadOnly(), - amountTextFieldValue = TextFieldValue(), - amountEquivalent = stringReference("1 000 000"), - tokenIconUrl = "", - tokenCurrency = "DAI", - isNotNativeToken = true, - canSelectAnotherToken = true, - balance = "33333", - coinId = "", - token = null, - networkIconRes = R.drawable.img_polygon_22, - isBalanceHidden = false, -) - private val state = SwapStateHolder( sendCardData = sendCard, receiveCardData = receiveCard, @@ -469,14 +394,13 @@ private val state = SwapStateHolder( onRefresh = {}, onBackClicked = {}, onChangeCardsClicked = {}, - permissionState = GiveTxPermissionState.InProgress, - blockchainId = "POLYGON", + permissionUM = SwapPermissionUM.Empty, providerState = ProviderState.Loading(), priceImpact = PriceImpact.Empty, shouldShowMaxAmount = true, isInsufficientFunds = false, onSuccess = {}, - onSelectTokenClick = {}, + onSelectTokenClick = { _ -> }, tosState = TosState( tosLink = LegalState( title = stringReference("Terms of Use"), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index 1886f3effd..0b5497b495 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -1,84 +1,99 @@ package com.tangem.feature.swap.ui -import androidx.annotation.DrawableRes +import android.content.res.Configuration import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.ripple -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil.compose.SubcomposeAsyncImage -import coil.request.ImageRequest -import com.tangem.common.ui.account.AccountNameUM import com.tangem.common.ui.account.AccountTitle -import com.tangem.common.ui.account.AccountTitleUM -import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.core.ui.R import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.SwapTokenScreenTestTags -import com.tangem.core.ui.utils.ImageBackgroundContrastChecker -import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.feature.swap.domain.models.ui.PriceImpact +import com.tangem.feature.swap.models.SwapCardState import com.tangem.feature.swap.models.TransactionCardType -import kotlinx.coroutines.launch +import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview -@Suppress("LongParameterList") @Composable -fun TransactionCard( - type: TransactionCardType, - balance: String, - tokenIconUrl: String, - tokenCurrency: String, - amountEquivalent: TextReference?, +internal fun TransactionCard( priceImpact: PriceImpact, - textFieldValue: TextFieldValue?, + swapCardState: SwapCardState, + onSelectTokenClick: () -> Unit, modifier: Modifier = Modifier, - @DrawableRes iconPlaceholder: Int? = null, - @DrawableRes networkIconRes: Int? = null, - onChangeTokenClick: (() -> Unit)? = null, ) { - val cardTag = when (type) { + val cardTag = when (swapCardState.type) { is TransactionCardType.Inputtable -> SwapTokenScreenTestTags.SWAP_CARD is TransactionCardType.ReadOnly -> SwapTokenScreenTestTags.RECEIVE_CARD } + when (swapCardState) { + is SwapCardState.Empty -> { + TransactionCardEmpty( + cardState = swapCardState, + onChangeTokenClick = onSelectTokenClick, + modifier = modifier.testTag(cardTag), + ) + } + is SwapCardState.SwapCardData -> { + TransactionCardData( + cardState = swapCardState, + priceImpact = priceImpact, + onChangeTokenClick = onSelectTokenClick, + modifier = modifier.testTag(cardTag), + ) + } + is SwapCardState.Loading -> TransactionCardLoading( + modifier = modifier.testTag(cardTag), + ) + } +} + +@Composable +private fun TransactionCardData( + cardState: SwapCardState.SwapCardData, + priceImpact: PriceImpact, + modifier: Modifier = Modifier, + onChangeTokenClick: (() -> Unit)? = null, +) { Box( modifier = modifier .background( shape = RoundedCornerShape(TangemTheme.dimens.radius16), color = TangemTheme.colors.background.primary, ) - .fillMaxSize() - .testTag(cardTag), + .fillMaxWidth(), ) { Column( modifier = Modifier @@ -86,22 +101,26 @@ fun TransactionCard( verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.Start, ) { - Header(balance = stringResourceSafe(R.string.common_balance, balance), type = type) + Header( + balance = stringResourceSafe( + R.string.common_balance, + cardState.balance, + ).orMaskWithStars(cardState.isBalanceHidden), + type = cardState.type, + ) Content( - type = type, - amountEquivalent = amountEquivalent, - textFieldValue = textFieldValue, + type = cardState.type, + amountEquivalent = cardState.amountEquivalent, + textFieldValue = cardState.amountTextFieldValue, priceImpact = priceImpact, ) } Box(modifier = Modifier.align(Alignment.BottomEnd)) { Token( - tokenIconUrl = tokenIconUrl, - tokenCurrency = tokenCurrency, - networkIconRes = networkIconRes, - iconPlaceholder = iconPlaceholder, + currencyIconState = cardState.currencyIconState, + tokenSymbol = cardState.tokenSymbol, ) } @@ -124,61 +143,134 @@ fun TransactionCard( } @Composable -fun TransactionCardEmpty( - type: TransactionCardType, - amountEquivalent: TextReference?, - textFieldValue: TextFieldValue?, +private fun TransactionCardEmpty( + cardState: SwapCardState.Empty, modifier: Modifier = Modifier, - onChangeTokenClick: (() -> Unit)? = null, + onChangeTokenClick: () -> Unit, ) { - Box( + Column( modifier = modifier .background( shape = RoundedCornerShape(TangemTheme.dimens.radius12), color = TangemTheme.colors.background.primary, ) - .fillMaxSize(), + .padding( + top = 12.dp, + start = 12.dp, + end = 12.dp, + bottom = 16.dp, + ) + .fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth(), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.Start, + AccountTitle( + accountTitleUM = cardState.type.accountTitleUM, + modifier = Modifier.fillMaxWidth(), + ) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Header( - balance = stringResourceSafe(id = R.string.swapping_token_not_available), - type = type, - ) - - Content( - type = type, - amountEquivalent = amountEquivalent, - textFieldValue = textFieldValue, - priceImpact = PriceImpact.Empty, - ) - } - - Box(modifier = Modifier.align(Alignment.BottomEnd)) { - Token( - tokenIconUrl = "", - tokenCurrency = "", - iconPlaceholder = R.drawable.ic_no_token_44, - ) - } - - if (onChangeTokenClick != null) { - Box(modifier = Modifier.align(Alignment.CenterEnd)) { - ChangeTokenSelector() + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = cardState.amountTextFieldValue?.text.orEmpty(), + color = TangemTheme.colors.text.disabled, + style = TangemTheme.typography.h2, + autoSize = TextAutoSize.StepBased( + minFontSize = 16.sp, + maxFontSize = TangemTheme.typography.h2.fontSize, + ), + maxLines = 1, + modifier = Modifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD), + ) + Text( + text = cardState.amountEquivalent.resolveAnnotatedReference(), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.body2, + modifier = Modifier + .defaultMinSize(minHeight = TangemTheme.dimens.size20) + .testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT), + ) } - Box( - Modifier - .align(Alignment.CenterEnd) - .height(TangemTheme.dimens.size116) - .width(TangemTheme.dimens.size102) - .clickable( - indication = ripple(bounded = false), - interactionSource = remember { MutableInteractionSource() }, - ) { onChangeTokenClick() }, + SecondarySmallButton( + config = SmallButtonConfig( + text = resourceReference(R.string.common_choose_token), + icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24), + onClick = onChangeTokenClick, + ), + ) + } + } +} + +@Composable +private fun TransactionCardLoading(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background( + shape = RoundedCornerShape(TangemTheme.dimens.radius12), + color = TangemTheme.colors.background.primary, + ) + .padding( + top = 12.dp, + start = 12.dp, + end = 12.dp, + bottom = 16.dp, + ) + .fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + TextShimmer( + text = stringResourceSafe(R.string.swapping_to_title), + style = TangemTheme.typography.subtitle2, + ) + TextShimmer( + style = TangemTheme.typography.body2, + modifier = Modifier + .testTag(SwapTokenScreenTestTags.BALANCE) + .width(60.dp), + ) + } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TextShimmer( + style = TangemTheme.typography.h2, + modifier = Modifier + .width(100.dp) + .testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD), + ) + TextShimmer( + style = TangemTheme.typography.body2, + modifier = Modifier + .defaultMinSize( + minHeight = 20.dp, + minWidth = 40.dp, + ) + .testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT), + ) + } + SecondarySmallButton( + config = SmallButtonConfig( + text = resourceReference(R.string.common_choose_token), + icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24), + isEnabled = false, + onClick = {}, + ), ) } } @@ -376,12 +468,7 @@ private fun Content( @Suppress("MagicNumber") @Composable -fun Token( - tokenIconUrl: String, - tokenCurrency: String, - @DrawableRes iconPlaceholder: Int? = null, - @DrawableRes networkIconRes: Int? = null, -) { +fun Token(currencyIconState: CurrencyIconState, tokenSymbol: TextReference) { Column( modifier = Modifier .padding( @@ -392,15 +479,13 @@ fun Token( verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.End, ) { - TokenIcon( - tokenIconUrl = tokenIconUrl, - tokenCurrency = tokenCurrency, - iconPlaceholder = iconPlaceholder, - networkIconRes = networkIconRes, + CurrencyIcon( + state = currencyIconState, + modifier = Modifier.padding(end = TangemTheme.dimens.spacing16), ) SpacerH4() Text( - text = tokenCurrency, + text = tokenSymbol.resolveReference(), color = TangemTheme.colors.text.primary1, maxLines = 1, style = TangemTheme.typography.subtitle2, @@ -412,89 +497,10 @@ fun Token( } } -@Suppress("NullableToStringCall") -@Composable -private fun TokenIcon( - tokenIconUrl: String, - tokenCurrency: String, - @DrawableRes iconPlaceholder: Int? = null, - @DrawableRes networkIconRes: Int? = null, -) { - var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) } - var isBackgroundColorDefined by remember { mutableStateOf(false) } - val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb() - val isDarkTheme = isSystemInDarkTheme() - val coroutineScope = rememberCoroutineScope() - - Box( - modifier = Modifier - .padding(end = TangemTheme.dimens.spacing16) - .size(TangemTheme.dimens.size42) - .testTag(SwapTokenScreenTestTags.TOKEN_ICON), - ) { - val tokenImageModifier = Modifier - .align(Alignment.BottomStart) - .size(TangemTheme.dimens.size36) - .background( - color = iconBackgroundColor, - shape = TangemTheme.shapes.roundedCorners8, - ) - .clip(TangemTheme.shapes.roundedCorners8) - - val data = tokenIconUrl.ifEmpty { iconPlaceholder } - - val pixelsSize = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() } - - SubcomposeAsyncImage( - modifier = tokenImageModifier, - model = ImageRequest.Builder(LocalContext.current) - .data(data) - .size(size = pixelsSize) - .memoryCacheKey(key = data.toString() + pixelsSize) - .crossfade(true) - .allowHardware(false) - .listener( - onSuccess = { _, result -> - if (isDarkTheme) { - coroutineScope.launch { - val color = ImageBackgroundContrastChecker( - drawable = result.drawable, - backgroundColor = itemBackgroundColor, - size = pixelsSize, - ).getContrastColor(true) - iconBackgroundColor = color - isBackgroundColorDefined = true - } - } - }, - ).build(), - loading = { CircleShimmer(modifier = tokenImageModifier) }, - contentDescription = tokenCurrency, - ) - - if (networkIconRes != null) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .size(TangemTheme.dimens.size18) - .background(color = TangemTheme.colors.background.primary, shape = CircleShape), - contentAlignment = Alignment.Center, - ) { - Image( - modifier = Modifier.padding(all = TangemTheme.dimens.spacing2), - painter = painterResource(id = networkIconRes), - contentDescription = null, - ) - } - } - } -} - @Composable fun ChangeTokenSelector() { Box( modifier = Modifier - .fillMaxHeight() .padding( top = TangemTheme.dimens.spacing12, start = TangemTheme.dimens.spacing24, @@ -513,129 +519,29 @@ fun ChangeTokenSelector() { } } -// region preview - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) +// region Preview @Composable -private fun Preview_TransactionCard_InLightTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreview() +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TransactionCard_Preview(@PreviewParameter(PreviewProvider::class) params: SwapCardState) { + TangemThemePreview { + TransactionCard( + priceImpact = PriceImpact.Empty, + swapCardState = params, + onSelectTokenClick = {}, + modifier = Modifier, + ) } } -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCardWithPriceImpact_InLightTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreviewWithPriceImpact() - } +private class PreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + SwapTransactionCardPreview.sendCard, + SwapTransactionCardPreview.receiveCard, + SwapTransactionCardPreview.emptyReadOnlyCard, + SwapTransactionCardPreview.emptyInputtableCard, + SwapTransactionCardPreview.loadingCard, + ) } - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCardWithoutPriceImpact_InLightTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreviewWithoutPriceImpact() - } -} - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCard_InDarkTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreview() - } -} - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCardWithPriceImpact_InDarkTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreviewWithPriceImpact() - } -} - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCardWithoutPriceImpact_InDarkTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreviewWithoutPriceImpact() - } -} - -@Composable -private fun TransactionCardPreview() { - TransactionCard( - type = TransactionCardType.Inputtable( - onAmountChanged = {}, - onFocusChanged = {}, - inputError = TransactionCardType.InputError.Empty, - accountTitleUM = null, - ), - amountEquivalent = stringReference("1 000 000"), - tokenIconUrl = "", - tokenCurrency = "DAI", - networkIconRes = R.drawable.img_polygon_22, - onChangeTokenClick = {}, - balance = "123", - textFieldValue = TextFieldValue(), - priceImpact = PriceImpact.Empty, - ) -} - -@Composable -@Suppress("MagicNumber") -private fun TransactionCardPreviewWithPriceImpact() { - TransactionCard( - type = TransactionCardType.ReadOnly( - shouldShowWarning = true, - accountTitleUM = AccountTitleUM.Account( - prefixText = resourceReference(R.string.common_from), - name = AccountNameUM.DefaultMain.value, - icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()), - ), - ), - amountEquivalent = combinedReference( - stringReference("1 000 000 $"), - styledStringReference( - " (-15%)", - { SpanStyle(color = TangemTheme.colors.text.attention) }, - ), - ), - tokenIconUrl = "", - tokenCurrency = "DAI", - networkIconRes = R.drawable.img_polygon_22, - onChangeTokenClick = {}, - balance = "123", - textFieldValue = TextFieldValue("1000000.0000000000000000000000000"), - priceImpact = PriceImpact( - value = 0.15F.toBigDecimal(), - type = PriceImpact.Type.MEDIUM, - amountSignificance = PriceImpact.AmountSignificance.HIGH, - ), - ) -} - -@Composable -@Suppress("MagicNumber") -private fun TransactionCardPreviewWithoutPriceImpact() { - TransactionCard( - type = TransactionCardType.ReadOnly( - accountTitleUM = AccountTitleUM.Account( - prefixText = resourceReference(R.string.common_from), - name = AccountNameUM.DefaultMain.value, - icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()), - ), - ), - amountEquivalent = stringReference("1 000 000"), - tokenIconUrl = "", - tokenCurrency = "DAI", - networkIconRes = R.drawable.img_polygon_22, - onChangeTokenClick = {}, - balance = "123", - textFieldValue = TextFieldValue(), - priceImpact = PriceImpact.Empty, - ) -} - -// endregion preview \ No newline at end of file +// endregion \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt new file mode 100644 index 0000000000..04fc89a1a1 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt @@ -0,0 +1,79 @@ +package com.tangem.feature.swap.ui.preview + +import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.common.ui.account.AccountNameUM +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.CryptoPortfolioIconConverter +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.feature.swap.models.SwapCardState +import com.tangem.feature.swap.models.TransactionCardType +import com.tangem.feature.swap.presentation.R + +internal object SwapTransactionCardPreview { + + val sendCard = SwapCardState.SwapCardData( + type = TransactionCardType.Inputtable( + onAmountChanged = {}, + onFocusChanged = {}, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = AccountTitleUM.Account( + prefixText = stringReference("From"), + name = AccountNameUM.DefaultMain.value, + icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()), + ), + ), + amountTextFieldValue = TextFieldValue(), + amountEquivalent = stringReference("1 000 000"), + currencyIconState = CurrencyIconState.Loading, + tokenSymbol = stringReference("DAI"), + balance = "123", + isBalanceHidden = false, + ) + + val receiveCard = SwapCardState.SwapCardData( + type = TransactionCardType.ReadOnly( + accountTitleUM = AccountTitleUM.Account( + prefixText = stringReference("To"), + name = AccountNameUM.DefaultMain.value, + icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()), + ), + ), + amountTextFieldValue = TextFieldValue(), + amountEquivalent = stringReference("1 000 000"), + currencyIconState = CurrencyIconState.Loading, + tokenSymbol = stringReference("DAI"), + balance = "33333", + isBalanceHidden = false, + ) + + val emptyReadOnlyCard = SwapCardState.Empty( + type = TransactionCardType.ReadOnly( + accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)), + ), + amountEquivalent = stringReference("$0.00"), + amountTextFieldValue = null, + ) + + val emptyInputtableCard = SwapCardState.Empty( + type = TransactionCardType.Inputtable( + onAmountChanged = {}, + onFocusChanged = {}, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_from_title)), + ), + amountEquivalent = stringReference("$0.00"), + amountTextFieldValue = null, + ) + + val loadingCard = SwapCardState.Loading( + type = TransactionCardType.Inputtable( + onAmountChanged = {}, + onFocusChanged = {}, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)), + ), + ) +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt new file mode 100644 index 0000000000..82ce65c84e --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt @@ -0,0 +1,867 @@ +package com.tangem.feature.swap + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.* +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.quote.PriceChange +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.feature.swap.model.InitialCurrenciesResolver +import com.tangem.features.swap.SwapComponent.Params.CurrencyPosition +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultInitialCurrenciesResolverTest { + + private val getUserWalletUseCase = mockk() + private val singleAccountStatusListSupplier = mockk() + private val rampStateManager = mockk() + + private val userWalletId = UserWalletId("0011") + private val userWallet = mockk { + every { walletId } returns userWalletId + } + + private val resolver = InitialCurrenciesResolver( + getUserWalletUseCase = getUserWalletUseCase, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + rampStateManager = rampStateManager, + ) + + private var uniqueIndex = 0 + + @BeforeEach + fun setup() { + coEvery { getUserWalletUseCase(userWalletId) } returns userWallet.right() + } + + // region no initial currency + + @Test + fun `GIVEN available tokens with balance WHEN no initial currency THEN returns available token with max fiat balance`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + val currency3 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("100")) + val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("300")) + val status3 = createCurrencyStatus(currency3, fiatAmount = BigDecimal("500")) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2, status3)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(currency1 to true, currency2 to true, currency3 to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status2) + assertThat(to).isNull() + } + + @Test + fun `GIVEN available tokens without balance WHEN no initial currency THEN returns first token from first account`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal.ZERO) + val status2 = createCurrencyStatus(currency2, fiatAmount = null) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(currency1 to true, currency2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status1) + assertThat(to).isNull() + } + + @Test + fun `GIVEN no available tokens with balance WHEN no initial currency THEN returns token with max fiat balance`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("100")) + val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("200")) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(currency1 to false, currency2 to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status2) + assertThat(to).isNull() + } + + @Test + fun `GIVEN no available tokens without balance WHEN no initial currency THEN returns first token from first account`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal.ZERO) + val status2 = createCurrencyStatus(currency2, fiatAmount = null) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(currency1 to false, currency2 to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status1) + assertThat(to).isNull() + } + + @Test + fun `GIVEN empty accounts WHEN no initial currency THEN returns null pair`() = runTest { + setupSupplier(emptyList()) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to).isNull() + } + + @Test + fun `GIVEN multiple accounts WHEN fallback to first THEN returns first token from first account`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = null) + val status2 = createCurrencyStatus(currency2, fiatAmount = null) + + val account1Status = createCryptoPortfolioAccountStatus(listOf(status1)) + val account2Status = createCryptoPortfolioAccountStatus(listOf(status2)) + setupSupplier(listOf(account1Status, account2Status)) + + setupAvailability(linkedMapOf(currency1 to true)) + setupAvailability(linkedMapOf(currency2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status1) + assertThat(to).isNull() + } + + @Test + fun `GIVEN mixed availability and balance across accounts WHEN no initial currency THEN returns best available with balance`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + val currency3 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("50")) + val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("200")) + val status3 = createCurrencyStatus(currency3, fiatAmount = BigDecimal("100")) + + val account1Status = createCryptoPortfolioAccountStatus(listOf(status1)) + val account2Status = createCryptoPortfolioAccountStatus(listOf(status2, status3)) + setupSupplier(listOf(account1Status, account2Status)) + + setupAvailability(linkedMapOf(currency1 to true)) + setupAvailability(linkedMapOf(currency2 to false, currency3 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status3) + assertThat(to).isNull() + } + + // endregion + + // region initial currency tests + + @Test + fun `GIVEN initial currency not found WHEN invoke THEN returns null pair`() = runTest { + val initialCurrency = mockCryptoCurrency() + val otherCurrency = mockCryptoCurrency() + + val status = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(otherCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial currency available with balance WHEN invoke THEN returns it as from`() = runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status) + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial currency available without balance WHEN invoke THEN returns it as to and best as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal.ZERO) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("200")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(accountCurrency to true, otherCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(otherStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency available without balance and is only token WHEN invoke THEN returns it as to and from is null`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal.ZERO) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(status) + } + + @Test + fun `GIVEN initial currency not available with balance WHEN invoke THEN returns it as from`() = runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status) + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial currency not available with balance and other available with higher balance WHEN invoke THEN returns initial as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("500")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(accountCurrency to false, otherCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(initialStatus) + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial currency not available without balance and other available with balance WHEN invoke THEN returns best available as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val other1 = mockCryptoCurrency() + val other2 = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null) + val other1Status = createCurrencyStatus(other1, fiatAmount = BigDecimal("100")) + val other2Status = createCurrencyStatus(other2, fiatAmount = BigDecimal("300")) + val accountStatus = createCryptoPortfolioAccountStatus( + listOf(initialStatus, other1Status, other2Status), + ) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(accountCurrency to false, other1 to true, other2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(other2Status) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency not available without balance and other available without balance WHEN invoke THEN returns first token as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal.ZERO) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, initialStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(otherCurrency to true, accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(otherStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency not available without balance and no available tokens with balance WHEN invoke THEN returns best by balance as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("200")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(accountCurrency to false, otherCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(otherStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency not available without balance and no available tokens without balance WHEN invoke THEN returns first token as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal.ZERO) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, initialStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(otherCurrency to false, accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(otherStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency not available without balance and is only token WHEN invoke THEN returns it as to and from is null`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = null) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(status) + } + + @Test + fun `GIVEN initial from second account without balance and same token in main with balance WHEN invoke THEN does not pick same token as from`() = + runTest { + val sharedNetworkId = "ethereum" + val sharedContractAddress = "0xUSDT" + + // Same token (same network + contract), different ids (simulates different derivations) + val idInSecondary = mockCurrencyId(sharedNetworkId, sharedContractAddress) + val idInMain = mockCurrencyId(sharedNetworkId, sharedContractAddress) + val initialCurrency = mockCryptoCurrency(id = idInSecondary) + val usdtInSecondary = mockCryptoCurrency(id = idInSecondary) + val usdtInMain = mockCryptoCurrency(id = idInMain) + + val statusInSecondary = createCurrencyStatus(usdtInSecondary, fiatAmount = null) + val statusInMain = createCurrencyStatus(usdtInMain, fiatAmount = BigDecimal("1000")) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(statusInMain, statusInSecondary)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(usdtInMain to true, usdtInSecondary to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // Selected goes to TO; FROM must not be the same token from another account + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(statusInSecondary) + } + + @Test + fun `GIVEN initial from second account with balance WHEN invoke THEN returns it as from`() = runTest { + val idInSecondary = mockCurrencyId("ethereum", "0xUSDT") + val initialCurrency = mockCryptoCurrency(id = idInSecondary) + val usdtInSecondary = mockCryptoCurrency(id = idInSecondary) + val otherCurrency = mockCryptoCurrency() + + val statusInSecondary = createCurrencyStatus(usdtInSecondary, fiatAmount = BigDecimal("200")) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("500")) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, statusInSecondary)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(otherCurrency to true, usdtInSecondary to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(statusInSecondary) + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial in secondary account placed in TO WHEN invoke THEN FROM is picked only from same account`() = + runTest { + // Main account has a high-balance available currency. + val mainOnlyCurrency = mockCryptoCurrency() + val mainStatus = createCurrencyStatus(mainOnlyCurrency, fiatAmount = BigDecimal("10000")) + val mainAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(mainStatus), + derivationIndexValue = 0, + ) + + // Secondary account holds the initial currency (available, zero balance → TO) + // plus another available currency with balance. + val initialId = mockCurrencyId("ethereum", "0xUSDT") + val initialCurrency = mockCryptoCurrency(id = initialId) + val initialInSecondary = mockCryptoCurrency(id = initialId) + val secondaryCompanion = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(initialInSecondary, fiatAmount = BigDecimal.ZERO) + val secondaryStatus = createCurrencyStatus(secondaryCompanion, fiatAmount = BigDecimal("50")) + + val secondaryAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(initialStatus, secondaryStatus), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(mainAccount, secondaryAccount)) + + setupAvailability(linkedMapOf(mainOnlyCurrency to true)) + setupAvailability(linkedMapOf(initialInSecondary to true, secondaryCompanion to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // FROM must come from secondary account only — never the main account's high-balance currency. + assertThat(from?.status).isSameInstanceAs(secondaryStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial in secondary account is only token in that account WHEN invoke THEN FROM is null`() = + runTest { + // Main account has candidates that must NOT be picked as FROM. + val mainOnlyCurrency = mockCryptoCurrency() + val mainStatus = createCurrencyStatus(mainOnlyCurrency, fiatAmount = BigDecimal("500")) + val mainAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(mainStatus), + derivationIndexValue = 0, + ) + + // Secondary account has only the initial currency (available, no balance → TO). + val initialId = mockCurrencyId("ethereum", "0xUSDT") + val initialCurrency = mockCryptoCurrency(id = initialId) + val initialInSecondary = mockCryptoCurrency(id = initialId) + + val initialStatus = createCurrencyStatus(initialInSecondary, fiatAmount = BigDecimal.ZERO) + val secondaryAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(initialStatus), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(mainAccount, secondaryAccount)) + + setupAvailability(linkedMapOf(mainOnlyCurrency to true)) + setupAvailability(linkedMapOf(initialInSecondary to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // Secondary account has no other candidates; FROM must be null, not pulled from main. + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + // endregion + + // region currency position FROM + + @Test + fun `GIVEN position FROM and available with balance WHEN invoke THEN returns selected as from`() = runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.FROM, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status) + assertThat(to).isNull() + } + + @Test + fun `GIVEN position FROM and not available without balance WHEN invoke THEN still returns selected as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = null) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.FROM, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status) + assertThat(to).isNull() + } + + // endregion + + // region currency position TO + + @Test + fun `GIVEN position TO and available with balance WHEN invoke THEN returns selected as to`() = runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.TO, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(status) + } + + @Test + fun `GIVEN position TO and not available without balance WHEN invoke THEN still returns selected as to`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = null) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.TO, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(status) + } + + // endregion + + // region helpers + + private fun mockCryptoCurrency( + id: CryptoCurrency.ID = mockCurrencyId(), + ): CryptoCurrency = mockk(relaxed = true) { + every { this@mockk.id } returns id + } + + private fun mockCurrencyId( + rawNetworkId: String = "net-${uniqueIndex++}", + contractAddress: String = "contract-${uniqueIndex++}", + ): CryptoCurrency.ID = mockk(relaxed = true) { + every { this@mockk.rawNetworkId } returns rawNetworkId + every { this@mockk.contractAddress } returns contractAddress + } + + private fun createCurrencyStatus( + currency: CryptoCurrency, + fiatAmount: BigDecimal?, + ): CryptoCurrencyStatus { + val value = mockk { + every { this@mockk.fiatAmount } returns fiatAmount + } + return CryptoCurrencyStatus(currency = currency, value = value) + } + + private fun createCryptoPortfolioAccountStatus( + currencies: List, + ): AccountStatus.CryptoPortfolio { + val account = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId) + return AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loaded( + amount = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ), + sortedBy = TokensSortType.NONE, + currencies = currencies, + ), + priceChangeLce = Lce.Content(PriceChange(value = BigDecimal.ZERO, source = StatusSource.ACTUAL)), + ) + } + + private fun createCryptoPortfolioAccountStatus( + currencies: List, + derivationIndexValue: Int, + ): AccountStatus.CryptoPortfolio { + val derivationIndex = requireNotNull(DerivationIndex(value = derivationIndexValue).getOrNull()) { + "Invalid derivation index for test: $derivationIndexValue" + } + val accountId = AccountId.forCryptoPortfolio( + userWalletId = userWalletId, + derivationIndex = derivationIndex, + ) + val accountName = if (derivationIndex.isMain) { + AccountName.DefaultMain + } else { + requireNotNull(AccountName.Custom(value = "Account $derivationIndexValue").getOrNull()) { + "Invalid account name for test" + } + } + val account = Account.CryptoPortfolio( + accountId = accountId, + accountName = accountName, + icon = CryptoPortfolioIcon.ofMainAccount(userWalletId), + derivationIndex = derivationIndex, + ) + return AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loaded( + amount = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ), + sortedBy = TokensSortType.NONE, + currencies = currencies, + ), + priceChangeLce = Lce.Content(PriceChange(value = BigDecimal.ZERO, source = StatusSource.ACTUAL)), + ) + } + + private fun setupSupplier(accountStatuses: List) { + val accountStatusList = if (accountStatuses.isEmpty()) { + null + } else { + AccountStatusList( + userWalletId = userWalletId, + accountStatuses = accountStatuses, + totalAccounts = accountStatuses.size, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loaded( + amount = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ), + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + } + coEvery { + singleAccountStatusListSupplier.getSyncOrNull(any(), any()) + } returns accountStatusList + } + + private fun setupAvailability(currenciesAvailability: LinkedHashMap) { + val result = currenciesAvailability.map { (currency, available) -> + val reason = if (available) { + ScenarioUnavailabilityReason.None + } else { + ScenarioUnavailabilityReason.Unreachable + } + currency to reason + }.toMap() + coEvery { rampStateManager.availableForSwap(userWalletId, currenciesAvailability.keys.toList()) } returns result + } + + // endregion +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index dd917953d6..1e357c518b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -207,9 +207,9 @@ internal class TangemPayCardPageModel @Inject constructor( bottomSheetNavigation.dismiss() router.push( AppRoute.Swap( - currencyFrom = data.currency, + cryptoCurrency = data.currency, userWalletId = data.walletId, - isInitialReverseOrder = true, + currencyPosition = AppRoute.Swap.CurrencyPosition.TO, screenSource = AnalyticsParam.ScreensSources.TangemPay.value, tangemPayInput = AppRoute.Swap.TangemPayInput( cryptoAmount = data.cryptoBalance, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 87c07ef8ba..9de0999957 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -55,6 +55,7 @@ import com.tangem.features.tokendetails.ExpressTransactionsEventListener import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn +import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -62,7 +63,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") @@ -314,10 +314,10 @@ internal class TangemPayDetailsModel @Inject constructor( ) { router.push( AppRoute.Swap( - currencyFrom = currency, + cryptoCurrency = currency, userWalletId = params.userWalletId, - isInitialReverseOrder = false, screenSource = AnalyticsParam.ScreensSources.TangemPay.value, + currencyPosition = AppRoute.Swap.CurrencyPosition.FROM, tangemPayInput = AppRoute.Swap.TangemPayInput( cryptoAmount = currentBalance.availableForWithdrawal, fiatAmount = currentBalance.availableForWithdrawal, @@ -425,10 +425,10 @@ internal class TangemPayDetailsModel @Inject constructor( bottomSheetNavigation.dismiss() router.push( AppRoute.Swap( - currencyFrom = data.currency, + cryptoCurrency = data.currency, userWalletId = data.walletId, - isInitialReverseOrder = true, screenSource = AnalyticsParam.ScreensSources.TangemPay.value, + currencyPosition = AppRoute.Swap.CurrencyPosition.TO, tangemPayInput = AppRoute.Swap.TangemPayInput( cryptoAmount = data.cryptoBalance, fiatAmount = data.fiatBalance, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index bf086562db..89fba14379 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -787,7 +787,7 @@ internal class TokenDetailsModel @Inject constructor( } else { appRouter.push( AppRoute.Swap( - currencyFrom = cryptoCurrency, + cryptoCurrency = cryptoCurrency, userWalletId = userWalletId, screenSource = AnalyticsParam.ScreensSources.Token.value, ), @@ -1307,7 +1307,7 @@ internal class TokenDetailsModel @Inject constructor( TokenAction.Send -> sendCurrency() TokenAction.Swap -> appRouter.push( AppRoute.Swap( - currencyFrom = cryptoCurrency, + cryptoCurrency = cryptoCurrency, userWalletId = userWalletId, screenSource = AnalyticsParam.ScreensSources.Token.value, ), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/express/ExchangeUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/express/ExchangeUM.kt index d0737b6df2..c65cac824f 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/express/ExchangeUM.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/express/ExchangeUM.kt @@ -3,6 +3,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.express import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateInfoUM import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification @@ -15,6 +16,7 @@ internal data class ExchangeUM( val statuses: ImmutableList, val notification: ExchangeStatusNotification?, val showProviderLink: Boolean, + val fromUserWalletId: UserWalletId, val fromCryptoCurrency: CryptoCurrency, val toCryptoCurrency: CryptoCurrency, val hasLongTime: Boolean, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt index 78540b1ba8..c55c898406 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt @@ -19,6 +19,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.quote.mapData +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed @@ -98,6 +99,7 @@ internal class TokenDetailsSwapTransactionsStateConverter( val showProviderLink = getShowProviderLink(notification, transaction.status) result.add( ExchangeUM( + fromUserWalletId = UserWalletId(swapTransaction.fromUserWalletId), provider = transaction.provider, statuses = getStatuses(statusModel?.status), notification = notification, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt index 3bc30b0d7c..e2465a65bd 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt @@ -12,8 +12,10 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.domain.* @@ -21,6 +23,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTr import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter import com.tangem.utils.Provider +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -29,7 +32,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.map -import com.tangem.utils.logging.TangemLogger @Suppress("LongParameterList") internal class ExchangeStatusFactory @AssistedInject constructor( @@ -40,6 +42,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val swapTransactionStatusStore: SwapTransactionStatusStore, private val analyticsEventsHandler: AnalyticsEventHandler, + private val getUserWalletUseCase: GetUserWalletUseCase, @Assisted private val clickIntents: ExpressTransactionsClickIntents, @Assisted private val appCurrencyProvider: Provider, @Assisted private val currentStateProvider: Provider, @@ -59,6 +62,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( operator fun invoke(): Flow> { return swapTransactionRepository.getTransactions( userWallet = userWallet, + cryptoCurrencyId = cryptoCurrency.id, ).conflate() .map { savedTransactions -> @@ -93,7 +97,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( return if (swapTx.activeStatus?.isTerminal == true) { swapTx } else { - val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider) + val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider, swapTx.fromUserWalletId) if (statusModel != null) { swapTransactionsStateConverter.updateTxStatus( @@ -106,15 +110,24 @@ internal class ExchangeStatusFactory @AssistedInject constructor( } } - private suspend fun getExchangeStatus(txId: String, provider: SwapProvider): ExchangeStatusModel? { - return swapRepository.getExchangeStatus(userWallet = userWallet, txId = txId) + private suspend fun getExchangeStatus( + txId: String, + provider: SwapProvider, + fromUserWalletId: UserWalletId, + ): ExchangeStatusModel? { + val fromUserWallet = getUserWalletUseCase(fromUserWalletId).getOrNull() + return swapRepository.getExchangeStatus( + userWallet = fromUserWallet, + userWalletId = fromUserWalletId, + txId = txId, + ) .fold( ifLeft = { null }, ifRight = { statusModel -> sendStatusUpdateAnalytics(statusModel, provider) val accountId = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = userWallet.walletId, + userWalletId = fromUserWalletId, currency = cryptoCurrency, ) .map { it.account.accountId } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/TokenDetailsExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/TokenDetailsExchangeStatusFactory.kt index a8af24a292..c7b54127e2 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/TokenDetailsExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/TokenDetailsExchangeStatusFactory.kt @@ -1,5 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express +import arrow.core.getOrElse import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.datasource.local.swap.ExpressAnalyticsStatus @@ -11,8 +12,10 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.domain.* @@ -21,6 +24,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter import com.tangem.utils.Provider +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -29,7 +33,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.map -import com.tangem.utils.logging.TangemLogger import kotlin.coroutines.cancellation.CancellationException @Suppress("LongParameterList") @@ -41,6 +44,7 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val swapTransactionStatusStore: SwapTransactionStatusStore, private val analyticsEventsHandler: AnalyticsEventHandler, + private val getUserWalletUseCase: GetUserWalletUseCase, @Assisted private val clickIntents: ExpressTransactionsClickIntents, @Assisted private val appCurrencyProvider: Provider, @Assisted private val currentStateProvider: Provider, @@ -94,7 +98,7 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor( return if (swapTx.activeStatus?.isTerminal == true) { swapTx } else { - val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider) + val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider, swapTx.fromUserWalletId) if (statusModel != null) { swapTransactionsStateConverter.updateTxStatus( @@ -107,43 +111,54 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor( } } - private suspend fun getExchangeStatus(txId: String, provider: SwapProvider): ExchangeStatusModel? { - return swapRepository.getExchangeStatus(userWallet = userWallet, txId = txId) - .fold( - ifLeft = { null }, - ifRight = { statusModel -> - sendStatusUpdateAnalytics(statusModel, provider) + private suspend fun getExchangeStatus( + txId: String, + provider: SwapProvider, + fromUserWalletId: UserWalletId, + ): ExchangeStatusModel? { + val fromUserWallet = getUserWalletUseCase(fromUserWalletId).getOrElse { error -> + TangemLogger.e("Couldn't find userWallet: $error") + return null + } + return swapRepository.getExchangeStatus( + userWallet = fromUserWallet, + userWalletId = fromUserWalletId, + txId = txId, + ).fold( + ifLeft = { null }, + ifRight = { statusModel -> + sendStatusUpdateAnalytics(statusModel, provider) - val accountId = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = userWallet.walletId, - currency = cryptoCurrency, - ) - .map { it.account.accountId } - .getOrNull() + val accountId = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = fromUserWalletId, + currency = cryptoCurrency, + ) + .map { it.account.accountId } + .getOrNull() - val refundTokenCurrency = if (accountId != null) { - addRefundCurrencyIfNeeded( - accountId = accountId, - status = statusModel, - type = provider.type, - ) - } else { - TangemLogger.e("Account ID is null, cannot add refund currency ${cryptoCurrency.id}") - null - } - - swapTransactionRepository.storeTransactionState( - txId = txId, + val refundTokenCurrency = if (accountId != null) { + addRefundCurrencyIfNeeded( + accountId = accountId, status = statusModel, - accountWithCurrency = if (refundTokenCurrency != null) { - Pair(accountId, refundTokenCurrency) - } else { - null - }, + type = provider.type, ) - statusModel.copy(refundCurrency = refundTokenCurrency) - }, - ) + } else { + TangemLogger.e("Account ID is null, cannot add refund currency ${cryptoCurrency.id}") + null + } + + swapTransactionRepository.storeTransactionState( + txId = txId, + status = statusModel, + accountWithCurrency = if (refundTokenCurrency != null) { + Pair(accountId, refundTokenCurrency) + } else { + null + }, + ) + statusModel.copy(refundCurrency = refundTokenCurrency) + }, + ) } private suspend fun sendStatusUpdateAnalytics(statusModel: ExchangeStatusModel, provider: SwapProvider) { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt index 8e92b4c203..dfcff712d9 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt @@ -9,6 +9,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency.ID import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.swap.domain.models.domain.SwapProvider @@ -23,6 +24,7 @@ class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider checkSwapCryptoAvailability( - tokenCount = tokenListState.items.count { it is TokensListItemUM.Token }, - ) - is WalletTokensListState.ContentState.PortfolioContent -> checkSwapCryptoAvailability( - tokenCount = tokenListState.items.sumOf { it.tokens.count { it is TokensListItemUM.Token } }, - ) + when (selectedWallet.tokensListState) { + is WalletTokensListState.ContentState.Content, + is WalletTokensListState.ContentState.PortfolioContent, + -> Unit WalletTokensListState.ContentState.Loading, WalletTokensListState.ContentState.Locked, WalletTokensListState.Empty, @@ -470,7 +466,10 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( modelScope.launch { val swapRoute = getSwapRoute( - AppRoute.SwapCrypto(userWalletId = userWalletId), + AppRoute.Swap( + userWalletId = userWalletId, + screenSource = AnalyticsParam.ScreensSources.Main.value, + ), ) onMultiWalletActionClick( statusFlow = rampStateManager.getExpressInitializationStatus(userWalletId), @@ -660,7 +659,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private fun navigateToSwap(cryptoCurrencyStatus: CryptoCurrencyStatus, userWalletId: UserWalletId) { appRouter.push( AppRoute.Swap( - currencyFrom = cryptoCurrencyStatus.currency, + cryptoCurrency = cryptoCurrencyStatus.currency, userWalletId = userWalletId, screenSource = AnalyticsParam.ScreensSources.LongTap.value, ), @@ -675,11 +674,4 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } } } - - private fun checkSwapCryptoAvailability(tokenCount: Int) { - if (tokenCount < 2) { - analyticsEventHandler.send(event = MainScreenAnalyticsEvent.ButtonSwap(AnalyticsParam.Status.Error)) - uiMessageSender.send(WalletAlertUM.insufficientTokensCountForSwapping()) - } - } } \ No newline at end of file