Updated on 2026-08-14

This commit is contained in:
Tangem 2023-11-22 13:17:51 +03:00
commit 64ed64820b
10 changed files with 198 additions and 136 deletions

View file

@ -1,53 +0,0 @@
package com.tangem.datasource.api.express
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.express.models.request.AssetsRequestBody
import com.tangem.datasource.api.express.models.request.PairsRequestBody
import com.tangem.datasource.api.express.models.response.*
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import java.math.BigDecimal
/**
* Interface of Tangem Express API (new swap mechanism)
*/
@Suppress("LongParameterList")
interface ExpressApi {
@POST("assets")
suspend fun getAssets(@Body body: AssetsRequestBody): ApiResponse<List<Asset>>
@POST("pairs")
suspend fun getPairs(@Body body: PairsRequestBody): ApiResponse<List<SwapPair>>
@GET("providers")
suspend fun getProviders(): ApiResponse<List<ExchangeProvider>>
@GET("exchange-quote")
suspend fun getExchangeQuote(
@Query("fromContractAddress") fromContractAddress: String,
@Query("fromNetwork") fromNetwork: String,
@Query("toContractAddress") toContractAddress: String,
@Query("toNetwork") toNetwork: String,
@Query("fromAmount") fromAmount: BigDecimal,
@Query("providerId") providerId: Int,
@Query("rateType") rateType: RateType,
): ApiResponse<ExchangeQuoteResponse>
@GET("exchange-data")
suspend fun getExchangeData(
@Query("fromContractAddress") fromContractAddress: String,
@Query("fromNetwork") fromNetwork: String,
@Query("toContractAddress") toContractAddress: String,
@Query("toNetwork") toNetwork: String,
@Query("fromAmount") fromAmount: BigDecimal,
@Query("providerId") providerId: Int,
@Query("rateType") rateType: RateType,
@Query("toAddress") toAddress: String,
): ApiResponse<ExchangeDataResponse>
@GET("exchange-result")
suspend fun getExchangeResults(@Query("txId") txId: String): ApiResponse<ExchangeResultsResponse>
}

View file

@ -31,9 +31,9 @@ interface TangemExpressApi {
@Query("fromNetwork") fromNetwork: String,
@Query("toContractAddress") toContractAddress: String,
@Query("toNetwork") toNetwork: String,
@Query("fromAmount") fromAmount: BigDecimal,
@Query("fromAmount") fromAmount: String,
@Query("providerId") providerId: Int,
@Query("rateType") rateType: RateType,
@Query("rateType") rateType: String,
): ApiResponse<ExchangeQuoteResponse>
@GET("exchange-data")

View file

@ -4,9 +4,23 @@ import com.squareup.moshi.Json
import java.math.BigDecimal
data class ExchangeQuoteResponse(
@Json(name = "fromAmount")
val fromAmount: String,
@Json(name = "fromDecimals")
val fromDecimals: Int,
@Json(name = "toAmount")
val toAmount: BigDecimal,
val toAmount: String,
@Json(name = "toDecimals")
val toDecimals: Int,
@Json(name = "allowanceContract")
val allowanceContract: String?,
@Json(name = "minAmount")
val minAmount: BigDecimal,
)

View file

