Updated on 2026-08-14

This commit is contained in:
Tangem 2023-12-18 12:43:47 +03:00
commit fa4b51cebe
13 changed files with 97 additions and 60 deletions

View file

@ -11,6 +11,7 @@ import com.tangem.domain.wallets.legacy.WalletsStateHolder
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.tap.common.entities.FiatCurrency
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.domain.TangemSdkManager
@ -69,6 +70,10 @@ class AppStateHolder @Inject constructor() : WalletsStateHolder, ReduxNavControl
mainStore?.dispatch(action)
}
override suspend fun dispatchWithMain(action: Action) {
mainStore?.dispatchWithMain(action)
}
override suspend fun onUserWalletSelected(userWallet: UserWallet) {
mainStore?.onUserWalletSelected(userWallet)
}

View file

@ -198,6 +198,7 @@
<string name="express_choose_providers_subtitle">Провайдеры проводят транзакции, обеспечивая плавный и эффективный обмен токенами</string>
<string name="express_choose_providers_title">Выберите провайдера</string>
<string name="express_estimated_amount">Курс обмена</string>
<string name="express_more_providers_soon">Больше провайдеров на подходе.\nСледите за обновлениями!</string>
<string name="express_exchange_notification_failed_text">Чтобы вернуть ваши деньги, посетите сайт провайдера</string>
<string name="express_exchange_notification_failed_title">Операция не выполнена провайдером</string>
<string name="express_exchange_notification_verification_text">Посетите сайт провайдера для проверки</string>

View file

@ -196,6 +196,7 @@
<string name="express_cex_status_button_title">Status</string>
<string name="express_choose_providers_subtitle">Providers facilitate transactions, ensuring smooth and efficient token swaps</string>
<string name="express_choose_providers_title">Choose provider</string>
<string name="express_more_providers_soon">More providers are coming soon.\nStay tuned!</string>
<string name="express_estimated_amount">Estimated amount</string>
<string name="express_exchange_by">Exchange by %s</string>
<string name="express_exchange_notification_failed_text">Visit providers website to refund your money</string>

View file

@ -7,5 +7,7 @@ interface ReduxStateHolder {
fun dispatch(action: Action)
suspend fun dispatchWithMain(action: Action)
suspend fun onUserWalletSelected(userWallet: UserWallet)
}

View file

@ -2,7 +2,6 @@ package com.tangem.feature.swap.domain
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
@ -15,8 +14,6 @@ interface SwapInteractor {
suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress
fun initDerivationPathAndNetwork(derivationPath: String?, network: Network)
/**
* Gives permission to swap, this starts scan card process
*
@ -30,7 +27,6 @@ interface SwapInteractor {
* Find best quote for given tokens to swap
* under the hood calls different methods to receive data, depends on permission for given token
*
* @param networkId network for tokens
* @param fromToken [Currency] from which want to swap
* @param toToken [Currency] that receive after swap
* @param amountToSwap amount you want to swap
@ -39,7 +35,6 @@ interface SwapInteractor {
*/
@Throws(IllegalStateException::class)
suspend fun findBestQuote(
networkId: String,
fromToken: CryptoCurrencyStatus,
toToken: CryptoCurrencyStatus,
providers: List<SwapProvider>,
@ -62,7 +57,6 @@ interface SwapInteractor {
@Throws(IllegalStateException::class)
suspend fun onSwap(
swapProvider: SwapProvider,
networkId: String,
swapData: SwapDataModel?,
currencyToSend: CryptoCurrencyStatus,
currencyToGet: CryptoCurrencyStatus,
@ -76,7 +70,6 @@ interface SwapInteractor {
selectedFee: FeeType,
fromToken: CryptoCurrencyStatus,
amountToSwap: String,
networkId: String,
): SwapState.QuotesLoadedState
/**

View file

@ -62,7 +62,6 @@ internal class SwapInteractorImpl @Inject constructor(
private val swapCurrencyConverter = SwapCurrencyConverter()
private val amountFormatter = AmountFormatter()
private var derivationPath: String? = null
private var network: Network? = null
override suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress {
@ -80,7 +79,7 @@ internal class SwapInteractorImpl @Inject constructor(
.filter {
val currencyFilter = it.currency.network.backendId != currency.network.backendId ||
it.currency.getContractAddress() != currency.getContractAddress()
val statusFilter = it.value is CryptoCurrencyStatus.Loaded
val statusFilter = it.value is CryptoCurrencyStatus.Loaded || it.value is CryptoCurrencyStatus.NoAccount
val notCustomTokenFilter = !it.currency.isCustom
statusFilter && currencyFilter && notCustomTokenFilter
}
@ -182,14 +181,9 @@ internal class SwapInteractorImpl @Inject constructor(
return repository.getPairs(initialCurrency, currenciesList)
}
@Deprecated("used in old swap mechanism")
override fun initDerivationPathAndNetwork(derivationPath: String?, network: Network) {
this.derivationPath = derivationPath
this.network = network
}
@Deprecated("used in old swap mechanism")
override suspend fun givePermissionToSwap(networkId: String, permissionOptions: PermissionOptions): TxState {
val derivationPath = permissionOptions.fromToken.network.derivationPath.value
val dataToSign = if (permissionOptions.approveType == SwapApproveType.UNLIMITED) {
getApproveData(
networkId = networkId,
@ -233,7 +227,6 @@ internal class SwapInteractorImpl @Inject constructor(
@Deprecated("used in old swap mechanism")
override suspend fun findBestQuote(
networkId: String,
fromToken: CryptoCurrencyStatus,
toToken: CryptoCurrencyStatus,
providers: List<SwapProvider>,
@ -249,7 +242,7 @@ internal class SwapInteractorImpl @Inject constructor(
}
val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken.currency))
val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null)
val networkId = fromToken.currency.network.backendId
when (provider.type) {
ExchangeProviderType.DEX -> {
manageDex(
@ -310,7 +303,10 @@ internal class SwapInteractorImpl @Inject constructor(
if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) {
allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress)
transactionManager.updateWalletManager(networkId, derivationPath)
transactionManager.updateWalletManager(
networkId,
fromToken.currency.network.derivationPath.value,
)
}
return if (isAllowedToSpend && isBalanceWithoutFeeEnough) {
provider to loadDexSwapData(
@ -361,7 +357,6 @@ internal class SwapInteractorImpl @Inject constructor(
override suspend fun onSwap(
swapProvider: SwapProvider,
networkId: String,
swapData: SwapDataModel?,
currencyToSend: CryptoCurrencyStatus,
currencyToGet: CryptoCurrencyStatus,
@ -389,7 +384,7 @@ internal class SwapInteractorImpl @Inject constructor(
}
ExchangeProviderType.DEX -> {
onSwapDex(
networkId = networkId,
networkId = currencyToSend.currency.network.backendId,
swapData = requireNotNull(swapData),
currencyToSend = currencyToSend.currency,
currencyToGet = currencyToGet.currency,
@ -405,7 +400,6 @@ internal class SwapInteractorImpl @Inject constructor(
selectedFee: FeeType,
fromToken: CryptoCurrencyStatus,
amountToSwap: String,
networkId: String,
): SwapState.QuotesLoadedState {
val amountDecimal = toBigDecimalOrNull(amountToSwap)
if (amountDecimal == null || amountDecimal.signum() == 0) {
@ -413,7 +407,7 @@ internal class SwapInteractorImpl @Inject constructor(
}
val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken.currency))
val includeFeeInAmount = getIncludeFeeInAmount(
networkId = networkId,
networkId = fromToken.currency.network.backendId,
txFee = state.txFee,
amount = amount,
fromToken = fromToken.currency,
@ -422,6 +416,7 @@ internal class SwapInteractorImpl @Inject constructor(
return state.copy(
permissionState = PermissionDataState.Empty,
preparedSwapConfigState = state.preparedSwapConfigState.copy(
isFeeEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough,
isBalanceEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough,
includeFeeInAmount = includeFeeInAmount,
),
@ -438,6 +433,7 @@ internal class SwapInteractorImpl @Inject constructor(
): TxState {
val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" }
val amount = SwapAmount(amountDecimal, getTokenDecimals(currencyToSend))
val derivationPath = currencyToSend.network.derivationPath.value
val result = transactionManager.sendTransaction(
txData = SwapTxData(
networkId = networkId,
@ -481,6 +477,7 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
@Suppress("LongMethod")
private suspend fun onSwapCex(
currencyToSend: CryptoCurrencyStatus,
currencyToGet: CryptoCurrencyStatus,
@ -519,6 +516,7 @@ internal class SwapInteractorImpl @Inject constructor(
val externalUrl = (exchangeData.transaction as? ExpressTransactionModel.CEX)?.externalTxUrl
val derivationPath = currencyToSend.currency.network.derivationPath.value
return result.fold(
ifLeft = {
when (it) {
@ -659,7 +657,7 @@ internal class SwapInteractorImpl @Inject constructor(
val allowance = repository.getAllowance(
userWalletId = userWallet.walletId,
networkId = networkId,
derivationPath = derivationPath,
derivationPath = fromToken.network.derivationPath.value,
tokenDecimalCount = getTokenDecimals(fromToken),
tokenAddress = getTokenAddress(fromToken),
spenderAddress = spenderAddress,
@ -832,7 +830,10 @@ internal class SwapInteractorImpl @Inject constructor(
}
val tokenForFeeBalance =
userWalletManager.getNativeTokenBalance(networkId, derivationPath) ?: ProxyAmount.empty()
userWalletManager.getNativeTokenBalance(
networkId,
fromToken.network.derivationPath.value,
) ?: ProxyAmount.empty()
if (amount.value > tokenForFeeBalance.value) {
return IncludeFeeInAmount.BalanceNotEnough
@ -907,7 +908,7 @@ internal class SwapInteractorImpl @Inject constructor(
destinationAddress = swapData.transaction.txTo,
increaseBy = INCREASE_GAS_LIMIT_BY,
data = (swapData.transaction as ExpressTransactionModel.DEX).txData,
derivationPath = derivationPath,
derivationPath = fromToken.currency.network.derivationPath.value,
)
val txFeeState = when (feeData) {
is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(networkId)
@ -1048,6 +1049,7 @@ internal class SwapInteractorImpl @Inject constructor(
permissionState = PermissionDataState.PermissionLoading,
)
}
val derivationPath = fromToken.network.derivationPath.value
// setting up amount for approve with given amount for swap [SwapApproveType.Limited]
val transactionData = getApproveData(
networkId = networkId,
@ -1091,7 +1093,7 @@ internal class SwapInteractorImpl @Inject constructor(
permissionState = PermissionDataState.PermissionReadyForRequest(
currency = fromToken.symbol,
amount = INFINITY_SYMBOL,
walletAddress = getWalletAddress(networkId),
walletAddress = getWalletAddress(networkId, derivationPath),
spenderAddress = getTokenAddress(fromToken),
requestApproveData = RequestApproveStateData(
fee = feeState,
@ -1262,7 +1264,7 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
private suspend fun getWalletAddress(networkId: String): String {
private suspend fun getWalletAddress(networkId: String, derivationPath: String?): String {
return userWalletManager.getWalletAddress(networkId, derivationPath)
}
@ -1286,7 +1288,10 @@ internal class SwapInteractorImpl @Inject constructor(
if (fee == null) {
return false
}
val nativeTokenBalance = userWalletManager.getNativeTokenBalance(networkId, derivationPath)
val nativeTokenBalance = userWalletManager.getNativeTokenBalance(
networkId,
fromToken.network.derivationPath.value,
)
val percentsToFeeIncrease = BigDecimal.ONE
return when (fromToken) {
is CryptoCurrency.Coin -> {

View file

@ -4,11 +4,13 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -55,7 +57,12 @@ private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetC
)
Column(
modifier = Modifier
.padding(TangemTheme.dimens.spacing16)
.padding(
top = TangemTheme.dimens.spacing16,
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing14,
)
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
@ -76,6 +83,23 @@ private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetC
)
}
}
Icon(
painterResource(id = R.drawable.ic_lightning_16),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.align(Alignment.CenterHorizontally),
)
Text(
text = stringResource(R.string.express_more_providers_soon),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.icon.informative,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing6, bottom = TangemTheme.dimens.spacing16)
.padding(horizontal = TangemTheme.dimens.spacing56)
.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center,
)
}
}

View file

@ -74,16 +74,15 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi
if (state.warnings.isNotEmpty()) SwapWarnings(warnings = state.warnings)
MainButton(state = state, onPermissionWarningClick = state.onShowPermissionBottomSheet)
}
}
state.tosState?.let {
ProviderTos(
tosState = it,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = TangemTheme.dimens.spacing16),
)
state.tosState?.let {
ProviderTos(
tosState = it,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16),
)
}
}
}
AnimatedVisibility(

View file

@ -11,7 +11,6 @@ import com.tangem.feature.swap.models.ApproveType
data class SwapProcessDataState(
// Initial network id
val networkId: String,
val fromCryptoCurrency: CryptoCurrencyStatus? = null,
val toCryptoCurrency: CryptoCurrencyStatus? = null,
// Amount from input

View file

@ -79,7 +79,7 @@ internal class SwapViewModel @Inject constructor(
private val amountDebouncer = Debouncer()
private val singleTaskScheduler = SingleTaskScheduler<Map<SwapProvider, SwapState>>()
private var dataState by mutableStateOf(SwapProcessDataState(networkId = initialCryptoCurrency.network.backendId))
private var dataState by mutableStateOf(SwapProcessDataState())
var uiState: SwapStateHolder by mutableStateOf(
stateBuilder.createInitialLoadingState(
@ -103,11 +103,6 @@ internal class SwapViewModel @Inject constructor(
requireNotNull(getCryptoCurrencyStatusUseCase(it.walletId, initialCryptoCurrency.id).getOrNull()) {
"Failed to get initial crypto currency status"
}
swapInteractor.initDerivationPathAndNetwork(
derivationPath = initialCryptoCurrency.network.derivationPath.value,
network = initialCryptoCurrency.network,
)
initTokens()
}
}
@ -278,7 +273,6 @@ internal class SwapViewModel @Inject constructor(
approveDataModel = null,
)
swapInteractor.findBestQuote(
networkId = dataState.networkId,
fromToken = fromToken,
toToken = toToken,
providers = toProvidersList,
@ -438,13 +432,13 @@ internal class SwapViewModel @Inject constructor(
Timber.e("Last loaded quotes state is null")
return
}
val fromCurrency = requireNotNull(dataState.fromCryptoCurrency)
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
swapInteractor.onSwap(
swapProvider = provider,
networkId = dataState.networkId,
swapData = dataState.swapDataModel,
currencyToSend = requireNotNull(dataState.fromCryptoCurrency),
currencyToSend = fromCurrency,
currencyToGet = requireNotNull(dataState.toCryptoCurrency),
amountToSwap = requireNotNull(dataState.amount),
includeFeeInAmount = lastLoadedQuotesState.preparedSwapConfigState.includeFeeInAmount,
@ -454,7 +448,7 @@ internal class SwapViewModel @Inject constructor(
when (it) {
is TxState.TxSent -> {
val url = blockchainInteractor.getExplorerTransactionLink(
networkId = dataState.networkId,
networkId = fromCurrency.currency.network.backendId,
txAddress = it.txAddress,
)
uiState = stateBuilder.createSuccessState(
@ -528,17 +522,18 @@ internal class SwapViewModel @Inject constructor(
is TxFeeState.SingleFeeState -> fee.fee
null -> error("Fee should not be null")
}
val fromToken = requireNotNull(dataState.fromCryptoCurrency?.currency) {
"dataState.fromCurrency might not be null"
}
swapInteractor.givePermissionToSwap(
networkId = dataState.networkId,
networkId = fromToken.network.backendId,
permissionOptions = PermissionOptions(
approveData = requireNotNull(dataState.approveDataModel) {
"dataState.approveDataModel might not be null"
},
forTokenContractAddress = (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Token)
?.contractAddress ?: "",
fromToken = requireNotNull(dataState.fromCryptoCurrency?.currency) {
"dataState.fromCurrency might not be null"
},
fromToken = fromToken,
approveType = requireNotNull(dataState.approveType) {
"uiState.permissionState should not be null"
}.toDomainApproveType(),
@ -805,7 +800,6 @@ internal class SwapViewModel @Inject constructor(
selectedFee = it.feeType,
fromToken = fromToken,
amountToSwap = amountToSwap,
networkId = dataState.networkId,
)
setupLoadedState(selectedProvider, updatedState, fromToken)
}
@ -839,7 +833,11 @@ internal class SwapViewModel @Inject constructor(
},
onBuyClick = {
swapInteractor.getSelectedWallet()?.let {
swapRouter.openTokenDetails(it.walletId, swapInteractor.getNativeToken(dataState.networkId))
val fromToken = dataState.fromCryptoCurrency ?: return@let
swapRouter.openTokenDetails(
it.walletId,
swapInteractor.getNativeToken(fromToken.currency.network.backendId),
)
}
},
onRetryClick = {

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="17dp"
android:height="16dp"
android:viewportWidth="17"
android:viewportHeight="16">
<path
android:pathData="M5.904,6.28L14.027,1.372L9.871,8.57L5.904,6.28ZM12.489,10.082L4.366,14.99L8.522,7.791L12.489,10.082Z"
android:fillColor="#C9C9C9"
android:fillType="evenOdd"/>
</vector>

View file

@ -333,7 +333,7 @@ internal class TokenDetailsViewModel @Inject constructor(
val cryptoCurrencyStatus = cryptoCurrencyStatus ?: return
viewModelScope.launch(dispatchers.io) {
viewModelScope.launch(dispatchers.main) {
when (val currency = cryptoCurrencyStatus.currency) {
is CryptoCurrency.Coin -> {
reduxStateHolder.dispatch(
@ -363,7 +363,7 @@ internal class TokenDetailsViewModel @Inject constructor(
.distinctUntilChanged()
.firstOrNull()
reduxStateHolder.dispatch(
reduxStateHolder.dispatchWithMain(
action = TradeCryptoAction.New.SendToken(
userWallet = wallet,
tokenCurrency = tokenCurrency,

View file

@ -675,7 +675,7 @@ internal class WalletViewModel @Inject constructor(
.collectLatest {
it.onRight { coinStatus ->
uiState = stateFactory.getStateWithClosedBottomSheet()
reduxStateHolder.dispatch(
reduxStateHolder.dispatchWithMain(
action = TradeCryptoAction.New.SendToken(
userWallet = userWallet,
tokenCurrency = requireNotNull(cryptoCurrencyStatus.currency as? CryptoCurrency.Token),
@ -1163,8 +1163,8 @@ internal class WalletViewModel @Inject constructor(
)
}
private fun setupWalletConnectOnWallet(userWallet: UserWallet) {
reduxStateHolder.dispatch(
private suspend fun setupWalletConnectOnWallet(userWallet: UserWallet) {
reduxStateHolder.dispatchWithMain(
action = WalletConnectActions.New.SetupUserChains(userWallet = userWallet),
)
}