@ -20,6 +20,7 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.swap.converters.*
import com.tangem.feature.swap.domain.SwapRepository
import com.tangem.feature.swap.domain.models.createFromAmountWithOffset
import com.tangem.feature.swap.domain.models.data.AggregatedSwapDataModel
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.mapErrors
@ -127,21 +128,30 @@ internal class SwapRepositoryImpl @Inject constructor(
}
override suspend fun findBestQuote(
networkId: String,
fromTokenAddress: String,
toTokenAddress: String,
amount: String,
fromContractAddress: String,
fromNetwork: String,
toContractAddress: String,
toNetwork: String,
fromAmount: String,
providerId: Int,
rateType: RateType,
): AggregatedSwapDataModel<QuoteModel> {
return withContext(coroutineDispatcher.io) {
try {
val response = oneInchErrorsHandler.handleOneInchResponse(
getOneInchApi(networkId).quote(
fromTokenAddress = fromTokenAddress,
toTokenAddress = toTokenAddress,
amount = amount,
val response = tangemExpressApi.getExchangeQuote(
fromContractAddress = fromContractAddress,
fromNetwork = fromNetwork,
toContractAddress = toContractAddress,
toNetwork = toNetwork,
fromAmount = fromAmount,
providerId = providerId,
rateType = rateType.name.lowercase(),
).getOrThrow()
AggregatedSwapDataModel(
dataModel = QuoteModel(
toTokenAmount = createFromAmountWithOffset(response.toAmount, response.toDecimals),
),
)
AggregatedSwapDataModel(dataModel = quotesConverter.convert(response))
} catch (ex: OneIncResponseException) {
AggregatedSwapDataModel(null, mapErrors(ex.data.description))
}
@ -263,7 +273,7 @@ internal class SwapRepositoryImpl @Inject constructor(
fromNetwork: String,
toContractAddress: String,
toNetwork: String,
fromAmount: BigDecimal,
fromAmount: String,
providerId: Int,
rateType: RateType,
): ExchangeQuote {
@ -274,7 +284,7 @@ internal class SwapRepositoryImpl @Inject constructor(
toNetwork,
fromAmount,
providerId,
rateTypeConverter.convertBack(rateType),
rateType.name.lowercase(),
).getOrThrow()
return ExchangeQuote(

View file

@ -67,9 +67,10 @@ interface SwapInteractor {
networkId: String,
fromToken: CryptoCurrency,
toToken: CryptoCurrency,
providers: List<SwapProvider>,
amountToSwap: String,
selectedFee: FeeType = FeeType.NORMAL,
): SwapState
): Map<SwapProvider, SwapState>
/**
* Starts swap transaction, perform sign transaction

View file

@ -13,6 +13,7 @@ import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.feature.swap.domain.cache.SwapDataCache
import com.tangem.feature.swap.domain.converters.SwapCurrencyConverter
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.data.AggregatedSwapDataModel
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.domain.Currency
import com.tangem.feature.swap.domain.models.toStringWithRightOffset
@ -27,6 +28,9 @@ import timber.log.Timber
import java.math.BigDecimal
import java.math.RoundingMode
import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@Suppress("LargeClass", "LongParameterList")
internal class SwapInteractorImpl @Inject constructor(
@ -274,13 +278,14 @@ internal class SwapInteractorImpl @Inject constructor(
networkId: String,
fromToken: CryptoCurrency,
toToken: CryptoCurrency,
providers: List<SwapProvider>,
amountToSwap: String,
selectedFee: FeeType,
): SwapState {
): Map<SwapProvider, SwapState> {
syncWalletBalanceForTokens(networkId, listOf(fromToken, toToken))
val amountDecimal = toBigDecimalOrNull(amountToSwap)
if (amountDecimal == null || amountDecimal.signum() == 0) {
return createEmptyAmountState(networkId, fromToken, toToken)
return providers.associateWith { createEmptyAmountState(networkId, fromToken, toToken) }
}
val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken))
val fromTokenAddress = getTokenAddress(fromToken)
@ -292,25 +297,27 @@ internal class SwapInteractorImpl @Inject constructor(
}
val isBalanceWithoutFeeEnough = isBalanceEnough(networkId, fromToken, amount, null)
return if (isAllowedToSpend && isBalanceWithoutFeeEnough) {
loadSwapData(
networkId = networkId,
fromTokenAddress = fromTokenAddress,
toTokenAddress = toTokenAddress,
fromToken = fromToken,
toToken = toToken,
amount = amount,
selectedFee = selectedFee,
)
// TODO
providers.associateWith {
loadSwapData(
networkId = networkId,
fromTokenAddress = fromTokenAddress,
toTokenAddress = toTokenAddress,
fromToken = fromToken,
toToken = toToken,
amount = amount,
selectedFee = selectedFee,
)
}
} else {
loadQuoteData(
networkId = networkId,
fromTokenAddress = fromTokenAddress,
toTokenAddress = toTokenAddress,
amount = amount,
fromToken = fromToken,
toToken = toToken,
isAllowedToSpend = isAllowedToSpend,
isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough,
providers = providers,
)
}
}
@ -513,45 +520,76 @@ internal class SwapInteractorImpl @Inject constructor(
@Suppress("LongParameterList")
private suspend fun loadQuoteData(
networkId: String,
fromTokenAddress: String,
toTokenAddress: String,
amount: SwapAmount,
fromToken: CryptoCurrency,
toToken: CryptoCurrency,
providers: List<SwapProvider>,
isAllowedToSpend: Boolean,
isBalanceWithoutFeeEnough: Boolean,
): Map<SwapProvider, SwapState> {
return coroutineScope {
val quoteRequests = providers.map { provider ->
async {
provider to repository.findBestQuote(
fromContractAddress = fromToken.getContractAddress(),
fromNetwork = fromToken.network.backendId,
toContractAddress = toToken.getContractAddress(),
toNetwork = toToken.network.backendId,
fromAmount = amount.toStringWithRightOffset(),
providerId = provider.providerId,
rateType = RateType.FLOAT,
)
}
}
quoteRequests.awaitAll().map {
it.first to
getState(
quoteDataModel = it.second,
amount = amount,
fromToken = fromToken,
toToken = toToken,
networkId = networkId,
isAllowedToSpend = isAllowedToSpend,
isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough
)
}.associate { it.first to it.second }
}
}
private suspend fun getState(
quoteDataModel: AggregatedSwapDataModel<QuoteModel>,
amount: SwapAmount,
fromToken: CryptoCurrency,
toToken: CryptoCurrency,
networkId: String,
isAllowedToSpend: Boolean,
isBalanceWithoutFeeEnough: Boolean,
): SwapState {
repository.findBestQuote(
networkId = networkId,
fromTokenAddress = fromTokenAddress,
toTokenAddress = toTokenAddress,
amount = amount.toStringWithRightOffset(),
).let { quotes ->
val quoteDataModel = quotes.dataModel
if (quoteDataModel != null) {
val swapState = updateBalances(
networkId = networkId,
fromToken = fromToken,
toToken = toToken,
fromTokenAmount = amount,
toTokenAmount = quoteDataModel.toTokenAmount,
swapStateData = null,
)
val quotesState = updatePermissionState(
networkId = networkId,
fromToken = fromToken,
swapAmount = amount,
quotesLoadedState = swapState,
)
return quotesState.copy(
preparedSwapConfigState = quotesState.preparedSwapConfigState.copy(
isAllowedToSpend = isAllowedToSpend,
isBalanceEnough = isBalanceWithoutFeeEnough,
),
)
} else {
return SwapState.SwapError(quotes.error)
}
val quoteModel = quoteDataModel.dataModel
if (quoteModel != null) {
val swapState = updateBalances(
networkId = networkId,
fromToken = fromToken,
toToken = toToken,
fromTokenAmount = amount,
toTokenAmount = quoteModel.toTokenAmount,
swapStateData = null,
)
val quotesState = updatePermissionState(
networkId = networkId,
fromToken = fromToken,
swapAmount = amount,
quotesLoadedState = swapState,
)
return quotesState.copy(
preparedSwapConfigState = quotesState.preparedSwapConfigState.copy(
isAllowedToSpend = isAllowedToSpend,
isBalanceEnough = isBalanceWithoutFeeEnough,
),
)
} else {
return SwapState.SwapError(quoteDataModel.error)
}
}
@ -647,7 +685,10 @@ internal class SwapInteractorImpl @Inject constructor(
): SwapState.QuotesLoadedState {
val appCurrency = userWalletManager.getUserAppCurrency()
val nativeToken = userWalletManager.getNativeTokenForNetwork(networkId)
val rates = repository.getRates(appCurrency.code, listOf(fromToken.id.value, toToken.id.value, nativeToken.id))
val rates = repository.getRates(
appCurrency.code,
listOf(fromToken.network.backendId, toToken.network.backendId, nativeToken.id),
)
val fromTokenBalance = cache.getBalanceForToken(networkId, derivationPath, fromToken.symbol)
val toTokenBalance = cache.getBalanceForToken(networkId, derivationPath, toToken.symbol)
return SwapState.QuotesLoadedState(
@ -657,7 +698,7 @@ internal class SwapInteractorImpl @Inject constructor(
tokenWalletBalance = fromTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") }
?: ZERO_BALANCE,
tokenFiatBalance = fromTokenAmount.value.toFiatString(
rateValue = rates[fromToken.id.value]?.toBigDecimal() ?: BigDecimal.ZERO,
rateValue = rates[fromToken.network.backendId]?.toBigDecimal() ?: BigDecimal.ZERO,
fiatCurrencyName = appCurrency.symbol,
formatWithSpaces = true,
),
@ -668,16 +709,16 @@ internal class SwapInteractorImpl @Inject constructor(
tokenWalletBalance = toTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") }
?: ZERO_BALANCE,
tokenFiatBalance = toTokenAmount.value.toFiatString(
rateValue = rates[toToken.id.value]?.toBigDecimal() ?: BigDecimal.ZERO,
rateValue = rates[toToken.network.backendId]?.toBigDecimal() ?: BigDecimal.ZERO,
fiatCurrencyName = appCurrency.symbol,
formatWithSpaces = true,
),
),
priceImpact = calculatePriceImpact(
fromTokenAmount = fromTokenAmount.value,
fromRate = rates[fromToken.id.value] ?: 0.0,
fromRate = rates[fromToken.network.backendId] ?: 0.0,
toTokenAmount = toTokenAmount.value,
toRate = rates[toToken.id.value] ?: 0.0,
toRate = rates[toToken.network.backendId] ?: 0.0,
),
networkCurrency = userWalletManager.getNetworkCurrency(networkId),
swapDataModel = swapStateData,
@ -819,7 +860,7 @@ internal class SwapInteractorImpl @Inject constructor(
private fun getTokenAddress(currency: CryptoCurrency): String {
return when (currency) {
is CryptoCurrency.Coin -> {
DEFAULT_BLOCKCHAIN_INCH_ADDRESS
"0"
}
is CryptoCurrency.Token -> {
currency.contractAddress
@ -893,7 +934,6 @@ internal class SwapInteractorImpl @Inject constructor(
companion object {
private const val DEFAULT_SLIPPAGE = 2
private const val ZERO_BALANCE = "0"
private const val DEFAULT_BLOCKCHAIN_INCH_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"
@Suppress("UnusedPrivateMember")
private const val INCREASE_FEE_TO_CHECK_ENOUGH_PERCENT = 1.0 // if need to increase fee when check isEnough

View file

@ -14,11 +14,15 @@ interface SwapRepository {
suspend fun getExchangeableTokens(networkId: String): List<Currency>
@Suppress("LongParameterList")
suspend fun findBestQuote(
networkId: String,
fromTokenAddress: String,
toTokenAddress: String,
amount: String,
fromContractAddress: String,
fromNetwork: String,
toContractAddress: String,
toNetwork: String,
fromAmount: String,
providerId: Int,
rateType: RateType,
): AggregatedSwapDataModel<QuoteModel>
/**
@ -68,7 +72,7 @@ interface SwapRepository {
fromNetwork: String,
toContractAddress: String,
toNetwork: String,
fromAmount: BigDecimal,
fromAmount: String,
providerId: Int,
rateType: RateType,
): ExchangeQuote

View file

@ -1,6 +1,7 @@
package com.tangem.feature.swap.domain.di
import com.tangem.domain.tokens.GetCardTokensListUseCase
import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.NetworksRepository

View file

@ -1,8 +1,6 @@
package com.tangem.feature.swap.domain.models.domain
import java.math.BigDecimal
data class ExchangeQuote(
val toAmount: BigDecimal,
val toAmount: String,
val allowanceContract: String?,
)

View file

@ -18,6 +18,8 @@ import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.domain.BlockchainInteractor
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.models.domain.PermissionOptions
import com.tangem.feature.swap.domain.models.domain.RateType
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.formatToUIRepresentation
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.SwapPermissionState
@ -175,6 +177,7 @@ internal class SwapViewModel @Inject constructor(
fromToken = fromCurrencyStatus.currency,
toToken = selectedCurrency.currency,
amount = lastAmount.value,
toProvidersList = findSwapProviders(fromCurrencyStatus, selectedCurrency)
)
}
}
@ -192,7 +195,12 @@ internal class SwapViewModel @Inject constructor(
)
}
private fun startLoadingQuotes(fromToken: CryptoCurrency, toToken: CryptoCurrency, amount: String) {
private fun startLoadingQuotes(
fromToken: CryptoCurrency,
toToken: CryptoCurrency,
amount: String,
toProvidersList: List<SwapProvider>,
) {
singleTaskScheduler.cancelTask()
uiState = stateBuilder.createQuotesLoadingState(uiState, fromToken, toToken, initialCryptoCurrency.id.value)
singleTaskScheduler.scheduleTask(
@ -201,6 +209,7 @@ internal class SwapViewModel @Inject constructor(
fromToken = fromToken,
toToken = toToken,
amount = amount,
toProvidersList = toProvidersList,
),
)
}
@ -210,7 +219,12 @@ internal class SwapViewModel @Inject constructor(
val toCurrency = dataState.toCryptoCurrency
val amount = dataState.amount
if (fromCurrency != null && toCurrency != null && amount != null) {
startLoadingQuotes(fromCurrency.currency, toCurrency.currency, amount)
startLoadingQuotes(
fromToken = fromCurrency.currency,
toToken = toCurrency.currency,
amount = amount,
toProvidersList = findSwapProviders(fromCurrency, toCurrency)
)
}
}
@ -218,6 +232,7 @@ internal class SwapViewModel @Inject constructor(
fromToken: CryptoCurrency,
toToken: CryptoCurrency,
amount: String,
toProvidersList: List<SwapProvider>,
): PeriodicTask<SwapState> {
return PeriodicTask(
UPDATE_DELAY,
@ -233,9 +248,10 @@ internal class SwapViewModel @Inject constructor(
networkId = dataState.networkId,
fromToken = fromToken,
toToken = toToken,
providers = toProvidersList,
amountToSwap = amount,
selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
)
).entries.first().value// TODO
}
},
onSuccess = { swapState ->
@ -418,7 +434,12 @@ internal class SwapViewModel @Inject constructor(
fromCryptoCurrency = fromToken,
toCryptoCurrency = toToken,
)
startLoadingQuotes(fromToken.currency, toToken.currency, lastAmount.value)
startLoadingQuotes(
fromToken = fromToken.currency,
toToken = toToken.currency,
amount = lastAmount.value,
toProvidersList = findSwapProviders(fromToken, toToken)
)
swapRouter.openScreen(SwapNavScreen.Main)
}
}
@ -438,7 +459,12 @@ internal class SwapViewModel @Inject constructor(
uiState,
inputNumberFormatter.formatWithThousands(lastAmount.value, decimals),
)
startLoadingQuotes(newFromToken.currency, newToToken.currency, lastAmount.value)
startLoadingQuotes(
fromToken = newFromToken.currency,
toToken = newToToken.currency,
amount = lastAmount.value,
toProvidersList = findSwapProviders(newFromToken, newToToken)
)
}
}
@ -452,7 +478,12 @@ internal class SwapViewModel @Inject constructor(
uiState =
stateBuilder.updateSwapAmount(uiState, inputNumberFormatter.formatWithThousands(cutValue, decimals))
amountDebouncer.debounce(viewModelScope, DEBOUNCE_AMOUNT_DELAY) {
startLoadingQuotes(fromToken.currency, toToken.currency, lastAmount.value)
startLoadingQuotes(
fromToken = fromToken.currency,
toToken = toToken.currency,
amount = lastAmount.value,
toProvidersList = findSwapProviders(fromToken, toToken)
)
}
}
}
@ -566,6 +597,22 @@ internal class SwapViewModel @Inject constructor(
)
}
private fun findSwapProviders(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): List<SwapProvider> {
val groupToFind = if (isOrderReversed) {
dataState.tokensDataState?.fromGroup
} else {
dataState.tokensDataState?.toGroup
} ?: return emptyList()
val idToFind = if (isOrderReversed) {
fromToken.currency.id.value
} else {
toToken.currency.id.value
}
return groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value }?.providers ?: emptyList()
}
companion object {
private const val loggingTag = "SwapViewModel"
private const val INITIAL_AMOUNT = ""